返回 presentation-ai
DiagramPanel.tsx
根目录 / src / components / presentation / edit-panel / sections / DiagramPanel.tsx
1 "use client";
2
3 import { DRAG_ITEM_BLOCK } from "@platejs/dnd";
4 import { ChevronDown, 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 applyThemeToSyntax,
19 type InfographicPaletteThemeColors,
20 } from "@/components/notebook/presentation/editor/utils/infographic-utils";
21 import {
22 getElementId,
23 getPaletteMutableSignature,
24 replaceElementById,
25 replaceFocusedEmptyParagraph,
26 type PaletteDropTarget,
27 } from "@/components/notebook/presentation/editor/utils/paletteDrop";
28 import { type PlateSlide } from "@/components/notebook/presentation/utils/parser";
29 import { type MyEditor } from "@/components/plate/editor-kit";
30 import { ScrollList, type ScrollListRange } from "@/components/ui/scroll-list";
31 import { Skeleton } from "@/components/ui/skeleton";
32 import { renderInfographicPreviewHtml } from "@/hooks/presentation/infographic/infographic-preview-renderer";
33 import { resolvePresentationThemeData } from "@/lib/presentation/theme-resolution";
34 import { cn } from "@/lib/utils";
35 import { usePresentationState } from "@/states/presentation-state";
36 import { usePresentationTheme } from "../../providers/PresentationThemeProvider";
37 import {
38 diagramCategories,
39 type DiagramCategory,
40 type DiagramItem,
41 } from "./diagrams";
42 import { PanelSearchFilter } from "./PanelSearchFilter";
43 import { matchesPanelSearch } from "./PanelSearchFilter";
44
45 const KEYBOARD_APPLY_DELAY_MS = 250;
46 const VIRTUAL_ROW_OVERSCAN = 1_200;
47 const PREVIEW_LOOKAHEAD_PX = 3_600;
48 const PREVIEW_LOOKBEHIND_PX = 1_000;
49 const HEADER_ROW_HEIGHT = 37;
50 const CARD_ROW_HEIGHT = 159;
51 const ROW_GAP = 0;
52 const ALL_DIAGRAM_ITEMS = diagramCategories.flatMap(
53 (category) => category.items,
54 );
55
56 type DiagramPreviewCache = {
57 failedKeys: Set<string>;
58 markupByKey: Map<string, string>;
59 };
60
61 type DiagramPreviewRequest = {
62 isDark: boolean;
63 items: DiagramItem[];
64 themeColors: InfographicPaletteThemeColors | null;
65 };
66
67 type PreviewCandidate<TItem> = {
68 distance: number;
69 item: TItem;
70 };
71
72 type VirtualHeaderRow = {
73 categoryKey: string;
74 categoryName: string;
75 collapsed: boolean;
76 count: number;
77 height: number;
78 key: string;
79 type: "header";
80 };
81
82 type VirtualCardRow = {
83 categoryKey: string;
84 categoryName: string;
85 height: number;
86 items: DiagramItem[];
87 key: string;
88 type: "cards";
89 };
90
91 type VirtualDiagramRow = VirtualHeaderRow | VirtualCardRow;
92
93 const diagramPreviewMarkupCache = new Map<string, string>();
94 const diagramPreviewFailureCache = new Set<string>();
95 const diagramPreviewPromiseCache = new Map<string, Promise<void>>();
96
97 function getDiagramPreviewCacheKey(
98 item: DiagramItem,
99 isDark: boolean,
100 themeColors: InfographicPaletteThemeColors | null,
101 ): string {
102 return [
103 item.key,
104 isDark ? "dark" : "light",
105 themeColors?.primary ?? "",
106 themeColors?.accent ?? "",
107 themeColors?.smartLayout ?? "",
108 themeColors?.text ?? "",
109 themeColors?.heading ?? "",
110 themeColors?.cardBackground ?? "",
111 ].join("|");
112 }
113
114 async function renderDiagramPreviewMarkup(
115 item: DiagramItem,
116 isDark: boolean,
117 themeColors: InfographicPaletteThemeColors | null,
118 ): Promise<string> {
119 return renderInfographicPreviewHtml(
120 applyThemeToSyntax(item.syntax, isDark, themeColors),
121 );
122 }
123
124 async function ensureDiagramPreviewMarkup(
125 item: DiagramItem,
126 isDark: boolean,
127 themeColors: InfographicPaletteThemeColors | null,
128 ): Promise<void> {
129 const cacheKey = getDiagramPreviewCacheKey(item, isDark, themeColors);
130
131 if (
132 diagramPreviewMarkupCache.has(cacheKey) ||
133 diagramPreviewFailureCache.has(cacheKey)
134 ) {
135 return;
136 }
137
138 const existingPromise = diagramPreviewPromiseCache.get(cacheKey);
139 if (existingPromise) {
140 await existingPromise;
141 return;
142 }
143
144 const previewPromise = renderDiagramPreviewMarkup(item, isDark, themeColors)
145 .then((markup) => {
146 diagramPreviewMarkupCache.set(cacheKey, markup);
147 })
148 .catch((error: unknown) => {
149 console.error("Failed to render diagram preview:", error);
150 diagramPreviewFailureCache.add(cacheKey);
151 })
152 .finally(() => {
153 diagramPreviewPromiseCache.delete(cacheKey);
154 });
155
156 diagramPreviewPromiseCache.set(cacheKey, previewPromise);
157 await previewPromise;
158 }
159
160 function getPendingPreviewItem(
161 items: DiagramItem[],
162 isDark: boolean,
163 themeColors: InfographicPaletteThemeColors | null,
164 ): DiagramItem | undefined {
165 return items.find((item) => {
166 const cacheKey = getDiagramPreviewCacheKey(item, isDark, themeColors);
167 return (
168 !diagramPreviewMarkupCache.has(cacheKey) &&
169 !diagramPreviewFailureCache.has(cacheKey) &&
170 !diagramPreviewPromiseCache.has(cacheKey)
171 );
172 });
173 }
174
175 function useDiagramPreviewCache(
176 isDark: boolean,
177 themeColors: InfographicPaletteThemeColors | null,
178 requestedPreviewItems: DiagramItem[],
179 ): DiagramPreviewCache {
180 const [, setCacheVersion] = useState(0);
181 const isMountedRef = useRef(false);
182 const isPreloadingRef = useRef(false);
183 const latestRequestRef = useRef<DiagramPreviewRequest>({
184 isDark,
185 items: requestedPreviewItems,
186 themeColors,
187 });
188
189 useEffect(() => {
190 isMountedRef.current = true;
191
192 return () => {
193 isMountedRef.current = false;
194 };
195 }, []);
196
197 useEffect(() => {
198 latestRequestRef.current = {
199 isDark,
200 items: requestedPreviewItems,
201 themeColors,
202 };
203
204 async function preloadRequestedDiagrams() {
205 if (isPreloadingRef.current) return;
206
207 isPreloadingRef.current = true;
208
209 try {
210 const request = latestRequestRef.current;
211 const item = getPendingPreviewItem(
212 request.items,
213 request.isDark,
214 request.themeColors,
215 );
216
217 if (item) {
218 await ensureDiagramPreviewMarkup(
219 item,
220 request.isDark,
221 request.themeColors,
222 );
223
224 if (isMountedRef.current) {
225 setCacheVersion((version) => version + 1);
226 }
227 }
228 } finally {
229 isPreloadingRef.current = false;
230 const request = latestRequestRef.current;
231
232 if (
233 isMountedRef.current &&
234 getPendingPreviewItem(
235 request.items,
236 request.isDark,
237 request.themeColors,
238 )
239 ) {
240 void preloadRequestedDiagrams();
241
242 isPreloadingRef.current = false;
243 const latestRequest = latestRequestRef.current;
244
245 if (
246 isMountedRef.current &&
247 getPendingPreviewItem(
248 latestRequest.items,
249 latestRequest.isDark,
250 latestRequest.themeColors,
251 )
252 ) {
253 void preloadRequestedDiagrams();
254 }
255 }
256 }
257 }
258
259 void preloadRequestedDiagrams();
260 }, [isDark, requestedPreviewItems, themeColors]);
261
262 const currentFailedKeys = new Set<string>();
263 const markupByKey = new Map<string, string>();
264
265 for (const item of ALL_DIAGRAM_ITEMS) {
266 const cacheKey = getDiagramPreviewCacheKey(item, isDark, themeColors);
267 const markup = diagramPreviewMarkupCache.get(cacheKey);
268
269 if (markup) {
270 markupByKey.set(cacheKey, markup);
271 }
272 if (diagramPreviewFailureCache.has(cacheKey)) {
273 currentFailedKeys.add(cacheKey);
274 }
275 }
276
277 return {
278 failedKeys: currentFailedKeys,
279 markupByKey,
280 };
281 }
282
283 function buildVirtualRows(
284 categories: DiagramCategory[],
285 collapsedCategoryKeys: ReadonlySet<string>,
286 ): VirtualDiagramRow[] {
287 return categories.flatMap<VirtualDiagramRow>((category) => {
288 const cardRows: VirtualCardRow[] = [];
289 const collapsed = collapsedCategoryKeys.has(category.key);
290
291 if (!collapsed) {
292 for (let index = 0; index < category.items.length; index += 2) {
293 const items = category.items.slice(index, index + 2);
294 cardRows.push({
295 type: "cards",
296 key: `${category.key}-cards-${index}`,
297 categoryKey: category.key,
298 categoryName: category.name,
299 items,
300 height: CARD_ROW_HEIGHT,
301 });
302 }
303 }
304
305 return [
306 {
307 type: "header",
308 key: `${category.key}-header`,
309 categoryKey: category.key,
310 categoryName: category.name,
311 collapsed,
312 count: category.items.length,
313 height: HEADER_ROW_HEIGHT,
314 },
315 ...cardRows,
316 ];
317 });
318 }
319
320 function getRequestedPreviewItems(
321 rows: VirtualDiagramRow[],
322 scrollTop: number,
323 viewportHeight: number,
324 ): DiagramItem[] {
325 const visibleStart = scrollTop;
326 const visibleEnd = scrollTop + viewportHeight;
327 const preloadStart = Math.max(0, scrollTop - PREVIEW_LOOKBEHIND_PX);
328 const preloadEnd = visibleEnd + PREVIEW_LOOKAHEAD_PX;
329 const visibleItems: DiagramItem[] = [];
330 const nearbyCandidates: PreviewCandidate<DiagramItem>[] = [];
331 let top = 0;
332
333 for (const row of rows) {
334 const rowHeight = row.height + ROW_GAP;
335 const rowBottom = top + rowHeight;
336 const isCardRow = row.type === "cards";
337 const isVisible = rowBottom >= visibleStart && top <= visibleEnd;
338 const isNearViewport = rowBottom >= preloadStart && top <= preloadEnd;
339
340 if (isCardRow && isVisible) {
341 visibleItems.push(...row.items);
342 } else if (isCardRow && isNearViewport) {
343 const distance =
344 rowBottom < visibleStart ? visibleStart - rowBottom : top - visibleEnd;
345
346 for (const item of row.items) {
347 nearbyCandidates.push({ distance, item });
348 }
349 }
350
351 top += rowHeight;
352 }
353
354 return [
355 ...visibleItems,
356 ...nearbyCandidates
357 .sort((left, right) => left.distance - right.distance)
358 .map((candidate) => candidate.item),
359 ];
360 }
361
362 function getActiveCategory(
363 rows: VirtualDiagramRow[],
364 scrollTop: number,
365 ): { row: VirtualHeaderRow; top: number } | null {
366 let top = 0;
367 let activeHeader: { row: VirtualHeaderRow; top: number } | null = null;
368
369 for (const row of rows) {
370 if (top > scrollTop + HEADER_ROW_HEIGHT) {
371 break;
372 }
373
374 if (row.type === "header") {
375 activeHeader = { row, top };
376 }
377 top += row.height + ROW_GAP;
378 }
379
380 return activeHeader;
381 }
382
383 export function DiagramPanel({ isLoaded }: { isLoaded: boolean }) {
384 const paletteDropTarget = usePresentationState((s) => s.paletteDropTarget);
385 const currentSlideId = usePresentationState((s) => s.currentSlideId);
386 const setPaletteDropTarget = usePresentationState(
387 (s) => s.setPaletteDropTarget,
388 );
389 const updateSlide = usePresentationState((s) => s.updateSlide);
390 const editor = useEditorRef<MyEditor>(currentSlideId ?? undefined);
391
392 const insertFocusedItem = useCallback(
393 (item: DiagramItem) => {
394 if (!currentSlideId) return;
395
396 const insertedElement = replaceFocusedEmptyParagraph(editor, item.node);
397 const insertedElementId = getElementId(insertedElement ?? undefined);
398
399 if (!insertedElementId) return;
400
401 const insertedEntry = editor.api.node({ id: insertedElementId, at: [] });
402 if (insertedEntry) {
403 updateDroppedElementAfterDrop(editor, insertedEntry[1]);
404 }
405
406 updateSlide(currentSlideId, {
407 content: editor.children as PlateSlide["content"],
408 });
409
410 const updatedEntry = editor.api.node({ id: insertedElementId, at: [] });
411 const [updatedElement] = updatedEntry ?? [];
412
413 setPaletteDropTarget({
414 editorId: currentSlideId,
415 elementId: insertedElementId,
416 itemKey: item.key,
417 source: "diagrams",
418 mutableSignature: updatedElement
419 ? getPaletteMutableSignature(updatedElement)
420 : undefined,
421 });
422 },
423 [currentSlideId, editor, setPaletteDropTarget, updateSlide],
424 );
425
426 if (!isLoaded) {
427 return (
428 <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">
429 <div className="grid grid-cols-2 gap-3">
430 {Array.from({ length: 12 }).map((_, index) => (
431 <div key={index} className="rounded-md border p-2">
432 <div className="aspect-video w-full rounded-sm bg-muted/30">
433 <Skeleton className="h-full w-full rounded-sm" />
434 </div>
435 <div className="mt-1.5 flex items-center gap-1 px-0.5">
436 <div className="size-3 shrink-0 animate-pulse rounded-full bg-muted" />
437 <Skeleton className="h-3 w-20" />
438 </div>
439 </div>
440 ))}
441 </div>
442 </div>
443 );
444 }
445
446 if (paletteDropTarget?.source === "diagrams") {
447 return <TrackedDiagramPanel paletteDropTarget={paletteDropTarget} />;
448 }
449
450 return <DiagramPanelContent insertFocusedItem={insertFocusedItem} />;
451 }
452
453 function TrackedDiagramPanel({
454 paletteDropTarget,
455 }: {
456 paletteDropTarget: PaletteDropTarget;
457 }) {
458 const setPaletteDropTarget = usePresentationState(
459 (s) => s.setPaletteDropTarget,
460 );
461 const updateSlide = usePresentationState((s) => s.updateSlide);
462 const editor = useEditorRef<MyEditor>(paletteDropTarget.editorId);
463
464 const replaceTrackedDrop = useCallback(
465 (item: DiagramItem) => {
466 const replaced = replaceElementById(
467 editor,
468 paletteDropTarget.elementId,
469 item.node,
470 paletteDropTarget.mutableSignature,
471 );
472
473 if (!replaced) {
474 setPaletteDropTarget(null);
475 return;
476 }
477
478 updateSlide(paletteDropTarget.editorId, {
479 content: editor.children as PlateSlide["content"],
480 });
481
482 const updatedEntry = editor.api.node({
483 id: paletteDropTarget.elementId,
484 at: [],
485 });
486 const [updatedElement] = updatedEntry ?? [];
487
488 setPaletteDropTarget({
489 ...paletteDropTarget,
490 itemKey: item.key,
491 mutableSignature: updatedElement
492 ? getPaletteMutableSignature(updatedElement)
493 : undefined,
494 });
495 },
496 [editor, paletteDropTarget, setPaletteDropTarget, updateSlide],
497 );
498
499 const initialSelectedIndex = Math.max(
500 ALL_DIAGRAM_ITEMS.findIndex(
501 (item) => item.key === paletteDropTarget.itemKey,
502 ),
503 0,
504 );
505
506 return (
507 <DiagramPanelContent
508 key={paletteDropTarget.elementId}
509 initialSelectedIndex={initialSelectedIndex}
510 replaceTrackedDrop={replaceTrackedDrop}
511 />
512 );
513 }
514
515 function DiagramPanelContent({
516 initialSelectedIndex = 0,
517 insertFocusedItem,
518 replaceTrackedDrop,
519 }: {
520 initialSelectedIndex?: number;
521 insertFocusedItem?: (item: DiagramItem) => void;
522 replaceTrackedDrop?: (item: DiagramItem) => void;
523 }) {
524 const [selectedIndex, setSelectedIndex] = useState(initialSelectedIndex);
525 const [searchQuery, setSearchQuery] = useState("");
526 const [collapsedCategoryKeys, setCollapsedCategoryKeys] = useState<
527 Set<string>
528 >(() => new Set());
529 const cardRefs = useRef<Array<HTMLButtonElement | null>>([]);
530 const applyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
531 const { resolvedTheme } = usePresentationTheme();
532 const isDark = resolvedTheme === "dark";
533 const presentationTheme = usePresentationState((state) => state.theme);
534 const customThemeData = usePresentationState(
535 (state) => state.customThemeData,
536 );
537 const themeColors = useMemo<InfographicPaletteThemeColors | null>(
538 () =>
539 resolvePresentationThemeData({
540 customThemeData,
541 theme: presentationTheme,
542 })?.colors ?? null,
543 [customThemeData, presentationTheme],
544 );
545 const filteredCategories = useMemo(
546 () =>
547 diagramCategories
548 .map((category) => ({
549 ...category,
550 items: category.items.filter((item) => {
551 return matchesPanelSearch(searchQuery, [
552 item.label,
553 item.key,
554 item.templateId,
555 item.categoryName,
556 item.categoryKey,
557 ]);
558 }),
559 }))
560 .filter((category) => category.items.length > 0),
561 [searchQuery],
562 );
563 const visibleDiagramItems = useMemo(
564 () => filteredCategories.flatMap((category) => category.items),
565 [filteredCategories],
566 );
567 const virtualRows = useMemo(
568 () => buildVirtualRows(filteredCategories, collapsedCategoryKeys),
569 [collapsedCategoryKeys, filteredCategories],
570 );
571 const itemIndexByKey = useMemo(
572 () => new Map(visibleDiagramItems.map((item, index) => [item.key, index])),
573 [visibleDiagramItems],
574 );
575 const [scrollRange, setScrollRange] = useState<ScrollListRange>({
576 scrollTop: 0,
577 viewportHeight: 0,
578 });
579 const requestedPreviewItems = useMemo(
580 () =>
581 getRequestedPreviewItems(
582 virtualRows,
583 scrollRange.scrollTop,
584 scrollRange.viewportHeight,
585 ),
586 [scrollRange.scrollTop, scrollRange.viewportHeight, virtualRows],
587 );
588 const previewCache = useDiagramPreviewCache(
589 isDark,
590 themeColors,
591 requestedPreviewItems,
592 );
593 const activeCategory = getActiveCategory(virtualRows, scrollRange.scrollTop);
594 const shouldShowStickyCategory =
595 activeCategory != null && activeCategory.top < scrollRange.scrollTop;
596 const scrollListKey = useMemo(
597 () => [searchQuery, ...[...collapsedCategoryKeys].sort()].join("|"),
598 [collapsedCategoryKeys, searchQuery],
599 );
600
601 const toggleCategory = useCallback((categoryKey: string) => {
602 setCollapsedCategoryKeys((currentKeys) => {
603 const nextKeys = new Set(currentKeys);
604
605 if (nextKeys.has(categoryKey)) {
606 nextKeys.delete(categoryKey);
607 } else {
608 nextKeys.add(categoryKey);
609 }
610
611 return nextKeys;
612 });
613 }, []);
614
615 const focusCard = useCallback((index: number) => {
616 cardRefs.current[index]?.focus();
617 }, []);
618
619 useEffect(() => {
620 window.requestAnimationFrame(() => {
621 focusCard(initialSelectedIndex);
622 });
623 }, [focusCard, initialSelectedIndex]);
624
625 useEffect(() => {
626 setSelectedIndex((currentIndex) =>
627 visibleDiagramItems.length === 0
628 ? 0
629 : Math.min(currentIndex, visibleDiagramItems.length - 1),
630 );
631 }, [visibleDiagramItems.length]);
632
633 useEffect(
634 () => () => {
635 if (applyTimeoutRef.current) {
636 clearTimeout(applyTimeoutRef.current);
637 }
638 },
639 [],
640 );
641
642 const commitSelection = useCallback(
643 (index: number) => {
644 const item = visibleDiagramItems[index];
645
646 if (!item) return;
647
648 if (replaceTrackedDrop) {
649 replaceTrackedDrop(item);
650 return;
651 }
652
653 insertFocusedItem?.(item);
654 },
655 [insertFocusedItem, replaceTrackedDrop, visibleDiagramItems],
656 );
657
658 const selectItem = useCallback(
659 (index: number) => {
660 if (applyTimeoutRef.current) {
661 clearTimeout(applyTimeoutRef.current);
662 applyTimeoutRef.current = null;
663 }
664
665 setSelectedIndex(index);
666 commitSelection(index);
667 },
668 [commitSelection],
669 );
670
671 const scheduleSelectionCommit = useCallback(
672 (index: number) => {
673 if (applyTimeoutRef.current) {
674 clearTimeout(applyTimeoutRef.current);
675 }
676
677 applyTimeoutRef.current = setTimeout(() => {
678 applyTimeoutRef.current = null;
679 commitSelection(index);
680
681 window.requestAnimationFrame(() => {
682 focusCard(index);
683 });
684 }, KEYBOARD_APPLY_DELAY_MS);
685 },
686 [commitSelection, focusCard],
687 );
688
689 const moveSelection = useCallback(
690 (nextIndex: number) => {
691 const boundedIndex = Math.min(
692 Math.max(nextIndex, 0),
693 visibleDiagramItems.length - 1,
694 );
695
696 if (boundedIndex < 0) return;
697
698 setSelectedIndex(boundedIndex);
699 focusCard(boundedIndex);
700 if (replaceTrackedDrop) {
701 scheduleSelectionCommit(boundedIndex);
702 }
703 },
704 [
705 focusCard,
706 replaceTrackedDrop,
707 scheduleSelectionCommit,
708 visibleDiagramItems.length,
709 ],
710 );
711
712 const handleCardKeyDown = useCallback(
713 (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
714 const columns = 2;
715
716 switch (event.key) {
717 case "ArrowLeft":
718 event.preventDefault();
719 event.stopPropagation();
720 moveSelection(index - 1);
721 break;
722 case "ArrowRight":
723 event.preventDefault();
724 event.stopPropagation();
725 moveSelection(index + 1);
726 break;
727 case "ArrowUp":
728 event.preventDefault();
729 event.stopPropagation();
730 moveSelection(index - columns);
731 break;
732 case "ArrowDown":
733 event.preventDefault();
734 event.stopPropagation();
735 moveSelection(index + columns);
736 break;
737 case "Home":
738 event.preventDefault();
739 event.stopPropagation();
740 moveSelection(0);
741 break;
742 case "End":
743 event.preventDefault();
744 event.stopPropagation();
745 moveSelection(visibleDiagramItems.length - 1);
746 break;
747 case "Enter":
748 case " ":
749 event.preventDefault();
750 event.stopPropagation();
751 selectItem(index);
752 break;
753 }
754 },
755 [moveSelection, selectItem, visibleDiagramItems.length],
756 );
757
758 const renderDiagramRow = useCallback(
759 ({ item: row }: { item: VirtualDiagramRow }) =>
760 row.type === "header" ? (
761 <DiagramCategoryTrigger row={row} onToggle={toggleCategory} />
762 ) : (
763 <div className="grid h-full grid-cols-2 gap-3 px-4 py-2">
764 {row.items.map((item) => {
765 const index = itemIndexByKey.get(item.key);
766 if (index === undefined) return null;
767
768 const cacheKey = getDiagramPreviewCacheKey(
769 item,
770 isDark,
771 themeColors,
772 );
773 const previewMarkup = previewCache.markupByKey.get(cacheKey);
774 const hasPreviewError = previewCache.failedKeys.has(cacheKey);
775
776 return (
777 <DiagramCard
778 key={item.key}
779 item={item}
780 previewMarkup={previewMarkup}
781 hasPreviewError={hasPreviewError}
782 refCallback={(node) => {
783 cardRefs.current[index] = node;
784 }}
785 isSelected={selectedIndex === index}
786 tabIndex={selectedIndex === index ? 0 : -1}
787 onClick={() => selectItem(index)}
788 onFocus={() => setSelectedIndex(index)}
789 onKeyDown={(event) => handleCardKeyDown(event, index)}
790 />
791 );
792 })}
793 </div>
794 ),
795 [
796 handleCardKeyDown,
797 isDark,
798 itemIndexByKey,
799 previewCache.failedKeys,
800 previewCache.markupByKey,
801 selectItem,
802 selectedIndex,
803 themeColors,
804 toggleCategory,
805 ],
806 );
807
808 return (
809 <div className="flex h-full flex-col overflow-hidden">
810 <PanelSearchFilter
811 onQueryChange={setSearchQuery}
812 placeholder="Search diagrams..."
813 query={searchQuery}
814 />
815 <div className="relative min-h-0 flex-1">
816 {visibleDiagramItems.length > 0 ? (
817 <>
818 {shouldShowStickyCategory && (
819 <div className="absolute top-0 right-0 left-0 z-20 border-b bg-background/95 backdrop-blur">
820 <DiagramCategoryTrigger
821 row={activeCategory.row}
822 onToggle={toggleCategory}
823 sticky
824 />
825 </div>
826 )}
827
828 <ScrollList
829 key={scrollListKey}
830 items={virtualRows}
831 getItemKey={(row) => row.key}
832 getItemHeight={(row) => row.height}
833 gap={ROW_GAP}
834 overscan={VIRTUAL_ROW_OVERSCAN}
835 paddingBottom={20}
836 onRangeChange={(range) => {
837 setScrollRange((currentRange) =>
838 currentRange.scrollTop === range.scrollTop &&
839 currentRange.viewportHeight === range.viewportHeight
840 ? currentRange
841 : range,
842 );
843 }}
844 renderItem={renderDiagramRow}
845 />
846 </>
847 ) : (
848 <div className="flex h-full items-center justify-center px-6 text-center text-sm text-muted-foreground">
849 No diagrams match your search.
850 </div>
851 )}
852 </div>
853 </div>
854 );
855 }
856
857 function DiagramCategoryTrigger({
858 onToggle,
859 row,
860 sticky = false,
861 }: {
862 onToggle: (categoryKey: string) => void;
863 row: VirtualHeaderRow;
864 sticky?: boolean;
865 }) {
866 return (
867 <button
868 type="button"
869 aria-expanded={!row.collapsed}
870 onClick={() => onToggle(row.categoryKey)}
871 className={cn(
872 "flex h-full w-full items-center justify-between gap-3 border-b bg-background/95 px-4 text-left transition-colors hover:bg-muted/35 focus-visible:bg-muted/50 focus-visible:outline-none",
873 sticky && "h-9 border-b-0",
874 )}
875 >
876 <span className="min-w-0 text-xs font-semibold text-muted-foreground">
877 {row.categoryName}{" "}
878 <span className="font-normal opacity-70">({row.count})</span>
879 </span>
880 <ChevronDown
881 className={cn(
882 "size-4 shrink-0 text-muted-foreground transition-transform",
883 row.collapsed && "-rotate-90",
884 )}
885 />
886 </button>
887 );
888 }
889
890 function DiagramCard({
891 item,
892 previewMarkup,
893 hasPreviewError,
894 refCallback,
895 isSelected,
896 tabIndex,
897 onClick,
898 onFocus,
899 onKeyDown,
900 }: {
901 item: DiagramItem;
902 previewMarkup: string | undefined;
903 hasPreviewError: boolean;
904 refCallback: (node: HTMLButtonElement | null) => void;
905 isSelected: boolean;
906 tabIndex: number;
907 onClick: () => void;
908 onFocus: () => void;
909 onKeyDown: (event: KeyboardEvent<HTMLButtonElement>) => void;
910 }) {
911 const [{ isDragging }, drag] = useDrag(() => ({
912 type: DRAG_ITEM_BLOCK,
913 item: {
914 id: `external-${item.key}`,
915 element: item.node,
916 itemKey: item.key,
917 sourcePanel: "diagrams" as const,
918 },
919 collect: (monitor) => ({ isDragging: monitor.isDragging() }),
920 }));
921
922 return (
923 <button
924 type="button"
925 ref={(el) => {
926 refCallback(el);
927 if (el) drag(el);
928 }}
929 aria-label={item.label}
930 aria-pressed={isSelected}
931 tabIndex={tabIndex}
932 data-panel-arrow-target="true"
933 onClick={onClick}
934 onFocus={onFocus}
935 onKeyDown={onKeyDown}
936 className={cn(
937 "group h-full cursor-grab rounded-md border p-2 transition hover:border-primary hover:shadow focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none active:cursor-grabbing",
938 isSelected && "border-primary ring-1 ring-primary",
939 isDragging && "opacity-50",
940 )}
941 >
942 <DiagramPreview
943 previewMarkup={previewMarkup}
944 hasPreviewError={hasPreviewError}
945 />
946 <div className="mt-1.5 flex items-start gap-1 px-0.5">
947 <GripVertical className="mt-0.5 size-3 shrink-0 text-muted-foreground/50 transition-colors group-hover:text-muted-foreground" />
948 <span className="line-clamp-2 text-xs leading-snug text-muted-foreground">
949 {item.label}
950 </span>
951 </div>
952 </button>
953 );
954 }
955
956 function DiagramPreview({
957 previewMarkup,
958 hasPreviewError,
959 }: {
960 previewMarkup: string | undefined;
961 hasPreviewError: boolean;
962 }) {
963 const previewRef = useRef<HTMLDivElement | null>(null);
964
965 useEffect(() => {
966 const container = previewRef.current;
967 if (!container) return;
968
969 container.replaceChildren();
970 if (!previewMarkup) return;
971
972 const template = document.createElement("template");
973 template.innerHTML = previewMarkup;
974 container.replaceChildren(template.content.cloneNode(true));
975 }, [previewMarkup]);
976
977 return (
978 <div className="pointer-events-none relative aspect-video w-full overflow-hidden rounded-sm border bg-card select-none">
979 {hasPreviewError && (
980 <div className="absolute inset-0 z-10 flex items-center justify-center bg-muted/10 p-2 text-center text-xs text-muted-foreground">
981 Preview unavailable
982 </div>
983 )}
984 <div
985 ref={previewRef}
986 className="h-full w-full p-1.5 [&_svg]:h-full [&_svg]:w-full"
987 />
988 </div>
989 );
990 }
991
991 lines Plain Text