@pauloricca/spagbol 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +330 -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 +1210 -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 +169 -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/portState.d.ts +10 -0
  114. package/dist/react/portState.d.ts.map +1 -0
  115. package/dist/react/portState.js +35 -0
  116. package/dist/react/portState.js.map +1 -0
  117. package/dist/react/viewport.d.ts +24 -0
  118. package/dist/react/viewport.d.ts.map +1 -0
  119. package/dist/react/viewport.js +55 -0
  120. package/dist/react/viewport.js.map +1 -0
  121. package/dist/styles.css +748 -0
  122. package/package.json +58 -4
  123. package/src/core/controller.ts +844 -0
  124. package/src/core/graph.ts +451 -0
  125. package/src/core/grouping.ts +159 -0
  126. package/src/core/groups.ts +125 -0
  127. package/src/core/index.ts +7 -0
  128. package/src/core/model.ts +304 -0
  129. package/src/core/observations.ts +31 -0
  130. package/src/core/serialization.ts +184 -0
  131. package/src/index.ts +9 -0
  132. package/src/react/Controls.tsx +355 -0
  133. package/src/react/SpagbolCanvas.tsx +1859 -0
  134. package/src/react/SpagbolCanvasControls.tsx +255 -0
  135. package/src/react/SpagbolCanvasFlow.tsx +38 -0
  136. package/src/react/SpagbolCanvasLinkControls.tsx +21 -0
  137. package/src/react/SpagbolCanvasMenu.tsx +121 -0
  138. package/src/react/SpagbolControlNumber.tsx +297 -0
  139. package/src/react/SpagbolControlNumberLink.tsx +209 -0
  140. package/src/react/SpagbolControlZoom.tsx +102 -0
  141. package/src/react/SpagbolNodeFrame.tsx +329 -0
  142. package/src/react/SpagbolNodeFrameTypePicker.tsx +51 -0
  143. package/src/react/SpagbolNodeGroupPort.tsx +111 -0
  144. package/src/react/SpagbolNodeGroupPortAdd.tsx +48 -0
  145. package/src/react/context.tsx +146 -0
  146. package/src/react/controls.css +122 -0
  147. package/src/react/duplicateDrag.ts +97 -0
  148. package/src/react/flowPresentation.ts +56 -0
  149. package/src/react/geometry.ts +53 -0
  150. package/src/react/index.ts +61 -0
  151. package/src/react/link-controls.css +123 -0
  152. package/src/react/portState.ts +24 -0
  153. package/src/react/styles.css +748 -0
  154. package/src/react/viewport.ts +90 -0
@@ -0,0 +1,1210 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { createPortal } from "react-dom";
3
+ import { SpagbolCanvasLinkControls } from "./SpagbolCanvasLinkControls.js";
4
+ import { SpagbolCanvasFlow } from "./SpagbolCanvasFlow.js";
5
+ import { BaseEdge, ConnectionLineType, EdgeLabelRenderer, Handle, Position, ReactFlow, ReactFlowProvider, SelectionMode, ViewportPortal, useReactFlow, useStore, useStoreApi, useUpdateNodeInternals, useNodeId, useNodesInitialized, useViewport, } from "@xyflow/react";
6
+ import { memo, useCallback, useEffect, useId, useMemo, useRef, useState, } from "react";
7
+ import { areaMembers, groupPanelLayout, spagbolGroupKinds, copyFragment, createId, portsFor, readFragment, } from "../core/index.js";
8
+ import { GraphContext, useEditorSnapshot, useEditorSelector, useSpagbol, } from "./context.js";
9
+ import { SpagbolNodeFrame, SpagbolNodePanel, useNodeViewProps } from "./SpagbolNodeFrame.js";
10
+ import { SpagbolControlNumber } from "./SpagbolControlNumber.js";
11
+ import { SpagbolCanvasControls, } from "./SpagbolCanvasControls.js";
12
+ import { rememberMeasurements, reuseFlowPresentations, stationaryReconnectEdge, } from "./flowPresentation.js";
13
+ import { duplicateDragPresentation, } from "./duplicateDrag.js";
14
+ import { translateExtentForVisibleContent, clampViewportToTranslateExtent, infiniteCanvasExtent, } from "./viewport.js";
15
+ import { anchorFlowId, areaFlowId, linkPath, nodeFlowId, parseHandle, sourceHandle, targetHandle, } from "./geometry.js";
16
+ const emptyViews = Object.freeze({});
17
+ const emptyPresentations = Object.freeze({});
18
+ const defaultFeatures = { compact: true, nestedGraphs: true, panels: true, areas: true, linkProperties: true };
19
+ export function SpagbolCanvas(props) {
20
+ const margin = props.panBounds === false ? undefined : props.panBounds?.emptyScreenRatio;
21
+ if (margin !== undefined && (!Number.isFinite(margin) || margin < 0))
22
+ throw new Error("Pan margin must be a finite, nonnegative screen proportion.");
23
+ const features = useMemo(() => ({ ...defaultFeatures, ...props.features }), [props.features]);
24
+ if (features.panels && !features.nestedGraphs)
25
+ throw new Error("Projected panels require the nestedGraphs feature.");
26
+ return (_jsx(ReactFlowProvider, { children: _jsx(SpagbolCanvasInner, { ...props, features: features }) }));
27
+ }
28
+ const SpagbolNodeRenderer = memo(function SpagbolNodeRenderer({ data, selected }) {
29
+ const node = data.node;
30
+ const { views, features, editor } = useSpagbol();
31
+ const updateInternals = useUpdateNodeInternals();
32
+ const flowId = useNodeId();
33
+ const portKey = portsFor(node, editor.nodes)
34
+ .map((port) => `${port.direction}:${port.id}`)
35
+ .join("|");
36
+ useEffect(() => {
37
+ if (data.projected || !flowId)
38
+ return;
39
+ const frame = requestAnimationFrame(() => updateInternals(flowId));
40
+ return () => cancelAnimationFrame(frame);
41
+ }, [flowId, portKey, data.projected, updateInternals]);
42
+ const baseProps = useNodeViewProps(node, selected, data.projected === true);
43
+ const props = {
44
+ ...baseProps,
45
+ readOnly: baseProps.readOnly || data.stationaryOriginal === true,
46
+ };
47
+ const view = views[node.kind];
48
+ const Renderer = data.projected ? view?.projected : view?.render;
49
+ const Body = view?.body;
50
+ const content = Renderer ? (_jsx(Renderer, { ...props })) : (_jsxs(SpagbolNodeFrame, { ...props, children: [features.panels && node.graph && groupPanelLayout(node) ? (_jsx(SpagbolNodePanel, { group: node })) : null, Body ? _jsx(Body, { ...props }) : null] }));
51
+ return data.stationaryOriginal ? (_jsx("div", { className: "spagbol-canvas-duplicate-origin", inert: true, children: content })) : (content);
52
+ }, (previous, next) => previous.selected === next.selected && previous.data.node === next.data.node && previous.data.projected === next.data.projected && previous.data.stationaryOriginal === next.data.stationaryOriginal);
53
+ const SpagbolCanvasLinkAnchor = memo(function SpagbolCanvasLinkAnchor({ data }) {
54
+ const { editor, connecting } = useSpagbol();
55
+ return (_jsx("div", { className: "spagbol-canvas-link-anchor", children: (editor.policy.linkProperties ?? []).map((property) => (_jsx(Handle, { id: `property:${property}`, type: "target", position: Position.Left, className: "spagbol-property-port", isConnectable: editor.editing && connecting, style: { opacity: 0 }, "aria-label": `Link ${property}` }, property))) }));
56
+ }, (before, after) => before.data.linkId === after.data.linkId);
57
+ function SpagbolCanvasArea({ data, selected }) {
58
+ const area = data.area;
59
+ const { editor, zoom, areaDuplicate } = useSpagbol();
60
+ const [titleEditing, setTitleEditing] = useState(false);
61
+ const [titleDraft, setTitleDraft] = useState(area.title);
62
+ const drag = useRef(null);
63
+ function start(event, resize = false) {
64
+ if (!editor.editing || event.button !== 0)
65
+ return;
66
+ event.preventDefault();
67
+ event.stopPropagation();
68
+ editor.select({ areaIds: [area.id] });
69
+ editor.beginGesture(resize ? "Resize area" : event.altKey ? "Duplicate area" : "Move area");
70
+ if (!resize && event.altKey)
71
+ areaDuplicate?.(area.id, "start", { x: 0, y: 0 }, event.metaKey || event.ctrlKey || event.shiftKey);
72
+ drag.current = {
73
+ pointer: event.pointerId,
74
+ start: { x: event.clientX, y: event.clientY },
75
+ position: { ...area.position },
76
+ size: { ...area.size },
77
+ resize,
78
+ duplicate: !resize && event.altKey, offset: { x: 0, y: 0 }, moved: false, title: !!event.target.closest(".spagbol-canvas-area-title"),
79
+ };
80
+ event.currentTarget.setPointerCapture(event.pointerId);
81
+ }
82
+ function move(event) {
83
+ const current = drag.current;
84
+ if (!current || current.pointer !== event.pointerId)
85
+ return;
86
+ event.stopPropagation();
87
+ const x = (event.clientX - current.start.x) / zoom, y = (event.clientY - current.start.y) / zoom;
88
+ current.offset = { x, y };
89
+ current.moved ||= Math.abs(x) + Math.abs(y) > 3 / zoom;
90
+ if (current.duplicate) {
91
+ areaDuplicate?.(area.id, "move", { x, y });
92
+ return;
93
+ }
94
+ if (current.resize) {
95
+ const members = areaMembers(editor.graph, area.id);
96
+ const minWidth = area.locked
97
+ ? Math.max(140, ...editor.graph.nodes
98
+ .filter((node) => members.nodeIds.has(node.id))
99
+ .map((node) => node.position.x - area.position.x + (node.size?.width ?? 220)))
100
+ : 140;
101
+ const minHeight = area.locked
102
+ ? Math.max(80, ...editor.graph.nodes
103
+ .filter((node) => members.nodeIds.has(node.id))
104
+ .map((node) => node.position.y -
105
+ area.position.y +
106
+ (node.size?.height ?? 120)))
107
+ : 80;
108
+ editor.updateArea(area.id, {
109
+ size: {
110
+ width: Math.max(minWidth, current.size.width + x),
111
+ height: Math.max(minHeight, current.size.height + y),
112
+ },
113
+ });
114
+ }
115
+ else
116
+ editor.moveArea(area.id, {
117
+ x: current.position.x + x,
118
+ y: current.position.y + y,
119
+ });
120
+ }
121
+ function end(event, cancel = false) {
122
+ const current = drag.current;
123
+ if (current?.pointer !== event.pointerId)
124
+ return;
125
+ drag.current = null;
126
+ if (event.currentTarget.hasPointerCapture(event.pointerId))
127
+ event.currentTarget.releasePointerCapture(event.pointerId);
128
+ if (current.duplicate)
129
+ areaDuplicate?.(area.id, cancel || !current.moved ? 'cancel' : 'end', current.offset);
130
+ else {
131
+ if (cancel)
132
+ editor.cancelGesture();
133
+ else
134
+ editor.commitGesture();
135
+ if (!cancel && !current.moved && current.title) {
136
+ setTitleDraft(area.title);
137
+ setTitleEditing(true);
138
+ }
139
+ }
140
+ }
141
+ const pointerHandlers = {
142
+ onPointerMove: move,
143
+ onPointerUp: (event) => end(event),
144
+ onPointerCancel: (event) => end(event, true),
145
+ onLostPointerCapture: (event) => end(event, true),
146
+ };
147
+ return (_jsxs("div", { inert: data.stationaryOriginal === true, className: `spagbol-canvas-area${selected ? " spagbol-canvas-area-selected" : ""}${area.collapsed ? " spagbol-canvas-area-collapsed" : ""}`, style: {
148
+ width: area.size.width,
149
+ height: area.collapsed
150
+ ? 34 + (area.controlHeight ?? 0)
151
+ : area.size.height,
152
+ }, children: [_jsxs("div", { className: "spagbol-canvas-area-header nodrag nopan", onPointerDown: (event) => start(event), ...pointerHandlers, children: [titleEditing ? _jsx("input", { autoFocus: true, 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')
153
+ event.currentTarget.blur(); if (event.key === 'Escape') {
154
+ setTitleDraft(area.title);
155
+ setTitleEditing(false);
156
+ } } }) : _jsx("span", { className: "spagbol-canvas-area-title", onDoubleClick: event => { event.stopPropagation(); if (editor.editing) {
157
+ setTitleDraft(area.title);
158
+ setTitleEditing(true);
159
+ } }, children: area.title }), _jsxs("div", { className: "spagbol-canvas-area-actions", onPointerDown: (event) => event.stopPropagation(), children: [_jsx("button", { title: area.locked ? "Unlock membership" : "Lock membership", "aria-label": area.locked ? "Unlock membership" : "Lock membership", disabled: !editor.editing, onClick: () => editor.lockArea(area.id, !area.locked), children: area.locked ? "●" : "○" }), _jsx("button", { title: area.collapsed ? "Expand area" : "Collapse area", "aria-label": area.collapsed ? "Expand area" : "Collapse area", disabled: !editor.editing, onClick: () => editor.updateArea(area.id, { collapsed: !area.collapsed }), children: area.collapsed ? "+" : "−" })] })] }), !area.collapsed && area.controlHeight ? (_jsx("div", { className: "spagbol-canvas-area-divider", style: { top: 34 + area.controlHeight } })) : null, !area.collapsed && editor.editing && (_jsx("button", { className: "spagbol-canvas-area-resize nodrag nopan", "aria-label": "Resize area", onPointerDown: (event) => start(event, true), ...pointerHandlers })), _jsx(Handle, { id: "collapsed:in", type: "target", position: Position.Left, isConnectable: false, className: "spagbol-canvas-area-pin", style: { top: 17 } }), _jsx(Handle, { id: "collapsed:out", type: "source", position: Position.Right, isConnectable: false, className: "spagbol-canvas-area-pin", style: { top: 17 } })] }));
160
+ }
161
+ const SpagbolCanvasLink = memo(function SpagbolCanvasLink(props) {
162
+ const { editor, diagnostics, linkOverlay, connecting } = useSpagbol();
163
+ const flow = useReactFlow();
164
+ const viewport = useViewport();
165
+ const [controlsVisible, setControlsVisible] = useState(false);
166
+ const selection = useEditorSelector(editor, snapshot => snapshot.selection);
167
+ const showControls = props.selected && selection.linkIds.length === 1 && editor.editing && !connecting && !props.data?.virtual;
168
+ useEffect(() => {
169
+ if (!showControls) {
170
+ setControlsVisible(false);
171
+ return;
172
+ }
173
+ const timer = window.setTimeout(() => setControlsVisible(true), 260);
174
+ return () => window.clearTimeout(timer);
175
+ }, [showControls]);
176
+ const link = props.data.link;
177
+ const [path, x, y] = linkPath({ x: props.sourceX, y: props.sourceY }, { x: props.targetX, y: props.targetY }, props.data?.feedback, link.target.type === "port" && link.source.nodeId === link.target.nodeId);
178
+ const mode = editor.modes.get(link.mode);
179
+ const errors = diagnostics.filter((item) => item.linkId === link.id);
180
+ const selected = props.selected;
181
+ 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" : ""}`;
182
+ return (_jsxs(_Fragment, { children: [_jsx("path", { d: path, className: "spagbol-canvas-link-underlay", fill: "none", style: { strokeWidth: selected || props.data?.emphasized ? 7 : 5 } }), _jsx(BaseEdge, { id: props.id, path: path, interactionWidth: link.source.nodeId ===
183
+ (link.target.type === "port" ? link.target.nodeId : null)
184
+ ? 38
185
+ : 22, className: `${className}${props.data?.preview ? " spagbol-canvas-link-preview" : ""}`, style: { stroke: mode?.color ?? "var(--spagbol-muted)" } }), _jsx("path", { d: path, className: "spagbol-canvas-link-hit", "data-spagbol-link": link.id, fill: "none", pointerEvents: "none" }), showControls && controlsVisible && linkOverlay && createPortal(_jsx(SpagbolCanvasLinkControls, { 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})` }, weight: link.weight, mode: link.mode, enabled: link.enabled !== false, modes: editor.modes.list().filter(mode => !editor.policy.modes || editor.policy.modes.includes(mode.id)), onWeightChange: weight => editor.updateLink(link.id, { weight }), onModeChange: mode => editor.updateLink(link.id, { mode }), onEnabledChange: enabled => editor.updateLink(link.id, { enabled }) }), linkOverlay), errors.length > 0 && (_jsx(EdgeLabelRenderer, { children: _jsx("div", { className: "spagbol-canvas-link-diagnostic", role: "status", style: {
186
+ transform: `translate(-50%, -50%) translate(${x}px,${y + 18}px)`,
187
+ }, title: errors.map((error) => error.message).join("\n"), children: "!" }) }))] }));
188
+ }, (before, after) => {
189
+ 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)
190
+ return false;
191
+ const keys = Object.keys(before.data ?? {});
192
+ return keys.length === Object.keys(after.data ?? {}).length && keys.every(key => before.data?.[key] === after.data?.[key]);
193
+ });
194
+ const nodeTypes = {
195
+ spagbolNode: SpagbolNodeRenderer,
196
+ spagbolArea: SpagbolCanvasArea,
197
+ spagbolAnchor: SpagbolCanvasLinkAnchor,
198
+ };
199
+ const edgeTypes = { spagbolLink: SpagbolCanvasLink };
200
+ function SpagbolCanvasInner({ editor, views = emptyViews, features = defaultFeatures, linkPresentation = emptyPresentations, ...props }) {
201
+ const snapshot = useEditorSnapshot(editor);
202
+ const graph = editor.graph;
203
+ const root = useRef(null);
204
+ const [linkOverlay, setLinkOverlay] = useState(null);
205
+ const instanceId = useId();
206
+ const flow = useReactFlow();
207
+ const flowStore = useStoreApi();
208
+ const nodesInitialized = useNodesInitialized();
209
+ const flowNodes = useStore((state) => state.nodes);
210
+ // Measurements are canvas state, not saved graph data. Retaining them keeps
211
+ // React Flow from hiding/reinitializing every node on each drag preview frame.
212
+ const measuredSizes = useRef(new Map());
213
+ const [measurementRevision, setMeasurementRevision] = useState(0);
214
+ const [zoom, setZoom] = useState(props.defaultViewport?.zoom ?? 1);
215
+ const [settledViewport, setSettledViewport] = useState(props.defaultViewport ?? { x: 0, y: 0, zoom: 1 });
216
+ const [editorSize, setEditorSize] = useState({ width: 0, height: 0 });
217
+ const viewportMoving = useRef(false);
218
+ useEffect(() => {
219
+ const element = root.current;
220
+ if (!element)
221
+ return;
222
+ const update = () => {
223
+ const bounds = element.getBoundingClientRect();
224
+ setEditorSize((previous) => previous.width === bounds.width && previous.height === bounds.height
225
+ ? previous
226
+ : { width: bounds.width, height: bounds.height });
227
+ };
228
+ update();
229
+ const observer = new ResizeObserver(update);
230
+ observer.observe(element);
231
+ return () => observer.disconnect();
232
+ }, []);
233
+ const [connecting, setConnecting] = useState(false);
234
+ const [revealedNodeId, setRevealedNodeId] = useState(null);
235
+ const [activeMode, setActiveMode] = useState(editor.policy.modes?.[0] ?? editor.modes.list()[0]?.id ?? "set");
236
+ const [picker, setPicker] = useState(null);
237
+ const [search, setSearch] = useState("");
238
+ const [error, setError] = useState(null);
239
+ const [drawing, setDrawing] = useState(null);
240
+ const connection = useRef(null);
241
+ const connectionDone = useRef(false);
242
+ const reconnecting = useRef(null);
243
+ const reconnectDuplicate = useRef(false);
244
+ const [reconnectOriginal, setReconnectOriginal] = useState(null);
245
+ const duplicate = useRef(null);
246
+ const [duplicatePreview, setDuplicatePreview] = useState(null);
247
+ const cancelledDuplicate = useRef(false);
248
+ useEffect(() => {
249
+ if (!snapshot.gesture && duplicate.current) {
250
+ cancelledDuplicate.current = true;
251
+ duplicate.current = null;
252
+ setDuplicatePreview(null);
253
+ }
254
+ }, [snapshot.gesture]);
255
+ const duplicatePresentation = useMemo(() => duplicatePreview
256
+ ? duplicateDragPresentation(graph, duplicatePreview)
257
+ : null, [graph, duplicatePreview]);
258
+ const baseRenderGraph = duplicatePresentation?.graph ?? graph;
259
+ const renderGraph = useMemo(() => props.virtualLinks?.length ? { ...baseRenderGraph, links: [...baseRenderGraph.links, ...props.virtualLinks] } : baseRenderGraph, [baseRenderGraph, props.virtualLinks]);
260
+ const targets = useRef(new Map());
261
+ const coreDiagnostics = useMemo(() => editor.diagnostics(), [editor, snapshot.document]);
262
+ const diagnosticKey = JSON.stringify([coreDiagnostics, props.diagnostics ?? []]);
263
+ const diagnostics = useMemo(() => [...coreDiagnostics, ...(props.diagnostics ?? [])].filter((item) => item.path.length === snapshot.path.length &&
264
+ item.path.every((id, index) => id === snapshot.path[index])), [diagnosticKey, snapshot.path]);
265
+ const registerTarget = useCallback((key, element) => {
266
+ if (element)
267
+ targets.current.set(key, element);
268
+ else
269
+ targets.current.delete(key);
270
+ props.onRenderTarget?.(key, element);
271
+ }, [props.onRenderTarget]);
272
+ useEffect(() => () => {
273
+ for (const key of targets.current.keys())
274
+ props.onRenderTarget?.(key, null);
275
+ targets.current.clear();
276
+ }, [props.onRenderTarget]);
277
+ useEffect(() => {
278
+ setPicker(null);
279
+ setError(null);
280
+ }, [snapshot.path.join("/")]);
281
+ const areaDuplicate = useCallback((id, phase, offset, external = false) => {
282
+ if (phase === 'start') {
283
+ const area = editor.graph.areas.find(area => area.id === id);
284
+ if (!area)
285
+ return;
286
+ cancelledDuplicate.current = false;
287
+ const fragment = copyFragment(editor.graph, [], [id]);
288
+ 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 };
289
+ setDuplicatePreview(duplicate.current);
290
+ return;
291
+ }
292
+ const current = duplicate.current;
293
+ if (!current)
294
+ return;
295
+ if (phase === 'move') {
296
+ duplicate.current = { ...current,
297
+ positions: new Map(current.fragment.nodes.map(node => [node.id, { x: node.position.x + offset.x, y: node.position.y + offset.y }])),
298
+ areaPositions: new Map(current.fragment.areas.map(area => [area.id, { x: area.position.x + offset.x, y: area.position.y + offset.y }])),
299
+ };
300
+ setDuplicatePreview(duplicate.current);
301
+ return;
302
+ }
303
+ duplicate.current = null;
304
+ setDuplicatePreview(null);
305
+ if (phase === 'cancel')
306
+ editor.cancelGesture();
307
+ else {
308
+ editor.paste(current.fragment, offset, current.external);
309
+ editor.commitGesture();
310
+ }
311
+ }, [editor]);
312
+ const context = useMemo(() => ({
313
+ editor,
314
+ views,
315
+ nodeKinds: props.nodeKinds,
316
+ changeNodeKind: props.onChangeNodeKind,
317
+ features,
318
+ diagnostics,
319
+ zoom,
320
+ connecting,
321
+ instanceId,
322
+ areaDuplicate,
323
+ registerTarget,
324
+ linkOverlay,
325
+ }), [
326
+ editor,
327
+ views,
328
+ features,
329
+ diagnostics,
330
+ zoom,
331
+ connecting,
332
+ instanceId,
333
+ registerTarget,
334
+ linkOverlay,
335
+ areaDuplicate,
336
+ props.nodeKinds,
337
+ props.onChangeNodeKind,
338
+ ]);
339
+ const attempt = useCallback((action) => {
340
+ try {
341
+ action();
342
+ setError(null);
343
+ }
344
+ catch (cause) {
345
+ setError(cause instanceof Error ? cause.message : String(cause));
346
+ }
347
+ }, []);
348
+ const collapsed = useMemo(() => {
349
+ const nodes = new Map(), projected = new Set(), hiddenAreas = new Set();
350
+ if (features.areas)
351
+ for (const area of renderGraph.areas.filter((area) => area.collapsed)) {
352
+ const members = areaMembers(renderGraph, area.id);
353
+ for (const id of members.nodeIds) {
354
+ const node = renderGraph.nodes.find((node) => node.id === id);
355
+ if (node &&
356
+ (area.controlHeight ?? 0) > 0 &&
357
+ node.position.y >= area.position.y + 34 &&
358
+ node.position.y < area.position.y + 34 + (area.controlHeight ?? 0))
359
+ projected.add(id);
360
+ else
361
+ nodes.set(id, area);
362
+ }
363
+ for (const id of members.areaIds)
364
+ hiddenAreas.add(id);
365
+ }
366
+ return { nodes, projected, hiddenAreas };
367
+ }, [renderGraph, features.areas]);
368
+ const modelNodes = useMemo(() => [
369
+ ...(features.areas
370
+ ? renderGraph.areas.map((area) => ({
371
+ id: areaFlowId(area.id),
372
+ type: "spagbolArea",
373
+ position: area.position,
374
+ selected: snapshot.selection.areaIds.includes(area.id),
375
+ hidden: collapsed.hiddenAreas.has(area.id),
376
+ draggable: false,
377
+ selectable: false,
378
+ connectable: false,
379
+ deletable: false,
380
+ zIndex: -1,
381
+ data: { area, stationaryOriginal: duplicatePresentation?.originalAreas.has(area.id) },
382
+ measured: measuredSizes.current.get(areaFlowId(area.id)),
383
+ style: {
384
+ width: area.size.width,
385
+ height: area.collapsed
386
+ ? 34 + (area.controlHeight ?? 0)
387
+ : area.size.height,
388
+ },
389
+ }))
390
+ : []),
391
+ ...renderGraph.nodes.map((node) => ({
392
+ id: nodeFlowId(node.id),
393
+ type: "spagbolNode",
394
+ position: node.position,
395
+ selected: snapshot.selection.nodeIds.includes(node.id),
396
+ hidden: collapsed.nodes.has(node.id),
397
+ draggable: editor.editing && views[node.kind]?.draggable !== false &&
398
+ !collapsed.projected.has(node.id) &&
399
+ !duplicatePresentation?.originalNodes.has(node.id),
400
+ selectable: editor.editing && views[node.kind]?.selectable !== false &&
401
+ !collapsed.projected.has(node.id) &&
402
+ !duplicatePresentation?.originalNodes.has(node.id),
403
+ connectable: editor.editing &&
404
+ !collapsed.projected.has(node.id) &&
405
+ !duplicatePresentation?.originalNodes.has(node.id),
406
+ focusable: !duplicatePresentation?.originalNodes.has(node.id),
407
+ deletable: false,
408
+ dragHandle: ".spagbol-canvas-drag-handle",
409
+ data: {
410
+ node: duplicatePresentation?.originalNodes.get(node.id) ?? node,
411
+ projected: collapsed.projected.has(node.id),
412
+ stationaryOriginal: duplicatePresentation?.originalNodes.has(node.id),
413
+ },
414
+ measured: measuredSizes.current.get(nodeFlowId(node.id)) ??
415
+ (duplicatePresentation?.originalNodes.has(node.id)
416
+ ? measuredSizes.current.get(nodeFlowId(duplicatePresentation.originalNodes.get(node.id).id))
417
+ : undefined),
418
+ className: duplicatePresentation?.previewNodeIds.has(node.id)
419
+ ? "spagbol-canvas-duplicate-preview"
420
+ : undefined,
421
+ style: {
422
+ 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),
423
+ ...(node.size?.height ? { height: node.size.height * (node.scale ?? 1) } : {}),
424
+ },
425
+ zIndex: connecting && revealedNodeId === node.id ? 5 : snapshot.selection.nodeIds.includes(node.id) ? 3 : 1,
426
+ })),
427
+ ], [
428
+ renderGraph,
429
+ duplicatePresentation,
430
+ snapshot.selection,
431
+ snapshot.mode,
432
+ editor,
433
+ features.areas,
434
+ collapsed,
435
+ measurementRevision,
436
+ connecting,
437
+ revealedNodeId,
438
+ ]);
439
+ // React Flow publishes handle measurements. Only measured geometry participates
440
+ // in anchor derivation, so updating invisible anchors cannot form a render loop.
441
+ const measurementKey = flowNodes
442
+ .filter((node) => node.id.startsWith("node:") || node.id.startsWith("area:"))
443
+ .map((node) => {
444
+ const internal = flow.getInternalNode(node.id);
445
+ return JSON.stringify([
446
+ node.id,
447
+ node.position,
448
+ node.measured,
449
+ internal?.internals.handleBounds,
450
+ ]);
451
+ })
452
+ .join("|");
453
+ const panExtent = useMemo(() => {
454
+ if (props.panBounds === false)
455
+ return infiniteCanvasExtent;
456
+ // Use saved content, not temporary duplication nodes or invisible link anchors.
457
+ const items = graph.nodes.map((node) => {
458
+ const internal = flow.getInternalNode(nodeFlowId(node.id));
459
+ return {
460
+ position: node.position,
461
+ hidden: collapsed.nodes.has(node.id),
462
+ size: {
463
+ width: internal?.measured?.width ?? node.size?.width ?? 220,
464
+ height: internal?.measured?.height ?? node.size?.height ?? 96,
465
+ },
466
+ };
467
+ });
468
+ if (features.areas)
469
+ items.push(...graph.areas.map((area) => ({
470
+ position: area.position,
471
+ hidden: collapsed.hiddenAreas.has(area.id),
472
+ size: {
473
+ width: area.size.width,
474
+ height: area.collapsed
475
+ ? 34 + (area.controlHeight ?? 0)
476
+ : area.size.height,
477
+ },
478
+ })));
479
+ return translateExtentForVisibleContent(items, settledViewport, editorSize, props.panBounds?.emptyScreenRatio ?? 0.7);
480
+ }, [
481
+ graph,
482
+ features.areas,
483
+ collapsed,
484
+ flow,
485
+ measurementKey,
486
+ settledViewport.zoom,
487
+ editorSize,
488
+ props.panBounds,
489
+ ]);
490
+ useEffect(() => {
491
+ if (props.panBounds === false ||
492
+ !flow.viewportInitialized ||
493
+ !nodesInitialized ||
494
+ viewportMoving.current ||
495
+ snapshot.gesture)
496
+ return;
497
+ const current = flow.getViewport();
498
+ const clamped = clampViewportToTranslateExtent(current, panExtent, editorSize);
499
+ if (Math.abs(current.x - clamped.x) < 0.0001 &&
500
+ Math.abs(current.y - clamped.y) < 0.0001 &&
501
+ current.zoom === clamped.zoom)
502
+ return;
503
+ setSettledViewport((previous) => previous.x === clamped.x &&
504
+ previous.y === clamped.y &&
505
+ previous.zoom === clamped.zoom
506
+ ? previous
507
+ : clamped);
508
+ void flow.setViewport(clamped);
509
+ }, [
510
+ panExtent,
511
+ editorSize,
512
+ flow,
513
+ settledViewport,
514
+ nodesInitialized,
515
+ snapshot.gesture,
516
+ props.panBounds,
517
+ ]);
518
+ const anchors = useMemo(() => {
519
+ if (!features.linkProperties || snapshot.mode !== "edit")
520
+ return [];
521
+ function endpoint(point, direction) {
522
+ const area = collapsed.nodes.get(point.nodeId);
523
+ if (area)
524
+ return {
525
+ x: area.position.x + (direction === "output" ? area.size.width : 0),
526
+ y: area.position.y + 17,
527
+ };
528
+ const internal = flow.getInternalNode(nodeFlowId(point.nodeId));
529
+ const handle = internal?.internals.handleBounds?.[direction === "output" ? "source" : "target"]?.find((handle) => handle.id ===
530
+ (direction === "output"
531
+ ? sourceHandle(point.portId)
532
+ : targetHandle(point.portId)));
533
+ if (internal && handle)
534
+ return {
535
+ x: internal.internals.positionAbsolute.x + handle.x + handle.width / 2,
536
+ y: internal.internals.positionAbsolute.y +
537
+ handle.y +
538
+ handle.height / 2,
539
+ };
540
+ const node = renderGraph.nodes.find((node) => node.id === point.nodeId);
541
+ return {
542
+ x: (node?.position.x ?? 0) +
543
+ (direction === "output" ? (node?.size?.width ?? 220) : 0),
544
+ y: (node?.position.y ?? 0) + 56,
545
+ };
546
+ }
547
+ const cache = new Map();
548
+ function midpoint(link, visited = new Set()) {
549
+ const cached = cache.get(link.id);
550
+ if (cached)
551
+ return cached;
552
+ if (visited.has(link.id))
553
+ return { x: 0, y: 0 };
554
+ const next = new Set([...visited, link.id]);
555
+ // Keep the target reference narrowed across callback boundaries.
556
+ const reference = link.target.type === "link" ? link.target.linkId : undefined;
557
+ const parent = reference
558
+ ? renderGraph.links.find((candidate) => candidate.id === reference)
559
+ : undefined;
560
+ const destination = link.target.type === "port"
561
+ ? endpoint(link.target, "input")
562
+ : parent
563
+ ? midpoint(parent, next)
564
+ : { x: 0, y: 0 };
565
+ const [, x, y] = linkPath(endpoint(link.source, "output"), destination, linkPresentation[duplicatePresentation?.previewLinks.get(link.id) ?? link.id]?.feedback, link.target.type === "port" &&
566
+ link.source.nodeId === link.target.nodeId);
567
+ const result = { x, y };
568
+ cache.set(link.id, result);
569
+ return result;
570
+ }
571
+ const targeted = new Set(renderGraph.links.flatMap(link => link.target.type === 'link' ? [link.target.linkId] : []));
572
+ return renderGraph.links.filter(link => connecting || targeted.has(link.id)).map((link) => ({
573
+ id: anchorFlowId(link.id),
574
+ type: "spagbolAnchor",
575
+ position: midpoint(link),
576
+ data: { linkId: link.id },
577
+ measured: { width: 1, height: 1 },
578
+ draggable: false,
579
+ selectable: false,
580
+ deletable: false,
581
+ focusable: false,
582
+ zIndex: 4,
583
+ style: { width: 1, height: 1 },
584
+ }));
585
+ }, [
586
+ renderGraph,
587
+ duplicatePresentation,
588
+ features.linkProperties,
589
+ connecting,
590
+ snapshot.mode,
591
+ collapsed,
592
+ linkPresentation,
593
+ measurementKey,
594
+ flow,
595
+ ]);
596
+ const nodePresentations = useRef([]);
597
+ const displayNodes = useMemo(() => { const result = reuseFlowPresentations(nodePresentations.current, [...modelNodes, ...anchors]); nodePresentations.current = result; return result; }, [modelNodes, anchors]);
598
+ const graphLinks = useMemo(() => snapshot.mode === "controls"
599
+ ? []
600
+ : renderGraph.links.flatMap((link) => {
601
+ if (link.target.type === "link" && !features.linkProperties)
602
+ return [];
603
+ const sourceArea = collapsed.nodes.get(link.source.nodeId);
604
+ const targetArea = link.target.type === "port"
605
+ ? collapsed.nodes.get(link.target.nodeId)
606
+ : undefined;
607
+ if (sourceArea && sourceArea.id === targetArea?.id)
608
+ return [];
609
+ const sourceNode = renderGraph.nodes.find((node) => node.id === link.source.nodeId);
610
+ const targetNodeId = link.target.type === "port" ? link.target.nodeId : undefined;
611
+ const targetNode = renderGraph.nodes.find((node) => node.id === targetNodeId);
612
+ const preview = duplicatePresentation?.previewLinks.has(link.id) ?? false;
613
+ const presentation = props.virtualLinks?.some(item => item.id === link.id) ? { virtual: true } :
614
+ linkPresentation[duplicatePresentation?.previewLinks.get(link.id) ?? link.id];
615
+ const related = snapshot.selection.nodeIds.includes(link.source.nodeId) ||
616
+ (!!targetNodeId &&
617
+ snapshot.selection.nodeIds.includes(targetNodeId));
618
+ const selected = !duplicatePreview && snapshot.selection.linkIds.includes(link.id);
619
+ return [
620
+ {
621
+ id: link.id,
622
+ type: "spagbolLink",
623
+ source: sourceArea
624
+ ? areaFlowId(sourceArea.id)
625
+ : nodeFlowId(link.source.nodeId),
626
+ sourceHandle: sourceArea
627
+ ? "collapsed:out"
628
+ : sourceHandle(link.source.portId),
629
+ target: targetArea
630
+ ? areaFlowId(targetArea.id)
631
+ : link.target.type === "port"
632
+ ? nodeFlowId(link.target.nodeId)
633
+ : anchorFlowId(link.target.linkId),
634
+ targetHandle: targetArea
635
+ ? "collapsed:in"
636
+ : link.target.type === "port"
637
+ ? targetHandle(link.target.portId)
638
+ : `property:${link.target.property}`,
639
+ selected,
640
+ reconnectable: selected &&
641
+ editor.editing &&
642
+ !presentation?.virtual &&
643
+ !sourceArea &&
644
+ !targetArea,
645
+ selectable: editor.editing && !presentation?.virtual && !duplicatePreview,
646
+ deletable: false,
647
+ zIndex: selected ? 10 : 0,
648
+ data: {
649
+ link,
650
+ preview,
651
+ ...presentation,
652
+ emphasized: related || preview,
653
+ dimmed: !duplicatePreview &&
654
+ (snapshot.selection.nodeIds.length > 0 ||
655
+ snapshot.selection.linkIds.length > 0) &&
656
+ !selected &&
657
+ !related,
658
+ disabled: link.enabled === false ||
659
+ sourceNode?.enabled === false ||
660
+ targetNode?.enabled === false,
661
+ },
662
+ },
663
+ ];
664
+ }), [
665
+ renderGraph,
666
+ duplicatePresentation,
667
+ duplicatePreview,
668
+ snapshot.mode,
669
+ snapshot.selection,
670
+ features.linkProperties,
671
+ collapsed,
672
+ linkPresentation,
673
+ editor,
674
+ ]);
675
+ const rawDisplayLinks = useMemo(() => reconnectOriginal && snapshot.mode === "edit"
676
+ ? [...graphLinks, reconnectOriginal]
677
+ : graphLinks, [graphLinks, reconnectOriginal, snapshot.mode]);
678
+ const linkPresentations = useRef([]);
679
+ const displayLinks = useMemo(() => {
680
+ const result = reuseFlowPresentations(linkPresentations.current, rawDisplayLinks);
681
+ linkPresentations.current = result;
682
+ return result;
683
+ }, [rawDisplayLinks]);
684
+ function modelConnection(value) {
685
+ const source = parseHandle(value.sourceHandle), target = parseHandle(value.targetHandle);
686
+ if (!source ||
687
+ source.direction !== "output" ||
688
+ !value.source.startsWith("node:"))
689
+ return;
690
+ if (value.target.startsWith("anchor:") &&
691
+ value.targetHandle?.startsWith("property:"))
692
+ return {
693
+ source: { nodeId: value.source.slice(5), portId: source.id },
694
+ target: {
695
+ type: "link",
696
+ linkId: value.target.slice(7),
697
+ property: value.targetHandle.slice(9),
698
+ },
699
+ };
700
+ if (!target ||
701
+ target.direction !== "input" ||
702
+ !value.target.startsWith("node:"))
703
+ return;
704
+ return {
705
+ source: { nodeId: value.source.slice(5), portId: source.id },
706
+ target: {
707
+ type: "port",
708
+ nodeId: value.target.slice(5),
709
+ portId: target.id,
710
+ },
711
+ };
712
+ }
713
+ function onNodesChange(changes) {
714
+ const remeasured = rememberMeasurements(measuredSizes.current, changes);
715
+ if (remeasured)
716
+ setMeasurementRevision((value) => value + 1);
717
+ const selected = new Set(editor.getSnapshot().selection.nodeIds);
718
+ let selectionChanged = false;
719
+ for (const change of changes)
720
+ if (change.type === "select" &&
721
+ change.id.startsWith("node:") &&
722
+ graph.nodes.some((node) => nodeFlowId(node.id) === change.id)) {
723
+ const id = change.id.slice(5);
724
+ if (change.selected)
725
+ selected.add(id);
726
+ else
727
+ selected.delete(id);
728
+ selectionChanged = true;
729
+ }
730
+ if (selectionChanged)
731
+ editor.select({
732
+ nodeIds: [...selected],
733
+ linkIds: editor.getSnapshot().selection.linkIds,
734
+ });
735
+ const positions = changes.filter((change) => change.type === "position" &&
736
+ change.position &&
737
+ change.id.startsWith("node:"));
738
+ if (positions.length && duplicate.current) {
739
+ const next = {
740
+ ...duplicate.current,
741
+ positions: new Map(duplicate.current.positions),
742
+ };
743
+ let changed = false;
744
+ for (const change of positions)
745
+ if (change.type === "position" &&
746
+ change.position &&
747
+ next.positions.has(change.id.slice(5))) {
748
+ const previous = next.positions.get(change.id.slice(5));
749
+ if (previous?.x !== change.position.x ||
750
+ previous?.y !== change.position.y) {
751
+ next.positions.set(change.id.slice(5), { ...change.position });
752
+ changed = true;
753
+ }
754
+ }
755
+ if (!changed)
756
+ return;
757
+ duplicate.current = next;
758
+ setDuplicatePreview(next);
759
+ return;
760
+ }
761
+ if (cancelledDuplicate.current)
762
+ return;
763
+ if (positions.length)
764
+ editor.edit("Move nodes", "layout", (graph) => {
765
+ for (const change of positions)
766
+ if (change.type === "position" && change.position) {
767
+ const node = graph.nodes.find((node) => nodeFlowId(node.id) === change.id);
768
+ if (node)
769
+ node.position = { ...change.position };
770
+ }
771
+ });
772
+ }
773
+ function onEdgesChange(changes) {
774
+ const selected = new Set(editor.getSnapshot().selection.linkIds);
775
+ let changed = false;
776
+ for (const change of changes)
777
+ if (change.type === "select") {
778
+ if (change.selected)
779
+ selected.add(change.id);
780
+ else
781
+ selected.delete(change.id);
782
+ changed = true;
783
+ }
784
+ if (changed)
785
+ editor.select({
786
+ nodeIds: editor.getSnapshot().selection.nodeIds,
787
+ linkIds: [...selected],
788
+ });
789
+ }
790
+ function openPicker(client, extra = {}) {
791
+ if (!editor.editing)
792
+ return;
793
+ const bounds = root.current?.getBoundingClientRect();
794
+ setSearch("");
795
+ setPicker({
796
+ point: flow.screenToFlowPosition(client),
797
+ screen: {
798
+ x: Math.min(client.x - (bounds?.left ?? 0), Math.max(8, (bounds?.width ?? 400) - 260)),
799
+ y: Math.min(client.y - (bounds?.top ?? 0), Math.max(8, (bounds?.height ?? 500) - 320)),
800
+ },
801
+ ...extra,
802
+ });
803
+ }
804
+ function chooseNode(kind) {
805
+ if (!picker)
806
+ return;
807
+ attempt(() => {
808
+ if (kind === 'spagbol.area') {
809
+ editor.addArea(picker.point);
810
+ setPicker(null);
811
+ return;
812
+ }
813
+ const definition = editor.nodes.get(kind);
814
+ if (picker.linkId) {
815
+ const prototype = editor.nodes.create(kind, picker.point);
816
+ const ports = portsFor(prototype, editor.nodes), input = ports.find((port) => port.direction === "input" && port.connectable !== false), output = ports.find((port) => port.direction === "output" && port.connectable !== false);
817
+ if (!input || !output)
818
+ throw new Error("Choose a node with an input and an output to insert it into a link.");
819
+ editor.insertNode(picker.linkId, kind, picker.point, input.id, output.id);
820
+ }
821
+ else {
822
+ editor.beginGesture(picker.origin ? "Create connected node" : "Create node");
823
+ try {
824
+ const id = editor.addNode(kind, picker.point);
825
+ if (picker.origin) {
826
+ const node = editor.graph.nodes.find((node) => node.id === id);
827
+ const opposite = picker.origin.direction === "output" ? "input" : "output";
828
+ const port = definition
829
+ .ports(node)
830
+ .find((port) => port.direction === opposite && port.connectable !== false);
831
+ if (!port)
832
+ throw new Error(`This node has no connectable ${opposite}.`);
833
+ if (opposite === "input")
834
+ editor.connect({ nodeId: picker.origin.nodeId, portId: picker.origin.portId }, { type: "port", nodeId: id, portId: port.id }, activeMode);
835
+ else
836
+ editor.connect({ nodeId: id, portId: port.id }, {
837
+ type: "port",
838
+ nodeId: picker.origin.nodeId,
839
+ portId: picker.origin.portId,
840
+ }, activeMode);
841
+ }
842
+ editor.commitGesture();
843
+ }
844
+ catch (cause) {
845
+ editor.cancelGesture();
846
+ throw cause;
847
+ }
848
+ }
849
+ setPicker(null);
850
+ });
851
+ }
852
+ function linkAt(point, source, mode, replacingId) {
853
+ if (!features.linkProperties ||
854
+ !(editor.policy.linkProperties ?? []).includes("weight"))
855
+ return;
856
+ let best;
857
+ for (const path of root.current?.querySelectorAll("[data-spagbol-link]") ?? []) {
858
+ const id = path.dataset.spagbolLink;
859
+ if (!id ||
860
+ editor.canConnect(source, { type: "link", linkId: id, property: "weight" }, mode, replacingId))
861
+ continue;
862
+ const matrix = path.getScreenCTM();
863
+ if (!matrix)
864
+ continue;
865
+ const local = new DOMPoint(point.x, point.y).matrixTransform(matrix.inverse());
866
+ const length = path.getTotalLength();
867
+ let distance = Infinity;
868
+ for (let i = 0; i <= 48; i++) {
869
+ const p = path.getPointAtLength((length * i) / 48);
870
+ distance = Math.min(distance, Math.hypot(p.x - local.x, p.y - local.y) * zoom);
871
+ }
872
+ if (distance < 14 && (!best || distance < best.distance))
873
+ best = { id, distance };
874
+ }
875
+ return best?.id;
876
+ }
877
+ async function paste() {
878
+ let fragment;
879
+ try {
880
+ const text = await navigator.clipboard?.readText();
881
+ if (text)
882
+ fragment = readFragment(text);
883
+ }
884
+ catch {
885
+ /* Fall back to this controller's clipboard. */
886
+ }
887
+ attempt(() => editor.paste(fragment));
888
+ }
889
+ function keyDown(event) {
890
+ const target = event.target;
891
+ if (target.closest('input,textarea,select,[contenteditable="true"],[role="spinbutton"]'))
892
+ return;
893
+ const command = event.metaKey || event.ctrlKey, key = event.key.toLowerCase();
894
+ if (key === "escape") {
895
+ if (duplicate.current)
896
+ cancelledDuplicate.current = true;
897
+ duplicate.current = null;
898
+ setDuplicatePreview(null);
899
+ editor.cancelGesture();
900
+ flowStore.getState().cancelConnection();
901
+ setPicker(null);
902
+ setDrawing(null);
903
+ setConnecting(false);
904
+ connection.current = null;
905
+ connectionDone.current = true;
906
+ setReconnectOriginal(null);
907
+ return;
908
+ }
909
+ if (command && key === "z") {
910
+ event.preventDefault();
911
+ event.shiftKey ? editor.redo() : editor.undo();
912
+ return;
913
+ }
914
+ if (command && key === "y") {
915
+ event.preventDefault();
916
+ editor.redo();
917
+ return;
918
+ }
919
+ if (!editor.editing)
920
+ return;
921
+ if (command && key === "c") {
922
+ event.preventDefault();
923
+ const fragment = editor.copy();
924
+ void navigator.clipboard
925
+ ?.writeText(JSON.stringify(fragment))
926
+ .catch(() => undefined);
927
+ return;
928
+ }
929
+ if (command && key === "v") {
930
+ event.preventDefault();
931
+ void paste();
932
+ return;
933
+ }
934
+ if (command && key === "d") {
935
+ event.preventDefault();
936
+ attempt(() => editor.duplicate());
937
+ return;
938
+ }
939
+ if (command && key === "a") {
940
+ event.preventDefault();
941
+ editor.select({ nodeIds: graph.nodes.filter(node => views[node.kind]?.selectable !== false).map((node) => node.id) });
942
+ return;
943
+ }
944
+ if (command && key === "0") {
945
+ event.preventDefault();
946
+ void flow.zoomTo(1);
947
+ return;
948
+ }
949
+ if (key === "delete" || key === "backspace") {
950
+ event.preventDefault();
951
+ editor.deleteSelection(command);
952
+ return;
953
+ }
954
+ if (!command && key === "x") {
955
+ editor.edit("Toggle enabled", "values", (graph) => {
956
+ const nodes = graph.nodes.filter((node) => snapshot.selection.nodeIds.includes(node.id));
957
+ if (nodes.length) {
958
+ const enabled = nodes.every((node) => node.enabled === false);
959
+ for (const node of nodes)
960
+ node.enabled = enabled;
961
+ }
962
+ else {
963
+ const links = graph.links.filter((link) => snapshot.selection.linkIds.includes(link.id));
964
+ const enabled = links.every((link) => link.enabled === false);
965
+ for (const link of links)
966
+ link.enabled = enabled;
967
+ }
968
+ });
969
+ return;
970
+ }
971
+ const mode = editor.modes
972
+ .list()
973
+ .find((mode) => mode.shortcut === key &&
974
+ (!editor.policy.modes || editor.policy.modes.includes(mode.id)));
975
+ if (mode && !command) {
976
+ setActiveMode(mode.id);
977
+ if (!connecting)
978
+ editor.edit("Change link mode", "values", (graph) => {
979
+ for (const link of graph.links)
980
+ if (snapshot.selection.linkIds.includes(link.id))
981
+ link.mode = mode.id;
982
+ });
983
+ }
984
+ }
985
+ function pointFromEvent(event) {
986
+ if ("clientX" in event)
987
+ return { x: event.clientX, y: event.clientY };
988
+ const touch = event.changedTouches[0];
989
+ return touch ? { x: touch.clientX, y: touch.clientY } : undefined;
990
+ }
991
+ 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: () => [] }] : [])]
992
+ .filter(definition => snapshot.path.length > 0 || ![spagbolGroupKinds.inputs, spagbolGroupKinds.outputs].includes(definition.kind))
993
+ .filter((definition) => `${definition.label} ${definition.category ?? ""} ${definition.keywords?.join(" ") ?? ""}`
994
+ .toLowerCase()
995
+ .includes(search.toLowerCase()));
996
+ return (_jsx(GraphContext.Provider, { value: context, children: _jsxs("div", { ref: root, className: `spagbol-canvas ${props.className ?? ""}`, style: {
997
+ ...props.style,
998
+ "--spagbol-title-scale": zoom < 0.3 ? 1.75 : zoom < 0.5 ? 1.3 : 1,
999
+ "--spagbol-canvas-height": `${editorSize.height}px`,
1000
+ }, tabIndex: 0, "aria-label": props.ariaLabel ?? "Graph editor", onKeyDown: keyDown, onDoubleClick: (event) => {
1001
+ if (event.target.classList.contains("react-flow__pane")) {
1002
+ event.preventDefault();
1003
+ openPicker({ x: event.clientX, y: event.clientY });
1004
+ }
1005
+ }, onPointerDownCapture: (event) => {
1006
+ const pane = event.target.classList.contains("react-flow__pane");
1007
+ if (pane)
1008
+ root.current?.focus({ preventScroll: true });
1009
+ if (pane &&
1010
+ features.areas &&
1011
+ editor.editing &&
1012
+ (event.metaKey || event.ctrlKey) &&
1013
+ event.button === 0) {
1014
+ event.preventDefault();
1015
+ event.stopPropagation();
1016
+ const point = flow.screenToFlowPosition({
1017
+ x: event.clientX,
1018
+ y: event.clientY,
1019
+ });
1020
+ setDrawing({ start: point, end: point, pointer: event.pointerId });
1021
+ event.currentTarget.setPointerCapture(event.pointerId);
1022
+ }
1023
+ }, onPointerMove: (event) => {
1024
+ if (drawing?.pointer === event.pointerId)
1025
+ setDrawing({
1026
+ ...drawing,
1027
+ end: flow.screenToFlowPosition({
1028
+ x: event.clientX,
1029
+ y: event.clientY,
1030
+ }),
1031
+ });
1032
+ }, onPointerUp: (event) => {
1033
+ if (drawing?.pointer !== event.pointerId)
1034
+ return;
1035
+ const position = {
1036
+ x: Math.min(drawing.start.x, drawing.end.x),
1037
+ y: Math.min(drawing.start.y, drawing.end.y),
1038
+ }, size = {
1039
+ width: Math.abs(drawing.start.x - drawing.end.x),
1040
+ height: Math.abs(drawing.start.y - drawing.end.y),
1041
+ };
1042
+ if (size.width >= 40 && size.height >= 40)
1043
+ editor.addArea(position, size);
1044
+ setDrawing(null);
1045
+ if (event.currentTarget.hasPointerCapture(event.pointerId))
1046
+ event.currentTarget.releasePointerCapture(event.pointerId);
1047
+ }, onPointerCancel: () => {
1048
+ if (duplicate.current)
1049
+ cancelledDuplicate.current = true;
1050
+ duplicate.current = null;
1051
+ setDuplicatePreview(null);
1052
+ setDrawing(null);
1053
+ editor.cancelGesture();
1054
+ }, children: [props.background && (_jsx("div", { className: "spagbol-canvas-background", children: props.background })), _jsx(SpagbolCanvasFlow, { nodes: displayNodes, onlyRenderVisibleElements: true, edges: displayLinks, nodeTypes: nodeTypes, edgeTypes: edgeTypes, onNodesChange: onNodesChange, onEdgesChange: onEdgesChange, onNodeMouseEnter: (_, node) => { if (connecting && node.data.node?.compact && !node.data.stationaryOriginal)
1055
+ setRevealedNodeId(node.data.node.id); }, onNodeMouseLeave: (_, node) => setRevealedNodeId(id => id === node.data.node?.id ? null : id), onNodeDoubleClick: (event, flowNode) => {
1056
+ const node = flowNode.data.node;
1057
+ if (node?.graph && features.nestedGraphs && !event.target.closest('.spagbol-node-frame-header')) {
1058
+ event.preventDefault();
1059
+ editor.enter(node.id);
1060
+ }
1061
+ }, onPaneClick: () => {
1062
+ editor.select({});
1063
+ setPicker(null);
1064
+ }, onNodeDragStart: (event, node) => {
1065
+ if (!node.data.node)
1066
+ return;
1067
+ cancelledDuplicate.current = false;
1068
+ editor.beginGesture(event.altKey ? "Duplicate nodes" : "Move nodes");
1069
+ if (event.altKey) {
1070
+ const selection = editor.getSnapshot().selection;
1071
+ const ids = selection.nodeIds.includes(node.data.node.id)
1072
+ ? selection.nodeIds
1073
+ : [node.data.node.id];
1074
+ duplicate.current = {
1075
+ namespace: createId("duplicate-preview"),
1076
+ fragment: copyFragment(graph, ids),
1077
+ origin: { ...node.position },
1078
+ nodeId: node.data.node.id,
1079
+ positions: new Map(graph.nodes
1080
+ .filter((node) => ids.includes(node.id))
1081
+ .map((node) => [node.id, { ...node.position }])),
1082
+ external: event.ctrlKey || event.metaKey || event.shiftKey,
1083
+ };
1084
+ setDuplicatePreview(duplicate.current);
1085
+ }
1086
+ }, onNodeDragStop: (_event, node) => {
1087
+ const current = duplicate.current;
1088
+ duplicate.current = null;
1089
+ setDuplicatePreview(null);
1090
+ if (cancelledDuplicate.current) {
1091
+ cancelledDuplicate.current = false;
1092
+ editor.cancelGesture();
1093
+ editor.select(editor.getSnapshot().selection);
1094
+ return;
1095
+ }
1096
+ if (current) {
1097
+ const position = current.positions.get(current.nodeId) ?? node.position;
1098
+ const delta = {
1099
+ x: position.x - current.origin.x,
1100
+ y: position.y - current.origin.y,
1101
+ };
1102
+ try {
1103
+ editor.paste(current.fragment, delta, current.external);
1104
+ }
1105
+ catch (cause) {
1106
+ editor.cancelGesture();
1107
+ setError(cause instanceof Error ? cause.message : String(cause));
1108
+ return;
1109
+ }
1110
+ }
1111
+ editor.commitGesture();
1112
+ }, onConnectStart: (_event, params) => {
1113
+ setRevealedNodeId(params.nodeId?.startsWith("node:") ? params.nodeId.slice(5) : null);
1114
+ connection.current = params;
1115
+ connectionDone.current = false;
1116
+ setConnecting(true);
1117
+ }, onConnect: (value) => {
1118
+ connectionDone.current = true;
1119
+ const next = modelConnection(value);
1120
+ if (next)
1121
+ attempt(() => {
1122
+ editor.connect(next.source, next.target, activeMode);
1123
+ });
1124
+ }, onConnectEnd: (event, state) => {
1125
+ const origin = connection.current;
1126
+ connection.current = null;
1127
+ setConnecting(false);
1128
+ if (connectionDone.current ||
1129
+ reconnecting.current ||
1130
+ !origin ||
1131
+ state.toHandle)
1132
+ return;
1133
+ const point = pointFromEvent(event), port = parseHandle(origin.handleId);
1134
+ if (!point || !port || !origin.nodeId?.startsWith("node:"))
1135
+ return;
1136
+ const source = { nodeId: origin.nodeId.slice(5), portId: port.id };
1137
+ const hit = port.direction === "output"
1138
+ ? linkAt(point, source, activeMode)
1139
+ : undefined;
1140
+ if (hit)
1141
+ attempt(() => {
1142
+ editor.connect(source, { type: "link", linkId: hit, property: "weight" }, activeMode);
1143
+ });
1144
+ else if ("metaKey" in event && (event.metaKey || event.ctrlKey))
1145
+ openPicker(point, {
1146
+ origin: { ...source, direction: port.direction },
1147
+ });
1148
+ }, reconnectRadius: 12, onReconnectStart: (event, edge) => {
1149
+ reconnecting.current = edge.id;
1150
+ reconnectDuplicate.current =
1151
+ event.ctrlKey || event.metaKey || event.altKey;
1152
+ setReconnectOriginal(reconnectDuplicate.current ? stationaryReconnectEdge(edge) : null);
1153
+ connectionDone.current = false;
1154
+ setConnecting(true);
1155
+ }, onReconnect: (edge, value) => {
1156
+ connectionDone.current = true;
1157
+ const next = modelConnection(value);
1158
+ if (next)
1159
+ attempt(() => {
1160
+ editor.reconnect(edge.id, next.source, next.target, reconnectDuplicate.current);
1161
+ });
1162
+ }, onReconnectEnd: (event, edge, handleType, state) => {
1163
+ if (!connectionDone.current &&
1164
+ !state.toHandle &&
1165
+ handleType === "source") {
1166
+ const point = pointFromEvent(event), link = edge.data?.link;
1167
+ if (point && link) {
1168
+ const hit = linkAt(point, link.source, link.mode, edge.id);
1169
+ if (hit)
1170
+ attempt(() => {
1171
+ editor.reconnect(edge.id, link.source, { type: "link", linkId: hit, property: "weight" }, event.ctrlKey || event.metaKey || event.altKey);
1172
+ });
1173
+ }
1174
+ }
1175
+ reconnecting.current = null;
1176
+ setReconnectOriginal(null);
1177
+ setConnecting(false);
1178
+ }, isValidConnection: (value) => {
1179
+ const next = modelConnection(value);
1180
+ const old = reconnecting.current &&
1181
+ graph.links.find((link) => link.id === reconnecting.current);
1182
+ return (!!next &&
1183
+ !editor.canConnect(next.source, next.target, old ? old.mode : activeMode, reconnecting.current ?? undefined));
1184
+ }, onEdgeDoubleClick: (event, edge) => {
1185
+ event.preventDefault();
1186
+ if (edge.data?.virtual)
1187
+ return;
1188
+ openPicker({ x: event.clientX, y: event.clientY }, { linkId: edge.id });
1189
+ }, connectionLineType: ConnectionLineType.Bezier, connectionLineStyle: {
1190
+ stroke: editor.modes.get(activeMode)?.color,
1191
+ strokeWidth: 2,
1192
+ }, defaultViewport: props.defaultViewport, onInit: instance => props.onInit?.(instance), onMoveStart: () => {
1193
+ viewportMoving.current = true;
1194
+ }, onMove: (_, viewport) => setZoom(viewport.zoom), onMoveEnd: (_, viewport) => {
1195
+ viewportMoving.current = false;
1196
+ setSettledViewport(viewport);
1197
+ props.onViewportChange?.(viewport);
1198
+ }, translateExtent: panExtent, fitView: !props.defaultViewport, fitViewOptions: { padding: 0.18 }, minZoom: 0.05, maxZoom: 3, panOnScroll: true, selectionOnDrag: true, selectionMode: SelectionMode.Partial, panOnDrag: [1, 2], zoomOnScroll: false, zoomOnPinch: true, zoomOnDoubleClick: false, deleteKeyCode: null, multiSelectionKeyCode: ["Meta", "Shift"], nodesDraggable: editor.editing, nodesConnectable: editor.editing, elementsSelectable: editor.editing, autoPanOnConnect: true, autoPanOnNodeDrag: true, proOptions: { hideAttribution: true }, children: drawing && (_jsx(ViewportPortal, { children: _jsx("div", { className: "spagbol-canvas-area-draft", style: {
1199
+ transform: `translate(${Math.min(drawing.start.x, drawing.end.x)}px, ${Math.min(drawing.start.y, drawing.end.y)}px)`,
1200
+ width: Math.abs(drawing.start.x - drawing.end.x),
1201
+ height: Math.abs(drawing.start.y - drawing.end.y),
1202
+ } }) })) }), _jsx("div", { ref: setLinkOverlay, className: "spagbol-canvas-link-overlay" }), (props.toolbar ?? true) === true ? (props.controls === false ? null : (_jsx(SpagbolCanvasControls, { ...props.controls }))) : (props.toolbar), picker && (_jsxs("div", { className: "spagbol-canvas-picker nodrag nopan", role: "dialog", "aria-label": "Add node", style: { left: picker.screen.x, top: picker.screen.y }, onPointerDown: (event) => event.stopPropagation(), children: [_jsxs("div", { className: "spagbol-canvas-picker-heading", children: [picker.linkId ? "insert a node" : "add a node", _jsx("button", { "aria-label": "Cancel node creation", onClick: () => setPicker(null), children: "\u00D7" })] }), _jsx("input", { autoFocus: true, "aria-label": "Search node types", placeholder: "Search nodes\u2026", value: search, onChange: (event) => setSearch(event.target.value), onKeyDown: (event) => {
1203
+ event.stopPropagation();
1204
+ if (event.key === "Escape")
1205
+ setPicker(null);
1206
+ if (event.key === "Enter" && availableNodes[0])
1207
+ chooseNode(availableNodes[0].kind);
1208
+ } }), _jsxs("div", { className: "spagbol-canvas-picker-list", children: [availableNodes.map((definition) => (_jsxs("button", { onClick: () => chooseNode(definition.kind), children: [_jsx("span", { children: definition.label }), _jsx("small", { children: definition.category })] }, definition.kind))), !availableNodes.length && _jsx("p", { children: "No matching nodes." })] })] })), error && (_jsxs("div", { className: "spagbol-canvas-error", role: "alert", children: [error, _jsx("button", { "aria-label": "Dismiss error", onClick: () => setError(null), children: "\u00D7" })] })), props.overlay && (_jsx("div", { className: "spagbol-canvas-overlay", children: props.overlay }))] }) }));
1209
+ }
1210
+ //# sourceMappingURL=SpagbolCanvas.js.map