@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
@@ -10,8 +10,14 @@ import { useEffect, useMemo, useRef, useState } from 'react';
10
10
  import { createPortal } from 'react-dom';
11
11
  import type { CustomTemplateDefinition } from '@bendyline/squisq/schemas';
12
12
  import { TEMPLATE_METADATA, resolveTemplateName } from '@bendyline/squisq/doc';
13
+ import { extractPlainText } from '@bendyline/squisq/markdown';
13
14
  import { useCustomTemplates } from './customTemplates/CustomTemplateContext';
14
15
  import { TemplateThumbnail } from './customTemplates/thumbnail';
16
+ import { TemplateContentPreview } from './TemplateContentPreview';
17
+ import {
18
+ resolveTemplateContentPreviewResult,
19
+ type TemplatePreviewSource,
20
+ } from './templateContentPreviewResolver';
15
21
 
16
22
  // ── Template metadata ─────────────────────────────────────────────
17
23
  //
@@ -30,6 +36,15 @@ interface TemplateEntry {
30
36
  icon: JSX.Element;
31
37
  }
32
38
 
39
+ /**
40
+ * DOM id of the portaled template gallery. Exported so hosts that embed the
41
+ * picker inside their own popovers (e.g. the toolbar overflow menu) can treat
42
+ * clicks inside the gallery as "inside" in their outside-click handling.
43
+ */
44
+ export const TEMPLATE_GALLERY_PORTAL_ID = 'squisq-template-gallery-portal';
45
+
46
+ const TEMPLATE_GALLERY_DIALOG_ID = 'squisq-template-gallery-dialog';
47
+
33
48
  const W = 56;
34
49
  const H = 40;
35
50
 
@@ -57,7 +72,7 @@ function TemplateIcon({ children }: { children: React.ReactNode }) {
57
72
  const NONE_ENTRY: TemplateEntry = {
58
73
  name: '',
59
74
  label: '— none —',
60
- description: 'Plain heading block with no visual template.',
75
+ description: 'Plain heading block with no visual treatment.',
61
76
  icon: (
62
77
  <TemplateIcon>
63
78
  <rect
@@ -513,6 +528,8 @@ export function templateLabel(
513
528
  export interface TemplatePickerProps {
514
529
  value: string;
515
530
  onChange: (name: string) => void;
531
+ /** Active editor chrome theme, used by the portaled dialog. */
532
+ colorScheme?: 'light' | 'dark';
516
533
  /** When true, shows only the trigger button (no popover) — used in the overflow menu. */
517
534
  compact?: boolean;
518
535
  /**
@@ -528,48 +545,35 @@ export interface TemplatePickerProps {
528
545
  * hidden.
529
546
  */
530
547
  onOpenDesigner?: () => void;
548
+ /**
549
+ * Active block content used to render live template thumbnails. When omitted,
550
+ * or when a template cannot be meaningfully derived from the block, cards use
551
+ * their static wireframe icons.
552
+ */
553
+ previewSource?: TemplatePreviewSource;
531
554
  }
532
555
 
533
556
  export function TemplatePicker({
534
557
  value,
535
558
  onChange,
559
+ colorScheme = 'light',
536
560
  compact,
537
561
  recommended,
538
562
  onOpenDesigner,
563
+ previewSource,
539
564
  }: TemplatePickerProps) {
540
565
  const [open, setOpen] = useState(false);
541
- const [popoverStyle, setPopoverStyle] = useState<React.CSSProperties>({});
566
+ const [dialogStyle, setDialogStyle] = useState<React.CSSProperties>({});
542
567
  const containerRef = useRef<HTMLDivElement>(null);
543
568
  const triggerRef = useRef<HTMLButtonElement>(null);
544
569
 
545
- // Position the portal popover below the trigger button. Clamp the
546
- // left edge so the gallery (up to 780px wide) doesn't overflow the
547
- // viewport when the toolbar trigger sits near the right edge of the
548
- // window — previously the popover was left-aligned to the trigger,
549
- // which pushed half the cards off-screen at narrow widths.
550
- const updatePosition = () => {
570
+ const updateDialogBounds = () => {
551
571
  if (!triggerRef.current) return;
552
- const rect = triggerRef.current.getBoundingClientRect();
553
- const popoverEl = document.getElementById('squisq-template-gallery-portal');
554
- // Use the actual rendered width if the popover is already mounted;
555
- // otherwise fall back to the CSS-defined max so the first paint
556
- // doesn't overflow either. Measure the portal element directly —
557
- // its `firstElementChild` is the (small) "(none)" option, not the
558
- // gallery itself.
559
- const popoverWidth = popoverEl?.getBoundingClientRect().width ?? 780;
560
- const margin = 8;
561
- const maxLeft = Math.max(margin, window.innerWidth - popoverWidth - margin);
562
- const left = Math.min(Math.max(margin, rect.left), maxLeft);
563
- setPopoverStyle({
564
- position: 'fixed',
565
- top: rect.bottom + 6,
566
- left,
567
- zIndex: 9999,
568
- });
572
+ setDialogStyle(computeDialogStyle(triggerRef.current));
569
573
  };
570
574
 
571
575
  const handleOpen = () => {
572
- updatePosition();
576
+ updateDialogBounds();
573
577
  setOpen((v) => !v);
574
578
  };
575
579
 
@@ -579,8 +583,8 @@ export function TemplatePicker({
579
583
  const handler = (e: MouseEvent) => {
580
584
  const target = e.target as Node;
581
585
  const inTrigger = triggerRef.current?.contains(target);
582
- const inPopover = document.getElementById('squisq-template-gallery-portal')?.contains(target);
583
- if (!inTrigger && !inPopover) {
586
+ const inDialog = document.getElementById(TEMPLATE_GALLERY_DIALOG_ID)?.contains(target);
587
+ if (!inTrigger && !inDialog) {
584
588
  setOpen(false);
585
589
  }
586
590
  };
@@ -601,10 +605,8 @@ export function TemplatePicker({
601
605
  // Reposition on scroll/resize while open
602
606
  useEffect(() => {
603
607
  if (!open) return;
604
- // Reposition once on the next frame so the clamp uses the actual
605
- // rendered popover width (the initial open() runs before mount).
606
- requestAnimationFrame(updatePosition);
607
- const handler = () => updatePosition();
608
+ requestAnimationFrame(updateDialogBounds);
609
+ const handler = () => updateDialogBounds();
608
610
  window.addEventListener('scroll', handler, true);
609
611
  window.addEventListener('resize', handler);
610
612
  return () => {
@@ -621,6 +623,7 @@ export function TemplatePicker({
621
623
  const currentLabel = templateLabel(value);
622
624
  const currentEntry: TemplateEntry =
623
625
  (value && TEMPLATE_ENTRIES.find((e) => e.name === value)) || NONE_ENTRY;
626
+ const dialogTitle = templateDialogTitle(previewSource);
624
627
 
625
628
  if (compact) {
626
629
  // In overflow menu, use a simple select for space efficiency
@@ -642,13 +645,21 @@ export function TemplatePicker({
642
645
 
643
646
  const gallery = open
644
647
  ? createPortal(
645
- <TemplateGalleryBody
646
- value={value}
647
- onSelect={handleSelect}
648
- style={popoverStyle}
649
- recommended={recommended}
650
- onOpenDesigner={onOpenDesigner}
651
- />,
648
+ <TemplateGalleryDialog
649
+ title={dialogTitle}
650
+ colorScheme={colorScheme}
651
+ style={dialogStyle}
652
+ onClose={() => setOpen(false)}
653
+ >
654
+ <TemplateGalleryBody
655
+ value={value}
656
+ onSelect={handleSelect}
657
+ style={{}}
658
+ recommended={recommended}
659
+ onOpenDesigner={onOpenDesigner}
660
+ previewSource={previewSource}
661
+ />
662
+ </TemplateGalleryDialog>,
652
663
  document.body,
653
664
  )
654
665
  : null;
@@ -660,9 +671,9 @@ export function TemplatePicker({
660
671
  type="button"
661
672
  className={`squisq-template-picker-trigger${open ? ' squisq-template-picker-trigger--open' : ''}`}
662
673
  onClick={handleOpen}
663
- aria-haspopup="listbox"
674
+ aria-haspopup="dialog"
664
675
  aria-expanded={open}
665
- title="Choose block template"
676
+ title="Choose block type"
666
677
  >
667
678
  <span className="squisq-template-picker-trigger-label">Block:</span>
668
679
  <span className="squisq-template-picker-trigger-thumb" aria-hidden="true">
@@ -695,8 +706,61 @@ export function TemplatePicker({
695
706
 
696
707
  // ── Reusable gallery body ──────────────────────────────────────────
697
708
 
709
+ function TemplateGalleryDialog({
710
+ children,
711
+ title,
712
+ colorScheme,
713
+ style,
714
+ onClose,
715
+ }: {
716
+ children: React.ReactNode;
717
+ title: string;
718
+ colorScheme: 'light' | 'dark';
719
+ style: React.CSSProperties;
720
+ onClose: () => void;
721
+ }) {
722
+ return (
723
+ <div
724
+ id={TEMPLATE_GALLERY_DIALOG_ID}
725
+ className="squisq-template-gallery-dialog"
726
+ data-theme={colorScheme}
727
+ style={style}
728
+ onMouseDown={(event) => {
729
+ if (event.target === event.currentTarget) onClose();
730
+ }}
731
+ >
732
+ <div
733
+ className="squisq-template-gallery-dialog-panel"
734
+ role="dialog"
735
+ aria-modal="true"
736
+ aria-label={title}
737
+ >
738
+ <div className="squisq-template-gallery-dialog-header">
739
+ <h2 className="squisq-template-gallery-dialog-title">{title}</h2>
740
+ <button
741
+ type="button"
742
+ className="squisq-template-gallery-dialog-close"
743
+ onClick={onClose}
744
+ aria-label="Close block type picker"
745
+ >
746
+ <svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true">
747
+ <path
748
+ d="M3.2 3.2l7.6 7.6M10.8 3.2l-7.6 7.6"
749
+ stroke="currentColor"
750
+ strokeWidth="1.7"
751
+ strokeLinecap="round"
752
+ />
753
+ </svg>
754
+ </button>
755
+ </div>
756
+ <div className="squisq-template-gallery-dialog-body">{children}</div>
757
+ </div>
758
+ </div>
759
+ );
760
+ }
761
+
698
762
  /**
699
- * The popover/grid markup shared by the toolbar `TemplatePicker` and the
763
+ * The dialog/grid markup shared by the toolbar `TemplatePicker` and the
700
764
  * inline `TemplateBadgeMenu`. Renders all template cards plus the
701
765
  * "(none)" option; no positioning logic — callers supply `style` (typically
702
766
  * a `position: fixed` rect from `getBoundingClientRect()`).
@@ -705,10 +769,12 @@ function TemplateCard({
705
769
  entry,
706
770
  value,
707
771
  onSelect,
772
+ previewSource,
708
773
  }: {
709
774
  entry: TemplateEntry;
710
775
  value: string;
711
776
  onSelect: (name: string) => void;
777
+ previewSource?: TemplatePreviewSource;
712
778
  }) {
713
779
  return (
714
780
  <button
@@ -719,7 +785,13 @@ function TemplateCard({
719
785
  onClick={() => onSelect(entry.name)}
720
786
  title={entry.description}
721
787
  >
722
- <div className="squisq-template-gallery-card-icon">{entry.icon}</div>
788
+ <div className="squisq-template-gallery-card-icon">
789
+ <TemplateContentPreview
790
+ templateName={entry.name}
791
+ source={previewSource}
792
+ fallback={entry.icon}
793
+ />
794
+ </div>
723
795
  <div className="squisq-template-gallery-card-body">
724
796
  <span className="squisq-template-gallery-card-name">{entry.label}</span>
725
797
  <span className="squisq-template-gallery-card-desc">{entry.description}</span>
@@ -728,18 +800,38 @@ function TemplateCard({
728
800
  );
729
801
  }
730
802
 
803
+ function splitBlockTypeEntriesByContent(
804
+ entries: readonly TemplateEntry[],
805
+ previewSource?: TemplatePreviewSource,
806
+ ): { blockTypeEntries: TemplateEntry[]; contentNeededEntries: TemplateEntry[] } {
807
+ if (!previewSource) return { blockTypeEntries: [...entries], contentNeededEntries: [] };
808
+
809
+ const blockTypeEntries: TemplateEntry[] = [];
810
+ const contentNeededEntries: TemplateEntry[] = [];
811
+
812
+ for (const entry of entries) {
813
+ const result = resolveTemplateContentPreviewResult(entry.name, previewSource);
814
+ if (result.warning) contentNeededEntries.push(entry);
815
+ else blockTypeEntries.push(entry);
816
+ }
817
+
818
+ return { blockTypeEntries, contentNeededEntries };
819
+ }
820
+
731
821
  function TemplateGalleryBody({
732
822
  value,
733
823
  onSelect,
734
824
  style,
735
825
  recommended,
736
826
  onOpenDesigner,
827
+ previewSource,
737
828
  }: {
738
829
  value: string;
739
830
  onSelect: (name: string) => void;
740
831
  style: React.CSSProperties;
741
832
  recommended?: readonly string[];
742
833
  onOpenDesigner?: () => void;
834
+ previewSource?: TemplatePreviewSource;
743
835
  }) {
744
836
  const [query, setQuery] = useState('');
745
837
  const searchRef = useRef<HTMLInputElement>(null);
@@ -779,21 +871,33 @@ function TemplateGalleryBody({
779
871
  return { built, custom };
780
872
  }, [hasQuery, trimmedQuery, customTemplates]);
781
873
 
782
- const recommendedSet = recommended && recommended.length > 0 ? new Set(recommended) : null;
783
- const recommendedEntries = recommendedSet
784
- ? TEMPLATE_ENTRIES.filter((e) => recommendedSet.has(e.name))
785
- : [];
786
- const restEntries = recommendedSet
787
- ? TEMPLATE_ENTRIES.filter((e) => !recommendedSet.has(e.name))
788
- : TEMPLATE_ENTRIES;
789
- const segmented = !hasQuery && recommendedEntries.length > 0;
874
+ const recommendedSet = useMemo(
875
+ () => (recommended && recommended.length > 0 ? new Set(recommended) : null),
876
+ [recommended],
877
+ );
878
+ const recommendedEntries = useMemo(
879
+ () => (recommendedSet ? TEMPLATE_ENTRIES.filter((e) => recommendedSet.has(e.name)) : []),
880
+ [recommendedSet],
881
+ );
882
+ const restEntries = useMemo(
883
+ () =>
884
+ recommendedSet
885
+ ? TEMPLATE_ENTRIES.filter((e) => !recommendedSet.has(e.name))
886
+ : TEMPLATE_ENTRIES,
887
+ [recommendedSet],
888
+ );
889
+ const { blockTypeEntries, contentNeededEntries } = useMemo(
890
+ () => splitBlockTypeEntriesByContent(restEntries, previewSource),
891
+ [restEntries, previewSource],
892
+ );
893
+ const grouped = !hasQuery && (recommendedEntries.length > 0 || contentNeededEntries.length > 0);
790
894
 
791
895
  return (
792
896
  <div
793
- id="squisq-template-gallery-portal"
794
- className={`squisq-template-gallery${segmented ? ' squisq-template-gallery--segmented' : ''}`}
897
+ id={TEMPLATE_GALLERY_PORTAL_ID}
898
+ className={`squisq-template-gallery${grouped ? ' squisq-template-gallery--segmented' : ''}`}
795
899
  role="listbox"
796
- aria-label="Block templates"
900
+ aria-label="Block types"
797
901
  style={style}
798
902
  >
799
903
  <div className="squisq-template-gallery-search">
@@ -812,10 +916,10 @@ function TemplateGalleryBody({
812
916
  ref={searchRef}
813
917
  type="search"
814
918
  className="squisq-template-gallery-search-input"
815
- placeholder="Search templates…"
919
+ placeholder="Search block types…"
816
920
  value={query}
817
921
  onChange={(e) => setQuery(e.target.value)}
818
- aria-label="Search block templates"
922
+ aria-label="Search block types"
819
923
  />
820
924
  </div>
821
925
 
@@ -854,13 +958,19 @@ function TemplateGalleryBody({
854
958
  {matches.built.length > 0 && (
855
959
  <div className="squisq-template-gallery-grid">
856
960
  {matches.built.map((entry) => (
857
- <TemplateCard key={entry.name} entry={entry} value={value} onSelect={onSelect} />
961
+ <TemplateCard
962
+ key={entry.name}
963
+ entry={entry}
964
+ value={value}
965
+ onSelect={onSelect}
966
+ previewSource={previewSource}
967
+ />
858
968
  ))}
859
969
  </div>
860
970
  )}
861
971
  </>
862
972
  ) : (
863
- <div className="squisq-template-gallery-empty">No templates match "{query}".</div>
973
+ <div className="squisq-template-gallery-empty">No block types match "{query}".</div>
864
974
  )
865
975
  ) : (
866
976
  <>
@@ -872,9 +982,9 @@ function TemplateGalleryBody({
872
982
  +
873
983
  </span>
874
984
  <span className="squisq-template-gallery-new-body">
875
- <span className="squisq-template-gallery-new-label">New layout</span>
985
+ <span className="squisq-template-gallery-new-label">New block type</span>
876
986
  <span className="squisq-template-gallery-new-desc">
877
- Design a reusable layout with placeholders for {'{title}'} and {'{content}'}.
987
+ Design a reusable block type with placeholders for {'{title}'} and {'{content}'}.
878
988
  </span>
879
989
  </span>
880
990
  </button>
@@ -891,30 +1001,69 @@ function TemplateGalleryBody({
891
1001
  </div>
892
1002
  )}
893
1003
 
894
- {segmented && (
1004
+ {recommendedEntries.length > 0 && (
895
1005
  <div className="squisq-template-gallery-section">
896
- <h3 className="squisq-template-gallery-section-title">Recommended for this block</h3>
1006
+ <h3 className="squisq-template-gallery-section-title">Suggested Block Types</h3>
897
1007
  <div className="squisq-template-gallery-grid">
898
1008
  {recommendedEntries.map((entry) => (
899
- <TemplateCard key={entry.name} entry={entry} value={value} onSelect={onSelect} />
1009
+ <TemplateCard
1010
+ key={entry.name}
1011
+ entry={entry}
1012
+ value={value}
1013
+ onSelect={onSelect}
1014
+ previewSource={previewSource}
1015
+ />
900
1016
  ))}
901
1017
  </div>
902
1018
  </div>
903
1019
  )}
904
1020
 
905
- {segmented ? (
906
- <div className="squisq-template-gallery-section">
907
- <h3 className="squisq-template-gallery-section-title">All templates</h3>
908
- <div className="squisq-template-gallery-grid">
909
- {restEntries.map((entry) => (
910
- <TemplateCard key={entry.name} entry={entry} value={value} onSelect={onSelect} />
911
- ))}
912
- </div>
913
- </div>
1021
+ {grouped ? (
1022
+ <>
1023
+ {blockTypeEntries.length > 0 && (
1024
+ <div className="squisq-template-gallery-section">
1025
+ <h3 className="squisq-template-gallery-section-title">Block Types</h3>
1026
+ <div className="squisq-template-gallery-grid">
1027
+ {blockTypeEntries.map((entry) => (
1028
+ <TemplateCard
1029
+ key={entry.name}
1030
+ entry={entry}
1031
+ value={value}
1032
+ onSelect={onSelect}
1033
+ previewSource={previewSource}
1034
+ />
1035
+ ))}
1036
+ </div>
1037
+ </div>
1038
+ )}
1039
+
1040
+ {contentNeededEntries.length > 0 && (
1041
+ <div className="squisq-template-gallery-section">
1042
+ <h3 className="squisq-template-gallery-section-title">Block Types for Content</h3>
1043
+ <div className="squisq-template-gallery-grid">
1044
+ {contentNeededEntries.map((entry) => (
1045
+ <TemplateCard
1046
+ key={entry.name}
1047
+ entry={entry}
1048
+ value={value}
1049
+ onSelect={onSelect}
1050
+ previewSource={previewSource}
1051
+ />
1052
+ ))}
1053
+ </div>
1054
+ </div>
1055
+ )}
1056
+ </>
914
1057
  ) : (
915
1058
  <div className="squisq-template-gallery-grid">
916
- {restEntries.map((entry) => (
917
- <TemplateCard key={entry.name} entry={entry} value={value} onSelect={onSelect} />
1059
+ {blockTypeEntries.map((entry) => (
1060
+ <TemplateCard
1061
+ key={entry.name}
1062
+ entry={entry}
1063
+ value={value}
1064
+ onSelect={onSelect}
1065
+ previewSource={previewSource}
1066
+ />
918
1067
  ))}
919
1068
  </div>
920
1069
  )}
@@ -957,7 +1106,7 @@ function CustomTemplateCard({
957
1106
  <div className="squisq-template-gallery-card-body">
958
1107
  <span className="squisq-template-gallery-card-name">{def.label}</span>
959
1108
  <span className="squisq-template-gallery-card-desc">
960
- {def.description ?? 'Custom template'}
1109
+ {def.description ?? 'Custom block type'}
961
1110
  </span>
962
1111
  </div>
963
1112
  </button>
@@ -973,6 +1122,8 @@ export interface TemplateBadgePopoverProps {
973
1122
  value: string;
974
1123
  onChange: (name: string) => void;
975
1124
  onClose: () => void;
1125
+ /** Active editor chrome theme, used by the portaled dialog. */
1126
+ colorScheme?: 'light' | 'dark';
976
1127
  /** Optional list of template names to surface as "Recommended for this block". */
977
1128
  recommended?: readonly string[];
978
1129
  /**
@@ -981,27 +1132,32 @@ export interface TemplateBadgePopoverProps {
981
1132
  * it closes the popover and opens the designer.
982
1133
  */
983
1134
  onOpenDesigner?: () => void;
1135
+ /**
1136
+ * Active block content used to render live template thumbnails. Falls back to
1137
+ * static icons when a candidate cannot be derived.
1138
+ */
1139
+ previewSource?: TemplatePreviewSource;
984
1140
  }
985
1141
 
986
1142
  /**
987
- * Standalone popover that mirrors the toolbar `TemplatePicker`'s gallery,
988
- * but is anchored to a caller-supplied DOM rect (typically a clicked
989
- * `.squisq-template-badge` span). Handles its own positioning, outside
990
- * clicks, Escape, and viewport-edge clamping.
1143
+ * Standalone dialog that mirrors the toolbar `TemplatePicker`'s gallery
1144
+ * when opened from an inline heading badge.
991
1145
  */
992
1146
  export function TemplateBadgePopover({
993
1147
  anchorRect,
994
1148
  value,
995
1149
  onChange,
996
1150
  onClose,
1151
+ colorScheme = 'light',
997
1152
  recommended,
998
1153
  onOpenDesigner,
1154
+ previewSource,
999
1155
  }: TemplateBadgePopoverProps) {
1000
- const [style, setStyle] = useState<React.CSSProperties>(() => computePopoverStyle(anchorRect));
1156
+ const [style, setStyle] = useState<React.CSSProperties>(() => computeDialogStyle(anchorRect));
1001
1157
 
1002
- // Reposition once after mount using the actual rendered popover width.
1158
+ // Reposition once after mount so the dialog covers the current editor shell.
1003
1159
  useEffect(() => {
1004
- requestAnimationFrame(() => setStyle(computePopoverStyle(anchorRect)));
1160
+ requestAnimationFrame(() => setStyle(computeDialogStyle(anchorRect)));
1005
1161
  }, [anchorRect]);
1006
1162
 
1007
1163
  // Outside click + Escape close
@@ -1011,8 +1167,8 @@ export function TemplateBadgePopover({
1011
1167
  };
1012
1168
  const onMouse = (e: MouseEvent) => {
1013
1169
  const target = e.target as Node;
1014
- const inPopover = document.getElementById('squisq-template-gallery-portal')?.contains(target);
1015
- if (!inPopover) onClose();
1170
+ const inDialog = document.getElementById(TEMPLATE_GALLERY_DIALOG_ID)?.contains(target);
1171
+ if (!inDialog) onClose();
1016
1172
  };
1017
1173
  // Defer the mousedown listener by one frame so the click that opened
1018
1174
  // us doesn't immediately close us.
@@ -1032,62 +1188,73 @@ export function TemplateBadgePopover({
1032
1188
  onClose();
1033
1189
  };
1034
1190
 
1191
+ const dialogTitle = templateDialogTitle(previewSource);
1192
+
1035
1193
  return createPortal(
1036
- <TemplateGalleryBody
1037
- value={value}
1038
- onSelect={handleSelect}
1194
+ <TemplateGalleryDialog
1195
+ title={dialogTitle}
1196
+ colorScheme={colorScheme}
1039
1197
  style={style}
1040
- recommended={recommended}
1041
- onOpenDesigner={onOpenDesigner}
1042
- />,
1198
+ onClose={onClose}
1199
+ >
1200
+ <TemplateGalleryBody
1201
+ value={value}
1202
+ onSelect={handleSelect}
1203
+ style={{}}
1204
+ recommended={recommended}
1205
+ onOpenDesigner={onOpenDesigner}
1206
+ previewSource={previewSource}
1207
+ />
1208
+ </TemplateGalleryDialog>,
1043
1209
  document.body,
1044
1210
  );
1045
1211
  }
1046
1212
 
1047
- function computePopoverStyle(rect: DOMRect): React.CSSProperties {
1048
- // The portal *is* the gallery (`#squisq-template-gallery-portal` is the
1049
- // outer `.squisq-template-gallery` div). Measure it directly — using
1050
- // `firstElementChild` returns the "(none)" option, which is ~50px tall
1051
- // and made the "fits below" check incorrectly pass on tall galleries.
1052
- const popoverEl = document.getElementById('squisq-template-gallery-portal');
1053
- const popoverRect = popoverEl?.getBoundingClientRect();
1054
- const popoverWidth = popoverRect?.width ?? 780;
1055
- const popoverHeight = popoverRect?.height ?? 520;
1056
- const margin = 8;
1057
- const gap = 6;
1058
- const vw = window.innerWidth;
1059
- const vh = window.innerHeight;
1213
+ function templateDialogTitle(previewSource?: TemplatePreviewSource): string {
1214
+ return `Block type for ${templateDialogBlockTitle(previewSource)}`;
1215
+ }
1060
1216
 
1061
- // Vertical placement: prefer below the badge, fall back to above.
1062
- // If neither fits, center the popover in the viewport (dialog-style)
1063
- // so the gallery is always fully visible no matter where the chip
1064
- // sits in the editor (top, middle, bottom).
1065
- const spaceBelow = vh - rect.bottom - margin;
1066
- const spaceAbove = rect.top - margin;
1067
- let top: number;
1068
- let left: number;
1069
- if (popoverHeight + gap <= spaceBelow) {
1070
- top = rect.bottom + gap;
1071
- const maxLeft = Math.max(margin, vw - popoverWidth - margin);
1072
- left = Math.min(Math.max(margin, rect.left), maxLeft);
1073
- } else if (popoverHeight + gap <= spaceAbove) {
1074
- top = rect.top - popoverHeight - gap;
1075
- const maxLeft = Math.max(margin, vw - popoverWidth - margin);
1076
- left = Math.min(Math.max(margin, rect.left), maxLeft);
1077
- } else {
1078
- // Center it.
1079
- top = Math.max(margin, Math.floor((vh - popoverHeight) / 2));
1080
- left = Math.max(margin, Math.floor((vw - popoverWidth) / 2));
1081
- }
1217
+ function templateDialogBlockTitle(previewSource?: TemplatePreviewSource): string {
1218
+ const block = previewSource?.block;
1219
+ if (!block) return 'this block';
1220
+
1221
+ const headingText = block.sourceHeading ? extractPlainText(block.sourceHeading).trim() : '';
1222
+ const title = (headingText || block.title || '').replace(/\s+/g, ' ').trim();
1223
+ return title || 'this block';
1224
+ }
1225
+
1226
+ function computeDialogStyle(anchor: Element | DOMRect): React.CSSProperties {
1227
+ const shellRect = findEditorShellRect(anchor);
1228
+ const left = Math.max(0, shellRect.left);
1229
+ const top = Math.max(0, shellRect.top);
1230
+ const right = Math.min(window.innerWidth, shellRect.right);
1231
+ const bottom = Math.min(window.innerHeight, shellRect.bottom);
1082
1232
 
1083
1233
  return {
1084
1234
  position: 'fixed',
1085
1235
  top,
1086
1236
  left,
1087
- // Cap height so an oversized gallery still fits and scrolls
1088
- // gracefully instead of pushing past the viewport.
1089
- maxHeight: `${vh - 2 * margin}px`,
1090
- overflowY: 'auto',
1237
+ width: Math.max(0, right - left),
1238
+ height: Math.max(0, bottom - top),
1091
1239
  zIndex: 9999,
1092
1240
  };
1093
1241
  }
1242
+
1243
+ function findEditorShellRect(anchor: Element | DOMRect): DOMRect {
1244
+ if (anchor instanceof Element) {
1245
+ const shell = anchor.closest('.squisq-editor-shell');
1246
+ if (shell) return shell.getBoundingClientRect();
1247
+ } else {
1248
+ const x = anchor.left + anchor.width / 2;
1249
+ const y = anchor.top + anchor.height / 2;
1250
+ for (const element of document.elementsFromPoint(x, y)) {
1251
+ const shell = element.closest('.squisq-editor-shell');
1252
+ if (shell) return shell.getBoundingClientRect();
1253
+ }
1254
+ }
1255
+
1256
+ const fallback = document.querySelector('.squisq-editor-shell');
1257
+ if (fallback) return fallback.getBoundingClientRect();
1258
+
1259
+ return new DOMRect(0, 0, window.innerWidth, window.innerHeight);
1260
+ }