@bendyline/squisq-editor-react 1.6.1 → 2.0.0

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 (208) hide show
  1. package/README.md +12 -3
  2. package/dist/index.d.ts +1304 -208
  3. package/dist/index.js +22838 -14494
  4. package/dist/index.js.map +1 -1
  5. package/dist/monaco.d.ts +1 -0
  6. package/dist/monaco.js +4 -0
  7. package/dist/monaco.js.map +1 -0
  8. package/dist/styles/index.css +2384 -408
  9. package/package.json +9 -5
  10. package/src/BlockPropertiesPopover.tsx +48 -17
  11. package/src/DocumentSettingsDialog.tsx +13 -21
  12. package/src/EditorContext.tsx +199 -42
  13. package/src/EditorShell.tsx +403 -270
  14. package/src/ImageEditor.tsx +22 -1
  15. package/src/InlinePreviewGutter.tsx +16 -8
  16. package/src/MediaBin.tsx +276 -40
  17. package/src/OutlinePanel.tsx +202 -7
  18. package/src/PlainHtmlPreview.tsx +40 -1
  19. package/src/PreviewControls.tsx +466 -93
  20. package/src/PreviewPanel.tsx +313 -77
  21. package/src/RawEditor.tsx +13 -2
  22. package/src/StatusBar.tsx +5 -2
  23. package/src/TemplateAnnotation.ts +34 -10
  24. package/src/TemplateContentPreview.tsx +56 -0
  25. package/src/TemplatePicker.tsx +355 -134
  26. package/src/ThemeCustomizerPanel.tsx +30 -15
  27. package/src/ThemePicker.tsx +10 -5
  28. package/src/TimelineBlockPreview.tsx +1 -1
  29. package/src/Toolbar.tsx +719 -238
  30. package/src/TransitionPicker.tsx +44 -5
  31. package/src/ViewMenuPanel.tsx +12 -14
  32. package/src/ViewSwitcher.tsx +4 -4
  33. package/src/WysiwygEditor.tsx +167 -92
  34. package/src/__tests__/blockPropertiesPopoverTheme.test.tsx +33 -0
  35. package/src/__tests__/blockTagActivity.test.ts +183 -0
  36. package/src/__tests__/buildDocumentPreviewMarkdown.test.ts +75 -0
  37. package/src/__tests__/buildPreviewDocContent.test.ts +48 -0
  38. package/src/__tests__/buildPreviewDocTransition.test.ts +22 -3
  39. package/src/__tests__/canvasSurfaceTextEditing.test.tsx +60 -0
  40. package/src/__tests__/customLayoutManagerTheme.test.tsx +34 -0
  41. package/src/__tests__/documentSettingsDialog.test.tsx +29 -1
  42. package/src/__tests__/editorScrollHandoff.test.tsx +102 -0
  43. package/src/__tests__/editorScrollSync.test.ts +65 -0
  44. package/src/__tests__/editorShellProps.test.tsx +390 -1
  45. package/src/__tests__/emojiPicker.test.tsx +2 -42
  46. package/src/__tests__/headingTransition.test.ts +53 -15
  47. package/src/__tests__/imageEditAffordance.test.tsx +5 -1
  48. package/src/__tests__/imageEditModalContract.test.tsx +119 -0
  49. package/src/__tests__/imageEditorShell.test.tsx +38 -1
  50. package/src/__tests__/jsonEditor.test.tsx +56 -1
  51. package/src/__tests__/layersPanel.test.tsx +75 -5
  52. package/src/__tests__/majorApiTypes.test.ts +26 -0
  53. package/src/__tests__/mediaBinDrop.test.tsx +90 -0
  54. package/src/__tests__/mediaReferences.test.ts +82 -0
  55. package/src/__tests__/narrationSave.test.ts +148 -0
  56. package/src/__tests__/outlinePanel.test.tsx +93 -3
  57. package/src/__tests__/outlineSource.test.ts +217 -0
  58. package/src/__tests__/plainHtmlPreview.test.tsx +17 -1
  59. package/src/__tests__/presentationMode.test.tsx +290 -0
  60. package/src/__tests__/previewControls.test.tsx +312 -6
  61. package/src/__tests__/previewPanelAudioFailure.test.tsx +46 -0
  62. package/src/__tests__/previewPanelPresentation.test.tsx +126 -0
  63. package/src/__tests__/previewPanelTransforms.test.tsx +117 -0
  64. package/src/__tests__/rawEditorModelIsolation.test.ts +19 -0
  65. package/src/__tests__/resolveBlockVisual.test.ts +43 -0
  66. package/src/__tests__/statusBar.test.tsx +27 -0
  67. package/src/__tests__/teleprompter.test.tsx +190 -0
  68. package/src/__tests__/templateAnnotationRoundTrip.test.ts +49 -2
  69. package/src/__tests__/templateContentPreview.test.ts +101 -0
  70. package/src/__tests__/templatePickerPortal.test.tsx +62 -0
  71. package/src/__tests__/tiptapBridge.test.ts +70 -0
  72. package/src/__tests__/tiptapCodeBlockRoundTrip.test.ts +109 -0
  73. package/src/__tests__/transformStyleId.test.ts +13 -0
  74. package/src/__tests__/useImageEditor.test.tsx +67 -0
  75. package/src/__tests__/useMediaRecorder.test.ts +75 -0
  76. package/src/__tests__/viewMenuPanel.test.tsx +130 -0
  77. package/src/__tests__/wysiwygImageUpload.test.ts +69 -0
  78. package/src/__tests__/wysiwygTemplateBadgeFocus.test.tsx +56 -0
  79. package/src/asciiDiagram/AsciiDiagramExtension.ts +343 -0
  80. package/src/asciiDiagram/AsciiDiagramWidget.tsx +224 -0
  81. package/src/asciiDiagram/RepairableDiagramExtension.ts +200 -0
  82. package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +145 -0
  83. package/src/asciiDiagram/__tests__/RepairableDiagramExtension.test.ts +159 -0
  84. package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +227 -0
  85. package/src/asciiDiagram/__tests__/asciiDiagramOps.test.ts +114 -0
  86. package/src/asciiDiagram/__tests__/asciiPaste.test.ts +43 -0
  87. package/src/asciiDiagram/asciiDiagramCommands.ts +148 -0
  88. package/src/asciiDiagram/asciiDiagramData.ts +116 -0
  89. package/src/asciiDiagram/asciiDiagramOps.ts +219 -0
  90. package/src/asciiDiagram/asciiPaste.ts +19 -0
  91. package/src/blockTagActivity.ts +233 -0
  92. package/src/buildDocumentPreviewMarkdown.ts +168 -0
  93. package/src/buildPreviewDoc.ts +19 -12
  94. package/src/customTemplates/CustomLayoutManager.tsx +3 -2
  95. package/src/customTemplates/CustomTemplateContext.tsx +6 -0
  96. package/src/customTemplates/TemplateDesigner.tsx +7 -4
  97. package/src/customTemplates/__tests__/library.test.ts +8 -0
  98. package/src/customTemplates/designer.css +158 -99
  99. package/src/customTemplates/library.ts +16 -3
  100. package/src/customThemes/CustomThemeContext.tsx +6 -0
  101. package/src/customThemes/CustomThemeDialog.tsx +35 -5
  102. package/src/customThemes/ImportThemeSection.tsx +178 -0
  103. package/src/customThemes/__tests__/customThemeLibrary.test.ts +9 -0
  104. package/src/customThemes/__tests__/importThemeSection.test.tsx +192 -0
  105. package/src/customThemes/customThemeLibrary.ts +7 -4
  106. package/src/customThemes/index.ts +7 -1
  107. package/src/customThemes/themeDraft.ts +23 -7
  108. package/src/diagram/DiagramCanvas.tsx +9 -4
  109. package/src/diagram/types.ts +35 -0
  110. package/src/editorScrollSync.ts +73 -0
  111. package/src/emojiData.ts +3 -23
  112. package/src/headingTransition.ts +85 -25
  113. package/src/imageEditor/CanvasSurface.tsx +29 -23
  114. package/src/imageEditor/LayersPanel.tsx +78 -3
  115. package/src/imageEditor/Toolbar.tsx +5 -1
  116. package/src/imageEditor/icons.tsx +4 -0
  117. package/src/imageEditor/image-editor.css +75 -0
  118. package/src/imageEditor/layers/SelectionHandles.tsx +1 -1
  119. package/src/imageEditor/useImageEditor.ts +71 -14
  120. package/src/index.ts +193 -20
  121. package/src/jsonEditor/JsonEditorContext.tsx +12 -6
  122. package/src/jsonEditor/RenderNode.tsx +24 -3
  123. package/src/jsonEditor/editors.tsx +86 -19
  124. package/src/mediaEntries.ts +12 -0
  125. package/src/mediaReferences.ts +299 -0
  126. package/src/monaco.ts +35 -0
  127. package/src/monacoWorkers.ts +89 -0
  128. package/src/outlineSource.ts +171 -0
  129. package/src/presentation/PresentationMode.tsx +596 -0
  130. package/src/rawEditorIsolation.ts +18 -0
  131. package/src/recorder/hooks/useMediaRecorder.ts +97 -53
  132. package/src/resolveBlockVisual.ts +10 -4
  133. package/src/scene/Scene.tsx +101 -25
  134. package/src/scene/SceneBlockExtension.ts +17 -10
  135. package/src/scene/SceneBlockWidget.tsx +11 -4
  136. package/src/scene/SceneSelection.tsx +19 -15
  137. package/src/scene/SceneSideToolbar.tsx +89 -0
  138. package/src/scene/SceneViewport.tsx +7 -3
  139. package/src/scene/ShapePalette.tsx +17 -2
  140. package/src/scene/__tests__/DiagramAdapter.test.ts +86 -0
  141. package/src/scene/__tests__/SceneBlockExtension.test.ts +33 -0
  142. package/src/scene/__tests__/sceneIsolation.test.tsx +93 -0
  143. package/src/scene/adapters/DiagramAdapter.ts +12 -101
  144. package/src/scene/commands/SceneCommand.ts +4 -1
  145. package/src/scene/index.ts +1 -6
  146. package/src/scene/layers/DiagramEdges.tsx +24 -9
  147. package/src/scene/layers/edgeGeometry.ts +31 -4
  148. package/src/scene/layers/nodeCard.tsx +27 -8
  149. package/src/scene/scene.css +145 -2
  150. package/src/scene/text/SceneTextOverlay.tsx +5 -5
  151. package/src/scene/text/sceneTextChannel.ts +26 -20
  152. package/src/scene/text/sceneTextConfig.ts +4 -0
  153. package/src/scene/tools/ConnectTool.ts +126 -28
  154. package/src/scene/tools/DrawingConnectTool.ts +86 -16
  155. package/src/scene/tools/SceneTool.ts +10 -0
  156. package/src/scene/tools/SelectTool.ts +34 -18
  157. package/src/scene/tools/ShapeTool.ts +2 -3
  158. package/src/styles/ascii-diagram.css +79 -0
  159. package/src/styles/ascii-timeline.css +499 -0
  160. package/src/styles/editor.css +1566 -219
  161. package/src/styles/index.css +3 -0
  162. package/src/styles/tree-view.css +139 -0
  163. package/src/teleprompter/TeleprompterControls.tsx +218 -0
  164. package/src/teleprompter/TeleprompterSelfView.tsx +22 -0
  165. package/src/teleprompter/TeleprompterSurface.tsx +267 -0
  166. package/src/teleprompter/TeleprompterView.tsx +338 -0
  167. package/src/teleprompter/canvasRenderer.ts +161 -0
  168. package/src/teleprompter/floatingWindow.ts +352 -0
  169. package/src/teleprompter/index.ts +61 -0
  170. package/src/teleprompter/pcmWorklet.ts +62 -0
  171. package/src/teleprompter/recording/insertPreamble.ts +80 -0
  172. package/src/teleprompter/recording/narrationSave.ts +134 -0
  173. package/src/teleprompter/recording/useNarrationRecorder.ts +367 -0
  174. package/src/teleprompter/scrollModel.ts +85 -0
  175. package/src/teleprompter/teleprompterTheme.ts +303 -0
  176. package/src/teleprompter/types.ts +38 -0
  177. package/src/teleprompter/useFloatingWindow.ts +74 -0
  178. package/src/teleprompter/useMicAnalysis.ts +211 -0
  179. package/src/teleprompter/useTeleprompter.ts +421 -0
  180. package/src/templateContentPreviewResolver.ts +360 -0
  181. package/src/timeline/TimelineEditorWidget.tsx +702 -0
  182. package/src/timeline/TimelineViewExtension.ts +252 -0
  183. package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +355 -0
  184. package/src/timeline/__tests__/TimelineViewExtension.test.ts +163 -0
  185. package/src/timeline/__tests__/timelineCommands.test.ts +327 -0
  186. package/src/timeline/__tests__/timelineOps.test.ts +222 -0
  187. package/src/timeline/__tests__/timelinePaste.test.ts +34 -0
  188. package/src/timeline/timelineCommands.ts +367 -0
  189. package/src/timeline/timelineData.ts +54 -0
  190. package/src/timeline/timelineOps.ts +277 -0
  191. package/src/timeline/timelinePaste.ts +8 -0
  192. package/src/tiptapBridge.ts +84 -49
  193. package/src/transformStyleId.ts +17 -0
  194. package/src/treeview/TreeOutlineWidget.tsx +240 -0
  195. package/src/treeview/TreeViewExtension.ts +250 -0
  196. package/src/treeview/__tests__/TreeViewExtension.test.ts +122 -0
  197. package/src/treeview/__tests__/treeOps.test.ts +125 -0
  198. package/src/treeview/__tests__/treePaste.test.ts +40 -0
  199. package/src/treeview/__tests__/treeViewCommands.test.ts +163 -0
  200. package/src/treeview/treeOps.ts +172 -0
  201. package/src/treeview/treePaste.ts +13 -0
  202. package/src/treeview/treeViewCommands.ts +94 -0
  203. package/src/treeview/treeViewData.ts +41 -0
  204. package/src/useMonacoLoader.ts +11 -34
  205. package/src/wysiwygImageUpload.ts +113 -0
  206. package/src/diagram/DiagramExtension.ts +0 -209
  207. package/src/diagram/DiagramWidget.tsx +0 -270
  208. package/src/diagram/useDiagramData.ts +0 -126
@@ -6,12 +6,18 @@
6
6
  * and a one-sentence description so authors can quickly find the right layout.
7
7
  */
8
8
 
9
- import { useEffect, useMemo, useRef, useState } from 'react';
9
+ import { useEffect, useId, 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,21 @@ interface TemplateEntry {
30
36
  icon: JSX.Element;
31
37
  }
32
38
 
39
+ /** Matches every gallery when multiple editor instances are mounted. */
40
+ export const TEMPLATE_GALLERY_PORTAL_SELECTOR = '[data-squisq-template-gallery-portal]';
41
+
42
+ const TEMPLATE_GALLERY_DIALOG_ID = 'squisq-template-gallery-dialog';
43
+ const TEMPLATE_GALLERY_PORTAL_ID_PREFIX = 'squisq-template-gallery-portal';
44
+
45
+ /**
46
+ * Give each portaled gallery a stable, collision-free id. Hosts should match
47
+ * galleries through {@link TEMPLATE_GALLERY_PORTAL_SELECTOR}; a process-wide
48
+ * singleton id cannot identify the right editor when several are mounted.
49
+ */
50
+ function useTemplateGalleryPortalId(): string {
51
+ return `${TEMPLATE_GALLERY_PORTAL_ID_PREFIX}-${useId().replace(/:/g, '')}`;
52
+ }
53
+
33
54
  const W = 56;
34
55
  const H = 40;
35
56
 
@@ -57,7 +78,7 @@ function TemplateIcon({ children }: { children: React.ReactNode }) {
57
78
  const NONE_ENTRY: TemplateEntry = {
58
79
  name: '',
59
80
  label: '— none —',
60
- description: 'Plain heading block with no visual template.',
81
+ description: 'Plain heading block with no visual treatment.',
61
82
  icon: (
62
83
  <TemplateIcon>
63
84
  <rect
@@ -404,28 +425,62 @@ export const TEMPLATE_ENTRIES: TemplateEntry[] = [
404
425
  <rect x={4} y={6} width={16} height={10} rx={2} fill={FA} opacity={0.85} />
405
426
  <rect x={36} y={6} width={16} height={10} rx={2} fill={F2} opacity={0.8} />
406
427
  <rect x={20} y={24} width={16} height={10} rx={2} fill={F2} opacity={0.8} />
428
+ <path d="M 20 11 L 36 11" stroke={FA} strokeWidth={1.5} fill="none" opacity={0.7} />
429
+ <path d="M 36 11 L 32 8.5 L 32 13.5 Z" fill={FA} opacity={0.7} />
407
430
  <path
408
- d="M 20 11 L 36 11"
431
+ d="M 12 16 C 12 22, 24 22, 24 24"
409
432
  stroke={FA}
410
433
  strokeWidth={1.5}
411
434
  fill="none"
412
435
  opacity={0.7}
413
- markerEnd="url(#tp-diagram-arrow)"
414
436
  />
415
437
  <path
416
- d="M 12 16 C 12 22, 24 22, 24 24"
438
+ d="M 44 16 C 44 22, 32 22, 32 24"
417
439
  stroke={FA}
418
440
  strokeWidth={1.5}
419
441
  fill="none"
420
442
  opacity={0.7}
421
443
  />
444
+ </TemplateIcon>
445
+ ),
446
+ },
447
+ {
448
+ name: 'tree',
449
+ label: 'Tree',
450
+ description:
451
+ 'Renders an ASCII file-tree / outline fence as a filesystem-style treeview — edit as an outline.',
452
+ icon: (
453
+ <TemplateIcon>
454
+ <rect x={6} y={5} width={16} height={7} rx={1.5} fill={FA} opacity={0.85} />
422
455
  <path
423
- d="M 44 16 C 44 22, 32 22, 32 24"
424
- stroke={FA}
456
+ d="M 12 12 L 12 34 M 12 20 L 22 20 M 12 34 L 22 34"
457
+ stroke={F1}
425
458
  strokeWidth={1.5}
426
459
  fill="none"
427
460
  opacity={0.7}
428
461
  />
462
+ <rect x={24} y={16} width={14} height={7} rx={1.5} fill={F2} opacity={0.8} />
463
+ <rect x={24} y={30} width={14} height={7} rx={1.5} fill={F2} opacity={0.8} />
464
+ <rect x={26} y={2} width={14} height={7} rx={1.5} fill={F2} opacity={0.6} />
465
+ </TemplateIcon>
466
+ ),
467
+ },
468
+ {
469
+ name: 'timeline',
470
+ label: 'Timeline',
471
+ description:
472
+ 'Plots events across one or more horizontal tracks with callouts and optional branching links.',
473
+ icon: (
474
+ <TemplateIcon>
475
+ <path d="M 5 21 L 51 21" stroke={F1} strokeWidth={1.8} fill="none" />
476
+ <path d="M 47 18 L 52 21 L 47 24" stroke={F1} strokeWidth={1.5} fill="none" />
477
+ <circle cx={13} cy={21} r={3} fill={FA} />
478
+ <circle cx={29} cy={21} r={3} fill={F2} />
479
+ <circle cx={44} cy={21} r={3} fill={FA} />
480
+ <path d="M 13 18 L 13 10 M 29 24 L 29 32 M 44 18 L 44 8" stroke={F1} strokeWidth={1} />
481
+ <rect x={7} y={5} width={12} height={3} rx={1} fill={F2} opacity={0.8} />
482
+ <rect x={23} y={33} width={12} height={3} rx={1} fill={F2} opacity={0.8} />
483
+ <rect x={38} y={3} width={12} height={3} rx={1} fill={F2} opacity={0.8} />
429
484
  </TemplateIcon>
430
485
  ),
431
486
  },
@@ -513,6 +568,8 @@ export function templateLabel(
513
568
  export interface TemplatePickerProps {
514
569
  value: string;
515
570
  onChange: (name: string) => void;
571
+ /** Active editor chrome theme, used by the portaled dialog. */
572
+ colorScheme?: 'light' | 'dark';
516
573
  /** When true, shows only the trigger button (no popover) — used in the overflow menu. */
517
574
  compact?: boolean;
518
575
  /**
@@ -528,48 +585,37 @@ export interface TemplatePickerProps {
528
585
  * hidden.
529
586
  */
530
587
  onOpenDesigner?: () => void;
588
+ /**
589
+ * Active block content used to render live template thumbnails. When omitted,
590
+ * or when a template cannot be meaningfully derived from the block, cards use
591
+ * their static wireframe icons.
592
+ */
593
+ previewSource?: TemplatePreviewSource;
531
594
  }
532
595
 
533
596
  export function TemplatePicker({
534
597
  value,
535
598
  onChange,
599
+ colorScheme = 'light',
536
600
  compact,
537
601
  recommended,
538
602
  onOpenDesigner,
603
+ previewSource,
539
604
  }: TemplatePickerProps) {
540
605
  const [open, setOpen] = useState(false);
541
- const [popoverStyle, setPopoverStyle] = useState<React.CSSProperties>({});
606
+ const [dialogStyle, setDialogStyle] = useState<React.CSSProperties>({});
542
607
  const containerRef = useRef<HTMLDivElement>(null);
543
608
  const triggerRef = useRef<HTMLButtonElement>(null);
609
+ const dialogRef = useRef<HTMLDivElement>(null);
610
+ const dialogId = `${TEMPLATE_GALLERY_DIALOG_ID}-${useId().replace(/:/g, '')}`;
544
611
 
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 = () => {
612
+ const updateDialogBounds = () => {
551
613
  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
- });
614
+ setDialogStyle(computeDialogStyle(triggerRef.current));
569
615
  };
570
616
 
571
617
  const handleOpen = () => {
572
- updatePosition();
618
+ updateDialogBounds();
573
619
  setOpen((v) => !v);
574
620
  };
575
621
 
@@ -579,8 +625,8 @@ export function TemplatePicker({
579
625
  const handler = (e: MouseEvent) => {
580
626
  const target = e.target as Node;
581
627
  const inTrigger = triggerRef.current?.contains(target);
582
- const inPopover = document.getElementById('squisq-template-gallery-portal')?.contains(target);
583
- if (!inTrigger && !inPopover) {
628
+ const inDialog = dialogRef.current?.contains(target);
629
+ if (!inTrigger && !inDialog) {
584
630
  setOpen(false);
585
631
  }
586
632
  };
@@ -601,10 +647,8 @@ export function TemplatePicker({
601
647
  // Reposition on scroll/resize while open
602
648
  useEffect(() => {
603
649
  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();
650
+ requestAnimationFrame(updateDialogBounds);
651
+ const handler = () => updateDialogBounds();
608
652
  window.addEventListener('scroll', handler, true);
609
653
  window.addEventListener('resize', handler);
610
654
  return () => {
@@ -621,6 +665,7 @@ export function TemplatePicker({
621
665
  const currentLabel = templateLabel(value);
622
666
  const currentEntry: TemplateEntry =
623
667
  (value && TEMPLATE_ENTRIES.find((e) => e.name === value)) || NONE_ENTRY;
668
+ const dialogTitle = templateDialogTitle(previewSource);
624
669
 
625
670
  if (compact) {
626
671
  // In overflow menu, use a simple select for space efficiency
@@ -642,13 +687,23 @@ export function TemplatePicker({
642
687
 
643
688
  const gallery = open
644
689
  ? createPortal(
645
- <TemplateGalleryBody
646
- value={value}
647
- onSelect={handleSelect}
648
- style={popoverStyle}
649
- recommended={recommended}
650
- onOpenDesigner={onOpenDesigner}
651
- />,
690
+ <TemplateGalleryDialog
691
+ dialogRef={dialogRef}
692
+ dialogId={dialogId}
693
+ title={dialogTitle}
694
+ colorScheme={colorScheme}
695
+ style={dialogStyle}
696
+ onClose={() => setOpen(false)}
697
+ >
698
+ <TemplateGalleryBody
699
+ value={value}
700
+ onSelect={handleSelect}
701
+ style={{}}
702
+ recommended={recommended}
703
+ onOpenDesigner={onOpenDesigner}
704
+ previewSource={previewSource}
705
+ />
706
+ </TemplateGalleryDialog>,
652
707
  document.body,
653
708
  )
654
709
  : null;
@@ -660,9 +715,10 @@ export function TemplatePicker({
660
715
  type="button"
661
716
  className={`squisq-template-picker-trigger${open ? ' squisq-template-picker-trigger--open' : ''}`}
662
717
  onClick={handleOpen}
663
- aria-haspopup="listbox"
718
+ aria-haspopup="dialog"
664
719
  aria-expanded={open}
665
- title="Choose block template"
720
+ aria-controls={open ? dialogId : undefined}
721
+ title="Choose block type"
666
722
  >
667
723
  <span className="squisq-template-picker-trigger-label">Block:</span>
668
724
  <span className="squisq-template-picker-trigger-thumb" aria-hidden="true">
@@ -695,8 +751,66 @@ export function TemplatePicker({
695
751
 
696
752
  // ── Reusable gallery body ──────────────────────────────────────────
697
753
 
754
+ function TemplateGalleryDialog({
755
+ dialogRef,
756
+ dialogId,
757
+ children,
758
+ title,
759
+ colorScheme,
760
+ style,
761
+ onClose,
762
+ }: {
763
+ dialogRef: React.RefObject<HTMLDivElement>;
764
+ dialogId: string;
765
+ children: React.ReactNode;
766
+ title: string;
767
+ colorScheme: 'light' | 'dark';
768
+ style: React.CSSProperties;
769
+ onClose: () => void;
770
+ }) {
771
+ return (
772
+ <div
773
+ ref={dialogRef}
774
+ id={dialogId}
775
+ className="squisq-template-gallery-dialog"
776
+ data-theme={colorScheme}
777
+ style={style}
778
+ onMouseDown={(event) => {
779
+ if (event.target === event.currentTarget) onClose();
780
+ }}
781
+ >
782
+ <div
783
+ className="squisq-template-gallery-dialog-panel"
784
+ role="dialog"
785
+ aria-modal="true"
786
+ aria-label={title}
787
+ >
788
+ <div className="squisq-template-gallery-dialog-header">
789
+ <h2 className="squisq-template-gallery-dialog-title">{title}</h2>
790
+ <button
791
+ type="button"
792
+ className="squisq-template-gallery-dialog-close"
793
+ onClick={onClose}
794
+ aria-label="Close block type picker"
795
+ >
796
+ <svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true">
797
+ <path
798
+ d="M3.2 3.2l7.6 7.6M10.8 3.2l-7.6 7.6"
799
+ stroke="currentColor"
800
+ strokeWidth="1.7"
801
+ strokeLinecap="round"
802
+ />
803
+ </svg>
804
+ </button>
805
+ </div>
806
+ <div className="squisq-template-gallery-dialog-body">{children}</div>
807
+ </div>
808
+ </div>
809
+ );
810
+ }
811
+
698
812
  /**
699
- * The popover/grid markup shared by the toolbar `TemplatePicker` and the
813
+ * The dialog/grid markup shared by the toolbar `TemplatePicker` and the
700
814
  * inline `TemplateBadgeMenu`. Renders all template cards plus the
701
815
  * "(none)" option; no positioning logic — callers supply `style` (typically
702
816
  * a `position: fixed` rect from `getBoundingClientRect()`).
@@ -705,10 +819,12 @@ function TemplateCard({
705
819
  entry,
706
820
  value,
707
821
  onSelect,
822
+ previewSource,
708
823
  }: {
709
824
  entry: TemplateEntry;
710
825
  value: string;
711
826
  onSelect: (name: string) => void;
827
+ previewSource?: TemplatePreviewSource;
712
828
  }) {
713
829
  return (
714
830
  <button
@@ -719,7 +835,13 @@ function TemplateCard({
719
835
  onClick={() => onSelect(entry.name)}
720
836
  title={entry.description}
721
837
  >
722
- <div className="squisq-template-gallery-card-icon">{entry.icon}</div>
838
+ <div className="squisq-template-gallery-card-icon">
839
+ <TemplateContentPreview
840
+ templateName={entry.name}
841
+ source={previewSource}
842
+ fallback={entry.icon}
843
+ />
844
+ </div>
723
845
  <div className="squisq-template-gallery-card-body">
724
846
  <span className="squisq-template-gallery-card-name">{entry.label}</span>
725
847
  <span className="squisq-template-gallery-card-desc">{entry.description}</span>
@@ -728,19 +850,40 @@ function TemplateCard({
728
850
  );
729
851
  }
730
852
 
853
+ function splitBlockTypeEntriesByContent(
854
+ entries: readonly TemplateEntry[],
855
+ previewSource?: TemplatePreviewSource,
856
+ ): { blockTypeEntries: TemplateEntry[]; contentNeededEntries: TemplateEntry[] } {
857
+ if (!previewSource) return { blockTypeEntries: [...entries], contentNeededEntries: [] };
858
+
859
+ const blockTypeEntries: TemplateEntry[] = [];
860
+ const contentNeededEntries: TemplateEntry[] = [];
861
+
862
+ for (const entry of entries) {
863
+ const result = resolveTemplateContentPreviewResult(entry.name, previewSource);
864
+ if (result.warning) contentNeededEntries.push(entry);
865
+ else blockTypeEntries.push(entry);
866
+ }
867
+
868
+ return { blockTypeEntries, contentNeededEntries };
869
+ }
870
+
731
871
  function TemplateGalleryBody({
732
872
  value,
733
873
  onSelect,
734
874
  style,
735
875
  recommended,
736
876
  onOpenDesigner,
877
+ previewSource,
737
878
  }: {
738
879
  value: string;
739
880
  onSelect: (name: string) => void;
740
881
  style: React.CSSProperties;
741
882
  recommended?: readonly string[];
742
883
  onOpenDesigner?: () => void;
884
+ previewSource?: TemplatePreviewSource;
743
885
  }) {
886
+ const galleryId = useTemplateGalleryPortalId();
744
887
  const [query, setQuery] = useState('');
745
888
  const searchRef = useRef<HTMLInputElement>(null);
746
889
  // Pull merged doc + library templates from the surrounding context.
@@ -779,21 +922,34 @@ function TemplateGalleryBody({
779
922
  return { built, custom };
780
923
  }, [hasQuery, trimmedQuery, customTemplates]);
781
924
 
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;
925
+ const recommendedSet = useMemo(
926
+ () => (recommended && recommended.length > 0 ? new Set(recommended) : null),
927
+ [recommended],
928
+ );
929
+ const recommendedEntries = useMemo(
930
+ () => (recommendedSet ? TEMPLATE_ENTRIES.filter((e) => recommendedSet.has(e.name)) : []),
931
+ [recommendedSet],
932
+ );
933
+ const restEntries = useMemo(
934
+ () =>
935
+ recommendedSet
936
+ ? TEMPLATE_ENTRIES.filter((e) => !recommendedSet.has(e.name))
937
+ : TEMPLATE_ENTRIES,
938
+ [recommendedSet],
939
+ );
940
+ const { blockTypeEntries, contentNeededEntries } = useMemo(
941
+ () => splitBlockTypeEntriesByContent(restEntries, previewSource),
942
+ [restEntries, previewSource],
943
+ );
944
+ const grouped = !hasQuery && (recommendedEntries.length > 0 || contentNeededEntries.length > 0);
790
945
 
791
946
  return (
792
947
  <div
793
- id="squisq-template-gallery-portal"
794
- className={`squisq-template-gallery${segmented ? ' squisq-template-gallery--segmented' : ''}`}
948
+ id={galleryId}
949
+ data-squisq-template-gallery-portal=""
950
+ className={`squisq-template-gallery${grouped ? ' squisq-template-gallery--segmented' : ''}`}
795
951
  role="listbox"
796
- aria-label="Block templates"
952
+ aria-label="Block types"
797
953
  style={style}
798
954
  >
799
955
  <div className="squisq-template-gallery-search">
@@ -812,10 +968,10 @@ function TemplateGalleryBody({
812
968
  ref={searchRef}
813
969
  type="search"
814
970
  className="squisq-template-gallery-search-input"
815
- placeholder="Search templates…"
971
+ placeholder="Search block types…"
816
972
  value={query}
817
973
  onChange={(e) => setQuery(e.target.value)}
818
- aria-label="Search block templates"
974
+ aria-label="Search block types"
819
975
  />
820
976
  </div>
821
977
 
@@ -854,13 +1010,19 @@ function TemplateGalleryBody({
854
1010
  {matches.built.length > 0 && (
855
1011
  <div className="squisq-template-gallery-grid">
856
1012
  {matches.built.map((entry) => (
857
- <TemplateCard key={entry.name} entry={entry} value={value} onSelect={onSelect} />
1013
+ <TemplateCard
1014
+ key={entry.name}
1015
+ entry={entry}
1016
+ value={value}
1017
+ onSelect={onSelect}
1018
+ previewSource={previewSource}
1019
+ />
858
1020
  ))}
859
1021
  </div>
860
1022
  )}
861
1023
  </>
862
1024
  ) : (
863
- <div className="squisq-template-gallery-empty">No templates match "{query}".</div>
1025
+ <div className="squisq-template-gallery-empty">No block types match "{query}".</div>
864
1026
  )
865
1027
  ) : (
866
1028
  <>
@@ -872,9 +1034,9 @@ function TemplateGalleryBody({
872
1034
  +
873
1035
  </span>
874
1036
  <span className="squisq-template-gallery-new-body">
875
- <span className="squisq-template-gallery-new-label">New layout</span>
1037
+ <span className="squisq-template-gallery-new-label">New block type</span>
876
1038
  <span className="squisq-template-gallery-new-desc">
877
- Design a reusable layout with placeholders for {'{title}'} and {'{content}'}.
1039
+ Design a reusable block type with placeholders for {'{title}'} and {'{content}'}.
878
1040
  </span>
879
1041
  </span>
880
1042
  </button>
@@ -891,30 +1053,69 @@ function TemplateGalleryBody({
891
1053
  </div>
892
1054
  )}
893
1055
 
894
- {segmented && (
1056
+ {recommendedEntries.length > 0 && (
895
1057
  <div className="squisq-template-gallery-section">
896
- <h3 className="squisq-template-gallery-section-title">Recommended for this block</h3>
1058
+ <h3 className="squisq-template-gallery-section-title">Suggested Block Types</h3>
897
1059
  <div className="squisq-template-gallery-grid">
898
1060
  {recommendedEntries.map((entry) => (
899
- <TemplateCard key={entry.name} entry={entry} value={value} onSelect={onSelect} />
1061
+ <TemplateCard
1062
+ key={entry.name}
1063
+ entry={entry}
1064
+ value={value}
1065
+ onSelect={onSelect}
1066
+ previewSource={previewSource}
1067
+ />
900
1068
  ))}
901
1069
  </div>
902
1070
  </div>
903
1071
  )}
904
1072
 
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>
1073
+ {grouped ? (
1074
+ <>
1075
+ {blockTypeEntries.length > 0 && (
1076
+ <div className="squisq-template-gallery-section">
1077
+ <h3 className="squisq-template-gallery-section-title">Block Types</h3>
1078
+ <div className="squisq-template-gallery-grid">
1079
+ {blockTypeEntries.map((entry) => (
1080
+ <TemplateCard
1081
+ key={entry.name}
1082
+ entry={entry}
1083
+ value={value}
1084
+ onSelect={onSelect}
1085
+ previewSource={previewSource}
1086
+ />
1087
+ ))}
1088
+ </div>
1089
+ </div>
1090
+ )}
1091
+
1092
+ {contentNeededEntries.length > 0 && (
1093
+ <div className="squisq-template-gallery-section">
1094
+ <h3 className="squisq-template-gallery-section-title">Block Types for Content</h3>
1095
+ <div className="squisq-template-gallery-grid">
1096
+ {contentNeededEntries.map((entry) => (
1097
+ <TemplateCard
1098
+ key={entry.name}
1099
+ entry={entry}
1100
+ value={value}
1101
+ onSelect={onSelect}
1102
+ previewSource={previewSource}
1103
+ />
1104
+ ))}
1105
+ </div>
1106
+ </div>
1107
+ )}
1108
+ </>
914
1109
  ) : (
915
1110
  <div className="squisq-template-gallery-grid">
916
- {restEntries.map((entry) => (
917
- <TemplateCard key={entry.name} entry={entry} value={value} onSelect={onSelect} />
1111
+ {blockTypeEntries.map((entry) => (
1112
+ <TemplateCard
1113
+ key={entry.name}
1114
+ entry={entry}
1115
+ value={value}
1116
+ onSelect={onSelect}
1117
+ previewSource={previewSource}
1118
+ />
918
1119
  ))}
919
1120
  </div>
920
1121
  )}
@@ -957,7 +1158,7 @@ function CustomTemplateCard({
957
1158
  <div className="squisq-template-gallery-card-body">
958
1159
  <span className="squisq-template-gallery-card-name">{def.label}</span>
959
1160
  <span className="squisq-template-gallery-card-desc">
960
- {def.description ?? 'Custom template'}
1161
+ {def.description ?? 'Custom block type'}
961
1162
  </span>
962
1163
  </div>
963
1164
  </button>
@@ -973,6 +1174,8 @@ export interface TemplateBadgePopoverProps {
973
1174
  value: string;
974
1175
  onChange: (name: string) => void;
975
1176
  onClose: () => void;
1177
+ /** Active editor chrome theme, used by the portaled dialog. */
1178
+ colorScheme?: 'light' | 'dark';
976
1179
  /** Optional list of template names to surface as "Recommended for this block". */
977
1180
  recommended?: readonly string[];
978
1181
  /**
@@ -981,27 +1184,35 @@ export interface TemplateBadgePopoverProps {
981
1184
  * it closes the popover and opens the designer.
982
1185
  */
983
1186
  onOpenDesigner?: () => void;
1187
+ /**
1188
+ * Active block content used to render live template thumbnails. Falls back to
1189
+ * static icons when a candidate cannot be derived.
1190
+ */
1191
+ previewSource?: TemplatePreviewSource;
984
1192
  }
985
1193
 
986
1194
  /**
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.
1195
+ * Standalone dialog that mirrors the toolbar `TemplatePicker`'s gallery
1196
+ * when opened from an inline heading badge.
991
1197
  */
992
1198
  export function TemplateBadgePopover({
993
1199
  anchorRect,
994
1200
  value,
995
1201
  onChange,
996
1202
  onClose,
1203
+ colorScheme = 'light',
997
1204
  recommended,
998
1205
  onOpenDesigner,
1206
+ previewSource,
999
1207
  }: TemplateBadgePopoverProps) {
1000
- const [style, setStyle] = useState<React.CSSProperties>(() => computePopoverStyle(anchorRect));
1208
+ const [style, setStyle] = useState<React.CSSProperties>(() => computeDialogStyle(anchorRect));
1209
+ const dialogRef = useRef<HTMLDivElement>(null);
1210
+ const dialogId = `${TEMPLATE_GALLERY_DIALOG_ID}-${useId().replace(/:/g, '')}`;
1001
1211
 
1002
- // Reposition once after mount using the actual rendered popover width.
1212
+ // Reposition once after mount so the dialog covers the current editor shell.
1003
1213
  useEffect(() => {
1004
- requestAnimationFrame(() => setStyle(computePopoverStyle(anchorRect)));
1214
+ const frame = requestAnimationFrame(() => setStyle(computeDialogStyle(anchorRect)));
1215
+ return () => cancelAnimationFrame(frame);
1005
1216
  }, [anchorRect]);
1006
1217
 
1007
1218
  // Outside click + Escape close
@@ -1011,8 +1222,8 @@ export function TemplateBadgePopover({
1011
1222
  };
1012
1223
  const onMouse = (e: MouseEvent) => {
1013
1224
  const target = e.target as Node;
1014
- const inPopover = document.getElementById('squisq-template-gallery-portal')?.contains(target);
1015
- if (!inPopover) onClose();
1225
+ const inDialog = dialogRef.current?.contains(target);
1226
+ if (!inDialog) onClose();
1016
1227
  };
1017
1228
  // Defer the mousedown listener by one frame so the click that opened
1018
1229
  // us doesn't immediately close us.
@@ -1032,62 +1243,72 @@ export function TemplateBadgePopover({
1032
1243
  onClose();
1033
1244
  };
1034
1245
 
1246
+ const dialogTitle = templateDialogTitle(previewSource);
1247
+
1035
1248
  return createPortal(
1036
- <TemplateGalleryBody
1037
- value={value}
1038
- onSelect={handleSelect}
1249
+ <TemplateGalleryDialog
1250
+ dialogRef={dialogRef}
1251
+ dialogId={dialogId}
1252
+ title={dialogTitle}
1253
+ colorScheme={colorScheme}
1039
1254
  style={style}
1040
- recommended={recommended}
1041
- onOpenDesigner={onOpenDesigner}
1042
- />,
1255
+ onClose={onClose}
1256
+ >
1257
+ <TemplateGalleryBody
1258
+ value={value}
1259
+ onSelect={handleSelect}
1260
+ style={{}}
1261
+ recommended={recommended}
1262
+ onOpenDesigner={onOpenDesigner}
1263
+ previewSource={previewSource}
1264
+ />
1265
+ </TemplateGalleryDialog>,
1043
1266
  document.body,
1044
1267
  );
1045
1268
  }
1046
1269
 
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;
1270
+ function templateDialogTitle(previewSource?: TemplatePreviewSource): string {
1271
+ return `Block Type for ${templateDialogBlockTitle(previewSource)}`;
1272
+ }
1060
1273
 
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
- }
1274
+ function templateDialogBlockTitle(previewSource?: TemplatePreviewSource): string {
1275
+ const block = previewSource?.block;
1276
+ if (!block) return 'this block';
1277
+
1278
+ const headingText = block.sourceHeading ? extractPlainText(block.sourceHeading).trim() : '';
1279
+ const title = (headingText || block.title || '').replace(/\s+/g, ' ').trim();
1280
+ return title || 'this block';
1281
+ }
1282
+
1283
+ function computeDialogStyle(anchor: Element | DOMRect): React.CSSProperties {
1284
+ const shellRect = findEditorShellRect(anchor);
1285
+ const left = Math.max(0, shellRect.left);
1286
+ const top = Math.max(0, shellRect.top);
1287
+ const right = Math.min(window.innerWidth, shellRect.right);
1288
+ const bottom = Math.min(window.innerHeight, shellRect.bottom);
1082
1289
 
1083
1290
  return {
1084
1291
  position: 'fixed',
1085
1292
  top,
1086
1293
  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',
1294
+ width: Math.max(0, right - left),
1295
+ height: Math.max(0, bottom - top),
1091
1296
  zIndex: 9999,
1092
1297
  };
1093
1298
  }
1299
+
1300
+ function findEditorShellRect(anchor: Element | DOMRect): DOMRect {
1301
+ if (anchor instanceof Element) {
1302
+ const shell = anchor.closest('.squisq-editor-shell');
1303
+ if (shell) return shell.getBoundingClientRect();
1304
+ } else {
1305
+ const x = anchor.left + anchor.width / 2;
1306
+ const y = anchor.top + anchor.height / 2;
1307
+ for (const element of document.elementsFromPoint(x, y)) {
1308
+ const shell = element.closest('.squisq-editor-shell');
1309
+ if (shell) return shell.getBoundingClientRect();
1310
+ }
1311
+ }
1312
+
1313
+ return new DOMRect(0, 0, window.innerWidth, window.innerHeight);
1314
+ }