@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
@@ -23,7 +23,7 @@ import {
23
23
  } from 'react';
24
24
  import type { Layer } from '@bendyline/squisq/schemas';
25
25
  import type { SceneCommand, SceneEdge } from './commands/SceneCommand';
26
- import type { SceneTool, SceneToolContext } from './tools/SceneTool';
26
+ import type { SceneInteractionState, SceneTool, SceneToolContext } from './tools/SceneTool';
27
27
  import { useScenePanZoom } from './hooks/useScenePanZoom';
28
28
  import { useSceneSelection } from './hooks/useSceneSelection';
29
29
  import { useSceneHitTest, layerBounds, type HitTestable } from './hooks/useSceneHitTest';
@@ -140,12 +140,21 @@ export function Scene(props: SceneProps) {
140
140
  // ── Pan/zoom + selection ────────────────────────────────────
141
141
  const panZoom = useScenePanZoom();
142
142
  const selection = useSceneSelection();
143
+ const setSceneSelection = selection.setSelection;
143
144
  const { hit } = useSceneHitTest();
145
+ const interaction = useRef<SceneInteractionState>({}).current;
144
146
 
145
- // Surface selection upward so a host can drive a properties panel.
146
- useEffect(() => {
147
- onSelectionChange?.(selection.selection);
148
- }, [selection.selection, onSelectionChange]);
147
+ // Surface selection in the originating interaction rather than a passive
148
+ // effect. Detached editor roots can then update their toolbar without
149
+ // forcing a flush from inside another component's lifecycle.
150
+ const setSelection = useCallback(
151
+ (ids: Iterable<string>) => {
152
+ const next = new Set(ids);
153
+ setSceneSelection(next);
154
+ onSelectionChange?.(next);
155
+ },
156
+ [setSceneSelection, onSelectionChange],
157
+ );
149
158
 
150
159
  // ── Hit-test cache ──────────────────────────────────────────
151
160
  const hitItems: HitTestable[] = useMemo(() => {
@@ -161,6 +170,7 @@ export function Scene(props: SceneProps) {
161
170
  // ── Tool context (rebuilt every render — cheap, no allocations in hot path) ──
162
171
  const ctx: SceneToolContext = useMemo(
163
172
  () => ({
173
+ interaction,
164
174
  viewport,
165
175
  transform: panZoom.transform,
166
176
  layers,
@@ -170,7 +180,7 @@ export function Scene(props: SceneProps) {
170
180
  screenToViewport: panZoom.screenToViewport,
171
181
  viewportToScreen: panZoom.viewportToScreen,
172
182
  hit: (p) => hit(p, hitItems),
173
- setSelection: selection.setSelection,
183
+ setSelection,
174
184
  dispatch: onCommand,
175
185
  setActiveTool,
176
186
  beginTextEdit: textEdit.begin,
@@ -183,16 +193,17 @@ export function Scene(props: SceneProps) {
183
193
  layers,
184
194
  edges,
185
195
  selection.selection,
186
- selection.setSelection,
196
+ setSelection,
187
197
  hitItems,
188
198
  hit,
189
199
  onCommand,
190
200
  setActiveTool,
191
201
  textEdit.begin,
202
+ interaction,
192
203
  ],
193
204
  );
194
- // Mirror ctx in a ref so keyboard handlers attached to window read the
195
- // latest selection without re-binding on every selection change.
205
+ // Mirror ctx in a ref so root keyboard handlers read the latest selection
206
+ // without re-binding on every selection change.
196
207
  const ctxRef = useRef(ctx);
197
208
  ctxRef.current = ctx;
198
209
 
@@ -220,12 +231,9 @@ export function Scene(props: SceneProps) {
220
231
  [panZoom],
221
232
  );
222
233
 
223
- // Tools keep their drag state in module-level closures (so it survives
224
- // outside React's render cycle). To make the live drag/resize preview
225
- // *visible*, the Scene needs to re-render on every pointer-move while
226
- // a gesture is in progress. We bump `dragTick` on pointer-down and on
227
- // every move thereafter — cheap (one setState per move) and only fires
228
- // while the user is actively interacting.
234
+ // Gesture state belongs to this Scene instance and lives outside React's
235
+ // render cycle. Bump `dragTick` while a pointer gesture is active so its
236
+ // live drag/resize preview is visible.
229
237
  const [, setDragTick] = useState(0);
230
238
  const pointerActive = useRef(false);
231
239
  const bumpTick = useCallback(() => setDragTick((t) => t + 1), []);
@@ -280,6 +288,27 @@ export function Scene(props: SceneProps) {
280
288
  [activeTool],
281
289
  );
282
290
 
291
+ const handlePointerCancel = useCallback(
292
+ (e: React.PointerEvent<SVGSVGElement>) => {
293
+ pointerActive.current = false;
294
+ isPanning.current = false;
295
+ panLast.current = null;
296
+ interaction.selectDrag = undefined;
297
+ interaction.connect = undefined;
298
+ (e.currentTarget as Element).releasePointerCapture?.(e.pointerId);
299
+ bumpTick();
300
+ },
301
+ [interaction, bumpTick],
302
+ );
303
+
304
+ useEffect(
305
+ () => () => {
306
+ interaction.selectDrag = undefined;
307
+ interaction.connect = undefined;
308
+ },
309
+ [interaction],
310
+ );
311
+
283
312
  const handleDoubleClick = useCallback(
284
313
  (e: React.MouseEvent<SVGSVGElement>) => {
285
314
  activeTool?.onDoubleClick?.(e, ctxRef.current);
@@ -387,8 +416,8 @@ export function Scene(props: SceneProps) {
387
416
  }, [containerSize, hitItems, panZoom]);
388
417
 
389
418
  // ── Render ──────────────────────────────────────────────────
390
- const liveOffset = activeId === 'select' ? getActiveMoveOffset() : null;
391
- const liveResize = activeId === 'select' ? getActiveResize() : null;
419
+ const liveOffset = activeId === 'select' ? getActiveMoveOffset(interaction) : null;
420
+ const liveResize = activeId === 'select' ? getActiveResize(interaction) : null;
392
421
  const showSelectionHandles = activeTool?.hideSelectionHandles !== true;
393
422
 
394
423
  // Per-layer transform applied during an in-flight drag or resize so
@@ -453,7 +482,7 @@ export function Scene(props: SceneProps) {
453
482
  const sx = e.clientX - rect.left;
454
483
  const sy = e.clientY - rect.top;
455
484
  const startV = panZoom.screenToViewport(sx, sy);
456
- beginHandleDrag({ layerId: id, corner, startV, startBounds: bounds });
485
+ beginHandleDrag({ layerId: id, corner, startV, startBounds: bounds }, interaction);
457
486
  (e.currentTarget as Element).setPointerCapture?.(e.pointerId);
458
487
  // Mark a gesture as active so the Scene's pointer-move handler
459
488
  // triggers re-renders for the live resize preview.
@@ -475,6 +504,7 @@ export function Scene(props: SceneProps) {
475
504
  onPointerDown={handlePointerDown}
476
505
  onPointerMove={handlePointerMove}
477
506
  onPointerUp={handlePointerUp}
507
+ onPointerCancel={handlePointerCancel}
478
508
  onDoubleClick={handleDoubleClick}
479
509
  onDragOver={onDrop ? handleDragOver : undefined}
480
510
  onDrop={onDrop ? handleDrop : undefined}
@@ -20,6 +20,7 @@ import { createElement } from 'react';
20
20
  import type { Node as PMNode } from '@tiptap/pm/model';
21
21
  import { parsePandocAttrTokens } from '@bendyline/squisq/markdown';
22
22
  import { SceneBlockWidget, type SceneBlockMode } from './SceneBlockWidget';
23
+ import type { SceneTextChannel } from './text/sceneTextChannel';
23
24
 
24
25
  /**
25
26
  * Stable identifier for a layout/drawing heading — used as part of the
@@ -27,24 +28,24 @@ import { SceneBlockWidget, type SceneBlockMode } from './SceneBlockWidget';
27
28
  * changes (drag/resize commits write into the heading's
28
29
  * `data-block-attrs`).
29
30
  */
30
- export function getHeadingKey(node: PMNode): string {
31
+ export function getHeadingKey(node: PMNode, position?: number): string {
31
32
  const raw = (node.attrs as Record<string, unknown>).dataBlockAttrs;
32
33
  if (typeof raw === 'string' && raw.length > 0) {
33
34
  const attrs = parsePandocAttrTokens(raw);
34
- if (attrs.id) return attrs.id;
35
+ if (attrs.id) return position == null ? attrs.id : `${attrs.id}@${position}`;
35
36
  }
36
37
  let text = '';
37
38
  node.content.forEach((child) => {
38
39
  if (child.isText) text += child.text ?? '';
39
40
  });
40
- return (
41
+ const slug =
41
42
  text
42
43
  .toLowerCase()
43
44
  .replace(/[^\w\s-]/g, '')
44
45
  .replace(/[\s_]+/g, '-')
45
46
  .replace(/-+/g, '-')
46
- .replace(/^-|-$/g, '') || 'block'
47
- );
47
+ .replace(/^-|-$/g, '') || 'block';
48
+ return position == null ? slug : `${slug}@${position}`;
48
49
  }
49
50
 
50
51
  /**
@@ -63,7 +64,7 @@ export function findSceneHeadingPos(
63
64
  if (node.type.name !== 'heading') return;
64
65
  const attrs = node.attrs as { dataTemplate?: string };
65
66
  if (attrs.dataTemplate !== expectedTemplate) return;
66
- if (getHeadingKey(node) === headingKey) {
67
+ if (getHeadingKey(node, pos) === headingKey) {
67
68
  found = pos;
68
69
  return false;
69
70
  }
@@ -82,7 +83,11 @@ interface SceneRoot {
82
83
  root: Root;
83
84
  }
84
85
 
85
- function buildDecorations(state: EditorState, editor: Editor): DecorationSet {
86
+ function buildDecorations(
87
+ state: EditorState,
88
+ editor: Editor,
89
+ textChannel?: SceneTextChannel,
90
+ ): DecorationSet {
86
91
  const decos: Decoration[] = [];
87
92
 
88
93
  state.doc.descendants((node, pos) => {
@@ -93,7 +98,7 @@ function buildDecorations(state: EditorState, editor: Editor): DecorationSet {
93
98
  const parentDepth = attrs.level;
94
99
  const widgetPos = pos + node.nodeSize;
95
100
  const parentPos = pos;
96
- const headingKey = getHeadingKey(node);
101
+ const headingKey = getHeadingKey(node, pos);
97
102
  decos.push(
98
103
  Decoration.widget(
99
104
  widgetPos,
@@ -112,6 +117,7 @@ function buildDecorations(state: EditorState, editor: Editor): DecorationSet {
112
117
  fallbackParentPos: parentPos,
113
118
  mode,
114
119
  host: view.dom.parentElement ?? view.dom,
120
+ textChannel,
115
121
  }),
116
122
  );
117
123
 
@@ -161,6 +167,7 @@ function buildDecorations(state: EditorState, editor: Editor): DecorationSet {
161
167
  export interface SceneBlockExtensionOptions {
162
168
  /** When false, the extension is inert. */
163
169
  enabled?: boolean;
170
+ textChannel?: SceneTextChannel;
164
171
  }
165
172
 
166
173
  export const SceneBlockExtension = Extension.create<SceneBlockExtensionOptions>({
@@ -179,10 +186,10 @@ export const SceneBlockExtension = Extension.create<SceneBlockExtensionOptions>(
179
186
  new Plugin({
180
187
  key: KEY,
181
188
  state: {
182
- init: (_config, state) => buildDecorations(state, editor),
189
+ init: (_config, state) => buildDecorations(state, editor, this.options.textChannel),
183
190
  apply: (tr, oldDecos, _oldState, newState) => {
184
191
  if (!tr.docChanged) return oldDecos;
185
- return buildDecorations(newState, editor);
192
+ return buildDecorations(newState, editor, this.options.textChannel);
186
193
  },
187
194
  },
188
195
  props: {
@@ -30,6 +30,7 @@ import type { SceneTextEditConfig } from './text/sceneTextConfig';
30
30
  import { markdownToTiptap } from '../tiptapBridge';
31
31
  import { Icon } from '../Icon';
32
32
  import { DiagramMaximizedOverlay } from '../diagram/DiagramMaximizedOverlay';
33
+ import type { SceneTextChannel } from './text/sceneTextChannel';
33
34
 
34
35
  export type SceneBlockMode = 'layout' | 'drawing';
35
36
 
@@ -40,6 +41,7 @@ interface SceneBlockWidgetProps {
40
41
  mode: SceneBlockMode;
41
42
  /** Host element for portal targeting (used by maximize overlay). */
42
43
  host?: HTMLElement | null;
44
+ textChannel?: SceneTextChannel;
43
45
  }
44
46
 
45
47
  // Default viewport for layout/drawing surfaces. Matches Squisq's 16:9
@@ -59,6 +61,7 @@ export function SceneBlockWidget({
59
61
  fallbackParentPos,
60
62
  mode,
61
63
  host,
64
+ textChannel,
62
65
  }: SceneBlockWidgetProps) {
63
66
  const [parentPos, setParentPos] = useState<number>(
64
67
  () => findSceneHeadingPos(editor, headingKey, mode) ?? fallbackParentPos,
@@ -89,6 +92,7 @@ export function SceneBlockWidget({
89
92
  dispatchRef.current = adapter.dispatch;
90
93
  const textConfig = useMemo<SceneTextEditConfig>(
91
94
  () => ({
95
+ channel: textChannel,
92
96
  resolveEditableId: (id) => {
93
97
  const l = layersRef.current.find((x) => x.id === id);
94
98
  return l && l.type === 'text' ? id : null;
@@ -114,7 +118,7 @@ export function SceneBlockWidget({
114
118
  // content is stored as the child sub-block's markdown body.
115
119
  level: isDrawing ? 'inline' : 'block',
116
120
  }),
117
- [isDrawing],
121
+ [isDrawing, textChannel],
118
122
  );
119
123
 
120
124
  const [maximized, setMaximized] = useState(false);
@@ -11,7 +11,7 @@
11
11
  * canvas grid would.
12
12
  */
13
13
 
14
- import { forwardRef, type ReactNode, type WheelEvent } from 'react';
14
+ import { forwardRef, useId, type ReactNode, type WheelEvent } from 'react';
15
15
  import type { SceneTransform } from './hooks/useScenePanZoom';
16
16
 
17
17
  interface SceneViewportProps {
@@ -24,6 +24,7 @@ interface SceneViewportProps {
24
24
  onPointerDown?: (e: React.PointerEvent<SVGSVGElement>) => void;
25
25
  onPointerMove?: (e: React.PointerEvent<SVGSVGElement>) => void;
26
26
  onPointerUp?: (e: React.PointerEvent<SVGSVGElement>) => void;
27
+ onPointerCancel?: (e: React.PointerEvent<SVGSVGElement>) => void;
27
28
  onDoubleClick?: (e: React.MouseEvent<SVGSVGElement>) => void;
28
29
  /** HTML5 drag-and-drop handlers — used for palette drag-to-place. */
29
30
  onDragOver?: (e: React.DragEvent<SVGSVGElement>) => void;
@@ -43,6 +44,7 @@ export const SceneViewport = forwardRef<SVGSVGElement, SceneViewportProps>(funct
43
44
  onPointerDown,
44
45
  onPointerMove,
45
46
  onPointerUp,
47
+ onPointerCancel,
46
48
  onDoubleClick,
47
49
  onDragOver,
48
50
  onDrop,
@@ -52,6 +54,7 @@ export const SceneViewport = forwardRef<SVGSVGElement, SceneViewportProps>(funct
52
54
  ref,
53
55
  ) {
54
56
  const t = transform;
57
+ const gridId = `squisq-scene-dot-grid-${useId().replace(/:/g, '')}`;
55
58
  const groupTransform = `translate(${t.tx} ${t.ty}) scale(${t.scale})`;
56
59
 
57
60
  return (
@@ -70,6 +73,7 @@ export const SceneViewport = forwardRef<SVGSVGElement, SceneViewportProps>(funct
70
73
  onPointerDown={onPointerDown}
71
74
  onPointerMove={onPointerMove}
72
75
  onPointerUp={onPointerUp}
76
+ onPointerCancel={onPointerCancel}
73
77
  onDoubleClick={onDoubleClick}
74
78
  onDragOver={onDragOver}
75
79
  onDrop={onDrop}
@@ -85,7 +89,7 @@ export const SceneViewport = forwardRef<SVGSVGElement, SceneViewportProps>(funct
85
89
  >
86
90
  <defs>
87
91
  <pattern
88
- id="squisq-scene-dot-grid"
92
+ id={gridId}
89
93
  x={t.tx}
90
94
  y={t.ty}
91
95
  width={24 * t.scale}
@@ -95,7 +99,7 @@ export const SceneViewport = forwardRef<SVGSVGElement, SceneViewportProps>(funct
95
99
  <circle cx={1} cy={1} r={1} className="squisq-scene-dot" />
96
100
  </pattern>
97
101
  </defs>
98
- <rect width="100%" height="100%" fill="url(#squisq-scene-dot-grid)" />
102
+ <rect width="100%" height="100%" fill={`url(#${gridId})`} />
99
103
  <g transform={groupTransform}>{children}</g>
100
104
  </svg>
101
105
  );
@@ -8,7 +8,7 @@
8
8
  * gets drawn.
9
9
  */
10
10
 
11
- import { useEffect, useMemo, useRef, useState } from 'react';
11
+ import { useEffect, useId, useMemo, useRef, useState } from 'react';
12
12
  import { shapePath } from '@bendyline/squisq/doc';
13
13
 
14
14
  interface ShapePaletteProps {
@@ -152,8 +152,23 @@ export function ShapePalette({
152
152
  /** A 40×30 SVG thumbnail of a shape, inside a 48×38 button viewport. */
153
153
  function ShapeThumb({ kind }: { kind: string }) {
154
154
  const d = shapePath(kind, 4, 4, 40, 30);
155
+ const arrowId = `squisq-shape-thumb-arrow-${useId().replace(/:/g, '')}`;
155
156
  return (
156
157
  <svg viewBox="0 0 48 38" width={36} height={28} aria-hidden="true">
158
+ {kind === 'arrow' ? (
159
+ <defs>
160
+ <marker
161
+ id={arrowId}
162
+ viewBox="0 0 10 10"
163
+ refX={9}
164
+ refY={5}
165
+ markerWidth={4}
166
+ markerHeight={4}
167
+ >
168
+ <path d="M 0 0 L 10 5 L 0 10 z" className="squisq-shape-thumb-line" />
169
+ </marker>
170
+ </defs>
171
+ ) : null}
157
172
  {d ? (
158
173
  <path d={d} className="squisq-shape-thumb" />
159
174
  ) : kind === 'circle' ? (
@@ -167,7 +182,7 @@ function ShapeThumb({ kind }: { kind: string }) {
167
182
  x2={44}
168
183
  y2={4}
169
184
  className="squisq-shape-thumb-line"
170
- markerEnd="url(#squisq-edge-arrow-end)"
185
+ markerEnd={`url(#${arrowId})`}
171
186
  />
172
187
  ) : kind === 'text' ? (
173
188
  <text x={24} y={26} textAnchor="middle" className="squisq-shape-thumb-text">
@@ -53,4 +53,90 @@ describe('buildDiagramScene', () => {
53
53
  );
54
54
  expect(out.edges[0]).toEqual({ id: 'a->b', source: 'a', target: 'b' });
55
55
  });
56
+
57
+ it('passes container kind through and styles the label top-anchored', () => {
58
+ const out = buildDiagramScene(
59
+ [
60
+ {
61
+ id: 'group',
62
+ position: { x: 0, y: 0 },
63
+ data: { label: 'Cluster\nextra' },
64
+ width: 600,
65
+ height: 400,
66
+ kind: 'container',
67
+ },
68
+ { id: 'a', position: { x: 40, y: 60 }, data: { label: 'A' } },
69
+ ],
70
+ [],
71
+ );
72
+ expect(out.nodes[0].kind).toBe('container');
73
+ const label = out.layers.find((l) => l.id === 'node-label-group');
74
+ expect(label?.position.y).toBeLessThan(0 + 400 / 2); // top-anchored, not centered
75
+ if (label?.type === 'text') expect(label.content.text).toBe('Cluster'); // first line only
76
+ });
77
+
78
+ it('fits and vertically centers dense multi-line labels inside ASCII-sized cards', () => {
79
+ const out = buildDiagramScene(
80
+ [
81
+ {
82
+ id: 'kernel',
83
+ position: { x: 100, y: 200 },
84
+ data: {
85
+ label: '@bendyline/molen-kernel\nheadless sim — no DOM, no thread\nWorker + Node',
86
+ },
87
+ width: 252,
88
+ height: 112,
89
+ },
90
+ ],
91
+ [],
92
+ );
93
+ const label = out.layers.find((layer) => layer.id === 'node-label-kernel');
94
+ expect(label?.type).toBe('text');
95
+ if (label?.type !== 'text') return;
96
+
97
+ expect(label.content.style.fontSize).toBeLessThan(38);
98
+ expect(label.content.style.lineHeight).toBe(1.25);
99
+ expect(label.position.width).toBe(228);
100
+ expect(label.position.y).toBeLessThan(200 + 112 / 2);
101
+ });
102
+
103
+ it('maps directed:false to endMarker none, leaves default edges alone', () => {
104
+ const out = buildDiagramScene(
105
+ [
106
+ { id: 'a', position: { x: 0, y: 0 }, data: { label: 'A' } },
107
+ { id: 'b', position: { x: 300, y: 0 }, data: { label: 'B' } },
108
+ ],
109
+ [
110
+ { id: 'a->b', source: 'a', target: 'b', directed: false },
111
+ { id: 'b->a', source: 'b', target: 'a' },
112
+ ],
113
+ );
114
+ expect(out.edges[0]).toEqual({ id: 'a->b', source: 'a', target: 'b', endMarker: 'none' });
115
+ expect(out.edges[1]).toEqual({ id: 'b->a', source: 'b', target: 'a' });
116
+ });
117
+
118
+ it('preserves authored anchors and routing for ASCII-derived edges', () => {
119
+ const out = buildDiagramScene(
120
+ [
121
+ { id: 'source', position: { x: 0, y: 0 }, data: { label: 'Source' } },
122
+ { id: 'target', position: { x: 0, y: 200 }, data: { label: 'Target' } },
123
+ ],
124
+ [
125
+ {
126
+ id: 'source->target',
127
+ source: 'source',
128
+ target: 'target',
129
+ sourceAnchor: { side: 'bottom', offset: 0.25 },
130
+ targetAnchor: { side: 'top', offset: 0.5 },
131
+ routing: 'orthogonal',
132
+ },
133
+ ],
134
+ );
135
+
136
+ expect(out.edges[0]).toMatchObject({
137
+ sourceAnchor: { side: 'bottom', offset: 0.25 },
138
+ targetAnchor: { side: 'top', offset: 0.5 },
139
+ routing: 'orthogonal',
140
+ });
141
+ });
56
142
  });
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { Schema } from '@tiptap/pm/model';
3
+ import type { Editor } from '@tiptap/react';
4
+ import { findSceneHeadingPos, getHeadingKey } from '../SceneBlockExtension';
5
+
6
+ const schema = new Schema({
7
+ nodes: {
8
+ doc: { content: 'heading+' },
9
+ text: {},
10
+ heading: {
11
+ content: 'text*',
12
+ attrs: {
13
+ level: { default: 1 },
14
+ dataTemplate: { default: 'layout' },
15
+ dataBlockAttrs: { default: null },
16
+ },
17
+ },
18
+ },
19
+ });
20
+
21
+ describe('SceneBlockExtension heading identity', () => {
22
+ it('targets the correct canvas when headings have duplicate text or ids', () => {
23
+ const first = schema.node('heading', { dataBlockAttrs: '#same' }, schema.text('Layout'));
24
+ const second = schema.node('heading', { dataBlockAttrs: '#same' }, schema.text('Layout'));
25
+ const secondPos = first.nodeSize;
26
+ const doc = schema.node('doc', null, [first, second]);
27
+ const editor = { state: { doc } } as unknown as Editor;
28
+ const firstKey = getHeadingKey(first, 0);
29
+ const secondKey = getHeadingKey(second, secondPos);
30
+ expect(firstKey).not.toBe(secondKey);
31
+ expect(findSceneHeadingPos(editor, secondKey, 'layout')).toBe(secondPos);
32
+ });
33
+ });
@@ -0,0 +1,119 @@
1
+ /** @vitest-environment jsdom */
2
+ import { render } from '@testing-library/react';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import type { SceneToolContext } from '../tools/SceneTool';
5
+ import { SelectTool, beginHandleDrag, getActiveMoveOffset } from '../tools/SelectTool';
6
+ import { ConnectTool } from '../tools/ConnectTool';
7
+ import { createSceneTextChannel } from '../text/sceneTextChannel';
8
+ import { SceneViewport } from '../SceneViewport';
9
+ import { DiagramEdges } from '../layers/DiagramEdges';
10
+
11
+ function context(id = 'layer'): SceneToolContext {
12
+ const layer = {
13
+ id,
14
+ type: 'shape' as const,
15
+ position: { x: 0, y: 0, width: 20, height: 20 },
16
+ content: { shape: 'rect' as const },
17
+ };
18
+ return {
19
+ interaction: {},
20
+ viewport: { width: 100, height: 100 },
21
+ transform: { tx: 0, ty: 0, scale: 1 },
22
+ layers: [layer],
23
+ edges: [],
24
+ selection: new Set(),
25
+ hitItems: [{ id, layer, bounds: { x: 0, y: 0, width: 20, height: 20 } }],
26
+ screenToViewport: (x, y) => ({ x, y }),
27
+ viewportToScreen: (x, y) => ({ x, y }),
28
+ hit: () => id,
29
+ setSelection: vi.fn(),
30
+ dispatch: vi.fn(),
31
+ };
32
+ }
33
+
34
+ function pointer(clientX: number, clientY: number) {
35
+ return {
36
+ button: 0,
37
+ clientX,
38
+ clientY,
39
+ pointerId: 1,
40
+ shiftKey: false,
41
+ currentTarget: {
42
+ getBoundingClientRect: () => ({ left: 0, top: 0 }),
43
+ setPointerCapture: vi.fn(),
44
+ releasePointerCapture: vi.fn(),
45
+ },
46
+ stopPropagation: vi.fn(),
47
+ } as unknown as React.PointerEvent;
48
+ }
49
+
50
+ describe('Scene instance isolation', () => {
51
+ it('keeps select and connect gestures in the owning Scene context', () => {
52
+ const first = context('node-card-a');
53
+ const second = context('node-card-a');
54
+ SelectTool.onPointerDown!(pointer(0, 0), first);
55
+ SelectTool.onPointerMove!(pointer(10, 0), first);
56
+ expect(getActiveMoveOffset(first.interaction)).toEqual({ dx: 10, dy: 0 });
57
+ expect(getActiveMoveOffset(second.interaction)).toBeNull();
58
+
59
+ ConnectTool.onPointerDown!(pointer(5, 5), first);
60
+ expect(first.interaction.connect).toBeTruthy();
61
+ expect(second.interaction.connect).toBeUndefined();
62
+ });
63
+
64
+ it('commits a resize as one command containing origin and size', () => {
65
+ const ctx = context('node-card-a');
66
+ beginHandleDrag(
67
+ {
68
+ layerId: 'node-card-a',
69
+ corner: 'nw',
70
+ startV: { x: 10, y: 20 },
71
+ startBounds: { x: 10, y: 20, width: 100, height: 50 },
72
+ },
73
+ ctx.interaction,
74
+ );
75
+
76
+ SelectTool.onPointerMove!(pointer(0, 0), ctx);
77
+ SelectTool.onPointerUp!(pointer(0, 0), ctx);
78
+
79
+ expect(ctx.dispatch).toHaveBeenCalledTimes(1);
80
+ expect(ctx.dispatch).toHaveBeenCalledWith({
81
+ kind: 'resizeLayer',
82
+ id: 'node-card-a',
83
+ x: 0,
84
+ y: 0,
85
+ width: 110,
86
+ height: 70,
87
+ });
88
+ });
89
+
90
+ it('creates independent text-toolbar channels', () => {
91
+ const first = createSceneTextChannel();
92
+ const second = createSceneTextChannel();
93
+ const listener = vi.fn();
94
+ second.subscribe(listener);
95
+ first.set({ editor: {} as never, level: 'inline' });
96
+ expect(listener).not.toHaveBeenCalled();
97
+ expect(second.get()).toBeNull();
98
+ });
99
+
100
+ it('namespaces grid and edge marker definitions per canvas', () => {
101
+ const edge = { id: 'e', source: 'a', target: 'b', endMarker: 'arrow' as const };
102
+ const nodes = [
103
+ { id: 'a', x: 0, y: 0, width: 10, height: 10, cx: 5, cy: 5 },
104
+ { id: 'b', x: 30, y: 0, width: 10, height: 10, cx: 35, cy: 5 },
105
+ ];
106
+ const { container } = render(
107
+ <>
108
+ <SceneViewport width={100} height={100} transform={{ tx: 0, ty: 0, scale: 1 }}>
109
+ <DiagramEdges nodes={nodes} edges={[edge]} />
110
+ </SceneViewport>
111
+ <SceneViewport width={100} height={100} transform={{ tx: 0, ty: 0, scale: 1 }}>
112
+ <DiagramEdges nodes={nodes} edges={[edge]} />
113
+ </SceneViewport>
114
+ </>,
115
+ );
116
+ const ids = Array.from(container.querySelectorAll('[id]')).map((node) => node.id);
117
+ expect(new Set(ids).size).toBe(ids.length);
118
+ });
119
+ });