返回 presentation-ai
circular-grid.tsx
1 "use client";
2
3 import {
4 PlateElement,
5 useReadOnly,
6 type PlateElementProps,
7 } from "platejs/react";
8 import { createContext, useContext, useMemo, type ReactNode } from "react";
9 import TextareaAutosize from "react-textarea-autosize";
10
11 import { useForceUpdateChildrenOnLengthChange } from "@/hooks/presentation/useForceUpdateChildrenOnLengthChange";
12 import { cn } from "@/lib/utils";
13 import { CIRCULAR_GRID_MAX_ITEMS } from "../lib";
14 import { type TCircularGridGroupElement } from "../plugins/diagram-components-plugin";
15 import { getAlignmentClasses } from "../utils";
16 import { getPresentationAccentColor } from "./color-utils";
17 import { getDiagramFitFrameStyle, useDiagramFitScale } from "./diagram-fit";
18 import { getSmartLayoutStepColor } from "./smart-layout-gradient";
19
20 const CIRCULAR_GRID_LAYOUT_WIDTH_PX = 640;
21 const CIRCULAR_GRID_LAYOUT_HEIGHT_PX = 520;
22 const CIRCLE_SIZE_PX = 128;
23 const ITEM_COLUMN_WIDTH_PX = 220;
24 const ITEM_COLUMN_GAP_PX = 160;
25 const ITEM_ROW_GAP_PX = 56;
26 export type CircularGridLayoutContextValue = {
27 alignment: "left" | "center" | "right";
28 parentElement: Pick<TCircularGridGroupElement, "alignment" | "children">;
29 total: number;
30 };
31
32 export const CircularGridLayoutContext =
33 createContext<CircularGridLayoutContextValue | null>(null);
34
35 export function CircularGridLayoutProvider({
36 children,
37 value,
38 }: {
39 children: ReactNode;
40 value: CircularGridLayoutContextValue;
41 }) {
42 return (
43 <CircularGridLayoutContext.Provider value={value}>
44 {children}
45 </CircularGridLayoutContext.Provider>
46 );
47 }
48
49 export default function CircularGrid(
50 props: PlateElementProps<TCircularGridGroupElement>,
51 ) {
52 const readOnly = useReadOnly();
53 const { alignment = "center", centerText = "Smart Diagram" } = props.element;
54 const total = Math.min(
55 props.element.children.length || 1,
56 CIRCULAR_GRID_MAX_ITEMS,
57 );
58 const accentColor = getPresentationAccentColor(
59 props.element,
60 undefined,
61 getSmartLayoutStepColor(Math.floor(total / 2), total),
62 );
63 const layoutContextValue = useMemo<CircularGridLayoutContextValue>(
64 () => ({
65 alignment,
66 parentElement: props.element,
67 total,
68 }),
69 [alignment, props.element, total],
70 );
71 const { containerRef, fitStyle, frameStyle, layoutRef } =
72 useDiagramFitScale<HTMLDivElement>(
73 CIRCULAR_GRID_LAYOUT_WIDTH_PX,
74 CIRCULAR_GRID_LAYOUT_HEIGHT_PX,
75 );
76
77 useForceUpdateChildrenOnLengthChange(props.editor, props.element);
78
79 return (
80 <PlateElement {...props} className="relative my-4">
81 <div
82 ref={containerRef}
83 className={cn(
84 "w-full overflow-visible",
85 getAlignmentClasses(alignment),
86 )}
87 >
88 <div
89 style={{
90 ...frameStyle,
91 ...getDiagramFitFrameStyle(alignment),
92 }}
93 >
94 <div
95 ref={layoutRef}
96 className="relative"
97 style={{
98 ...fitStyle,
99 minHeight: CIRCULAR_GRID_LAYOUT_HEIGHT_PX,
100 }}
101 >
102 <div
103 className="grid gap-4"
104 style={{
105 alignContent: "center",
106 columnGap: ITEM_COLUMN_GAP_PX,
107 gridTemplateColumns: `repeat(2, ${ITEM_COLUMN_WIDTH_PX}px)`,
108 gridTemplateRows: "repeat(3, minmax(96px, auto))",
109 height: CIRCULAR_GRID_LAYOUT_HEIGHT_PX,
110 justifyContent: "center",
111 rowGap: ITEM_ROW_GAP_PX,
112 }}
113 >
114 {/* Render children first — they are PlateElements and must be direct children */}
115 <CircularGridLayoutProvider value={layoutContextValue}>
116 {props.children}
117 </CircularGridLayoutProvider>
118
119 {/* Center circle */}
120 <div
121 className="pointer-events-none absolute inset-0 z-0 grid place-items-center"
122 style={{
123 width: "100%",
124 height: "100%",
125 }}
126 data-bg-export="true"
127 >
128 {/* Outer glow */}
129 <div
130 className="absolute rounded-full opacity-30"
131 style={{
132 width: CIRCLE_SIZE_PX + 16,
133 height: CIRCLE_SIZE_PX + 16,
134 background: accentColor,
135 }}
136 aria-hidden="true"
137 data-decor="true"
138 />
139 {/* Main circle */}
140 <div
141 className="pointer-events-auto grid place-items-center rounded-full px-4 text-center text-lg font-semibold text-(--presentation-card-background)"
142 style={{
143 width: CIRCLE_SIZE_PX,
144 height: CIRCLE_SIZE_PX,
145 background: accentColor,
146 }}
147 contentEditable={false}
148 data-decor="true"
149 data-slate-void="true"
150 >
151 {readOnly ? (
152 centerText
153 ) : (
154 <TextareaAutosize
155 value={centerText}
156 onChange={(e) => {
157 const path = props.editor.api.findPath(props.element);
158 if (path) {
159 props.editor.tf.setNodes(
160 { centerText: e.target.value },
161 { at: path },
162 );
163 }
164 }}
165 onFocus={() => {
166 props.editor.tf.blur();
167 }}
168 onBlur={(e) => {
169 const path = props.editor.api.findPath(props.element);
170 if (path) {
171 props.editor.tf.setNodes(
172 { centerText: e.target.value },
173 { at: path },
174 );
175 }
176 }}
177 onKeyDown={(e) => {
178 e.stopPropagation();
179 }}
180 className="w-full resize-none bg-transparent text-center font-semibold text-(--presentation-card-background) outline-none opacity-100"
181 style={{ opacity: 1 }}
182 />
183 )}
184 </div>
185 </div>
186 </div>
187 </div>
188 </div>
189 </div>
190 </PlateElement>
191 );
192 }
193
194 const ITEM_INNER_ROW_OFFSET_PX = 72;
195
196 type CircularGridChildElement = TCircularGridGroupElement["children"][number];
197
198 function getElementId(element: CircularGridChildElement): string | undefined {
199 const elementId = (element as { id?: unknown }).id;
200
201 return typeof elementId === "string" ? elementId : undefined;
202 }
203
204 export function useCircularGridLayoutContext() {
205 const context = useContext(CircularGridLayoutContext);
206 if (!context) {
207 throw new Error(
208 "useCircularGridLayoutContext must be used within a CircularGridLayoutProvider",
209 );
210 }
211 return context;
212 }
213
214 export function getCircularGridItemIndex(
215 parentElement: Pick<TCircularGridGroupElement, "children"> | undefined,
216 element: CircularGridChildElement,
217 fallbackIndex: number,
218 ): number {
219 const children = parentElement?.children;
220
221 if (!children?.length) return fallbackIndex;
222
223 const elementId = getElementId(element);
224
225 if (elementId) {
226 const idIndex = children.findIndex(
227 (child) => getElementId(child) === elementId,
228 );
229
230 if (idIndex >= 0) return idIndex;
231 }
232
233 const referenceIndex = children.indexOf(element);
234
235 return referenceIndex >= 0 ? referenceIndex : fallbackIndex;
236 }
237
238 export type CircularGridPointerDirection =
239 | "bottom"
240 | "bottom-right"
241 | "bottom-left"
242 | "right"
243 | "left"
244 | "top-right"
245 | "top-left";
246
247 export function getCircularGridItemPosition(
248 index: number,
249 total: number,
250 ): { gridRow: string; gridColumn: string } {
251 const visibleIndex = Math.max(
252 0,
253 Math.min(index, CIRCULAR_GRID_MAX_ITEMS - 1),
254 );
255 const visibleTotal = Math.max(1, Math.min(total, CIRCULAR_GRID_MAX_ITEMS));
256
257 if (visibleTotal % 2 === 1 && visibleIndex === visibleTotal - 1) {
258 return {
259 gridRow: "1",
260 gridColumn: "1 / 3",
261 };
262 }
263
264 if (visibleTotal === 4) {
265 return {
266 gridRow: visibleIndex < 2 ? "1" : "3",
267 gridColumn: `${(visibleIndex % 2) + 1}`,
268 };
269 }
270
271 const rowOffset = visibleTotal % 2 === 1 ? 2 : 1;
272
273 return {
274 gridRow: `${Math.floor(visibleIndex / 2) + rowOffset}`,
275 gridColumn: `${(visibleIndex % 2) + 1}`,
276 };
277 }
278
279 export function getCircularGridPointerDirection(
280 index: number,
281 total: number,
282 ): CircularGridPointerDirection {
283 const position = getCircularGridItemPosition(index, total);
284 const row = parseInt(position.gridRow, 10);
285 const isCentered = position.gridColumn.includes("/");
286 const visibleIndex = Math.max(
287 0,
288 Math.min(index, CIRCULAR_GRID_MAX_ITEMS - 1),
289 );
290 const isLeftColumn = visibleIndex % 2 === 0;
291
292 if (isCentered) return "bottom";
293 if (row === 1) return isLeftColumn ? "bottom-right" : "bottom-left";
294 if (row === 2) return isLeftColumn ? "right" : "left";
295 if (row === 3) return isLeftColumn ? "top-right" : "top-left";
296
297 return "bottom-right"; // fallback
298 }
299
300 export function getCircularGridItemSelfAlignment(
301 index: number,
302 total: number,
303 ): {
304 justifySelf: "start" | "center" | "end";
305 alignSelf: "start" | "center" | "end";
306 } {
307 const position = getCircularGridItemPosition(index, total);
308 const row = parseInt(position.gridRow, 10);
309 const isCentered = position.gridColumn.includes("/");
310 const visibleIndex = Math.max(
311 0,
312 Math.min(index, CIRCULAR_GRID_MAX_ITEMS - 1),
313 );
314 const isLeftColumn = visibleIndex % 2 === 0;
315
316 const justifySelf = isCentered ? "center" : isLeftColumn ? "end" : "start";
317 const alignSelf: "start" | "center" | "end" =
318 row === 1 ? "end" : row === 3 ? "start" : "center";
319
320 return { justifySelf, alignSelf };
321 }
322
323 export function getCircularGridItemTransform(
324 index: number,
325 total: number,
326 ): string | undefined {
327 const pos = getCircularGridItemPosition(index, total);
328 const row = parseInt(pos.gridRow, 10);
329 const col = parseInt(pos.gridColumn, 10);
330
331 if (pos.gridColumn.includes("/")) return undefined;
332 if (row === 2) return undefined;
333
334 return `translateX(${col === 1 ? ITEM_INNER_ROW_OFFSET_PX : -ITEM_INNER_ROW_OFFSET_PX}px)`;
335 }
336
337 export function isVisibleCircularGridItem(index: number): boolean {
338 return index >= 0 && index < CIRCULAR_GRID_MAX_ITEMS;
339 }
340
341
341 lines Plain Text