返回 presentation-ai
ChartPanel.tsx
1 "use client";
2
3 import { DRAG_ITEM_BLOCK } from "@platejs/dnd";
4 import { GripVertical } from "lucide-react";
5 import { useEditorRef } from "platejs/react";
6 import {
7 useCallback,
8 useEffect,
9 useMemo,
10 useRef,
11 useState,
12 type KeyboardEvent,
13 } from "react";
14 import { useDrag } from "react-dnd";
15
16 import { updateDroppedElementAfterDrop } from "@/components/notebook/presentation/editor/dnd/utils/updateSiblingsForcefully";
17 import {
18 getElementId,
19 getPaletteMutableSignature,
20 isPaletteDropMutable,
21 replaceElementById,
22 replaceFocusedEmptyParagraph,
23 type PaletteDropTarget,
24 } from "@/components/notebook/presentation/editor/utils/paletteDrop";
25 import {
26 type PlateSlide,
27 type RootImage,
28 } from "@/components/notebook/presentation/utils/parser";
29 import { type MyEditor } from "@/components/plate/editor-kit";
30 import { Skeleton } from "@/components/ui/skeleton";
31 import { cn } from "@/lib/utils";
32 import { usePresentationState } from "@/states/presentation-state";
33 import { ChartPreview } from "./ChartPreview";
34 import { chartItems, type PaletteItem } from "./elements";
35 import { PanelSearchFilter } from "./PanelSearchFilter";
36 import { matchesPanelSearch } from "./PanelSearchFilter";
37
38 const KEYBOARD_APPLY_DELAY_MS = 250;
39
40 function getChartFilterValue(item: PaletteItem): string {
41 if (
42 [
43 "chart-pie",
44 "chart-donut",
45 "chart-bar",
46 "chart-line",
47 "chart-area",
48 "chart-radar",
49 "chart-radial-bar",
50 "chart-radial-column",
51 "chart-composed",
52 "chart-nightingale",
53 ].includes(item.key)
54 ) {
55 return "basic";
56 }
57 if (
58 [
59 "chart-scatter",
60 "chart-bubble",
61 "chart-histogram",
62 "chart-heatmap",
63 "chart-box-plot",
64 ].includes(item.key)
65 ) {
66 return "statistical";
67 }
68 if (
69 ["chart-range-bar", "chart-range-area", "chart-waterfall"].includes(
70 item.key,
71 )
72 ) {
73 return "range";
74 }
75 if (["chart-candlestick", "chart-ohlc"].includes(item.key)) {
76 return "financial";
77 }
78 if (["chart-treemap", "chart-sunburst", "chart-pyramid"].includes(item.key)) {
79 return "hierarchy";
80 }
81 if (["chart-sankey", "chart-chord"].includes(item.key)) {
82 return "flow";
83 }
84 if (["chart-funnel", "chart-cone-funnel"].includes(item.key)) {
85 return "funnel";
86 }
87 return "gauge";
88 }
89
90 export function ChartPanel({ isLoaded }: { isLoaded: boolean }) {
91 const paletteDropTarget = usePresentationState((s) => s.paletteDropTarget);
92 const currentSlideId = usePresentationState((s) => s.currentSlideId);
93 const setPaletteDropTarget = usePresentationState(
94 (s) => s.setPaletteDropTarget,
95 );
96 const updateSlide = usePresentationState((s) => s.updateSlide);
97 const editor = useEditorRef<MyEditor>(currentSlideId ?? undefined);
98
99 const insertFocusedItem = useCallback(
100 (item: PaletteItem) => {
101 if (!currentSlideId) return;
102
103 const insertedElement = replaceFocusedEmptyParagraph(editor, item.node);
104 const insertedElementId = getElementId(insertedElement ?? undefined);
105
106 if (!insertedElementId) return;
107
108 const insertedEntry = editor.api.node({ id: insertedElementId, at: [] });
109 if (insertedEntry) {
110 updateDroppedElementAfterDrop(editor, insertedEntry[1]);
111 }
112
113 updateSlide(currentSlideId, {
114 content: editor.children as PlateSlide["content"],
115 });
116
117 const updatedEntry = editor.api.node({ id: insertedElementId, at: [] });
118 const [updatedElement] = updatedEntry ?? [];
119
120 setPaletteDropTarget({
121 editorId: currentSlideId,
122 elementId: insertedElementId,
123 itemKey: item.key,
124 source: "charts",
125 mutableSignature: updatedElement
126 ? getPaletteMutableSignature(updatedElement)
127 : undefined,
128 });
129 },
130 [currentSlideId, editor, setPaletteDropTarget, updateSlide],
131 );
132
133 if (!isLoaded) {
134 return (
135 <div className="animate-fade-in scrollbar-thin flex h-full flex-col gap-4 overflow-y-auto px-4 pb-5 scrollbar-thumb-primary scrollbar-track-transparent">
136 <div className="grid grid-cols-2 gap-4">
137 {Array.from({ length: chartItems.length }).map((_, i) => (
138 <div key={i} className="rounded-md border p-2">
139 <div className="mb-2 flex items-center gap-1.5 text-xs font-medium">
140 <div className="h-4 w-4 shrink-0 animate-pulse rounded-full bg-muted" />
141 <Skeleton className="h-3 w-20" />
142 </div>
143 <div className="aspect-video w-full rounded-sm bg-muted/30">
144 <Skeleton className="h-full w-full rounded-sm" />
145 </div>
146 </div>
147 ))}
148 </div>
149 </div>
150 );
151 }
152
153 if (paletteDropTarget?.source === "charts") {
154 return <TrackedChartPanel paletteDropTarget={paletteDropTarget} />;
155 }
156
157 return <ChartPanelContent insertFocusedItem={insertFocusedItem} />;
158 }
159
160 function TrackedChartPanel({
161 paletteDropTarget,
162 }: {
163 paletteDropTarget: PaletteDropTarget;
164 }) {
165 const setPaletteDropTarget = usePresentationState(
166 (s) => s.setPaletteDropTarget,
167 );
168 const updateSlide = usePresentationState((s) => s.updateSlide);
169 const slides = usePresentationState((s) => s.slides);
170 const editor = useEditorRef<MyEditor>(paletteDropTarget.editorId);
171
172 const replaceTrackedDrop = useCallback(
173 (item: PaletteItem) => {
174 if (paletteDropTarget.targetKind === "rootImage") {
175 const chartElement = item.node as TElementWithChartData;
176 const currentRootImage = slides.find(
177 (slide) => slide.id === paletteDropTarget.editorId,
178 )?.rootImage;
179
180 if (!isPaletteDropMutable(currentRootImage)) {
181 setPaletteDropTarget(null);
182 return;
183 }
184 if (
185 paletteDropTarget.mutableSignature &&
186 getPaletteMutableSignature(currentRootImage) !==
187 paletteDropTarget.mutableSignature
188 ) {
189 setPaletteDropTarget(null);
190 return;
191 }
192
193 const nextRootImage = {
194 ...currentRootImage,
195 query: currentRootImage?.query ?? "",
196 chartType: chartElement.type,
197 chartData: chartElement.data,
198 chartOptions: {
199 variant: chartElement.variant,
200 disableAnimation: true,
201 },
202 paletteDropMutable: true,
203 url: undefined,
204 embedType: undefined,
205 imageSource: undefined,
206 } satisfies RootImage;
207
208 updateSlide(paletteDropTarget.editorId, {
209 rootImage: nextRootImage,
210 });
211 setPaletteDropTarget({
212 ...paletteDropTarget,
213 itemKey: item.key,
214 mutableSignature: getPaletteMutableSignature(nextRootImage),
215 });
216 return;
217 }
218
219 const replaced = replaceElementById(
220 editor,
221 paletteDropTarget.elementId,
222 item.node,
223 paletteDropTarget.mutableSignature,
224 );
225
226 if (!replaced) {
227 setPaletteDropTarget(null);
228 return;
229 }
230
231 updateSlide(paletteDropTarget.editorId, {
232 content: editor.children as PlateSlide["content"],
233 });
234 const updatedEntry = editor.api.node({
235 id: paletteDropTarget.elementId,
236 at: [],
237 });
238 const [updatedElement] = updatedEntry ?? [];
239
240 setPaletteDropTarget({
241 ...paletteDropTarget,
242 itemKey: item.key,
243 mutableSignature: updatedElement
244 ? getPaletteMutableSignature(updatedElement)
245 : undefined,
246 });
247 },
248 [editor, paletteDropTarget, setPaletteDropTarget, slides, updateSlide],
249 );
250
251 const initialSelectedIndex = Math.max(
252 chartItems.findIndex((item) => item.key === paletteDropTarget.itemKey),
253 0,
254 );
255
256 return (
257 <ChartPanelContent
258 key={paletteDropTarget.elementId}
259 initialSelectedIndex={initialSelectedIndex}
260 replaceTrackedDrop={replaceTrackedDrop}
261 />
262 );
263 }
264
265 type TElementWithChartData = PaletteItem["node"] & {
266 data?: unknown;
267 variant?: string;
268 };
269
270 function ChartPanelContent({
271 initialSelectedIndex = 0,
272 insertFocusedItem,
273 replaceTrackedDrop,
274 }: {
275 initialSelectedIndex?: number;
276 insertFocusedItem?: (item: PaletteItem) => void;
277 replaceTrackedDrop?: (item: PaletteItem) => void;
278 }) {
279 const [selectedIndex, setSelectedIndex] = useState(initialSelectedIndex);
280 const [searchQuery, setSearchQuery] = useState("");
281 const cardRefs = useRef<Array<HTMLDivElement | null>>([]);
282 const applyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
283 const filteredChartItems = useMemo(
284 () =>
285 chartItems.filter((item) => {
286 const category = getChartFilterValue(item);
287
288 return matchesPanelSearch(searchQuery, [
289 item.label,
290 item.key,
291 category,
292 ]);
293 }),
294 [searchQuery],
295 );
296
297 const focusCard = useCallback((index: number) => {
298 cardRefs.current[index]?.focus();
299 }, []);
300
301 useEffect(() => {
302 window.requestAnimationFrame(() => {
303 focusCard(initialSelectedIndex);
304 });
305 }, [focusCard, initialSelectedIndex]);
306
307 useEffect(() => {
308 setSelectedIndex((currentIndex) =>
309 filteredChartItems.length === 0
310 ? 0
311 : Math.min(currentIndex, filteredChartItems.length - 1),
312 );
313 }, [filteredChartItems.length]);
314
315 useEffect(
316 () => () => {
317 if (applyTimeoutRef.current) {
318 clearTimeout(applyTimeoutRef.current);
319 }
320 },
321 [],
322 );
323
324 const commitSelection = useCallback(
325 (index: number) => {
326 const item = filteredChartItems[index];
327
328 if (!item) return;
329
330 if (replaceTrackedDrop) {
331 replaceTrackedDrop(item);
332 return;
333 }
334
335 insertFocusedItem?.(item);
336 },
337 [filteredChartItems, insertFocusedItem, replaceTrackedDrop],
338 );
339
340 const selectItem = useCallback(
341 (index: number) => {
342 if (applyTimeoutRef.current) {
343 clearTimeout(applyTimeoutRef.current);
344 applyTimeoutRef.current = null;
345 }
346
347 setSelectedIndex(index);
348 commitSelection(index);
349 },
350 [commitSelection],
351 );
352
353 const scheduleSelectionCommit = useCallback(
354 (index: number) => {
355 if (applyTimeoutRef.current) {
356 clearTimeout(applyTimeoutRef.current);
357 }
358
359 applyTimeoutRef.current = setTimeout(() => {
360 applyTimeoutRef.current = null;
361 commitSelection(index);
362
363 window.requestAnimationFrame(() => {
364 focusCard(index);
365 });
366 }, KEYBOARD_APPLY_DELAY_MS);
367 },
368 [commitSelection, focusCard],
369 );
370
371 const moveSelection = useCallback(
372 (nextIndex: number) => {
373 const boundedIndex = Math.min(
374 Math.max(nextIndex, 0),
375 filteredChartItems.length - 1,
376 );
377
378 if (boundedIndex < 0) return;
379
380 setSelectedIndex(boundedIndex);
381 focusCard(boundedIndex);
382 if (replaceTrackedDrop) {
383 scheduleSelectionCommit(boundedIndex);
384 }
385 },
386 [
387 filteredChartItems.length,
388 focusCard,
389 replaceTrackedDrop,
390 scheduleSelectionCommit,
391 ],
392 );
393
394 const handleCardKeyDown = useCallback(
395 (event: KeyboardEvent<HTMLDivElement>, index: number) => {
396 const columns = 2;
397
398 switch (event.key) {
399 case "ArrowLeft":
400 event.preventDefault();
401 event.stopPropagation();
402 moveSelection(index - 1);
403 break;
404 case "ArrowRight":
405 event.preventDefault();
406 event.stopPropagation();
407 moveSelection(index + 1);
408 break;
409 case "ArrowUp":
410 event.preventDefault();
411 event.stopPropagation();
412 moveSelection(index - columns);
413 break;
414 case "ArrowDown":
415 event.preventDefault();
416 event.stopPropagation();
417 moveSelection(index + columns);
418 break;
419 case "Home":
420 event.preventDefault();
421 event.stopPropagation();
422 moveSelection(0);
423 break;
424 case "End":
425 event.preventDefault();
426 event.stopPropagation();
427 moveSelection(filteredChartItems.length - 1);
428 break;
429 case "Enter":
430 case " ":
431 event.preventDefault();
432 event.stopPropagation();
433 selectItem(index);
434 break;
435 }
436 },
437 [filteredChartItems.length, moveSelection, selectItem],
438 );
439
440 return (
441 <div className="flex h-full flex-col overflow-hidden">
442 <PanelSearchFilter
443 onQueryChange={setSearchQuery}
444 placeholder="Search charts..."
445 query={searchQuery}
446 />
447 <div className="scrollbar-thin flex-1 overflow-y-auto px-4 scrollbar-thumb-primary scrollbar-track-transparent">
448 {filteredChartItems.length > 0 ? (
449 <div className="grid grid-cols-2 gap-4 py-4">
450 {filteredChartItems.map((item, index) => (
451 <ChartCard
452 key={item.key}
453 item={item}
454 refCallback={(node) => {
455 cardRefs.current[index] = node;
456 }}
457 isSelected={selectedIndex === index}
458 tabIndex={selectedIndex === index ? 0 : -1}
459 onClick={() => selectItem(index)}
460 onFocus={() => setSelectedIndex(index)}
461 onKeyDown={(event) => handleCardKeyDown(event, index)}
462 />
463 ))}
464 </div>
465 ) : (
466 <div className="flex h-full items-center justify-center px-6 text-center text-sm text-muted-foreground">
467 No charts match your search.
468 </div>
469 )}
470 </div>
471 </div>
472 );
473 }
474
475 function ChartCard({
476 item,
477 refCallback,
478 isSelected,
479 tabIndex,
480 onClick,
481 onFocus,
482 onKeyDown,
483 }: {
484 item: PaletteItem;
485 refCallback: (node: HTMLDivElement | null) => void;
486 isSelected: boolean;
487 tabIndex: number;
488 onClick: () => void;
489 onFocus: () => void;
490 onKeyDown: (event: KeyboardEvent<HTMLDivElement>) => void;
491 }) {
492 const [{ isDragging }, drag] = useDrag(() => ({
493 type: DRAG_ITEM_BLOCK,
494 item: {
495 id: `external-${item.key}`,
496 element: item.node,
497 itemKey: item.key,
498 sourcePanel: "charts" as const,
499 },
500 collect: (monitor) => ({ isDragging: monitor.isDragging() }),
501 }));
502
503 return (
504 <div
505 ref={(el) => {
506 refCallback(el);
507 if (el) drag(el);
508 }}
509 aria-label={item.label}
510 aria-pressed={isSelected}
511 tabIndex={tabIndex}
512 data-panel-arrow-target="true"
513 onClick={onClick}
514 onFocus={onFocus}
515 onKeyDown={onKeyDown}
516 className={cn(
517 "group cursor-grab rounded-md border p-2 px-4 transition hover:border hover:border-primary hover:shadow focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none active:cursor-grabbing",
518 isSelected && "border-primary ring-1 ring-primary",
519 isDragging && "opacity-50",
520 )}
521 >
522 <div className="mb-2 flex items-center gap-1.5 text-xs font-medium">
523 <GripVertical className="size-4 text-muted-foreground transition-colors group-hover:text-foreground" />
524 <span>{item.label}</span>
525 </div>
526 <ChartPreview chartType={item.node.type as string} className="w-full" />
527 </div>
528 );
529 }
530
530 lines Plain Text