返回 presentation-ai
LayoutPreviewSheet.tsx
根目录 / src / components / presentation / floating-toolbar / LayoutPreviewSheet.tsx
1 "use client";
2
3 import { Replace } from "lucide-react";
4 import { type TElement } from "platejs";
5 import { useEditorRef } from "platejs/react";
6 import * as React from "react";
7
8 import {
9 ARROW_LIST,
10 BEFORE_AFTER_GROUP,
11 BLOCKS,
12 BOX_GROUP,
13 BULLET_GROUP,
14 CIRCULAR_GRID_GROUP,
15 COLUMN_GROUP,
16 COMPARE_GROUP,
17 CONNECTED_CIRCLES_GROUP,
18 CYCLE_GROUP,
19 getAvailableConversionOptions,
20 getOrientationOptions,
21 handleLayoutChange,
22 ICON_LIST,
23 PARENT_CHILD_RELATIONSHIP,
24 PROS_CONS_GROUP,
25 PYRAMID_GROUP,
26 QUOTE_ELEMENT,
27 SEQUENCE_ARROW_GROUP,
28 SLOPE_GROUP,
29 SNAKE_GROUP,
30 STAIRCASE_GROUP,
31 STATS_GROUP,
32 STEPS_GROUP,
33 supportsOrientation,
34 TIMELINE_GROUP,
35 } from "@/components/notebook/presentation/editor/lib";
36 import StaticPresentationEditor from "@/components/notebook/presentation/editor/presentation-editor-static";
37 import { type PlateSlide } from "@/components/notebook/presentation/utils/parser";
38 import { type MyEditor } from "@/components/plate/editor-kit";
39 import { ToolbarButton, ToolbarGroup } from "@/components/plate/ui/toolbar";
40 import { Skeleton } from "@/components/ui/skeleton";
41 import {
42 calculateHeightFromRatio,
43 getSlideBaseWidth,
44 } from "@/config/slideFormats";
45 import { cn } from "@/lib/utils";
46 import {
47 usePresentationState,
48 type LayoutEditorApplyLayout,
49 type LayoutEditorElementSnapshot,
50 } from "@/states/presentation-state";
51 import { PanelSearchFilter } from "../edit-panel/sections/PanelSearchFilter";
52 import { matchesPanelSearch } from "../edit-panel/sections/PanelSearchFilter";
53
54 interface LayoutEditorButtonProps {
55 editorId: string;
56 elementId: string | undefined;
57 element: Record<string, unknown> | undefined;
58 onApplyLayout: LayoutEditorApplyLayout;
59 }
60
61 interface LayoutVariation {
62 id: string;
63 type: string;
64 name: string;
65 element: TElement;
66 additionalData: Record<string, unknown>;
67 }
68
69 interface LayoutVariationSection {
70 title: string;
71 variations: LayoutVariation[];
72 }
73
74 interface PreviewDimensions {
75 width: number;
76 height: number;
77 }
78
79 const KEYBOARD_APPLY_DELAY_MS = 250;
80
81 export function LayoutEditorButton({
82 editorId,
83 elementId,
84 element,
85 onApplyLayout,
86 }: LayoutEditorButtonProps) {
87 const openLayoutEditor = usePresentationState((s) => s.openLayoutEditor);
88 const activeRightPanel = usePresentationState((s) => s.activeRightPanel);
89
90 return (
91 <ToolbarGroup>
92 <ToolbarButton
93 tooltip="Change layout"
94 pressed={activeRightPanel === "layoutEditor"}
95 onClick={() =>
96 openLayoutEditor(
97 editorId,
98 elementId ?? null,
99 (element as LayoutEditorElementSnapshot | undefined) ?? null,
100 onApplyLayout,
101 )
102 }
103 >
104 <Replace className="size-4" />
105 <span>Change</span>
106 </ToolbarButton>
107 </ToolbarGroup>
108 );
109 }
110
111 function ScaledPreview({
112 children,
113 dimensions,
114 }: {
115 children: React.ReactNode;
116 dimensions: PreviewDimensions;
117 }) {
118 const frameRef = React.useRef<HTMLDivElement | null>(null);
119 const [frameWidth, setFrameWidth] = React.useState(0);
120
121 React.useLayoutEffect(() => {
122 if (!frameRef.current) return;
123
124 const updateFrameWidth = () => {
125 if (!frameRef.current) return;
126 setFrameWidth(frameRef.current.clientWidth);
127 };
128
129 const resizeObserver = new ResizeObserver(updateFrameWidth);
130 resizeObserver.observe(frameRef.current);
131 updateFrameWidth();
132
133 return () => resizeObserver.disconnect();
134 }, []);
135
136 const scale = frameWidth > 0 ? frameWidth / dimensions.width : 0;
137
138 return (
139 <div
140 ref={frameRef}
141 className="w-full overflow-hidden rounded-sm bg-muted/30"
142 style={{ aspectRatio: `${dimensions.width} / ${dimensions.height}` }}
143 >
144 <div
145 style={{
146 transform: `scale(${scale})`,
147 transformOrigin: "top left",
148 width: `${dimensions.width}px`,
149 height: `${dimensions.height}px`,
150 }}
151 >
152 {children}
153 </div>
154 </div>
155 );
156 }
157
158 function EditorWrapper({
159 elementNode,
160 previewSlide,
161 dimensions,
162 }: {
163 elementNode: TElement;
164 previewSlide: PlateSlide;
165 dimensions: PreviewDimensions;
166 }) {
167 const [showEditor, setShowEditor] = React.useState(false);
168 const previewId = React.useId();
169
170 React.useEffect(() => {
171 const id = requestAnimationFrame(() => setShowEditor(true));
172 return () => cancelAnimationFrame(id);
173 }, []);
174
175 if (!showEditor) {
176 return (
177 <Skeleton
178 className="w-full"
179 style={{ aspectRatio: `${dimensions.width} / ${dimensions.height}` }}
180 />
181 );
182 }
183
184 return (
185 <ScaledPreview dimensions={dimensions}>
186 <StaticPresentationEditor
187 initialContent={previewSlide}
188 id={`layout-preview-${elementNode.type}-${previewId}`}
189 className="h-full min-h-0! w-full"
190 />
191 </ScaledPreview>
192 );
193 }
194
195 function ElementPreview({
196 variation,
197 isVisible,
198 previewSlide,
199 dimensions,
200 }: {
201 variation: LayoutVariation;
202 isVisible: boolean;
203 previewSlide: PlateSlide;
204 dimensions: PreviewDimensions;
205 }) {
206 if (!isVisible) {
207 return (
208 <Skeleton
209 className="w-full"
210 style={{ aspectRatio: `${dimensions.width} / ${dimensions.height}` }}
211 />
212 );
213 }
214
215 return (
216 <EditorWrapper
217 elementNode={variation.element}
218 previewSlide={previewSlide}
219 dimensions={dimensions}
220 />
221 );
222 }
223
224 function getPreviewDimensions(currentSlide: PlateSlide | undefined) {
225 const formatCategory = currentSlide?.formatCategory ?? "presentation";
226 const widthSize = currentSlide?.width ?? "M";
227 const aspectRatio =
228 currentSlide?.aspectRatio?.type === "fluid" || !currentSlide?.aspectRatio
229 ? { type: "ratio", value: "16:9" }
230 : currentSlide.aspectRatio;
231 const width = getSlideBaseWidth(
232 formatCategory,
233 widthSize as "S" | "M" | "L",
234 aspectRatio,
235 );
236 const height =
237 calculateHeightFromRatio(width, aspectRatio).minHeightPx ??
238 Math.round(width * (9 / 16));
239
240 return { width, height };
241 }
242
243 function createPreviewSlide({
244 currentSlide,
245 element,
246 fallbackAspectRatio,
247 variationId,
248 }: {
249 currentSlide: PlateSlide | undefined;
250 element: TElement;
251 fallbackAspectRatio: PlateSlide["aspectRatio"];
252 variationId: string;
253 }): PlateSlide {
254 return {
255 id: `layout-preview-slide-${variationId}`,
256 content: [element] as PlateSlide["content"],
257 alignment: currentSlide?.alignment ?? "start",
258 aspectRatio: fallbackAspectRatio,
259 formatCategory: currentSlide?.formatCategory ?? "presentation",
260 width: currentSlide?.width ?? "M",
261 };
262 }
263
264 function getBlockName(elementType: string) {
265 return (
266 BLOCKS.find((block) => block.type === elementType)?.name || elementType
267 );
268 }
269
270 function getVariationIdValue(value: unknown) {
271 return String(value).replaceAll(" ", "-").toLowerCase();
272 }
273
274 function createVariation({
275 elementType,
276 blockName,
277 previewElement,
278 additionalData,
279 }: {
280 elementType: string;
281 blockName: string;
282 previewElement: Record<string, unknown>;
283 additionalData: Record<string, unknown>;
284 }): LayoutVariation {
285 const dataKey =
286 Object.entries(additionalData)
287 .map(([key, value]) => `${key}-${getVariationIdValue(value)}`)
288 .join("-") || "default";
289
290 return {
291 id: `${elementType}-${dataKey}`,
292 type: elementType,
293 name: blockName,
294 element: {
295 ...previewElement,
296 ...additionalData,
297 } as unknown as TElement,
298 additionalData,
299 };
300 }
301
302 function createCapabilityPropertySets(
303 elementType: string,
304 baseProperties: Record<string, unknown>,
305 ) {
306 const propertySets: Record<string, unknown>[] = [baseProperties];
307
308 const appendOptions = (key: string, values: readonly unknown[]) => {
309 if (values.length === 0) return;
310
311 const nextPropertySets = propertySets.flatMap((propertySet) =>
312 values.map((value) => ({ ...propertySet, [key]: value })),
313 );
314
315 propertySets.splice(0, propertySets.length, ...nextPropertySets);
316 };
317
318 if (supportsOrientation(elementType)) {
319 if (elementType !== BOX_GROUP || baseProperties.boxType === "alternating") {
320 appendOptions("orientation", getOrientationOptions(elementType));
321 }
322 }
323
324 if (elementType === PYRAMID_GROUP || elementType === STAIRCASE_GROUP) {
325 appendOptions("variant", ["default", "inside"]);
326 }
327
328 return propertySets;
329 }
330
331 function createTimelinePreviewData(
332 previewElement: Record<string, unknown>,
333 ): LayoutVariation[] {
334 const blockName = getBlockName(TIMELINE_GROUP);
335 const timelineVariants: Record<string, unknown>[] = [
336 {
337 orientation: "vertical",
338 sidedness: "single",
339 numbered: true,
340 showLine: true,
341 },
342 {
343 orientation: "vertical",
344 sidedness: "double",
345 numbered: true,
346 showLine: true,
347 },
348 {
349 orientation: "horizontal",
350 sidedness: "single",
351 numbered: true,
352 showLine: true,
353 },
354 {
355 orientation: "horizontal",
356 sidedness: "double",
357 numbered: true,
358 showLine: true,
359 },
360 {
361 orientation: "vertical",
362 sidedness: "single",
363 numbered: false,
364 showLine: true,
365 },
366 {
367 orientation: "vertical",
368 sidedness: "single",
369 numbered: false,
370 showLine: false,
371 },
372 ];
373
374 return timelineVariants.map((additionalData) =>
375 createVariation({
376 elementType: TIMELINE_GROUP,
377 blockName,
378 previewElement,
379 additionalData,
380 }),
381 );
382 }
383
384 function generatePreviewData(
385 elementType: string,
386 currentElement: TElement,
387 variant?: string,
388 variantKey?: string,
389 ): LayoutVariation[] {
390 const blockName = getBlockName(elementType);
391
392 const previewElement: Record<string, unknown> = {
393 ...currentElement,
394 type: elementType,
395 children: currentElement.children.map((child, index) => {
396 const childRelation =
397 PARENT_CHILD_RELATIONSHIP[
398 elementType as keyof typeof PARENT_CHILD_RELATIONSHIP
399 ]?.child;
400 const childType = Array.isArray(childRelation)
401 ? childRelation[index % childRelation.length]
402 : childRelation || child.type;
403
404 return {
405 ...child,
406 type: childType,
407 };
408 }),
409 };
410
411 const baseProperties: Record<string, unknown> =
412 variant && variantKey
413 ? {
414 [variantKey]:
415 variantKey === "isFunnel" ? variant === "funnel" : variant,
416 }
417 : {};
418
419 if (elementType === TIMELINE_GROUP) {
420 return createTimelinePreviewData(previewElement);
421 }
422
423 return createCapabilityPropertySets(elementType, baseProperties).map(
424 (additionalData) =>
425 createVariation({
426 elementType,
427 blockName,
428 previewElement,
429 additionalData,
430 }),
431 );
432 }
433
434 function getVariationLabel(variation: LayoutVariation) {
435 const variationElement = variation.element as Record<string, unknown>;
436 const labelParts: string[] = [];
437
438 const appendOrientationLabel = () => {
439 if (variationElement.orientation === "horizontal") {
440 labelParts.push("Horizontal");
441 } else if (variationElement.orientation === "vertical") {
442 labelParts.push("Vertical");
443 }
444 };
445
446 const joinLabel = (baseLabel: string) =>
447 labelParts.length > 0
448 ? `${baseLabel} - ${labelParts.join(", ")}`
449 : baseLabel;
450
451 if (variation.type === BULLET_GROUP) {
452 switch (variationElement.bulletType) {
453 case "numbered":
454 return joinLabel("Numbered list");
455 case "basic":
456 return joinLabel("Bullet list");
457 case "arrow":
458 return joinLabel("Arrow list");
459 default:
460 return joinLabel("List");
461 }
462 }
463
464 if (variation.type === BOX_GROUP) {
465 if (variationElement.boxType === "alternating") {
466 appendOrientationLabel();
467 }
468 switch (variationElement.boxType) {
469 case "solid":
470 return joinLabel("Solid box");
471 case "outline":
472 return joinLabel("Outline box");
473 case "icon":
474 return joinLabel("Icon box");
475 case "sideline":
476 return joinLabel("Side line box");
477 case "side-label":
478 return joinLabel("Side line text");
479 case "top-label":
480 return joinLabel("Top line text");
481 case "top-circle":
482 return joinLabel("Top circle box");
483 case "joined":
484 return joinLabel("Joined box");
485 case "joined-icon":
486 return joinLabel("Joined box with icons");
487 case "leaf":
488 return joinLabel("Leaf box");
489 case "labeled":
490 return joinLabel("Labeled box");
491 case "alternating":
492 return joinLabel("Alternating box");
493 default:
494 return joinLabel("Box");
495 }
496 }
497 if (variation.type === ARROW_LIST) {
498 appendOrientationLabel();
499
500 switch (variationElement.svgType) {
501 case "arrow":
502 return joinLabel("Arrow sequence");
503 case "pill":
504 return joinLabel("Pill sequence");
505 case "parallelogram":
506 return joinLabel("Parallelogram sequence");
507 default:
508 return joinLabel("Sequence");
509 }
510 }
511
512 if (variation.type === TIMELINE_GROUP) {
513 appendOrientationLabel();
514
515 if (variationElement.sidedness === "single") {
516 labelParts.push("Single");
517 } else if (variationElement.sidedness === "double") {
518 labelParts.push("Double");
519 }
520
521 if (variationElement.numbered === false) {
522 labelParts.push("No numbers");
523 }
524
525 if (variationElement.showLine === false) {
526 labelParts.push("No line");
527 }
528
529 return joinLabel("Timeline");
530 }
531
532 if (variation.type === SEQUENCE_ARROW_GROUP) {
533 appendOrientationLabel();
534 return joinLabel("Steps");
535 }
536
537 if (variation.type === PYRAMID_GROUP) {
538 const baseName = variationElement.isFunnel ? "Funnel" : "Pyramid";
539 return joinLabel(
540 variationElement.variant === "inside" ? `Inside ${baseName}` : baseName,
541 );
542 }
543
544 if (variation.type === STATS_GROUP) {
545 switch (variationElement.statsType) {
546 case "plain":
547 return joinLabel("Plain stats");
548 case "circle":
549 return joinLabel("Circle stats");
550 case "circle-bold":
551 return joinLabel("Bold circle stats");
552 case "star":
553 return joinLabel("Star rating");
554 case "bar":
555 return joinLabel("Bar stats");
556 case "dot-grid":
557 return joinLabel("Dot grid stats");
558 case "dot-line":
559 return joinLabel("Dot line stats");
560 default:
561 return joinLabel("Stats");
562 }
563 }
564
565 if (variation.type === QUOTE_ELEMENT) {
566 switch (variationElement.variant) {
567 case "large":
568 return "Large quote";
569 case "sidequote-icon":
570 return "Side quote with icon";
571 case "sidequote":
572 return "Side quote";
573 default:
574 return "Quote";
575 }
576 }
577
578 switch (variation.type) {
579 case STEPS_GROUP:
580 return joinLabel(
581 variationElement.variant === "arrow"
582 ? "Arrow Steps"
583 : variationElement.variant === "box"
584 ? "Box Steps"
585 : "Steps",
586 );
587 case STAIRCASE_GROUP:
588 return joinLabel(
589 variationElement.variant === "inside"
590 ? "Inside Staircase"
591 : "Staircase",
592 );
593 case CYCLE_GROUP:
594 return "Cycle";
595 case ICON_LIST:
596 return joinLabel("Icon list");
597 case CONNECTED_CIRCLES_GROUP:
598 return "Connected circles";
599 case CIRCULAR_GRID_GROUP:
600 return "Circular grid";
601 case SLOPE_GROUP:
602 return "Slope";
603 case SNAKE_GROUP:
604 return "Snake";
605 case COLUMN_GROUP:
606 return "Columns";
607 case COMPARE_GROUP:
608 return "Comparison";
609 case BEFORE_AFTER_GROUP:
610 return "Before and after";
611 case PROS_CONS_GROUP:
612 return "Pros and cons";
613 default:
614 return variation.name;
615 }
616 }
617
618 function flattenSections(sections: LayoutVariationSection[]) {
619 return sections.flatMap((section) => section.variations);
620 }
621
622 export function LayoutEditorPanel({ isLoaded }: { isLoaded?: boolean }) {
623 const layoutEditorEditorId = usePresentationState(
624 (s) => s.layoutEditorEditorId,
625 );
626 const editor = useEditorRef<MyEditor>(layoutEditorEditorId ?? undefined);
627 const currentSlide = usePresentationState((s) =>
628 s.slides.find((slide) => slide.id === s.currentSlideId),
629 );
630 const layoutEditorElement = usePresentationState(
631 (s) => s.layoutEditorElement,
632 );
633 const layoutEditorElementId = usePresentationState(
634 (s) => s.layoutEditorElementId,
635 );
636 const layoutEditorApplyLayout = usePresentationState(
637 (s) => s.layoutEditorApplyLayout,
638 );
639 const setPaletteDropTarget = usePresentationState(
640 (s) => s.setPaletteDropTarget,
641 );
642 const updateSlide = usePresentationState((s) => s.updateSlide);
643 const [currentElement, setCurrentElement] =
644 React.useState<LayoutEditorElementSnapshot | null>(layoutEditorElement);
645 const [focusedVariationIndex, setFocusedVariationIndex] = React.useState(0);
646 const [searchQuery, setSearchQuery] = React.useState("");
647 const variationRefs = React.useRef<Array<HTMLDivElement | null>>([]);
648 const applyTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(
649 null,
650 );
651 const previewDimensions = React.useMemo(
652 () => getPreviewDimensions(currentSlide),
653 [currentSlide],
654 );
655
656 React.useEffect(() => {
657 setCurrentElement(layoutEditorElement);
658 }, [layoutEditorElement]);
659
660 const currentElementType =
661 (currentElement?.type as string) ?? BLOCKS[0]?.type ?? "";
662 const availableOptions = React.useMemo(
663 () => getAvailableConversionOptions(currentElementType),
664 [currentElementType],
665 );
666
667 const currentElementAsTElement: TElement = currentElement
668 ? (currentElement as unknown as TElement)
669 : ({
670 type: currentElementType,
671 children: [],
672 } as TElement);
673
674 const currentVariations = React.useMemo(
675 () =>
676 generatePreviewData(currentElementType, currentElementAsTElement).map(
677 (variation) => ({
678 ...variation,
679 id: `current-${variation.id}`,
680 name: `Current ${variation.name}`,
681 }),
682 ),
683 [currentElementType, currentElementAsTElement],
684 );
685
686 const variationSections = React.useMemo<LayoutVariationSection[]>(() => {
687 const sections: LayoutVariationSection[] = [
688 {
689 title: `Current: ${
690 BLOCKS.find((block) => block.type === currentElementType)?.name ??
691 currentElementType
692 }`,
693 variations: currentVariations,
694 },
695 ];
696
697 Object.entries(availableOptions).forEach(([category, options]) => {
698 const variations = options.flatMap((option) => {
699 if (option.supportsOrientation) {
700 return generatePreviewData(
701 option.type,
702 currentElementAsTElement,
703 option.variant,
704 option.variant ? option.key : "orientation",
705 );
706 }
707
708 return generatePreviewData(
709 option.type,
710 currentElementAsTElement,
711 option.variant,
712 option.key,
713 );
714 });
715
716 if (variations.length > 0) {
717 sections.push({ title: category, variations });
718 }
719 });
720
721 return sections;
722 }, [
723 availableOptions,
724 currentElementAsTElement,
725 currentElementType,
726 currentVariations,
727 ]);
728
729 const filteredVariationSections = React.useMemo(
730 () =>
731 variationSections
732 .map((section) => ({
733 ...section,
734 variations: section.variations.filter((variation) => {
735 const label = getVariationLabel(variation);
736
737 return matchesPanelSearch(searchQuery, [
738 label,
739 variation.name,
740 variation.type,
741 section.title,
742 ]);
743 }),
744 }))
745 .filter((section) => section.variations.length > 0),
746 [searchQuery, variationSections],
747 );
748 const allVariations = React.useMemo(
749 () => flattenSections(filteredVariationSections),
750 [filteredVariationSections],
751 );
752
753 React.useEffect(() => {
754 setFocusedVariationIndex(0);
755 }, [currentElement?.id, searchQuery]);
756
757 React.useEffect(() => {
758 variationRefs.current = variationRefs.current.slice(
759 0,
760 allVariations.length,
761 );
762 }, [allVariations.length]);
763
764 React.useEffect(() => {
765 window.requestAnimationFrame(() => {
766 variationRefs.current[0]?.focus();
767 });
768 }, [currentElement?.id]);
769
770 React.useEffect(
771 () => () => {
772 if (applyTimeoutRef.current) {
773 clearTimeout(applyTimeoutRef.current);
774 }
775 },
776 [],
777 );
778
779 const applyVariation = React.useCallback(
780 (variation: LayoutVariation) => {
781 if (applyTimeoutRef.current) {
782 clearTimeout(applyTimeoutRef.current);
783 applyTimeoutRef.current = null;
784 }
785
786 const additionalData =
787 Object.keys(variation.additionalData).length > 0
788 ? variation.additionalData
789 : undefined;
790
791 setPaletteDropTarget(null);
792 const appliedElement = layoutEditorApplyLayout
793 ? layoutEditorApplyLayout(variation.type, additionalData)
794 : handleLayoutChange(
795 editor,
796 variation.type,
797 additionalData,
798 layoutEditorElementId ?? undefined,
799 );
800
801 setCurrentElement(
802 appliedElement
803 ? (appliedElement as LayoutEditorElementSnapshot)
804 : (variation.element as LayoutEditorElementSnapshot),
805 );
806
807 if (currentSlide?.id) {
808 updateSlide(currentSlide.id, {
809 content: editor.children as PlateSlide["content"],
810 });
811 }
812 },
813 [
814 currentSlide?.id,
815 editor,
816 layoutEditorApplyLayout,
817 layoutEditorElementId,
818 setPaletteDropTarget,
819 updateSlide,
820 ],
821 );
822
823 const scheduleVariationApply = React.useCallback(
824 (variation: LayoutVariation, index: number) => {
825 if (applyTimeoutRef.current) {
826 clearTimeout(applyTimeoutRef.current);
827 }
828
829 applyTimeoutRef.current = setTimeout(() => {
830 applyTimeoutRef.current = null;
831 applyVariation(variation);
832
833 window.requestAnimationFrame(() => {
834 variationRefs.current[index]?.focus();
835 });
836 }, KEYBOARD_APPLY_DELAY_MS);
837 },
838 [applyVariation],
839 );
840
841 const focusVariation = React.useCallback(
842 (nextIndex: number, shouldApply = false) => {
843 if (allVariations.length === 0) return;
844
845 const boundedIndex =
846 (nextIndex + allVariations.length) % allVariations.length;
847 const variation = allVariations[boundedIndex];
848
849 setFocusedVariationIndex(boundedIndex);
850
851 if (shouldApply && variation) {
852 scheduleVariationApply(variation, boundedIndex);
853 }
854
855 window.requestAnimationFrame(() => {
856 variationRefs.current[boundedIndex]?.scrollIntoView({
857 block: "nearest",
858 behavior: "smooth",
859 });
860 variationRefs.current[boundedIndex]?.focus();
861 });
862 },
863 [allVariations, scheduleVariationApply],
864 );
865
866 const handleCardKeyDown = React.useCallback(
867 (event: React.KeyboardEvent<HTMLDivElement>, index: number) => {
868 switch (event.key) {
869 case "ArrowLeft":
870 case "ArrowUp":
871 event.preventDefault();
872 event.stopPropagation();
873 focusVariation(index - 1, true);
874 break;
875 case "ArrowRight":
876 case "ArrowDown":
877 event.preventDefault();
878 event.stopPropagation();
879 focusVariation(index + 1, true);
880 break;
881 case "Home":
882 event.preventDefault();
883 event.stopPropagation();
884 focusVariation(0, true);
885 break;
886 case "End":
887 event.preventDefault();
888 event.stopPropagation();
889 focusVariation(allVariations.length - 1, true);
890 break;
891 case "Enter": {
892 event.preventDefault();
893 event.stopPropagation();
894 const variation = allVariations[index];
895 if (!variation) return;
896 applyVariation(variation);
897 break;
898 }
899 }
900 },
901 [allVariations, applyVariation, focusVariation],
902 );
903
904 if (!isLoaded) {
905 return (
906 <div
907 draggable={false}
908 className="animate-fade-in scrollbar-thin flex h-full flex-col gap-5 overflow-y-auto px-4 pb-5 scrollbar-thumb-primary scrollbar-track-transparent"
909 >
910 <div>
911 <h3 className="mb-3 animate-pulse text-xs font-semibold tracking-wide text-muted-foreground uppercase">
912 Loading layouts...
913 </h3>
914 <div className="grid grid-cols-2 gap-3">
915 {Array.from({ length: 6 }).map((_, i) => (
916 <div key={i} className="rounded-md border p-2">
917 <div
918 className="w-full overflow-hidden rounded-sm bg-muted/30"
919 style={{
920 aspectRatio: `${previewDimensions.width} / ${previewDimensions.height}`,
921 }}
922 >
923 <Skeleton className="h-full w-full rounded-sm" />
924 </div>
925 <div className="mt-1.5 px-0.5">
926 <Skeleton className="h-4 w-20" />
927 </div>
928 </div>
929 ))}
930 </div>
931 </div>
932 </div>
933 );
934 }
935
936 if (!currentElement || !layoutEditorEditorId) {
937 return (
938 <div className="px-4 py-5 text-sm text-muted-foreground">
939 Select a layout block on the slide to edit its layout.
940 </div>
941 );
942 }
943
944 return (
945 <div draggable={false} className="flex h-full flex-col overflow-hidden">
946 <PanelSearchFilter
947 onQueryChange={setSearchQuery}
948 placeholder="Search layouts..."
949 query={searchQuery}
950 />
951 <div className="scrollbar-thin flex-1 overflow-y-auto px-4 pb-5 scrollbar-thumb-primary scrollbar-track-transparent">
952 {filteredVariationSections.length > 0 ? (
953 <div className="flex flex-col gap-5 py-4">
954 {filteredVariationSections.map((section) => (
955 <div key={section.title}>
956 <h3 className="mb-3 text-xs font-semibold tracking-wide text-muted-foreground uppercase">
957 {section.title}
958 </h3>
959 <div className="grid grid-cols-2 gap-3">
960 {section.variations.map((variation) => {
961 const absoluteIndex = allVariations.findIndex(
962 (item) => item.id === variation.id,
963 );
964 const isFocused = absoluteIndex === focusedVariationIndex;
965 const label = getVariationLabel(variation);
966 const previewAspectRatio =
967 currentSlide?.aspectRatio?.type === "fluid" ||
968 !currentSlide?.aspectRatio
969 ? ({ type: "ratio", value: "16:9" } as const)
970 : currentSlide.aspectRatio;
971 const previewSlide = createPreviewSlide({
972 currentSlide,
973 element: variation.element,
974 fallbackAspectRatio: previewAspectRatio,
975 variationId: variation.id,
976 });
977
978 return (
979 <div
980 key={variation.id}
981 ref={(node) => {
982 if (absoluteIndex >= 0) {
983 variationRefs.current[absoluteIndex] = node;
984 }
985 }}
986 role="button"
987 aria-label={`Apply ${label}`}
988 aria-pressed={isFocused}
989 tabIndex={isFocused ? 0 : -1}
990 data-panel-arrow-target="true"
991 onClick={() => applyVariation(variation)}
992 onFocus={() => setFocusedVariationIndex(absoluteIndex)}
993 onKeyDown={(event) =>
994 handleCardKeyDown(event, absoluteIndex)
995 }
996 className={cn(
997 "group cursor-pointer rounded-md border p-2 transition hover:shadow focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none",
998 isFocused && "border-primary ring-1 ring-primary",
999 )}
1000 >
1001 <ElementPreview
1002 variation={variation}
1003 isVisible
1004 previewSlide={previewSlide}
1005 dimensions={previewDimensions}
1006 />
1007 <div className="mt-1.5 px-0.5">
1008 <div className="truncate text-sm font-medium text-foreground">
1009 {label}
1010 </div>
1011 </div>
1012 </div>
1013 );
1014 })}
1015 </div>
1016 </div>
1017 ))}
1018 </div>
1019 ) : (
1020 <div className="flex h-full items-center justify-center px-6 text-center text-sm text-muted-foreground">
1021 No layouts match your search.
1022 </div>
1023 )}
1024 </div>
1025 </div>
1026 );
1027 }
1028
1028 lines Plain Text