@bendyline/squisq-editor-react 1.5.3 → 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 +16875 -6883
  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 +169 -13
  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 +2 -14
  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 +8 -1
  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 +23 -1
  164. package/src/useTimelineClock.ts +76 -0
  165. package/src/utils/dropUtils.ts +1 -1
@@ -11,19 +11,40 @@
11
11
  * 4. Synthesize a dummy audio segment for timer-based playback
12
12
  */
13
13
 
14
- import { flattenBlocks, hasTemplate } from '@bendyline/squisq/doc';
15
- import { extractPlainText } from '@bendyline/squisq/markdown';
14
+ import { flattenRenderableBlocks, hasTemplate } from '@bendyline/squisq/doc';
15
+ import { extractPlainText, KNOWN_BLOCK_META_KEYS } from '@bendyline/squisq/markdown';
16
16
  import { getChildren } from '@bendyline/squisq/markdown';
17
+ import { iconMarker } from '@bendyline/squisq/icon-marker';
18
+ import type { IconFamily } from '@bendyline/squisq/icons';
17
19
  import type { Block, Doc } from '@bendyline/squisq/schemas';
18
20
  import type { MarkdownBlockNode, MarkdownList, MarkdownNode } from '@bendyline/squisq/markdown';
19
21
 
20
22
  // ── Helpers ────────────────────────────────────────────────────────
21
23
 
24
+ /**
25
+ * Like `extractPlainText`, but preserves inline icons as encoded markers so
26
+ * template text can render them as glyphs downstream (see `iconMarker` and
27
+ * `TextLayer`). Mirrors `extractPlainText`'s value/child/list handling; the
28
+ * only addition is the `inlineIcon` interception.
29
+ */
30
+ function extractRichText(node: MarkdownNode): string {
31
+ if (node.type === 'inlineIcon') {
32
+ const icon = node as unknown as { family: IconFamily; name: string };
33
+ return iconMarker(icon.family, icon.name);
34
+ }
35
+ if ('value' in node && typeof (node as { value?: unknown }).value === 'string') {
36
+ return (node as { value: string }).value;
37
+ }
38
+ const children = getChildren(node);
39
+ const separator = node.type === 'list' || node.type === 'listItem' ? '\n' : '';
40
+ return children.map(extractRichText).join(separator);
41
+ }
42
+
22
43
  function extractBodyText(contents: MarkdownBlockNode[] | undefined): string {
23
44
  if (!contents || contents.length === 0) return '';
24
45
  const parts: string[] = [];
25
46
  for (const node of contents) {
26
- parts.push(extractPlainText(node));
47
+ parts.push(extractRichText(node));
27
48
  }
28
49
  return parts.join('\n').trim();
29
50
  }
@@ -174,13 +195,23 @@ function getTemplateDefaults(
174
195
  }
175
196
  }
176
197
 
177
- function blockToSlide(block: Block, index: number): Record<string, unknown> {
198
+ function blockToSlide(
199
+ block: Block,
200
+ index: number,
201
+ knownTemplates?: ReadonlySet<string>,
202
+ ): Record<string, unknown> {
178
203
  const headingText = block.sourceHeading
179
204
  ? extractPlainText(block.sourceHeading)
180
205
  : block.title || block.id || `Slide ${index + 1}`;
181
206
 
182
207
  const requestedTemplate = block.template || 'sectionHeader';
183
- const template = hasTemplate(requestedTemplate) ? requestedTemplate : 'sectionHeader';
208
+ // A template is recognized if it's a built-in OR a user-defined
209
+ // template carried in the doc's `customTemplates` set. Without this,
210
+ // an annotated `{[hero]}` heading would silently fall back to
211
+ // `sectionHeader` because `hasTemplate` only knows built-ins.
212
+ const isCustomTemplate = knownTemplates?.has(requestedTemplate) ?? false;
213
+ const recognized = hasTemplate(requestedTemplate) || isCustomTemplate;
214
+ const template = recognized ? requestedTemplate : 'sectionHeader';
184
215
  const defaults = getTemplateDefaults(template, headingText, block);
185
216
 
186
217
  const {
@@ -196,6 +227,7 @@ function blockToSlide(block: Block, index: number): Record<string, unknown> {
196
227
  contents: _co,
197
228
  sourceHeading: _sh,
198
229
  templateOverrides: _to,
230
+ templateData: _td,
199
231
  ...extraFields
200
232
  } = block as unknown as Record<string, unknown>;
201
233
 
@@ -204,14 +236,59 @@ function blockToSlide(block: Block, index: number): Record<string, unknown> {
204
236
  template,
205
237
  duration: block.duration,
206
238
  audioSegment: 0,
207
- transition: index > 0 ? { type: 'fade', duration: 0.5 } : undefined,
239
+ // Respect the block's authored transition (set via the toolbar / on-canvas
240
+ // properties palette → `{…}` block attrs). Only fall back to a default fade
241
+ // for blocks past the first when the author hasn't chosen one; the first
242
+ // block has no previous slide to transition in from.
243
+ transition: block.transition ?? (index > 0 ? { type: 'fade', duration: 0.5 } : undefined),
208
244
  title: headingText,
245
+ // Custom templates need access to the source block's body content
246
+ // + children so their token resolver (`{content}`, `{children}`,
247
+ // `{image:N}`) substitutes against the user's prose, not just the
248
+ // heading. Built-in templates don't read these fields and risk
249
+ // surprising overlap with their typed inputs, so we only attach
250
+ // them when the slide actually maps to a custom template.
251
+ ...(isCustomTemplate && block.contents ? { contents: block.contents } : {}),
252
+ ...(isCustomTemplate && block.children ? { children: block.children } : {}),
209
253
  ...defaults,
210
254
  ...extraFields,
211
- ...block.templateOverrides,
255
+ // Structured body data (```json data fences, GFM tables for dataTable)
256
+ // carries typed values; `{[…]}` string overrides win last so an explicit
257
+ // annotation param can still pin any field.
258
+ //
259
+ // Block-meta keys (transition, startTime, duration, …) are the exception:
260
+ // they were already coerced to typed block fields above (e.g.
261
+ // `block.transition` → `{ type, duration, direction }`). Their raw string
262
+ // form also rides along in `templateData`/`templateOverrides` because the
263
+ // author wrote them inside `{[…]}`; left un-stripped, that string would
264
+ // spread back over the typed value here and clobber it — turning
265
+ // `transition=vortex` into the string `"vortex"`, which the player can't
266
+ // animate. Omit them from the content spreads so the typed fields win.
267
+ ...omitBlockMeta(block.templateData),
268
+ ...omitBlockMeta(block.templateOverrides),
212
269
  };
213
270
  }
214
271
 
272
+ /** Keys coerced to typed block fields; must not be re-applied as raw strings. */
273
+ const BLOCK_META_KEYS: ReadonlySet<string> = new Set(Object.keys(KNOWN_BLOCK_META_KEYS));
274
+
275
+ /** Copy of a template-param record with block-meta keys removed. */
276
+ function omitBlockMeta(
277
+ data: Record<string, unknown> | undefined,
278
+ ): Record<string, unknown> | undefined {
279
+ if (!data) return data;
280
+ let hit = false;
281
+ const out: Record<string, unknown> = {};
282
+ for (const key of Object.keys(data)) {
283
+ if (BLOCK_META_KEYS.has(key)) {
284
+ hit = true;
285
+ continue;
286
+ }
287
+ out[key] = data[key];
288
+ }
289
+ return hit ? out : data;
290
+ }
291
+
215
292
  const IMAGE_MOTIONS: Array<'zoomIn' | 'zoomOut' | 'panLeft' | 'panRight'> = [
216
293
  'zoomIn',
217
294
  'zoomOut',
@@ -229,9 +306,18 @@ const IMAGE_MOTIONS: Array<'zoomIn' | 'zoomOut' | 'panLeft' | 'panRight'> = [
229
306
  * audio segment.
230
307
  */
231
308
  export function buildPreviewDoc(doc: Doc): Doc {
232
- const flat = flattenBlocks(doc.blocks);
309
+ // Container templates (`diagram`, `drawing`) render their children as
310
+ // nodes/shapes, so those children must not also become preview slides.
311
+ const flat = flattenRenderableBlocks(doc.blocks);
233
312
  const allImages = collectAllDocImages(doc.blocks);
234
313
  const usedImageSrcs = new Set<string>();
314
+ // Names of user-defined templates carried by the doc — passed into
315
+ // `blockToSlide` so heading annotations like `{[hero]}` aren't
316
+ // silently downgraded to `sectionHeader` when the template doesn't
317
+ // exist in the built-in registry.
318
+ const knownTemplates = doc.customTemplates
319
+ ? new Set(doc.customTemplates.map((d) => d.name))
320
+ : undefined;
235
321
 
236
322
  const slides: Record<string, unknown>[] = [];
237
323
  let motionIndex = 0;
@@ -239,7 +325,7 @@ export function buildPreviewDoc(doc: Doc): Doc {
239
325
  for (let i = 0; i < flat.length; i++) {
240
326
  const block = flat[i];
241
327
  const blockImages = extractBlockImages(block.contents);
242
- const slide = blockToSlide(block, i);
328
+ const slide = blockToSlide(block, i, knownTemplates);
243
329
 
244
330
  if (blockImages.length > 0 && slide.template === 'sectionHeader') {
245
331
  const img = blockImages[0];
@@ -305,5 +391,8 @@ export function buildPreviewDoc(doc: Doc): Doc {
305
391
  ...(doc.captions ? { captions: doc.captions } : {}),
306
392
  ...(doc.startBlock ? { startBlock: doc.startBlock } : {}),
307
393
  ...(doc.themeId ? { themeId: doc.themeId } : {}),
394
+ // Custom templates ride along so `useDocPlayback` can merge them
395
+ // onto the registry before expanding slides.
396
+ ...(doc.customTemplates ? { customTemplates: doc.customTemplates } : {}),
308
397
  };
309
398
  }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * AddBin — the template designer's "things you can add" sidebar.
3
+ *
4
+ * Three sections, all feeding the same canvas:
5
+ * - Placeholders: dynamic tokens ({title}, {content}, …) substituted at
6
+ * render time. See {@link TOKEN_DEFS}.
7
+ * - Shapes: Squisq's standard shape library. See {@link SHAPE_DEFS}.
8
+ * - Media: drop an image to pin as a static, full-bleed background.
9
+ *
10
+ * Tokens and shapes can be placed two ways — drag onto the canvas, or
11
+ * click to arm the matching tool and then click the canvas. The drag
12
+ * carries the entry id under {@link TOKEN_DRAG_MIME} / {@link SHAPE_DRAG_MIME};
13
+ * the TemplateDesigner's drop handler reads it (see `buildTokenLayer` /
14
+ * `buildShapeLayer`). Media is upload-backed and handled by the designer.
15
+ */
16
+
17
+ import { useRef } from 'react';
18
+ import { TOKEN_DEFS, TOKEN_DRAG_MIME } from './tokenDefs';
19
+ import { SHAPE_DEFS, SHAPE_DRAG_MIME } from './shapeDefs';
20
+ import { ShapeGlyph } from './ShapeGlyph';
21
+
22
+ interface AddBinProps {
23
+ /** Currently active tool id — the matching button is highlighted. */
24
+ activeToolId: string;
25
+ /** Switch the Scene's active tool (a token or shape place tool). */
26
+ onActivate: (toolId: string) => void;
27
+ /** Whether media can be added (a MediaProvider is wired up). */
28
+ canAddMedia: boolean;
29
+ /** Add picked image files as static background layer(s). */
30
+ onAddMediaFiles: (files: File[]) => void;
31
+ }
32
+
33
+ export function AddBin({ activeToolId, onActivate, canAddMedia, onAddMediaFiles }: AddBinProps) {
34
+ const fileInputRef = useRef<HTMLInputElement>(null);
35
+
36
+ return (
37
+ <aside className="squisq-template-designer-palette" aria-label="Add to layout">
38
+ <h3 className="squisq-template-designer-palette-title">Add</h3>
39
+
40
+ {/* Placeholders — dynamic tokens substituted at render time. */}
41
+ <div className="squisq-template-designer-palette-section">
42
+ <div className="squisq-template-designer-palette-section-title">Placeholders</div>
43
+ <div className="squisq-template-designer-palette-list">
44
+ {TOKEN_DEFS.map((t) => (
45
+ <button
46
+ key={t.id}
47
+ type="button"
48
+ draggable
49
+ className={`squisq-template-designer-palette-item${
50
+ activeToolId === t.id ? ' squisq-template-designer-palette-item--active' : ''
51
+ }`}
52
+ onDragStart={(e) => {
53
+ e.dataTransfer.setData(TOKEN_DRAG_MIME, t.id);
54
+ // Some browsers require text/plain for the drag to start.
55
+ e.dataTransfer.setData('text/plain', t.token);
56
+ e.dataTransfer.effectAllowed = 'copy';
57
+ }}
58
+ onClick={() => onActivate(t.id)}
59
+ title={t.desc}
60
+ >
61
+ <span className="squisq-template-designer-palette-item-preview">{t.token}</span>
62
+ <span className="squisq-template-designer-palette-item-label">{t.label}</span>
63
+ </button>
64
+ ))}
65
+ </div>
66
+ </div>
67
+
68
+ {/* Shapes — the standard shape library. */}
69
+ <div className="squisq-template-designer-palette-section">
70
+ <div className="squisq-template-designer-palette-section-title">Shapes</div>
71
+ <div className="squisq-template-designer-palette-shapes">
72
+ {SHAPE_DEFS.map((s) => (
73
+ <button
74
+ key={s.id}
75
+ type="button"
76
+ draggable
77
+ className={`squisq-template-designer-shape-item${
78
+ activeToolId === s.id ? ' squisq-template-designer-shape-item--active' : ''
79
+ }`}
80
+ onDragStart={(e) => {
81
+ e.dataTransfer.setData(SHAPE_DRAG_MIME, s.id);
82
+ e.dataTransfer.setData('text/plain', s.label);
83
+ e.dataTransfer.effectAllowed = 'copy';
84
+ }}
85
+ onClick={() => onActivate(s.id)}
86
+ title={s.label}
87
+ aria-label={s.label}
88
+ >
89
+ <ShapeGlyph kind={s.kind} rounded={s.rounded} />
90
+ </button>
91
+ ))}
92
+ </div>
93
+ </div>
94
+
95
+ {/* Media — drop an image to pin a static background. */}
96
+ <div className="squisq-template-designer-palette-section">
97
+ <div className="squisq-template-designer-palette-section-title">Media</div>
98
+ <p className="squisq-template-designer-palette-hint">
99
+ {canAddMedia
100
+ ? 'Drop an image on the canvas to add a full-bleed background.'
101
+ : 'Connect media storage to add image backgrounds.'}
102
+ </p>
103
+ <button
104
+ type="button"
105
+ className="squisq-template-designer-palette-media-add"
106
+ disabled={!canAddMedia}
107
+ onClick={() => fileInputRef.current?.click()}
108
+ >
109
+ Add image…
110
+ </button>
111
+ <input
112
+ ref={fileInputRef}
113
+ type="file"
114
+ accept="image/*"
115
+ multiple
116
+ style={{ display: 'none' }}
117
+ onChange={(e) => {
118
+ const files = Array.from(e.target.files ?? []);
119
+ if (files.length) onAddMediaFiles(files);
120
+ e.target.value = ''; // allow re-selecting the same file
121
+ }}
122
+ />
123
+ </div>
124
+ </aside>
125
+ );
126
+ }
@@ -0,0 +1,233 @@
1
+ /**
2
+ * CustomLayoutManager — a near-full-window modal for managing custom
3
+ * layouts (templates). The left rail lists every layout available to the
4
+ * document — those inlined in the doc and those saved to the browser
5
+ * library — plus a "New layout" button. The right pane embeds the
6
+ * existing {@link TemplateDesigner} (in `embedded` mode) so the selected
7
+ * layout can be authored in place.
8
+ *
9
+ * The manager seeds its own {@link CustomTemplateProvider} from the
10
+ * active doc (via {@link useDocCustomTemplates}) so it works whether or
11
+ * not the host already mounted a provider — the toolbar that opens it
12
+ * sits outside the WYSIWYG editor's provider.
13
+ */
14
+
15
+ import { useCallback, useEffect, useState } from 'react';
16
+ import { createPortal } from 'react-dom';
17
+ import type { CustomTemplateDefinition, MediaProvider } from '@bendyline/squisq/schemas';
18
+ import {
19
+ CustomTemplateProvider,
20
+ useCustomTemplates,
21
+ type CustomTemplateContextValue,
22
+ } from './CustomTemplateContext';
23
+ import { TemplateDesigner, type DesignerSaveTarget } from './TemplateDesigner';
24
+ import { TemplateThumbnail } from './thumbnail';
25
+ import { useDocCustomTemplates } from './useDocCustomTemplates';
26
+ import { useEditorContext } from '../EditorContext';
27
+
28
+ export interface CustomLayoutManagerProps {
29
+ /** Close the manager. */
30
+ onClose: () => void;
31
+ }
32
+
33
+ /**
34
+ * A picked sidebar entry: `'new'` for the blank-slate designer, or an
35
+ * existing template tagged by which pool it came from.
36
+ *
37
+ * `def` carries the definition directly for the just-saved case: the doc
38
+ * re-parses on a 150ms debounce (see EditorContext), so immediately after
39
+ * a save the doc-template list is briefly stale. Holding the saved
40
+ * definition here lets the designer re-seed from it without waiting for —
41
+ * or racing — that re-parse. User-initiated selections omit `def` and
42
+ * resolve from the (by-then fresh) list.
43
+ */
44
+ type Selection =
45
+ | 'new'
46
+ | { source: 'doc' | 'library'; name: string; def?: CustomTemplateDefinition };
47
+
48
+ export function CustomLayoutManager({ onClose }: CustomLayoutManagerProps) {
49
+ const { docTemplates, onDocTemplatesChange } = useDocCustomTemplates();
50
+ const { mediaProvider } = useEditorContext();
51
+
52
+ // Close on Escape, mirroring the other editor dialogs.
53
+ useEffect(() => {
54
+ const onKey = (e: KeyboardEvent) => {
55
+ if (e.key === 'Escape') onClose();
56
+ };
57
+ window.addEventListener('keydown', onKey);
58
+ return () => window.removeEventListener('keydown', onKey);
59
+ }, [onClose]);
60
+
61
+ return createPortal(
62
+ <div
63
+ className="squisq-layout-manager-overlay"
64
+ role="dialog"
65
+ aria-modal="true"
66
+ aria-label="Custom layouts"
67
+ onClick={(e) => {
68
+ if (e.target === e.currentTarget) onClose();
69
+ }}
70
+ >
71
+ <div className="squisq-layout-manager-panel">
72
+ <header className="squisq-layout-manager-header">
73
+ <h2 className="squisq-layout-manager-title">Custom layouts</h2>
74
+ <button
75
+ type="button"
76
+ className="squisq-template-designer-close"
77
+ onClick={onClose}
78
+ aria-label="Close custom layouts"
79
+ title="Close (Esc)"
80
+ >
81
+ ×
82
+ </button>
83
+ </header>
84
+ <CustomTemplateProvider
85
+ docTemplates={docTemplates}
86
+ onDocTemplatesChange={onDocTemplatesChange}
87
+ >
88
+ <ManagerBody mediaProvider={mediaProvider} />
89
+ </CustomTemplateProvider>
90
+ </div>
91
+ </div>,
92
+ document.body,
93
+ );
94
+ }
95
+
96
+ /** Inner body — runs under the provider so it can read/write both pools. */
97
+ function ManagerBody({ mediaProvider }: { mediaProvider: MediaProvider | null }) {
98
+ const ctx = useCustomTemplates();
99
+ // The provider is mounted just above by CustomLayoutManager, so this is
100
+ // never null in practice; narrow defensively rather than asserting.
101
+ if (!ctx) return null;
102
+ return <ManagerContent ctx={ctx} mediaProvider={mediaProvider} />;
103
+ }
104
+
105
+ function ManagerContent({
106
+ ctx,
107
+ mediaProvider,
108
+ }: {
109
+ ctx: CustomTemplateContextValue;
110
+ mediaProvider: MediaProvider | null;
111
+ }) {
112
+ const { docTemplates, libraryTemplates, upsertDocTemplate, upsertLibraryTemplate } = ctx;
113
+
114
+ // The doc and library pools are listed in full and kept separate — a
115
+ // layout can legitimately exist in both (the doc's inlined copy and a
116
+ // reusable library copy), so saving a doc layout "to library" produces
117
+ // a visible second entry rather than silently merging into the doc one.
118
+
119
+ // Default selection: first doc layout, else first library layout, else
120
+ // the blank-slate "new" designer.
121
+ const [selection, setSelection] = useState<Selection>(() => {
122
+ if (docTemplates[0]) return { source: 'doc', name: docTemplates[0].name };
123
+ if (libraryTemplates[0]) return { source: 'library', name: libraryTemplates[0].name };
124
+ return 'new';
125
+ });
126
+
127
+ // Resolve the selected definition (undefined for the "new" slate). A
128
+ // `def` carried on the selection wins over the list lookup so a freshly
129
+ // saved layout shows immediately, before the doc re-parse lands.
130
+ const selected: CustomTemplateDefinition | undefined =
131
+ selection === 'new'
132
+ ? undefined
133
+ : (selection.def ??
134
+ (selection.source === 'doc' ? docTemplates : libraryTemplates).find(
135
+ (t) => t.name === selection.name,
136
+ ));
137
+
138
+ const handleSave = useCallback(
139
+ (def: CustomTemplateDefinition, target: DesignerSaveTarget) => {
140
+ if (target === 'doc') upsertDocTemplate(def);
141
+ else upsertLibraryTemplate(def);
142
+ // After saving, select the just-saved layout so the list highlights
143
+ // it and further edits target the same entry (rather than the blank
144
+ // "new" slate). Carry the saved def so the designer re-seeds from it
145
+ // even while the doc re-parse (debounced) is still in flight.
146
+ setSelection({ source: target === 'doc' ? 'doc' : 'library', name: def.name, def });
147
+ },
148
+ [upsertDocTemplate, upsertLibraryTemplate],
149
+ );
150
+
151
+ const isActive = (source: 'doc' | 'library', name: string) =>
152
+ selection !== 'new' && selection.source === source && selection.name === name;
153
+
154
+ const renderItem = (def: CustomTemplateDefinition, source: 'doc' | 'library') => (
155
+ <button
156
+ key={`${source}:${def.name}`}
157
+ type="button"
158
+ className={`squisq-layout-manager-item${
159
+ isActive(source, def.name) ? ' squisq-layout-manager-item--active' : ''
160
+ }`}
161
+ onClick={() => setSelection({ source, name: def.name })}
162
+ >
163
+ <span className="squisq-layout-manager-item-thumb">
164
+ <TemplateThumbnail def={def} />
165
+ </span>
166
+ <span className="squisq-layout-manager-item-text">
167
+ <span className="squisq-layout-manager-item-label">{def.label || def.name}</span>
168
+ <span className="squisq-layout-manager-item-name">{def.name}</span>
169
+ </span>
170
+ </button>
171
+ );
172
+
173
+ return (
174
+ <div className="squisq-layout-manager-body">
175
+ <aside className="squisq-layout-manager-sidebar">
176
+ <button
177
+ type="button"
178
+ className={`squisq-layout-manager-new${
179
+ selection === 'new' ? ' squisq-layout-manager-new--active' : ''
180
+ }`}
181
+ onClick={() => setSelection('new')}
182
+ >
183
+ + New layout
184
+ </button>
185
+
186
+ {docTemplates.length > 0 && (
187
+ <>
188
+ <div className="squisq-layout-manager-section-title">This document</div>
189
+ <div className="squisq-layout-manager-list">
190
+ {docTemplates.map((def) => renderItem(def, 'doc'))}
191
+ </div>
192
+ </>
193
+ )}
194
+
195
+ {libraryTemplates.length > 0 && (
196
+ <>
197
+ <div className="squisq-layout-manager-section-title">Library</div>
198
+ <div className="squisq-layout-manager-list">
199
+ {libraryTemplates.map((def) => renderItem(def, 'library'))}
200
+ </div>
201
+ </>
202
+ )}
203
+
204
+ {docTemplates.length === 0 && libraryTemplates.length === 0 && (
205
+ <p className="squisq-layout-manager-empty-hint">
206
+ No custom layouts yet. Design one on the right and save it to this document or your
207
+ library.
208
+ </p>
209
+ )}
210
+ </aside>
211
+
212
+ <div className="squisq-layout-manager-main">
213
+ <TemplateDesigner
214
+ // Remount when the selection changes so the designer re-seeds
215
+ // its internal state from the newly chosen definition.
216
+ key={selection === 'new' ? 'new' : `${selection.source}:${selection.name}`}
217
+ embedded
218
+ initial={selected}
219
+ mediaProvider={mediaProvider}
220
+ // A layout already in the doc just "Save"s; a new or library
221
+ // layout reads "Save to this doc" since saving adds it.
222
+ primarySaveLabel={
223
+ selection !== 'new' && selection.source === 'doc' ? 'Save' : 'Save to this doc'
224
+ }
225
+ onSave={handleSave}
226
+ onClose={() => {
227
+ /* embedded: the manager frame owns dismissal */
228
+ }}
229
+ />
230
+ </div>
231
+ </div>
232
+ );
233
+ }