@bendyline/squisq-editor-react 1.6.2 → 2.0.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 (218) hide show
  1. package/README.md +12 -3
  2. package/dist/index.d.ts +1275 -183
  3. package/dist/index.js +20345 -12777
  4. package/dist/index.js.map +1 -1
  5. package/dist/monaco.d.ts +1 -0
  6. package/dist/monaco.js +4 -0
  7. package/dist/monaco.js.map +1 -0
  8. package/dist/styles/index.css +1607 -241
  9. package/package.json +9 -5
  10. package/src/BlockPropertiesPopover.tsx +30 -15
  11. package/src/DocumentSettingsDialog.tsx +39 -33
  12. package/src/EditorContext.tsx +199 -42
  13. package/src/EditorShell.tsx +349 -261
  14. package/src/ImageEditor.tsx +22 -1
  15. package/src/InlinePreviewGutter.tsx +16 -8
  16. package/src/MediaBin.tsx +107 -14
  17. package/src/OutlinePanel.tsx +202 -7
  18. package/src/PlainHtmlPreview.tsx +40 -1
  19. package/src/PreviewControls.tsx +362 -89
  20. package/src/PreviewPanel.tsx +312 -78
  21. package/src/RawEditor.tsx +13 -2
  22. package/src/RecorderEntry.tsx +2 -0
  23. package/src/StatusBar.tsx +5 -2
  24. package/src/TemplateAnnotation.ts +12 -3
  25. package/src/TemplatePicker.tsx +75 -21
  26. package/src/ThemeCustomizerPanel.tsx +8 -0
  27. package/src/ThemePicker.tsx +10 -5
  28. package/src/TimelineBlockPreview.tsx +1 -1
  29. package/src/Toolbar.tsx +357 -51
  30. package/src/TransitionPicker.tsx +36 -4
  31. package/src/ViewMenuPanel.tsx +12 -14
  32. package/src/WysiwygEditor.tsx +124 -91
  33. package/src/__tests__/blockPropertiesPopoverTheme.test.tsx +33 -0
  34. package/src/__tests__/blockTagActivity.test.ts +183 -0
  35. package/src/__tests__/buildDocumentPreviewMarkdown.test.ts +75 -0
  36. package/src/__tests__/buildPreviewDocContent.test.ts +48 -0
  37. package/src/__tests__/buildPreviewDocTransition.test.ts +21 -1
  38. package/src/__tests__/canvasSurfaceTextEditing.test.tsx +60 -0
  39. package/src/__tests__/codeContextSectionView.test.tsx +8 -6
  40. package/src/__tests__/customLayoutManagerTheme.test.tsx +34 -0
  41. package/src/__tests__/documentSettingsDialog.test.tsx +51 -1
  42. package/src/__tests__/editorScrollHandoff.test.tsx +102 -0
  43. package/src/__tests__/editorScrollSync.test.ts +65 -0
  44. package/src/__tests__/editorShellProps.test.tsx +123 -1
  45. package/src/__tests__/emojiPicker.test.tsx +2 -42
  46. package/src/__tests__/headingTransition.test.ts +0 -12
  47. package/src/__tests__/imageEditAffordance.test.tsx +5 -1
  48. package/src/__tests__/imageEditModalContract.test.tsx +119 -0
  49. package/src/__tests__/imageEditorShell.test.tsx +15 -1
  50. package/src/__tests__/jsonEditor.test.tsx +56 -1
  51. package/src/__tests__/layersPanel.test.tsx +75 -5
  52. package/src/__tests__/majorApiTypes.test.ts +26 -0
  53. package/src/__tests__/mediaAttachmentFlow.test.ts +2 -2
  54. package/src/__tests__/mediaBinDrop.test.tsx +90 -0
  55. package/src/__tests__/narrationSave.test.ts +148 -0
  56. package/src/__tests__/outlinePanel.test.tsx +93 -3
  57. package/src/__tests__/outlineSource.test.ts +217 -0
  58. package/src/__tests__/plainHtmlPreview.test.tsx +17 -1
  59. package/src/__tests__/presentationMode.test.tsx +290 -0
  60. package/src/__tests__/previewControls.test.tsx +382 -5
  61. package/src/__tests__/previewPanelAudioFailure.test.tsx +46 -0
  62. package/src/__tests__/previewPanelPresentation.test.tsx +126 -0
  63. package/src/__tests__/previewPanelTransforms.test.tsx +117 -0
  64. package/src/__tests__/rawEditorModelIsolation.test.ts +19 -0
  65. package/src/__tests__/recorderTheme.test.tsx +42 -0
  66. package/src/__tests__/resolveBlockVisual.test.ts +43 -0
  67. package/src/__tests__/selectionConversions.test.ts +80 -0
  68. package/src/__tests__/statusBar.test.tsx +27 -0
  69. package/src/__tests__/teleprompter.test.tsx +190 -0
  70. package/src/__tests__/templateAnnotationRoundTrip.test.ts +26 -0
  71. package/src/__tests__/templatePickerPortal.test.tsx +62 -0
  72. package/src/__tests__/tiptapBridge.test.ts +71 -7
  73. package/src/__tests__/tiptapCodeBlockRoundTrip.test.ts +109 -0
  74. package/src/__tests__/tiptapImageRoundTrip.test.ts +1 -1
  75. package/src/__tests__/toolbarSelectionConversion.test.tsx +164 -0
  76. package/src/__tests__/transformStyleId.test.ts +13 -0
  77. package/src/__tests__/useImageEditor.test.tsx +67 -0
  78. package/src/__tests__/useMediaRecorder.test.ts +75 -0
  79. package/src/__tests__/viewMenuPanel.test.tsx +130 -0
  80. package/src/__tests__/wysiwygImageUpload.test.ts +69 -0
  81. package/src/__tests__/wysiwygTemplateBadgeFocus.test.tsx +56 -0
  82. package/src/asciiDiagram/AsciiDiagramExtension.ts +343 -0
  83. package/src/asciiDiagram/AsciiDiagramWidget.tsx +254 -0
  84. package/src/asciiDiagram/RepairableDiagramExtension.ts +200 -0
  85. package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +145 -0
  86. package/src/asciiDiagram/__tests__/RepairableDiagramExtension.test.ts +159 -0
  87. package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +284 -0
  88. package/src/asciiDiagram/__tests__/asciiDiagramOps.test.ts +114 -0
  89. package/src/asciiDiagram/__tests__/asciiPaste.test.ts +43 -0
  90. package/src/asciiDiagram/asciiDiagramCommands.ts +174 -0
  91. package/src/asciiDiagram/asciiDiagramData.ts +149 -0
  92. package/src/asciiDiagram/asciiDiagramOps.ts +238 -0
  93. package/src/asciiDiagram/asciiPaste.ts +19 -0
  94. package/src/blockTagActivity.ts +233 -0
  95. package/src/buildDocumentPreviewMarkdown.ts +168 -0
  96. package/src/buildPreviewDoc.ts +19 -12
  97. package/src/codeContext/types.ts +1 -1
  98. package/src/customTemplates/CustomLayoutManager.tsx +3 -2
  99. package/src/customTemplates/CustomTemplateContext.tsx +6 -0
  100. package/src/customTemplates/TemplateDesigner.tsx +7 -4
  101. package/src/customTemplates/__tests__/library.test.ts +8 -0
  102. package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +13 -0
  103. package/src/customTemplates/designer.css +158 -99
  104. package/src/customTemplates/library.ts +16 -3
  105. package/src/customTemplates/useMemoryLayerAdapter.ts +7 -1
  106. package/src/customThemes/CustomThemeContext.tsx +6 -0
  107. package/src/customThemes/CustomThemeDialog.tsx +35 -5
  108. package/src/customThemes/ImportThemeSection.tsx +178 -0
  109. package/src/customThemes/__tests__/customThemeLibrary.test.ts +9 -0
  110. package/src/customThemes/__tests__/importThemeSection.test.tsx +192 -0
  111. package/src/customThemes/customThemeLibrary.ts +7 -4
  112. package/src/customThemes/index.ts +7 -1
  113. package/src/customThemes/themeDraft.ts +23 -7
  114. package/src/diagram/DiagramCanvas.tsx +25 -6
  115. package/src/diagram/types.ts +35 -0
  116. package/src/editorScrollSync.ts +73 -0
  117. package/src/emojiData.ts +3 -23
  118. package/src/frontmatterSettings.ts +23 -0
  119. package/src/headingTransition.ts +0 -15
  120. package/src/imageEditor/CanvasSurface.tsx +29 -23
  121. package/src/imageEditor/LayersPanel.tsx +78 -3
  122. package/src/imageEditor/Toolbar.tsx +5 -1
  123. package/src/imageEditor/icons.tsx +4 -0
  124. package/src/imageEditor/image-editor.css +75 -0
  125. package/src/imageEditor/layers/SelectionHandles.tsx +1 -1
  126. package/src/imageEditor/useImageEditor.ts +71 -14
  127. package/src/index.ts +198 -20
  128. package/src/jsonEditor/JsonEditorContext.tsx +12 -6
  129. package/src/jsonEditor/RenderNode.tsx +24 -3
  130. package/src/jsonEditor/editors.tsx +86 -19
  131. package/src/mediaEntries.ts +12 -0
  132. package/src/monaco.ts +35 -0
  133. package/src/monacoWorkers.ts +89 -0
  134. package/src/outlineSource.ts +171 -0
  135. package/src/presentation/PresentationMode.tsx +596 -0
  136. package/src/rawEditorIsolation.ts +18 -0
  137. package/src/recorder/RecorderButton.tsx +9 -1
  138. package/src/recorder/RecorderModal.tsx +84 -41
  139. package/src/recorder/RecorderPanel.tsx +9 -1
  140. package/src/recorder/hooks/useMediaRecorder.ts +97 -53
  141. package/src/resolveBlockVisual.ts +10 -4
  142. package/src/scene/Scene.tsx +48 -18
  143. package/src/scene/SceneBlockExtension.ts +17 -10
  144. package/src/scene/SceneBlockWidget.tsx +5 -1
  145. package/src/scene/SceneViewport.tsx +7 -3
  146. package/src/scene/ShapePalette.tsx +17 -2
  147. package/src/scene/__tests__/DiagramAdapter.test.ts +86 -0
  148. package/src/scene/__tests__/SceneBlockExtension.test.ts +33 -0
  149. package/src/scene/__tests__/sceneIsolation.test.tsx +119 -0
  150. package/src/scene/adapters/DiagramAdapter.ts +12 -101
  151. package/src/scene/adapters/DrawingAdapter.ts +6 -1
  152. package/src/scene/adapters/LayoutAdapter.ts +3 -0
  153. package/src/scene/commands/SceneCommand.ts +17 -3
  154. package/src/scene/index.ts +1 -6
  155. package/src/scene/layers/DiagramEdges.tsx +21 -7
  156. package/src/scene/layers/edgeGeometry.ts +9 -1
  157. package/src/scene/layers/nodeCard.tsx +27 -8
  158. package/src/scene/scene.css +5 -2
  159. package/src/scene/text/SceneTextOverlay.tsx +5 -5
  160. package/src/scene/text/sceneTextChannel.ts +26 -20
  161. package/src/scene/text/sceneTextConfig.ts +4 -0
  162. package/src/scene/tools/ConnectTool.ts +13 -4
  163. package/src/scene/tools/SceneTool.ts +8 -0
  164. package/src/scene/tools/SelectTool.ts +39 -23
  165. package/src/scene/tools/ShapeTool.ts +2 -3
  166. package/src/selectionConversions.ts +155 -0
  167. package/src/styles/ascii-diagram.css +79 -0
  168. package/src/styles/ascii-timeline.css +596 -0
  169. package/src/styles/editor.css +736 -120
  170. package/src/styles/index.css +3 -0
  171. package/src/styles/tree-view.css +189 -0
  172. package/src/teleprompter/TeleprompterControls.tsx +218 -0
  173. package/src/teleprompter/TeleprompterSelfView.tsx +22 -0
  174. package/src/teleprompter/TeleprompterSurface.tsx +267 -0
  175. package/src/teleprompter/TeleprompterView.tsx +338 -0
  176. package/src/teleprompter/canvasRenderer.ts +161 -0
  177. package/src/teleprompter/floatingWindow.ts +352 -0
  178. package/src/teleprompter/index.ts +61 -0
  179. package/src/teleprompter/pcmWorklet.ts +62 -0
  180. package/src/teleprompter/recording/insertPreamble.ts +80 -0
  181. package/src/teleprompter/recording/narrationSave.ts +134 -0
  182. package/src/teleprompter/recording/useNarrationRecorder.ts +367 -0
  183. package/src/teleprompter/scrollModel.ts +85 -0
  184. package/src/teleprompter/teleprompterTheme.ts +303 -0
  185. package/src/teleprompter/types.ts +38 -0
  186. package/src/teleprompter/useFloatingWindow.ts +74 -0
  187. package/src/teleprompter/useMicAnalysis.ts +211 -0
  188. package/src/teleprompter/useTeleprompter.ts +421 -0
  189. package/src/templateContentPreviewResolver.ts +12 -5
  190. package/src/timeline/TimelineEditorWidget.tsx +861 -0
  191. package/src/timeline/TimelineViewExtension.ts +252 -0
  192. package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +414 -0
  193. package/src/timeline/__tests__/TimelineViewExtension.test.ts +163 -0
  194. package/src/timeline/__tests__/timelineCommands.test.ts +359 -0
  195. package/src/timeline/__tests__/timelineOps.test.ts +277 -0
  196. package/src/timeline/__tests__/timelinePaste.test.ts +34 -0
  197. package/src/timeline/timelineCommands.ts +421 -0
  198. package/src/timeline/timelineData.ts +54 -0
  199. package/src/timeline/timelineOps.ts +381 -0
  200. package/src/timeline/timelinePaste.ts +8 -0
  201. package/src/tiptapBridge.ts +47 -21
  202. package/src/transformStyleId.ts +17 -0
  203. package/src/treeview/TreeOutlineWidget.tsx +390 -0
  204. package/src/treeview/TreeViewExtension.ts +250 -0
  205. package/src/treeview/__tests__/TreeOutlineWidget.test.tsx +156 -0
  206. package/src/treeview/__tests__/TreeViewExtension.test.ts +122 -0
  207. package/src/treeview/__tests__/treeOps.test.ts +177 -0
  208. package/src/treeview/__tests__/treePaste.test.ts +40 -0
  209. package/src/treeview/__tests__/treeViewCommands.test.ts +179 -0
  210. package/src/treeview/treeOps.ts +231 -0
  211. package/src/treeview/treePaste.ts +13 -0
  212. package/src/treeview/treeViewCommands.ts +99 -0
  213. package/src/treeview/treeViewData.ts +41 -0
  214. package/src/useMonacoLoader.ts +11 -34
  215. package/src/wysiwygImageUpload.ts +113 -0
  216. package/src/diagram/DiagramExtension.ts +0 -209
  217. package/src/diagram/DiagramWidget.tsx +0 -272
  218. package/src/diagram/useDiagramData.ts +0 -126
@@ -10,26 +10,15 @@
10
10
  * underlying heading id when dispatching writes.
11
11
  */
12
12
 
13
- import type { Editor } from '@tiptap/react';
14
13
  import type { Layer } from '@bendyline/squisq/schemas';
15
- import type { SceneCommand, SceneEdge } from '../commands/SceneCommand';
14
+ import type { SceneEdge } from '../commands/SceneCommand';
16
15
  import {
17
16
  nodesToCardLayers,
18
- nodeIdFromCardLayerId,
19
17
  NODE_WIDTH,
20
18
  NODE_HEIGHT,
21
19
  type DiagramNodeDescriptor,
22
20
  } 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';
21
+ import type { DiagramNode, DiagramEdge } from '../../diagram/types';
33
22
 
34
23
  export interface DiagramSceneData {
35
24
  layers: Layer[];
@@ -44,8 +33,8 @@ export interface DiagramSceneData {
44
33
  * shape so the existing data hook can stay unchanged during the swap.
45
34
  */
46
35
  export function buildDiagramScene(
47
- nodes: readonly DiagramRFNode[],
48
- edges: readonly DiagramRFEdge[],
36
+ nodes: readonly DiagramNode[],
37
+ edges: readonly DiagramEdge[],
49
38
  ): DiagramSceneData {
50
39
  const descriptors: DiagramNodeDescriptor[] = nodes.map((n) => ({
51
40
  id: n.id,
@@ -54,6 +43,7 @@ export function buildDiagramScene(
54
43
  y: n.position.y,
55
44
  ...(n.width != null ? { width: n.width } : {}),
56
45
  ...(n.height != null ? { height: n.height } : {}),
46
+ ...(n.kind === 'container' ? { kind: 'container' as const } : {}),
57
47
  }));
58
48
  const layers = nodesToCardLayers(descriptors);
59
49
  const sceneEdges: SceneEdge[] = edges.map((e) => ({
@@ -61,6 +51,10 @@ export function buildDiagramScene(
61
51
  source: e.source,
62
52
  target: e.target,
63
53
  ...(e.label ? { label: e.label } : {}),
54
+ ...(e.directed === false ? { endMarker: 'none' as const } : {}),
55
+ ...(e.sourceAnchor ? { sourceAnchor: e.sourceAnchor } : {}),
56
+ ...(e.targetAnchor ? { targetAnchor: e.targetAnchor } : {}),
57
+ ...(e.routing ? { routing: e.routing } : {}),
64
58
  }));
65
59
  return { layers, edges: sceneEdges, nodes: descriptors };
66
60
  }
@@ -80,89 +74,6 @@ export function nodeCenter(node: DiagramNodeDescriptor): { x: number; y: number
80
74
  return { x: node.x + NODE_WIDTH / 2, y: node.y + NODE_HEIGHT / 2 };
81
75
  }
82
76
 
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
- }
77
+ // (The heading-based `makeDiagramDispatch` was removed with the ASCII-fence
78
+ // cutover canvas commands for diagrams now flow through
79
+ // `asciiDiagram/asciiDiagramCommands.ts` instead of heading mutations.)
@@ -238,7 +238,12 @@ export function useDrawingAdapter(
238
238
  case 'resizeLayer': {
239
239
  if (!isPrimaryShapeLayer(cmd.id)) return;
240
240
  const id = shapeIdFromLayerId(cmd.id);
241
- if (id) resizeShape(editor, headingPos, id, cmd.width, cmd.height);
241
+ if (id) {
242
+ if (cmd.x !== undefined && cmd.y !== undefined) {
243
+ moveShape(editor, headingPos, id, cmd.x, cmd.y);
244
+ }
245
+ resizeShape(editor, headingPos, id, cmd.width, cmd.height);
246
+ }
242
247
  return;
243
248
  }
244
249
  case 'addLayer': {
@@ -130,6 +130,9 @@ export function useLayoutAdapter(
130
130
  moveLayoutLayer(editor, headingPos, cmd.id, cmd.x, cmd.y);
131
131
  return;
132
132
  case 'resizeLayer':
133
+ if (cmd.x !== undefined && cmd.y !== undefined) {
134
+ moveLayoutLayer(editor, headingPos, cmd.id, cmd.x, cmd.y);
135
+ }
133
136
  resizeLayoutLayer(editor, headingPos, cmd.id, cmd.width, cmd.height);
134
137
  return;
135
138
  case 'addLayer': {
@@ -11,13 +11,24 @@
11
11
  * component drive three different editing surfaces.
12
12
  */
13
13
 
14
- import type { Layer } from '@bendyline/squisq/schemas';
14
+ import type { DiagramEdgeAnchor, Layer } from '@bendyline/squisq/schemas';
15
15
 
16
16
  export type SceneCommand =
17
17
  /** Commit a layer's new top-left position (in viewport units). */
18
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 }
19
+ /**
20
+ * Commit a layer's new size (in viewport units). `x`/`y` carry the final
21
+ * top-left for west/north handle drags so hosts can persist the whole
22
+ * resize atomically instead of reparsing between a move and a size write.
23
+ */
24
+ | {
25
+ kind: 'resizeLayer';
26
+ id: string;
27
+ width: number;
28
+ height: number;
29
+ x?: number;
30
+ y?: number;
31
+ }
21
32
  /** Append a new layer to the scene. */
22
33
  | { kind: 'addLayer'; layer: Layer }
23
34
  /** Remove a layer by id. */
@@ -67,4 +78,7 @@ export interface SceneEdge {
67
78
  dasharray?: string;
68
79
  /** Per-edge routing override (else the renderer's variant default). */
69
80
  routing?: import('@bendyline/squisq/doc').ConnectorRouting;
81
+ /** Optional authored node-side attachments (used by ASCII-derived diagrams). */
82
+ sourceAnchor?: DiagramEdgeAnchor;
83
+ targetAnchor?: DiagramEdgeAnchor;
70
84
  }
@@ -59,12 +59,7 @@ export {
59
59
  } from './layers/nodeCard';
60
60
  export { DiagramEdges } from './layers/DiagramEdges';
61
61
 
62
- export {
63
- buildDiagramScene,
64
- makeDiagramDispatch,
65
- nodeCenter,
66
- type DiagramSceneData,
67
- } from './adapters/DiagramAdapter';
62
+ export { buildDiagramScene, nodeCenter, type DiagramSceneData } from './adapters/DiagramAdapter';
68
63
 
69
64
  export {
70
65
  useLayoutAdapter,
@@ -13,6 +13,7 @@
13
13
  * drag).
14
14
  */
15
15
 
16
+ import { useId } from 'react';
16
17
  import type { MarkerStyle } from '@bendyline/squisq/schemas';
17
18
  import { connectorPath, markerPath } from '@bendyline/squisq/doc';
18
19
  import type { SceneEdge } from '../commands/SceneCommand';
@@ -33,8 +34,8 @@ interface DiagramEdgesProps {
33
34
  /** Marker styles that carry a glyph (everything except 'none'). */
34
35
  const MARKER_STYLES: MarkerStyle[] = ['arrow', 'open', 'diamond', 'circle', 'square'];
35
36
 
36
- function markerId(style: MarkerStyle, dir: 'start' | 'end'): string {
37
- return `squisq-edge-${style}-${dir}`;
37
+ function markerId(prefix: string, style: MarkerStyle, dir: 'start' | 'end'): string {
38
+ return `${prefix}-squisq-edge-${style}-${dir}`;
38
39
  }
39
40
 
40
41
  export function DiagramEdges({
@@ -45,6 +46,7 @@ export function DiagramEdges({
45
46
  onEdgeClick,
46
47
  }: DiagramEdgesProps) {
47
48
  const boxById = boxesOf(nodes);
49
+ const markerPrefix = useId().replace(/:/g, '');
48
50
  const defaultRouting = variant === 'straight' ? 'straight' : 'curved';
49
51
 
50
52
  return (
@@ -56,8 +58,8 @@ export function DiagramEdges({
56
58
  if (!mp) return null;
57
59
  return (
58
60
  <marker
59
- key={markerId(style, dir)}
60
- id={markerId(style, dir)}
61
+ key={markerId(markerPrefix, style, dir)}
62
+ id={markerId(markerPrefix, style, dir)}
61
63
  viewBox="0 0 10 10"
62
64
  refX={dir === 'end' ? 9 : 1}
63
65
  refY={5}
@@ -81,7 +83,13 @@ export function DiagramEdges({
81
83
  const a = boxById.get(edge.source);
82
84
  const b = boxById.get(edge.target);
83
85
  if (!a || !b) return null;
84
- const snapped = edgeEndpoints(nodes, edge.source, edge.target);
86
+ const snapped = edgeEndpoints(
87
+ nodes,
88
+ edge.source,
89
+ edge.target,
90
+ edge.sourceAnchor,
91
+ edge.targetAnchor,
92
+ );
85
93
  if (!snapped) return null;
86
94
  const { start, end } = snapped;
87
95
  const d = connectorPath(edge.routing ?? defaultRouting, start, end);
@@ -99,9 +107,15 @@ export function DiagramEdges({
99
107
  className="squisq-scene-edge-path"
100
108
  strokeDasharray={edge.dasharray}
101
109
  markerStart={
102
- startMarker !== 'none' ? `url(#${markerId(startMarker, 'start')})` : undefined
110
+ startMarker !== 'none'
111
+ ? `url(#${markerId(markerPrefix, startMarker, 'start')})`
112
+ : undefined
113
+ }
114
+ markerEnd={
115
+ endMarker !== 'none'
116
+ ? `url(#${markerId(markerPrefix, endMarker, 'end')})`
117
+ : undefined
103
118
  }
104
- markerEnd={endMarker !== 'none' ? `url(#${markerId(endMarker, 'end')})` : undefined}
105
119
  />
106
120
  {edge.label ? (
107
121
  <text
@@ -9,8 +9,10 @@
9
9
 
10
10
  import { NODE_WIDTH, NODE_HEIGHT } from './nodeCard';
11
11
  import {
12
+ anchorPoint,
12
13
  nearestSnapPoint as nearestCoreSnapPoint,
13
14
  snapEndpoints as coreSnapEndpoints,
15
+ type ConnectorAnchor,
14
16
  type ConnectorSnapPoint,
15
17
  } from '@bendyline/squisq/doc';
16
18
 
@@ -77,12 +79,18 @@ export function edgeEndpoints(
77
79
  nodes: readonly EdgeNodeBox[],
78
80
  source: string,
79
81
  target: string,
82
+ sourceAnchor?: ConnectorAnchor,
83
+ targetAnchor?: ConnectorAnchor,
80
84
  ): { start: ConnectorSnapPoint; end: ConnectorSnapPoint } | null {
81
85
  const boxes = boxesOf(nodes);
82
86
  const a = boxes.get(source);
83
87
  const b = boxes.get(target);
84
88
  if (!a || !b) return null;
85
- return coreSnapEndpoints(a, b);
89
+ const snapped = coreSnapEndpoints(a, b);
90
+ return {
91
+ start: sourceAnchor ? anchorPoint(a, sourceAnchor) : snapped.start,
92
+ end: targetAnchor ? anchorPoint(b, targetAnchor) : snapped.end,
93
+ };
86
94
  }
87
95
 
88
96
  export function straightPath(a: { x: number; y: number }, b: { x: number; y: number }): string {
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import type { ShapeLayer, TextLayer, Layer } from '@bendyline/squisq/schemas';
14
+ import { DIAGRAM_LABEL_LINE_HEIGHT, fitDiagramLabel } from '@bendyline/squisq/doc';
14
15
 
15
16
  export const NODE_WIDTH = 180;
16
17
  export const NODE_HEIGHT = 64;
@@ -34,6 +35,12 @@ export interface DiagramNodeDescriptor {
34
35
  stroke?: string;
35
36
  /** Optional shape: 'rect' (default) or 'pill' (fully rounded). */
36
37
  shape?: 'rect' | 'pill';
38
+ /**
39
+ * 'container' renders as a background grouping card: translucent fill,
40
+ * muted stroke, label anchored near the top edge instead of centered
41
+ * (children paint over the interior).
42
+ */
43
+ kind?: 'container';
37
44
  }
38
45
 
39
46
  /**
@@ -45,43 +52,55 @@ export interface DiagramNodeDescriptor {
45
52
  export function nodeCardLayers(node: DiagramNodeDescriptor): [ShapeLayer, TextLayer] {
46
53
  const width = node.width ?? NODE_WIDTH;
47
54
  const height = node.height ?? NODE_HEIGHT;
55
+ const isContainer = node.kind === 'container';
48
56
  const card: ShapeLayer = {
49
57
  id: `node-card-${node.id}`,
50
58
  type: 'shape',
51
59
  position: { x: node.x, y: node.y, width, height },
52
60
  content: {
53
61
  shape: 'rect',
54
- fill: node.fill ?? '#ffffff',
55
- stroke: node.stroke ?? '#1e293b',
62
+ fill: node.fill ?? (isContainer ? '#94a3b8' : '#ffffff'),
63
+ ...(isContainer ? { fillOpacity: 0.12 } : {}),
64
+ stroke: node.stroke ?? (isContainer ? '#94a3b8' : '#1e293b'),
56
65
  strokeWidth: 2,
57
66
  borderRadius: node.shape === 'pill' ? height / 2 : 10,
58
67
  },
59
68
  };
60
69
 
61
70
  // Scale the label font with the node height so resized cards stay
62
- // legible without the text overflowing.
63
- const fontSize = Math.max(12, Math.min(48, Math.round(height * 0.34)));
71
+ // legible without the text overflowing. Containers keep a fixed, small
72
+ // title size their height tracks their children, not their label.
73
+ const labelText = isContainer ? (node.label.split('\n')[0] ?? '') : node.label;
74
+ const preferredFontSize = Math.max(12, Math.min(48, Math.round(height * 0.34)));
75
+ const fit = isContainer
76
+ ? { fontSize: 16, firstLineOffset: 0, textWidth: width }
77
+ : fitDiagramLabel(labelText, width, height, preferredFontSize);
78
+ const { fontSize } = fit;
64
79
 
65
80
  // Label has no explicit height — that keeps its bounding box
66
81
  // unresolvable in `layerBounds`, which in turn keeps it out of the
67
82
  // Scene's hit-test. The label rides along visually via `layerFollows`
68
83
  // but never wins a pointer-down (the card underneath does), so a
69
84
  // drag-on-text moves the card as expected.
85
+ //
86
+ // Container labels anchor near the top edge — the interior belongs to
87
+ // the children painting over it.
70
88
  const label: TextLayer = {
71
89
  id: `node-label-${node.id}`,
72
90
  type: 'text',
73
91
  position: {
74
92
  x: node.x + width / 2,
75
- y: node.y + height / 2,
76
- width,
93
+ y: isContainer ? node.y + fontSize + 8 : node.y + height / 2 + fit.firstLineOffset,
94
+ width: fit.textWidth,
77
95
  anchor: 'center',
78
96
  },
79
97
  content: {
80
- text: node.label,
98
+ text: labelText,
81
99
  style: {
82
100
  fontSize,
101
+ ...(!isContainer ? { lineHeight: DIAGRAM_LABEL_LINE_HEIGHT } : {}),
83
102
  fontWeight: 'bold',
84
- color: node.stroke ?? '#1e293b',
103
+ color: node.stroke ?? (isContainer ? '#64748b' : '#1e293b'),
85
104
  textAlign: 'center',
86
105
  },
87
106
  },
@@ -37,11 +37,14 @@
37
37
 
38
38
  .squisq-editor-shell[data-theme='dark'] .squisq-scene-layer--shape,
39
39
  .squisq-editor-shell[data-theme='dark'] .squisq-scene-layer--path,
40
- .squisq-editor-shell[data-theme='dark'] .squisq-scene-layer--text,
41
- .squisq-editor-shell[data-theme='dark'] .squisq-scene-extras {
40
+ .squisq-editor-shell[data-theme='dark'] .squisq-scene-layer--text {
42
41
  filter: invert(1) hue-rotate(180deg) saturate(0.9);
43
42
  }
44
43
 
44
+ /* Scene extras (diagram/drawing connectors) already use the dark-aware
45
+ --squisq-text tokens below. Inverting this group a second time turns the
46
+ intended light strokes and arrowheads nearly black against the canvas. */
47
+
45
48
  .squisq-scene-viewport {
46
49
  display: block;
47
50
  width: 100%;
@@ -19,7 +19,6 @@ import { layerBounds } from '../hooks/useSceneHitTest';
19
19
  import type { SceneTransform } from '../hooks/useScenePanZoom';
20
20
  import type { SceneTextEditConfig } from './sceneTextConfig';
21
21
  import { buildSceneTextExtensions } from './sceneTiptap';
22
- import { sceneTextChannel } from './sceneTextChannel';
23
22
 
24
23
  interface SceneTextOverlayProps {
25
24
  config: SceneTextEditConfig;
@@ -90,12 +89,13 @@ export function SceneTextOverlay({
90
89
 
91
90
  // Publish to the toolbar channel while mounted; clear on unmount.
92
91
  useEffect(() => {
93
- if (!editor) return;
94
- sceneTextChannel.set({ editor, level: config.level });
92
+ if (!editor || !config.channel) return;
93
+ const channel = config.channel;
94
+ channel.set({ editor, level: config.level });
95
95
  return () => {
96
- if (sceneTextChannel.get()?.editor === editor) sceneTextChannel.set(null);
96
+ if (channel.get()?.editor === editor) channel.set(null);
97
97
  };
98
- }, [editor, config.level]);
98
+ }, [editor, config.level, config.channel]);
99
99
 
100
100
  // Commit when focus leaves the editor — unless it went to the toolbar
101
101
  // (the user clicking Bold) or stayed within the overlay.
@@ -1,13 +1,13 @@
1
1
  /**
2
- * sceneTextChannel — a module singleton that bridges the canvas's inline
2
+ * sceneTextChannel — an editor-owned channel that bridges the canvas's inline
3
3
  * text editor (which renders in a **detached React root** created by the
4
4
  * Diagram/SceneBlock ProseMirror extensions, outside `<EditorProvider>`)
5
5
  * to the provider, so the top formatting toolbar can target it.
6
6
  *
7
7
  * The active textbox's `SceneTextOverlay` publishes its Tiptap editor here
8
8
  * on focus and clears it on blur/unmount; `EditorProvider` subscribes and
9
- * mirrors the handle into `activeSceneText`. Singleton because only one
10
- * canvas textbox can be focused at a time.
9
+ * mirrors the handle into `activeSceneText`. Each EditorProvider creates its
10
+ * own channel, preventing focus in one editor from mutating another editor.
11
11
  */
12
12
 
13
13
  import type { Editor as TiptapEditor } from '@tiptap/core';
@@ -20,21 +20,27 @@ export interface SceneTextHandle {
20
20
 
21
21
  type Listener = (handle: SceneTextHandle | null) => void;
22
22
 
23
- let current: SceneTextHandle | null = null;
24
- const listeners = new Set<Listener>();
23
+ export interface SceneTextChannel {
24
+ set(handle: SceneTextHandle | null): void;
25
+ get(): SceneTextHandle | null;
26
+ subscribe(listener: Listener): () => void;
27
+ }
25
28
 
26
- export const sceneTextChannel = {
27
- /** Publish (or clear with `null`) the focused canvas textbox editor. */
28
- set(handle: SceneTextHandle | null): void {
29
- current = handle;
30
- for (const listener of listeners) listener(handle);
31
- },
32
- get(): SceneTextHandle | null {
33
- return current;
34
- },
35
- /** Subscribe to handle changes. Returns an unsubscribe function. */
36
- subscribe(listener: Listener): () => void {
37
- listeners.add(listener);
38
- return () => listeners.delete(listener);
39
- },
40
- };
29
+ /** Create a channel owned by one EditorProvider; no cross-editor singleton. */
30
+ export function createSceneTextChannel(): SceneTextChannel {
31
+ let current: SceneTextHandle | null = null;
32
+ const listeners = new Set<Listener>();
33
+ return {
34
+ set(handle) {
35
+ current = handle;
36
+ for (const listener of listeners) listener(handle);
37
+ },
38
+ get() {
39
+ return current;
40
+ },
41
+ subscribe(listener) {
42
+ listeners.add(listener);
43
+ return () => listeners.delete(listener);
44
+ },
45
+ };
46
+ }
@@ -5,9 +5,13 @@
5
5
  * the host owns persistence (markdown heading vs. layout JSON blob).
6
6
  */
7
7
 
8
+ import type { SceneTextChannel } from './sceneTextChannel';
9
+
8
10
  export type SceneTextLevel = 'inline' | 'block' | 'rich';
9
11
 
10
12
  export interface SceneTextEditConfig {
13
+ /** Per-editor bridge to the formatting toolbar. */
14
+ channel?: SceneTextChannel;
11
15
  /**
12
16
  * Map a hit layer id (what the pointer landed on) to the layer id whose
13
17
  * on-screen box the editor should cover, or `null` when that layer isn't
@@ -32,7 +32,13 @@ interface ConnectState {
32
32
  hoveredTargetId: string | null;
33
33
  }
34
34
 
35
- let state: ConnectState | null = null;
35
+ function getState(ctx: SceneToolContext): ConnectState | null {
36
+ return (ctx.interaction.connect as ConnectState | undefined) ?? null;
37
+ }
38
+
39
+ function setState(ctx: SceneToolContext, state: ConnectState | null): void {
40
+ ctx.interaction.connect = state ?? undefined;
41
+ }
36
42
 
37
43
  function pointerFromEvent(e: React.PointerEvent): { sx: number; sy: number } {
38
44
  const rect = (e.currentTarget as Element).getBoundingClientRect();
@@ -126,18 +132,19 @@ export const ConnectTool: SceneTool = {
126
132
  const sourceBox = nodeBoxFromCard(ctx, nodeId);
127
133
  if (!sourceBox) return;
128
134
  const preview = updatePreview(sourceBox, v, null);
129
- state = {
135
+ setState(ctx, {
130
136
  sourceNodeId: nodeId,
131
137
  sourceBox,
132
138
  start: preview.start,
133
139
  end: preview.end,
134
140
  hoveredTargetId: null,
135
- };
141
+ });
136
142
  (e.currentTarget as Element).setPointerCapture?.(e.pointerId);
137
143
  e.stopPropagation();
138
144
  },
139
145
 
140
146
  onPointerMove(e, ctx) {
147
+ const state = getState(ctx);
141
148
  if (!state) return;
142
149
  const { sx, sy } = pointerFromEvent(e);
143
150
  const v = ctx.screenToViewport(sx, sy);
@@ -152,16 +159,18 @@ export const ConnectTool: SceneTool = {
152
159
  },
153
160
 
154
161
  onPointerUp(e, ctx) {
162
+ const state = getState(ctx);
155
163
  if (!state) return;
156
164
  const target = state.hoveredTargetId;
157
165
  if (target) {
158
166
  ctx.dispatch({ kind: 'addEdge', source: state.sourceNodeId, target });
159
167
  }
160
168
  (e.currentTarget as Element).releasePointerCapture?.(e.pointerId);
161
- state = null;
169
+ setState(ctx, null);
162
170
  },
163
171
 
164
172
  renderOverlay(ctx): JSX.Element | null {
173
+ const state = getState(ctx);
165
174
  const points = nodeBoxes(ctx).flatMap((box) =>
166
175
  connectionPoints(box).map((point, index) =>
167
176
  createElement('circle', {
@@ -14,7 +14,15 @@ import type { SceneCommand, SceneEdge } from '../commands/SceneCommand';
14
14
  import type { HitTestable } from '../hooks/useSceneHitTest';
15
15
  import type { SceneTransform } from '../hooks/useScenePanZoom';
16
16
 
17
+ /** Mutable gesture storage owned by one Scene instance. */
18
+ export interface SceneInteractionState {
19
+ selectDrag?: unknown;
20
+ connect?: unknown;
21
+ }
22
+
17
23
  export interface SceneToolContext {
24
+ /** Gesture state private to the owning Scene instance. */
25
+ interaction: SceneInteractionState;
18
26
  /** Current viewport size in viewport units. */
19
27
  viewport: { width: number; height: number };
20
28
  /** Current pan/zoom transform. */