@bendyline/squisq-editor-react 1.5.2 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/dist/index.d.ts +757 -20
  2. package/dist/index.js +16910 -6844
  3. package/dist/index.js.map +1 -1
  4. package/package.json +4 -4
  5. package/src/BlockCardView.tsx +121 -0
  6. package/src/BlockPreviewPanel.tsx +69 -0
  7. package/src/BlockPropertiesPopover.tsx +191 -0
  8. package/src/EditorContext.tsx +143 -0
  9. package/src/EditorShell.tsx +200 -120
  10. package/src/FolderView.tsx +131 -0
  11. package/src/Icon.tsx +26 -0
  12. package/src/ImageEditor.tsx +69 -22
  13. package/src/OutlinePanel.tsx +38 -3
  14. package/src/PlainHtmlPreview.tsx +30 -3
  15. package/src/PreviewControls.tsx +180 -8
  16. package/src/RawEditor.tsx +216 -29
  17. package/src/RecorderEntry.tsx +9 -16
  18. package/src/TemplateAnnotation.ts +44 -0
  19. package/src/TemplatePicker.tsx +329 -54
  20. package/src/ThemeCustomizerPanel.tsx +30 -336
  21. package/src/ThemePicker.tsx +112 -3
  22. package/src/TimelineBlockPreview.tsx +37 -0
  23. package/src/TimelineTrack.tsx +671 -0
  24. package/src/Toolbar.tsx +528 -174
  25. package/src/Tooltip.tsx +22 -4
  26. package/src/TransitionPicker.tsx +351 -0
  27. package/src/VersionHistoryPanel.tsx +61 -31
  28. package/src/ViewMenuPanel.tsx +17 -14
  29. package/src/WysiwygEditor.tsx +161 -65
  30. package/src/__tests__/blockProperties.test.ts +92 -0
  31. package/src/__tests__/blockRange.test.ts +105 -0
  32. package/src/__tests__/buildPreviewDocTransition.test.ts +73 -0
  33. package/src/__tests__/createShapeLayer.test.ts +46 -0
  34. package/src/__tests__/drawingShapeRoundTrip.test.ts +49 -0
  35. package/src/__tests__/embeddedMedia.test.ts +48 -0
  36. package/src/__tests__/headingTransition.test.ts +138 -0
  37. package/src/__tests__/layoutChildRoundTrip.test.ts +71 -0
  38. package/src/__tests__/plainHtmlPreview.test.tsx +10 -8
  39. package/src/__tests__/recorderMediaInsert.test.ts +86 -0
  40. package/src/__tests__/templateAnnotationRoundTrip.test.ts +18 -0
  41. package/src/__tests__/templatePickerMetadata.test.ts +32 -0
  42. package/src/__tests__/timelineSource.test.ts +134 -0
  43. package/src/__tests__/tiptapBridge.test.ts +92 -0
  44. package/src/__tests__/tiptapBridgeConformance.test.ts +47 -0
  45. package/src/__tests__/tooltip.test.tsx +72 -0
  46. package/src/__tests__/transitionCatalog.test.ts +64 -0
  47. package/src/__tests__/useBlockNavigator.test.tsx +67 -0
  48. package/src/__tests__/useMediaRecorder.test.ts +24 -0
  49. package/src/__tests__/useTimelineClock.test.ts +21 -0
  50. package/src/blockProperties.ts +88 -0
  51. package/src/blockRange.ts +132 -0
  52. package/src/buildPreviewDoc.ts +98 -9
  53. package/src/customTemplates/AddBin.tsx +126 -0
  54. package/src/customTemplates/CustomLayoutManager.tsx +233 -0
  55. package/src/customTemplates/CustomTemplateContext.tsx +182 -0
  56. package/src/customTemplates/LayerToolbar.tsx +580 -0
  57. package/src/customTemplates/ShapeGlyph.tsx +47 -0
  58. package/src/customTemplates/TemplateDesigner.tsx +430 -0
  59. package/src/customTemplates/__tests__/library.test.ts +88 -0
  60. package/src/customTemplates/__tests__/normalizePositions.test.ts +109 -0
  61. package/src/customTemplates/__tests__/shapeDefs.test.ts +49 -0
  62. package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +95 -0
  63. package/src/customTemplates/designer.css +673 -0
  64. package/src/customTemplates/index.ts +31 -0
  65. package/src/customTemplates/library.ts +97 -0
  66. package/src/customTemplates/normalizePositions.ts +75 -0
  67. package/src/customTemplates/shapeDefs.ts +131 -0
  68. package/src/customTemplates/thumbnail.tsx +63 -0
  69. package/src/customTemplates/tokenDefs.ts +60 -0
  70. package/src/customTemplates/useDocCustomTemplates.ts +52 -0
  71. package/src/customTemplates/useMemoryLayerAdapter.ts +123 -0
  72. package/src/customThemes/CustomThemeContext.tsx +179 -0
  73. package/src/customThemes/CustomThemeDialog.tsx +286 -0
  74. package/src/customThemes/__tests__/CustomThemeContext.test.tsx +64 -0
  75. package/src/customThemes/__tests__/CustomThemeDialog.test.tsx +47 -0
  76. package/src/customThemes/__tests__/customThemeLibrary.test.ts +51 -0
  77. package/src/customThemes/customThemeLibrary.ts +97 -0
  78. package/src/customThemes/index.ts +31 -0
  79. package/src/customThemes/themeControls.tsx +229 -0
  80. package/src/customThemes/themeDraft.ts +272 -0
  81. package/src/customThemes/useDocCustomThemes.ts +49 -0
  82. package/src/diagram/DiagramCanvas.tsx +240 -0
  83. package/src/diagram/DiagramExtension.ts +209 -0
  84. package/src/diagram/DiagramMaximizedOverlay.tsx +46 -0
  85. package/src/diagram/DiagramWidget.tsx +270 -0
  86. package/src/diagram/diagramCommands.ts +604 -0
  87. package/src/diagram/diagramConstants.ts +17 -0
  88. package/src/diagram/useDiagramData.ts +126 -0
  89. package/src/embeddedMedia.ts +78 -0
  90. package/src/frontmatter.ts +29 -0
  91. package/src/headingTransition.ts +231 -0
  92. package/src/imageEditor/CanvasSurface.tsx +383 -88
  93. package/src/imageEditor/PropertiesPanel.tsx +47 -1
  94. package/src/imageEditor/Toolbar.tsx +229 -16
  95. package/src/imageEditor/createShapeLayer.ts +280 -0
  96. package/src/imageEditor/icons.tsx +34 -114
  97. package/src/imageEditor/image-editor.css +54 -5
  98. package/src/imageEditor/state.ts +23 -3
  99. package/src/index.ts +77 -0
  100. package/src/recorder/RecorderModal.tsx +120 -53
  101. package/src/recorder/RecorderPanel.tsx +2 -26
  102. package/src/recorder/hooks/useMediaRecorder.ts +17 -2
  103. package/src/recorder/insertMediaBlock.ts +30 -0
  104. package/src/resolveBlockVisual.ts +33 -0
  105. package/src/scene/Scene.tsx +540 -0
  106. package/src/scene/SceneBlockExtension.ts +198 -0
  107. package/src/scene/SceneBlockToolbar.tsx +201 -0
  108. package/src/scene/SceneBlockWidget.tsx +434 -0
  109. package/src/scene/ScenePropsBar.tsx +85 -0
  110. package/src/scene/SceneSelection.tsx +107 -0
  111. package/src/scene/SceneViewport.tsx +102 -0
  112. package/src/scene/ShapePalette.tsx +181 -0
  113. package/src/scene/__tests__/DiagramAdapter.test.ts +56 -0
  114. package/src/scene/__tests__/bezierEdit.test.ts +85 -0
  115. package/src/scene/__tests__/blockLayers.test.ts +57 -0
  116. package/src/scene/__tests__/shapeLayers.test.ts +106 -0
  117. package/src/scene/__tests__/useSceneHitTest.test.ts +90 -0
  118. package/src/scene/__tests__/useScenePanZoom.test.ts +103 -0
  119. package/src/scene/adapters/DiagramAdapter.ts +168 -0
  120. package/src/scene/adapters/DrawingAdapter.ts +415 -0
  121. package/src/scene/adapters/LayoutAdapter.ts +310 -0
  122. package/src/scene/adapters/blockLayers.ts +159 -0
  123. package/src/scene/commands/SceneCommand.ts +70 -0
  124. package/src/scene/commands/drawingCommands.ts +318 -0
  125. package/src/scene/commands/layoutCommands.ts +301 -0
  126. package/src/scene/hooks/useSceneHitTest.ts +105 -0
  127. package/src/scene/hooks/useScenePanZoom.ts +147 -0
  128. package/src/scene/hooks/useSceneSelection.ts +62 -0
  129. package/src/scene/index.ts +95 -0
  130. package/src/scene/layers/DiagramEdges.tsx +127 -0
  131. package/src/scene/layers/edgeGeometry.ts +77 -0
  132. package/src/scene/layers/nodeCard.tsx +145 -0
  133. package/src/scene/layers/renderLayer.tsx +70 -0
  134. package/src/scene/layers/shapeLayers.ts +201 -0
  135. package/src/scene/paths/bezierEdit.ts +208 -0
  136. package/src/scene/scene.css +649 -0
  137. package/src/scene/text/SceneTextOverlay.tsx +161 -0
  138. package/src/scene/text/sceneTextChannel.ts +40 -0
  139. package/src/scene/text/sceneTextConfig.ts +27 -0
  140. package/src/scene/text/sceneTiptap.ts +36 -0
  141. package/src/scene/text/useSceneTextEditing.ts +39 -0
  142. package/src/scene/tools/ConnectTool.ts +111 -0
  143. package/src/scene/tools/DrawingConnectTool.ts +161 -0
  144. package/src/scene/tools/PathTool.ts +158 -0
  145. package/src/scene/tools/PlaceTool.ts +47 -0
  146. package/src/scene/tools/SceneTool.ts +75 -0
  147. package/src/scene/tools/SelectTool.ts +284 -0
  148. package/src/scene/tools/ShapeTool.ts +144 -0
  149. package/src/scene/tools/TextTool.ts +72 -0
  150. package/src/scene/tools/TokenTool.ts +95 -0
  151. package/src/scene/tools/createDrawShapeTool.ts +82 -0
  152. package/src/styles/diagram.css +183 -0
  153. package/src/styles/editor.css +1615 -203
  154. package/src/styles/folder-view.css +210 -0
  155. package/src/styles/image-edit-affordance.css +2 -2
  156. package/src/styles/index.css +4 -0
  157. package/src/timelineSource.ts +244 -0
  158. package/src/tiptapBridge.ts +115 -34
  159. package/src/tooltipPlacement.ts +13 -0
  160. package/src/transitionCatalog.ts +159 -0
  161. package/src/types/monaco-shims.d.ts +10 -0
  162. package/src/useBlockNavigator.ts +153 -0
  163. package/src/useMonacoLoader.ts +105 -0
  164. package/src/useTimelineClock.ts +76 -0
  165. package/src/utils/dropUtils.ts +1 -1
@@ -0,0 +1,540 @@
1
+ /**
2
+ * Scene — the editing surface used by diagrams, layouts, and drawings.
3
+ *
4
+ * Owns the viewport (pan/zoom), the selection state, and the active
5
+ * tool. Tools are plain `SceneTool` objects that respond to pointer/
6
+ * keyboard events via the `SceneToolContext` the Scene constructs each
7
+ * render. Adapters (DiagramAdapter, LayoutAdapter, DrawingAdapter) sit
8
+ * outside the Scene and translate `SceneCommand`s into domain edits
9
+ * (markdown attrs, Tiptap mutations, etc).
10
+ *
11
+ * The Scene is deliberately small — most of the logic lives in the hooks
12
+ * (pan/zoom, hit-test, selection) and the tools (interaction rules).
13
+ */
14
+
15
+ import {
16
+ useCallback,
17
+ useEffect,
18
+ useLayoutEffect,
19
+ useMemo,
20
+ useRef,
21
+ useState,
22
+ type ReactNode,
23
+ } from 'react';
24
+ import type { Layer } from '@bendyline/squisq/schemas';
25
+ import type { SceneCommand, SceneEdge } from './commands/SceneCommand';
26
+ import type { SceneTool, SceneToolContext } from './tools/SceneTool';
27
+ import { useScenePanZoom } from './hooks/useScenePanZoom';
28
+ import { useSceneSelection } from './hooks/useSceneSelection';
29
+ import { useSceneHitTest, layerBounds, type HitTestable } from './hooks/useSceneHitTest';
30
+ import { SceneViewport } from './SceneViewport';
31
+ import { SceneSelection, type ResizeCorner } from './SceneSelection';
32
+ import { getActiveMoveOffset, getActiveResize, beginHandleDrag } from './tools/SelectTool';
33
+ import { RenderLayer } from './layers/renderLayer';
34
+ import { useSceneTextEditing } from './text/useSceneTextEditing';
35
+ import { SceneTextOverlay } from './text/SceneTextOverlay';
36
+ import type { SceneTextEditConfig } from './text/sceneTextConfig';
37
+
38
+ export interface SceneProps {
39
+ /** Viewport size in viewport units. Layers render in this coordinate space. */
40
+ viewport: { width: number; height: number };
41
+ /** Layers, back-to-front. Identity changes trigger re-render. */
42
+ layers: readonly Layer[];
43
+ /** Optional edges (diagram mode). Rendered as PathLayer-style curves. */
44
+ edges?: readonly SceneEdge[];
45
+ /** Ordered list of available tools. The first is active by default. */
46
+ tools: readonly SceneTool[];
47
+ /** Override the active tool id. Falls back to `tools[0].id`. */
48
+ activeToolId?: string;
49
+ /** Called when the user picks a different tool from the built-in toolbar. */
50
+ onActiveToolIdChange?: (id: string) => void;
51
+ /** Called when a tool issues a command. */
52
+ onCommand: (cmd: SceneCommand) => void;
53
+ /** Called when the layer selection changes (lets a host drive a properties panel). */
54
+ onSelectionChange?: (selectedIds: ReadonlySet<string>) => void;
55
+ /**
56
+ * Optional renderer for non-Layer content (e.g. diagram edges, in-flight
57
+ * connection preview). Receives the same SceneToolContext the tools do.
58
+ * Rendered behind the layer pass so edges sit below cards.
59
+ */
60
+ renderExtras?: (ctx: SceneToolContext) => ReactNode;
61
+ /**
62
+ * Optional override for how individual layers render. Defaults to the
63
+ * SSR-identical `RenderLayer` from `packages/react/src/layers/`. Hosts
64
+ * that want bespoke per-layer decoration (custom diagram card shells,
65
+ * inline editing affordances) can replace this.
66
+ */
67
+ renderLayer?: (layer: Layer, viewport: { width: number; height: number }) => ReactNode;
68
+ /**
69
+ * Optional follower relationship — given a layer id, return the id of
70
+ * the layer it "follows" (or null when independent). A follower
71
+ * receives the same live drag/resize transform as its owner, so a
72
+ * decorative pair (e.g. a diagram node's card + label) moves together
73
+ * even though only one is selectable. Pure visual sugar — selection
74
+ * state itself is unaffected.
75
+ */
76
+ layerFollows?: (layerId: string) => string | null;
77
+ /** When true, render the corner maximize toggle. */
78
+ showMaximize?: boolean;
79
+ maximized?: boolean;
80
+ onToggleMaximize?: () => void;
81
+ /** Render the built-in toolbar (Select / Connect / etc.). Default true. */
82
+ showToolbar?: boolean;
83
+ /**
84
+ * Enables the shared inline text editor. When provided, double-clicking a
85
+ * (resolvable) layer opens a Tiptap overlay over it. The host supplies
86
+ * read/commit per its persistence model. Omit to disable text editing.
87
+ */
88
+ textEditing?: SceneTextEditConfig;
89
+ /**
90
+ * Called when content is dropped onto the canvas via HTML5 drag-and-drop
91
+ * (e.g. a placeholder dragged from the template designer's palette).
92
+ * `point` is in viewport coordinates — already through the pan/zoom
93
+ * transform — so callers can build a layer at the drop location. When
94
+ * omitted, the canvas accepts no drops.
95
+ */
96
+ onDrop?: (e: React.DragEvent<SVGSVGElement>, point: { x: number; y: number }) => void;
97
+ }
98
+
99
+ export function Scene(props: SceneProps) {
100
+ const {
101
+ viewport,
102
+ layers,
103
+ edges = [],
104
+ tools,
105
+ activeToolId: controlledActiveId,
106
+ onActiveToolIdChange,
107
+ onCommand,
108
+ onSelectionChange,
109
+ renderExtras,
110
+ renderLayer,
111
+ layerFollows,
112
+ showMaximize,
113
+ maximized,
114
+ onToggleMaximize,
115
+ showToolbar = true,
116
+ textEditing,
117
+ onDrop,
118
+ } = props;
119
+
120
+ const layerRenderer = renderLayer ?? defaultRenderLayer;
121
+
122
+ // Inline text editing (double-click a layer → Tiptap overlay).
123
+ const textEdit = useSceneTextEditing(textEditing);
124
+
125
+ // ── Tool state ──────────────────────────────────────────────
126
+ const [internalActiveId, setInternalActiveId] = useState<string>(tools[0]?.id ?? 'select');
127
+ const activeId = controlledActiveId ?? internalActiveId;
128
+ const setActiveTool = useCallback(
129
+ (id: string) => {
130
+ if (onActiveToolIdChange) onActiveToolIdChange(id);
131
+ else setInternalActiveId(id);
132
+ },
133
+ [onActiveToolIdChange],
134
+ );
135
+ const activeTool = useMemo(
136
+ () => tools.find((t) => t.id === activeId) ?? tools[0],
137
+ [tools, activeId],
138
+ );
139
+
140
+ // ── Pan/zoom + selection ────────────────────────────────────
141
+ const panZoom = useScenePanZoom();
142
+ const selection = useSceneSelection();
143
+ const { hit } = useSceneHitTest();
144
+
145
+ // Surface selection upward so a host can drive a properties panel.
146
+ useEffect(() => {
147
+ onSelectionChange?.(selection.selection);
148
+ }, [selection.selection, onSelectionChange]);
149
+
150
+ // ── Hit-test cache ──────────────────────────────────────────
151
+ const hitItems: HitTestable[] = useMemo(() => {
152
+ const out: HitTestable[] = [];
153
+ for (const layer of layers) {
154
+ const bounds = layerBounds(layer, viewport);
155
+ if (!bounds) continue;
156
+ out.push({ id: layer.id, layer, bounds });
157
+ }
158
+ return out;
159
+ }, [layers, viewport]);
160
+
161
+ // ── Tool context (rebuilt every render — cheap, no allocations in hot path) ──
162
+ const ctx: SceneToolContext = useMemo(
163
+ () => ({
164
+ viewport,
165
+ transform: panZoom.transform,
166
+ layers,
167
+ edges,
168
+ selection: selection.selection,
169
+ hitItems,
170
+ screenToViewport: panZoom.screenToViewport,
171
+ viewportToScreen: panZoom.viewportToScreen,
172
+ hit: (p) => hit(p, hitItems),
173
+ setSelection: selection.setSelection,
174
+ dispatch: onCommand,
175
+ setActiveTool,
176
+ beginTextEdit: textEdit.begin,
177
+ }),
178
+ [
179
+ viewport,
180
+ panZoom.transform,
181
+ panZoom.screenToViewport,
182
+ panZoom.viewportToScreen,
183
+ layers,
184
+ edges,
185
+ selection.selection,
186
+ selection.setSelection,
187
+ hitItems,
188
+ hit,
189
+ onCommand,
190
+ setActiveTool,
191
+ textEdit.begin,
192
+ ],
193
+ );
194
+ // Mirror ctx in a ref so keyboard handlers attached to window read the
195
+ // latest selection without re-binding on every selection change.
196
+ const ctxRef = useRef(ctx);
197
+ ctxRef.current = ctx;
198
+
199
+ // ── Pointer dispatch ────────────────────────────────────────
200
+ const containerRef = useRef<SVGSVGElement>(null);
201
+ const isPanning = useRef(false);
202
+ const panLast = useRef<{ x: number; y: number } | null>(null);
203
+
204
+ const handleWheel = useCallback(
205
+ (e: React.WheelEvent<SVGSVGElement>) => {
206
+ // A plain mouse wheel must scroll the PAGE — the canvas is embedded in a
207
+ // scrolling document, so hijacking the wheel to zoom (which makes shapes
208
+ // appear to grow/shrink under the cursor) is surprising. Only zoom on an
209
+ // explicit zoom gesture: Ctrl/Cmd + wheel, which is also what a trackpad
210
+ // pinch emits. Otherwise let the event bubble so the page scrolls.
211
+ if (!e.ctrlKey && !e.metaKey) return;
212
+ e.preventDefault();
213
+ const rect = (e.currentTarget as SVGSVGElement).getBoundingClientRect();
214
+ const sx = e.clientX - rect.left;
215
+ const sy = e.clientY - rect.top;
216
+ // Negative deltaY = wheel up = zoom in.
217
+ const factor = Math.exp(-e.deltaY * 0.0015);
218
+ panZoom.zoomAt(factor, sx, sy);
219
+ },
220
+ [panZoom],
221
+ );
222
+
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.
229
+ const [, setDragTick] = useState(0);
230
+ const pointerActive = useRef(false);
231
+ const bumpTick = useCallback(() => setDragTick((t) => t + 1), []);
232
+
233
+ const handlePointerDown = useCallback(
234
+ (e: React.PointerEvent<SVGSVGElement>) => {
235
+ // Focus the SVG so it can receive keyboard events (Delete, Escape,
236
+ // tool shortcuts). preventDefault on focus loss is what makes the
237
+ // ProseMirror parent NOT steal focus back mid-gesture.
238
+ (e.currentTarget as SVGSVGElement).focus({ preventScroll: true });
239
+ // Middle-button or space-modified drag → pan, regardless of tool.
240
+ if (e.button === 1 || (e.button === 0 && e.altKey)) {
241
+ isPanning.current = true;
242
+ panLast.current = { x: e.clientX, y: e.clientY };
243
+ (e.currentTarget as Element).setPointerCapture?.(e.pointerId);
244
+ e.preventDefault();
245
+ return;
246
+ }
247
+ pointerActive.current = true;
248
+ activeTool?.onPointerDown?.(e, ctxRef.current);
249
+ bumpTick();
250
+ },
251
+ [activeTool, bumpTick],
252
+ );
253
+
254
+ const handlePointerMove = useCallback(
255
+ (e: React.PointerEvent<SVGSVGElement>) => {
256
+ if (isPanning.current && panLast.current) {
257
+ const dx = e.clientX - panLast.current.x;
258
+ const dy = e.clientY - panLast.current.y;
259
+ panLast.current = { x: e.clientX, y: e.clientY };
260
+ panZoom.panBy(dx, dy);
261
+ return;
262
+ }
263
+ activeTool?.onPointerMove?.(e, ctxRef.current);
264
+ if (pointerActive.current) bumpTick();
265
+ },
266
+ [activeTool, panZoom, bumpTick],
267
+ );
268
+
269
+ const handlePointerUp = useCallback(
270
+ (e: React.PointerEvent<SVGSVGElement>) => {
271
+ pointerActive.current = false;
272
+ if (isPanning.current) {
273
+ isPanning.current = false;
274
+ panLast.current = null;
275
+ (e.currentTarget as Element).releasePointerCapture?.(e.pointerId);
276
+ return;
277
+ }
278
+ activeTool?.onPointerUp?.(e, ctxRef.current);
279
+ },
280
+ [activeTool],
281
+ );
282
+
283
+ const handleDoubleClick = useCallback(
284
+ (e: React.MouseEvent<SVGSVGElement>) => {
285
+ activeTool?.onDoubleClick?.(e, ctxRef.current);
286
+ },
287
+ [activeTool],
288
+ );
289
+
290
+ // ── Drag-and-drop (palette → canvas) ────────────────────────
291
+ // HTML5 DnD is independent of the pointer-event tool pipeline; the
292
+ // browser suppresses pointer events during a drag, so there's no
293
+ // conflict with the active tool. We only enable it when a host wires
294
+ // `onDrop`, and we translate the drop point into viewport coordinates
295
+ // (through the live pan/zoom transform) before handing it back.
296
+ const handleDragOver = useCallback(
297
+ (e: React.DragEvent<SVGSVGElement>) => {
298
+ if (!onDrop) return;
299
+ e.preventDefault();
300
+ e.dataTransfer.dropEffect = 'copy';
301
+ },
302
+ [onDrop],
303
+ );
304
+
305
+ const handleDrop = useCallback(
306
+ (e: React.DragEvent<SVGSVGElement>) => {
307
+ if (!onDrop) return;
308
+ e.preventDefault();
309
+ const rect = (e.currentTarget as SVGSVGElement).getBoundingClientRect();
310
+ const sx = e.clientX - rect.left;
311
+ const sy = e.clientY - rect.top;
312
+ onDrop(e, panZoom.screenToViewport(sx, sy));
313
+ },
314
+ [onDrop, panZoom],
315
+ );
316
+
317
+ // ── Keyboard handling ──────────────────────────────────────
318
+ // We make the SVG focusable (tabIndex=-1, set in SceneViewport) and
319
+ // focus it on pointer-down. Keydown listeners on the SVG only fire
320
+ // when it has focus — that keeps us from hijacking Delete in
321
+ // surrounding form fields or the parent ProseMirror editor.
322
+ useEffect(() => {
323
+ const root = containerRef.current;
324
+ if (!root) return;
325
+ const onKey = (e: KeyboardEvent) => {
326
+ // While a text overlay is open it owns the keyboard — don't let tool
327
+ // shortcuts / Delete fire against the canvas.
328
+ if (textEdit.activeRef.current) return;
329
+ // Tool shortcuts (single-letter, no modifier).
330
+ if (!e.ctrlKey && !e.metaKey && !e.altKey && e.key.length === 1) {
331
+ const shortcut = e.key.toLowerCase();
332
+ const match = tools.find((t) => t.shortcut === shortcut);
333
+ if (match) {
334
+ setActiveTool(match.id);
335
+ e.preventDefault();
336
+ return;
337
+ }
338
+ }
339
+ // Tool keydown (Delete, Escape, etc.).
340
+ activeTool?.onKeyDown?.(e, ctxRef.current);
341
+ };
342
+ root.addEventListener('keydown', onKey);
343
+ return () => root.removeEventListener('keydown', onKey);
344
+ }, [tools, activeTool, setActiveTool, textEdit.activeRef]);
345
+
346
+ // ── Initial fit ─────────────────────────────────────────────
347
+ const didFitRef = useRef(false);
348
+ const [containerSize, setContainerSize] = useState<{ width: number; height: number } | null>(
349
+ null,
350
+ );
351
+
352
+ useLayoutEffect(() => {
353
+ if (!containerRef.current) return;
354
+ const el = containerRef.current;
355
+ const measure = () => {
356
+ const rect = el.getBoundingClientRect();
357
+ setContainerSize({ width: rect.width, height: rect.height });
358
+ };
359
+ measure();
360
+ const ro = new ResizeObserver(measure);
361
+ ro.observe(el);
362
+ return () => ro.disconnect();
363
+ }, []);
364
+
365
+ useEffect(() => {
366
+ if (didFitRef.current) return;
367
+ if (!containerSize) return;
368
+ if (hitItems.length === 0) return;
369
+ didFitRef.current = true;
370
+ // Fit to the bounding box of all hit items (the visible content).
371
+ let minX = Infinity;
372
+ let minY = Infinity;
373
+ let maxX = -Infinity;
374
+ let maxY = -Infinity;
375
+ for (const it of hitItems) {
376
+ if (it.bounds.x < minX) minX = it.bounds.x;
377
+ if (it.bounds.y < minY) minY = it.bounds.y;
378
+ if (it.bounds.x + it.bounds.width > maxX) maxX = it.bounds.x + it.bounds.width;
379
+ if (it.bounds.y + it.bounds.height > maxY) maxY = it.bounds.y + it.bounds.height;
380
+ }
381
+ if (!Number.isFinite(minX)) return;
382
+ panZoom.fitBox(
383
+ { x: minX, y: minY, width: maxX - minX, height: maxY - minY },
384
+ containerSize,
385
+ 40,
386
+ );
387
+ }, [containerSize, hitItems, panZoom]);
388
+
389
+ // ── Render ──────────────────────────────────────────────────
390
+ const liveOffset = activeId === 'select' ? getActiveMoveOffset() : null;
391
+ const liveResize = activeId === 'select' ? getActiveResize() : null;
392
+
393
+ // Per-layer transform applied during an in-flight drag or resize so
394
+ // the user sees the layer move/stretch in real time, not just on commit.
395
+ // A layer is "active" for transform purposes if it's directly selected,
396
+ // OR if it follows a directly-selected layer via `layerFollows`.
397
+ const wrapperFor = (layer: Layer): { transform?: string } => {
398
+ const followsId = layerFollows?.(layer.id) ?? null;
399
+ const ownerId = selection.selection.has(layer.id)
400
+ ? layer.id
401
+ : followsId && selection.selection.has(followsId)
402
+ ? followsId
403
+ : null;
404
+ if (!ownerId) return {};
405
+ if (liveResize && liveResize.layerId === ownerId) {
406
+ const bounds = hitItems.find((it) => it.id === ownerId)?.bounds;
407
+ if (!bounds) return {};
408
+ const sx = bounds.width > 0 ? liveResize.bounds.width / bounds.width : 1;
409
+ const sy = bounds.height > 0 ? liveResize.bounds.height / bounds.height : 1;
410
+ const tx = liveResize.bounds.x - bounds.x * sx;
411
+ const ty = liveResize.bounds.y - bounds.y * sy;
412
+ return { transform: `matrix(${sx} 0 0 ${sy} ${tx} ${ty})` };
413
+ }
414
+ if (liveOffset) return { transform: `translate(${liveOffset.dx} ${liveOffset.dy})` };
415
+ return {};
416
+ };
417
+
418
+ const handleHandlePointerDown = (e: React.PointerEvent<SVGElement>, corner: ResizeCorner) => {
419
+ const id = selection.selection.values().next().value as string | undefined;
420
+ if (!id) return;
421
+ const bounds = hitItems.find((it) => it.id === id)?.bounds;
422
+ if (!bounds) return;
423
+ const root = containerRef.current;
424
+ if (!root) return;
425
+ const rect = root.getBoundingClientRect();
426
+ const sx = e.clientX - rect.left;
427
+ const sy = e.clientY - rect.top;
428
+ const startV = panZoom.screenToViewport(sx, sy);
429
+ beginHandleDrag({ layerId: id, corner, startV, startBounds: bounds });
430
+ (e.currentTarget as Element).setPointerCapture?.(e.pointerId);
431
+ // Mark a gesture as active so the Scene's pointer-move handler
432
+ // triggers re-renders for the live resize preview.
433
+ pointerActive.current = true;
434
+ bumpTick();
435
+ e.stopPropagation();
436
+ };
437
+
438
+ return (
439
+ <div className="squisq-scene-root">
440
+ {showToolbar && <SceneToolbar tools={tools} activeId={activeId} onSelect={setActiveTool} />}
441
+ <SceneViewport
442
+ ref={containerRef}
443
+ width={containerSize?.width ?? viewport.width}
444
+ height={containerSize?.height ?? viewport.height}
445
+ transform={panZoom.transform}
446
+ cursor={activeTool?.cursor}
447
+ onWheel={handleWheel}
448
+ onPointerDown={handlePointerDown}
449
+ onPointerMove={handlePointerMove}
450
+ onPointerUp={handlePointerUp}
451
+ onDoubleClick={handleDoubleClick}
452
+ onDragOver={onDrop ? handleDragOver : undefined}
453
+ onDrop={onDrop ? handleDrop : undefined}
454
+ >
455
+ {/* Extras (e.g. diagram edges) render behind the layers so the
456
+ cards visually clip edge endpoints. */}
457
+ {renderExtras?.(ctx)}
458
+ {/* Layers — the Scene wraps each in a transformable <g> so a
459
+ selected layer can show a live drag/resize preview without
460
+ requiring the host to know about drag state. */}
461
+ {layers.map((layer) => (
462
+ <g key={layer.id} data-layer-id={layer.id} {...wrapperFor(layer)}>
463
+ {layerRenderer(layer, viewport)}
464
+ </g>
465
+ ))}
466
+ <SceneSelection
467
+ selection={selection.selection}
468
+ hitItems={hitItems}
469
+ liveOffset={liveOffset}
470
+ liveResize={liveResize}
471
+ onHandlePointerDown={handleHandlePointerDown}
472
+ />
473
+ {activeTool?.renderOverlay?.(ctx)}
474
+ </SceneViewport>
475
+ {showMaximize && onToggleMaximize && (
476
+ <button
477
+ type="button"
478
+ className="squisq-scene-maximize-btn"
479
+ onClick={onToggleMaximize}
480
+ title={maximized ? 'Exit fullscreen (Esc)' : 'Maximize'}
481
+ >
482
+ {maximized ? '✕' : '⛶'}
483
+ </button>
484
+ )}
485
+ {/* Inline text editor overlay (HTML over the SVG). Renders only once
486
+ the edited layer exists in `layers` (tolerates new-text timing). */}
487
+ {textEditing &&
488
+ textEdit.editingId &&
489
+ (() => {
490
+ const layer = layers.find((l) => l.id === textEdit.editingId);
491
+ if (!layer) return null;
492
+ return (
493
+ <SceneTextOverlay
494
+ config={textEditing}
495
+ layer={layer}
496
+ editableId={textEdit.editingId}
497
+ viewport={viewport}
498
+ transform={panZoom.transform}
499
+ viewportToScreen={panZoom.viewportToScreen}
500
+ onClose={textEdit.close}
501
+ />
502
+ );
503
+ })()}
504
+ </div>
505
+ );
506
+ }
507
+
508
+ function defaultRenderLayer(layer: Layer, viewport: { width: number; height: number }): ReactNode {
509
+ return <RenderLayer layer={layer} viewport={viewport} />;
510
+ }
511
+
512
+ // ── Toolbar ────────────────────────────────────────────────────
513
+
514
+ function SceneToolbar({
515
+ tools,
516
+ activeId,
517
+ onSelect,
518
+ }: {
519
+ tools: readonly SceneTool[];
520
+ activeId: string;
521
+ onSelect: (id: string) => void;
522
+ }) {
523
+ if (tools.length <= 1) return null;
524
+ return (
525
+ <div className="squisq-scene-toolbar" role="toolbar" aria-label="Scene tools">
526
+ {tools.map((t) => (
527
+ <button
528
+ key={t.id}
529
+ type="button"
530
+ className={`squisq-scene-tool${activeId === t.id ? ' squisq-scene-tool--active' : ''}`}
531
+ onClick={() => onSelect(t.id)}
532
+ title={t.shortcut ? `${t.label} (${t.shortcut.toUpperCase()})` : t.label}
533
+ aria-pressed={activeId === t.id}
534
+ >
535
+ {t.icon ?? <span className="squisq-scene-tool-label">{t.label}</span>}
536
+ </button>
537
+ ))}
538
+ </div>
539
+ );
540
+ }