返回 presentation-ai
ElementsPanel.tsx
根目录 / src / components / presentation / edit-panel / sections / ElementsPanel.tsx
1 "use client";
2
3 import { DRAG_ITEM_BLOCK } from "@platejs/dnd";
4 import {
5 Brackets,
6 CircleAlert,
7 CircleCheck,
8 CircleHelp,
9 CircleUserRound,
10 FileText,
11 GripVertical,
12 Heading1Icon,
13 Heading2Icon,
14 Heading3Icon,
15 Info,
16 List,
17 ListChecks,
18 ListOrdered,
19 MousePointerClick,
20 Quote,
21 Sigma,
22 TableIcon,
23 TableOfContentsIcon,
24 Tag,
25 ToggleLeft,
26 Type,
27 type LucideIcon,
28 } from "lucide-react";
29 import { useEditorRef } from "platejs/react";
30 import {
31 useCallback,
32 useEffect,
33 useMemo,
34 useRef,
35 useState,
36 type KeyboardEvent,
37 } from "react";
38 import { useDrag } from "react-dnd";
39
40 import { updateDroppedElementAfterDrop } from "@/components/notebook/presentation/editor/dnd/utils/updateSiblingsForcefully";
41 import {
42 getElementId,
43 getPaletteMutableSignature,
44 replaceElementById,
45 replaceFocusedEmptyParagraph,
46 type PaletteDropTarget,
47 } from "@/components/notebook/presentation/editor/utils/paletteDrop";
48 import { type PlateSlide } from "@/components/notebook/presentation/utils/parser";
49 import { type MyEditor } from "@/components/plate/editor-kit";
50 import { Skeleton } from "@/components/ui/skeleton";
51 import { cn } from "@/lib/utils";
52 import { usePresentationState } from "@/states/presentation-state";
53 import { ElementPreview } from "./ElementPreview";
54 import { visiblePaletteItems, type PaletteItem } from "./elements";
55 import { PanelSearchFilter } from "./PanelSearchFilter";
56 import { matchesPanelSearch } from "./PanelSearchFilter";
57
58 const KEYBOARD_APPLY_DELAY_MS = 250;
59 type PalettePanelSource = "basicBlocks" | "elements";
60 type PalettePanelAppearance = "basicBlocks" | "default";
61
62 const BASIC_BLOCK_ICON_MAP: Record<string, LucideIcon> = {
63 title: Type,
64 "heading-1": Heading1Icon,
65 "heading-2": Heading2Icon,
66 "heading-3": Heading3Icon,
67 "heading-4": Type,
68 paragraph: Type,
69 blockquote: Quote,
70 label: Tag,
71 "table-2x2": TableIcon,
72 "table-3x3": TableIcon,
73 "table-4x4": TableIcon,
74 "bulleted-list": List,
75 "numbered-list": ListOrdered,
76 "todo-list": ListChecks,
77 "callout-note": FileText,
78 "callout-info": Info,
79 "callout-warning": CircleAlert,
80 "callout-caution": CircleAlert,
81 "callout-success": CircleCheck,
82 "callout-question": CircleHelp,
83 button: MousePointerClick,
84 toggle: ToggleLeft,
85 code: Brackets,
86 math: Sigma,
87 contributors: CircleUserRound,
88 toc: TableOfContentsIcon,
89 };
90
91 function createPaletteNode(item: PaletteItem): PaletteItem["node"] {
92 return structuredClone(item.node) as PaletteItem["node"];
93 }
94
95 function getElementFilterValue(item: PaletteItem): string {
96 if (["quote-large", "quote-side-icon", "quote-side"].includes(item.key)) {
97 return "text";
98 }
99 if (
100 ["bullets", "timeline", "steps", "arrows", "arrow-vertical"].includes(
101 item.key,
102 )
103 ) {
104 return "process";
105 }
106 if (
107 [
108 "slope",
109 "snake",
110 "pyramid",
111 "staircase",
112 "cycle",
113 "connected-circles",
114 "circular-grid",
115 "icon-list",
116 ].includes(item.key)
117 ) {
118 return "diagrams";
119 }
120 if (["boxes", "compare", "before-after", "pros-cons"].includes(item.key)) {
121 return "compare";
122 }
123 if (["columns"].includes(item.key)) {
124 return "layout";
125 }
126 if (
127 [
128 "table",
129 "stats-plain",
130 "stats-circle",
131 "stats-star",
132 "stats-bar",
133 "stats-dot-grid",
134 "stats-dot-line",
135 ].includes(item.key)
136 ) {
137 return "data";
138 }
139 if (["image", "media-embed", "infographic"].includes(item.key)) {
140 return "media";
141 }
142 return "utility";
143 }
144
145 export function ElementsPanel({ isLoaded }: { isLoaded: boolean }) {
146 return (
147 <PaletteItemsPanel
148 emptyMessage="No elements match your search."
149 isLoaded={isLoaded}
150 paletteItems={visiblePaletteItems}
151 searchPlaceholder="Search elements..."
152 source="elements"
153 getFilterValue={getElementFilterValue}
154 />
155 );
156 }
157
158 export function PaletteItemsPanel({
159 emptyMessage,
160 getFilterValue,
161 isLoaded,
162 paletteItems,
163 searchPlaceholder,
164 source,
165 }: {
166 emptyMessage: string;
167 getFilterValue: (item: PaletteItem) => string;
168 isLoaded: boolean;
169 paletteItems: PaletteItem[];
170 searchPlaceholder: string;
171 source: PalettePanelSource;
172 }) {
173 const appearance: PalettePanelAppearance =
174 source === "basicBlocks" ? "basicBlocks" : "default";
175 const paletteDropTarget = usePresentationState((s) => s.paletteDropTarget);
176 const currentSlideId = usePresentationState((s) => s.currentSlideId);
177 const setPaletteDropTarget = usePresentationState(
178 (s) => s.setPaletteDropTarget,
179 );
180 const updateSlide = usePresentationState((s) => s.updateSlide);
181 const editor = useEditorRef<MyEditor>(currentSlideId ?? undefined);
182
183 const insertFocusedItem = useCallback(
184 (item: PaletteItem) => {
185 if (!currentSlideId) return;
186
187 const node = createPaletteNode(item);
188 const insertedElement = replaceFocusedEmptyParagraph(editor, node);
189 const insertedElementId = getElementId(insertedElement ?? undefined);
190
191 if (!insertedElementId) return;
192
193 const insertedEntry = editor.api.node({ id: insertedElementId, at: [] });
194 if (insertedEntry) {
195 updateDroppedElementAfterDrop(editor, insertedEntry[1]);
196 }
197
198 updateSlide(currentSlideId, {
199 content: editor.children as PlateSlide["content"],
200 });
201
202 const updatedEntry = editor.api.node({ id: insertedElementId, at: [] });
203 const [updatedElement] = updatedEntry ?? [];
204
205 setPaletteDropTarget({
206 editorId: currentSlideId,
207 elementId: insertedElementId,
208 itemKey: item.key,
209 source,
210 mutableSignature: updatedElement
211 ? getPaletteMutableSignature(updatedElement)
212 : undefined,
213 });
214 },
215 [currentSlideId, editor, setPaletteDropTarget, source, updateSlide],
216 );
217
218 if (!isLoaded) {
219 return (
220 <div
221 draggable={false}
222 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"
223 >
224 <div className="grid grid-cols-2 gap-3">
225 {Array.from({ length: paletteItems.length }).map((_, i) => (
226 <div key={i} className="rounded-md border p-2">
227 <div className="aspect-video w-full rounded-sm bg-muted/30">
228 <Skeleton className="h-full w-full rounded-sm" />
229 </div>
230 <div className="mt-1.5 flex items-center gap-1 px-0.5">
231 <div className="h-3 w-3 shrink-0 animate-pulse rounded-full bg-muted" />
232 <Skeleton className="h-3 w-20" />
233 </div>
234 </div>
235 ))}
236 </div>
237 </div>
238 );
239 }
240
241 if (paletteDropTarget?.source === source) {
242 return (
243 <TrackedElementsPanel
244 emptyMessage={emptyMessage}
245 getFilterValue={getFilterValue}
246 appearance={appearance}
247 paletteDropTarget={paletteDropTarget}
248 paletteItems={paletteItems}
249 searchPlaceholder={searchPlaceholder}
250 source={source}
251 />
252 );
253 }
254
255 return (
256 <ElementsPanelContent
257 emptyMessage={emptyMessage}
258 getFilterValue={getFilterValue}
259 appearance={appearance}
260 paletteItems={paletteItems}
261 insertFocusedItem={insertFocusedItem}
262 searchPlaceholder={searchPlaceholder}
263 source={source}
264 />
265 );
266 }
267
268 function TrackedElementsPanel({
269 appearance,
270 emptyMessage,
271 getFilterValue,
272 paletteDropTarget,
273 paletteItems,
274 searchPlaceholder,
275 source,
276 }: {
277 appearance: PalettePanelAppearance;
278 emptyMessage: string;
279 getFilterValue: (item: PaletteItem) => string;
280 paletteDropTarget: PaletteDropTarget;
281 paletteItems: PaletteItem[];
282 searchPlaceholder: string;
283 source: PalettePanelSource;
284 }) {
285 const setPaletteDropTarget = usePresentationState(
286 (s) => s.setPaletteDropTarget,
287 );
288 const updateSlide = usePresentationState((s) => s.updateSlide);
289 const editor = useEditorRef<MyEditor>(paletteDropTarget.editorId);
290
291 const replaceTrackedDrop = useCallback(
292 (item: PaletteItem) => {
293 const node = createPaletteNode(item);
294 const replaced = replaceElementById(
295 editor,
296 paletteDropTarget.elementId,
297 node,
298 paletteDropTarget.mutableSignature,
299 );
300
301 if (!replaced) {
302 setPaletteDropTarget(null);
303 return;
304 }
305
306 updateSlide(paletteDropTarget.editorId, {
307 content: editor.children as PlateSlide["content"],
308 });
309 const updatedEntry = editor.api.node({
310 id: paletteDropTarget.elementId,
311 at: [],
312 });
313 const [updatedElement] = updatedEntry ?? [];
314
315 setPaletteDropTarget({
316 ...paletteDropTarget,
317 itemKey: item.key,
318 mutableSignature: updatedElement
319 ? getPaletteMutableSignature(updatedElement)
320 : undefined,
321 });
322 },
323 [editor, paletteDropTarget, setPaletteDropTarget, updateSlide],
324 );
325
326 const initialSelectedIndex = Math.max(
327 paletteItems.findIndex((item) => item.key === paletteDropTarget.itemKey),
328 0,
329 );
330
331 return (
332 <ElementsPanelContent
333 key={paletteDropTarget.elementId}
334 emptyMessage={emptyMessage}
335 getFilterValue={getFilterValue}
336 appearance={appearance}
337 paletteItems={paletteItems}
338 initialSelectedIndex={initialSelectedIndex}
339 replaceTrackedDrop={replaceTrackedDrop}
340 searchPlaceholder={searchPlaceholder}
341 source={source}
342 />
343 );
344 }
345
346 function ElementsPanelContent({
347 appearance,
348 emptyMessage,
349 getFilterValue,
350 paletteItems,
351 initialSelectedIndex = 0,
352 insertFocusedItem,
353 replaceTrackedDrop,
354 searchPlaceholder,
355 source,
356 }: {
357 appearance: PalettePanelAppearance;
358 emptyMessage: string;
359 getFilterValue: (item: PaletteItem) => string;
360 paletteItems: PaletteItem[];
361 initialSelectedIndex?: number;
362 insertFocusedItem?: (item: PaletteItem) => void;
363 replaceTrackedDrop?: (item: PaletteItem) => void;
364 searchPlaceholder: string;
365 source: PalettePanelSource;
366 }) {
367 const isBasicBlocks = appearance === "basicBlocks";
368 const [selectedIndex, setSelectedIndex] = useState(initialSelectedIndex);
369 const [searchQuery, setSearchQuery] = useState("");
370 const cardRefs = useRef<Array<HTMLDivElement | null>>([]);
371 const applyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
372 const filteredPaletteItems = useMemo(
373 () =>
374 paletteItems.filter((item) => {
375 const category = getFilterValue(item);
376
377 return matchesPanelSearch(searchQuery, [
378 item.description,
379 item.label,
380 item.key,
381 category,
382 ]);
383 }),
384 [getFilterValue, paletteItems, searchQuery],
385 );
386 const groupedPaletteItems = useMemo(() => {
387 const groups: Array<{ category: string | null; items: PaletteItem[] }> = [];
388
389 for (const item of filteredPaletteItems) {
390 const category = item.category ?? null;
391 const previousGroup = groups.at(-1);
392
393 if (previousGroup && previousGroup.category === category) {
394 previousGroup.items.push(item);
395 continue;
396 }
397
398 groups.push({ category, items: [item] });
399 }
400
401 return groups;
402 }, [filteredPaletteItems]);
403
404 const focusCard = useCallback((index: number) => {
405 cardRefs.current[index]?.focus();
406 }, []);
407
408 useEffect(() => {
409 window.requestAnimationFrame(() => {
410 focusCard(initialSelectedIndex);
411 });
412 }, [focusCard, initialSelectedIndex]);
413
414 useEffect(() => {
415 setSelectedIndex((currentIndex) =>
416 filteredPaletteItems.length === 0
417 ? 0
418 : Math.min(currentIndex, filteredPaletteItems.length - 1),
419 );
420 }, [filteredPaletteItems.length]);
421
422 useEffect(
423 () => () => {
424 if (applyTimeoutRef.current) {
425 clearTimeout(applyTimeoutRef.current);
426 }
427 },
428 [],
429 );
430
431 const commitSelection = useCallback(
432 (index: number) => {
433 const item = filteredPaletteItems[index];
434
435 if (!item) return;
436
437 if (replaceTrackedDrop) {
438 replaceTrackedDrop(item);
439 return;
440 }
441
442 insertFocusedItem?.(item);
443 },
444 [filteredPaletteItems, insertFocusedItem, replaceTrackedDrop],
445 );
446
447 const selectItem = useCallback(
448 (index: number) => {
449 if (applyTimeoutRef.current) {
450 clearTimeout(applyTimeoutRef.current);
451 applyTimeoutRef.current = null;
452 }
453
454 setSelectedIndex(index);
455 commitSelection(index);
456 },
457 [commitSelection],
458 );
459
460 const scheduleSelectionCommit = useCallback(
461 (index: number) => {
462 if (applyTimeoutRef.current) {
463 clearTimeout(applyTimeoutRef.current);
464 }
465
466 applyTimeoutRef.current = setTimeout(() => {
467 applyTimeoutRef.current = null;
468 commitSelection(index);
469
470 window.requestAnimationFrame(() => {
471 focusCard(index);
472 });
473 }, KEYBOARD_APPLY_DELAY_MS);
474 },
475 [commitSelection, focusCard],
476 );
477
478 const moveSelection = useCallback(
479 (nextIndex: number) => {
480 const boundedIndex = Math.min(
481 Math.max(nextIndex, 0),
482 filteredPaletteItems.length - 1,
483 );
484
485 if (boundedIndex < 0) return;
486
487 setSelectedIndex(boundedIndex);
488 focusCard(boundedIndex);
489 if (replaceTrackedDrop) {
490 scheduleSelectionCommit(boundedIndex);
491 }
492 },
493 [
494 filteredPaletteItems.length,
495 focusCard,
496 replaceTrackedDrop,
497 scheduleSelectionCommit,
498 ],
499 );
500
501 const handleCardKeyDown = useCallback(
502 (event: KeyboardEvent<HTMLDivElement>, index: number) => {
503 const columns = isBasicBlocks ? 3 : 2;
504
505 switch (event.key) {
506 case "ArrowLeft":
507 event.preventDefault();
508 event.stopPropagation();
509 moveSelection(index - 1);
510 break;
511 case "ArrowRight":
512 event.preventDefault();
513 event.stopPropagation();
514 moveSelection(index + 1);
515 break;
516 case "ArrowUp":
517 event.preventDefault();
518 event.stopPropagation();
519 moveSelection(index - columns);
520 break;
521 case "ArrowDown":
522 event.preventDefault();
523 event.stopPropagation();
524 moveSelection(index + columns);
525 break;
526 case "Home":
527 event.preventDefault();
528 event.stopPropagation();
529 moveSelection(0);
530 break;
531 case "End":
532 event.preventDefault();
533 event.stopPropagation();
534 moveSelection(filteredPaletteItems.length - 1);
535 break;
536 case "Enter":
537 case " ":
538 event.preventDefault();
539 event.stopPropagation();
540 selectItem(index);
541 break;
542 }
543 },
544 [filteredPaletteItems.length, isBasicBlocks, moveSelection, selectItem],
545 );
546
547 return (
548 <div draggable={false} className="flex h-full flex-col overflow-hidden">
549 <PanelSearchFilter
550 onQueryChange={setSearchQuery}
551 placeholder={searchPlaceholder}
552 query={searchQuery}
553 />
554 <div
555 className={cn(
556 "scrollbar-thin flex-1 overflow-y-auto px-4 pb-5 scrollbar-thumb-primary scrollbar-track-transparent",
557 isBasicBlocks &&
558 "px-4 pb-8 scrollbar-thumb-primary scrollbar-track-transparent",
559 )}
560 >
561 {filteredPaletteItems.length > 0 ? (
562 <div
563 className={cn("space-y-5 py-4", isBasicBlocks && "space-y-7 pt-5")}
564 >
565 {groupedPaletteItems.map((group) => {
566 const firstIndex = filteredPaletteItems.findIndex(
567 (item) => item.key === group.items[0]?.key,
568 );
569
570 return (
571 <section key={group.category ?? "palette-items"}>
572 {group.category && (
573 <h3
574 className={cn(
575 "mb-2 text-sm font-semibold text-foreground",
576 isBasicBlocks &&
577 "mb-4 text-base leading-none font-semibold text-foreground",
578 )}
579 >
580 {group.category}
581 </h3>
582 )}
583 <div
584 className={cn(
585 "grid grid-cols-2 gap-3",
586 isBasicBlocks && "grid-cols-3 gap-x-3 gap-y-5",
587 )}
588 >
589 {group.items.map((item, groupIndex) => {
590 const index = firstIndex + groupIndex;
591
592 return (
593 <PaletteCard
594 key={item.key}
595 item={item}
596 refCallback={(node) => {
597 cardRefs.current[index] = node;
598 }}
599 isSelected={selectedIndex === index}
600 appearance={appearance}
601 source={source}
602 tabIndex={selectedIndex === index ? 0 : -1}
603 onClick={() => selectItem(index)}
604 onFocus={() => setSelectedIndex(index)}
605 onKeyDown={(event) => handleCardKeyDown(event, index)}
606 />
607 );
608 })}
609 </div>
610 </section>
611 );
612 })}
613 </div>
614 ) : (
615 <div className="flex h-full items-center justify-center px-6 text-center text-sm text-muted-foreground">
616 {emptyMessage}
617 </div>
618 )}
619 </div>
620 </div>
621 );
622 }
623
624 function PaletteCard({
625 appearance,
626 item,
627 refCallback,
628 isSelected,
629 source,
630 tabIndex,
631 onClick,
632 onFocus,
633 onKeyDown,
634 }: {
635 appearance: PalettePanelAppearance;
636 item: PaletteItem;
637 refCallback: (node: HTMLDivElement | null) => void;
638 isSelected: boolean;
639 source: PalettePanelSource;
640 tabIndex: number;
641 onClick: () => void;
642 onFocus: () => void;
643 onKeyDown: (event: KeyboardEvent<HTMLDivElement>) => void;
644 }) {
645 const [{ isDragging }, drag] = useDrag(
646 () => ({
647 type: DRAG_ITEM_BLOCK,
648 item: {
649 id: `external-${item.key}`,
650 element: createPaletteNode(item),
651 itemKey: item.key,
652 sourcePanel: source,
653 },
654 collect: (monitor) => ({ isDragging: monitor.isDragging() }),
655 }),
656 [item, source],
657 );
658
659 const isBasicBlocks = appearance === "basicBlocks";
660
661 return (
662 <div
663 ref={(el) => {
664 refCallback(el);
665 if (el) drag(el);
666 }}
667 aria-label={item.label}
668 aria-pressed={isSelected}
669 tabIndex={tabIndex}
670 data-panel-arrow-target="true"
671 onClick={onClick}
672 onFocus={onFocus}
673 onKeyDown={onKeyDown}
674 className={cn(
675 "group cursor-grab rounded-md border p-2 transition hover:shadow focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none active:cursor-grabbing",
676 isBasicBlocks &&
677 "rounded-none border-0 p-0 text-center hover:shadow-none focus-visible:ring-primary",
678 isSelected && !isBasicBlocks && "border-primary ring-1 ring-primary",
679 isSelected && isBasicBlocks && "ring-0",
680 isDragging && "opacity-50",
681 )}
682 >
683 {isBasicBlocks ? (
684 <BasicBlockPreviewIcon elementKey={item.key} />
685 ) : (
686 <ElementPreview elementKey={item.key} />
687 )}
688 <div
689 className={cn(
690 "mt-1.5 flex items-center gap-1 px-0.5",
691 isBasicBlocks && "mt-3 block px-0",
692 )}
693 >
694 {!isBasicBlocks && (
695 <GripVertical className="size-3 shrink-0 text-muted-foreground/50 transition-colors group-hover:text-muted-foreground" />
696 )}
697 <div className="min-w-0">
698 <span
699 className={cn(
700 "block truncate text-xs text-muted-foreground",
701 isBasicBlocks &&
702 "whitespace-normal text-base leading-tight font-semibold text-foreground",
703 )}
704 >
705 {item.label}
706 </span>
707 {item.description && (
708 <span
709 className={cn(
710 "block truncate text-[11px] text-muted-foreground/65",
711 isBasicBlocks &&
712 "mt-0.5 whitespace-normal text-sm leading-tight font-normal text-muted-foreground",
713 )}
714 >
715 {item.description}
716 </span>
717 )}
718 </div>
719 </div>
720 </div>
721 );
722 }
723
724 function BasicBlockPreviewIcon({ elementKey }: { elementKey: string }) {
725 const Icon = BASIC_BLOCK_ICON_MAP[elementKey] ?? Type;
726 const textIcon = {
727 "heading-4": "H4",
728 }[elementKey];
729
730 return (
731 <div className="flex aspect-square w-full items-center justify-center rounded-xl border border-border bg-card transition-colors group-hover:border-primary/50">
732 {textIcon ? (
733 <span className="text-2xl leading-none font-semibold text-primary">
734 {textIcon}
735 </span>
736 ) : (
737 <Icon className="size-7 stroke-[2.25] text-primary" />
738 )}
739 </div>
740 );
741 }
742
742 lines Plain Text