@bendyline/squisq-editor-react 1.5.3 → 1.6.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.
- package/README.md +57 -10
- package/dist/index.d.ts +1114 -52
- package/dist/index.js +18446 -8050
- package/dist/index.js.map +1 -1
- package/dist/styles/fa-brands-400-AHOAZHCU.woff2 +0 -0
- package/dist/styles/fa-regular-400-VRZYIBIZ.woff2 +0 -0
- package/dist/styles/fa-solid-900-MDEYK55F.woff2 +0 -0
- package/dist/styles/fa-v4compatibility-ETEVP6IB.woff2 +0 -0
- package/dist/styles/index.css +13867 -0
- package/package.json +15 -7
- package/src/BlockCardView.tsx +121 -0
- package/src/BlockPreviewPanel.tsx +69 -0
- package/src/BlockPropertiesPopover.tsx +191 -0
- package/src/EditorContext.tsx +165 -16
- package/src/EditorShell.tsx +265 -144
- package/src/FolderView.tsx +131 -0
- package/src/Icon.tsx +26 -0
- package/src/ImageEditor.tsx +69 -22
- package/src/OutlinePanel.tsx +64 -7
- package/src/PlainHtmlPreview.tsx +30 -3
- package/src/PreviewControls.tsx +491 -122
- package/src/PreviewPanel.tsx +15 -9
- package/src/RawEditor.tsx +179 -17
- package/src/RecorderEntry.tsx +9 -16
- package/src/TemplateAnnotation.ts +44 -0
- package/src/TemplatePicker.tsx +329 -54
- package/src/ThemeCustomizerPanel.tsx +30 -336
- package/src/ThemePicker.tsx +112 -3
- package/src/TimelineBlockPreview.tsx +37 -0
- package/src/TimelineTrack.tsx +671 -0
- package/src/Toolbar.tsx +544 -180
- package/src/Tooltip.tsx +22 -4
- package/src/TransitionPicker.tsx +351 -0
- package/src/VersionHistoryPanel.tsx +4 -16
- package/src/ViewMenuPanel.tsx +17 -14
- package/src/WysiwygEditor.tsx +161 -65
- package/src/__tests__/blockProperties.test.ts +92 -0
- package/src/__tests__/blockRange.test.ts +105 -0
- package/src/__tests__/buildPreviewDocTransition.test.ts +73 -0
- package/src/__tests__/codeContextSectionView.test.tsx +95 -0
- package/src/__tests__/codeContextZoneManager.test.ts +127 -0
- package/src/__tests__/createShapeLayer.test.ts +46 -0
- package/src/__tests__/diffContextSections.test.ts +39 -0
- package/src/__tests__/drawingShapeRoundTrip.test.ts +49 -0
- package/src/__tests__/editorShellCodeContext.test.tsx +86 -0
- package/src/__tests__/editorShellProps.test.tsx +96 -0
- package/src/__tests__/embeddedMedia.test.ts +48 -0
- package/src/__tests__/headingTransition.test.ts +138 -0
- package/src/__tests__/layoutChildRoundTrip.test.ts +71 -0
- package/src/__tests__/plainHtmlPreview.test.tsx +10 -8
- package/src/__tests__/previewControls.test.tsx +70 -0
- package/src/__tests__/recorderMediaInsert.test.ts +86 -0
- package/src/__tests__/templateAnnotationRoundTrip.test.ts +18 -0
- package/src/__tests__/templatePickerMetadata.test.ts +32 -0
- package/src/__tests__/timelineSource.test.ts +134 -0
- package/src/__tests__/tiptapBridge.test.ts +92 -0
- package/src/__tests__/tiptapBridgeConformance.test.ts +47 -0
- package/src/__tests__/tooltip.test.tsx +72 -0
- package/src/__tests__/transitionCatalog.test.ts +64 -0
- package/src/__tests__/useBlockNavigator.test.tsx +67 -0
- package/src/__tests__/useJsonEditorTokens.test.ts +59 -0
- package/src/__tests__/useMediaRecorder.test.ts +41 -0
- package/src/__tests__/useTimelineClock.test.ts +21 -0
- package/src/blockProperties.ts +88 -0
- package/src/blockRange.ts +132 -0
- package/src/buildPreviewDoc.ts +98 -9
- package/src/codeContext/CodeContextSectionView.tsx +124 -0
- package/src/codeContext/CodeContextZoneManager.ts +149 -0
- package/src/codeContext/CodeContextZones.tsx +121 -0
- package/src/codeContext/diffContextSections.ts +38 -0
- package/src/codeContext/types.ts +75 -0
- package/src/customTemplates/AddBin.tsx +126 -0
- package/src/customTemplates/CustomLayoutManager.tsx +233 -0
- package/src/customTemplates/CustomTemplateContext.tsx +182 -0
- package/src/customTemplates/LayerToolbar.tsx +580 -0
- package/src/customTemplates/ShapeGlyph.tsx +47 -0
- package/src/customTemplates/TemplateDesigner.tsx +430 -0
- package/src/customTemplates/__tests__/library.test.ts +88 -0
- package/src/customTemplates/__tests__/normalizePositions.test.ts +109 -0
- package/src/customTemplates/__tests__/shapeDefs.test.ts +49 -0
- package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +95 -0
- package/src/customTemplates/designer.css +673 -0
- package/src/customTemplates/index.ts +31 -0
- package/src/customTemplates/library.ts +97 -0
- package/src/customTemplates/normalizePositions.ts +75 -0
- package/src/customTemplates/shapeDefs.ts +131 -0
- package/src/customTemplates/thumbnail.tsx +63 -0
- package/src/customTemplates/tokenDefs.ts +60 -0
- package/src/customTemplates/useDocCustomTemplates.ts +52 -0
- package/src/customTemplates/useMemoryLayerAdapter.ts +123 -0
- package/src/customThemes/CustomThemeContext.tsx +179 -0
- package/src/customThemes/CustomThemeDialog.tsx +286 -0
- package/src/customThemes/__tests__/CustomThemeContext.test.tsx +64 -0
- package/src/customThemes/__tests__/CustomThemeDialog.test.tsx +47 -0
- package/src/customThemes/__tests__/customThemeLibrary.test.ts +51 -0
- package/src/customThemes/customThemeLibrary.ts +97 -0
- package/src/customThemes/index.ts +31 -0
- package/src/customThemes/themeControls.tsx +229 -0
- package/src/customThemes/themeDraft.ts +272 -0
- package/src/customThemes/useDocCustomThemes.ts +49 -0
- package/src/diagram/DiagramCanvas.tsx +240 -0
- package/src/diagram/DiagramExtension.ts +209 -0
- package/src/diagram/DiagramMaximizedOverlay.tsx +46 -0
- package/src/diagram/DiagramWidget.tsx +270 -0
- package/src/diagram/diagramCommands.ts +604 -0
- package/src/diagram/diagramConstants.ts +17 -0
- package/src/diagram/useDiagramData.ts +126 -0
- package/src/embeddedMedia.ts +78 -0
- package/src/frontmatter.ts +29 -0
- package/src/headingTransition.ts +231 -0
- package/src/imageEditor/CanvasSurface.tsx +383 -88
- package/src/imageEditor/PropertiesPanel.tsx +47 -1
- package/src/imageEditor/Toolbar.tsx +229 -16
- package/src/imageEditor/createShapeLayer.ts +280 -0
- package/src/imageEditor/icons.tsx +34 -114
- package/src/imageEditor/image-editor.css +54 -5
- package/src/imageEditor/state.ts +23 -3
- package/src/index.ts +109 -1
- package/src/jsonEditor/useJsonEditorTokens.ts +13 -43
- package/src/recorder/RecorderModal.tsx +120 -53
- package/src/recorder/RecorderPanel.tsx +2 -26
- package/src/recorder/hooks/useMediaRecorder.ts +17 -11
- package/src/recorder/insertMediaBlock.ts +30 -0
- package/src/resolveBlockVisual.ts +33 -0
- package/src/scene/Scene.tsx +540 -0
- package/src/scene/SceneBlockExtension.ts +198 -0
- package/src/scene/SceneBlockToolbar.tsx +201 -0
- package/src/scene/SceneBlockWidget.tsx +434 -0
- package/src/scene/ScenePropsBar.tsx +85 -0
- package/src/scene/SceneSelection.tsx +107 -0
- package/src/scene/SceneViewport.tsx +102 -0
- package/src/scene/ShapePalette.tsx +181 -0
- package/src/scene/__tests__/DiagramAdapter.test.ts +56 -0
- package/src/scene/__tests__/bezierEdit.test.ts +85 -0
- package/src/scene/__tests__/blockLayers.test.ts +57 -0
- package/src/scene/__tests__/shapeLayers.test.ts +106 -0
- package/src/scene/__tests__/useSceneHitTest.test.ts +90 -0
- package/src/scene/__tests__/useScenePanZoom.test.ts +103 -0
- package/src/scene/adapters/DiagramAdapter.ts +168 -0
- package/src/scene/adapters/DrawingAdapter.ts +415 -0
- package/src/scene/adapters/LayoutAdapter.ts +310 -0
- package/src/scene/adapters/blockLayers.ts +159 -0
- package/src/scene/commands/SceneCommand.ts +70 -0
- package/src/scene/commands/drawingCommands.ts +318 -0
- package/src/scene/commands/layoutCommands.ts +301 -0
- package/src/scene/hooks/useSceneHitTest.ts +105 -0
- package/src/scene/hooks/useScenePanZoom.ts +147 -0
- package/src/scene/hooks/useSceneSelection.ts +62 -0
- package/src/scene/index.ts +95 -0
- package/src/scene/layers/DiagramEdges.tsx +127 -0
- package/src/scene/layers/edgeGeometry.ts +77 -0
- package/src/scene/layers/nodeCard.tsx +145 -0
- package/src/scene/layers/renderLayer.tsx +70 -0
- package/src/scene/layers/shapeLayers.ts +201 -0
- package/src/scene/paths/bezierEdit.ts +208 -0
- package/src/scene/scene.css +649 -0
- package/src/scene/text/SceneTextOverlay.tsx +161 -0
- package/src/scene/text/sceneTextChannel.ts +40 -0
- package/src/scene/text/sceneTextConfig.ts +27 -0
- package/src/scene/text/sceneTiptap.ts +36 -0
- package/src/scene/text/useSceneTextEditing.ts +39 -0
- package/src/scene/tools/ConnectTool.ts +111 -0
- package/src/scene/tools/DrawingConnectTool.ts +161 -0
- package/src/scene/tools/PathTool.ts +158 -0
- package/src/scene/tools/PlaceTool.ts +47 -0
- package/src/scene/tools/SceneTool.ts +75 -0
- package/src/scene/tools/SelectTool.ts +284 -0
- package/src/scene/tools/ShapeTool.ts +144 -0
- package/src/scene/tools/TextTool.ts +72 -0
- package/src/scene/tools/TokenTool.ts +95 -0
- package/src/scene/tools/createDrawShapeTool.ts +82 -0
- package/src/styles/code-context.css +155 -0
- package/src/styles/diagram.css +183 -0
- package/src/styles/editor.css +1708 -150
- package/src/styles/folder-view.css +210 -0
- package/src/styles/image-edit-affordance.css +2 -2
- package/src/styles/index.css +5 -0
- package/src/timelineSource.ts +244 -0
- package/src/tiptapBridge.ts +115 -34
- package/src/tooltipPlacement.ts +13 -0
- package/src/transitionCatalog.ts +159 -0
- package/src/types/monaco-shims.d.ts +10 -0
- package/src/useBlockNavigator.ts +153 -0
- package/src/useMonacoLoader.ts +23 -1
- package/src/useTimelineClock.ts +76 -0
- 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
|
+
}
|