@pauloricca/spagbol 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +338 -2
  3. package/dist/controls.css +122 -0
  4. package/dist/core/controller.d.ts +89 -0
  5. package/dist/core/controller.d.ts.map +1 -0
  6. package/dist/core/controller.js +732 -0
  7. package/dist/core/controller.js.map +1 -0
  8. package/dist/core/graph.d.ts +30 -0
  9. package/dist/core/graph.d.ts.map +1 -0
  10. package/dist/core/graph.js +318 -0
  11. package/dist/core/graph.js.map +1 -0
  12. package/dist/core/grouping.d.ts +10 -0
  13. package/dist/core/grouping.d.ts.map +1 -0
  14. package/dist/core/grouping.js +130 -0
  15. package/dist/core/grouping.js.map +1 -0
  16. package/dist/core/groups.d.ts +40 -0
  17. package/dist/core/groups.d.ts.map +1 -0
  18. package/dist/core/groups.js +164 -0
  19. package/dist/core/groups.js.map +1 -0
  20. package/dist/core/index.d.ts +8 -0
  21. package/dist/core/index.d.ts.map +1 -0
  22. package/dist/core/index.js +8 -0
  23. package/dist/core/index.js.map +1 -0
  24. package/dist/core/model.d.ts +182 -0
  25. package/dist/core/model.d.ts.map +1 -0
  26. package/dist/core/model.js +149 -0
  27. package/dist/core/model.js.map +1 -0
  28. package/dist/core/observations.d.ts +10 -0
  29. package/dist/core/observations.d.ts.map +1 -0
  30. package/dist/core/observations.js +36 -0
  31. package/dist/core/observations.js.map +1 -0
  32. package/dist/core/serialization.d.ts +9 -0
  33. package/dist/core/serialization.d.ts.map +1 -0
  34. package/dist/core/serialization.js +148 -0
  35. package/dist/core/serialization.js.map +1 -0
  36. package/dist/index.d.ts +4 -0
  37. package/dist/index.d.ts.map +1 -0
  38. package/dist/index.js +4 -0
  39. package/dist/index.js.map +1 -0
  40. package/dist/link-controls.css +123 -0
  41. package/dist/react/Controls.d.ts +18 -0
  42. package/dist/react/Controls.d.ts.map +1 -0
  43. package/dist/react/Controls.js +301 -0
  44. package/dist/react/Controls.js.map +1 -0
  45. package/dist/react/SpagbolCanvas.d.ts +38 -0
  46. package/dist/react/SpagbolCanvas.d.ts.map +1 -0
  47. package/dist/react/SpagbolCanvas.js +1248 -0
  48. package/dist/react/SpagbolCanvas.js.map +1 -0
  49. package/dist/react/SpagbolCanvasControls.d.ts +36 -0
  50. package/dist/react/SpagbolCanvasControls.d.ts.map +1 -0
  51. package/dist/react/SpagbolCanvasControls.js +113 -0
  52. package/dist/react/SpagbolCanvasControls.js.map +1 -0
  53. package/dist/react/SpagbolCanvasFlow.d.ts +14 -0
  54. package/dist/react/SpagbolCanvasFlow.d.ts.map +1 -0
  55. package/dist/react/SpagbolCanvasFlow.js +6 -0
  56. package/dist/react/SpagbolCanvasFlow.js.map +1 -0
  57. package/dist/react/SpagbolCanvasLinkControls.d.ts +16 -0
  58. package/dist/react/SpagbolCanvasLinkControls.d.ts.map +1 -0
  59. package/dist/react/SpagbolCanvasLinkControls.js +7 -0
  60. package/dist/react/SpagbolCanvasLinkControls.js.map +1 -0
  61. package/dist/react/SpagbolCanvasMenu.d.ts +12 -0
  62. package/dist/react/SpagbolCanvasMenu.d.ts.map +1 -0
  63. package/dist/react/SpagbolCanvasMenu.js +65 -0
  64. package/dist/react/SpagbolCanvasMenu.js.map +1 -0
  65. package/dist/react/SpagbolControlNumber.d.ts +25 -0
  66. package/dist/react/SpagbolControlNumber.d.ts.map +1 -0
  67. package/dist/react/SpagbolControlNumber.js +209 -0
  68. package/dist/react/SpagbolControlNumber.js.map +1 -0
  69. package/dist/react/SpagbolControlNumberLink.d.ts +6 -0
  70. package/dist/react/SpagbolControlNumberLink.d.ts.map +1 -0
  71. package/dist/react/SpagbolControlNumberLink.js +159 -0
  72. package/dist/react/SpagbolControlNumberLink.js.map +1 -0
  73. package/dist/react/SpagbolControlZoom.d.ts +11 -0
  74. package/dist/react/SpagbolControlZoom.d.ts.map +1 -0
  75. package/dist/react/SpagbolControlZoom.js +24 -0
  76. package/dist/react/SpagbolControlZoom.js.map +1 -0
  77. package/dist/react/SpagbolNodeFrame.d.ts +35 -0
  78. package/dist/react/SpagbolNodeFrame.d.ts.map +1 -0
  79. package/dist/react/SpagbolNodeFrame.js +171 -0
  80. package/dist/react/SpagbolNodeFrame.js.map +1 -0
  81. package/dist/react/SpagbolNodeFrameTypePicker.d.ts +9 -0
  82. package/dist/react/SpagbolNodeFrameTypePicker.d.ts.map +1 -0
  83. package/dist/react/SpagbolNodeFrameTypePicker.js +68 -0
  84. package/dist/react/SpagbolNodeFrameTypePicker.js.map +1 -0
  85. package/dist/react/SpagbolNodeGroupPort.d.ts +15 -0
  86. package/dist/react/SpagbolNodeGroupPort.d.ts.map +1 -0
  87. package/dist/react/SpagbolNodeGroupPort.js +62 -0
  88. package/dist/react/SpagbolNodeGroupPort.js.map +1 -0
  89. package/dist/react/SpagbolNodeGroupPortAdd.d.ts +5 -0
  90. package/dist/react/SpagbolNodeGroupPortAdd.d.ts.map +1 -0
  91. package/dist/react/SpagbolNodeGroupPortAdd.js +27 -0
  92. package/dist/react/SpagbolNodeGroupPortAdd.js.map +1 -0
  93. package/dist/react/context.d.ts +84 -0
  94. package/dist/react/context.d.ts.map +1 -0
  95. package/dist/react/context.js +43 -0
  96. package/dist/react/context.js.map +1 -0
  97. package/dist/react/duplicateDrag.d.ts +19 -0
  98. package/dist/react/duplicateDrag.d.ts.map +1 -0
  99. package/dist/react/duplicateDrag.js +71 -0
  100. package/dist/react/duplicateDrag.js.map +1 -0
  101. package/dist/react/flowPresentation.d.ts +11 -0
  102. package/dist/react/flowPresentation.d.ts.map +1 -0
  103. package/dist/react/flowPresentation.js +50 -0
  104. package/dist/react/flowPresentation.js.map +1 -0
  105. package/dist/react/geometry.d.ts +13 -0
  106. package/dist/react/geometry.d.ts.map +1 -0
  107. package/dist/react/geometry.js +43 -0
  108. package/dist/react/geometry.js.map +1 -0
  109. package/dist/react/index.d.ts +11 -0
  110. package/dist/react/index.d.ts.map +1 -0
  111. package/dist/react/index.js +11 -0
  112. package/dist/react/index.js.map +1 -0
  113. package/dist/react/nodeDrag.d.ts +7 -0
  114. package/dist/react/nodeDrag.d.ts.map +1 -0
  115. package/dist/react/nodeDrag.js +27 -0
  116. package/dist/react/nodeDrag.js.map +1 -0
  117. package/dist/react/portState.d.ts +10 -0
  118. package/dist/react/portState.d.ts.map +1 -0
  119. package/dist/react/portState.js +35 -0
  120. package/dist/react/portState.js.map +1 -0
  121. package/dist/react/viewport.d.ts +24 -0
  122. package/dist/react/viewport.d.ts.map +1 -0
  123. package/dist/react/viewport.js +55 -0
  124. package/dist/react/viewport.js.map +1 -0
  125. package/dist/styles.css +748 -0
  126. package/package.json +58 -4
  127. package/src/core/controller.ts +844 -0
  128. package/src/core/graph.ts +451 -0
  129. package/src/core/grouping.ts +159 -0
  130. package/src/core/groups.ts +125 -0
  131. package/src/core/index.ts +7 -0
  132. package/src/core/model.ts +304 -0
  133. package/src/core/observations.ts +31 -0
  134. package/src/core/serialization.ts +184 -0
  135. package/src/index.ts +9 -0
  136. package/src/react/Controls.tsx +355 -0
  137. package/src/react/SpagbolCanvas.tsx +1893 -0
  138. package/src/react/SpagbolCanvasControls.tsx +255 -0
  139. package/src/react/SpagbolCanvasFlow.tsx +38 -0
  140. package/src/react/SpagbolCanvasLinkControls.tsx +21 -0
  141. package/src/react/SpagbolCanvasMenu.tsx +121 -0
  142. package/src/react/SpagbolControlNumber.tsx +297 -0
  143. package/src/react/SpagbolControlNumberLink.tsx +209 -0
  144. package/src/react/SpagbolControlZoom.tsx +102 -0
  145. package/src/react/SpagbolNodeFrame.tsx +330 -0
  146. package/src/react/SpagbolNodeFrameTypePicker.tsx +51 -0
  147. package/src/react/SpagbolNodeGroupPort.tsx +111 -0
  148. package/src/react/SpagbolNodeGroupPortAdd.tsx +48 -0
  149. package/src/react/context.tsx +146 -0
  150. package/src/react/controls.css +122 -0
  151. package/src/react/duplicateDrag.ts +97 -0
  152. package/src/react/flowPresentation.ts +56 -0
  153. package/src/react/geometry.ts +53 -0
  154. package/src/react/index.ts +61 -0
  155. package/src/react/link-controls.css +123 -0
  156. package/src/react/nodeDrag.ts +33 -0
  157. package/src/react/portState.ts +24 -0
  158. package/src/react/styles.css +748 -0
  159. package/src/react/viewport.ts +90 -0
@@ -0,0 +1,1893 @@
1
+ import { previewNodePositions, applyNodePositions } from "./nodeDrag.js";
2
+ import { createPortal } from "react-dom";
3
+ import { SpagbolCanvasLinkControls } from "./SpagbolCanvasLinkControls.js";
4
+ import { SpagbolCanvasFlow } from "./SpagbolCanvasFlow.js";
5
+ import {
6
+ BaseEdge,
7
+ ConnectionLineType,
8
+ EdgeLabelRenderer,
9
+ Handle,
10
+ Position,
11
+ ReactFlow,
12
+ ReactFlowProvider,
13
+ SelectionMode,
14
+ ViewportPortal,
15
+ useReactFlow,
16
+ useStore,
17
+ useStoreApi,
18
+ useUpdateNodeInternals,
19
+ useNodeId,
20
+ useNodesInitialized,
21
+ useViewport,
22
+ type Connection,
23
+ type Edge,
24
+ type EdgeProps,
25
+ type Node,
26
+ type NodeProps,
27
+ type NodeChange,
28
+ type EdgeChange,
29
+ type OnConnectStartParams,
30
+ type Viewport,
31
+ type ReactFlowInstance,
32
+ } from "@xyflow/react";
33
+ import {
34
+ memo,
35
+ useCallback,
36
+ useEffect,
37
+ useId,
38
+ useMemo,
39
+ useRef,
40
+ useState,
41
+ type CSSProperties,
42
+ type KeyboardEvent,
43
+ type PointerEvent,
44
+ type ReactNode,
45
+ } from "react";
46
+ import {
47
+ areaMembers,
48
+ groupPanelLayout,
49
+ spagbolGroupKinds,
50
+ copyFragment,
51
+ createId,
52
+ portsFor,
53
+ readFragment,
54
+ type Diagnostic,
55
+ type GraphArea,
56
+ type GraphEditor,
57
+ type GraphLink,
58
+ type GraphNode,
59
+ type LinkTarget,
60
+ type Point,
61
+ } from "../core/index.js";
62
+ import {
63
+ GraphContext,
64
+ useEditorSnapshot,
65
+ useEditorSelector,
66
+ useSpagbol,
67
+ type SpagbolCanvasSlots,
68
+ type SpagbolCanvasFeatures,
69
+ type SpagbolTheme,
70
+ type SpagbolNodeView,
71
+ } from "./context.js";
72
+ import { SpagbolNodeFrame, SpagbolNodePanel, useNodeViewProps } from "./SpagbolNodeFrame.js";
73
+ import { SpagbolControlNumber } from "./SpagbolControlNumber.js";
74
+ import {
75
+ SpagbolCanvasControls,
76
+ type SpagbolCanvasControlsOptions,
77
+ } from "./SpagbolCanvasControls.js";
78
+ import {
79
+ rememberMeasurements,
80
+ reuseFlowPresentations,
81
+ stationaryReconnectEdge,
82
+ } from "./flowPresentation.js";
83
+ import {
84
+ duplicateDragPresentation,
85
+ type DuplicateDrag,
86
+ } from "./duplicateDrag.js";
87
+ import {
88
+ translateExtentForVisibleContent,
89
+ clampViewportToTranslateExtent,
90
+ infiniteCanvasExtent,
91
+ } from "./viewport.js";
92
+ import {
93
+ anchorFlowId,
94
+ areaFlowId,
95
+ linkPath,
96
+ nodeFlowId,
97
+ parseHandle,
98
+ sourceHandle,
99
+ targetHandle,
100
+ } from "./geometry.js";
101
+
102
+ interface FlowData extends Record<string, unknown> {
103
+ node?: GraphNode;
104
+ area?: GraphArea;
105
+ linkId?: string;
106
+ projected?: boolean;
107
+ stationaryOriginal?: boolean;
108
+ }
109
+ type FlowNode = Node<FlowData, "spagbolNode" | "spagbolArea" | "spagbolAnchor">;
110
+ interface LinkData extends Record<string, unknown> {
111
+ link: GraphLink;
112
+ emphasized?: boolean;
113
+ dimmed?: boolean;
114
+ feedback?: boolean;
115
+ control?: boolean;
116
+ virtual?: boolean;
117
+ disabled?: boolean;
118
+ preview?: boolean;
119
+ }
120
+ type FlowLink = Edge<LinkData, "spagbolLink">;
121
+ export interface SpagbolLinkPresentation {
122
+ feedback?: boolean;
123
+ control?: boolean;
124
+ virtual?: boolean;
125
+ }
126
+ export interface SpagbolCanvasProps extends SpagbolCanvasSlots {
127
+ editor: GraphEditor;
128
+ views?: Readonly<Record<string, SpagbolNodeView>>;
129
+ /** Limit creation/type conversion without discarding existing unsupported nodes. */
130
+ nodeKinds?: readonly string[];
131
+ /** Domain conversion, e.g. capturing an asset before replacing a DSP node. */
132
+ onChangeNodeKind?(id: string, kind: string): void | Promise<void>;
133
+ features?: SpagbolCanvasFeatures;
134
+ diagnostics?: readonly Diagnostic[];
135
+ linkPresentation?: Readonly<Record<string, SpagbolLinkPresentation>>;
136
+ /** Read-only domain relationships, never stored, copied or selected. */
137
+ virtualLinks?: readonly GraphLink[];
138
+ className?: string;
139
+ style?: SpagbolTheme;
140
+ defaultViewport?: Viewport;
141
+ onViewportChange?(viewport: Viewport): void;
142
+ onInit?(instance: ReactFlowInstance): void;
143
+ /** Teia-style content bounds by default; optional margin in screen proportions. */
144
+ panBounds?: false | { emptyScreenRatio?: number };
145
+ onRenderTarget?(key: string, element: HTMLElement | null): void;
146
+ toolbar?: boolean | ReactNode;
147
+ controls?: SpagbolCanvasControlsOptions | false;
148
+ ariaLabel?: string;
149
+ }
150
+ const emptyFlowNodes: FlowNode[] = [];
151
+ const emptyViews: Readonly<Record<string, SpagbolNodeView>> = Object.freeze({});
152
+ const emptyPresentations: Readonly<Record<string, SpagbolLinkPresentation>> =
153
+ Object.freeze({});
154
+ const defaultFeatures: SpagbolCanvasFeatures = { compact: true, nestedGraphs: true, panels: true, areas: true, linkProperties: true };
155
+
156
+ export function SpagbolCanvas(props: SpagbolCanvasProps) {
157
+ const margin =
158
+ props.panBounds === false ? undefined : props.panBounds?.emptyScreenRatio;
159
+ if (margin !== undefined && (!Number.isFinite(margin) || margin < 0))
160
+ throw new Error(
161
+ "Pan margin must be a finite, nonnegative screen proportion.",
162
+ );
163
+ const features = useMemo(() => ({ ...defaultFeatures, ...props.features }), [props.features]);
164
+ if (features.panels && !features.nestedGraphs)
165
+ throw new Error("Projected panels require the nestedGraphs feature.");
166
+ return (
167
+ <ReactFlowProvider>
168
+ <SpagbolCanvasInner {...props} features={features} />
169
+ </ReactFlowProvider>
170
+ );
171
+ }
172
+ const SpagbolNodeRenderer = memo(function SpagbolNodeRenderer({ data, selected }: NodeProps<FlowNode>) {
173
+ const node = data.node!;
174
+ const { views, features, editor } = useSpagbol();
175
+ const updateInternals = useUpdateNodeInternals();
176
+ const flowId = useNodeId();
177
+ const portKey = portsFor(node, editor.nodes)
178
+ .map((port) => `${port.direction}:${port.id}`)
179
+ .join("|");
180
+ useEffect(() => {
181
+ if (data.projected || !flowId) return;
182
+ const frame = requestAnimationFrame(() => updateInternals(flowId));
183
+ return () => cancelAnimationFrame(frame);
184
+ }, [flowId, portKey, data.projected, updateInternals]);
185
+ const baseProps = useNodeViewProps(node, selected, data.projected === true);
186
+ const props = {
187
+ ...baseProps,
188
+ readOnly: baseProps.readOnly || data.stationaryOriginal === true,
189
+ };
190
+ const view = views[node.kind];
191
+ const Renderer = data.projected ? view?.projected : view?.render;
192
+ const Body = view?.body;
193
+ const content = Renderer ? (
194
+ <Renderer {...props} />
195
+ ) : (
196
+ <SpagbolNodeFrame {...props}>
197
+ {features.panels && node.graph && groupPanelLayout(node) ? (
198
+ <SpagbolNodePanel group={node} />
199
+ ) : null}
200
+ {Body ? <Body {...props} /> : null}
201
+ </SpagbolNodeFrame>
202
+ );
203
+ return data.stationaryOriginal ? (
204
+ <div className="spagbol-canvas-duplicate-origin" inert>
205
+ {content}
206
+ </div>
207
+ ) : (
208
+ content
209
+ );
210
+ }, (previous, next) => previous.selected === next.selected && previous.data.node === next.data.node && previous.data.projected === next.data.projected && previous.data.stationaryOriginal === next.data.stationaryOriginal);
211
+ const SpagbolCanvasLinkAnchor = memo(function SpagbolCanvasLinkAnchor({ data }: NodeProps<FlowNode>) {
212
+ const { editor, connecting } = useSpagbol();
213
+ return (
214
+ <div className="spagbol-canvas-link-anchor">
215
+ {(editor.policy.linkProperties ?? []).map((property) => (
216
+ <Handle
217
+ key={property}
218
+ id={`property:${property}`}
219
+ type="target"
220
+ position={Position.Left}
221
+ className="spagbol-property-port"
222
+ isConnectable={editor.editing && connecting}
223
+ style={{ opacity: 0 }}
224
+ aria-label={`Link ${property}`}
225
+ />
226
+ ))}
227
+ </div>
228
+ );
229
+ }, (before, after) => before.data.linkId === after.data.linkId);
230
+ function SpagbolCanvasArea({ data, selected }: NodeProps<FlowNode>) {
231
+ const area = data.area!;
232
+ const { editor, zoom, areaDuplicate } = useSpagbol();
233
+ const [titleEditing, setTitleEditing] = useState(false);
234
+ const [titleDraft, setTitleDraft] = useState(area.title);
235
+ const drag = useRef<{
236
+ pointer: number;
237
+ start: Point;
238
+ position: Point;
239
+ size: { width: number; height: number };
240
+ resize: boolean;
241
+ duplicate: boolean;
242
+ offset: Point;
243
+ moved: boolean;
244
+ title: boolean;
245
+ } | null>(null);
246
+ function start(event: PointerEvent<HTMLElement>, resize = false) {
247
+ if (!editor.editing || event.button !== 0) return;
248
+ event.preventDefault();
249
+ event.stopPropagation();
250
+ editor.select({ areaIds: [area.id] });
251
+ editor.beginGesture(resize ? "Resize area" : event.altKey ? "Duplicate area" : "Move area");
252
+ if (!resize && event.altKey) areaDuplicate?.(area.id, "start", { x: 0, y: 0 }, event.metaKey || event.ctrlKey || event.shiftKey);
253
+ drag.current = {
254
+ pointer: event.pointerId,
255
+ start: { x: event.clientX, y: event.clientY },
256
+ position: { ...area.position },
257
+ size: { ...area.size },
258
+ resize,
259
+ duplicate: !resize && event.altKey, offset: { x: 0, y: 0 }, moved: false, title: !!(event.target as HTMLElement).closest(".spagbol-canvas-area-title"),
260
+ };
261
+ event.currentTarget.setPointerCapture(event.pointerId);
262
+ }
263
+ function move(event: PointerEvent<HTMLElement>) {
264
+ const current = drag.current;
265
+ if (!current || current.pointer !== event.pointerId) return;
266
+ event.stopPropagation();
267
+ const x = (event.clientX - current.start.x) / zoom,
268
+ y = (event.clientY - current.start.y) / zoom;
269
+ current.offset = { x, y };
270
+ current.moved ||= Math.abs(x) + Math.abs(y) > 3 / zoom;
271
+ if (current.duplicate) { areaDuplicate?.(area.id, "move", { x, y }); return; }
272
+ if (current.resize) {
273
+ const members = areaMembers(editor.graph, area.id);
274
+ const minWidth = area.locked
275
+ ? Math.max(
276
+ 140,
277
+ ...editor.graph.nodes
278
+ .filter((node) => members.nodeIds.has(node.id))
279
+ .map(
280
+ (node) =>
281
+ node.position.x - area.position.x + (node.size?.width ?? 220),
282
+ ),
283
+ )
284
+ : 140;
285
+ const minHeight = area.locked
286
+ ? Math.max(
287
+ 80,
288
+ ...editor.graph.nodes
289
+ .filter((node) => members.nodeIds.has(node.id))
290
+ .map(
291
+ (node) =>
292
+ node.position.y -
293
+ area.position.y +
294
+ (node.size?.height ?? 120),
295
+ ),
296
+ )
297
+ : 80;
298
+ editor.updateArea(area.id, {
299
+ size: {
300
+ width: Math.max(minWidth, current.size.width + x),
301
+ height: Math.max(minHeight, current.size.height + y),
302
+ },
303
+ });
304
+ } else
305
+ editor.moveArea(area.id, {
306
+ x: current.position.x + x,
307
+ y: current.position.y + y,
308
+ });
309
+ }
310
+ function end(event: PointerEvent<HTMLElement>, cancel = false) {
311
+ const current = drag.current;
312
+ if (current?.pointer !== event.pointerId) return;
313
+ drag.current = null;
314
+ if (event.currentTarget.hasPointerCapture(event.pointerId))
315
+ event.currentTarget.releasePointerCapture(event.pointerId);
316
+ if (current.duplicate) areaDuplicate?.(area.id, cancel || !current.moved ? 'cancel' : 'end', current.offset);
317
+ else {
318
+ if (cancel) editor.cancelGesture(); else editor.commitGesture();
319
+ if (!cancel && !current.moved && current.title) { setTitleDraft(area.title); setTitleEditing(true); }
320
+ }
321
+ }
322
+ const pointerHandlers = {
323
+ onPointerMove: move,
324
+ onPointerUp: (event: PointerEvent<HTMLElement>) => end(event),
325
+ onPointerCancel: (event: PointerEvent<HTMLElement>) => end(event, true),
326
+ onLostPointerCapture: (event: PointerEvent<HTMLElement>) =>
327
+ end(event, true),
328
+ };
329
+ return (
330
+ <div
331
+ inert={data.stationaryOriginal === true}
332
+ className={`spagbol-canvas-area${selected ? " spagbol-canvas-area-selected" : ""}${area.collapsed ? " spagbol-canvas-area-collapsed" : ""}`}
333
+ style={{
334
+ width: area.size.width,
335
+ height: area.collapsed
336
+ ? 34 + (area.controlHeight ?? 0)
337
+ : area.size.height,
338
+ }}
339
+ >
340
+ <div
341
+ className="spagbol-canvas-area-header nodrag nopan"
342
+ onPointerDown={(event) => start(event)}
343
+ {...pointerHandlers}
344
+ >
345
+ {titleEditing ? <input autoFocus className="spagbol-canvas-area-title-input nodrag nopan" aria-label="Area title" value={titleDraft} onFocus={event => event.currentTarget.select()} onChange={event => setTitleDraft(event.currentTarget.value)} onPointerDown={event => event.stopPropagation()} onBlur={() => { editor.updateArea(area.id, { title: titleDraft.trim() || 'Area' }); setTitleEditing(false); }} onKeyDown={event => { event.stopPropagation(); if (event.key === 'Enter') event.currentTarget.blur(); if (event.key === 'Escape') { setTitleDraft(area.title); setTitleEditing(false); } }} /> : <span className="spagbol-canvas-area-title" onDoubleClick={event => { event.stopPropagation(); if (editor.editing) { setTitleDraft(area.title); setTitleEditing(true); } }}>{area.title}</span>}
346
+ <div
347
+ className="spagbol-canvas-area-actions"
348
+ onPointerDown={(event) => event.stopPropagation()}
349
+ >
350
+ <button
351
+ title={area.locked ? "Unlock membership" : "Lock membership"}
352
+ aria-label={area.locked ? "Unlock membership" : "Lock membership"}
353
+ disabled={!editor.editing}
354
+ onClick={() => editor.lockArea(area.id, !area.locked)}
355
+ >
356
+ {area.locked ? "●" : "○"}
357
+ </button>
358
+ <button
359
+ title={area.collapsed ? "Expand area" : "Collapse area"}
360
+ aria-label={area.collapsed ? "Expand area" : "Collapse area"}
361
+ disabled={!editor.editing}
362
+ onClick={() =>
363
+ editor.updateArea(area.id, { collapsed: !area.collapsed })
364
+ }
365
+ >
366
+ {area.collapsed ? "+" : "−"}
367
+ </button>
368
+ </div>
369
+ </div>
370
+ {!area.collapsed && area.controlHeight ? (
371
+ <div
372
+ className="spagbol-canvas-area-divider"
373
+ style={{ top: 34 + area.controlHeight }}
374
+ />
375
+ ) : null}
376
+ {!area.collapsed && editor.editing && (
377
+ <button
378
+ className="spagbol-canvas-area-resize nodrag nopan"
379
+ aria-label="Resize area"
380
+ onPointerDown={(event) => start(event, true)}
381
+ {...pointerHandlers}
382
+ />
383
+ )}
384
+ <Handle
385
+ id="collapsed:in"
386
+ type="target"
387
+ position={Position.Left}
388
+ isConnectable={false}
389
+ className="spagbol-canvas-area-pin"
390
+ style={{ top: 17 }}
391
+ />
392
+ <Handle
393
+ id="collapsed:out"
394
+ type="source"
395
+ position={Position.Right}
396
+ isConnectable={false}
397
+ className="spagbol-canvas-area-pin"
398
+ style={{ top: 17 }}
399
+ />
400
+ </div>
401
+ );
402
+ }
403
+
404
+ const SpagbolCanvasLink = memo(function SpagbolCanvasLink(props: EdgeProps<FlowLink>) {
405
+ const { editor, diagnostics, linkOverlay, connecting } = useSpagbol();
406
+ const flow = useReactFlow();
407
+ const viewport = useViewport();
408
+ const [controlsVisible, setControlsVisible] = useState(false);
409
+ const selectedLinkId = useEditorSelector(editor, snapshot => snapshot.selection.linkIds.length === 1 ? snapshot.selection.linkIds[0] : undefined);
410
+ const showControls = props.selected && selectedLinkId === props.id && editor.editing && !connecting && !props.data?.virtual;
411
+ useEffect(() => {
412
+ if (!showControls) { setControlsVisible(false); return; }
413
+ const timer = window.setTimeout(() => setControlsVisible(true), 260);
414
+ return () => window.clearTimeout(timer);
415
+ }, [showControls]);
416
+ const link = props.data!.link;
417
+ const [path, x, y] = linkPath(
418
+ { x: props.sourceX, y: props.sourceY },
419
+ { x: props.targetX, y: props.targetY },
420
+ props.data?.feedback,
421
+ link.target.type === "port" && link.source.nodeId === link.target.nodeId,
422
+ );
423
+ const mode = editor.modes.get(link.mode);
424
+ const errors = diagnostics.filter((item) => item.linkId === link.id);
425
+ const selected = props.selected;
426
+ const className = `spagbol-canvas-link${selected || props.data?.emphasized ? " spagbol-canvas-link-emphasized" : ""}${props.data?.dimmed ? " spagbol-canvas-link-dimmed" : ""}${props.data?.disabled ? " spagbol-canvas-link-disabled" : ""}${props.data?.control ? " spagbol-canvas-link-control" : ""}${props.data?.virtual ? " spagbol-canvas-link-virtual" : ""}${errors.length ? " spagbol-canvas-link-error" : ""}`;
427
+ return (
428
+ <>
429
+ <path
430
+ d={path}
431
+ className="spagbol-canvas-link-underlay"
432
+ fill="none"
433
+ style={{ strokeWidth: selected || props.data?.emphasized ? 7 : 5 }}
434
+ />
435
+ <BaseEdge
436
+ id={props.id}
437
+ path={path}
438
+ interactionWidth={
439
+ link.source.nodeId ===
440
+ (link.target.type === "port" ? link.target.nodeId : null)
441
+ ? 38
442
+ : 22
443
+ }
444
+ className={`${className}${props.data?.preview ? " spagbol-canvas-link-preview" : ""}`}
445
+ style={{ stroke: mode?.color ?? "var(--spagbol-muted)" }}
446
+ />
447
+ <path
448
+ d={path}
449
+ className="spagbol-canvas-link-hit"
450
+ data-spagbol-link={link.id}
451
+ fill="none"
452
+ pointerEvents="none"
453
+ />
454
+ {showControls && controlsVisible && linkOverlay && createPortal(
455
+ <SpagbolCanvasLinkControls
456
+ style={{ left: flow.flowToScreenPosition({ x, y }).x, top: flow.flowToScreenPosition({ x, y: !(props.data?.feedback || props.source === props.target) && Math.hypot(props.targetX - props.sourceX, props.targetY - props.sourceY) < 144 ? y - 52 : y }).y, transform: `translate(-50%, -50%) scale(${viewport.zoom})` }}
457
+ weight={link.weight} mode={link.mode} enabled={link.enabled !== false}
458
+ modes={editor.modes.list().filter(mode => !editor.policy.modes || editor.policy.modes.includes(mode.id))}
459
+ onWeightChange={weight => editor.updateLink(link.id, { weight })}
460
+ onModeChange={mode => editor.updateLink(link.id, { mode })}
461
+ onEnabledChange={enabled => editor.updateLink(link.id, { enabled })}
462
+ />, linkOverlay)}
463
+ {errors.length > 0 && (
464
+ <EdgeLabelRenderer>
465
+ <div
466
+ className="spagbol-canvas-link-diagnostic"
467
+ role="status"
468
+ style={{
469
+ transform: `translate(-50%, -50%) translate(${x}px,${y + 18}px)`,
470
+ }}
471
+ title={errors.map((error) => error.message).join("\n")}
472
+ >
473
+ !
474
+ </div>
475
+ </EdgeLabelRenderer>
476
+ )}
477
+ </>
478
+ );
479
+ }, (before, after) => {
480
+ if (before.id !== after.id || before.selected !== after.selected || before.source !== after.source || before.target !== after.target || before.sourceX !== after.sourceX || before.sourceY !== after.sourceY || before.targetX !== after.targetX || before.targetY !== after.targetY) return false;
481
+ const keys = Object.keys(before.data ?? {});
482
+ return keys.length === Object.keys(after.data ?? {}).length && keys.every(key => before.data?.[key] === after.data?.[key]);
483
+ });
484
+ const nodeTypes = {
485
+ spagbolNode: SpagbolNodeRenderer,
486
+ spagbolArea: SpagbolCanvasArea,
487
+ spagbolAnchor: SpagbolCanvasLinkAnchor,
488
+ };
489
+ const edgeTypes = { spagbolLink: SpagbolCanvasLink };
490
+
491
+ interface Picker {
492
+ point: Point;
493
+ screen: Point;
494
+ linkId?: string;
495
+ origin?: { nodeId: string; portId: string; direction: "input" | "output" };
496
+ }
497
+ function SpagbolCanvasInner({
498
+ editor,
499
+ views = emptyViews,
500
+ features = defaultFeatures,
501
+ linkPresentation = emptyPresentations,
502
+ ...props
503
+ }: SpagbolCanvasProps) {
504
+ const snapshot = useEditorSnapshot(editor);
505
+ const graph = editor.graph;
506
+ const root = useRef<HTMLDivElement>(null);
507
+ const [linkOverlay, setLinkOverlay] = useState<HTMLDivElement | null>(null);
508
+ const instanceId = useId();
509
+ const flow = useReactFlow<FlowNode, FlowLink>();
510
+ const flowStore = useStoreApi();
511
+ const nodesInitialized = useNodesInitialized();
512
+ // Only cable-property anchors need live native geometry. Subscribing to every
513
+ // position change otherwise causes redundant full-canvas renders during drags.
514
+ const flowNodes = useStore((state) => features.linkProperties ? state.nodes : emptyFlowNodes);
515
+ // Measurements are canvas state, not saved graph data. Retaining them keeps
516
+ // React Flow from hiding/reinitializing every node on each drag preview frame.
517
+ const measuredSizes = useRef(
518
+ new Map<string, { width: number; height: number }>(),
519
+ );
520
+ const [measurementRevision, setMeasurementRevision] = useState(0);
521
+ const [zoom, setZoom] = useState(props.defaultViewport?.zoom ?? 1);
522
+ const [settledViewport, setSettledViewport] = useState<Viewport>(
523
+ props.defaultViewport ?? { x: 0, y: 0, zoom: 1 },
524
+ );
525
+ const [editorSize, setEditorSize] = useState({ width: 0, height: 0 });
526
+ const viewportMoving = useRef(false);
527
+ useEffect(() => {
528
+ const element = root.current;
529
+ if (!element) return;
530
+ const update = () => {
531
+ const bounds = element.getBoundingClientRect();
532
+ setEditorSize((previous) =>
533
+ previous.width === bounds.width && previous.height === bounds.height
534
+ ? previous
535
+ : { width: bounds.width, height: bounds.height },
536
+ );
537
+ };
538
+ update();
539
+ const observer = new ResizeObserver(update);
540
+ observer.observe(element);
541
+ return () => observer.disconnect();
542
+ }, []);
543
+ const [connecting, setConnecting] = useState(false);
544
+ const [revealedNodeId, setRevealedNodeId] = useState<string | null>(null);
545
+ const [activeMode, setActiveMode] = useState(
546
+ editor.policy.modes?.[0] ?? editor.modes.list()[0]?.id ?? "set",
547
+ );
548
+ const [picker, setPicker] = useState<Picker | null>(null);
549
+ const [search, setSearch] = useState("");
550
+ const [error, setError] = useState<string | null>(null);
551
+ const [drawing, setDrawing] = useState<{
552
+ start: Point;
553
+ end: Point;
554
+ pointer: number;
555
+ } | null>(null);
556
+ const connection = useRef<OnConnectStartParams | null>(null);
557
+ const connectionDone = useRef(false);
558
+ const reconnecting = useRef<string | null>(null);
559
+ const reconnectDuplicate = useRef(false);
560
+ const [reconnectOriginal, setReconnectOriginal] = useState<FlowLink | null>(
561
+ null,
562
+ );
563
+ const duplicate = useRef<DuplicateDrag | null>(null);
564
+ const [duplicatePreview, setDuplicatePreview] =
565
+ useState<DuplicateDrag | null>(null);
566
+ const cancelledNodeDrag = useRef(false);
567
+ // Position previews belong to the canvas, not the persistent document. A drag
568
+ // commits once, avoiding whole-document edits/validation on every pointer move.
569
+ const nodeDrag = useRef<ReadonlyMap<string, Point> | null>(null);
570
+ const [positionRevision, setPositionRevision] = useState(0);
571
+ const clearNodeDrag = () => {
572
+ nodeDrag.current = null;
573
+ // Force a controlled refresh on cancellation, even though the saved position
574
+ // never changed: React Flow currently holds the transient pointer position.
575
+ setPositionRevision(value => value + 1);
576
+ };
577
+
578
+ useEffect(() => {
579
+ if (!snapshot.gesture && nodeDrag.current) {
580
+ cancelledNodeDrag.current = true;
581
+ clearNodeDrag();
582
+ }
583
+ if (!snapshot.gesture && duplicate.current) {
584
+ cancelledNodeDrag.current = true;
585
+ duplicate.current = null;
586
+ setDuplicatePreview(null);
587
+ }
588
+ }, [snapshot.gesture]);
589
+ const duplicatePresentation = useMemo(
590
+ () =>
591
+ duplicatePreview
592
+ ? duplicateDragPresentation(graph, duplicatePreview)
593
+ : null,
594
+ [graph, duplicatePreview],
595
+ );
596
+ const baseRenderGraph = duplicatePresentation?.graph ?? graph;
597
+ const renderGraph = useMemo(() => props.virtualLinks?.length ? { ...baseRenderGraph, links: [...baseRenderGraph.links, ...props.virtualLinks] } : baseRenderGraph, [baseRenderGraph, props.virtualLinks]);
598
+ const targets = useRef(new Map<string, HTMLElement>());
599
+ const coreDiagnostics = useMemo(() => editor.diagnostics(), [editor, snapshot.document]);
600
+ const diagnosticKey = JSON.stringify([coreDiagnostics, props.diagnostics ?? []]);
601
+ const diagnostics = useMemo(
602
+ () =>
603
+ [...coreDiagnostics, ...(props.diagnostics ?? [])].filter(
604
+ (item) =>
605
+ item.path.length === snapshot.path.length &&
606
+ item.path.every((id, index) => id === snapshot.path[index]),
607
+ ),
608
+ [diagnosticKey, snapshot.path],
609
+ );
610
+ const registerTarget = useCallback(
611
+ (key: string, element: HTMLElement | null) => {
612
+ if (element) targets.current.set(key, element);
613
+ else targets.current.delete(key);
614
+ props.onRenderTarget?.(key, element);
615
+ },
616
+ [props.onRenderTarget],
617
+ );
618
+ useEffect(
619
+ () => () => {
620
+ for (const key of targets.current.keys())
621
+ props.onRenderTarget?.(key, null);
622
+ targets.current.clear();
623
+ },
624
+ [props.onRenderTarget],
625
+ );
626
+ useEffect(() => {
627
+ setPicker(null);
628
+ setError(null);
629
+ }, [snapshot.path.join("/")]);
630
+ const areaDuplicate = useCallback((id: string, phase: 'start' | 'move' | 'end' | 'cancel', offset: Point, external = false) => {
631
+ if (phase === 'start') {
632
+ const area = editor.graph.areas.find(area => area.id === id);
633
+ if (!area) return;
634
+ cancelledNodeDrag.current = false;
635
+ const fragment = copyFragment(editor.graph, [], [id]);
636
+ duplicate.current = { namespace: createId('preview'), fragment, nodeId: '', origin: area.position, positions: new Map(fragment.nodes.map(node => [node.id, node.position])), areaPositions: new Map(fragment.areas.map(area => [area.id, area.position])), external };
637
+ setDuplicatePreview(duplicate.current);
638
+ return;
639
+ }
640
+ const current = duplicate.current;
641
+ if (!current) return;
642
+ if (phase === 'move') {
643
+ duplicate.current = { ...current,
644
+ positions: new Map(current.fragment.nodes.map(node => [node.id, { x: node.position.x + offset.x, y: node.position.y + offset.y }])),
645
+ areaPositions: new Map(current.fragment.areas.map(area => [area.id, { x: area.position.x + offset.x, y: area.position.y + offset.y }])),
646
+ };
647
+ setDuplicatePreview(duplicate.current);
648
+ return;
649
+ }
650
+ duplicate.current = null; setDuplicatePreview(null);
651
+ if (phase === 'cancel') editor.cancelGesture();
652
+ else { editor.paste(current.fragment, offset, current.external); editor.commitGesture(); }
653
+ }, [editor]);
654
+ const context = useMemo(
655
+ () => ({
656
+ editor,
657
+ views,
658
+ nodeKinds: props.nodeKinds,
659
+ changeNodeKind: props.onChangeNodeKind,
660
+ features,
661
+ diagnostics,
662
+ zoom,
663
+ connecting,
664
+ instanceId,
665
+ areaDuplicate,
666
+ registerTarget,
667
+ linkOverlay,
668
+ }),
669
+ [
670
+ editor,
671
+ views,
672
+ features,
673
+ diagnostics,
674
+ zoom,
675
+ connecting,
676
+ instanceId,
677
+ registerTarget,
678
+ linkOverlay,
679
+ areaDuplicate,
680
+ props.nodeKinds,
681
+ props.onChangeNodeKind,
682
+ ],
683
+ );
684
+ const attempt = useCallback((action: () => void) => {
685
+ try {
686
+ action();
687
+ setError(null);
688
+ } catch (cause) {
689
+ setError(cause instanceof Error ? cause.message : String(cause));
690
+ }
691
+ }, []);
692
+
693
+ const collapsed = useMemo(() => {
694
+ const nodes = new Map<string, GraphArea>(),
695
+ projected = new Set<string>(),
696
+ hiddenAreas = new Set<string>();
697
+ if (features.areas)
698
+ for (const area of renderGraph.areas.filter((area) => area.collapsed)) {
699
+ const members = areaMembers(renderGraph, area.id);
700
+ for (const id of members.nodeIds) {
701
+ const node = renderGraph.nodes.find((node) => node.id === id);
702
+ if (
703
+ node &&
704
+ (area.controlHeight ?? 0) > 0 &&
705
+ node.position.y >= area.position.y + 34 &&
706
+ node.position.y < area.position.y + 34 + (area.controlHeight ?? 0)
707
+ )
708
+ projected.add(id);
709
+ else nodes.set(id, area);
710
+ }
711
+ for (const id of members.areaIds) hiddenAreas.add(id);
712
+ }
713
+ return { nodes, projected, hiddenAreas };
714
+ }, [renderGraph, features.areas]);
715
+ const modelNodes: FlowNode[] = useMemo(
716
+ () => [
717
+ ...(features.areas
718
+ ? renderGraph.areas.map(
719
+ (area): FlowNode => ({
720
+ id: areaFlowId(area.id),
721
+ type: "spagbolArea",
722
+ position: area.position,
723
+ selected: snapshot.selection.areaIds.includes(area.id),
724
+ hidden: collapsed.hiddenAreas.has(area.id),
725
+ draggable: false,
726
+ selectable: false,
727
+ connectable: false,
728
+ deletable: false,
729
+ zIndex: -1,
730
+ data: { area, stationaryOriginal: duplicatePresentation?.originalAreas.has(area.id) },
731
+ measured: measuredSizes.current.get(areaFlowId(area.id)),
732
+ style: {
733
+ width: area.size.width,
734
+ height: area.collapsed
735
+ ? 34 + (area.controlHeight ?? 0)
736
+ : area.size.height,
737
+ },
738
+ }),
739
+ )
740
+ : []),
741
+ ...renderGraph.nodes.map(
742
+ (node): FlowNode => ({
743
+ id: nodeFlowId(node.id),
744
+ type: "spagbolNode",
745
+ position: nodeDrag.current?.get(node.id) ?? node.position,
746
+ selected: snapshot.selection.nodeIds.includes(node.id),
747
+ hidden: collapsed.nodes.has(node.id),
748
+ draggable:
749
+ editor.editing && views[node.kind]?.draggable !== false &&
750
+ !collapsed.projected.has(node.id) &&
751
+ !duplicatePresentation?.originalNodes.has(node.id),
752
+ selectable:
753
+ editor.editing && views[node.kind]?.selectable !== false &&
754
+ !collapsed.projected.has(node.id) &&
755
+ !duplicatePresentation?.originalNodes.has(node.id),
756
+ connectable:
757
+ editor.editing &&
758
+ !collapsed.projected.has(node.id) &&
759
+ !duplicatePresentation?.originalNodes.has(node.id),
760
+ focusable: !duplicatePresentation?.originalNodes.has(node.id),
761
+ deletable: false,
762
+ dragHandle: ".spagbol-canvas-drag-handle",
763
+ data: {
764
+ positionRevision,
765
+ node: duplicatePresentation?.originalNodes.get(node.id) ?? node,
766
+ projected: collapsed.projected.has(node.id),
767
+ stationaryOriginal: duplicatePresentation?.originalNodes.has(
768
+ node.id,
769
+ ),
770
+ },
771
+ measured:
772
+ measuredSizes.current.get(nodeFlowId(node.id)) ??
773
+ (duplicatePresentation?.originalNodes.has(node.id)
774
+ ? measuredSizes.current.get(
775
+ nodeFlowId(
776
+ duplicatePresentation.originalNodes.get(node.id)!.id,
777
+ ),
778
+ )
779
+ : undefined),
780
+ className: duplicatePresentation?.previewNodeIds.has(node.id)
781
+ ? "spagbol-canvas-duplicate-preview"
782
+ : undefined,
783
+ style: {
784
+ width: views[node.kind]?.autoWidth && !node.size?.width ? "max-content" : (node.size?.width ?? Math.max(220, (groupPanelLayout(node)?.size.width ?? 0) + 2)) * (node.scale ?? 1),
785
+ ...(node.size?.height ? { height: node.size.height * (node.scale ?? 1) } : {}),
786
+ },
787
+ zIndex: connecting && revealedNodeId === node.id ? 5 : snapshot.selection.nodeIds.includes(node.id) ? 3 : 1,
788
+ }),
789
+ ),
790
+ ],
791
+ [
792
+ renderGraph,
793
+ positionRevision,
794
+ duplicatePresentation,
795
+ snapshot.selection,
796
+ snapshot.mode,
797
+ editor,
798
+ features.areas,
799
+ collapsed,
800
+ measurementRevision,
801
+ connecting,
802
+ revealedNodeId,
803
+ ],
804
+ );
805
+ // React Flow publishes handle measurements. Only measured geometry participates
806
+ // in anchor derivation, so updating invisible anchors cannot form a render loop.
807
+ const measurementKey = flowNodes
808
+ .filter(
809
+ (node) => node.id.startsWith("node:") || node.id.startsWith("area:"),
810
+ )
811
+ .map((node) => {
812
+ const internal = flow.getInternalNode(node.id);
813
+ return JSON.stringify([
814
+ node.id,
815
+ node.position,
816
+ node.measured,
817
+ internal?.internals.handleBounds,
818
+ ]);
819
+ })
820
+ .join("|");
821
+ const panExtent = useMemo(() => {
822
+ if (props.panBounds === false) return infiniteCanvasExtent;
823
+ // Use saved content, not temporary duplication nodes or invisible link anchors.
824
+ const items = graph.nodes.map((node) => {
825
+ const internal = flow.getInternalNode(nodeFlowId(node.id));
826
+ return {
827
+ position: node.position,
828
+ hidden: collapsed.nodes.has(node.id),
829
+ size: {
830
+ width: internal?.measured?.width ?? node.size?.width ?? 220,
831
+ height: internal?.measured?.height ?? node.size?.height ?? 96,
832
+ },
833
+ };
834
+ });
835
+ if (features.areas)
836
+ items.push(
837
+ ...graph.areas.map((area) => ({
838
+ position: area.position,
839
+ hidden: collapsed.hiddenAreas.has(area.id),
840
+ size: {
841
+ width: area.size.width,
842
+ height: area.collapsed
843
+ ? 34 + (area.controlHeight ?? 0)
844
+ : area.size.height,
845
+ },
846
+ })),
847
+ );
848
+ return translateExtentForVisibleContent(
849
+ items,
850
+ settledViewport,
851
+ editorSize,
852
+ props.panBounds?.emptyScreenRatio ?? 0.7,
853
+ );
854
+ }, [
855
+ graph,
856
+ features.areas,
857
+ collapsed,
858
+ flow,
859
+ measurementKey,
860
+ measurementRevision,
861
+ settledViewport.zoom,
862
+ editorSize,
863
+ props.panBounds,
864
+ ]);
865
+ useEffect(() => {
866
+ if (
867
+ props.panBounds === false ||
868
+ !flow.viewportInitialized ||
869
+ !nodesInitialized ||
870
+ viewportMoving.current ||
871
+ snapshot.gesture
872
+ )
873
+ return;
874
+ const current = flow.getViewport();
875
+ const clamped = clampViewportToTranslateExtent(
876
+ current,
877
+ panExtent,
878
+ editorSize,
879
+ );
880
+ if (
881
+ Math.abs(current.x - clamped.x) < 0.0001 &&
882
+ Math.abs(current.y - clamped.y) < 0.0001 &&
883
+ current.zoom === clamped.zoom
884
+ )
885
+ return;
886
+ setSettledViewport((previous) =>
887
+ previous.x === clamped.x &&
888
+ previous.y === clamped.y &&
889
+ previous.zoom === clamped.zoom
890
+ ? previous
891
+ : clamped,
892
+ );
893
+ void flow.setViewport(clamped);
894
+ }, [
895
+ panExtent,
896
+ editorSize,
897
+ flow,
898
+ settledViewport,
899
+ nodesInitialized,
900
+ snapshot.gesture,
901
+ props.panBounds,
902
+ ]);
903
+ const anchors: FlowNode[] = useMemo(() => {
904
+ if (!features.linkProperties || snapshot.mode !== "edit") return [];
905
+ function endpoint(
906
+ point: { nodeId: string; portId: string },
907
+ direction: "input" | "output",
908
+ ): Point {
909
+ const area = collapsed.nodes.get(point.nodeId);
910
+ if (area)
911
+ return {
912
+ x: area.position.x + (direction === "output" ? area.size.width : 0),
913
+ y: area.position.y + 17,
914
+ };
915
+ const internal = flow.getInternalNode(nodeFlowId(point.nodeId));
916
+ const handle = internal?.internals.handleBounds?.[
917
+ direction === "output" ? "source" : "target"
918
+ ]?.find(
919
+ (handle) =>
920
+ handle.id ===
921
+ (direction === "output"
922
+ ? sourceHandle(point.portId)
923
+ : targetHandle(point.portId)),
924
+ );
925
+ if (internal && handle)
926
+ return {
927
+ x:
928
+ internal.internals.positionAbsolute.x + handle.x + handle.width / 2,
929
+ y:
930
+ internal.internals.positionAbsolute.y +
931
+ handle.y +
932
+ handle.height / 2,
933
+ };
934
+ const node = renderGraph.nodes.find((node) => node.id === point.nodeId);
935
+ return {
936
+ x:
937
+ (node?.position.x ?? 0) +
938
+ (direction === "output" ? (node?.size?.width ?? 220) : 0),
939
+ y: (node?.position.y ?? 0) + 56,
940
+ };
941
+ }
942
+ const cache = new Map<string, Point>();
943
+ function midpoint(link: GraphLink, visited = new Set<string>()): Point {
944
+ const cached = cache.get(link.id);
945
+ if (cached) return cached;
946
+ if (visited.has(link.id)) return { x: 0, y: 0 };
947
+ const next = new Set([...visited, link.id]);
948
+ // Keep the target reference narrowed across callback boundaries.
949
+ const reference =
950
+ link.target.type === "link" ? link.target.linkId : undefined;
951
+ const parent = reference
952
+ ? renderGraph.links.find((candidate) => candidate.id === reference)
953
+ : undefined;
954
+ const destination =
955
+ link.target.type === "port"
956
+ ? endpoint(link.target, "input")
957
+ : parent
958
+ ? midpoint(parent, next)
959
+ : { x: 0, y: 0 };
960
+ const [, x, y] = linkPath(
961
+ endpoint(link.source, "output"),
962
+ destination,
963
+ linkPresentation[
964
+ duplicatePresentation?.previewLinks.get(link.id) ?? link.id
965
+ ]?.feedback,
966
+ link.target.type === "port" &&
967
+ link.source.nodeId === link.target.nodeId,
968
+ );
969
+ const result = { x, y };
970
+ cache.set(link.id, result);
971
+ return result;
972
+ }
973
+ const targeted = new Set(renderGraph.links.flatMap(link => link.target.type === 'link' ? [link.target.linkId] : []));
974
+ return renderGraph.links.filter(link => connecting || targeted.has(link.id)).map((link) => ({
975
+ id: anchorFlowId(link.id),
976
+ type: "spagbolAnchor",
977
+ position: midpoint(link),
978
+ data: { linkId: link.id },
979
+ measured: { width: 1, height: 1 },
980
+ draggable: false,
981
+ selectable: false,
982
+ deletable: false,
983
+ focusable: false,
984
+ zIndex: 4,
985
+ style: { width: 1, height: 1 },
986
+ }));
987
+ }, [
988
+ renderGraph,
989
+ duplicatePresentation,
990
+ features.linkProperties,
991
+ connecting,
992
+ snapshot.mode,
993
+ collapsed,
994
+ linkPresentation,
995
+ measurementKey,
996
+ flow,
997
+ ]);
998
+ const nodePresentations = useRef<FlowNode[]>([]);
999
+ const displayNodes = useMemo(
1000
+ () => { const result = reuseFlowPresentations(nodePresentations.current, [...modelNodes, ...anchors]); nodePresentations.current = result; return result; },
1001
+ [modelNodes, anchors],
1002
+ );
1003
+ const graphLinks: FlowLink[] = useMemo(
1004
+ () =>
1005
+ snapshot.mode === "controls"
1006
+ ? []
1007
+ : renderGraph.links.flatMap((link): FlowLink[] => {
1008
+ if (link.target.type === "link" && !features.linkProperties)
1009
+ return [];
1010
+ const sourceArea = collapsed.nodes.get(link.source.nodeId);
1011
+ const targetArea =
1012
+ link.target.type === "port"
1013
+ ? collapsed.nodes.get(link.target.nodeId)
1014
+ : undefined;
1015
+ if (sourceArea && sourceArea.id === targetArea?.id) return [];
1016
+ const sourceNode = renderGraph.nodes.find(
1017
+ (node) => node.id === link.source.nodeId,
1018
+ );
1019
+ const targetNodeId =
1020
+ link.target.type === "port" ? link.target.nodeId : undefined;
1021
+ const targetNode = renderGraph.nodes.find(
1022
+ (node) => node.id === targetNodeId,
1023
+ );
1024
+ const preview =
1025
+ duplicatePresentation?.previewLinks.has(link.id) ?? false;
1026
+ const presentation = props.virtualLinks?.some(item => item.id === link.id) ? { virtual: true } :
1027
+ linkPresentation[
1028
+ duplicatePresentation?.previewLinks.get(link.id) ?? link.id
1029
+ ];
1030
+ const related =
1031
+ snapshot.selection.nodeIds.includes(link.source.nodeId) ||
1032
+ (!!targetNodeId &&
1033
+ snapshot.selection.nodeIds.includes(targetNodeId));
1034
+ const selected =
1035
+ !duplicatePreview && snapshot.selection.linkIds.includes(link.id);
1036
+ return [
1037
+ {
1038
+ id: link.id,
1039
+ type: "spagbolLink",
1040
+ source: sourceArea
1041
+ ? areaFlowId(sourceArea.id)
1042
+ : nodeFlowId(link.source.nodeId),
1043
+ sourceHandle: sourceArea
1044
+ ? "collapsed:out"
1045
+ : sourceHandle(link.source.portId),
1046
+ target: targetArea
1047
+ ? areaFlowId(targetArea.id)
1048
+ : link.target.type === "port"
1049
+ ? nodeFlowId(link.target.nodeId)
1050
+ : anchorFlowId(link.target.linkId),
1051
+ targetHandle: targetArea
1052
+ ? "collapsed:in"
1053
+ : link.target.type === "port"
1054
+ ? targetHandle(link.target.portId)
1055
+ : `property:${link.target.property}`,
1056
+ selected,
1057
+ reconnectable:
1058
+ selected &&
1059
+ editor.editing &&
1060
+ !presentation?.virtual &&
1061
+ !sourceArea &&
1062
+ !targetArea,
1063
+ selectable:
1064
+ editor.editing && !presentation?.virtual && !duplicatePreview,
1065
+ deletable: false,
1066
+ zIndex: selected ? 10 : 0,
1067
+ data: {
1068
+ link,
1069
+ preview,
1070
+ ...presentation,
1071
+ emphasized: related || preview,
1072
+ dimmed:
1073
+ !duplicatePreview &&
1074
+ (snapshot.selection.nodeIds.length > 0 ||
1075
+ snapshot.selection.linkIds.length > 0) &&
1076
+ !selected &&
1077
+ !related,
1078
+ disabled:
1079
+ link.enabled === false ||
1080
+ sourceNode?.enabled === false ||
1081
+ targetNode?.enabled === false,
1082
+ },
1083
+ },
1084
+ ];
1085
+ }),
1086
+ [
1087
+ renderGraph,
1088
+ duplicatePresentation,
1089
+ duplicatePreview,
1090
+ snapshot.mode,
1091
+ snapshot.selection,
1092
+ features.linkProperties,
1093
+ collapsed,
1094
+ linkPresentation,
1095
+ editor,
1096
+ ],
1097
+ );
1098
+ const rawDisplayLinks = useMemo(
1099
+ () =>
1100
+ reconnectOriginal && snapshot.mode === "edit"
1101
+ ? [...graphLinks, reconnectOriginal]
1102
+ : graphLinks,
1103
+ [graphLinks, reconnectOriginal, snapshot.mode],
1104
+ );
1105
+
1106
+ const linkPresentations = useRef<FlowLink[]>([]);
1107
+ const displayLinks = useMemo(() => {
1108
+ const result = reuseFlowPresentations(linkPresentations.current, rawDisplayLinks);
1109
+ linkPresentations.current = result; return result;
1110
+ }, [rawDisplayLinks]);
1111
+
1112
+ function modelConnection(
1113
+ value: Pick<Connection, "source" | "target"> & {
1114
+ sourceHandle?: string | null;
1115
+ targetHandle?: string | null;
1116
+ },
1117
+ ): { source: GraphLink["source"]; target: LinkTarget } | undefined {
1118
+ const source = parseHandle(value.sourceHandle),
1119
+ target = parseHandle(value.targetHandle);
1120
+ if (
1121
+ !source ||
1122
+ source.direction !== "output" ||
1123
+ !value.source.startsWith("node:")
1124
+ )
1125
+ return;
1126
+ if (
1127
+ value.target.startsWith("anchor:") &&
1128
+ value.targetHandle?.startsWith("property:")
1129
+ )
1130
+ return {
1131
+ source: { nodeId: value.source.slice(5), portId: source.id },
1132
+ target: {
1133
+ type: "link",
1134
+ linkId: value.target.slice(7),
1135
+ property: value.targetHandle.slice(9),
1136
+ },
1137
+ };
1138
+ if (
1139
+ !target ||
1140
+ target.direction !== "input" ||
1141
+ !value.target.startsWith("node:")
1142
+ )
1143
+ return;
1144
+ return {
1145
+ source: { nodeId: value.source.slice(5), portId: source.id },
1146
+ target: {
1147
+ type: "port",
1148
+ nodeId: value.target.slice(5),
1149
+ portId: target.id,
1150
+ },
1151
+ };
1152
+ }
1153
+ function onNodesChange(changes: NodeChange<FlowNode>[]) {
1154
+ const remeasured = rememberMeasurements(measuredSizes.current, changes);
1155
+ if (remeasured) setMeasurementRevision((value) => value + 1);
1156
+ const selected = new Set(editor.getSnapshot().selection.nodeIds);
1157
+ let selectionChanged = false;
1158
+ for (const change of changes)
1159
+ if (
1160
+ change.type === "select" &&
1161
+ change.id.startsWith("node:") &&
1162
+ graph.nodes.some((node) => nodeFlowId(node.id) === change.id)
1163
+ ) {
1164
+ const id = change.id.slice(5);
1165
+ if (change.selected) selected.add(id);
1166
+ else selected.delete(id);
1167
+ selectionChanged = true;
1168
+ }
1169
+ if (selectionChanged)
1170
+ editor.select({
1171
+ nodeIds: [...selected],
1172
+ linkIds: editor.getSnapshot().selection.linkIds,
1173
+ });
1174
+ const positions = changes.filter(
1175
+ (change) =>
1176
+ change.type === "position" &&
1177
+ change.position &&
1178
+ change.id.startsWith("node:"),
1179
+ );
1180
+ if (positions.length && duplicate.current) {
1181
+ const next = {
1182
+ ...duplicate.current,
1183
+ positions: new Map(duplicate.current.positions),
1184
+ };
1185
+ let changed = false;
1186
+ for (const change of positions)
1187
+ if (
1188
+ change.type === "position" &&
1189
+ change.position &&
1190
+ next.positions.has(change.id.slice(5))
1191
+ ) {
1192
+ const previous = next.positions.get(change.id.slice(5));
1193
+ if (
1194
+ previous?.x !== change.position.x ||
1195
+ previous?.y !== change.position.y
1196
+ ) {
1197
+ next.positions.set(change.id.slice(5), { ...change.position });
1198
+ changed = true;
1199
+ }
1200
+ }
1201
+ if (!changed) return;
1202
+ duplicate.current = next;
1203
+ setDuplicatePreview(next);
1204
+ return;
1205
+ }
1206
+ if (cancelledNodeDrag.current) return;
1207
+ if (positions.length && nodeDrag.current) {
1208
+ const next = previewNodePositions(nodeDrag.current, positions);
1209
+ if (next !== nodeDrag.current) nodeDrag.current = next;
1210
+ return;
1211
+ }
1212
+ if (positions.length)
1213
+ editor.edit("Move nodes", "layout", (graph) => {
1214
+ for (const change of positions)
1215
+ if (change.type === "position" && change.position) {
1216
+ const node = graph.nodes.find(
1217
+ (node) => nodeFlowId(node.id) === change.id,
1218
+ );
1219
+ if (node) node.position = { ...change.position };
1220
+ }
1221
+ });
1222
+ }
1223
+ function onEdgesChange(changes: EdgeChange<FlowLink>[]) {
1224
+ const selected = new Set(editor.getSnapshot().selection.linkIds);
1225
+ let changed = false;
1226
+ for (const change of changes)
1227
+ if (change.type === "select") {
1228
+ if (change.selected) selected.add(change.id);
1229
+ else selected.delete(change.id);
1230
+ changed = true;
1231
+ }
1232
+ if (changed)
1233
+ editor.select({
1234
+ nodeIds: editor.getSnapshot().selection.nodeIds,
1235
+ linkIds: [...selected],
1236
+ });
1237
+ }
1238
+ function openPicker(client: Point, extra: Partial<Picker> = {}) {
1239
+ if (!editor.editing) return;
1240
+ const bounds = root.current?.getBoundingClientRect();
1241
+ setSearch("");
1242
+ setPicker({
1243
+ point: flow.screenToFlowPosition(client),
1244
+ screen: {
1245
+ x: Math.min(
1246
+ client.x - (bounds?.left ?? 0),
1247
+ Math.max(8, (bounds?.width ?? 400) - 260),
1248
+ ),
1249
+ y: Math.min(
1250
+ client.y - (bounds?.top ?? 0),
1251
+ Math.max(8, (bounds?.height ?? 500) - 320),
1252
+ ),
1253
+ },
1254
+ ...extra,
1255
+ });
1256
+ }
1257
+ function chooseNode(kind: string) {
1258
+ if (!picker) return;
1259
+ attempt(() => {
1260
+ if (kind === 'spagbol.area') { editor.addArea(picker.point); setPicker(null); return; }
1261
+ const definition = editor.nodes.get(kind)!;
1262
+ if (picker.linkId) {
1263
+ const prototype = editor.nodes.create(kind, picker.point);
1264
+ const ports = portsFor(prototype, editor.nodes),
1265
+ input = ports.find(
1266
+ (port) => port.direction === "input" && port.connectable !== false,
1267
+ ),
1268
+ output = ports.find(
1269
+ (port) => port.direction === "output" && port.connectable !== false,
1270
+ );
1271
+ if (!input || !output)
1272
+ throw new Error(
1273
+ "Choose a node with an input and an output to insert it into a link.",
1274
+ );
1275
+ editor.insertNode(
1276
+ picker.linkId,
1277
+ kind,
1278
+ picker.point,
1279
+ input.id,
1280
+ output.id,
1281
+ );
1282
+ } else {
1283
+ editor.beginGesture(
1284
+ picker.origin ? "Create connected node" : "Create node",
1285
+ );
1286
+ try {
1287
+ const id = editor.addNode(kind, picker.point)!;
1288
+ if (picker.origin) {
1289
+ const node = editor.graph.nodes.find((node) => node.id === id)!;
1290
+ const opposite =
1291
+ picker.origin.direction === "output" ? "input" : "output";
1292
+ const port = definition
1293
+ .ports(node)
1294
+ .find(
1295
+ (port) =>
1296
+ port.direction === opposite && port.connectable !== false,
1297
+ );
1298
+ if (!port)
1299
+ throw new Error(`This node has no connectable ${opposite}.`);
1300
+ if (opposite === "input")
1301
+ editor.connect(
1302
+ { nodeId: picker.origin.nodeId, portId: picker.origin.portId },
1303
+ { type: "port", nodeId: id, portId: port.id },
1304
+ activeMode,
1305
+ );
1306
+ else
1307
+ editor.connect(
1308
+ { nodeId: id, portId: port.id },
1309
+ {
1310
+ type: "port",
1311
+ nodeId: picker.origin.nodeId,
1312
+ portId: picker.origin.portId,
1313
+ },
1314
+ activeMode,
1315
+ );
1316
+ }
1317
+ editor.commitGesture();
1318
+ } catch (cause) {
1319
+ editor.cancelGesture();
1320
+ throw cause;
1321
+ }
1322
+ }
1323
+ setPicker(null);
1324
+ });
1325
+ }
1326
+ function linkAt(
1327
+ point: Point,
1328
+ source: GraphLink["source"],
1329
+ mode: string,
1330
+ replacingId?: string,
1331
+ ): string | undefined {
1332
+ if (
1333
+ !features.linkProperties ||
1334
+ !(editor.policy.linkProperties ?? []).includes("weight")
1335
+ )
1336
+ return;
1337
+ let best: { id: string; distance: number } | undefined;
1338
+ for (const path of root.current?.querySelectorAll<SVGPathElement>(
1339
+ "[data-spagbol-link]",
1340
+ ) ?? []) {
1341
+ const id = path.dataset.spagbolLink;
1342
+ if (
1343
+ !id ||
1344
+ editor.canConnect(
1345
+ source,
1346
+ { type: "link", linkId: id, property: "weight" },
1347
+ mode,
1348
+ replacingId,
1349
+ )
1350
+ )
1351
+ continue;
1352
+ const matrix = path.getScreenCTM();
1353
+ if (!matrix) continue;
1354
+ const local = new DOMPoint(point.x, point.y).matrixTransform(
1355
+ matrix.inverse(),
1356
+ );
1357
+ const length = path.getTotalLength();
1358
+ let distance = Infinity;
1359
+ for (let i = 0; i <= 48; i++) {
1360
+ const p = path.getPointAtLength((length * i) / 48);
1361
+ distance = Math.min(
1362
+ distance,
1363
+ Math.hypot(p.x - local.x, p.y - local.y) * zoom,
1364
+ );
1365
+ }
1366
+ if (distance < 14 && (!best || distance < best.distance))
1367
+ best = { id, distance };
1368
+ }
1369
+ return best?.id;
1370
+ }
1371
+ async function paste() {
1372
+ let fragment: ReturnType<typeof readFragment> | undefined;
1373
+ try {
1374
+ const text = await navigator.clipboard?.readText();
1375
+ if (text) fragment = readFragment(text);
1376
+ } catch {
1377
+ /* Fall back to this controller's clipboard. */
1378
+ }
1379
+ attempt(() => editor.paste(fragment));
1380
+ }
1381
+ function keyDown(event: KeyboardEvent<HTMLDivElement>) {
1382
+ const target = event.target as HTMLElement;
1383
+ if (
1384
+ target.closest(
1385
+ 'input,textarea,select,[contenteditable="true"],[role="spinbutton"]',
1386
+ )
1387
+ )
1388
+ return;
1389
+ const command = event.metaKey || event.ctrlKey,
1390
+ key = event.key.toLowerCase();
1391
+ if (key === "escape") {
1392
+ if (duplicate.current || nodeDrag.current) cancelledNodeDrag.current = true;
1393
+ if (nodeDrag.current) clearNodeDrag();
1394
+ duplicate.current = null;
1395
+ setDuplicatePreview(null);
1396
+ editor.cancelGesture();
1397
+ flowStore.getState().cancelConnection();
1398
+ setPicker(null);
1399
+ setDrawing(null);
1400
+ setConnecting(false);
1401
+ connection.current = null;
1402
+ connectionDone.current = true;
1403
+ setReconnectOriginal(null);
1404
+ return;
1405
+ }
1406
+ if (command && key === "z") {
1407
+ event.preventDefault();
1408
+ event.shiftKey ? editor.redo() : editor.undo();
1409
+ return;
1410
+ }
1411
+ if (command && key === "y") {
1412
+ event.preventDefault();
1413
+ editor.redo();
1414
+ return;
1415
+ }
1416
+ if (!editor.editing) return;
1417
+ if (command && key === "c") {
1418
+ event.preventDefault();
1419
+ const fragment = editor.copy();
1420
+ void navigator.clipboard
1421
+ ?.writeText(JSON.stringify(fragment))
1422
+ .catch(() => undefined);
1423
+ return;
1424
+ }
1425
+ if (command && key === "v") {
1426
+ event.preventDefault();
1427
+ void paste();
1428
+ return;
1429
+ }
1430
+ if (command && key === "d") {
1431
+ event.preventDefault();
1432
+ attempt(() => editor.duplicate());
1433
+ return;
1434
+ }
1435
+ if (command && key === "a") {
1436
+ event.preventDefault();
1437
+ editor.select({ nodeIds: graph.nodes.filter(node => views[node.kind]?.selectable !== false).map((node) => node.id) });
1438
+ return;
1439
+ }
1440
+ if (command && key === "0") {
1441
+ event.preventDefault();
1442
+ void flow.zoomTo(1);
1443
+ return;
1444
+ }
1445
+ if (key === "delete" || key === "backspace") {
1446
+ event.preventDefault();
1447
+ editor.deleteSelection(command);
1448
+ return;
1449
+ }
1450
+ if (!command && key === "x") {
1451
+ editor.edit("Toggle enabled", "values", (graph) => {
1452
+ const nodes = graph.nodes.filter((node) =>
1453
+ snapshot.selection.nodeIds.includes(node.id),
1454
+ );
1455
+ if (nodes.length) {
1456
+ const enabled = nodes.every((node) => node.enabled === false);
1457
+ for (const node of nodes) node.enabled = enabled;
1458
+ } else {
1459
+ const links = graph.links.filter((link) =>
1460
+ snapshot.selection.linkIds.includes(link.id),
1461
+ );
1462
+ const enabled = links.every((link) => link.enabled === false);
1463
+ for (const link of links) link.enabled = enabled;
1464
+ }
1465
+ });
1466
+ return;
1467
+ }
1468
+ const mode = editor.modes
1469
+ .list()
1470
+ .find(
1471
+ (mode) =>
1472
+ mode.shortcut === key &&
1473
+ (!editor.policy.modes || editor.policy.modes.includes(mode.id)),
1474
+ );
1475
+ if (mode && !command) {
1476
+ setActiveMode(mode.id);
1477
+ if (!connecting)
1478
+ editor.edit("Change link mode", "values", (graph) => {
1479
+ for (const link of graph.links)
1480
+ if (snapshot.selection.linkIds.includes(link.id))
1481
+ link.mode = mode.id;
1482
+ });
1483
+ }
1484
+ }
1485
+ function pointFromEvent(event: MouseEvent | TouchEvent): Point | undefined {
1486
+ if ("clientX" in event) return { x: event.clientX, y: event.clientY };
1487
+ const touch = event.changedTouches[0];
1488
+ return touch ? { x: touch.clientX, y: touch.clientY } : undefined;
1489
+ }
1490
+ const availableNodes = [...editor.nodes.list().filter(definition => !props.nodeKinds || props.nodeKinds.includes(definition.kind)), ...(features.areas && !picker?.origin && !picker?.linkId ? [{ kind: 'spagbol.area', label: 'Area', createData: () => ({}), ports: () => [] } as import('../core/index.js').NodeDefinition] : [])]
1491
+ .filter(definition => snapshot.path.length > 0 || ![spagbolGroupKinds.inputs, spagbolGroupKinds.outputs].includes(definition.kind as typeof spagbolGroupKinds.inputs))
1492
+ .filter((definition) =>
1493
+ `${definition.label} ${definition.category ?? ""} ${definition.keywords?.join(" ") ?? ""}`
1494
+ .toLowerCase()
1495
+ .includes(search.toLowerCase()),
1496
+ );
1497
+
1498
+ return (
1499
+ <GraphContext.Provider value={context}>
1500
+ <div
1501
+ ref={root}
1502
+ className={`spagbol-canvas ${props.className ?? ""}`}
1503
+ style={
1504
+ {
1505
+ ...props.style,
1506
+ "--spagbol-title-scale": zoom < 0.3 ? 1.75 : zoom < 0.5 ? 1.3 : 1,
1507
+ "--spagbol-canvas-height": `${editorSize.height}px`,
1508
+ } as CSSProperties
1509
+ }
1510
+ tabIndex={0}
1511
+ aria-label={props.ariaLabel ?? "Graph editor"}
1512
+ onKeyDown={keyDown}
1513
+ onDoubleClick={(event) => {
1514
+ if (
1515
+ (event.target as HTMLElement).classList.contains("react-flow__pane")
1516
+ ) {
1517
+ event.preventDefault();
1518
+ openPicker({ x: event.clientX, y: event.clientY });
1519
+ }
1520
+ }}
1521
+ onPointerDownCapture={(event) => {
1522
+ const pane = (event.target as HTMLElement).classList.contains(
1523
+ "react-flow__pane",
1524
+ );
1525
+ if (pane) root.current?.focus({ preventScroll: true });
1526
+ if (
1527
+ pane &&
1528
+ features.areas &&
1529
+ editor.editing &&
1530
+ (event.metaKey || event.ctrlKey) &&
1531
+ event.button === 0
1532
+ ) {
1533
+ event.preventDefault();
1534
+ event.stopPropagation();
1535
+ const point = flow.screenToFlowPosition({
1536
+ x: event.clientX,
1537
+ y: event.clientY,
1538
+ });
1539
+ setDrawing({ start: point, end: point, pointer: event.pointerId });
1540
+ event.currentTarget.setPointerCapture(event.pointerId);
1541
+ }
1542
+ }}
1543
+ onPointerMove={(event) => {
1544
+ if (drawing?.pointer === event.pointerId)
1545
+ setDrawing({
1546
+ ...drawing,
1547
+ end: flow.screenToFlowPosition({
1548
+ x: event.clientX,
1549
+ y: event.clientY,
1550
+ }),
1551
+ });
1552
+ }}
1553
+ onPointerUp={(event) => {
1554
+ if (drawing?.pointer !== event.pointerId) return;
1555
+ const position = {
1556
+ x: Math.min(drawing.start.x, drawing.end.x),
1557
+ y: Math.min(drawing.start.y, drawing.end.y),
1558
+ },
1559
+ size = {
1560
+ width: Math.abs(drawing.start.x - drawing.end.x),
1561
+ height: Math.abs(drawing.start.y - drawing.end.y),
1562
+ };
1563
+ if (size.width >= 40 && size.height >= 40)
1564
+ editor.addArea(position, size);
1565
+ setDrawing(null);
1566
+ if (event.currentTarget.hasPointerCapture(event.pointerId))
1567
+ event.currentTarget.releasePointerCapture(event.pointerId);
1568
+ }}
1569
+ onPointerCancel={() => {
1570
+ if (duplicate.current || nodeDrag.current) cancelledNodeDrag.current = true;
1571
+ if (nodeDrag.current) clearNodeDrag();
1572
+ duplicate.current = null;
1573
+ setDuplicatePreview(null);
1574
+ setDrawing(null);
1575
+ editor.cancelGesture();
1576
+ }}
1577
+ >
1578
+ {props.background && (
1579
+ <div className="spagbol-canvas-background">{props.background}</div>
1580
+ )}
1581
+ <SpagbolCanvasFlow<FlowNode, FlowLink>
1582
+ nodes={displayNodes}
1583
+ onlyRenderVisibleElements
1584
+ edges={displayLinks}
1585
+ nodeTypes={nodeTypes}
1586
+ edgeTypes={edgeTypes}
1587
+ onNodesChange={onNodesChange}
1588
+ onEdgesChange={onEdgesChange}
1589
+ onNodeMouseEnter={(_, node) => { if (connecting && node.data.node?.compact && !node.data.stationaryOriginal) setRevealedNodeId(node.data.node.id); }}
1590
+ onNodeMouseLeave={(_, node) => setRevealedNodeId(id => id === node.data.node?.id ? null : id)}
1591
+ onNodeDoubleClick={(event, flowNode) => {
1592
+ const node = flowNode.data.node;
1593
+ if (node?.graph && features.nestedGraphs && !(event.target as HTMLElement).closest('.spagbol-node-frame-header')) { event.preventDefault(); editor.enter(node.id); }
1594
+ }}
1595
+ onPaneClick={() => {
1596
+ editor.select({});
1597
+ setPicker(null);
1598
+ }}
1599
+ onNodeDragStart={(event, node) => {
1600
+ if (!node.data.node) return;
1601
+ cancelledNodeDrag.current = false;
1602
+ editor.beginGesture(
1603
+ event.altKey ? "Duplicate nodes" : "Move nodes",
1604
+ );
1605
+ if (!event.altKey) nodeDrag.current = new Map();
1606
+ if (event.altKey) {
1607
+ const selection = editor.getSnapshot().selection;
1608
+ const ids = selection.nodeIds.includes(node.data.node.id)
1609
+ ? selection.nodeIds
1610
+ : [node.data.node.id];
1611
+ duplicate.current = {
1612
+ namespace: createId("duplicate-preview"),
1613
+ fragment: copyFragment(graph, ids),
1614
+ origin: { ...node.position },
1615
+ nodeId: node.data.node.id,
1616
+ positions: new Map(
1617
+ graph.nodes
1618
+ .filter((node) => ids.includes(node.id))
1619
+ .map((node) => [node.id, { ...node.position }]),
1620
+ ),
1621
+ external: event.ctrlKey || event.metaKey || event.shiftKey,
1622
+ };
1623
+ setDuplicatePreview(duplicate.current);
1624
+ }
1625
+ }}
1626
+ onNodeDragStop={(_event, node) => {
1627
+ const positions = nodeDrag.current;
1628
+ if (positions) clearNodeDrag();
1629
+ const current = duplicate.current;
1630
+ duplicate.current = null;
1631
+ setDuplicatePreview(null);
1632
+ if (cancelledNodeDrag.current) {
1633
+ cancelledNodeDrag.current = false;
1634
+ editor.cancelGesture();
1635
+ editor.select(editor.getSnapshot().selection);
1636
+ return;
1637
+ }
1638
+ if (current) {
1639
+ const position =
1640
+ current.positions.get(current.nodeId) ?? node.position;
1641
+ const delta = {
1642
+ x: position.x - current.origin.x,
1643
+ y: position.y - current.origin.y,
1644
+ };
1645
+ try {
1646
+ editor.paste(current.fragment, delta, current.external);
1647
+ } catch (cause) {
1648
+ editor.cancelGesture();
1649
+ setError(
1650
+ cause instanceof Error ? cause.message : String(cause),
1651
+ );
1652
+ return;
1653
+ }
1654
+ }
1655
+ if (positions?.size)
1656
+ editor.edit("Move nodes", "layout", graph => applyNodePositions(graph, positions));
1657
+ editor.commitGesture();
1658
+ }}
1659
+ onConnectStart={(_event, params) => {
1660
+ setRevealedNodeId(params.nodeId?.startsWith("node:") ? params.nodeId.slice(5) : null);
1661
+ connection.current = params;
1662
+ connectionDone.current = false;
1663
+ setConnecting(true);
1664
+ }}
1665
+ onConnect={(value) => {
1666
+ connectionDone.current = true;
1667
+ const next = modelConnection(value);
1668
+ if (next)
1669
+ attempt(() => {
1670
+ editor.connect(next.source, next.target, activeMode);
1671
+ });
1672
+ }}
1673
+ onConnectEnd={(event, state) => {
1674
+ const origin = connection.current;
1675
+ connection.current = null;
1676
+ setConnecting(false);
1677
+ if (
1678
+ connectionDone.current ||
1679
+ reconnecting.current ||
1680
+ !origin ||
1681
+ state.toHandle
1682
+ )
1683
+ return;
1684
+ const point = pointFromEvent(event),
1685
+ port = parseHandle(origin.handleId);
1686
+ if (!point || !port || !origin.nodeId?.startsWith("node:")) return;
1687
+ const source = { nodeId: origin.nodeId.slice(5), portId: port.id };
1688
+ const hit =
1689
+ port.direction === "output"
1690
+ ? linkAt(point, source, activeMode)
1691
+ : undefined;
1692
+ if (hit)
1693
+ attempt(() => {
1694
+ editor.connect(
1695
+ source,
1696
+ { type: "link", linkId: hit, property: "weight" },
1697
+ activeMode,
1698
+ );
1699
+ });
1700
+ else if ("metaKey" in event && (event.metaKey || event.ctrlKey))
1701
+ openPicker(point, {
1702
+ origin: { ...source, direction: port.direction },
1703
+ });
1704
+ }}
1705
+ reconnectRadius={12}
1706
+ onReconnectStart={(event, edge) => {
1707
+ reconnecting.current = edge.id;
1708
+ reconnectDuplicate.current =
1709
+ event.ctrlKey || event.metaKey || event.altKey;
1710
+ setReconnectOriginal(
1711
+ reconnectDuplicate.current ? stationaryReconnectEdge(edge) : null,
1712
+ );
1713
+ connectionDone.current = false;
1714
+ setConnecting(true);
1715
+ }}
1716
+ onReconnect={(edge, value) => {
1717
+ connectionDone.current = true;
1718
+ const next = modelConnection(value);
1719
+ if (next)
1720
+ attempt(() => {
1721
+ editor.reconnect(
1722
+ edge.id,
1723
+ next.source,
1724
+ next.target,
1725
+ reconnectDuplicate.current,
1726
+ );
1727
+ });
1728
+ }}
1729
+ onReconnectEnd={(event, edge, handleType, state) => {
1730
+ if (
1731
+ !connectionDone.current &&
1732
+ !state.toHandle &&
1733
+ handleType === "source"
1734
+ ) {
1735
+ const point = pointFromEvent(event),
1736
+ link = edge.data?.link;
1737
+ if (point && link) {
1738
+ const hit = linkAt(point, link.source, link.mode, edge.id);
1739
+ if (hit)
1740
+ attempt(() => {
1741
+ editor.reconnect(
1742
+ edge.id,
1743
+ link.source,
1744
+ { type: "link", linkId: hit, property: "weight" },
1745
+ event.ctrlKey || event.metaKey || event.altKey,
1746
+ );
1747
+ });
1748
+ }
1749
+ }
1750
+ reconnecting.current = null;
1751
+ setReconnectOriginal(null);
1752
+ setConnecting(false);
1753
+ }}
1754
+ isValidConnection={(value) => {
1755
+ const next = modelConnection(value);
1756
+ const old =
1757
+ reconnecting.current &&
1758
+ graph.links.find((link) => link.id === reconnecting.current);
1759
+ return (
1760
+ !!next &&
1761
+ !editor.canConnect(
1762
+ next.source,
1763
+ next.target,
1764
+ old ? old.mode : activeMode,
1765
+ reconnecting.current ?? undefined,
1766
+ )
1767
+ );
1768
+ }}
1769
+ onEdgeDoubleClick={(event, edge) => {
1770
+ event.preventDefault();
1771
+ if (edge.data?.virtual) return;
1772
+ openPicker(
1773
+ { x: event.clientX, y: event.clientY },
1774
+ { linkId: edge.id },
1775
+ );
1776
+ }}
1777
+ connectionLineType={ConnectionLineType.Bezier}
1778
+ connectionLineStyle={{
1779
+ stroke: editor.modes.get(activeMode)?.color,
1780
+ strokeWidth: 2,
1781
+ }}
1782
+ defaultViewport={props.defaultViewport}
1783
+ onInit={instance => props.onInit?.(instance as unknown as ReactFlowInstance)}
1784
+ onMoveStart={() => {
1785
+ viewportMoving.current = true;
1786
+ }}
1787
+ onMove={(_, viewport) => setZoom(viewport.zoom)}
1788
+ onMoveEnd={(_, viewport) => {
1789
+ viewportMoving.current = false;
1790
+ setSettledViewport(viewport);
1791
+ props.onViewportChange?.(viewport);
1792
+ }}
1793
+ translateExtent={panExtent}
1794
+ fitView={!props.defaultViewport}
1795
+ fitViewOptions={{ padding: 0.18 }}
1796
+ minZoom={0.05}
1797
+ maxZoom={3}
1798
+ panOnScroll
1799
+ selectionOnDrag
1800
+ selectionMode={SelectionMode.Partial}
1801
+ panOnDrag={[1, 2]}
1802
+ zoomOnScroll={false}
1803
+ zoomOnPinch
1804
+ zoomOnDoubleClick={false}
1805
+ deleteKeyCode={null}
1806
+ multiSelectionKeyCode={["Meta", "Shift"]}
1807
+ nodesDraggable={editor.editing}
1808
+ nodesConnectable={editor.editing}
1809
+ elementsSelectable={editor.editing}
1810
+ autoPanOnConnect
1811
+ autoPanOnNodeDrag
1812
+ proOptions={{ hideAttribution: true }}
1813
+ >
1814
+ {drawing && (
1815
+ <ViewportPortal>
1816
+ <div
1817
+ className="spagbol-canvas-area-draft"
1818
+ style={{
1819
+ transform: `translate(${Math.min(drawing.start.x, drawing.end.x)}px, ${Math.min(drawing.start.y, drawing.end.y)}px)`,
1820
+ width: Math.abs(drawing.start.x - drawing.end.x),
1821
+ height: Math.abs(drawing.start.y - drawing.end.y),
1822
+ }}
1823
+ />
1824
+ </ViewportPortal>
1825
+ )}
1826
+ </SpagbolCanvasFlow>
1827
+ <div ref={setLinkOverlay} className="spagbol-canvas-link-overlay" />
1828
+ {(props.toolbar ?? true) === true ? (
1829
+ props.controls === false ? null : (
1830
+ <SpagbolCanvasControls {...props.controls} />
1831
+ )
1832
+ ) : (
1833
+ props.toolbar
1834
+ )}
1835
+ {picker && (
1836
+ <div
1837
+ className="spagbol-canvas-picker nodrag nopan"
1838
+ role="dialog"
1839
+ aria-label="Add node"
1840
+ style={{ left: picker.screen.x, top: picker.screen.y }}
1841
+ onPointerDown={(event) => event.stopPropagation()}
1842
+ >
1843
+ <div className="spagbol-canvas-picker-heading">
1844
+ {picker.linkId ? "insert a node" : "add a node"}
1845
+ <button
1846
+ aria-label="Cancel node creation"
1847
+ onClick={() => setPicker(null)}
1848
+ >
1849
+ ×
1850
+ </button>
1851
+ </div>
1852
+ <input
1853
+ autoFocus
1854
+ aria-label="Search node types"
1855
+ placeholder="Search nodes…"
1856
+ value={search}
1857
+ onChange={(event) => setSearch(event.target.value)}
1858
+ onKeyDown={(event) => {
1859
+ event.stopPropagation();
1860
+ if (event.key === "Escape") setPicker(null);
1861
+ if (event.key === "Enter" && availableNodes[0])
1862
+ chooseNode(availableNodes[0].kind);
1863
+ }}
1864
+ />
1865
+ <div className="spagbol-canvas-picker-list">
1866
+ {availableNodes.map((definition) => (
1867
+ <button
1868
+ key={definition.kind}
1869
+ onClick={() => chooseNode(definition.kind)}
1870
+ >
1871
+ <span>{definition.label}</span>
1872
+ <small>{definition.category}</small>
1873
+ </button>
1874
+ ))}
1875
+ {!availableNodes.length && <p>No matching nodes.</p>}
1876
+ </div>
1877
+ </div>
1878
+ )}
1879
+ {error && (
1880
+ <div className="spagbol-canvas-error" role="alert">
1881
+ {error}
1882
+ <button aria-label="Dismiss error" onClick={() => setError(null)}>
1883
+ ×
1884
+ </button>
1885
+ </div>
1886
+ )}
1887
+ {props.overlay && (
1888
+ <div className="spagbol-canvas-overlay">{props.overlay}</div>
1889
+ )}
1890
+ </div>
1891
+ </GraphContext.Provider>
1892
+ );
1893
+ }