@bendyline/squisq-editor-react 1.5.2 → 1.6.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 (165) hide show
  1. package/dist/index.d.ts +757 -20
  2. package/dist/index.js +16910 -6844
  3. package/dist/index.js.map +1 -1
  4. package/package.json +4 -4
  5. package/src/BlockCardView.tsx +121 -0
  6. package/src/BlockPreviewPanel.tsx +69 -0
  7. package/src/BlockPropertiesPopover.tsx +191 -0
  8. package/src/EditorContext.tsx +143 -0
  9. package/src/EditorShell.tsx +200 -120
  10. package/src/FolderView.tsx +131 -0
  11. package/src/Icon.tsx +26 -0
  12. package/src/ImageEditor.tsx +69 -22
  13. package/src/OutlinePanel.tsx +38 -3
  14. package/src/PlainHtmlPreview.tsx +30 -3
  15. package/src/PreviewControls.tsx +180 -8
  16. package/src/RawEditor.tsx +216 -29
  17. package/src/RecorderEntry.tsx +9 -16
  18. package/src/TemplateAnnotation.ts +44 -0
  19. package/src/TemplatePicker.tsx +329 -54
  20. package/src/ThemeCustomizerPanel.tsx +30 -336
  21. package/src/ThemePicker.tsx +112 -3
  22. package/src/TimelineBlockPreview.tsx +37 -0
  23. package/src/TimelineTrack.tsx +671 -0
  24. package/src/Toolbar.tsx +528 -174
  25. package/src/Tooltip.tsx +22 -4
  26. package/src/TransitionPicker.tsx +351 -0
  27. package/src/VersionHistoryPanel.tsx +61 -31
  28. package/src/ViewMenuPanel.tsx +17 -14
  29. package/src/WysiwygEditor.tsx +161 -65
  30. package/src/__tests__/blockProperties.test.ts +92 -0
  31. package/src/__tests__/blockRange.test.ts +105 -0
  32. package/src/__tests__/buildPreviewDocTransition.test.ts +73 -0
  33. package/src/__tests__/createShapeLayer.test.ts +46 -0
  34. package/src/__tests__/drawingShapeRoundTrip.test.ts +49 -0
  35. package/src/__tests__/embeddedMedia.test.ts +48 -0
  36. package/src/__tests__/headingTransition.test.ts +138 -0
  37. package/src/__tests__/layoutChildRoundTrip.test.ts +71 -0
  38. package/src/__tests__/plainHtmlPreview.test.tsx +10 -8
  39. package/src/__tests__/recorderMediaInsert.test.ts +86 -0
  40. package/src/__tests__/templateAnnotationRoundTrip.test.ts +18 -0
  41. package/src/__tests__/templatePickerMetadata.test.ts +32 -0
  42. package/src/__tests__/timelineSource.test.ts +134 -0
  43. package/src/__tests__/tiptapBridge.test.ts +92 -0
  44. package/src/__tests__/tiptapBridgeConformance.test.ts +47 -0
  45. package/src/__tests__/tooltip.test.tsx +72 -0
  46. package/src/__tests__/transitionCatalog.test.ts +64 -0
  47. package/src/__tests__/useBlockNavigator.test.tsx +67 -0
  48. package/src/__tests__/useMediaRecorder.test.ts +24 -0
  49. package/src/__tests__/useTimelineClock.test.ts +21 -0
  50. package/src/blockProperties.ts +88 -0
  51. package/src/blockRange.ts +132 -0
  52. package/src/buildPreviewDoc.ts +98 -9
  53. package/src/customTemplates/AddBin.tsx +126 -0
  54. package/src/customTemplates/CustomLayoutManager.tsx +233 -0
  55. package/src/customTemplates/CustomTemplateContext.tsx +182 -0
  56. package/src/customTemplates/LayerToolbar.tsx +580 -0
  57. package/src/customTemplates/ShapeGlyph.tsx +47 -0
  58. package/src/customTemplates/TemplateDesigner.tsx +430 -0
  59. package/src/customTemplates/__tests__/library.test.ts +88 -0
  60. package/src/customTemplates/__tests__/normalizePositions.test.ts +109 -0
  61. package/src/customTemplates/__tests__/shapeDefs.test.ts +49 -0
  62. package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +95 -0
  63. package/src/customTemplates/designer.css +673 -0
  64. package/src/customTemplates/index.ts +31 -0
  65. package/src/customTemplates/library.ts +97 -0
  66. package/src/customTemplates/normalizePositions.ts +75 -0
  67. package/src/customTemplates/shapeDefs.ts +131 -0
  68. package/src/customTemplates/thumbnail.tsx +63 -0
  69. package/src/customTemplates/tokenDefs.ts +60 -0
  70. package/src/customTemplates/useDocCustomTemplates.ts +52 -0
  71. package/src/customTemplates/useMemoryLayerAdapter.ts +123 -0
  72. package/src/customThemes/CustomThemeContext.tsx +179 -0
  73. package/src/customThemes/CustomThemeDialog.tsx +286 -0
  74. package/src/customThemes/__tests__/CustomThemeContext.test.tsx +64 -0
  75. package/src/customThemes/__tests__/CustomThemeDialog.test.tsx +47 -0
  76. package/src/customThemes/__tests__/customThemeLibrary.test.ts +51 -0
  77. package/src/customThemes/customThemeLibrary.ts +97 -0
  78. package/src/customThemes/index.ts +31 -0
  79. package/src/customThemes/themeControls.tsx +229 -0
  80. package/src/customThemes/themeDraft.ts +272 -0
  81. package/src/customThemes/useDocCustomThemes.ts +49 -0
  82. package/src/diagram/DiagramCanvas.tsx +240 -0
  83. package/src/diagram/DiagramExtension.ts +209 -0
  84. package/src/diagram/DiagramMaximizedOverlay.tsx +46 -0
  85. package/src/diagram/DiagramWidget.tsx +270 -0
  86. package/src/diagram/diagramCommands.ts +604 -0
  87. package/src/diagram/diagramConstants.ts +17 -0
  88. package/src/diagram/useDiagramData.ts +126 -0
  89. package/src/embeddedMedia.ts +78 -0
  90. package/src/frontmatter.ts +29 -0
  91. package/src/headingTransition.ts +231 -0
  92. package/src/imageEditor/CanvasSurface.tsx +383 -88
  93. package/src/imageEditor/PropertiesPanel.tsx +47 -1
  94. package/src/imageEditor/Toolbar.tsx +229 -16
  95. package/src/imageEditor/createShapeLayer.ts +280 -0
  96. package/src/imageEditor/icons.tsx +34 -114
  97. package/src/imageEditor/image-editor.css +54 -5
  98. package/src/imageEditor/state.ts +23 -3
  99. package/src/index.ts +77 -0
  100. package/src/recorder/RecorderModal.tsx +120 -53
  101. package/src/recorder/RecorderPanel.tsx +2 -26
  102. package/src/recorder/hooks/useMediaRecorder.ts +17 -2
  103. package/src/recorder/insertMediaBlock.ts +30 -0
  104. package/src/resolveBlockVisual.ts +33 -0
  105. package/src/scene/Scene.tsx +540 -0
  106. package/src/scene/SceneBlockExtension.ts +198 -0
  107. package/src/scene/SceneBlockToolbar.tsx +201 -0
  108. package/src/scene/SceneBlockWidget.tsx +434 -0
  109. package/src/scene/ScenePropsBar.tsx +85 -0
  110. package/src/scene/SceneSelection.tsx +107 -0
  111. package/src/scene/SceneViewport.tsx +102 -0
  112. package/src/scene/ShapePalette.tsx +181 -0
  113. package/src/scene/__tests__/DiagramAdapter.test.ts +56 -0
  114. package/src/scene/__tests__/bezierEdit.test.ts +85 -0
  115. package/src/scene/__tests__/blockLayers.test.ts +57 -0
  116. package/src/scene/__tests__/shapeLayers.test.ts +106 -0
  117. package/src/scene/__tests__/useSceneHitTest.test.ts +90 -0
  118. package/src/scene/__tests__/useScenePanZoom.test.ts +103 -0
  119. package/src/scene/adapters/DiagramAdapter.ts +168 -0
  120. package/src/scene/adapters/DrawingAdapter.ts +415 -0
  121. package/src/scene/adapters/LayoutAdapter.ts +310 -0
  122. package/src/scene/adapters/blockLayers.ts +159 -0
  123. package/src/scene/commands/SceneCommand.ts +70 -0
  124. package/src/scene/commands/drawingCommands.ts +318 -0
  125. package/src/scene/commands/layoutCommands.ts +301 -0
  126. package/src/scene/hooks/useSceneHitTest.ts +105 -0
  127. package/src/scene/hooks/useScenePanZoom.ts +147 -0
  128. package/src/scene/hooks/useSceneSelection.ts +62 -0
  129. package/src/scene/index.ts +95 -0
  130. package/src/scene/layers/DiagramEdges.tsx +127 -0
  131. package/src/scene/layers/edgeGeometry.ts +77 -0
  132. package/src/scene/layers/nodeCard.tsx +145 -0
  133. package/src/scene/layers/renderLayer.tsx +70 -0
  134. package/src/scene/layers/shapeLayers.ts +201 -0
  135. package/src/scene/paths/bezierEdit.ts +208 -0
  136. package/src/scene/scene.css +649 -0
  137. package/src/scene/text/SceneTextOverlay.tsx +161 -0
  138. package/src/scene/text/sceneTextChannel.ts +40 -0
  139. package/src/scene/text/sceneTextConfig.ts +27 -0
  140. package/src/scene/text/sceneTiptap.ts +36 -0
  141. package/src/scene/text/useSceneTextEditing.ts +39 -0
  142. package/src/scene/tools/ConnectTool.ts +111 -0
  143. package/src/scene/tools/DrawingConnectTool.ts +161 -0
  144. package/src/scene/tools/PathTool.ts +158 -0
  145. package/src/scene/tools/PlaceTool.ts +47 -0
  146. package/src/scene/tools/SceneTool.ts +75 -0
  147. package/src/scene/tools/SelectTool.ts +284 -0
  148. package/src/scene/tools/ShapeTool.ts +144 -0
  149. package/src/scene/tools/TextTool.ts +72 -0
  150. package/src/scene/tools/TokenTool.ts +95 -0
  151. package/src/scene/tools/createDrawShapeTool.ts +82 -0
  152. package/src/styles/diagram.css +183 -0
  153. package/src/styles/editor.css +1615 -203
  154. package/src/styles/folder-view.css +210 -0
  155. package/src/styles/image-edit-affordance.css +2 -2
  156. package/src/styles/index.css +4 -0
  157. package/src/timelineSource.ts +244 -0
  158. package/src/tiptapBridge.ts +115 -34
  159. package/src/tooltipPlacement.ts +13 -0
  160. package/src/transitionCatalog.ts +159 -0
  161. package/src/types/monaco-shims.d.ts +10 -0
  162. package/src/useBlockNavigator.ts +153 -0
  163. package/src/useMonacoLoader.ts +105 -0
  164. package/src/useTimelineClock.ts +76 -0
  165. package/src/utils/dropUtils.ts +1 -1
@@ -6,10 +6,22 @@
6
6
  * and a one-sentence description so authors can quickly find the right layout.
7
7
  */
8
8
 
9
- import { useEffect, useRef, useState } from 'react';
9
+ import { useEffect, useMemo, useRef, useState } from 'react';
10
10
  import { createPortal } from 'react-dom';
11
+ import type { CustomTemplateDefinition } from '@bendyline/squisq/schemas';
12
+ import { TEMPLATE_METADATA, resolveTemplateName } from '@bendyline/squisq/doc';
13
+ import { useCustomTemplates } from './customTemplates/CustomTemplateContext';
14
+ import { TemplateThumbnail } from './customTemplates/thumbnail';
11
15
 
12
16
  // ── Template metadata ─────────────────────────────────────────────
17
+ //
18
+ // The canonical list of built-in templates (which ids exist, their labels,
19
+ // and their descriptions) lives in core's `TEMPLATE_METADATA`. The entries
20
+ // below add the one thing core can't hold — a React/SVG preview icon per id —
21
+ // and MUST stay 1:1 with `TEMPLATE_METADATA` (same ids, same order, same
22
+ // label/description). `templatePickerMetadata.test.ts` enforces this, so a
23
+ // template added to the core registry without a picker icon (or vice versa)
24
+ // fails the build instead of silently disappearing from the gallery.
13
25
 
14
26
  interface TemplateEntry {
15
27
  name: string;
@@ -65,7 +77,8 @@ const NONE_ENTRY: TemplateEntry = {
65
77
  ),
66
78
  };
67
79
 
68
- const TEMPLATE_ENTRIES: TemplateEntry[] = [
80
+ // eslint-disable-next-line react-refresh/only-export-components
81
+ export const TEMPLATE_ENTRIES: TemplateEntry[] = [
69
82
  {
70
83
  name: 'title',
71
84
  label: 'Title',
@@ -381,6 +394,85 @@ const TEMPLATE_ENTRIES: TemplateEntry[] = [
381
394
  </TemplateIcon>
382
395
  ),
383
396
  },
397
+ {
398
+ name: 'diagram',
399
+ label: 'Diagram',
400
+ description:
401
+ 'Renders sub-headings as connected nodes — edit visually as a node-and-edge diagram.',
402
+ icon: (
403
+ <TemplateIcon>
404
+ <rect x={4} y={6} width={16} height={10} rx={2} fill={FA} opacity={0.85} />
405
+ <rect x={36} y={6} width={16} height={10} rx={2} fill={F2} opacity={0.8} />
406
+ <rect x={20} y={24} width={16} height={10} rx={2} fill={F2} opacity={0.8} />
407
+ <path
408
+ d="M 20 11 L 36 11"
409
+ stroke={FA}
410
+ strokeWidth={1.5}
411
+ fill="none"
412
+ opacity={0.7}
413
+ markerEnd="url(#tp-diagram-arrow)"
414
+ />
415
+ <path
416
+ d="M 12 16 C 12 22, 24 22, 24 24"
417
+ stroke={FA}
418
+ strokeWidth={1.5}
419
+ fill="none"
420
+ opacity={0.7}
421
+ />
422
+ <path
423
+ d="M 44 16 C 44 22, 32 22, 32 24"
424
+ stroke={FA}
425
+ strokeWidth={1.5}
426
+ fill="none"
427
+ opacity={0.7}
428
+ />
429
+ </TemplateIcon>
430
+ ),
431
+ },
432
+ {
433
+ name: 'layout',
434
+ label: 'Layout',
435
+ description:
436
+ "Free-form 2D canvas — drag layers into place. Use for one-off block layouts that don't fit a template.",
437
+ icon: (
438
+ <TemplateIcon>
439
+ <rect
440
+ x={3}
441
+ y={3}
442
+ width={50}
443
+ height={34}
444
+ rx={2}
445
+ fill="none"
446
+ stroke={F1}
447
+ strokeWidth={1}
448
+ strokeDasharray="3 2"
449
+ />
450
+ <rect x={7} y={7} width={20} height={12} rx={1.5} fill={FA} opacity={0.85} />
451
+ <rect x={30} y={7} width={20} height={26} rx={1.5} fill={F2} opacity={0.7} />
452
+ <rect x={7} y={22} width={20} height={11} rx={1.5} fill={F1} opacity={0.7} />
453
+ </TemplateIcon>
454
+ ),
455
+ },
456
+ {
457
+ name: 'drawing',
458
+ label: 'Drawing',
459
+ description: 'Free-form sketches — draw shapes, paths, and text directly on a 2D surface.',
460
+ icon: (
461
+ <TemplateIcon>
462
+ <rect x={3} y={3} width={50} height={34} rx={2} fill="none" stroke={F1} strokeWidth={1} />
463
+ <path
464
+ d="M 8 28 C 14 12, 26 14, 30 22 S 44 32, 50 14"
465
+ stroke={FA}
466
+ strokeWidth={2}
467
+ fill="none"
468
+ opacity={0.85}
469
+ strokeLinecap="round"
470
+ />
471
+ <circle cx={10} cy={10} r={2.5} fill={F2} />
472
+ <rect x={40} y={28} width={8} height={6} rx={1} fill={F2} opacity={0.7} />
473
+ </TemplateIcon>
474
+ ),
475
+ },
384
476
  ];
385
477
 
386
478
  /**
@@ -399,27 +491,23 @@ export const TEMPLATE_NAMES: readonly string[] = TEMPLATE_ENTRIES.map((e) => e.n
399
491
  * normalizing their annotations.
400
492
  */
401
493
  // eslint-disable-next-line react-refresh/only-export-components
402
- export function templateLabel(name: string): string {
494
+ export function templateLabel(
495
+ name: string,
496
+ customTemplates?: readonly CustomTemplateDefinition[],
497
+ ): string {
403
498
  if (!name) return '— none —';
404
- const resolved = TEMPLATE_NAME_ALIASES[name] ?? name;
405
- const entry = TEMPLATE_ENTRIES.find((e) => e.name === resolved);
406
- if (entry) return entry.label;
499
+ // `resolveTemplateName` + `TEMPLATE_METADATA` are the canonical source of
500
+ // truth in core; both legacy long ids (`titleBlock`) and short ids resolve
501
+ // through it, so the picker never has to maintain its own alias/label table.
502
+ const resolved = resolveTemplateName(name);
503
+ const meta = TEMPLATE_METADATA[resolved];
504
+ if (meta) return meta.label;
505
+ const custom = customTemplates?.find((t) => t.name === resolved);
506
+ if (custom) return custom.label;
407
507
  // Fallback: split camelCase
408
508
  return resolved.replace(/([A-Z])/g, ' $1').replace(/^./, (s) => s.toUpperCase());
409
509
  }
410
510
 
411
- /**
412
- * Legacy template id → canonical short id. Kept inline to avoid pulling
413
- * the core package's runtime registry into the picker's bundle — the
414
- * picker only needs this for label resolution.
415
- */
416
- const TEMPLATE_NAME_ALIASES: Readonly<Record<string, string>> = {
417
- titleBlock: 'title',
418
- quoteBlock: 'quote',
419
- mapBlock: 'map',
420
- listBlock: 'list',
421
- };
422
-
423
511
  // ── Component ─────────────────────────────────────────────────────
424
512
 
425
513
  export interface TemplatePickerProps {
@@ -433,9 +521,22 @@ export interface TemplatePickerProps {
433
521
  * single ungrouped grid (legacy behavior).
434
522
  */
435
523
  recommended?: readonly string[];
524
+ /**
525
+ * Optional callback fired when the user clicks the "+ New custom
526
+ * template" card pinned at the top of the gallery. The host wires this
527
+ * to open the modal `TemplateDesigner`. When omitted, the card is
528
+ * hidden.
529
+ */
530
+ onOpenDesigner?: () => void;
436
531
  }
437
532
 
438
- export function TemplatePicker({ value, onChange, compact, recommended }: TemplatePickerProps) {
533
+ export function TemplatePicker({
534
+ value,
535
+ onChange,
536
+ compact,
537
+ recommended,
538
+ onOpenDesigner,
539
+ }: TemplatePickerProps) {
439
540
  const [open, setOpen] = useState(false);
440
541
  const [popoverStyle, setPopoverStyle] = useState<React.CSSProperties>({});
441
542
  const containerRef = useRef<HTMLDivElement>(null);
@@ -546,6 +647,7 @@ export function TemplatePicker({ value, onChange, compact, recommended }: Templa
546
647
  onSelect={handleSelect}
547
648
  style={popoverStyle}
548
649
  recommended={recommended}
650
+ onOpenDesigner={onOpenDesigner}
549
651
  />,
550
652
  document.body,
551
653
  )
@@ -631,12 +733,52 @@ function TemplateGalleryBody({
631
733
  onSelect,
632
734
  style,
633
735
  recommended,
736
+ onOpenDesigner,
634
737
  }: {
635
738
  value: string;
636
739
  onSelect: (name: string) => void;
637
740
  style: React.CSSProperties;
638
741
  recommended?: readonly string[];
742
+ onOpenDesigner?: () => void;
639
743
  }) {
744
+ const [query, setQuery] = useState('');
745
+ const searchRef = useRef<HTMLInputElement>(null);
746
+ // Pull merged doc + library templates from the surrounding context.
747
+ // When the host hasn't wrapped the editor in a CustomTemplateProvider
748
+ // we silently degrade to "no custom templates", which preserves the
749
+ // legacy behaviour for any caller that hasn't opted in.
750
+ const customCtx = useCustomTemplates();
751
+ const customTemplates = useMemo(() => customCtx?.allTemplates ?? [], [customCtx]);
752
+
753
+ // Auto-focus the search input when the gallery mounts so the user can
754
+ // start typing immediately to filter templates.
755
+ useEffect(() => {
756
+ searchRef.current?.focus();
757
+ }, []);
758
+
759
+ const trimmedQuery = query.trim().toLowerCase();
760
+ const hasQuery = trimmedQuery.length > 0;
761
+
762
+ // For search results we match both built-in entries and custom
763
+ // templates by label/name/description so the user can find a custom
764
+ // template by typing its name.
765
+ const matches = useMemo(() => {
766
+ if (!hasQuery) return null;
767
+ const built = TEMPLATE_ENTRIES.filter(
768
+ (e) =>
769
+ e.label.toLowerCase().includes(trimmedQuery) ||
770
+ e.description.toLowerCase().includes(trimmedQuery) ||
771
+ e.name.toLowerCase().includes(trimmedQuery),
772
+ );
773
+ const custom = customTemplates.filter(
774
+ (t) =>
775
+ t.label.toLowerCase().includes(trimmedQuery) ||
776
+ t.name.toLowerCase().includes(trimmedQuery) ||
777
+ (t.description ?? '').toLowerCase().includes(trimmedQuery),
778
+ );
779
+ return { built, custom };
780
+ }, [hasQuery, trimmedQuery, customTemplates]);
781
+
640
782
  const recommendedSet = recommended && recommended.length > 0 ? new Set(recommended) : null;
641
783
  const recommendedEntries = recommendedSet
642
784
  ? TEMPLATE_ENTRIES.filter((e) => recommendedSet.has(e.name))
@@ -644,7 +786,7 @@ function TemplateGalleryBody({
644
786
  const restEntries = recommendedSet
645
787
  ? TEMPLATE_ENTRIES.filter((e) => !recommendedSet.has(e.name))
646
788
  : TEMPLATE_ENTRIES;
647
- const segmented = recommendedEntries.length > 0;
789
+ const segmented = !hasQuery && recommendedEntries.length > 0;
648
790
 
649
791
  return (
650
792
  <div
@@ -654,49 +796,174 @@ function TemplateGalleryBody({
654
796
  aria-label="Block templates"
655
797
  style={style}
656
798
  >
657
- <button
658
- type="button"
659
- role="option"
660
- aria-selected={value === ''}
661
- className={`squisq-template-gallery-none${value === '' ? ' squisq-template-gallery-card--selected' : ''}`}
662
- onClick={() => onSelect('')}
663
- >
664
- {NONE_ENTRY.icon}
665
- <span className="squisq-template-gallery-none-label">{NONE_ENTRY.label}</span>
666
- <span className="squisq-template-gallery-none-desc">{NONE_ENTRY.description}</span>
667
- </button>
799
+ <div className="squisq-template-gallery-search">
800
+ <svg
801
+ className="squisq-template-gallery-search-icon"
802
+ width="14"
803
+ height="14"
804
+ viewBox="0 0 16 16"
805
+ fill="none"
806
+ aria-hidden="true"
807
+ >
808
+ <circle cx="7" cy="7" r="5" stroke="currentColor" strokeWidth="1.5" />
809
+ <path d="M11 11l3 3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
810
+ </svg>
811
+ <input
812
+ ref={searchRef}
813
+ type="search"
814
+ className="squisq-template-gallery-search-input"
815
+ placeholder="Search templates…"
816
+ value={query}
817
+ onChange={(e) => setQuery(e.target.value)}
818
+ aria-label="Search block templates"
819
+ />
820
+ </div>
668
821
 
669
- {segmented && (
670
- <div className="squisq-template-gallery-section">
671
- <h3 className="squisq-template-gallery-section-title">Recommended for this block</h3>
672
- <div className="squisq-template-gallery-grid">
673
- {recommendedEntries.map((entry) => (
674
- <TemplateCard key={entry.name} entry={entry} value={value} onSelect={onSelect} />
675
- ))}
676
- </div>
677
- </div>
822
+ {!hasQuery && (
823
+ <button
824
+ type="button"
825
+ role="option"
826
+ aria-selected={value === ''}
827
+ className={`squisq-template-gallery-none${value === '' ? ' squisq-template-gallery-card--selected' : ''}`}
828
+ onClick={() => onSelect('')}
829
+ >
830
+ {NONE_ENTRY.icon}
831
+ <span className="squisq-template-gallery-none-label">{NONE_ENTRY.label}</span>
832
+ <span className="squisq-template-gallery-none-desc">{NONE_ENTRY.description}</span>
833
+ </button>
678
834
  )}
679
835
 
680
- {segmented ? (
681
- <div className="squisq-template-gallery-section">
682
- <h3 className="squisq-template-gallery-section-title">All templates</h3>
683
- <div className="squisq-template-gallery-grid">
684
- {restEntries.map((entry) => (
685
- <TemplateCard key={entry.name} entry={entry} value={value} onSelect={onSelect} />
686
- ))}
687
- </div>
688
- </div>
836
+ {hasQuery ? (
837
+ matches && (matches.built.length > 0 || matches.custom.length > 0) ? (
838
+ <>
839
+ {matches.custom.length > 0 && (
840
+ <div className="squisq-template-gallery-section">
841
+ <h3 className="squisq-template-gallery-section-title">Custom</h3>
842
+ <div className="squisq-template-gallery-grid">
843
+ {matches.custom.map((def) => (
844
+ <CustomTemplateCard
845
+ key={def.name}
846
+ def={def}
847
+ value={value}
848
+ onSelect={onSelect}
849
+ />
850
+ ))}
851
+ </div>
852
+ </div>
853
+ )}
854
+ {matches.built.length > 0 && (
855
+ <div className="squisq-template-gallery-grid">
856
+ {matches.built.map((entry) => (
857
+ <TemplateCard key={entry.name} entry={entry} value={value} onSelect={onSelect} />
858
+ ))}
859
+ </div>
860
+ )}
861
+ </>
862
+ ) : (
863
+ <div className="squisq-template-gallery-empty">No templates match "{query}".</div>
864
+ )
689
865
  ) : (
690
- <div className="squisq-template-gallery-grid">
691
- {restEntries.map((entry) => (
692
- <TemplateCard key={entry.name} entry={entry} value={value} onSelect={onSelect} />
693
- ))}
694
- </div>
866
+ <>
867
+ {/* "+ New custom template" card pinned at the top — only shown
868
+ when the host has wired up `onOpenDesigner`. */}
869
+ {onOpenDesigner && (
870
+ <button type="button" className="squisq-template-gallery-new" onClick={onOpenDesigner}>
871
+ <span className="squisq-template-gallery-new-plus" aria-hidden="true">
872
+ +
873
+ </span>
874
+ <span className="squisq-template-gallery-new-body">
875
+ <span className="squisq-template-gallery-new-label">New layout</span>
876
+ <span className="squisq-template-gallery-new-desc">
877
+ Design a reusable layout with placeholders for {'{title}'} and {'{content}'}.
878
+ </span>
879
+ </span>
880
+ </button>
881
+ )}
882
+
883
+ {customTemplates.length > 0 && (
884
+ <div className="squisq-template-gallery-section">
885
+ <h3 className="squisq-template-gallery-section-title">Custom</h3>
886
+ <div className="squisq-template-gallery-grid">
887
+ {customTemplates.map((def) => (
888
+ <CustomTemplateCard key={def.name} def={def} value={value} onSelect={onSelect} />
889
+ ))}
890
+ </div>
891
+ </div>
892
+ )}
893
+
894
+ {segmented && (
895
+ <div className="squisq-template-gallery-section">
896
+ <h3 className="squisq-template-gallery-section-title">Recommended for this block</h3>
897
+ <div className="squisq-template-gallery-grid">
898
+ {recommendedEntries.map((entry) => (
899
+ <TemplateCard key={entry.name} entry={entry} value={value} onSelect={onSelect} />
900
+ ))}
901
+ </div>
902
+ </div>
903
+ )}
904
+
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>
914
+ ) : (
915
+ <div className="squisq-template-gallery-grid">
916
+ {restEntries.map((entry) => (
917
+ <TemplateCard key={entry.name} entry={entry} value={value} onSelect={onSelect} />
918
+ ))}
919
+ </div>
920
+ )}
921
+ </>
695
922
  )}
696
923
  </div>
697
924
  );
698
925
  }
699
926
 
927
+ /**
928
+ * Picker card for a user-authored template. Mirrors `TemplateCard`'s
929
+ * shape so it slots into the gallery grid, but draws the thumbnail by
930
+ * rendering the template's layers via `<TemplateThumbnail>`. Selecting
931
+ * it dispatches the template name; the host's `applyTemplate` (called
932
+ * separately by the badge update flow) inlines the def into the doc.
933
+ */
934
+ function CustomTemplateCard({
935
+ def,
936
+ value,
937
+ onSelect,
938
+ }: {
939
+ def: CustomTemplateDefinition;
940
+ value: string;
941
+ onSelect: (name: string) => void;
942
+ }) {
943
+ return (
944
+ <button
945
+ type="button"
946
+ role="option"
947
+ aria-selected={value === def.name}
948
+ className={`squisq-template-gallery-card${
949
+ value === def.name ? ' squisq-template-gallery-card--selected' : ''
950
+ }`}
951
+ onClick={() => onSelect(def.name)}
952
+ title={def.description ?? def.label}
953
+ >
954
+ <div className="squisq-template-gallery-card-icon">
955
+ <TemplateThumbnail def={def} />
956
+ </div>
957
+ <div className="squisq-template-gallery-card-body">
958
+ <span className="squisq-template-gallery-card-name">{def.label}</span>
959
+ <span className="squisq-template-gallery-card-desc">
960
+ {def.description ?? 'Custom template'}
961
+ </span>
962
+ </div>
963
+ </button>
964
+ );
965
+ }
966
+
700
967
  // ── Inline badge popover (anchored at a heading badge) ─────────────
701
968
 
702
969
  export interface TemplateBadgePopoverProps {
@@ -708,6 +975,12 @@ export interface TemplateBadgePopoverProps {
708
975
  onClose: () => void;
709
976
  /** Optional list of template names to surface as "Recommended for this block". */
710
977
  recommended?: readonly string[];
978
+ /**
979
+ * Optional callback for the "+ New custom template" card. When
980
+ * supplied, the gallery shows the card pinned at the top; clicking
981
+ * it closes the popover and opens the designer.
982
+ */
983
+ onOpenDesigner?: () => void;
711
984
  }
712
985
 
713
986
  /**
@@ -722,6 +995,7 @@ export function TemplateBadgePopover({
722
995
  onChange,
723
996
  onClose,
724
997
  recommended,
998
+ onOpenDesigner,
725
999
  }: TemplateBadgePopoverProps) {
726
1000
  const [style, setStyle] = useState<React.CSSProperties>(() => computePopoverStyle(anchorRect));
727
1001
 
@@ -764,6 +1038,7 @@ export function TemplateBadgePopover({
764
1038
  onSelect={handleSelect}
765
1039
  style={style}
766
1040
  recommended={recommended}
1041
+ onOpenDesigner={onOpenDesigner}
767
1042
  />,
768
1043
  document.body,
769
1044
  );