返回 presentation-ai
InfographicEditorControls.tsx
根目录 / src / components / presentation / edit-panel / sections / InfographicEditorControls.tsx
1 "use client";
2
3 import { Check, ChevronDown, Loader2 } from "lucide-react";
4 import { useCallback, useEffect, useMemo, useRef, useState } from "react";
5
6 import {
7 applyThemeToSyntax,
8 changeInfographicTemplate,
9 convertInfographicData,
10 parseInfographicTemplate,
11 type InfographicPaletteThemeColors,
12 } from "@/components/notebook/presentation/editor/utils/infographic-utils";
13 import { PALETTE_DROP_MUTABLE_KEY } from "@/components/notebook/presentation/editor/utils/paletteDrop";
14 import { ScrollList, type ScrollListRange } from "@/components/ui/scroll-list";
15 import { INFOGRAPHIC_CATEGORIES } from "@/constants/antv-templates";
16 import { renderInfographicPreviewHtml } from "@/hooks/presentation/infographic/infographic-preview-renderer";
17 import { resolvePresentationThemeData } from "@/lib/presentation/theme-resolution";
18 import { cn } from "@/lib/utils";
19 import { usePresentationState } from "@/states/presentation-state";
20 import { usePresentationTheme } from "../../providers/PresentationThemeProvider";
21 import { PanelSearchFilter } from "./PanelSearchFilter";
22 import { matchesPanelSearch } from "./PanelSearchFilter";
23
24 const VIRTUAL_ROW_OVERSCAN = 1_200;
25 const PREVIEW_LOOKAHEAD_PX = 3_600;
26 const PREVIEW_LOOKBEHIND_PX = 1_000;
27 const HEADER_ROW_HEIGHT = 37;
28 const CARD_ROW_HEIGHT = 159;
29 const ROW_GAP = 0;
30
31 type InfographicPreviewCache = {
32 failedKeys: Set<string>;
33 markupByKey: Map<string, string>;
34 };
35
36 type InfographicPreviewRequest = {
37 currentSyntax: string;
38 currentTemplate: string | null;
39 isDark: boolean;
40 templates: string[];
41 themeColors: InfographicPaletteThemeColors | null;
42 };
43
44 type PreviewCandidate<TItem> = {
45 distance: number;
46 item: TItem;
47 };
48
49 type VirtualHeaderRow = {
50 categoryKey: string;
51 categoryName: string;
52 collapsed: boolean;
53 count: number;
54 height: number;
55 key: string;
56 type: "header";
57 };
58
59 type VirtualCardRow = {
60 categoryKey: string;
61 categoryName: string;
62 height: number;
63 key: string;
64 templates: string[];
65 type: "cards";
66 };
67
68 type VirtualTemplateRow = VirtualHeaderRow | VirtualCardRow;
69 type TemplateCategory = (typeof INFOGRAPHIC_CATEGORIES)[number];
70
71 const infographicPreviewMarkupCache = new Map<string, string>();
72 const infographicPreviewFailureCache = new Set<string>();
73 const infographicPreviewPromiseCache = new Map<string, Promise<void>>();
74
75 function getTemplateLabel(templateId: string): string {
76 return templateId
77 .split("-")
78 .filter(Boolean)
79 .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
80 .join(" ");
81 }
82
83 function getInfographicPreviewCacheKey(
84 templateId: string,
85 currentSyntax: string,
86 currentTemplate: string | null,
87 isDark: boolean,
88 themeColors: InfographicPaletteThemeColors | null,
89 ): string {
90 return [
91 templateId,
92 currentTemplate ?? "",
93 currentSyntax,
94 isDark ? "dark" : "light",
95 themeColors?.primary ?? "",
96 themeColors?.accent ?? "",
97 themeColors?.smartLayout ?? "",
98 themeColors?.text ?? "",
99 themeColors?.heading ?? "",
100 themeColors?.cardBackground ?? "",
101 ].join("|");
102 }
103
104 async function renderInfographicPreviewMarkup({
105 currentSyntax,
106 currentTemplate,
107 isDark,
108 templateId,
109 themeColors,
110 }: {
111 currentSyntax: string;
112 currentTemplate: string | null;
113 isDark: boolean;
114 templateId: string;
115 themeColors: InfographicPaletteThemeColors | null;
116 }): Promise<string> {
117 if (!currentSyntax || !currentTemplate) {
118 throw new Error(
119 "Infographic preview requires current syntax and template.",
120 );
121 }
122
123 const converted = convertInfographicData(
124 currentSyntax,
125 currentTemplate,
126 templateId,
127 );
128
129 return renderInfographicPreviewHtml(
130 applyThemeToSyntax(converted, isDark, themeColors),
131 );
132 }
133
134 async function ensureInfographicPreviewMarkup(
135 templateId: string,
136 currentSyntax: string,
137 currentTemplate: string | null,
138 isDark: boolean,
139 themeColors: InfographicPaletteThemeColors | null,
140 ): Promise<void> {
141 const cacheKey = getInfographicPreviewCacheKey(
142 templateId,
143 currentSyntax,
144 currentTemplate,
145 isDark,
146 themeColors,
147 );
148
149 if (
150 infographicPreviewMarkupCache.has(cacheKey) ||
151 infographicPreviewFailureCache.has(cacheKey)
152 ) {
153 return;
154 }
155
156 const existingPromise = infographicPreviewPromiseCache.get(cacheKey);
157 if (existingPromise) {
158 await existingPromise;
159 return;
160 }
161
162 const previewPromise = renderInfographicPreviewMarkup({
163 currentSyntax,
164 currentTemplate,
165 isDark,
166 templateId,
167 themeColors,
168 })
169 .then((markup) => {
170 infographicPreviewMarkupCache.set(cacheKey, markup);
171 })
172 .catch((error: unknown) => {
173 console.error("Failed to render infographic preview:", error);
174 infographicPreviewFailureCache.add(cacheKey);
175 })
176 .finally(() => {
177 infographicPreviewPromiseCache.delete(cacheKey);
178 });
179
180 infographicPreviewPromiseCache.set(cacheKey, previewPromise);
181 await previewPromise;
182 }
183
184 function getPendingPreviewTemplate(
185 templates: string[],
186 currentSyntax: string,
187 currentTemplate: string | null,
188 isDark: boolean,
189 themeColors: InfographicPaletteThemeColors | null,
190 ): string | undefined {
191 return templates.find((templateId) => {
192 const cacheKey = getInfographicPreviewCacheKey(
193 templateId,
194 currentSyntax,
195 currentTemplate,
196 isDark,
197 themeColors,
198 );
199 return (
200 !infographicPreviewMarkupCache.has(cacheKey) &&
201 !infographicPreviewFailureCache.has(cacheKey) &&
202 !infographicPreviewPromiseCache.has(cacheKey)
203 );
204 });
205 }
206
207 function useInfographicPreviewCache(
208 isDark: boolean,
209 themeColors: InfographicPaletteThemeColors | null,
210 requestedTemplates: string[],
211 currentSyntax: string,
212 currentTemplate: string | null,
213 ): InfographicPreviewCache {
214 const [, setCacheVersion] = useState(0);
215 const isMountedRef = useRef(false);
216 const isPreloadingRef = useRef(false);
217 const latestRequestRef = useRef<InfographicPreviewRequest>({
218 currentSyntax,
219 currentTemplate,
220 isDark,
221 templates: requestedTemplates,
222 themeColors,
223 });
224
225 useEffect(() => {
226 isMountedRef.current = true;
227
228 return () => {
229 isMountedRef.current = false;
230 };
231 }, []);
232
233 useEffect(() => {
234 latestRequestRef.current = {
235 currentSyntax,
236 currentTemplate,
237 isDark,
238 templates: requestedTemplates,
239 themeColors,
240 };
241 if (!currentSyntax || !currentTemplate) return;
242
243 async function preloadRequestedPreviews() {
244 if (isPreloadingRef.current) return;
245
246 isPreloadingRef.current = true;
247
248 try {
249 const request = latestRequestRef.current;
250 const templateId = getPendingPreviewTemplate(
251 request.templates,
252 request.currentSyntax,
253 request.currentTemplate,
254 request.isDark,
255 request.themeColors,
256 );
257
258 if (templateId) {
259 await ensureInfographicPreviewMarkup(
260 templateId,
261 request.currentSyntax,
262 request.currentTemplate,
263 request.isDark,
264 request.themeColors,
265 );
266
267 if (isMountedRef.current) {
268 setCacheVersion((version) => version + 1);
269 }
270 }
271 } finally {
272 isPreloadingRef.current = false;
273 const request = latestRequestRef.current;
274
275 if (
276 isMountedRef.current &&
277 getPendingPreviewTemplate(
278 request.templates,
279 request.currentSyntax,
280 request.currentTemplate,
281 request.isDark,
282 request.themeColors,
283 )
284 ) {
285 void preloadRequestedPreviews();
286 }
287
288 isPreloadingRef.current = false;
289 const latestRequest = latestRequestRef.current;
290
291 if (
292 isMountedRef.current &&
293 getPendingPreviewTemplate(
294 latestRequest.templates,
295 latestRequest.currentSyntax,
296 latestRequest.currentTemplate,
297 latestRequest.isDark,
298 latestRequest.themeColors,
299 )
300 ) {
301 void preloadRequestedPreviews();
302 }
303 }
304 }
305
306 void preloadRequestedPreviews();
307 }, [currentSyntax, currentTemplate, isDark, requestedTemplates, themeColors]);
308
309 const currentFailedKeys = new Set<string>();
310 const markupByKey = new Map<string, string>();
311
312 for (const category of INFOGRAPHIC_CATEGORIES) {
313 for (const templateId of category.templates) {
314 const cacheKey = getInfographicPreviewCacheKey(
315 templateId,
316 currentSyntax,
317 currentTemplate,
318 isDark,
319 themeColors,
320 );
321 const markup = infographicPreviewMarkupCache.get(cacheKey);
322
323 if (markup) {
324 markupByKey.set(cacheKey, markup);
325 }
326 if (infographicPreviewFailureCache.has(cacheKey)) {
327 currentFailedKeys.add(cacheKey);
328 }
329 }
330 }
331
332 return {
333 failedKeys: currentFailedKeys,
334 markupByKey,
335 };
336 }
337
338 function buildVirtualRows(
339 categories: TemplateCategory[],
340 collapsedCategoryKeys: ReadonlySet<string>,
341 ): VirtualTemplateRow[] {
342 return categories.flatMap<VirtualTemplateRow>((category) => {
343 const cardRows: VirtualCardRow[] = [];
344 const collapsed = collapsedCategoryKeys.has(category.key);
345
346 if (!collapsed) {
347 for (let index = 0; index < category.templates.length; index += 2) {
348 cardRows.push({
349 type: "cards",
350 key: `${category.key}-cards-${index}`,
351 categoryKey: category.key,
352 categoryName: category.name,
353 templates: category.templates.slice(index, index + 2),
354 height: CARD_ROW_HEIGHT,
355 });
356 }
357 }
358
359 return [
360 {
361 type: "header",
362 key: `${category.key}-header`,
363 categoryKey: category.key,
364 categoryName: category.name,
365 collapsed,
366 count: category.templates.length,
367 height: HEADER_ROW_HEIGHT,
368 },
369 ...cardRows,
370 ];
371 });
372 }
373
374 function getRequestedPreviewTemplates(
375 rows: VirtualTemplateRow[],
376 scrollTop: number,
377 viewportHeight: number,
378 ): string[] {
379 const visibleStart = scrollTop;
380 const visibleEnd = scrollTop + viewportHeight;
381 const preloadStart = Math.max(0, scrollTop - PREVIEW_LOOKBEHIND_PX);
382 const preloadEnd = visibleEnd + PREVIEW_LOOKAHEAD_PX;
383 const visibleTemplates: string[] = [];
384 const nearbyCandidates: PreviewCandidate<string>[] = [];
385 let top = 0;
386
387 for (const row of rows) {
388 const rowHeight = row.height + ROW_GAP;
389 const rowBottom = top + rowHeight;
390 const isCardRow = row.type === "cards";
391 const isVisible = rowBottom >= visibleStart && top <= visibleEnd;
392 const isNearViewport = rowBottom >= preloadStart && top <= preloadEnd;
393
394 if (isCardRow && isVisible) {
395 visibleTemplates.push(...row.templates);
396 } else if (isCardRow && isNearViewport) {
397 const distance =
398 rowBottom < visibleStart ? visibleStart - rowBottom : top - visibleEnd;
399
400 for (const templateId of row.templates) {
401 nearbyCandidates.push({ distance, item: templateId });
402 }
403 }
404
405 top += rowHeight;
406 }
407
408 return [
409 ...visibleTemplates,
410 ...nearbyCandidates
411 .sort((left, right) => left.distance - right.distance)
412 .map((candidate) => candidate.item),
413 ];
414 }
415
416 function getActiveCategory(
417 rows: VirtualTemplateRow[],
418 scrollTop: number,
419 ): { row: VirtualHeaderRow; top: number } | null {
420 let top = 0;
421 let activeHeader: { row: VirtualHeaderRow; top: number } | null = null;
422
423 for (const row of rows) {
424 if (top > scrollTop + HEADER_ROW_HEIGHT) {
425 break;
426 }
427
428 if (row.type === "header") {
429 activeHeader = { row, top };
430 }
431 top += row.height + ROW_GAP;
432 }
433
434 return activeHeader;
435 }
436
437 export function InfographicEditorControls() {
438 const { resolvedTheme } = usePresentationTheme();
439 const isDark = resolvedTheme === "dark";
440 const presentationTheme = usePresentationState((state) => state.theme);
441 const customThemeData = usePresentationState(
442 (state) => state.customThemeData,
443 );
444 const themeColors = useMemo<InfographicPaletteThemeColors | null>(() => {
445 return (
446 resolvePresentationThemeData({
447 customThemeData,
448 theme: presentationTheme,
449 })?.colors ?? null
450 );
451 }, [customThemeData, presentationTheme]);
452
453 const boundUpdateElement = usePresentationState((s) => s.boundUpdateElement);
454 const setPaletteDropTarget = usePresentationState(
455 (s) => s.setPaletteDropTarget,
456 );
457
458 const [isConverting, setIsConverting] = useState(false);
459 const [scrollRange, setScrollRange] = useState<ScrollListRange>({
460 scrollTop: 0,
461 viewportHeight: 0,
462 });
463 const [collapsedCategoryKeys, setCollapsedCategoryKeys] = useState<
464 Set<string>
465 >(() => new Set());
466 const [searchQuery, setSearchQuery] = useState("");
467
468 // Track which template is currently applied (updates on each conversion)
469 const [appliedTemplate, setAppliedTemplate] = useState<string | null>(null);
470
471 // Committed syntax: captured once when the panel opens.
472 // All previews and conversions derive from this base syntax,
473 // so we never re-render previews after each conversion.
474 const [committedSyntax, setCommittedSyntax] = useState<string>("");
475 const committedTemplate = useMemo(
476 () => parseInfographicTemplate(committedSyntax),
477 [committedSyntax],
478 );
479 const hasCommitted = useRef(false);
480
481 const currentSlideId = usePresentationState((s) => s.currentSlideId);
482 const slides = usePresentationState((s) => s.slides);
483
484 // Find and commit the syntax once when the panel first opens
485 useEffect(() => {
486 if (hasCommitted.current || !currentSlideId) return;
487
488 const slide = slides.find((s) => s.id === currentSlideId);
489 if (!slide?.content) return;
490
491 const findInfographicSyntax = (nodes: unknown[]): string | null => {
492 for (const node of nodes) {
493 const n = node as Record<string, unknown>;
494 if (n.type === "antv-infographic" && typeof n.syntax === "string") {
495 return n.syntax;
496 }
497 if (Array.isArray(n.children)) {
498 const found = findInfographicSyntax(n.children as unknown[]);
499 if (found) return found;
500 }
501 }
502 return null;
503 };
504
505 const syntax = findInfographicSyntax(slide.content as unknown[]);
506 if (syntax) {
507 setCommittedSyntax(syntax);
508 const template = parseInfographicTemplate(syntax);
509 setAppliedTemplate(template);
510 hasCommitted.current = true;
511 }
512 }, [currentSlideId, slides]);
513
514 const filteredCategories = useMemo(
515 () =>
516 INFOGRAPHIC_CATEGORIES.map((category) => ({
517 ...category,
518 templates: category.templates.filter((templateId) => {
519 return matchesPanelSearch(searchQuery, [
520 getTemplateLabel(templateId),
521 templateId,
522 category.name,
523 category.key,
524 ]);
525 }),
526 })).filter((category) => category.templates.length > 0),
527 [searchQuery],
528 );
529 const visibleTemplateCount = useMemo(
530 () =>
531 filteredCategories.reduce(
532 (total, category) => total + category.templates.length,
533 0,
534 ),
535 [filteredCategories],
536 );
537 const virtualRows = useMemo(
538 () => buildVirtualRows(filteredCategories, collapsedCategoryKeys),
539 [collapsedCategoryKeys, filteredCategories],
540 );
541 const requestedTemplates = useMemo(
542 () =>
543 getRequestedPreviewTemplates(
544 virtualRows,
545 scrollRange.scrollTop,
546 scrollRange.viewportHeight,
547 ),
548 [scrollRange.scrollTop, scrollRange.viewportHeight, virtualRows],
549 );
550 const previewCache = useInfographicPreviewCache(
551 isDark,
552 themeColors,
553 requestedTemplates,
554 committedSyntax,
555 committedTemplate,
556 );
557 const activeCategory = getActiveCategory(virtualRows, scrollRange.scrollTop);
558 const shouldShowStickyCategory =
559 activeCategory != null && activeCategory.top < scrollRange.scrollTop;
560 const scrollListKey = useMemo(
561 () => [searchQuery, ...[...collapsedCategoryKeys].sort()].join("|"),
562 [collapsedCategoryKeys, searchQuery],
563 );
564
565 const handleTemplateChange = useCallback(
566 (newTemplateId: string) => {
567 if (
568 !committedSyntax ||
569 !boundUpdateElement ||
570 newTemplateId === appliedTemplate
571 )
572 return;
573
574 setIsConverting(true);
575
576 // Always convert from the committed (original) syntax
577 const newSyntax = committedTemplate
578 ? convertInfographicData(
579 committedSyntax,
580 committedTemplate,
581 newTemplateId,
582 )
583 : changeInfographicTemplate(committedSyntax, newTemplateId);
584
585 setPaletteDropTarget(null);
586 boundUpdateElement({
587 syntax: newSyntax,
588 data: undefined,
589 [PALETTE_DROP_MUTABLE_KEY]: false,
590 });
591 setAppliedTemplate(newTemplateId);
592
593 setTimeout(() => {
594 setIsConverting(false);
595 }, 500);
596 },
597 [
598 committedSyntax,
599 committedTemplate,
600 appliedTemplate,
601 boundUpdateElement,
602 setPaletteDropTarget,
603 ],
604 );
605
606 const toggleCategory = useCallback((categoryKey: string) => {
607 setCollapsedCategoryKeys((currentKeys) => {
608 const nextKeys = new Set(currentKeys);
609
610 if (nextKeys.has(categoryKey)) {
611 nextKeys.delete(categoryKey);
612 } else {
613 nextKeys.add(categoryKey);
614 }
615
616 return nextKeys;
617 });
618 }, []);
619
620 if (!boundUpdateElement) {
621 return (
622 <div className="flex h-full flex-col items-center justify-center p-6 text-center">
623 <p className="text-sm text-muted-foreground">
624 Select an infographic element to edit it.
625 </p>
626 </div>
627 );
628 }
629
630 const renderTemplateRow = ({ item: row }: { item: VirtualTemplateRow }) =>
631 row.type === "header" ? (
632 <InfographicCategoryTrigger row={row} onToggle={toggleCategory} />
633 ) : (
634 <div className="grid h-full grid-cols-2 gap-3 px-4 py-2">
635 {row.templates.map((templateId) => {
636 const cacheKey = getInfographicPreviewCacheKey(
637 templateId,
638 committedSyntax,
639 committedTemplate,
640 isDark,
641 themeColors,
642 );
643
644 return (
645 <InfographicCard
646 key={templateId}
647 templateId={templateId}
648 isSelected={templateId === appliedTemplate}
649 previewMarkup={previewCache.markupByKey.get(cacheKey)}
650 hasPreviewError={previewCache.failedKeys.has(cacheKey)}
651 onSelectTemplate={handleTemplateChange}
652 />
653 );
654 })}
655 </div>
656 );
657
658 return (
659 <div className="relative flex h-full flex-col">
660 <PanelSearchFilter
661 onQueryChange={setSearchQuery}
662 placeholder="Search templates..."
663 query={searchQuery}
664 />
665 <div className="relative min-h-0 flex-1">
666 {visibleTemplateCount > 0 ? (
667 <>
668 {isConverting ? (
669 <div className="absolute top-0 right-0 left-0 z-30 flex h-9 items-center justify-center gap-2 border-b bg-background/95 text-sm text-muted-foreground backdrop-blur">
670 <Loader2 className="size-4 animate-spin" />
671 <span>Converting&hellip;</span>
672 </div>
673 ) : shouldShowStickyCategory ? (
674 <div className="absolute top-0 right-0 left-0 z-20 border-b bg-background/95 backdrop-blur">
675 <InfographicCategoryTrigger
676 row={activeCategory.row}
677 onToggle={toggleCategory}
678 sticky
679 />
680 </div>
681 ) : null}
682
683 <ScrollList
684 key={scrollListKey}
685 items={virtualRows}
686 getItemKey={(row) => row.key}
687 getItemHeight={(row) => row.height}
688 gap={ROW_GAP}
689 overscan={VIRTUAL_ROW_OVERSCAN}
690 paddingBottom={20}
691 onRangeChange={(range) => {
692 setScrollRange((currentRange) =>
693 currentRange.scrollTop === range.scrollTop &&
694 currentRange.viewportHeight === range.viewportHeight
695 ? currentRange
696 : range,
697 );
698 }}
699 renderItem={renderTemplateRow}
700 />
701 </>
702 ) : (
703 <div className="flex h-full items-center justify-center px-6 text-center text-sm text-muted-foreground">
704 No infographic templates match your search.
705 </div>
706 )}
707 </div>
708 </div>
709 );
710 }
711
712 function InfographicCategoryTrigger({
713 onToggle,
714 row,
715 sticky = false,
716 }: {
717 onToggle: (categoryKey: string) => void;
718 row: VirtualHeaderRow;
719 sticky?: boolean;
720 }) {
721 return (
722 <button
723 type="button"
724 aria-expanded={!row.collapsed}
725 onClick={() => onToggle(row.categoryKey)}
726 className={cn(
727 "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",
728 sticky && "h-9 border-b-0",
729 )}
730 >
731 <span className="min-w-0 text-xs font-semibold text-muted-foreground">
732 {row.categoryName}{" "}
733 <span className="font-normal opacity-70">({row.count})</span>
734 </span>
735 <ChevronDown
736 className={cn(
737 "size-4 shrink-0 text-muted-foreground transition-transform",
738 row.collapsed && "-rotate-90",
739 )}
740 />
741 </button>
742 );
743 }
744
745 function InfographicCard({
746 templateId,
747 isSelected,
748 previewMarkup,
749 hasPreviewError,
750 onSelectTemplate,
751 }: {
752 templateId: string;
753 isSelected: boolean;
754 previewMarkup: string | undefined;
755 hasPreviewError: boolean;
756 onSelectTemplate: (templateId: string) => void;
757 }) {
758 return (
759 <button
760 type="button"
761 onClick={() => onSelectTemplate(templateId)}
762 className={cn(
763 "group relative h-full rounded-md border p-2 text-left transition hover:border-primary hover:shadow focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none",
764 isSelected && "border-primary ring-1 ring-primary",
765 )}
766 >
767 <InfographicPreview
768 previewMarkup={previewMarkup}
769 hasPreviewError={hasPreviewError}
770 />
771 <div className="mt-1.5 flex items-start gap-1 px-0.5">
772 <span className="line-clamp-2 text-xs leading-snug text-muted-foreground">
773 {getTemplateLabel(templateId)}
774 </span>
775 </div>
776 {isSelected && (
777 <div className="absolute top-3 right-3 flex size-5 items-center justify-center rounded-full bg-primary text-primary-foreground shadow">
778 <Check className="size-3" />
779 </div>
780 )}
781 </button>
782 );
783 }
784
785 function InfographicPreview({
786 previewMarkup,
787 hasPreviewError,
788 }: {
789 previewMarkup: string | undefined;
790 hasPreviewError: boolean;
791 }) {
792 const previewRef = useRef<HTMLDivElement | null>(null);
793
794 useEffect(() => {
795 const container = previewRef.current;
796 if (!container) return;
797
798 container.replaceChildren();
799 if (!previewMarkup) return;
800
801 const template = document.createElement("template");
802 template.innerHTML = previewMarkup;
803 container.replaceChildren(template.content.cloneNode(true));
804 }, [previewMarkup]);
805
806 return (
807 <div className="pointer-events-none relative aspect-video w-full overflow-hidden rounded-sm border bg-card select-none">
808 {!previewMarkup && !hasPreviewError && (
809 <div className="absolute inset-0 flex items-center justify-center bg-muted/20">
810 <Loader2 className="size-4 animate-spin text-muted-foreground" />
811 </div>
812 )}
813 {hasPreviewError && (
814 <div className="absolute inset-0 z-10 flex items-center justify-center bg-muted/10 p-2 text-center text-xs text-muted-foreground">
815 Preview unavailable
816 </div>
817 )}
818 <div
819 ref={previewRef}
820 className="h-full w-full p-1.5 [&_svg]:h-full [&_svg]:w-full"
821 />
822 </div>
823 );
824 }
825
825 lines Plain Text