@bendyline/squisq-editor-react 1.6.1 → 1.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/index.d.ts +87 -41
  2. package/dist/index.js +8263 -6705
  3. package/dist/index.js.map +1 -1
  4. package/dist/styles/index.css +841 -91
  5. package/package.json +4 -4
  6. package/src/BlockPropertiesPopover.tsx +23 -7
  7. package/src/EditorShell.tsx +65 -20
  8. package/src/MediaBin.tsx +171 -28
  9. package/src/PreviewControls.tsx +177 -44
  10. package/src/PreviewPanel.tsx +2 -0
  11. package/src/TemplateAnnotation.ts +22 -7
  12. package/src/TemplateContentPreview.tsx +56 -0
  13. package/src/TemplatePicker.tsx +295 -128
  14. package/src/ThemeCustomizerPanel.tsx +22 -15
  15. package/src/Toolbar.tsx +527 -207
  16. package/src/TransitionPicker.tsx +8 -1
  17. package/src/ViewSwitcher.tsx +4 -4
  18. package/src/WysiwygEditor.tsx +45 -3
  19. package/src/__tests__/buildPreviewDocTransition.test.ts +1 -2
  20. package/src/__tests__/editorShellProps.test.tsx +268 -1
  21. package/src/__tests__/headingTransition.test.ts +59 -9
  22. package/src/__tests__/imageEditorShell.test.tsx +23 -0
  23. package/src/__tests__/mediaReferences.test.ts +82 -0
  24. package/src/__tests__/previewControls.test.tsx +94 -1
  25. package/src/__tests__/templateAnnotationRoundTrip.test.ts +23 -2
  26. package/src/__tests__/templateContentPreview.test.ts +101 -0
  27. package/src/__tests__/tiptapBridge.test.ts +47 -0
  28. package/src/diagram/DiagramWidget.tsx +6 -4
  29. package/src/headingTransition.ts +96 -21
  30. package/src/index.ts +2 -1
  31. package/src/mediaReferences.ts +299 -0
  32. package/src/scene/Scene.tsx +53 -7
  33. package/src/scene/SceneBlockWidget.tsx +6 -3
  34. package/src/scene/SceneSelection.tsx +19 -15
  35. package/src/scene/SceneSideToolbar.tsx +89 -0
  36. package/src/scene/layers/DiagramEdges.tsx +4 -3
  37. package/src/scene/layers/edgeGeometry.ts +23 -4
  38. package/src/scene/scene.css +142 -2
  39. package/src/scene/tools/ConnectTool.ts +113 -24
  40. package/src/scene/tools/DrawingConnectTool.ts +86 -16
  41. package/src/scene/tools/SceneTool.ts +2 -0
  42. package/src/styles/editor.css +862 -101
  43. package/src/templateContentPreviewResolver.ts +353 -0
  44. package/src/tiptapBridge.ts +60 -33
@@ -62,6 +62,10 @@ export interface PreviewSettings {
62
62
  * that style. The single entry point so the toggle buttons persist in one
63
63
  * frontmatter write. */
64
64
  setCaptionMode: (mode: CaptionMode) => void;
65
+ /** Whether Squisq should synthesize and show its managed cover slide. */
66
+ activeCoverSlide: boolean;
67
+ /** Enable/disable the managed cover slide. */
68
+ setCoverSlideEnabled: (enabled: boolean) => void;
65
69
  /** User-authored themes (doc + browser library) for the picker's "Custom" group. */
66
70
  customThemes: Theme[];
67
71
  /** Open the custom-theme designer for a theme (or null to create a new one). */
@@ -171,6 +175,15 @@ function resolveFrontmatterCaptionMode(value: unknown): CaptionMode | null {
171
175
  return null;
172
176
  }
173
177
 
178
+ function resolveFrontmatterBoolean(value: unknown): boolean | null {
179
+ if (typeof value === 'boolean') return value;
180
+ if (typeof value !== 'string') return null;
181
+ const v = value.trim().toLowerCase();
182
+ if (v === 'true' || v === 'yes' || v === 'on' || v === 'show' || v === 'visible') return true;
183
+ if (v === 'false' || v === 'no' || v === 'off' || v === 'hide' || v === 'hidden') return false;
184
+ return null;
185
+ }
186
+
174
187
  // ── Provider ─────────────────────────────────────────────────────
175
188
 
176
189
  export interface PreviewSettingsProviderProps {
@@ -192,6 +205,7 @@ const FM_KEYS = {
192
205
  theme: { canonical: 'squisq-theme', legacy: 'theme' as const },
193
206
  transform: { canonical: 'squisq-transform', legacy: 'transform-style' as const },
194
207
  captions: { canonical: 'squisq-captions', legacy: 'caption-style' as const },
208
+ coverSlide: { canonical: 'squisq-cover-slide', legacy: 'cover-slide' as const },
195
209
  } as const;
196
210
 
197
211
  function readFrontmatterKey(
@@ -366,6 +380,26 @@ export function PreviewSettingsProvider({
366
380
  [persistFrontmatter],
367
381
  );
368
382
 
383
+ // Managed cover slide — generated from the document startBlock. Defaults on
384
+ // for existing documents; authors can persist an explicit off switch.
385
+ const fmCoverSlide = useMemo(
386
+ () =>
387
+ resolveFrontmatterBoolean(
388
+ readFrontmatterKey(frontmatter, FM_KEYS.coverSlide.canonical, FM_KEYS.coverSlide.legacy),
389
+ ),
390
+ [frontmatter],
391
+ );
392
+ const [selectedCoverSlide, setSelectedCoverSlide] = useState<boolean | null>(null);
393
+ useEffect(() => setSelectedCoverSlide(null), [fmCoverSlide]);
394
+ const activeCoverSlide = selectedCoverSlide ?? fmCoverSlide ?? true;
395
+ const handleSetCoverSlideEnabled = useCallback(
396
+ (enabled: boolean) => {
397
+ setSelectedCoverSlide(enabled);
398
+ persistFrontmatter({ [FM_KEYS.coverSlide.canonical]: enabled ? 'true' : 'false' });
399
+ },
400
+ [persistFrontmatter],
401
+ );
402
+
369
403
  // Config for the docked designer (rendered by `<ThemeDesignerDock>` in the
370
404
  // editor content row). Null when closed. setPreviewTheme is a stable setter.
371
405
  const themeDesigner = useMemo<ThemeDesignerConfig | null>(
@@ -396,6 +430,8 @@ export function PreviewSettingsProvider({
396
430
  activeCaptionStyle,
397
431
  activeCaptionsEnabled,
398
432
  setCaptionMode: handleSetCaptionMode,
433
+ activeCoverSlide,
434
+ setCoverSlideEnabled: handleSetCoverSlideEnabled,
399
435
  customThemes,
400
436
  openThemeDesigner,
401
437
  deleteCustomTheme,
@@ -410,9 +446,11 @@ export function PreviewSettingsProvider({
410
446
  activeTransformStyle,
411
447
  activeCaptionStyle,
412
448
  activeCaptionsEnabled,
449
+ activeCoverSlide,
413
450
  handleSetThemeId,
414
451
  handleSetTransformStyle,
415
452
  handleSetCaptionMode,
453
+ handleSetCoverSlideEnabled,
416
454
  customThemes,
417
455
  openThemeDesigner,
418
456
  deleteCustomTheme,
@@ -473,15 +511,28 @@ const TRANSFORM_STYLE_OPTIONS = [
473
511
  /**
474
512
  * Left-to-right priority order for the preview controls. As the toolbar
475
513
  * narrows, controls drop into the overflow menu from the END of this list
476
- * first (Captions, then Transform, …), so the higher-priority control (Theme)
477
- * stays inline the longest.
478
- *
479
- * Display mode and aspect ratio are not here — they're surfaced separately as
480
- * the segmented {@link PreviewModeSwitch} / {@link PreviewFormatSwitch} on the
481
- * left of the toolbar.
514
+ * first (Cover, then Captions, …). Display mode and aspect ratio stay inline
515
+ * the longest, but still collapse into the same menu when the toolbar is very
516
+ * constrained.
482
517
  */
483
- type ControlKey = 'theme' | 'transform' | 'captions';
484
- const CONTROL_KEYS: ControlKey[] = ['theme', 'transform', 'captions'];
518
+ type ControlKey = 'mode' | 'format' | 'theme' | 'transform' | 'captions' | 'cover';
519
+ const CONTROL_KEYS: ControlKey[] = ['mode', 'format', 'theme', 'transform', 'captions', 'cover'];
520
+
521
+ const PREVIEW_POPOVER_GAP = 4;
522
+ const PREVIEW_POPOVER_MARGIN = 8;
523
+ const PREVIEW_POPOVER_FALLBACK_WIDTH = 220;
524
+
525
+ function clampPreviewPopoverLeft(
526
+ triggerRect: DOMRect,
527
+ popoverWidth: number,
528
+ viewportWidth: number,
529
+ ): number {
530
+ const maxLeft = Math.max(
531
+ PREVIEW_POPOVER_MARGIN,
532
+ viewportWidth - popoverWidth - PREVIEW_POPOVER_MARGIN,
533
+ );
534
+ return Math.min(Math.max(PREVIEW_POPOVER_MARGIN, triggerRect.right - popoverWidth), maxLeft);
535
+ }
485
536
 
486
537
  // ── Shared styles ────────────────────────────────────────────────
487
538
 
@@ -510,7 +561,7 @@ const selectStyle: React.CSSProperties = {
510
561
  * whole row in and out at a fixed window-width breakpoint, the controls
511
562
  * measure how many of them actually fit in the width the toolbar gives them
512
563
  * and keep that many inline, folding the rest — from the low-priority end of
513
- * {@link CONTROL_KEYS} — into a single settings (gear) button's popover. As
564
+ * {@link CONTROL_KEYS} — into a single ellipsis button's popover. As
514
565
  * the toolbar widens or narrows, controls migrate one at a time between the
515
566
  * inline row and the menu, so the available space is always well used and the
516
567
  * row never wraps onto a second line.
@@ -526,6 +577,27 @@ export function PreviewToolbarControls() {
526
577
  // inline and overflow is computed from these per-control measurements.
527
578
  const probeRef = useRef<HTMLDivElement>(null);
528
579
  const popoverRef = useRef<HTMLDivElement>(null);
580
+ const popoverTriggerRef = useRef<HTMLButtonElement>(null);
581
+ const popoverPanelRef = useRef<HTMLDivElement>(null);
582
+ const [popoverAnchor, setPopoverAnchor] = useState<{ top: number; left: number } | null>(null);
583
+
584
+ const updatePopoverPosition = useCallback(() => {
585
+ const trigger = popoverTriggerRef.current;
586
+ if (!trigger) return;
587
+ const triggerRect = trigger.getBoundingClientRect();
588
+ const measuredWidth =
589
+ popoverPanelRef.current?.getBoundingClientRect().width ?? PREVIEW_POPOVER_FALLBACK_WIDTH;
590
+ const popoverWidth = Math.min(measuredWidth, window.innerWidth - PREVIEW_POPOVER_MARGIN * 2);
591
+ setPopoverAnchor({
592
+ top: triggerRect.bottom + PREVIEW_POPOVER_GAP,
593
+ left: clampPreviewPopoverLeft(triggerRect, popoverWidth, window.innerWidth),
594
+ });
595
+ }, []);
596
+
597
+ const closePopover = useCallback(() => {
598
+ setPopoverOpen(false);
599
+ setPopoverAnchor(null);
600
+ }, []);
529
601
 
530
602
  // Fit detection: keep as many controls inline as fit, overflow the rest.
531
603
  useLayoutEffect(() => {
@@ -534,7 +606,7 @@ export function PreviewToolbarControls() {
534
606
  if (!root || !probe) return;
535
607
  const GAP = 6; // matches the row's flex `gap`
536
608
  const LEAD_PAD = 9; // root's left padding, eaten before any control
537
- const GEAR_RESERVE = 40; // width kept for the overflow gear button (+ its gap)
609
+ const OVERFLOW_TRIGGER_RESERVE = 40; // width kept for the ellipsis button (+ its gap)
538
610
  const SAFETY = 2;
539
611
  const measure = () => {
540
612
  const available = root.clientWidth - LEAD_PAD;
@@ -549,8 +621,8 @@ export function PreviewToolbarControls() {
549
621
  setVisibleCount(widths.length);
550
622
  return;
551
623
  }
552
- // Otherwise reserve room for the gear and fit as many as possible.
553
- const budget = available - GEAR_RESERVE - GAP - SAFETY;
624
+ // Otherwise reserve room for the ellipsis and fit as many as possible.
625
+ const budget = available - OVERFLOW_TRIGGER_RESERVE - GAP - SAFETY;
554
626
  let count = 0;
555
627
  while (count < widths.length && rowWidth(count + 1) <= budget) count++;
556
628
  setVisibleCount(count);
@@ -568,13 +640,29 @@ export function PreviewToolbarControls() {
568
640
  useEffect(() => {
569
641
  if (!popoverOpen) return;
570
642
  const handler = (e: MouseEvent) => {
571
- if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
572
- setPopoverOpen(false);
573
- }
643
+ const target = e.target as Node;
644
+ if (popoverRef.current?.contains(target)) return;
645
+ if (popoverPanelRef.current?.contains(target)) return;
646
+ closePopover();
574
647
  };
575
648
  document.addEventListener('mousedown', handler);
576
649
  return () => document.removeEventListener('mousedown', handler);
577
- }, [popoverOpen]);
650
+ }, [closePopover, popoverOpen]);
651
+
652
+ useLayoutEffect(() => {
653
+ if (!popoverOpen) return;
654
+ updatePopoverPosition();
655
+ }, [popoverOpen, updatePopoverPosition, visibleCount]);
656
+
657
+ useEffect(() => {
658
+ if (!popoverOpen) return;
659
+ window.addEventListener('resize', updatePopoverPosition);
660
+ window.addEventListener('scroll', updatePopoverPosition, true);
661
+ return () => {
662
+ window.removeEventListener('resize', updatePopoverPosition);
663
+ window.removeEventListener('scroll', updatePopoverPosition, true);
664
+ };
665
+ }, [popoverOpen, updatePopoverPosition]);
578
666
 
579
667
  // "Edit theme" pencil next to the dropdown. A custom theme opens directly in
580
668
  // the designer; a built-in seeds a NEW "Modified <name>" custom theme based
@@ -593,6 +681,26 @@ export function PreviewToolbarControls() {
593
681
  // label-over-control layout used inside the overflow popover.
594
682
  const renderControl = (key: ControlKey, compact: boolean): ReactNode => {
595
683
  switch (key) {
684
+ case 'mode':
685
+ return (
686
+ <div
687
+ key="mode"
688
+ className={`squisq-preview-control squisq-preview-control--seg${compact ? ' squisq-preview-control--compact' : ''}`}
689
+ >
690
+ {compact && <label style={labelStyle}>Mode:</label>}
691
+ <PreviewModeSwitch />
692
+ </div>
693
+ );
694
+ case 'format':
695
+ return (
696
+ <div
697
+ key="format"
698
+ className={`squisq-preview-control squisq-preview-control--seg${compact ? ' squisq-preview-control--compact' : ''}`}
699
+ >
700
+ {compact && <label style={labelStyle}>Format:</label>}
701
+ <PreviewFormatSwitch />
702
+ </div>
703
+ );
596
704
  case 'theme':
597
705
  return (
598
706
  <div
@@ -684,6 +792,23 @@ export function PreviewToolbarControls() {
684
792
  </div>
685
793
  );
686
794
  }
795
+ case 'cover':
796
+ return (
797
+ <div
798
+ key="cover"
799
+ className={`squisq-preview-control${compact ? ' squisq-preview-control--compact' : ''}`}
800
+ >
801
+ <label style={labelStyle}>Cover:</label>
802
+ <label className="squisq-preview-checkbox">
803
+ <input
804
+ type="checkbox"
805
+ checked={s.activeCoverSlide}
806
+ onChange={(e) => s.setCoverSlideEnabled(e.target.checked)}
807
+ />
808
+ <span>Cover slide</span>
809
+ </label>
810
+ </div>
811
+ );
687
812
  }
688
813
  };
689
814
 
@@ -691,10 +816,18 @@ export function PreviewToolbarControls() {
691
816
  const visibleKeys = CONTROL_KEYS.slice(0, visibleCount);
692
817
  const overflowKeys = CONTROL_KEYS.slice(visibleCount);
693
818
 
819
+ useEffect(() => {
820
+ if (!hasOverflow && popoverOpen) closePopover();
821
+ }, [closePopover, hasOverflow, popoverOpen]);
822
+
694
823
  // The root is a flex:1 filler so it always spans the toolbar's leftover
695
824
  // width (which is what the fit measurement reads).
696
825
  return (
697
- <div className="squisq-preview-controls" ref={rootRef}>
826
+ <div
827
+ className="squisq-preview-controls"
828
+ data-has-overflow={hasOverflow ? 'true' : undefined}
829
+ ref={rootRef}
830
+ >
698
831
  {/* Hidden probe — every control at natural width, measured to decide the
699
832
  inline/overflow split. Absolutely positioned so it never affects
700
833
  layout. */}
@@ -702,35 +835,37 @@ export function PreviewToolbarControls() {
702
835
  {CONTROL_KEYS.map((key) => renderControl(key, false))}
703
836
  </div>
704
837
 
705
- <div className="squisq-preview-controls-inline">
706
- {visibleKeys.map((key) => renderControl(key, false))}
707
- </div>
838
+ {visibleKeys.length > 0 && (
839
+ <div className="squisq-preview-controls-inline">
840
+ {visibleKeys.map((key) => renderControl(key, false))}
841
+ </div>
842
+ )}
708
843
 
709
844
  {hasOverflow && (
710
845
  <div className="squisq-preview-controls-compact" ref={popoverRef}>
711
846
  <button
847
+ ref={popoverTriggerRef}
712
848
  className={`squisq-toolbar-button${popoverOpen ? ' squisq-toolbar-button--active' : ''}`}
713
- onClick={() => setPopoverOpen((v) => !v)}
849
+ onClick={() => {
850
+ if (popoverOpen) {
851
+ closePopover();
852
+ return;
853
+ }
854
+ updatePopoverPosition();
855
+ setPopoverOpen(true);
856
+ }}
714
857
  aria-label="More preview settings"
715
858
  title="More preview settings"
716
859
  aria-expanded={popoverOpen}
717
860
  >
718
- <svg
719
- width="16"
720
- height="16"
721
- viewBox="0 0 16 16"
722
- fill="none"
723
- stroke="currentColor"
724
- strokeWidth="1.5"
725
- strokeLinecap="round"
726
- strokeLinejoin="round"
727
- >
728
- <circle cx="8" cy="8" r="2.5" />
729
- <path d="M13.5 8a5.5 5.5 0 01-.4 1.8l1.2 1.2-1.6 1.6-1.2-1.2A5.5 5.5 0 018 13.5a5.5 5.5 0 01-3.5-1.3L3.3 13.4 1.7 11.8l1.2-1.2A5.5 5.5 0 012.5 8c0-.6.1-1.2.4-1.8L1.7 5 3.3 3.4l1.2 1.2A5.5 5.5 0 018 2.5c1.3 0 2.5.5 3.5 1.3l1.2-1.2 1.6 1.6-1.2 1.2c.3.6.4 1.2.4 1.6z" />
730
- </svg>
861
+ <Icon icon="fa-solid fa-ellipsis" />
731
862
  </button>
732
- {popoverOpen && (
733
- <div className="squisq-preview-controls-popover">
863
+ {popoverOpen && popoverAnchor && (
864
+ <div
865
+ ref={popoverPanelRef}
866
+ className="squisq-preview-controls-popover"
867
+ style={{ top: popoverAnchor.top, left: popoverAnchor.left }}
868
+ >
734
869
  {overflowKeys.map((key) => renderControl(key, true))}
735
870
  </div>
736
871
  )}
@@ -741,10 +876,9 @@ export function PreviewToolbarControls() {
741
876
  }
742
877
 
743
878
  /**
744
- * Segmented display-mode switch (Video / Slideshow / Document / Page) rendered
745
- * as four connected buttons on the left of the Play toolbar the prominent,
746
- * one-click counterpart to the old "Mode:" dropdown. Reads and writes the same
747
- * `activeDisplayMode` in preview settings.
879
+ * Segmented display-mode switch (Video / Slideshow / Document / Page), used
880
+ * inline in the Use toolbar and inside its overflow popover. Reads and writes
881
+ * the same `activeDisplayMode` in preview settings.
748
882
  */
749
883
  export function PreviewModeSwitch() {
750
884
  const s = usePreviewSettings();
@@ -787,10 +921,9 @@ function AspectIcon({ w, h }: { w: number; h: number }) {
787
921
  }
788
922
 
789
923
  /**
790
- * Segmented aspect-ratio switch (16:9 / 1:1 / 9:16 / 4:3) rendered as connected
791
- * icon buttons on the left of the Play toolbar, next to the mode switch — the
792
- * one-click counterpart to the old "Format:" dropdown. Reads and writes the
793
- * same `activePreset` in preview settings.
924
+ * Segmented aspect-ratio switch (16:9 / 1:1 / 9:16 / 4:3), used inline in the
925
+ * Use toolbar and inside its overflow popover. Reads and writes the same
926
+ * `activePreset` in preview settings.
794
927
  */
795
928
  export function PreviewFormatSwitch() {
796
929
  const s = usePreviewSettings();
@@ -49,6 +49,7 @@ export function PreviewPanel({ basePath = '/', className, workspaceContainer }:
49
49
  activeTransformStyle,
50
50
  activeCaptionStyle,
51
51
  activeCaptionsEnabled,
52
+ activeCoverSlide,
52
53
  } = usePreviewSettings();
53
54
 
54
55
  // Build the player-ready Doc whenever the parsed doc changes.
@@ -178,6 +179,7 @@ export function PreviewPanel({ basePath = '/', className, workspaceContainer }:
178
179
  theme={activeTheme}
179
180
  captionStyle={activeCaptionStyle}
180
181
  captionsEnabled={activeCaptionsEnabled}
182
+ showCoverSlide={activeCoverSlide}
181
183
  />
182
184
  )}
183
185
  </div>
@@ -63,10 +63,10 @@ export const HeadingWithTemplate = Heading.extend({
63
63
  (HTMLAttributes['data-template-params'] as string | undefined) ?? null,
64
64
  );
65
65
 
66
- // Render heading with a trailing badge span. The badge has no text
67
- // content — its label is painted via CSS `content: attr(data-template-label)`
68
- // so the template name never becomes part of the serialized heading
69
- // text (which would leak into markdown on round-trip).
66
+ // Render heading with a trailing badge span. The badge and its inner core
67
+ // have no real text content — labels/braces are painted via CSS so the
68
+ // template name never becomes part of serialized heading text (which
69
+ // would leak into markdown on round-trip).
70
70
  //
71
71
  // When no template is set we still render a subtle "empty" badge so
72
72
  // authors have a visible affordance for opening the template picker
@@ -86,10 +86,17 @@ export const HeadingWithTemplate = Heading.extend({
86
86
  role: 'button',
87
87
  tabindex: '0',
88
88
  'aria-haspopup': 'listbox',
89
- title: 'Change block template',
89
+ title: 'Change block type',
90
90
  'data-template': templateName,
91
- 'data-template-label': templateLabel(templateName),
92
91
  },
92
+ [
93
+ 'span',
94
+ {
95
+ class: 'squisq-template-badge-core',
96
+ 'data-template-label': templateLabel(templateName),
97
+ 'aria-hidden': 'true',
98
+ },
99
+ ],
93
100
  ],
94
101
  propsBadgeSpec(propsSummary),
95
102
  ];
@@ -107,8 +114,16 @@ export const HeadingWithTemplate = Heading.extend({
107
114
  role: 'button',
108
115
  tabindex: '0',
109
116
  'aria-haspopup': 'listbox',
110
- title: 'Choose block template',
117
+ title: 'Choose block type',
111
118
  },
119
+ [
120
+ 'span',
121
+ {
122
+ class: 'squisq-template-badge-core',
123
+ 'data-template-label': 'Block',
124
+ 'aria-hidden': 'true',
125
+ },
126
+ ],
112
127
  ],
113
128
  propsBadgeSpec(propsSummary),
114
129
  ];
@@ -0,0 +1,56 @@
1
+ import { useMemo } from 'react';
2
+ import { BlockRenderer, MediaContext } from '@bendyline/squisq-react';
3
+ import {
4
+ resolveTemplateContentPreviewResult,
5
+ type TemplatePreviewSource,
6
+ } from './templateContentPreviewResolver';
7
+
8
+ export interface TemplateContentPreviewProps {
9
+ templateName: string;
10
+ source?: TemplatePreviewSource;
11
+ fallback: JSX.Element;
12
+ }
13
+
14
+ export function TemplateContentPreview({
15
+ templateName,
16
+ source,
17
+ fallback,
18
+ }: TemplateContentPreviewProps) {
19
+ const preview = useMemo(
20
+ () => (source ? resolveTemplateContentPreviewResult(templateName, source) : null),
21
+ [templateName, source],
22
+ );
23
+
24
+ if (!source) return fallback;
25
+
26
+ if (!preview?.visual) {
27
+ if (!preview?.warning) return fallback;
28
+ return (
29
+ <div
30
+ className="squisq-template-gallery-content-preview squisq-template-gallery-content-preview--fallback"
31
+ style={{ aspectRatio: `${source.viewport.width} / ${source.viewport.height}` }}
32
+ aria-hidden="true"
33
+ >
34
+ <div className="squisq-template-gallery-content-preview-fallback">{fallback}</div>
35
+ <span className="squisq-template-gallery-content-preview-warning">{preview.warning}</span>
36
+ </div>
37
+ );
38
+ }
39
+
40
+ return (
41
+ <div
42
+ className="squisq-template-gallery-content-preview"
43
+ style={{ aspectRatio: `${source.viewport.width} / ${source.viewport.height}` }}
44
+ aria-hidden="true"
45
+ >
46
+ <MediaContext.Provider value={source.mediaProvider ?? null}>
47
+ <BlockRenderer
48
+ block={preview.visual}
49
+ blockTime={0}
50
+ basePath={source.basePath ?? '/'}
51
+ viewport={source.viewport}
52
+ />
53
+ </MediaContext.Provider>
54
+ </div>
55
+ );
56
+ }