@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
@@ -0,0 +1,103 @@
1
+ /**
2
+ * useScenePanZoom — verify pan, zoom-at, fit, and screen↔viewport
3
+ * coordinate conversions. The hook is pure state + math, so tests use
4
+ * @testing-library/react's `renderHook` and don't need DOM events.
5
+ */
6
+
7
+ import { describe, it, expect } from 'vitest';
8
+ import { act, renderHook } from '@testing-library/react';
9
+ import { useScenePanZoom, IDENTITY_TRANSFORM } from '../hooks/useScenePanZoom';
10
+
11
+ describe('useScenePanZoom', () => {
12
+ it('starts at identity by default', () => {
13
+ const { result } = renderHook(() => useScenePanZoom());
14
+ expect(result.current.transform).toEqual(IDENTITY_TRANSFORM);
15
+ });
16
+
17
+ it('panBy adds to translation', () => {
18
+ const { result } = renderHook(() => useScenePanZoom());
19
+ act(() => result.current.panBy(50, 30));
20
+ expect(result.current.transform.tx).toBe(50);
21
+ expect(result.current.transform.ty).toBe(30);
22
+ act(() => result.current.panBy(-20, 0));
23
+ expect(result.current.transform.tx).toBe(30);
24
+ expect(result.current.transform.ty).toBe(30);
25
+ });
26
+
27
+ it('zoomAt keeps the focus point stationary in viewport space', () => {
28
+ const { result } = renderHook(() => useScenePanZoom());
29
+ // Pre-zoom: identity. The screen point (200, 100) maps to viewport (200, 100).
30
+ const before = result.current.screenToViewport(200, 100);
31
+ expect(before).toEqual({ x: 200, y: 100 });
32
+
33
+ act(() => result.current.zoomAt(2, 200, 100));
34
+ expect(result.current.transform.scale).toBe(2);
35
+ // After zooming to scale=2 around screen-point (200, 100), the viewport
36
+ // point under that screen pixel must still be (200, 100).
37
+ const after = result.current.screenToViewport(200, 100);
38
+ expect(after.x).toBeCloseTo(200, 5);
39
+ expect(after.y).toBeCloseTo(100, 5);
40
+ });
41
+
42
+ it('clamps scale to the allowed range', () => {
43
+ const { result } = renderHook(() => useScenePanZoom());
44
+ act(() => result.current.zoomAt(100, 0, 0));
45
+ expect(result.current.transform.scale).toBeLessThanOrEqual(8);
46
+ act(() => result.current.zoomAt(0.0001, 0, 0));
47
+ expect(result.current.transform.scale).toBeGreaterThanOrEqual(0.1);
48
+ });
49
+
50
+ it('reset returns to identity', () => {
51
+ const { result } = renderHook(() => useScenePanZoom());
52
+ act(() => {
53
+ result.current.panBy(100, 100);
54
+ result.current.zoomAt(1.5, 50, 50);
55
+ });
56
+ expect(result.current.transform).not.toEqual(IDENTITY_TRANSFORM);
57
+ act(() => result.current.reset());
58
+ expect(result.current.transform).toEqual(IDENTITY_TRANSFORM);
59
+ });
60
+
61
+ it('fitBox centers and scales the box inside the container with padding', () => {
62
+ const { result } = renderHook(() => useScenePanZoom());
63
+ // 100x100 box, 200x200 container, 0 padding → scale=2, centered at (0,0).
64
+ act(() =>
65
+ result.current.fitBox(
66
+ { x: 0, y: 0, width: 100, height: 100 },
67
+ { width: 200, height: 200 },
68
+ 0,
69
+ ),
70
+ );
71
+ expect(result.current.transform.scale).toBe(2);
72
+ // Box origin (0,0) viewport → (0,0) screen (since the box is sized 200,
73
+ // matching the container after scaling); centered → tx=ty=0.
74
+ expect(result.current.transform.tx).toBe(0);
75
+ expect(result.current.transform.ty).toBe(0);
76
+ });
77
+
78
+ it('fitBox honors padding by leaving room around the content', () => {
79
+ const { result } = renderHook(() => useScenePanZoom());
80
+ act(() =>
81
+ result.current.fitBox(
82
+ { x: 0, y: 0, width: 100, height: 100 },
83
+ { width: 200, height: 200 },
84
+ 20,
85
+ ),
86
+ );
87
+ // Available area after padding: 160x160 → scale 1.6.
88
+ expect(result.current.transform.scale).toBeCloseTo(1.6, 5);
89
+ });
90
+
91
+ it('screenToViewport and viewportToScreen are inverses', () => {
92
+ const { result } = renderHook(() => useScenePanZoom());
93
+ act(() => {
94
+ result.current.panBy(30, -45);
95
+ result.current.zoomAt(1.7, 100, 100);
96
+ });
97
+ const screenPoint = { x: 250, y: 75 };
98
+ const v = result.current.screenToViewport(screenPoint.x, screenPoint.y);
99
+ const back = result.current.viewportToScreen(v.x, v.y);
100
+ expect(back.x).toBeCloseTo(screenPoint.x, 5);
101
+ expect(back.y).toBeCloseTo(screenPoint.y, 5);
102
+ });
103
+ });
@@ -0,0 +1,168 @@
1
+ /**
2
+ * DiagramAdapter — translate between Scene's generic command vocabulary
3
+ * and the diagram-specific Tiptap commands in `diagramCommands.ts`.
4
+ *
5
+ * Read direction: heading list + connectsTo → SceneLayer[] + SceneEdge[]
6
+ * Write direction: SceneCommand → moveNode / addConnection / addNode / etc.
7
+ *
8
+ * The diagram surface uses `node-card-<id>` / `node-label-<id>` ids for
9
+ * its synthetic card layers. The adapter maps those back to the
10
+ * underlying heading id when dispatching writes.
11
+ */
12
+
13
+ import type { Editor } from '@tiptap/react';
14
+ import type { Layer } from '@bendyline/squisq/schemas';
15
+ import type { SceneCommand, SceneEdge } from '../commands/SceneCommand';
16
+ import {
17
+ nodesToCardLayers,
18
+ nodeIdFromCardLayerId,
19
+ NODE_WIDTH,
20
+ NODE_HEIGHT,
21
+ type DiagramNodeDescriptor,
22
+ } from '../layers/nodeCard';
23
+ import {
24
+ moveNode,
25
+ addConnection,
26
+ removeConnection,
27
+ renameNode,
28
+ addNode,
29
+ removeNode,
30
+ listDiagramChildren,
31
+ } from '../../diagram/diagramCommands';
32
+ import type { DiagramRFNode, DiagramRFEdge } from '../../diagram/useDiagramData';
33
+
34
+ export interface DiagramSceneData {
35
+ layers: Layer[];
36
+ edges: SceneEdge[];
37
+ /** Diagram node descriptors (id, label, x, y). Useful for the ConnectTool overlay. */
38
+ nodes: DiagramNodeDescriptor[];
39
+ }
40
+
41
+ /**
42
+ * Convert the (nodes, edges) shape currently produced by `useDiagramData`
43
+ * into Scene's layer/edge vocabulary. We accept the React-Flow-flavored
44
+ * shape so the existing data hook can stay unchanged during the swap.
45
+ */
46
+ export function buildDiagramScene(
47
+ nodes: readonly DiagramRFNode[],
48
+ edges: readonly DiagramRFEdge[],
49
+ ): DiagramSceneData {
50
+ const descriptors: DiagramNodeDescriptor[] = nodes.map((n) => ({
51
+ id: n.id,
52
+ label: n.data.label,
53
+ x: n.position.x,
54
+ y: n.position.y,
55
+ ...(n.width != null ? { width: n.width } : {}),
56
+ ...(n.height != null ? { height: n.height } : {}),
57
+ }));
58
+ const layers = nodesToCardLayers(descriptors);
59
+ const sceneEdges: SceneEdge[] = edges.map((e) => ({
60
+ id: e.id,
61
+ source: e.source,
62
+ target: e.target,
63
+ ...(e.label ? { label: e.label } : {}),
64
+ }));
65
+ return { layers, edges: sceneEdges, nodes: descriptors };
66
+ }
67
+
68
+ /**
69
+ * Re-export of the diagram-specific `layerFollows` helper for hosts
70
+ * that want to pass it straight to `<Scene layerFollows={...} />`.
71
+ */
72
+ export { diagramLayerFollows } from '../layers/nodeCard';
73
+
74
+ /**
75
+ * Compute a node's center point in viewport units. Used by the
76
+ * ConnectTool to draw the in-flight connection preview and to find a
77
+ * drop target.
78
+ */
79
+ export function nodeCenter(node: DiagramNodeDescriptor): { x: number; y: number } {
80
+ return { x: node.x + NODE_WIDTH / 2, y: node.y + NODE_HEIGHT / 2 };
81
+ }
82
+
83
+ /**
84
+ * Build a SceneCommand dispatcher that writes back to Tiptap using the
85
+ * existing diagram commands. The returned function is stable for a
86
+ * given (editor, parentPos) tuple.
87
+ */
88
+ export function makeDiagramDispatch(
89
+ editor: Editor,
90
+ parentPos: number,
91
+ ): (cmd: SceneCommand) => void {
92
+ return (cmd: SceneCommand) => {
93
+ switch (cmd.kind) {
94
+ case 'moveLayer': {
95
+ const nodeId = nodeIdFromCardLayerId(cmd.id);
96
+ if (!nodeId) return;
97
+ // Only the card layer carries the node's position; ignore label
98
+ // moves (they'd duplicate the same write).
99
+ if (!cmd.id.startsWith('node-card-')) return;
100
+ moveNode(editor, parentPos, nodeId, cmd.x, cmd.y);
101
+ return;
102
+ }
103
+ case 'addEdge':
104
+ addConnection(editor, parentPos, cmd.source, cmd.target, cmd.type);
105
+ return;
106
+ case 'removeEdge':
107
+ removeConnection(editor, parentPos, cmd.source, cmd.target, cmd.type);
108
+ return;
109
+ case 'removeLayer': {
110
+ const nodeId = nodeIdFromCardLayerId(cmd.id);
111
+ if (nodeId) removeNode(editor, parentPos, nodeId);
112
+ return;
113
+ }
114
+ case 'renameLayer': {
115
+ const nodeId = nodeIdFromCardLayerId(cmd.id);
116
+ if (nodeId) renameNode(editor, parentPos, nodeId, cmd.label);
117
+ return;
118
+ }
119
+ case 'addLayer': {
120
+ // Diagram mode interprets addLayer as "add a new diagram node at
121
+ // the layer's position". Pull (x, y) from the layer's position
122
+ // and synthesize a fresh id.
123
+ const pos = cmd.layer.position;
124
+ const x = typeof pos.x === 'number' ? pos.x : 0;
125
+ const y = typeof pos.y === 'number' ? pos.y : 0;
126
+ const id = nextNodeId(editor, parentPos);
127
+ const label = inferAddedLabel(cmd.layer) ?? `Node`;
128
+ addNode(editor, parentPos, id, label, x, y);
129
+ return;
130
+ }
131
+ case 'setLayerAttr': {
132
+ const nodeId = nodeIdFromCardLayerId(cmd.id);
133
+ if (!nodeId) return;
134
+ if (cmd.path === 'content.text' && typeof cmd.value === 'string') {
135
+ renameNode(editor, parentPos, nodeId, cmd.value);
136
+ }
137
+ return;
138
+ }
139
+ case 'resizeLayer':
140
+ // v1: diagram nodes are fixed-size. Ignore resize.
141
+ return;
142
+ case 'setLayerText': {
143
+ const nodeId = nodeIdFromCardLayerId(cmd.id);
144
+ if (nodeId) renameNode(editor, parentPos, nodeId, cmd.text);
145
+ return;
146
+ }
147
+ }
148
+ // Exhaustiveness check — TS will flag unhandled command kinds.
149
+ const _exhaustive: never = cmd;
150
+ void _exhaustive;
151
+ };
152
+
153
+ function inferAddedLabel(layer: Layer): string | null {
154
+ if (layer.type === 'text') return layer.content.text;
155
+ return null;
156
+ }
157
+ }
158
+
159
+ function nextNodeId(editor: Editor, parentPos: number): string {
160
+ const used = new Set(listDiagramChildren(editor, parentPos).map((c) => c.id));
161
+ let i = used.size + 1;
162
+ let id = `node-${i}`;
163
+ while (used.has(id)) {
164
+ i++;
165
+ id = `node-${i}`;
166
+ }
167
+ return id;
168
+ }
@@ -0,0 +1,415 @@
1
+ /**
2
+ * DrawingAdapter — edit a `{[drawing]}` block as semantic markdown.
3
+ *
4
+ * Read direction: child `{[shape …]}` headings → `computeDrawingLayout` →
5
+ * Scene layers + connector edges.
6
+ * Write direction: SceneCommand → `drawingCommands` (insert/update/delete
7
+ * shape & connector headings).
8
+ *
9
+ * This is the drawing counterpart to `DiagramAdapter` — same seam
10
+ * (`LayoutAdapterResult`), richer shape vocabulary, resizable shapes, and
11
+ * connector create / select / delete / re-target. Unlike the old behaviour,
12
+ * nothing is persisted as a base64 `layers=` blob; every shape is a heading.
13
+ */
14
+
15
+ import { useCallback, useEffect, useMemo, useRef, useState, createElement } from 'react';
16
+ import type { ReactNode } from 'react';
17
+ import type { Editor } from '@tiptap/react';
18
+ import type { Layer, Block } from '@bendyline/squisq/schemas';
19
+ import { computeDrawingLayout } from '@bendyline/squisq/doc';
20
+ import type { SceneCommand, SceneEdge } from '../commands/SceneCommand';
21
+ import type { SceneTool, SceneToolContext } from '../tools/SceneTool';
22
+ import { SelectTool } from '../tools/SelectTool';
23
+ import { createPathTool } from '../tools/PathTool';
24
+ import { createTextTool } from '../tools/TextTool';
25
+ import { createDrawingConnectTool } from '../tools/DrawingConnectTool';
26
+ import { createDrawShapeTool } from '../tools/createDrawShapeTool';
27
+ import {
28
+ shapesToSceneLayers,
29
+ shapeBoxes,
30
+ drawingLayerFollows,
31
+ shapeIdFromLayerId,
32
+ isPrimaryShapeLayer,
33
+ type ShapeBox,
34
+ } from '../layers/shapeLayers';
35
+ import { DiagramEdges } from '../layers/DiagramEdges';
36
+ import { edgeEndpoints } from '../layers/edgeGeometry';
37
+ import {
38
+ addShape,
39
+ moveShape,
40
+ resizeShape,
41
+ setShapeParam,
42
+ setConnectorStyle,
43
+ removeShape,
44
+ renameShape,
45
+ addConnector,
46
+ removeConnector,
47
+ retargetConnector,
48
+ listDrawingChildren,
49
+ } from '../commands/drawingCommands';
50
+ import type { LayoutAdapterResult } from './LayoutAdapter';
51
+ import type { SceneEdge as SceneEdgeType } from '../commands/SceneCommand';
52
+
53
+ export interface DrawingAdapterOptions {
54
+ /** Default stroke color for new shapes/paths. */
55
+ stroke?: string;
56
+ /** Default fill color for new shapes. */
57
+ fill?: string;
58
+ }
59
+
60
+ /** Connector styling fields the properties panel can set. */
61
+ export interface ConnectorStyleInput {
62
+ startStyle?: string;
63
+ endStyle?: string;
64
+ lineStyle?: string;
65
+ routing?: string;
66
+ }
67
+
68
+ /**
69
+ * The drawing adapter's result extends the base scene-adapter contract with
70
+ * handles the host (SceneBlockWidget) uses to drive the shape palette and
71
+ * properties panel.
72
+ */
73
+ export interface DrawingAdapterResult extends LayoutAdapterResult {
74
+ /** Set the kind the draw tool will place next (the ShapePalette → canvas). */
75
+ setPendingKind: (kind: string | null) => void;
76
+ /** The currently-selected connector id, if any. */
77
+ selectedEdgeId: string | null;
78
+ /** The currently-selected connector, if any. */
79
+ selectedEdge: SceneEdgeType | null;
80
+ /** Update a connector's end/line/routing styles. */
81
+ setConnectorStyle: (id: string, style: ConnectorStyleInput) => void;
82
+ /** Update a shape's style param (fill/stroke/etc.); empty value clears it. */
83
+ setShapeParam: (id: string, key: string, value: string) => void;
84
+ }
85
+
86
+ /** Hit radius (viewport units) for grabbing a selected connector's endpoint. */
87
+ const ENDPOINT_HIT_RADIUS = 18;
88
+
89
+ export function useDrawingAdapter(
90
+ editor: Editor,
91
+ headingPos: number,
92
+ options: DrawingAdapterOptions = {},
93
+ ): DrawingAdapterResult {
94
+ const [, setVersion] = useState(0);
95
+ useEffect(() => {
96
+ const onUpdate = () => setVersion((v) => v + 1);
97
+ editor.on('transaction', onUpdate);
98
+ return () => {
99
+ editor.off('transaction', onUpdate);
100
+ };
101
+ }, [editor]);
102
+
103
+ // ── Read: child headings → shapes + connectors ────────────────
104
+ const node = editor.state.doc.nodeAt(headingPos);
105
+ const children = node ? listDrawingChildren(editor, headingPos) : [];
106
+ const blocks: Block[] = children.map((c) => ({
107
+ id: c.id,
108
+ startTime: 0,
109
+ duration: 0,
110
+ audioSegment: 0,
111
+ ...(c.kind ? { template: c.kind } : {}),
112
+ ...(Object.keys(c.params).length > 0 ? { templateOverrides: c.params } : {}),
113
+ ...(c.text ? { title: c.text } : {}),
114
+ }));
115
+ const layout = computeDrawingLayout(blocks);
116
+ const layers: Layer[] = shapesToSceneLayers(layout.shapes);
117
+ const boxes: ShapeBox[] = shapeBoxes(layout.shapes);
118
+ const edges: SceneEdge[] = layout.connectors.map((c) => ({
119
+ id: c.id,
120
+ source: c.from,
121
+ target: c.to,
122
+ ...(c.label ? { label: c.label } : {}),
123
+ startMarker: c.startMarker,
124
+ endMarker: c.endMarker,
125
+ ...(c.dasharray ? { dasharray: c.dasharray } : {}),
126
+ routing: c.routing,
127
+ }));
128
+
129
+ // ── Connector selection state ─────────────────────────────────
130
+ const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
131
+
132
+ // The shape kind the draw tool will place next (set by the ShapePalette).
133
+ // Defaults to rectangle so the toolbar "Shape" tool works before any pick.
134
+ const pendingKindRef = useRef<string | null>('rectangle');
135
+ const setPendingKind = useCallback((kind: string | null) => {
136
+ pendingKindRef.current = kind;
137
+ }, []);
138
+
139
+ // Mirror the live read in a ref so the memoized connect tool + keyboard
140
+ // handler always see current edges/boxes/selection without re-binding.
141
+ const dataRef = useRef({ edges, boxes, selectedEdgeId, headingPos });
142
+ dataRef.current = { edges, boxes, selectedEdgeId, headingPos };
143
+
144
+ // Drop a stale selection when its edge disappears (e.g. a shape removed).
145
+ useEffect(() => {
146
+ if (selectedEdgeId && !edges.some((e) => e.id === selectedEdgeId)) {
147
+ setSelectedEdgeId(null);
148
+ }
149
+ }, [edges, selectedEdgeId]);
150
+
151
+ // Delete/Backspace removes the selected connector (when focus isn't in a field).
152
+ useEffect(() => {
153
+ const onKey = (e: KeyboardEvent) => {
154
+ if (e.key !== 'Delete' && e.key !== 'Backspace') return;
155
+ if (isEditableTarget(e.target)) return;
156
+ const { edges: liveEdges, selectedEdgeId: sel, headingPos: pos } = dataRef.current;
157
+ if (!sel) return;
158
+ const edge = liveEdges.find((ed) => ed.id === sel);
159
+ if (!edge) return;
160
+ e.preventDefault();
161
+ removeConnector(editor, pos, edge.source, edge.target);
162
+ setSelectedEdgeId(null);
163
+ };
164
+ window.addEventListener('keydown', onKey);
165
+ return () => window.removeEventListener('keydown', onKey);
166
+ }, [editor]);
167
+
168
+ // ── Connect tool: create new edges + re-target a selected edge ──
169
+ const endpointAt = useCallback((point: { x: number; y: number }) => {
170
+ const { edges: liveEdges, boxes: liveBoxes, selectedEdgeId: sel } = dataRef.current;
171
+ if (!sel) return null;
172
+ const edge = liveEdges.find((ed) => ed.id === sel);
173
+ if (!edge) return null;
174
+ const ep = edgeEndpoints(liveBoxes, edge.source, edge.target);
175
+ if (!ep) return null;
176
+ if (within(ep.start, point))
177
+ return { connectorId: edge.id, end: 'from' as const, fixedShapeId: edge.target };
178
+ if (within(ep.end, point))
179
+ return { connectorId: edge.id, end: 'to' as const, fixedShapeId: edge.source };
180
+ return null;
181
+ }, []);
182
+
183
+ const onRetarget = useCallback(
184
+ (connectorId: string, end: 'from' | 'to', newTargetId: string) => {
185
+ retargetConnector(editor, dataRef.current.headingPos, connectorId, end, newTargetId);
186
+ },
187
+ [editor],
188
+ );
189
+
190
+ const onDrawShape = useCallback(
191
+ (kind: string, b: { x: number; y: number; width: number; height: number }) => {
192
+ const pos = dataRef.current.headingPos;
193
+ const id = nextShapeId(editor, pos, kind);
194
+ addShape(editor, pos, id, kind, {
195
+ x: String(Math.round(b.x)),
196
+ y: String(Math.round(b.y)),
197
+ width: String(Math.round(b.width)),
198
+ height: String(Math.round(b.height)),
199
+ });
200
+ },
201
+ [editor],
202
+ );
203
+
204
+ // Tools: Select, the kind-driven draw tool (the ShapePalette switches its
205
+ // kind; defaults to rectangle), freehand Pen, Text, and Connect. Specific
206
+ // rect/circle/line buttons are folded into the palette + draw tool.
207
+ const tools: SceneTool[] = useMemo(
208
+ () => [
209
+ SelectTool,
210
+ createDrawShapeTool({ getKind: () => pendingKindRef.current, onDraw: onDrawShape }),
211
+ createPathTool({ stroke: options.stroke }),
212
+ createTextTool({ color: options.stroke }),
213
+ createDrawingConnectTool({ endpointAt, onRetarget }),
214
+ ],
215
+ [options.stroke, endpointAt, onRetarget, onDrawShape],
216
+ );
217
+
218
+ const renderExtras = useCallback((_ctx: SceneToolContext): ReactNode => {
219
+ const { edges: liveEdges, boxes: liveBoxes, selectedEdgeId: sel } = dataRef.current;
220
+ return createElement(DiagramEdges, {
221
+ nodes: liveBoxes,
222
+ edges: liveEdges,
223
+ variant: 'straight',
224
+ selectedId: sel,
225
+ onEdgeClick: (edge: SceneEdge) => setSelectedEdgeId(edge.id),
226
+ });
227
+ }, []);
228
+
229
+ // ── Write: SceneCommand → drawing heading commands ─────────────
230
+ const dispatch = (cmd: SceneCommand) => {
231
+ switch (cmd.kind) {
232
+ case 'moveLayer': {
233
+ if (!isPrimaryShapeLayer(cmd.id)) return; // label moves duplicate the shape move
234
+ const id = shapeIdFromLayerId(cmd.id);
235
+ if (id) moveShape(editor, headingPos, id, cmd.x, cmd.y);
236
+ return;
237
+ }
238
+ case 'resizeLayer': {
239
+ if (!isPrimaryShapeLayer(cmd.id)) return;
240
+ const id = shapeIdFromLayerId(cmd.id);
241
+ if (id) resizeShape(editor, headingPos, id, cmd.width, cmd.height);
242
+ return;
243
+ }
244
+ case 'addLayer': {
245
+ const shape = layerToShape(cmd.layer);
246
+ const id = nextShapeId(editor, headingPos, shape.kind);
247
+ addShape(editor, headingPos, id, shape.kind, shape.params, shape.label);
248
+ return;
249
+ }
250
+ case 'removeLayer': {
251
+ const id = shapeIdFromLayerId(cmd.id);
252
+ if (id) removeShape(editor, headingPos, id);
253
+ return;
254
+ }
255
+ case 'renameLayer': {
256
+ const id = shapeIdFromLayerId(cmd.id);
257
+ if (id) renameShape(editor, headingPos, id, cmd.label);
258
+ return;
259
+ }
260
+ case 'setLayerText': {
261
+ // Drawing labels persist as heading text (markdown inline marks);
262
+ // the rich `html` is not stored separately.
263
+ const id = shapeIdFromLayerId(cmd.id);
264
+ if (id) renameShape(editor, headingPos, id, cmd.text);
265
+ return;
266
+ }
267
+ case 'setLayerAttr': {
268
+ const id = shapeIdFromLayerId(cmd.id);
269
+ if (!id) return;
270
+ const mapped = paramForLayerPath(cmd.path);
271
+ if (mapped === '__text__') {
272
+ renameShape(editor, headingPos, id, String(cmd.value ?? ''));
273
+ } else if (mapped) {
274
+ setShapeParam(editor, headingPos, id, mapped, String(cmd.value ?? ''));
275
+ }
276
+ return;
277
+ }
278
+ case 'addEdge':
279
+ addConnector(editor, headingPos, nextEdgeId(editor, headingPos), cmd.source, cmd.target);
280
+ return;
281
+ case 'removeEdge':
282
+ removeConnector(editor, headingPos, cmd.source, cmd.target);
283
+ return;
284
+ }
285
+ const _exhaustive: never = cmd;
286
+ void _exhaustive;
287
+ };
288
+
289
+ return {
290
+ layers,
291
+ edges,
292
+ tools,
293
+ dispatch,
294
+ layerFollows: drawingLayerFollows,
295
+ renderExtras,
296
+ setPendingKind,
297
+ selectedEdgeId,
298
+ selectedEdge: edges.find((e) => e.id === selectedEdgeId) ?? null,
299
+ setConnectorStyle: (id, style) => {
300
+ setConnectorStyle(editor, headingPos, id, style);
301
+ },
302
+ setShapeParam: (id, key, value) => {
303
+ setShapeParam(editor, headingPos, id, key, value);
304
+ },
305
+ };
306
+ }
307
+
308
+ // ============================================
309
+ // Layer ↔ shape mapping
310
+ // ============================================
311
+
312
+ interface ShapeWrite {
313
+ kind: string;
314
+ params: Record<string, string>;
315
+ label: string;
316
+ }
317
+
318
+ /** Map a tool-produced Layer to a `{[shape …]}` heading's kind + params + label. */
319
+ function layerToShape(layer: Layer): ShapeWrite {
320
+ const params = posParams(layer.position);
321
+ if (layer.type === 'shape') {
322
+ const c = layer.content;
323
+ const kind = c.shape === 'rect' ? 'rectangle' : c.shape; // circle / line keep their name
324
+ if (c.fill) params.fill = c.fill;
325
+ if (c.stroke) params.stroke = c.stroke;
326
+ if (c.strokeWidth != null) params.strokeWidth = String(c.strokeWidth);
327
+ if (c.borderRadius != null) params.borderRadius = String(c.borderRadius);
328
+ return { kind, params, label: '' };
329
+ }
330
+ if (layer.type === 'path') {
331
+ const c = layer.content;
332
+ params.d = c.d;
333
+ if (c.stroke) params.stroke = c.stroke;
334
+ if (c.strokeWidth != null) params.strokeWidth = String(c.strokeWidth);
335
+ if (c.fill) params.fill = c.fill;
336
+ return { kind: 'path', params, label: '' };
337
+ }
338
+ if (layer.type === 'text') {
339
+ return { kind: 'text', params, label: layer.content.text };
340
+ }
341
+ // Unknown layer types degrade to a rectangle outline.
342
+ return { kind: 'rectangle', params, label: '' };
343
+ }
344
+
345
+ /** Pull numeric x/y/width/height out of a Layer position into string params. */
346
+ function posParams(pos: Layer['position']): Record<string, string> {
347
+ const out: Record<string, string> = {};
348
+ if (typeof pos.x === 'number') out.x = String(Math.round(pos.x));
349
+ if (typeof pos.y === 'number') out.y = String(Math.round(pos.y));
350
+ if (typeof pos.width === 'number') out.width = String(Math.round(pos.width));
351
+ if (typeof pos.height === 'number') out.height = String(Math.round(pos.height));
352
+ return out;
353
+ }
354
+
355
+ /** Map a `setLayerAttr` dotted path to a shape param name (or '__text__' for the label). */
356
+ function paramForLayerPath(path: string): string | null {
357
+ switch (path) {
358
+ case 'content.fill':
359
+ return 'fill';
360
+ case 'content.stroke':
361
+ case 'content.style.color':
362
+ return 'stroke';
363
+ case 'content.strokeWidth':
364
+ return 'strokeWidth';
365
+ case 'content.borderRadius':
366
+ return 'borderRadius';
367
+ case 'content.dasharray':
368
+ return 'dasharray';
369
+ case 'content.d':
370
+ return 'd';
371
+ case 'content.text':
372
+ return '__text__';
373
+ default:
374
+ return null;
375
+ }
376
+ }
377
+
378
+ const KIND_PREFIX: Record<string, string> = {
379
+ rectangle: 'rect',
380
+ rect: 'rect',
381
+ circle: 'circle',
382
+ line: 'line',
383
+ arrow: 'arrow',
384
+ path: 'path',
385
+ text: 'text',
386
+ };
387
+
388
+ /** A fresh, unique heading id for a new shape, e.g. `rect-1`, `star-2`. */
389
+ function nextShapeId(editor: Editor, parentPos: number, kind: string): string {
390
+ const used = new Set(listDrawingChildren(editor, parentPos).map((c) => c.id));
391
+ const prefix = KIND_PREFIX[kind] ?? (kind.replace(/[^a-z0-9]/gi, '').toLowerCase() || 'shape');
392
+ let i = 1;
393
+ let id = `${prefix}-${i}`;
394
+ while (used.has(id)) id = `${prefix}-${++i}`;
395
+ return id;
396
+ }
397
+
398
+ /** A fresh, unique heading id for a new connector, e.g. `edge-1`. */
399
+ function nextEdgeId(editor: Editor, parentPos: number): string {
400
+ const used = new Set(listDrawingChildren(editor, parentPos).map((c) => c.id));
401
+ let i = 1;
402
+ let id = `edge-${i}`;
403
+ while (used.has(id)) id = `edge-${++i}`;
404
+ return id;
405
+ }
406
+
407
+ function within(a: { x: number; y: number }, b: { x: number; y: number }): boolean {
408
+ return Math.hypot(a.x - b.x, a.y - b.y) <= ENDPOINT_HIT_RADIUS;
409
+ }
410
+
411
+ function isEditableTarget(target: EventTarget | null): boolean {
412
+ if (!(target instanceof HTMLElement)) return false;
413
+ const tag = target.tagName;
414
+ return tag === 'INPUT' || tag === 'TEXTAREA' || target.isContentEditable;
415
+ }