@bendyline/squisq-editor-react 1.6.1 → 2.0.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 (208) hide show
  1. package/README.md +12 -3
  2. package/dist/index.d.ts +1304 -208
  3. package/dist/index.js +22838 -14494
  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 +2384 -408
  9. package/package.json +9 -5
  10. package/src/BlockPropertiesPopover.tsx +48 -17
  11. package/src/DocumentSettingsDialog.tsx +13 -21
  12. package/src/EditorContext.tsx +199 -42
  13. package/src/EditorShell.tsx +403 -270
  14. package/src/ImageEditor.tsx +22 -1
  15. package/src/InlinePreviewGutter.tsx +16 -8
  16. package/src/MediaBin.tsx +276 -40
  17. package/src/OutlinePanel.tsx +202 -7
  18. package/src/PlainHtmlPreview.tsx +40 -1
  19. package/src/PreviewControls.tsx +466 -93
  20. package/src/PreviewPanel.tsx +313 -77
  21. package/src/RawEditor.tsx +13 -2
  22. package/src/StatusBar.tsx +5 -2
  23. package/src/TemplateAnnotation.ts +34 -10
  24. package/src/TemplateContentPreview.tsx +56 -0
  25. package/src/TemplatePicker.tsx +355 -134
  26. package/src/ThemeCustomizerPanel.tsx +30 -15
  27. package/src/ThemePicker.tsx +10 -5
  28. package/src/TimelineBlockPreview.tsx +1 -1
  29. package/src/Toolbar.tsx +719 -238
  30. package/src/TransitionPicker.tsx +44 -5
  31. package/src/ViewMenuPanel.tsx +12 -14
  32. package/src/ViewSwitcher.tsx +4 -4
  33. package/src/WysiwygEditor.tsx +167 -92
  34. package/src/__tests__/blockPropertiesPopoverTheme.test.tsx +33 -0
  35. package/src/__tests__/blockTagActivity.test.ts +183 -0
  36. package/src/__tests__/buildDocumentPreviewMarkdown.test.ts +75 -0
  37. package/src/__tests__/buildPreviewDocContent.test.ts +48 -0
  38. package/src/__tests__/buildPreviewDocTransition.test.ts +22 -3
  39. package/src/__tests__/canvasSurfaceTextEditing.test.tsx +60 -0
  40. package/src/__tests__/customLayoutManagerTheme.test.tsx +34 -0
  41. package/src/__tests__/documentSettingsDialog.test.tsx +29 -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 +390 -1
  45. package/src/__tests__/emojiPicker.test.tsx +2 -42
  46. package/src/__tests__/headingTransition.test.ts +53 -15
  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 +38 -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__/mediaBinDrop.test.tsx +90 -0
  54. package/src/__tests__/mediaReferences.test.ts +82 -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 +312 -6
  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__/resolveBlockVisual.test.ts +43 -0
  66. package/src/__tests__/statusBar.test.tsx +27 -0
  67. package/src/__tests__/teleprompter.test.tsx +190 -0
  68. package/src/__tests__/templateAnnotationRoundTrip.test.ts +49 -2
  69. package/src/__tests__/templateContentPreview.test.ts +101 -0
  70. package/src/__tests__/templatePickerPortal.test.tsx +62 -0
  71. package/src/__tests__/tiptapBridge.test.ts +70 -0
  72. package/src/__tests__/tiptapCodeBlockRoundTrip.test.ts +109 -0
  73. package/src/__tests__/transformStyleId.test.ts +13 -0
  74. package/src/__tests__/useImageEditor.test.tsx +67 -0
  75. package/src/__tests__/useMediaRecorder.test.ts +75 -0
  76. package/src/__tests__/viewMenuPanel.test.tsx +130 -0
  77. package/src/__tests__/wysiwygImageUpload.test.ts +69 -0
  78. package/src/__tests__/wysiwygTemplateBadgeFocus.test.tsx +56 -0
  79. package/src/asciiDiagram/AsciiDiagramExtension.ts +343 -0
  80. package/src/asciiDiagram/AsciiDiagramWidget.tsx +224 -0
  81. package/src/asciiDiagram/RepairableDiagramExtension.ts +200 -0
  82. package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +145 -0
  83. package/src/asciiDiagram/__tests__/RepairableDiagramExtension.test.ts +159 -0
  84. package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +227 -0
  85. package/src/asciiDiagram/__tests__/asciiDiagramOps.test.ts +114 -0
  86. package/src/asciiDiagram/__tests__/asciiPaste.test.ts +43 -0
  87. package/src/asciiDiagram/asciiDiagramCommands.ts +148 -0
  88. package/src/asciiDiagram/asciiDiagramData.ts +116 -0
  89. package/src/asciiDiagram/asciiDiagramOps.ts +219 -0
  90. package/src/asciiDiagram/asciiPaste.ts +19 -0
  91. package/src/blockTagActivity.ts +233 -0
  92. package/src/buildDocumentPreviewMarkdown.ts +168 -0
  93. package/src/buildPreviewDoc.ts +19 -12
  94. package/src/customTemplates/CustomLayoutManager.tsx +3 -2
  95. package/src/customTemplates/CustomTemplateContext.tsx +6 -0
  96. package/src/customTemplates/TemplateDesigner.tsx +7 -4
  97. package/src/customTemplates/__tests__/library.test.ts +8 -0
  98. package/src/customTemplates/designer.css +158 -99
  99. package/src/customTemplates/library.ts +16 -3
  100. package/src/customThemes/CustomThemeContext.tsx +6 -0
  101. package/src/customThemes/CustomThemeDialog.tsx +35 -5
  102. package/src/customThemes/ImportThemeSection.tsx +178 -0
  103. package/src/customThemes/__tests__/customThemeLibrary.test.ts +9 -0
  104. package/src/customThemes/__tests__/importThemeSection.test.tsx +192 -0
  105. package/src/customThemes/customThemeLibrary.ts +7 -4
  106. package/src/customThemes/index.ts +7 -1
  107. package/src/customThemes/themeDraft.ts +23 -7
  108. package/src/diagram/DiagramCanvas.tsx +9 -4
  109. package/src/diagram/types.ts +35 -0
  110. package/src/editorScrollSync.ts +73 -0
  111. package/src/emojiData.ts +3 -23
  112. package/src/headingTransition.ts +85 -25
  113. package/src/imageEditor/CanvasSurface.tsx +29 -23
  114. package/src/imageEditor/LayersPanel.tsx +78 -3
  115. package/src/imageEditor/Toolbar.tsx +5 -1
  116. package/src/imageEditor/icons.tsx +4 -0
  117. package/src/imageEditor/image-editor.css +75 -0
  118. package/src/imageEditor/layers/SelectionHandles.tsx +1 -1
  119. package/src/imageEditor/useImageEditor.ts +71 -14
  120. package/src/index.ts +193 -20
  121. package/src/jsonEditor/JsonEditorContext.tsx +12 -6
  122. package/src/jsonEditor/RenderNode.tsx +24 -3
  123. package/src/jsonEditor/editors.tsx +86 -19
  124. package/src/mediaEntries.ts +12 -0
  125. package/src/mediaReferences.ts +299 -0
  126. package/src/monaco.ts +35 -0
  127. package/src/monacoWorkers.ts +89 -0
  128. package/src/outlineSource.ts +171 -0
  129. package/src/presentation/PresentationMode.tsx +596 -0
  130. package/src/rawEditorIsolation.ts +18 -0
  131. package/src/recorder/hooks/useMediaRecorder.ts +97 -53
  132. package/src/resolveBlockVisual.ts +10 -4
  133. package/src/scene/Scene.tsx +101 -25
  134. package/src/scene/SceneBlockExtension.ts +17 -10
  135. package/src/scene/SceneBlockWidget.tsx +11 -4
  136. package/src/scene/SceneSelection.tsx +19 -15
  137. package/src/scene/SceneSideToolbar.tsx +89 -0
  138. package/src/scene/SceneViewport.tsx +7 -3
  139. package/src/scene/ShapePalette.tsx +17 -2
  140. package/src/scene/__tests__/DiagramAdapter.test.ts +86 -0
  141. package/src/scene/__tests__/SceneBlockExtension.test.ts +33 -0
  142. package/src/scene/__tests__/sceneIsolation.test.tsx +93 -0
  143. package/src/scene/adapters/DiagramAdapter.ts +12 -101
  144. package/src/scene/commands/SceneCommand.ts +4 -1
  145. package/src/scene/index.ts +1 -6
  146. package/src/scene/layers/DiagramEdges.tsx +24 -9
  147. package/src/scene/layers/edgeGeometry.ts +31 -4
  148. package/src/scene/layers/nodeCard.tsx +27 -8
  149. package/src/scene/scene.css +145 -2
  150. package/src/scene/text/SceneTextOverlay.tsx +5 -5
  151. package/src/scene/text/sceneTextChannel.ts +26 -20
  152. package/src/scene/text/sceneTextConfig.ts +4 -0
  153. package/src/scene/tools/ConnectTool.ts +126 -28
  154. package/src/scene/tools/DrawingConnectTool.ts +86 -16
  155. package/src/scene/tools/SceneTool.ts +10 -0
  156. package/src/scene/tools/SelectTool.ts +34 -18
  157. package/src/scene/tools/ShapeTool.ts +2 -3
  158. package/src/styles/ascii-diagram.css +79 -0
  159. package/src/styles/ascii-timeline.css +499 -0
  160. package/src/styles/editor.css +1566 -219
  161. package/src/styles/index.css +3 -0
  162. package/src/styles/tree-view.css +139 -0
  163. package/src/teleprompter/TeleprompterControls.tsx +218 -0
  164. package/src/teleprompter/TeleprompterSelfView.tsx +22 -0
  165. package/src/teleprompter/TeleprompterSurface.tsx +267 -0
  166. package/src/teleprompter/TeleprompterView.tsx +338 -0
  167. package/src/teleprompter/canvasRenderer.ts +161 -0
  168. package/src/teleprompter/floatingWindow.ts +352 -0
  169. package/src/teleprompter/index.ts +61 -0
  170. package/src/teleprompter/pcmWorklet.ts +62 -0
  171. package/src/teleprompter/recording/insertPreamble.ts +80 -0
  172. package/src/teleprompter/recording/narrationSave.ts +134 -0
  173. package/src/teleprompter/recording/useNarrationRecorder.ts +367 -0
  174. package/src/teleprompter/scrollModel.ts +85 -0
  175. package/src/teleprompter/teleprompterTheme.ts +303 -0
  176. package/src/teleprompter/types.ts +38 -0
  177. package/src/teleprompter/useFloatingWindow.ts +74 -0
  178. package/src/teleprompter/useMicAnalysis.ts +211 -0
  179. package/src/teleprompter/useTeleprompter.ts +421 -0
  180. package/src/templateContentPreviewResolver.ts +360 -0
  181. package/src/timeline/TimelineEditorWidget.tsx +702 -0
  182. package/src/timeline/TimelineViewExtension.ts +252 -0
  183. package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +355 -0
  184. package/src/timeline/__tests__/TimelineViewExtension.test.ts +163 -0
  185. package/src/timeline/__tests__/timelineCommands.test.ts +327 -0
  186. package/src/timeline/__tests__/timelineOps.test.ts +222 -0
  187. package/src/timeline/__tests__/timelinePaste.test.ts +34 -0
  188. package/src/timeline/timelineCommands.ts +367 -0
  189. package/src/timeline/timelineData.ts +54 -0
  190. package/src/timeline/timelineOps.ts +277 -0
  191. package/src/timeline/timelinePaste.ts +8 -0
  192. package/src/tiptapBridge.ts +84 -49
  193. package/src/transformStyleId.ts +17 -0
  194. package/src/treeview/TreeOutlineWidget.tsx +240 -0
  195. package/src/treeview/TreeViewExtension.ts +250 -0
  196. package/src/treeview/__tests__/TreeViewExtension.test.ts +122 -0
  197. package/src/treeview/__tests__/treeOps.test.ts +125 -0
  198. package/src/treeview/__tests__/treePaste.test.ts +40 -0
  199. package/src/treeview/__tests__/treeViewCommands.test.ts +163 -0
  200. package/src/treeview/treeOps.ts +172 -0
  201. package/src/treeview/treePaste.ts +13 -0
  202. package/src/treeview/treeViewCommands.ts +94 -0
  203. package/src/treeview/treeViewData.ts +41 -0
  204. package/src/useMonacoLoader.ts +11 -34
  205. package/src/wysiwygImageUpload.ts +113 -0
  206. package/src/diagram/DiagramExtension.ts +0 -209
  207. package/src/diagram/DiagramWidget.tsx +0 -270
  208. package/src/diagram/useDiagramData.ts +0 -126
@@ -13,32 +13,105 @@
13
13
 
14
14
  import { createElement, type JSX } from 'react';
15
15
  import type { SceneTool, SceneToolContext } from './SceneTool';
16
- import { nodeIdFromCardLayerId, NODE_WIDTH, NODE_HEIGHT } from '../layers/nodeCard';
16
+ import { nodeIdFromCardLayerId } from '../layers/nodeCard';
17
+ import {
18
+ curvedPath,
19
+ edgeEndpoints,
20
+ snapPointToward,
21
+ type EdgeNodeBox,
22
+ } from '../layers/edgeGeometry';
17
23
 
18
24
  interface ConnectState {
19
25
  sourceNodeId: string;
20
- /** Source node center in viewport units (cached at drag start). */
21
- sourceCenter: { x: number; y: number };
22
- /** Current pointer position in viewport units. */
23
- current: { x: number; y: number };
26
+ /** Source node/card box in viewport units (cached at drag start). */
27
+ sourceBox: EdgeNodeBox;
28
+ /** Preview start/end in viewport units. */
29
+ start: { x: number; y: number };
30
+ end: { x: number; y: number };
24
31
  /** Currently-hovered target node id, if any (for highlight + drop). */
25
32
  hoveredTargetId: string | null;
26
33
  }
27
34
 
28
- 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
+ }
29
42
 
30
43
  function pointerFromEvent(e: React.PointerEvent): { sx: number; sy: number } {
31
44
  const rect = (e.currentTarget as Element).getBoundingClientRect();
32
45
  return { sx: e.clientX - rect.left, sy: e.clientY - rect.top };
33
46
  }
34
47
 
35
- function nodeCenterFromCard(
36
- ctx: SceneToolContext,
37
- nodeId: string,
38
- ): { x: number; y: number } | null {
48
+ function nodeBoxFromCard(ctx: SceneToolContext, nodeId: string): EdgeNodeBox | null {
39
49
  const card = ctx.hitItems.find((it) => it.id === `node-card-${nodeId}`);
40
50
  if (!card) return null;
41
- return { x: card.bounds.x + NODE_WIDTH / 2, y: card.bounds.y + NODE_HEIGHT / 2 };
51
+ return {
52
+ id: nodeId,
53
+ x: card.bounds.x,
54
+ y: card.bounds.y,
55
+ width: card.bounds.width,
56
+ height: card.bounds.height,
57
+ };
58
+ }
59
+
60
+ function nodeBoxes(ctx: SceneToolContext): EdgeNodeBox[] {
61
+ const boxes: EdgeNodeBox[] = [];
62
+ for (const it of ctx.hitItems) {
63
+ if (!it.id.startsWith('node-card-')) continue;
64
+ const nodeId = nodeIdFromCardLayerId(it.id);
65
+ if (!nodeId) continue;
66
+ boxes.push({
67
+ id: nodeId,
68
+ x: it.bounds.x,
69
+ y: it.bounds.y,
70
+ width: it.bounds.width,
71
+ height: it.bounds.height,
72
+ });
73
+ }
74
+ return boxes;
75
+ }
76
+
77
+ function connectionPoints(box: EdgeNodeBox): Array<{ x: number; y: number }> {
78
+ const width = box.width ?? 0;
79
+ const height = box.height ?? 0;
80
+ const left = box.x;
81
+ const right = box.x + width;
82
+ const top = box.y;
83
+ const bottom = box.y + height;
84
+ const centerX = box.x + width / 2;
85
+ const centerY = box.y + height / 2;
86
+ return [
87
+ { x: centerX, y: top },
88
+ { x: right, y: centerY },
89
+ { x: centerX, y: bottom },
90
+ { x: left, y: centerY },
91
+ { x: left, y: top },
92
+ { x: right, y: top },
93
+ { x: right, y: bottom },
94
+ { x: left, y: bottom },
95
+ ];
96
+ }
97
+
98
+ function updatePreview(
99
+ sourceBox: EdgeNodeBox,
100
+ current: { x: number; y: number },
101
+ targetBox: EdgeNodeBox | null,
102
+ ): { start: { x: number; y: number }; end: { x: number; y: number } } {
103
+ if (targetBox) {
104
+ return (
105
+ edgeEndpoints([sourceBox, targetBox], sourceBox.id, targetBox.id) ?? {
106
+ start: current,
107
+ end: current,
108
+ }
109
+ );
110
+ }
111
+ return {
112
+ start: snapPointToward([sourceBox], sourceBox.id, current) ?? current,
113
+ end: current,
114
+ };
42
115
  }
43
116
 
44
117
  export const ConnectTool: SceneTool = {
@@ -46,6 +119,7 @@ export const ConnectTool: SceneTool = {
46
119
  label: 'Connect',
47
120
  cursor: 'crosshair',
48
121
  shortcut: 'c',
122
+ hideSelectionHandles: true,
49
123
 
50
124
  onPointerDown(e, ctx) {
51
125
  if (e.button !== 0) return;
@@ -55,49 +129,73 @@ export const ConnectTool: SceneTool = {
55
129
  if (!hitId) return;
56
130
  const nodeId = nodeIdFromCardLayerId(hitId);
57
131
  if (!nodeId) return;
58
- const center = nodeCenterFromCard(ctx, nodeId);
59
- if (!center) return;
60
- state = {
132
+ const sourceBox = nodeBoxFromCard(ctx, nodeId);
133
+ if (!sourceBox) return;
134
+ const preview = updatePreview(sourceBox, v, null);
135
+ setState(ctx, {
61
136
  sourceNodeId: nodeId,
62
- sourceCenter: center,
63
- current: v,
137
+ sourceBox,
138
+ start: preview.start,
139
+ end: preview.end,
64
140
  hoveredTargetId: null,
65
- };
141
+ });
66
142
  (e.currentTarget as Element).setPointerCapture?.(e.pointerId);
67
143
  e.stopPropagation();
68
144
  },
69
145
 
70
146
  onPointerMove(e, ctx) {
147
+ const state = getState(ctx);
71
148
  if (!state) return;
72
149
  const { sx, sy } = pointerFromEvent(e);
73
150
  const v = ctx.screenToViewport(sx, sy);
74
- state.current = v;
75
151
  const hitId = ctx.hit(v);
76
152
  const nodeId = hitId ? nodeIdFromCardLayerId(hitId) : null;
77
- state.hoveredTargetId = nodeId && nodeId !== state.sourceNodeId ? nodeId : null;
153
+ const targetId = nodeId && nodeId !== state.sourceNodeId ? nodeId : null;
154
+ const targetBox = targetId ? nodeBoxFromCard(ctx, targetId) : null;
155
+ const preview = updatePreview(state.sourceBox, v, targetBox);
156
+ state.start = preview.start;
157
+ state.end = preview.end;
158
+ state.hoveredTargetId = targetBox?.id ?? null;
78
159
  },
79
160
 
80
161
  onPointerUp(e, ctx) {
162
+ const state = getState(ctx);
81
163
  if (!state) return;
82
164
  const target = state.hoveredTargetId;
83
165
  if (target) {
84
166
  ctx.dispatch({ kind: 'addEdge', source: state.sourceNodeId, target });
85
167
  }
86
168
  (e.currentTarget as Element).releasePointerCapture?.(e.pointerId);
87
- state = null;
169
+ setState(ctx, null);
88
170
  },
89
171
 
90
- renderOverlay(): JSX.Element | null {
91
- if (!state) return null;
92
- const { sourceCenter: a, current: b } = state;
93
- const dx = b.x - a.x;
94
- const cp = Math.max(40, Math.abs(dx) / 2);
95
- // Cubic bezier matching diagramBlock.ts's `curved` edge style so the
96
- // preview previews exactly what will render after commit.
97
- const d = `M ${a.x} ${a.y} C ${a.x + cp} ${a.y}, ${b.x - cp} ${b.y}, ${b.x} ${b.y}`;
172
+ renderOverlay(ctx): JSX.Element | null {
173
+ const state = getState(ctx);
174
+ const points = nodeBoxes(ctx).flatMap((box) =>
175
+ connectionPoints(box).map((point, index) =>
176
+ createElement('circle', {
177
+ key: `${box.id}-${index}`,
178
+ cx: point.x,
179
+ cy: point.y,
180
+ r: 4,
181
+ className:
182
+ state?.hoveredTargetId === box.id
183
+ ? 'squisq-scene-connection-point squisq-scene-connection-point--active'
184
+ : 'squisq-scene-connection-point',
185
+ }),
186
+ ),
187
+ );
188
+
189
+ if (!state) {
190
+ return points.length ? createElement('g', { key: 'connection-points' }, ...points) : null;
191
+ }
192
+
193
+ const { start: a, end: b } = state;
194
+ const d = curvedPath(a, b);
98
195
  return createElement(
99
196
  'g',
100
197
  { key: 'connect-preview' },
198
+ ...points,
101
199
  createElement('path', { d, className: 'squisq-scene-connect-preview' }),
102
200
  // Optional: a dot at the endpoint so the user sees the snap target.
103
201
  createElement('circle', {
@@ -16,6 +16,12 @@
16
16
  import { createElement, type JSX } from 'react';
17
17
  import type { SceneTool, SceneToolContext } from './SceneTool';
18
18
  import { shapeIdFromLayerId, isPrimaryShapeLayer } from '../layers/shapeLayers';
19
+ import {
20
+ edgeEndpoints,
21
+ snapPointToward,
22
+ straightPath,
23
+ type EdgeNodeBox,
24
+ } from '../layers/edgeGeometry';
19
25
 
20
26
  /** A grab on the selected connector's endpoint handle. */
21
27
  export interface EndpointHit {
@@ -37,8 +43,9 @@ type DragState =
37
43
  | {
38
44
  mode: 'new';
39
45
  sourceId: string;
40
- anchor: { x: number; y: number };
41
- current: { x: number; y: number };
46
+ sourceBox: EdgeNodeBox;
47
+ start: { x: number; y: number };
48
+ end: { x: number; y: number };
42
49
  hovered: string | null;
43
50
  }
44
51
  | {
@@ -46,8 +53,9 @@ type DragState =
46
53
  connectorId: string;
47
54
  end: 'from' | 'to';
48
55
  fixedShapeId: string;
49
- anchor: { x: number; y: number };
50
- current: { x: number; y: number };
56
+ fixedBox: EdgeNodeBox;
57
+ start: { x: number; y: number };
58
+ endPoint: { x: number; y: number };
51
59
  hovered: string | null;
52
60
  };
53
61
 
@@ -59,10 +67,16 @@ export function createDrawingConnectTool(options: DrawingConnectOptions = {}): S
59
67
  return { sx: e.clientX - rect.left, sy: e.clientY - rect.top };
60
68
  }
61
69
 
62
- function shapeCenter(ctx: SceneToolContext, shapeId: string): { x: number; y: number } | null {
70
+ function shapeBox(ctx: SceneToolContext, shapeId: string): EdgeNodeBox | null {
63
71
  const it = ctx.hitItems.find((h) => h.id === `dshape-${shapeId}`);
64
72
  if (!it) return null;
65
- return { x: it.bounds.x + it.bounds.width / 2, y: it.bounds.y + it.bounds.height / 2 };
73
+ return {
74
+ id: shapeId,
75
+ x: it.bounds.x,
76
+ y: it.bounds.y,
77
+ width: it.bounds.width,
78
+ height: it.bounds.height,
79
+ };
66
80
  }
67
81
 
68
82
  function shapeAt(ctx: SceneToolContext, v: { x: number; y: number }): string | null {
@@ -71,6 +85,45 @@ export function createDrawingConnectTool(options: DrawingConnectOptions = {}): S
71
85
  return shapeIdFromLayerId(hitId);
72
86
  }
73
87
 
88
+ function previewNew(
89
+ sourceBox: EdgeNodeBox,
90
+ current: { x: number; y: number },
91
+ targetBox: EdgeNodeBox | null,
92
+ ): { start: { x: number; y: number }; end: { x: number; y: number } } {
93
+ if (targetBox) {
94
+ return (
95
+ edgeEndpoints([sourceBox, targetBox], sourceBox.id, targetBox.id) ?? {
96
+ start: current,
97
+ end: current,
98
+ }
99
+ );
100
+ }
101
+ return {
102
+ start: snapPointToward([sourceBox], sourceBox.id, current) ?? current,
103
+ end: current,
104
+ };
105
+ }
106
+
107
+ function previewRetarget(
108
+ fixedBox: EdgeNodeBox,
109
+ movedEnd: 'from' | 'to',
110
+ current: { x: number; y: number },
111
+ targetBox: EdgeNodeBox | null,
112
+ ): { start: { x: number; y: number }; end: { x: number; y: number } } {
113
+ if (targetBox) {
114
+ const source = movedEnd === 'from' ? targetBox : fixedBox;
115
+ const target = movedEnd === 'from' ? fixedBox : targetBox;
116
+ return (
117
+ edgeEndpoints([source, target], source.id, target.id) ?? {
118
+ start: current,
119
+ end: current,
120
+ }
121
+ );
122
+ }
123
+ const fixed = snapPointToward([fixedBox], fixedBox.id, current) ?? current;
124
+ return movedEnd === 'from' ? { start: current, end: fixed } : { start: fixed, end: current };
125
+ }
126
+
74
127
  return {
75
128
  id: 'connect',
76
129
  label: 'Connect',
@@ -85,14 +138,17 @@ export function createDrawingConnectTool(options: DrawingConnectOptions = {}): S
85
138
  // Endpoint handle of the selected connector → re-target gesture.
86
139
  const ep = options.endpointAt?.(v);
87
140
  if (ep) {
88
- const anchor = shapeCenter(ctx, ep.fixedShapeId) ?? v;
141
+ const fixedBox = shapeBox(ctx, ep.fixedShapeId);
142
+ if (!fixedBox) return;
143
+ const preview = previewRetarget(fixedBox, ep.end, v, null);
89
144
  drag = {
90
145
  mode: 'retarget',
91
146
  connectorId: ep.connectorId,
92
147
  end: ep.end,
93
148
  fixedShapeId: ep.fixedShapeId,
94
- anchor,
95
- current: v,
149
+ fixedBox,
150
+ start: preview.start,
151
+ endPoint: preview.end,
96
152
  hovered: null,
97
153
  };
98
154
  (e.currentTarget as Element).setPointerCapture?.(e.pointerId);
@@ -103,11 +159,15 @@ export function createDrawingConnectTool(options: DrawingConnectOptions = {}): S
103
159
  // Otherwise start a new connection from the shape under the pointer.
104
160
  const src = shapeAt(ctx, v);
105
161
  if (!src) return;
162
+ const sourceBox = shapeBox(ctx, src);
163
+ if (!sourceBox) return;
164
+ const preview = previewNew(sourceBox, v, null);
106
165
  drag = {
107
166
  mode: 'new',
108
167
  sourceId: src,
109
- anchor: shapeCenter(ctx, src) ?? v,
110
- current: v,
168
+ sourceBox,
169
+ start: preview.start,
170
+ end: preview.end,
111
171
  hovered: null,
112
172
  };
113
173
  (e.currentTarget as Element).setPointerCapture?.(e.pointerId);
@@ -118,10 +178,20 @@ export function createDrawingConnectTool(options: DrawingConnectOptions = {}): S
118
178
  if (!drag) return;
119
179
  const { sx, sy } = pointer(e);
120
180
  const v = ctx.screenToViewport(sx, sy);
121
- drag.current = v;
122
181
  const tgt = shapeAt(ctx, v);
123
182
  const exclude = drag.mode === 'new' ? drag.sourceId : drag.fixedShapeId;
124
- drag.hovered = tgt && tgt !== exclude ? tgt : null;
183
+ const targetId = tgt && tgt !== exclude ? tgt : null;
184
+ const targetBox = targetId ? shapeBox(ctx, targetId) : null;
185
+ if (drag.mode === 'new') {
186
+ const preview = previewNew(drag.sourceBox, v, targetBox);
187
+ drag.start = preview.start;
188
+ drag.end = preview.end;
189
+ } else {
190
+ const preview = previewRetarget(drag.fixedBox, drag.end, v, targetBox);
191
+ drag.start = preview.start;
192
+ drag.endPoint = preview.end;
193
+ }
194
+ drag.hovered = targetBox?.id ?? null;
125
195
  },
126
196
 
127
197
  onPointerUp(e, ctx) {
@@ -139,13 +209,13 @@ export function createDrawingConnectTool(options: DrawingConnectOptions = {}): S
139
209
 
140
210
  renderOverlay(): JSX.Element | null {
141
211
  if (!drag) return null;
142
- const a = drag.anchor;
143
- const b = drag.current;
212
+ const a = drag.start;
213
+ const b = drag.mode === 'new' ? drag.end : drag.endPoint;
144
214
  return createElement(
145
215
  'g',
146
216
  { key: 'drawing-connect-preview' },
147
217
  createElement('path', {
148
- d: `M ${a.x} ${a.y} L ${b.x} ${b.y}`,
218
+ d: straightPath(a, b),
149
219
  className: 'squisq-scene-connect-preview',
150
220
  fill: 'none',
151
221
  }),
@@ -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. */
@@ -55,6 +63,8 @@ export interface SceneTool {
55
63
  cursor: string;
56
64
  /** Optional keyboard accelerator (single-character, lowercase). */
57
65
  shortcut?: string;
66
+ /** Hide resize handles on selected layers while this tool is active. */
67
+ hideSelectionHandles?: boolean;
58
68
  /**
59
69
  * Optional icon — JSX rendered inside a fixed-size toolbar button. Keep
60
70
  * it light (a single SVG path is ideal); the toolbar wrapper handles
@@ -18,7 +18,7 @@
18
18
  */
19
19
 
20
20
  import { createElement, type JSX } from 'react';
21
- import type { SceneTool, SceneToolContext } from './SceneTool';
21
+ import type { SceneInteractionState, SceneTool, SceneToolContext } from './SceneTool';
22
22
  import type { SceneCommand } from '../commands/SceneCommand';
23
23
  import type { ResizeCorner } from '../SceneSelection';
24
24
 
@@ -53,7 +53,13 @@ type DragState = MoveDragState | MarqueeDragState | ResizeDragState;
53
53
  const DRAG_THRESHOLD_PX = 3;
54
54
  const MIN_LAYER_SIZE = 8;
55
55
 
56
- let dragState: DragState | null = null;
56
+ function getDragState(interaction: SceneInteractionState): DragState | null {
57
+ return (interaction.selectDrag as DragState | undefined) ?? null;
58
+ }
59
+
60
+ function setDragState(interaction: SceneInteractionState, state: DragState | null): void {
61
+ interaction.selectDrag = state ?? undefined;
62
+ }
57
63
 
58
64
  function pointerCoords(e: React.PointerEvent): { sx: number; sy: number } {
59
65
  const rect = (e.currentTarget as Element).getBoundingClientRect();
@@ -90,20 +96,23 @@ function applyResize(
90
96
  }
91
97
 
92
98
  /** Begin a handle-initiated resize drag. Called by the Scene when a corner is pressed. */
93
- export function beginHandleDrag(args: {
94
- layerId: string;
95
- corner: ResizeCorner;
96
- startV: { x: number; y: number };
97
- startBounds: { x: number; y: number; width: number; height: number };
98
- }): void {
99
- dragState = {
99
+ export function beginHandleDrag(
100
+ args: {
101
+ layerId: string;
102
+ corner: ResizeCorner;
103
+ startV: { x: number; y: number };
104
+ startBounds: { x: number; y: number; width: number; height: number };
105
+ },
106
+ interaction: SceneInteractionState,
107
+ ): void {
108
+ setDragState(interaction, {
100
109
  kind: 'resize',
101
110
  layerId: args.layerId,
102
111
  corner: args.corner,
103
112
  startV: args.startV,
104
113
  startBounds: args.startBounds,
105
114
  currentBounds: args.startBounds,
106
- };
115
+ });
107
116
  }
108
117
 
109
118
  export const SelectTool: SceneTool = {
@@ -135,29 +144,30 @@ export const SelectTool: SceneTool = {
135
144
  const origin = getLayerOrigin(ctx, id);
136
145
  if (origin) layerStarts.set(id, origin);
137
146
  }
138
- dragState = {
147
+ setDragState(ctx.interaction, {
139
148
  kind: 'move',
140
149
  startV: v,
141
150
  startS: { x: sx, y: sy },
142
151
  layerStarts,
143
152
  currentV: v,
144
153
  movedPastThreshold: false,
145
- };
154
+ });
146
155
  } else {
147
156
  if (!e.shiftKey) ctx.setSelection([]);
148
- dragState = {
157
+ setDragState(ctx.interaction, {
149
158
  kind: 'marquee',
150
159
  startV: v,
151
160
  startS: { x: sx, y: sy },
152
161
  currentV: v,
153
162
  movedPastThreshold: false,
154
- };
163
+ });
155
164
  }
156
165
 
157
166
  (e.currentTarget as Element).setPointerCapture?.(e.pointerId);
158
167
  },
159
168
 
160
169
  onPointerMove(e, ctx) {
170
+ const dragState = getDragState(ctx.interaction);
161
171
  if (!dragState) return;
162
172
  const { sx, sy } = pointerCoords(e);
163
173
  const v = ctx.screenToViewport(sx, sy);
@@ -182,6 +192,7 @@ export const SelectTool: SceneTool = {
182
192
  },
183
193
 
184
194
  onPointerUp(e, ctx) {
195
+ const dragState = getDragState(ctx.interaction);
185
196
  if (!dragState) return;
186
197
  if (
187
198
  dragState.kind === 'move' &&
@@ -208,7 +219,7 @@ export const SelectTool: SceneTool = {
208
219
  ctx.dispatch({ kind: 'resizeLayer', id: dragState.layerId, width, height });
209
220
  }
210
221
  (e.currentTarget as Element).releasePointerCapture?.(e.pointerId);
211
- dragState = null;
222
+ setDragState(ctx.interaction, null);
212
223
  },
213
224
 
214
225
  onDoubleClick(e, ctx) {
@@ -232,7 +243,8 @@ export const SelectTool: SceneTool = {
232
243
  }
233
244
  },
234
245
 
235
- renderOverlay(): JSX.Element | null {
246
+ renderOverlay(ctx): JSX.Element | null {
247
+ const dragState = getDragState(ctx.interaction);
236
248
  if (!dragState || dragState.kind !== 'marquee' || !dragState.movedPastThreshold) return null;
237
249
  const box = boxFromCorners(dragState.startV, dragState.currentV);
238
250
  return createElement('rect', {
@@ -247,7 +259,10 @@ export const SelectTool: SceneTool = {
247
259
  };
248
260
 
249
261
  /** Return the active move-drag offset (viewport units). Null if no drag, or if the drag is a marquee/resize. */
250
- export function getActiveMoveOffset(): { dx: number; dy: number } | null {
262
+ export function getActiveMoveOffset(
263
+ interaction: SceneInteractionState,
264
+ ): { dx: number; dy: number } | null {
265
+ const dragState = getDragState(interaction);
251
266
  if (!dragState || dragState.kind !== 'move' || !dragState.movedPastThreshold) return null;
252
267
  return {
253
268
  dx: dragState.currentV.x - dragState.startV.x,
@@ -256,10 +271,11 @@ export function getActiveMoveOffset(): { dx: number; dy: number } | null {
256
271
  }
257
272
 
258
273
  /** Return the live bounds for the layer currently being resized. */
259
- export function getActiveResize(): {
274
+ export function getActiveResize(interaction: SceneInteractionState): {
260
275
  layerId: string;
261
276
  bounds: { x: number; y: number; width: number; height: number };
262
277
  } | null {
278
+ const dragState = getDragState(interaction);
263
279
  if (!dragState || dragState.kind !== 'resize') return null;
264
280
  return { layerId: dragState.layerId, bounds: dragState.currentBounds };
265
281
  }
@@ -2,9 +2,8 @@
2
2
  * ShapeTool — drag to draw a rectangle or circle as a new ShapeLayer.
3
3
  *
4
4
  * Exposed as a factory so a host can pre-bind the shape kind and
5
- * stroke/fill defaults. Returns a fresh SceneTool instance the
6
- * factory pattern lets multiple Scene instances coexist without
7
- * sharing module-level drag state.
5
+ * stroke/fill defaults. Returns a fresh SceneTool instance so hosts can bind
6
+ * independent tool identities and defaults; gesture state belongs to Scene.
8
7
  */
9
8
 
10
9
  import { createElement, type JSX } from 'react';
@@ -0,0 +1,79 @@
1
+ /* ASCII diagram fences in the WYSIWYG editor.
2
+ *
3
+ * A qualifying code fence is hidden behind its interactive canvas
4
+ * (AsciiDiagramWidget); toggling "Source" on the widget swaps the hide
5
+ * class for a monospace, non-wrapping view of the raw art. */
6
+
7
+ .squisq-wysiwyg-editor pre.squisq-ascii-fence-hidden {
8
+ display: none;
9
+ }
10
+
11
+ .squisq-wysiwyg-editor pre.squisq-ascii-fence-source {
12
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace;
13
+ /* prosemirror.css sets `.ProseMirror pre { white-space: pre-wrap }`,
14
+ which soft-wraps and visually scrambles column-aligned art. */
15
+ white-space: pre;
16
+ overflow-x: auto;
17
+ line-height: 1.2;
18
+ tab-size: 4;
19
+ }
20
+
21
+ .squisq-ascii-diagram-widget-host {
22
+ margin: 0.75rem 0 1.25rem;
23
+ }
24
+
25
+ .squisq-ascii-diagram-warnings {
26
+ margin-top: 4px;
27
+ font-size: 12px;
28
+ color: var(--squisq-theme-text-muted, #6b7280);
29
+ }
30
+
31
+ .squisq-ascii-diagram-warnings summary {
32
+ cursor: pointer;
33
+ user-select: none;
34
+ }
35
+
36
+ .squisq-ascii-diagram-warnings ul {
37
+ margin: 4px 0 0;
38
+ padding-left: 18px;
39
+ }
40
+
41
+ /* "Repair as diagram" affordance on broken box-art fences. The fence stays
42
+ * visible above this banner; clicking rewrites it to clean diagram art. */
43
+ .squisq-repair-banner {
44
+ display: flex;
45
+ align-items: center;
46
+ gap: 10px;
47
+ flex-wrap: wrap;
48
+ margin: 4px 0 1rem;
49
+ padding: 8px 12px;
50
+ border: 1px dashed var(--squisq-border, #d1d5db);
51
+ border-radius: 8px;
52
+ background: var(--squisq-input-bg, #f9fafb);
53
+ font-size: 13px;
54
+ }
55
+
56
+ .squisq-repair-banner-text {
57
+ color: var(--squisq-text-muted, #6b7280);
58
+ }
59
+
60
+ .squisq-repair-banner-btn {
61
+ cursor: pointer;
62
+ border: 1px solid var(--squisq-accent, #6366f1);
63
+ border-radius: 6px;
64
+ background: var(--squisq-accent, #6366f1);
65
+ color: #fff;
66
+ padding: 4px 12px;
67
+ font-size: 13px;
68
+ font-weight: 500;
69
+ line-height: 1.4;
70
+ white-space: nowrap;
71
+ }
72
+
73
+ .squisq-repair-banner-btn:hover {
74
+ filter: brightness(1.05);
75
+ }
76
+
77
+ .squisq-repair-banner-btn:active {
78
+ filter: brightness(0.95);
79
+ }