@bendyline/squisq-editor-react 1.5.3 → 1.6.1

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 (186) hide show
  1. package/README.md +57 -10
  2. package/dist/index.d.ts +1114 -52
  3. package/dist/index.js +18446 -8050
  4. package/dist/index.js.map +1 -1
  5. package/dist/styles/fa-brands-400-AHOAZHCU.woff2 +0 -0
  6. package/dist/styles/fa-regular-400-VRZYIBIZ.woff2 +0 -0
  7. package/dist/styles/fa-solid-900-MDEYK55F.woff2 +0 -0
  8. package/dist/styles/fa-v4compatibility-ETEVP6IB.woff2 +0 -0
  9. package/dist/styles/index.css +13867 -0
  10. package/package.json +15 -7
  11. package/src/BlockCardView.tsx +121 -0
  12. package/src/BlockPreviewPanel.tsx +69 -0
  13. package/src/BlockPropertiesPopover.tsx +191 -0
  14. package/src/EditorContext.tsx +165 -16
  15. package/src/EditorShell.tsx +265 -144
  16. package/src/FolderView.tsx +131 -0
  17. package/src/Icon.tsx +26 -0
  18. package/src/ImageEditor.tsx +69 -22
  19. package/src/OutlinePanel.tsx +64 -7
  20. package/src/PlainHtmlPreview.tsx +30 -3
  21. package/src/PreviewControls.tsx +491 -122
  22. package/src/PreviewPanel.tsx +15 -9
  23. package/src/RawEditor.tsx +179 -17
  24. package/src/RecorderEntry.tsx +9 -16
  25. package/src/TemplateAnnotation.ts +44 -0
  26. package/src/TemplatePicker.tsx +329 -54
  27. package/src/ThemeCustomizerPanel.tsx +30 -336
  28. package/src/ThemePicker.tsx +112 -3
  29. package/src/TimelineBlockPreview.tsx +37 -0
  30. package/src/TimelineTrack.tsx +671 -0
  31. package/src/Toolbar.tsx +544 -180
  32. package/src/Tooltip.tsx +22 -4
  33. package/src/TransitionPicker.tsx +351 -0
  34. package/src/VersionHistoryPanel.tsx +4 -16
  35. package/src/ViewMenuPanel.tsx +17 -14
  36. package/src/WysiwygEditor.tsx +161 -65
  37. package/src/__tests__/blockProperties.test.ts +92 -0
  38. package/src/__tests__/blockRange.test.ts +105 -0
  39. package/src/__tests__/buildPreviewDocTransition.test.ts +73 -0
  40. package/src/__tests__/codeContextSectionView.test.tsx +95 -0
  41. package/src/__tests__/codeContextZoneManager.test.ts +127 -0
  42. package/src/__tests__/createShapeLayer.test.ts +46 -0
  43. package/src/__tests__/diffContextSections.test.ts +39 -0
  44. package/src/__tests__/drawingShapeRoundTrip.test.ts +49 -0
  45. package/src/__tests__/editorShellCodeContext.test.tsx +86 -0
  46. package/src/__tests__/editorShellProps.test.tsx +96 -0
  47. package/src/__tests__/embeddedMedia.test.ts +48 -0
  48. package/src/__tests__/headingTransition.test.ts +138 -0
  49. package/src/__tests__/layoutChildRoundTrip.test.ts +71 -0
  50. package/src/__tests__/plainHtmlPreview.test.tsx +10 -8
  51. package/src/__tests__/previewControls.test.tsx +70 -0
  52. package/src/__tests__/recorderMediaInsert.test.ts +86 -0
  53. package/src/__tests__/templateAnnotationRoundTrip.test.ts +18 -0
  54. package/src/__tests__/templatePickerMetadata.test.ts +32 -0
  55. package/src/__tests__/timelineSource.test.ts +134 -0
  56. package/src/__tests__/tiptapBridge.test.ts +92 -0
  57. package/src/__tests__/tiptapBridgeConformance.test.ts +47 -0
  58. package/src/__tests__/tooltip.test.tsx +72 -0
  59. package/src/__tests__/transitionCatalog.test.ts +64 -0
  60. package/src/__tests__/useBlockNavigator.test.tsx +67 -0
  61. package/src/__tests__/useJsonEditorTokens.test.ts +59 -0
  62. package/src/__tests__/useMediaRecorder.test.ts +41 -0
  63. package/src/__tests__/useTimelineClock.test.ts +21 -0
  64. package/src/blockProperties.ts +88 -0
  65. package/src/blockRange.ts +132 -0
  66. package/src/buildPreviewDoc.ts +98 -9
  67. package/src/codeContext/CodeContextSectionView.tsx +124 -0
  68. package/src/codeContext/CodeContextZoneManager.ts +149 -0
  69. package/src/codeContext/CodeContextZones.tsx +121 -0
  70. package/src/codeContext/diffContextSections.ts +38 -0
  71. package/src/codeContext/types.ts +75 -0
  72. package/src/customTemplates/AddBin.tsx +126 -0
  73. package/src/customTemplates/CustomLayoutManager.tsx +233 -0
  74. package/src/customTemplates/CustomTemplateContext.tsx +182 -0
  75. package/src/customTemplates/LayerToolbar.tsx +580 -0
  76. package/src/customTemplates/ShapeGlyph.tsx +47 -0
  77. package/src/customTemplates/TemplateDesigner.tsx +430 -0
  78. package/src/customTemplates/__tests__/library.test.ts +88 -0
  79. package/src/customTemplates/__tests__/normalizePositions.test.ts +109 -0
  80. package/src/customTemplates/__tests__/shapeDefs.test.ts +49 -0
  81. package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +95 -0
  82. package/src/customTemplates/designer.css +673 -0
  83. package/src/customTemplates/index.ts +31 -0
  84. package/src/customTemplates/library.ts +97 -0
  85. package/src/customTemplates/normalizePositions.ts +75 -0
  86. package/src/customTemplates/shapeDefs.ts +131 -0
  87. package/src/customTemplates/thumbnail.tsx +63 -0
  88. package/src/customTemplates/tokenDefs.ts +60 -0
  89. package/src/customTemplates/useDocCustomTemplates.ts +52 -0
  90. package/src/customTemplates/useMemoryLayerAdapter.ts +123 -0
  91. package/src/customThemes/CustomThemeContext.tsx +179 -0
  92. package/src/customThemes/CustomThemeDialog.tsx +286 -0
  93. package/src/customThemes/__tests__/CustomThemeContext.test.tsx +64 -0
  94. package/src/customThemes/__tests__/CustomThemeDialog.test.tsx +47 -0
  95. package/src/customThemes/__tests__/customThemeLibrary.test.ts +51 -0
  96. package/src/customThemes/customThemeLibrary.ts +97 -0
  97. package/src/customThemes/index.ts +31 -0
  98. package/src/customThemes/themeControls.tsx +229 -0
  99. package/src/customThemes/themeDraft.ts +272 -0
  100. package/src/customThemes/useDocCustomThemes.ts +49 -0
  101. package/src/diagram/DiagramCanvas.tsx +240 -0
  102. package/src/diagram/DiagramExtension.ts +209 -0
  103. package/src/diagram/DiagramMaximizedOverlay.tsx +46 -0
  104. package/src/diagram/DiagramWidget.tsx +270 -0
  105. package/src/diagram/diagramCommands.ts +604 -0
  106. package/src/diagram/diagramConstants.ts +17 -0
  107. package/src/diagram/useDiagramData.ts +126 -0
  108. package/src/embeddedMedia.ts +78 -0
  109. package/src/frontmatter.ts +29 -0
  110. package/src/headingTransition.ts +231 -0
  111. package/src/imageEditor/CanvasSurface.tsx +383 -88
  112. package/src/imageEditor/PropertiesPanel.tsx +47 -1
  113. package/src/imageEditor/Toolbar.tsx +229 -16
  114. package/src/imageEditor/createShapeLayer.ts +280 -0
  115. package/src/imageEditor/icons.tsx +34 -114
  116. package/src/imageEditor/image-editor.css +54 -5
  117. package/src/imageEditor/state.ts +23 -3
  118. package/src/index.ts +109 -1
  119. package/src/jsonEditor/useJsonEditorTokens.ts +13 -43
  120. package/src/recorder/RecorderModal.tsx +120 -53
  121. package/src/recorder/RecorderPanel.tsx +2 -26
  122. package/src/recorder/hooks/useMediaRecorder.ts +17 -11
  123. package/src/recorder/insertMediaBlock.ts +30 -0
  124. package/src/resolveBlockVisual.ts +33 -0
  125. package/src/scene/Scene.tsx +540 -0
  126. package/src/scene/SceneBlockExtension.ts +198 -0
  127. package/src/scene/SceneBlockToolbar.tsx +201 -0
  128. package/src/scene/SceneBlockWidget.tsx +434 -0
  129. package/src/scene/ScenePropsBar.tsx +85 -0
  130. package/src/scene/SceneSelection.tsx +107 -0
  131. package/src/scene/SceneViewport.tsx +102 -0
  132. package/src/scene/ShapePalette.tsx +181 -0
  133. package/src/scene/__tests__/DiagramAdapter.test.ts +56 -0
  134. package/src/scene/__tests__/bezierEdit.test.ts +85 -0
  135. package/src/scene/__tests__/blockLayers.test.ts +57 -0
  136. package/src/scene/__tests__/shapeLayers.test.ts +106 -0
  137. package/src/scene/__tests__/useSceneHitTest.test.ts +90 -0
  138. package/src/scene/__tests__/useScenePanZoom.test.ts +103 -0
  139. package/src/scene/adapters/DiagramAdapter.ts +168 -0
  140. package/src/scene/adapters/DrawingAdapter.ts +415 -0
  141. package/src/scene/adapters/LayoutAdapter.ts +310 -0
  142. package/src/scene/adapters/blockLayers.ts +159 -0
  143. package/src/scene/commands/SceneCommand.ts +70 -0
  144. package/src/scene/commands/drawingCommands.ts +318 -0
  145. package/src/scene/commands/layoutCommands.ts +301 -0
  146. package/src/scene/hooks/useSceneHitTest.ts +105 -0
  147. package/src/scene/hooks/useScenePanZoom.ts +147 -0
  148. package/src/scene/hooks/useSceneSelection.ts +62 -0
  149. package/src/scene/index.ts +95 -0
  150. package/src/scene/layers/DiagramEdges.tsx +127 -0
  151. package/src/scene/layers/edgeGeometry.ts +77 -0
  152. package/src/scene/layers/nodeCard.tsx +145 -0
  153. package/src/scene/layers/renderLayer.tsx +70 -0
  154. package/src/scene/layers/shapeLayers.ts +201 -0
  155. package/src/scene/paths/bezierEdit.ts +208 -0
  156. package/src/scene/scene.css +649 -0
  157. package/src/scene/text/SceneTextOverlay.tsx +161 -0
  158. package/src/scene/text/sceneTextChannel.ts +40 -0
  159. package/src/scene/text/sceneTextConfig.ts +27 -0
  160. package/src/scene/text/sceneTiptap.ts +36 -0
  161. package/src/scene/text/useSceneTextEditing.ts +39 -0
  162. package/src/scene/tools/ConnectTool.ts +111 -0
  163. package/src/scene/tools/DrawingConnectTool.ts +161 -0
  164. package/src/scene/tools/PathTool.ts +158 -0
  165. package/src/scene/tools/PlaceTool.ts +47 -0
  166. package/src/scene/tools/SceneTool.ts +75 -0
  167. package/src/scene/tools/SelectTool.ts +284 -0
  168. package/src/scene/tools/ShapeTool.ts +144 -0
  169. package/src/scene/tools/TextTool.ts +72 -0
  170. package/src/scene/tools/TokenTool.ts +95 -0
  171. package/src/scene/tools/createDrawShapeTool.ts +82 -0
  172. package/src/styles/code-context.css +155 -0
  173. package/src/styles/diagram.css +183 -0
  174. package/src/styles/editor.css +1708 -150
  175. package/src/styles/folder-view.css +210 -0
  176. package/src/styles/image-edit-affordance.css +2 -2
  177. package/src/styles/index.css +5 -0
  178. package/src/timelineSource.ts +244 -0
  179. package/src/tiptapBridge.ts +115 -34
  180. package/src/tooltipPlacement.ts +13 -0
  181. package/src/transitionCatalog.ts +159 -0
  182. package/src/types/monaco-shims.d.ts +10 -0
  183. package/src/useBlockNavigator.ts +153 -0
  184. package/src/useMonacoLoader.ts +23 -1
  185. package/src/useTimelineClock.ts +76 -0
  186. package/src/utils/dropUtils.ts +1 -1
@@ -0,0 +1,310 @@
1
+ /**
2
+ * LayoutAdapter — edit a `{[layout]}` block as semantic markdown.
3
+ *
4
+ * Read direction: child `{[type …]}` headings → `computeLayoutLayers` →
5
+ * Scene layers (text content comes from each child's body).
6
+ * Write direction: SceneCommand → `layoutCommands` (insert/update/delete
7
+ * layer headings + bodies).
8
+ *
9
+ * The layout counterpart to `DrawingAdapter` — same seam
10
+ * (`LayoutAdapterResult`), absolute positioning, and text layers whose rich
11
+ * content lives in the child heading's markdown body. Nothing is persisted as
12
+ * a base64 `layers=` blob anymore; every layer is a heading. Documents that
13
+ * still carry the legacy blob are auto-migrated to child sub-blocks on first
14
+ * mount (see the migration effect).
15
+ */
16
+
17
+ import { useEffect, useRef, useState } from 'react';
18
+ import type { ReactNode } from 'react';
19
+ import type { Editor } from '@tiptap/react';
20
+ import type { Layer, Block, TextStyle } from '@bendyline/squisq/schemas';
21
+ import { computeLayoutLayers } from '@bendyline/squisq/doc';
22
+ import type { SceneCommand, SceneEdge } from '../commands/SceneCommand';
23
+ import type { SceneTool, SceneToolContext } from '../tools/SceneTool';
24
+ import { SelectTool } from '../tools/SelectTool';
25
+ import { markdownToTiptap } from '../../tiptapBridge';
26
+ import { readLayersFromHeading, writeLayersToHeading } from './blockLayers';
27
+ import {
28
+ listLayoutChildren,
29
+ addLayoutLayer,
30
+ moveLayoutLayer,
31
+ resizeLayoutLayer,
32
+ setLayoutParam,
33
+ setLayoutText,
34
+ removeLayoutLayer,
35
+ } from '../commands/layoutCommands';
36
+
37
+ export interface LayoutAdapterResult {
38
+ /** Layers to pass to `<Scene layers={...} />`. */
39
+ layers: Layer[];
40
+ /** Tool set the host should expose. */
41
+ tools: SceneTool[];
42
+ /** Command dispatch for `<Scene onCommand={...} />`. */
43
+ dispatch: (cmd: SceneCommand) => void;
44
+ // ── Connector-capable adapters (drawing) set these; layout omits them ──
45
+ /** Connector edges to pass to `<Scene edges={...} />`. */
46
+ edges?: SceneEdge[];
47
+ /** `<Scene layerFollows={...} />` — a label layer follows its shape. */
48
+ layerFollows?: (layerId: string) => string | null;
49
+ /** `<Scene renderExtras={...} />` — draws the connector pass behind layers. */
50
+ renderExtras?: (ctx: SceneToolContext) => ReactNode;
51
+ }
52
+
53
+ export interface LayoutAdapterOptions {
54
+ /** Override the tool set (defaults to `[SelectTool]`). */
55
+ tools?: SceneTool[];
56
+ }
57
+
58
+ /** Viewport the layout canvas composes against (matches `SceneBlockWidget`). */
59
+ const SCENE_VIEWPORT = { width: 1920, height: 1080 };
60
+
61
+ /**
62
+ * Build a LayoutAdapter for the heading at `headingPos`. Subscribes to the
63
+ * editor's transactions so the returned `layers` reflect the latest doc.
64
+ */
65
+ export function useLayoutAdapter(
66
+ editor: Editor,
67
+ headingPos: number,
68
+ options: LayoutAdapterOptions = {},
69
+ ): LayoutAdapterResult {
70
+ const [, setVersion] = useState(0);
71
+ useEffect(() => {
72
+ const onUpdate = () => setVersion((v) => v + 1);
73
+ editor.on('transaction', onUpdate);
74
+ return () => {
75
+ editor.off('transaction', onUpdate);
76
+ };
77
+ }, [editor]);
78
+
79
+ // ── Read: child headings → layers ──────────────────────────────
80
+ const node = editor.state.doc.nodeAt(headingPos);
81
+ const children = node ? listLayoutChildren(editor, headingPos) : [];
82
+ const blocks: Block[] = children.map((c) => ({
83
+ id: c.id,
84
+ startTime: 0,
85
+ duration: 0,
86
+ audioSegment: 0,
87
+ ...(c.kind ? { template: c.kind } : {}),
88
+ ...(Object.keys(c.params).length > 0 ? { templateOverrides: c.params } : {}),
89
+ }));
90
+ const { layers } = computeLayoutLayers(blocks, SCENE_VIEWPORT);
91
+ // Text content comes from the child body (faithful rich HTML + plain text),
92
+ // not the annotation — overwrite what the core derivation left empty.
93
+ for (const l of layers) {
94
+ if (l.type !== 'text') continue;
95
+ const c = children.find((ch) => ch.id === l.id);
96
+ if (!c) continue;
97
+ l.content.text = c.bodyText;
98
+ if (c.bodyHtml.trim()) l.content.html = c.bodyHtml;
99
+ }
100
+
101
+ // ── Migration: legacy base64 `layers=` blob → child sub-blocks ──
102
+ // SSR never decoded the blob, so this only matters in the editor. Runs
103
+ // at most once: no-op when children already exist or no blob is present.
104
+ const migratedRef = useRef(false);
105
+ useEffect(() => {
106
+ if (migratedRef.current) return;
107
+ const n = editor.state.doc.nodeAt(headingPos);
108
+ if (!n) return;
109
+ if (listLayoutChildren(editor, headingPos).length > 0) {
110
+ migratedRef.current = true;
111
+ return;
112
+ }
113
+ const legacy = readLayersFromHeading(n);
114
+ if (legacy.length === 0) {
115
+ migratedRef.current = true;
116
+ return;
117
+ }
118
+ migratedRef.current = true;
119
+ for (const layer of legacy) {
120
+ const child = layerToLayoutChild(layer);
121
+ addLayoutLayer(editor, headingPos, layer.id, child.kind, child.params, child.bodyHtml);
122
+ }
123
+ writeLayersToHeading(editor, headingPos, []);
124
+ }, [editor, headingPos]);
125
+
126
+ // ── Write: SceneCommand → layout heading commands ──────────────
127
+ const dispatch = (cmd: SceneCommand) => {
128
+ switch (cmd.kind) {
129
+ case 'moveLayer':
130
+ moveLayoutLayer(editor, headingPos, cmd.id, cmd.x, cmd.y);
131
+ return;
132
+ case 'resizeLayer':
133
+ resizeLayoutLayer(editor, headingPos, cmd.id, cmd.width, cmd.height);
134
+ return;
135
+ case 'addLayer': {
136
+ const child = layerToLayoutChild(cmd.layer);
137
+ const id = cmd.layer.id || nextLayoutId(editor, headingPos, child.kind);
138
+ addLayoutLayer(editor, headingPos, id, child.kind, child.params, child.bodyHtml);
139
+ return;
140
+ }
141
+ case 'removeLayer':
142
+ removeLayoutLayer(editor, headingPos, cmd.id);
143
+ return;
144
+ case 'renameLayer':
145
+ // Layout text content lives in the body; treat a rename as a plain
146
+ // text replacement of that body.
147
+ setLayoutText(editor, headingPos, cmd.id, markdownToTiptap(cmd.label));
148
+ return;
149
+ case 'setLayerText':
150
+ setLayoutText(
151
+ editor,
152
+ headingPos,
153
+ cmd.id,
154
+ cmd.html && cmd.html.trim() ? cmd.html : markdownToTiptap(cmd.text),
155
+ );
156
+ return;
157
+ case 'setLayerAttr': {
158
+ const mapped = paramForLayoutPath(cmd.path);
159
+ if (mapped) setLayoutParam(editor, headingPos, cmd.id, mapped, String(cmd.value ?? ''));
160
+ return;
161
+ }
162
+ case 'addEdge':
163
+ case 'removeEdge':
164
+ // Layouts don't model edges.
165
+ return;
166
+ }
167
+ const _exhaustive: never = cmd;
168
+ void _exhaustive;
169
+ };
170
+
171
+ return {
172
+ layers,
173
+ tools: options.tools ?? [SelectTool],
174
+ dispatch,
175
+ };
176
+ }
177
+
178
+ // ============================================
179
+ // Layer ↔ child mapping
180
+ // ============================================
181
+
182
+ interface LayoutChildWrite {
183
+ kind: string;
184
+ params: Record<string, string>;
185
+ /** Tiptap HTML for a text layer's body ('' for non-text layers). */
186
+ bodyHtml: string;
187
+ }
188
+
189
+ /** Map a Layer to a `{[type …]}` child heading's kind + params + body HTML. */
190
+ function layerToLayoutChild(layer: Layer): LayoutChildWrite {
191
+ const params = posParams(layer.position);
192
+ if (layer.type === 'text') {
193
+ Object.assign(params, styleParams(layer.content.style));
194
+ const bodyHtml = layer.content.html?.trim()
195
+ ? layer.content.html
196
+ : markdownToTiptap(layer.content.text ?? '');
197
+ return { kind: 'text', params, bodyHtml };
198
+ }
199
+ if (layer.type === 'shape') {
200
+ const c = layer.content;
201
+ const kind = c.shape === 'rect' ? 'rectangle' : c.shape; // circle / line keep their name
202
+ if (c.fill) params.fill = c.fill;
203
+ if (c.stroke) params.stroke = c.stroke;
204
+ if (c.strokeWidth != null) params.strokeWidth = String(c.strokeWidth);
205
+ if (c.borderStyle) params.borderStyle = c.borderStyle;
206
+ if (c.borderRadius != null) params.borderRadius = String(c.borderRadius);
207
+ return { kind, params, bodyHtml: '' };
208
+ }
209
+ if (layer.type === 'path') {
210
+ const c = layer.content;
211
+ params.d = c.d;
212
+ if (c.stroke) params.stroke = c.stroke;
213
+ if (c.strokeWidth != null) params.strokeWidth = String(c.strokeWidth);
214
+ if (c.fill) params.fill = c.fill;
215
+ return { kind: 'path', params, bodyHtml: '' };
216
+ }
217
+ if (layer.type === 'image') {
218
+ const c = layer.content;
219
+ params.src = c.src;
220
+ if (c.alt) params.alt = c.alt;
221
+ if (c.fit) params.fit = c.fit;
222
+ return { kind: 'image', params, bodyHtml: '' };
223
+ }
224
+ // Unknown layer types degrade to an empty text box.
225
+ return { kind: 'text', params, bodyHtml: '' };
226
+ }
227
+
228
+ /** Pull numeric x/y/width/height out of a Layer position into string params. */
229
+ function posParams(pos: Layer['position']): Record<string, string> {
230
+ const out: Record<string, string> = {};
231
+ if (typeof pos.x === 'number') out.x = String(Math.round(pos.x));
232
+ if (typeof pos.y === 'number') out.y = String(Math.round(pos.y));
233
+ if (typeof pos.width === 'number') out.width = String(Math.round(pos.width));
234
+ if (typeof pos.height === 'number') out.height = String(Math.round(pos.height));
235
+ return out;
236
+ }
237
+
238
+ /** Serialize a text layer's style into annotation params. */
239
+ function styleParams(style: TextStyle): Record<string, string> {
240
+ const p: Record<string, string> = {};
241
+ if (style.fontSize != null) p.fontSize = String(Math.round(style.fontSize));
242
+ if (style.color) p.color = style.color;
243
+ if (style.fontFamily) p.fontFamily = style.fontFamily;
244
+ if (style.fontWeight && style.fontWeight !== 'normal') p.fontWeight = style.fontWeight;
245
+ if (style.fontStyle && style.fontStyle !== 'normal') p.fontStyle = style.fontStyle;
246
+ if (style.textAlign) p.align = style.textAlign;
247
+ if (style.verticalAlign) p.valign = style.verticalAlign;
248
+ if (style.lineHeight != null) p.lineHeight = String(style.lineHeight);
249
+ if (style.padding != null) p.padding = String(style.padding);
250
+ if (style.background) p.background = style.background;
251
+ return p;
252
+ }
253
+
254
+ /** Map a `setLayerAttr` dotted path to a layout child param name. */
255
+ function paramForLayoutPath(path: string): string | null {
256
+ switch (path) {
257
+ case 'content.style.textAlign':
258
+ return 'align';
259
+ case 'content.style.verticalAlign':
260
+ return 'valign';
261
+ case 'content.style.fontSize':
262
+ return 'fontSize';
263
+ case 'content.style.color':
264
+ return 'color';
265
+ case 'content.style.fontFamily':
266
+ return 'fontFamily';
267
+ case 'content.style.fontWeight':
268
+ return 'fontWeight';
269
+ case 'content.style.fontStyle':
270
+ return 'fontStyle';
271
+ case 'content.style.lineHeight':
272
+ return 'lineHeight';
273
+ case 'content.style.padding':
274
+ return 'padding';
275
+ case 'content.style.background':
276
+ return 'background';
277
+ case 'content.fill':
278
+ return 'fill';
279
+ case 'content.stroke':
280
+ return 'stroke';
281
+ case 'content.strokeWidth':
282
+ return 'strokeWidth';
283
+ case 'content.borderStyle':
284
+ return 'borderStyle';
285
+ case 'content.borderRadius':
286
+ return 'borderRadius';
287
+ default:
288
+ return null;
289
+ }
290
+ }
291
+
292
+ const KIND_PREFIX: Record<string, string> = {
293
+ text: 'text',
294
+ rectangle: 'box',
295
+ rect: 'box',
296
+ circle: 'circle',
297
+ line: 'line',
298
+ path: 'path',
299
+ image: 'image',
300
+ };
301
+
302
+ /** A fresh, unique heading id for a new layer, e.g. `text-1`, `box-2`. */
303
+ function nextLayoutId(editor: Editor, parentPos: number, kind: string): string {
304
+ const used = new Set(listLayoutChildren(editor, parentPos).map((c) => c.id));
305
+ const prefix = KIND_PREFIX[kind] ?? (kind.replace(/[^a-z0-9]/gi, '').toLowerCase() || 'layer');
306
+ let i = 1;
307
+ let id = `${prefix}-${i}`;
308
+ while (used.has(id)) id = `${prefix}-${++i}`;
309
+ return id;
310
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * blockLayers — read / decode a block's legacy base64 Layer[] blob.
3
+ *
4
+ * **Legacy / migration only.** Layouts (and drawings) now persist every
5
+ * layer as a readable child sub-block heading (see `layoutCommands.ts` /
6
+ * `drawingCommands.ts`); nothing new is written here. These helpers remain
7
+ * so the editor can *read* documents authored before that change and
8
+ * migrate them to child sub-blocks on first edit (see `LayoutAdapter`'s
9
+ * migration effect). `updateLayer` is still used by the in-memory image
10
+ * editor designer.
11
+ *
12
+ * The legacy encoding piggy-backed on the `data-block-attrs` round-trip
13
+ * path by storing the layers as a base64-JSON Pandoc param named
14
+ * `layers="<...>"`. The bridge round-trips `{#id key="value"}` verbatim,
15
+ * so the markdown surface picked this up for free.
16
+ *
17
+ * Encoding choice — base64 keeps the JSON's commas, quotes, and braces
18
+ * from clashing with the Pandoc attribute tokenizer. Authors who want
19
+ * to hand-edit raw layer arrays can do so by switching to the raw
20
+ * markdown view, decoding, editing, re-encoding; future work may
21
+ * expose this in a more friendly format (e.g. a fenced `~~~layers`
22
+ * block).
23
+ */
24
+
25
+ import type { Editor } from '@tiptap/react';
26
+ import type { Node as PMNode } from '@tiptap/pm/model';
27
+ import type { Layer } from '@bendyline/squisq/schemas';
28
+ import {
29
+ parsePandocAttrTokens,
30
+ serializePandocAttributes,
31
+ type HeadingAttributes,
32
+ } from '@bendyline/squisq/markdown';
33
+
34
+ const LAYERS_PARAM = 'layers';
35
+
36
+ /** Decode the `layers="..."` param into a Layer[]; returns [] when absent or invalid. */
37
+ export function readLayersFromHeading(node: PMNode): Layer[] {
38
+ const attrs = getHeadingAttrs(node);
39
+ const encoded = attrs.params?.[LAYERS_PARAM];
40
+ if (!encoded) return [];
41
+ return decodeLayers(encoded);
42
+ }
43
+
44
+ /** Write a Layer[] back into the heading's `data-block-attrs`. */
45
+ export function writeLayersToHeading(
46
+ editor: Editor,
47
+ headingPos: number,
48
+ layers: readonly Layer[],
49
+ ): boolean {
50
+ return editor
51
+ .chain()
52
+ .command(({ tr }) => {
53
+ const node = tr.doc.nodeAt(headingPos);
54
+ if (!node || node.type.name !== 'heading') return false;
55
+ const attrs = getHeadingAttrs(node);
56
+ const params = { ...(attrs.params ?? {}) };
57
+ if (layers.length === 0) {
58
+ delete params[LAYERS_PARAM];
59
+ } else {
60
+ params[LAYERS_PARAM] = encodeLayers(layers);
61
+ }
62
+ const nextAttrs: HeadingAttributes = { ...attrs };
63
+ nextAttrs.params = Object.keys(params).length > 0 ? params : undefined;
64
+ // `serializePandocAttributes` returns the full `{…}` block, but
65
+ // `data-block-attrs` stores the INSIDE of those braces (matching the
66
+ // reader `parsePandocAttrTokens` and `tiptapBridge`). Strip them so
67
+ // the value round-trips — otherwise the next read sees `{layers=…}`
68
+ // as a malformed token and drops every layer.
69
+ const raw = serializePandocAttributes(nextAttrs);
70
+ tr.setNodeAttribute(headingPos, 'dataBlockAttrs', stripBraces(raw));
71
+ return true;
72
+ })
73
+ .run();
74
+ }
75
+
76
+ function utf8ToBase64(str: string): string {
77
+ const bytes = new TextEncoder().encode(str);
78
+ if (typeof globalThis.btoa === 'function') {
79
+ let binary = '';
80
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
81
+ return globalThis.btoa(binary);
82
+ }
83
+ return Buffer.from(bytes).toString('base64');
84
+ }
85
+
86
+ function base64ToUtf8(b64: string): string {
87
+ if (typeof globalThis.atob === 'function') {
88
+ const binary = globalThis.atob(b64);
89
+ const bytes = new Uint8Array(binary.length);
90
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
91
+ return new TextDecoder().decode(bytes);
92
+ }
93
+ return Buffer.from(b64, 'base64').toString('utf-8');
94
+ }
95
+
96
+ /** Base64-encode a Layer array as JSON. UTF-8 safe — handles Unicode layer content. */
97
+ export function encodeLayers(layers: readonly Layer[]): string {
98
+ return utf8ToBase64(JSON.stringify(layers));
99
+ }
100
+
101
+ /** Inverse of {@link encodeLayers}. Returns [] when the string isn't valid. */
102
+ export function decodeLayers(encoded: string): Layer[] {
103
+ try {
104
+ const parsed = JSON.parse(base64ToUtf8(encoded));
105
+ if (!Array.isArray(parsed)) return [];
106
+ return parsed as Layer[];
107
+ } catch {
108
+ return [];
109
+ }
110
+ }
111
+
112
+ function getHeadingAttrs(node: PMNode): HeadingAttributes {
113
+ const raw = (node.attrs as Record<string, unknown>).dataBlockAttrs;
114
+ if (typeof raw === 'string' && raw.length > 0) return parsePandocAttrTokens(raw);
115
+ return {};
116
+ }
117
+
118
+ /**
119
+ * Strip the wrapping `{…}` from a serialized Pandoc attribute block so it
120
+ * matches the inner form stored in `data-block-attrs`. An empty `{}`
121
+ * marker collapses to `null` (nothing to persist).
122
+ */
123
+ function stripBraces(s: string | null): string | null {
124
+ if (s == null) return null;
125
+ if (s === '{}') return null;
126
+ if (s.startsWith('{') && s.endsWith('}')) return s.slice(1, -1);
127
+ return s;
128
+ }
129
+
130
+ /**
131
+ * Apply a per-layer transform to the persisted layer list. Convenience
132
+ * for adapter dispatch functions that need to mutate one layer in place
133
+ * (e.g. `moveLayer`, `setLayerAttr`). The transform receives the
134
+ * matching layer (or `undefined` to insert) and returns the next
135
+ * version (or `null` to remove).
136
+ */
137
+ export function updateLayer(
138
+ editor: Editor,
139
+ headingPos: number,
140
+ layerId: string,
141
+ transform: (current: Layer | undefined) => Layer | null,
142
+ ): boolean {
143
+ const node = editor.state.doc.nodeAt(headingPos);
144
+ if (!node) return false;
145
+ const layers = readLayersFromHeading(node);
146
+ const idx = layers.findIndex((l) => l.id === layerId);
147
+ const current = idx >= 0 ? layers[idx] : undefined;
148
+ const next = transform(current);
149
+ let updated: Layer[];
150
+ if (next == null) {
151
+ updated = layers.filter((l) => l.id !== layerId);
152
+ } else if (idx >= 0) {
153
+ updated = layers.slice();
154
+ updated[idx] = next;
155
+ } else {
156
+ updated = [...layers, next];
157
+ }
158
+ return writeLayersToHeading(editor, headingPos, updated);
159
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * SceneCommand — the union of edits a Scene tool can request.
3
+ *
4
+ * Tools never mutate state directly. They produce a SceneCommand and the
5
+ * host adapter (DiagramAdapter / LayoutAdapter / DrawingAdapter) decides
6
+ * how to apply it — for diagrams that means a Tiptap transaction that
7
+ * updates a heading's Pandoc attrs; for layouts/drawings it's a write to
8
+ * the heading's `dataLayers` attr.
9
+ *
10
+ * Keeping the command shape adapter-agnostic is what lets the same Scene
11
+ * component drive three different editing surfaces.
12
+ */
13
+
14
+ import type { Layer } from '@bendyline/squisq/schemas';
15
+
16
+ export type SceneCommand =
17
+ /** Commit a layer's new top-left position (in viewport units). */
18
+ | { kind: 'moveLayer'; id: string; x: number; y: number }
19
+ /** Commit a layer's new size (in viewport units). */
20
+ | { kind: 'resizeLayer'; id: string; width: number; height: number }
21
+ /** Append a new layer to the scene. */
22
+ | { kind: 'addLayer'; layer: Layer }
23
+ /** Remove a layer by id. */
24
+ | { kind: 'removeLayer'; id: string }
25
+ /**
26
+ * Set a single attribute on a layer. `path` is a dotted path into the
27
+ * layer object (e.g. `content.text`, `content.style.fontSize`). The
28
+ * adapter is responsible for translating this to a domain-appropriate
29
+ * write (markdown attr, JSON-encoded override, etc.).
30
+ */
31
+ | { kind: 'setLayerAttr'; id: string; path: string; value: unknown }
32
+ /** Create an edge between two diagram nodes (diagram mode only). */
33
+ | { kind: 'addEdge'; source: string; target: string; type?: string }
34
+ /** Remove an edge by source/target (and optional type). */
35
+ | { kind: 'removeEdge'; source: string; target: string; type?: string }
36
+ /**
37
+ * Rename a labeled layer (e.g. a diagram node card or a TextLayer). The
38
+ * adapter decides whether this writes to TextLayer.content.text, a
39
+ * heading's text content, or something else.
40
+ */
41
+ | { kind: 'renameLayer'; id: string; label: string }
42
+ /**
43
+ * Set a text layer's content from the inline editor. `text` is the plain
44
+ * projection (always written); `html` is the optional rich representation
45
+ * (stored by adapters that can persist it — layout). Adapters that only
46
+ * keep plain text (diagram/drawing headings) ignore `html`.
47
+ */
48
+ | { kind: 'setLayerText'; id: string; text: string; html?: string };
49
+
50
+ /**
51
+ * An edge in diagram mode. Lives outside the Layer schema because edges
52
+ * are derived from a node's `connectsTo` rather than being authored as
53
+ * top-level visual layers — the Scene renders them as PathLayers, but
54
+ * the adapter never writes a PathLayer to the markdown.
55
+ */
56
+ export interface SceneEdge {
57
+ /** Stable edge id (`source->target` or `source->target:type`). */
58
+ id: string;
59
+ source: string;
60
+ target: string;
61
+ /** Optional label, rendered at the midpoint. */
62
+ label?: string;
63
+ /** End-of-line markers. Drawing connectors set these; diagrams default end→arrow. */
64
+ startMarker?: import('@bendyline/squisq/schemas').MarkerStyle;
65
+ endMarker?: import('@bendyline/squisq/schemas').MarkerStyle;
66
+ /** SVG stroke-dasharray for dashed/dotted lines. */
67
+ dasharray?: string;
68
+ /** Per-edge routing override (else the renderer's variant default). */
69
+ routing?: import('@bendyline/squisq/doc').ConnectorRouting;
70
+ }