@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,330 @@
1
+ import { graphPortStates, emptyPortState } from "./portState.js";
2
+ import { SpagbolNodeGroupPortAdd } from "./SpagbolNodeGroupPortAdd.js";
3
+ import { SpagbolNodeGroupPort } from "./SpagbolNodeGroupPort.js";
4
+ import {
5
+ Handle,
6
+ Position,
7
+ NodeResizer,
8
+ useUpdateNodeInternals,
9
+ useNodeId,
10
+ } from "@xyflow/react";
11
+ import { useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent, type CSSProperties, type Ref, type ReactNode } from "react";
12
+ import {
13
+ portsFor,
14
+ graphAt,
15
+ isGroupBoundary,
16
+ spagbolGroupKinds,
17
+ spagbolNewPortId,
18
+ groupPanelLayout,
19
+ type GraphNode,
20
+ type PortDefinition,
21
+ } from "../core/index.js";
22
+ import {
23
+ GraphContext,
24
+ useEditorSelector,
25
+ useSpagbol,
26
+ type SpagbolNodeViewProps,
27
+ } from "./context.js";
28
+ import { SpagbolControlNumber } from "./SpagbolControlNumber.js";
29
+ import { SpagbolNodeFrameTypePicker } from './SpagbolNodeFrameTypePicker.js';
30
+ import { nodeFlowId, sourceHandle, targetHandle } from "./geometry.js";
31
+
32
+ export interface SpagbolNodePortProps {
33
+ port: PortDefinition;
34
+ connectable?: boolean;
35
+ style?: CSSProperties;
36
+ position?: Position;
37
+ className?: string;
38
+ }
39
+ /** Also usable in a completely custom node renderer. */
40
+ export function SpagbolNodePort({ port, connectable = true, style, position, className }: SpagbolNodePortProps) {
41
+ const { editor } = useSpagbol();
42
+ const mode = useEditorSelector(editor, snapshot => snapshot.mode);
43
+ const flowId = useNodeId();
44
+ const nodeId = flowId?.startsWith('node:') ? flowId.slice(5) : undefined;
45
+ const reconnecting = useEditorSelector(editor, snapshot => {
46
+ if (snapshot.mode !== 'edit' || snapshot.selection.linkIds.length !== 1) return false;
47
+ const link = graphAt(snapshot.document, snapshot.path).links.find(link => link.id === snapshot.selection.linkIds[0]);
48
+ return !!link && (port.direction === 'output'
49
+ ? link.source.nodeId === nodeId && link.source.portId === port.id
50
+ : link.target.type === 'port' && link.target.nodeId === nodeId && link.target.portId === port.id);
51
+ });
52
+ const direction = port.direction;
53
+ return (
54
+ <Handle
55
+ id={direction === "input" ? targetHandle(port.id) : sourceHandle(port.id)}
56
+ type={direction === "input" ? "target" : "source"}
57
+ position={position ?? (direction === "input" ? Position.Left : Position.Right)}
58
+ className={`spagbol-node-port${reconnecting ? ' spagbol-node-port-selected-link' : ''} ${className ?? ""}`}
59
+ isConnectable={
60
+ mode === "edit" && port.connectable !== false && connectable
61
+ }
62
+ style={style}
63
+ aria-label={`${port.label} ${direction}`}
64
+ />
65
+ );
66
+ }
67
+ export interface SpagbolNodeFrameProps extends SpagbolNodeViewProps {
68
+ children?: ReactNode;
69
+ overlay?: ReactNode;
70
+ rootRef?: Ref<HTMLDivElement>;
71
+ title?: ReactNode;
72
+ className?: string;
73
+ hidePorts?: boolean;
74
+ headerOutput?: boolean;
75
+ portVisible?(port: PortDefinition): boolean;
76
+ style?: CSSProperties;
77
+ /** Domain widgets inside a shared port row. The canvas owns handles and gestures. */
78
+ renderPortContent?(port: PortDefinition): ReactNode;
79
+ portExtras?: ReactNode;
80
+ portsHeader?: ReactNode;
81
+ forceCompact?: boolean;
82
+ }
83
+ export function SpagbolNodeFrame(props: SpagbolNodeFrameProps) {
84
+ const { node, selected, projected, children } = props;
85
+ const { editor, views, features, connecting, diagnostics } = useSpagbol();
86
+ const [pointerOver, setPointerOver] = useState(false);
87
+ const updateInternals = useUpdateNodeInternals();
88
+ const flowId = useNodeId();
89
+ const singleSelected = useEditorSelector(editor, snapshot => snapshot.selection.nodeIds.length === 1 && snapshot.selection.nodeIds[0] === node.id);
90
+ const selectedPortId = useEditorSelector(editor, snapshot => snapshot.selection.port?.nodeId === node.id ? snapshot.selection.port.portId : undefined);
91
+ const mode = useEditorSelector(editor, snapshot => snapshot.mode);
92
+ const path = useEditorSelector(editor, snapshot => snapshot.path);
93
+ const boundary = isGroupBoundary(node);
94
+ const reorder = useRef<{ pointer: number; portId: string; element: HTMLElement } | null>(null);
95
+ const ports = portsFor(node, editor.nodes);
96
+ const inputPorts = ports.filter((port) => port.direction === "input" && props.portVisible?.(port) !== false);
97
+ const outputPorts = ports.filter((port) => port.direction === "output" && props.portVisible?.(port) !== false);
98
+ const compact = !projected && views[node.kind]?.compact !== false && features.compact && (props.forceCompact || node.compact) && !(selected && singleSelected) && !(connecting && pointerOver);
99
+ const headerInput = !boundary ? inputPorts.find(port => port.label === 'signal') : undefined;
100
+ const headerOutput = !boundary && props.headerOutput !== false ? outputPorts.find(port => port.label === 'signal') ?? (outputPorts.length === 1 ? outputPorts[0] : undefined) : undefined;
101
+ const portState = useEditorSelector(editor, snapshot => graphPortStates(graphAt(snapshot.document, snapshot.path)).get(node.id) ?? emptyPortState);
102
+ const connectedInputs = portState.inputs;
103
+ const connectedOutputs = portState.outputs;
104
+ const visibleInputs = inputPorts.filter(port => port !== headerInput && (!compact || connectedInputs.has(port.id)));
105
+ const visibleOutputs = outputPorts.filter(port => port !== headerOutput && (!compact || connectedOutputs.has(port.id)));
106
+ const definition = editor.nodes.get(node.kind);
107
+ const view = views[node.kind];
108
+ const newPortDirection = boundary ? (node.kind === spagbolGroupKinds.inputs ? "output" : "input") : definition?.editablePorts;
109
+ const newPortLabel = definition?.newPortLabel ?? (node.kind === spagbolGroupKinds.inputs ? "new input" : "new output");
110
+ const errors = diagnostics.filter((item) => item.nodeId === node.id);
111
+ const portKey = ports
112
+ .map((port) => `${port.direction}:${port.id}:${port.label}`)
113
+ .join("|");
114
+ useEffect(() => {
115
+ if (projected || !flowId) return;
116
+ const frame = requestAnimationFrame(() => updateInternals(flowId));
117
+ return () => cancelAnimationFrame(frame);
118
+ }, [flowId, portKey, compact, projected, updateInternals]);
119
+ const setInput = portState.replaced;
120
+ function movePort(event: ReactPointerEvent<HTMLDivElement>) {
121
+ const drag = reorder.current;
122
+ if (!drag || drag.pointer !== event.pointerId) return;
123
+ event.preventDefault(); event.stopPropagation();
124
+ const rows = Array.from(event.currentTarget.querySelectorAll<HTMLElement>('[data-group-port]'));
125
+ const target = rows.findIndex(row => event.clientY < row.getBoundingClientRect().bottom);
126
+ editor.moveBoundaryPort(node.id, drag.portId, target < 0 ? rows.length - 1 : target);
127
+ }
128
+ function endPort(event: ReactPointerEvent<HTMLDivElement>, cancel = false) {
129
+ const drag = reorder.current;
130
+ if (!drag || drag.pointer !== event.pointerId) return;
131
+ event.stopPropagation(); reorder.current = null;
132
+ if (drag.element.hasPointerCapture(event.pointerId)) drag.element.releasePointerCapture(event.pointerId);
133
+ if (cancel) editor.cancelGesture(); else editor.commitGesture();
134
+ }
135
+ function row(port: PortDefinition, index: number) {
136
+ const input = port.direction === "input";
137
+ return (
138
+ <div
139
+ className={`spagbol-node-port-row spagbol-node-port-row-${port.direction}`}
140
+ key={`${port.direction}:${port.id}`}
141
+ data-group-port={boundary ? port.id : undefined}
142
+ style={{ gridColumn: boundary || input || inputPorts.length === 0 ? 1 : 2, gridRow: index + 1 + (props.portsHeader ? 1 : 0) }}
143
+ >
144
+ {!projected && !props.hidePorts && <SpagbolNodePort port={port} />}
145
+ {props.renderPortContent ? props.renderPortContent(port) : <>{boundary && !projected ? <SpagbolNodeGroupPort name={port.label} editable={editor.editing && !props.readOnly} draggable={editor.editing && !props.readOnly} selected={selectedPortId === port.id} onChange={label => { try { editor.renameBoundaryPort(node.id, port.id, label); } catch { /* Keep the valid label. */ } }} onPointerDown={event => {
146
+ if (!editor.editing || props.readOnly || event.button !== 0) return;
147
+ event.preventDefault(); event.stopPropagation();
148
+ editor.select({ nodeIds: [node.id], port: { nodeId: node.id, portId: port.id, direction: port.direction } });
149
+ editor.beginGesture('Reorder group ports');
150
+ reorder.current = { pointer: event.pointerId, portId: port.id, element: event.currentTarget };
151
+ event.currentTarget.setPointerCapture(event.pointerId);
152
+ }} /> : <span className="spagbol-node-port-name" title={port.label}>{port.label}</span>}
153
+ {((input && node.kind !== spagbolGroupKinds.outputs) || node.kind === spagbolGroupKinds.inputs) && port.valueEditor !== false && (
154
+ <SpagbolControlNumber
155
+ value={node.values[port.id] ?? port.defaultValue ?? 0}
156
+ onChange={(value) => props.setValue(port.id, value)}
157
+ label={`${node.label ?? definition?.label ?? node.kind}: ${port.label}`}
158
+ min={port.min}
159
+ max={port.max}
160
+ step={port.step}
161
+ integer={port.integer}
162
+ disabled={props.readOnly}
163
+ replaced={setInput.has(port.id)}
164
+ onGestureStart={() => props.beginGesture(`Change ${port.label}`)}
165
+ onGestureEnd={(cancel) => props.endGesture(cancel)}
166
+ />
167
+ )}</>}
168
+ </div>
169
+ );
170
+ }
171
+ return (
172
+ <div
173
+ onPointerEnter={() => setPointerOver(true)} onPointerLeave={() => setPointerOver(false)}
174
+ ref={props.rootRef}
175
+ style={{ ...props.style, ...(node.scale ? { zoom: node.scale, width: view?.autoWidth ? props.style?.width ?? "max-content" : `${100 / node.scale}%`, height: `${100 / node.scale}%` } : view?.autoWidth ? { width: props.style?.width ?? "max-content" } : {}) }}
176
+ onPointerMove={movePort} onPointerUp={event => endPort(event)} onPointerCancel={event => endPort(event, true)}
177
+ className={`spagbol-node-frame${selected ? " spagbol-node-frame-selected" : ""}${node.enabled === false ? " spagbol-node-frame-disabled" : ""}${compact ? " spagbol-node-frame-compact" : ""}${props.forceCompact ? " spagbol-node-frame-auto-compact" : ""}${projected ? " spagbol-node-frame-projected nodrag nopan" : ""} ${props.className ?? ""}`}
178
+ >
179
+ {!projected && view?.resizable && editor.editing && (
180
+ <NodeResizer
181
+ isVisible={selected && !compact}
182
+ minWidth={view.minWidth ?? 180}
183
+ minHeight={view.minHeight ?? 80}
184
+ onResizeStart={() => props.beginGesture("Resize node")}
185
+ onResize={(_, size) =>
186
+ editor.updateNode(
187
+ node.id,
188
+ {
189
+ size: { width: size.width / (node.scale ?? 1), height: size.height / (node.scale ?? 1) },
190
+ position: { x: size.x, y: size.y },
191
+ },
192
+ "layout",
193
+ )
194
+ }
195
+ onResizeEnd={() => props.endGesture()}
196
+ />
197
+ )}
198
+ <div
199
+ style={{ position: "relative" }}
200
+ className="spagbol-node-frame-header spagbol-canvas-drag-handle"
201
+ onDoubleClickCapture={event => { if (projected && props.resetOverride) { event.preventDefault(); event.stopPropagation(); props.resetOverride(); } }}
202
+ >
203
+ {!projected && !props.hidePorts && headerInput && <SpagbolNodePort port={headerInput} style={{ top: "50%" }} />}
204
+ {!projected && !props.hidePorts && headerOutput && <SpagbolNodePort port={headerOutput} style={{ top: "50%" }} />}
205
+ <span className="spagbol-node-frame-title">
206
+ {props.title ?? <SpagbolNodeFrameTypePicker node={node} compact={!!compact} selected={selected} disabled={projected || props.readOnly || !editor.editing} />}
207
+ </span>
208
+ {!projected && <button className="spagbol-node-frame-enabled" type="button" aria-label={node.enabled === false ? 'Enable node' : 'Disable node'} title={node.enabled === false ? 'Enable node' : 'Disable node'} aria-pressed={node.enabled !== false} disabled={!editor.editing || props.readOnly} onPointerDown={event => event.stopPropagation()} onDoubleClick={event => event.stopPropagation()} onClick={event => { event.preventDefault(); event.stopPropagation(); editor.updateNode(node.id, { enabled: node.enabled === false }, 'structure'); }}><span className="spagbol-node-frame-enabled-icon" aria-hidden="true" /></button>}
209
+ {!projected && features.compact && !props.forceCompact && view?.compact !== false && (
210
+ <button
211
+ className="spagbol-node-frame-header-button nodrag nopan"
212
+ aria-label={node.compact ? "Expand ports" : "Compact ports"}
213
+ title={node.compact ? "Expand ports" : "Compact ports"}
214
+ disabled={!editor.editing}
215
+ onClick={(event) => {
216
+ event.stopPropagation();
217
+ editor.updateNode(node.id, { compact: !node.compact }, "layout");
218
+ }}
219
+ >
220
+ {node.compact ? "+" : "−"}
221
+ </button>
222
+ )}
223
+ {!projected && node.graph && features.nestedGraphs && (
224
+ <button
225
+ className="spagbol-node-frame-header-button nodrag nopan"
226
+ aria-label="Enter nested graph"
227
+ title="Enter nested graph"
228
+ onClick={(event) => {
229
+ event.stopPropagation();
230
+ editor.enter(node.id);
231
+ }}
232
+ >
233
+ ↳
234
+ </button>
235
+ )}
236
+ </div>
237
+ <>
238
+ {children && (
239
+ <div
240
+ className="spagbol-node-frame-body nodrag nopan"
241
+ onPointerDown={(event) => event.stopPropagation()}
242
+ >
243
+ {children}
244
+ </div>
245
+ )}
246
+ {!props.hidePorts && (visibleInputs.length > 0 || visibleOutputs.length > 0 || newPortDirection) && (
247
+ <div style={inputPorts.length === 0 || outputPorts.length === 0 ? { gridTemplateColumns: "minmax(0, 1fr)" } : undefined} className={`spagbol-node-frame-ports${boundary ? " spagbol-node-frame-ports-boundary" : ""}`}>
248
+ {props.portsHeader}
249
+ {visibleInputs.map(row)}
250
+ {visibleOutputs.map(row)}
251
+ {newPortDirection && !projected && editor.editing && <div style={{ gridColumn: newPortDirection === "input" || inputPorts.length === 0 ? 1 : 2, gridRow: ports.length + 1 + (props.portsHeader ? 1 : 0), position: "relative" }}><SpagbolNodePort port={{ id: spagbolNewPortId, label: newPortLabel, direction: newPortDirection }} style={{ top: "50%" }} /><SpagbolNodeGroupPortAdd label={newPortLabel} onAdd={label => { try { editor.addBoundaryPort(node.id, label); } catch { /* Keep the valid boundary. */ } }} /></div>}
252
+ </div>
253
+ )}
254
+ {!compact && props.portExtras}
255
+ {errors.length > 0 && (
256
+ <div
257
+ className="spagbol-node-frame-diagnostics"
258
+ role="status"
259
+ title={errors.map((error) => error.message).join("\n")}
260
+ >
261
+ {errors[0]?.message}
262
+ </div>
263
+ )}
264
+ </>
265
+ {props.overlay}
266
+ </div>
267
+ );
268
+ }
269
+
270
+ export function useNodeViewProps(
271
+ node: GraphNode,
272
+ selected = false,
273
+ projected = false,
274
+ groupId?: string,
275
+ ): SpagbolNodeViewProps {
276
+ const { editor, projection } = useSpagbol();
277
+ const owner = projection?.groupId ?? groupId;
278
+ const controlPath = projection?.path ?? [node.id];
279
+ const mode = useEditorSelector(editor, snapshot => snapshot.mode);
280
+ const path = useEditorSelector(editor, snapshot => snapshot.path);
281
+ return useMemo(
282
+ () => ({
283
+ node,
284
+ selected,
285
+ projected,
286
+ path: projection ? [...path, projection.groupId, ...projection.path.slice(0, -1)] : path,
287
+ resetOverride: owner ? () => editor.resetOverride(owner, controlPath) : undefined,
288
+ readOnly: mode === "readonly",
289
+ setValue: (portId: string, value: number) =>
290
+ owner
291
+ ? editor.setOverride(owner, controlPath, portId, value)
292
+ : editor.setValue(node.id, portId, value),
293
+ patchData: (data: Partial<GraphNode["data"]>, structural = false) => {
294
+ if (projected) { if (!structural && owner) editor.setDataOverride(owner, controlPath, data); return; }
295
+ editor.updateNode(
296
+ node.id,
297
+ { data: { ...node.data, ...data } as GraphNode["data"] },
298
+ structural ? "structure" : "values",
299
+ );
300
+ },
301
+ beginGesture: (label = "Edit control") => {
302
+ if (!editor.getSnapshot().gesture) editor.beginGesture(label);
303
+ },
304
+ endGesture: (cancel = false) =>
305
+ cancel ? editor.cancelGesture() : editor.commitGesture(),
306
+ }),
307
+ [editor, owner, projection, node, projected, selected, mode, path],
308
+ );
309
+ }
310
+
311
+ export function SpagbolNodePanel({ group }: { group: GraphNode }) {
312
+ const context = useSpagbol();
313
+ const layout = groupPanelLayout(group);
314
+ if (!layout) return null;
315
+ return <div className="spagbol-node-panel nodrag nopan" style={{ position: 'relative', width: layout.size.width, height: layout.size.height }} aria-label="Instance controls" onPointerDown={event => event.stopPropagation()}>
316
+ {layout.nodes.map(node => <GraphContext.Provider key={node.id} value={{ ...context, projection: { groupId: context.projection?.groupId ?? group.id, path: [...(context.projection?.path ?? []), node.id] } }}>
317
+ <div className="spagbol-node-panel-item" style={{ position: 'absolute', left: node.position.x - layout.position.x, top: node.position.y - layout.position.y, width: context.views[node.kind]?.autoWidth ? "max-content" : (node.size?.width ?? 220) * (node.scale ?? 1) }}>
318
+ <SpagbolNodePanelItem node={node} />
319
+ </div>
320
+ </GraphContext.Provider>)}
321
+ </div>;
322
+ }
323
+ function SpagbolNodePanelItem({ node }: { node: GraphNode }) {
324
+ const { views } = useSpagbol();
325
+ const props = useNodeViewProps(node, false, true);
326
+ const Renderer = views[node.kind]?.projected ?? views[node.kind]?.render;
327
+ if (Renderer) return <Renderer {...props} />;
328
+ const Body = views[node.kind]?.body;
329
+ return <SpagbolNodeFrame {...props} hidePorts={!!Body}>{Body ? <Body {...props} /> : null}</SpagbolNodeFrame>;
330
+ }
@@ -0,0 +1,51 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'react';
2
+ import type { GraphNode } from '../core/index.js';
3
+ import { useSpagbol } from './context.js';
4
+
5
+ /** Teia's inline type-editing interaction, backed by the host's node catalogue. */
6
+ export function SpagbolNodeFrameTypePicker({ node, compact, selected, disabled }: { node: GraphNode; compact: boolean; selected: boolean; disabled: boolean }) {
7
+ const { editor, nodeKinds, features, changeNodeKind } = useSpagbol();
8
+ const typeLabel = node.label ?? editor.nodes.get(node.kind)?.label ?? node.kind;
9
+ const label = compact && node.compactLabel?.trim() ? node.compactLabel : typeLabel;
10
+ const press = useRef({ time: 0, compact: false });
11
+ const [editingLabel, setEditingLabel] = useState(false);
12
+ const [open, setOpen] = useState(false);
13
+ const [query, setQuery] = useState(label);
14
+ const [highlight, setHighlight] = useState(0);
15
+ const input = useRef<HTMLInputElement>(null);
16
+ const menu = useRef<HTMLDivElement>(null);
17
+ const options = useMemo(() => [...editor.nodes.list().filter(item => !nodeKinds || nodeKinds.includes(item.kind)), ...(features.areas ? [{ kind: 'spagbol.area', label: 'Area' }] : [])].filter(item =>
18
+ `${item.kind} ${item.label}`.toLowerCase().includes(query.trim().toLowerCase())
19
+ ).sort((a, b) => Number(b.label.toLowerCase().startsWith(query.toLowerCase())) - Number(a.label.toLowerCase().startsWith(query.toLowerCase()))), [editor, query, nodeKinds, features.areas]);
20
+ useEffect(() => { if (!selected || disabled) setOpen(false); }, [selected, disabled]);
21
+ useEffect(() => { if (open) { input.current?.focus({ preventScroll: true }); input.current?.select(); } }, [open]);
22
+ useEffect(() => { menu.current?.querySelector('[aria-selected="true"]')?.scrollIntoView({ block: 'nearest' }); }, [highlight]);
23
+ function commitLabel() { editor.updateNode(node.id, { compactLabel: query.trim() }, "metadata"); setOpen(false); }
24
+ function choose(kind: string) { if (kind === 'spagbol.area') editor.convertNodeToArea(node.id); else if (changeNodeKind) void changeNodeKind(node.id, kind); else editor.changeNodeKind(node.id, kind); setOpen(false); }
25
+ return <span className="spagbol-node-frame-type-picker">
26
+ {!open ? <button type="button" className="spagbol-node-frame-type-picker-button" onPointerDown={() => { const now = performance.now(); if (now - press.current.time > 500) press.current = { time: now, compact }; }} onDoubleClick={event => {
27
+ event.preventDefault(); event.stopPropagation();
28
+ if (disabled || !selected) return;
29
+ const rename = press.current.compact; setEditingLabel(rename);
30
+ setQuery(rename ? node.compactLabel ?? "" : typeLabel); setHighlight(0); setOpen(true);
31
+ }}>{label}</button> : <>
32
+ <span className="spagbol-node-frame-type-picker-placeholder" aria-hidden="true">{label}</span>
33
+ <div className="spagbol-node-frame-type-picker-editor nodrag nopan nowheel" onMouseDown={event => event.stopPropagation()}>
34
+ <input ref={input} className="spagbol-node-frame-type-picker-input" aria-label={editingLabel ? "Node label" : "Node type"} role="combobox" aria-expanded="true" aria-controls={`node-type-${node.id}`} aria-autocomplete="list" value={query} spellCheck={false}
35
+ onChange={event => { setQuery(event.target.value); setHighlight(0); }} onBlur={() => editingLabel ? commitLabel() : setOpen(false)}
36
+ onPointerDown={event => event.stopPropagation()} onDoubleClick={event => event.stopPropagation()}
37
+ onKeyDown={event => {
38
+ event.stopPropagation();
39
+ if (event.key === 'ArrowDown') { event.preventDefault(); setHighlight(i => Math.min(i + 1, Math.max(0, options.length - 1))); }
40
+ if (event.key === 'ArrowUp') { event.preventDefault(); setHighlight(i => Math.max(0, i - 1)); }
41
+ if (event.key === 'Enter') { event.preventDefault(); if (editingLabel) { commitLabel(); return; } const item = options[highlight] ?? options[0]; if (item) choose(item.kind); }
42
+ if (event.key === 'Escape') { event.preventDefault(); setOpen(false); }
43
+ }} />
44
+ {!editingLabel && <div ref={menu} className="spagbol-node-frame-type-picker-menu nowheel" id={`node-type-${node.id}`} role="listbox" onMouseDown={event => { event.preventDefault(); event.stopPropagation(); }} onWheel={event => event.stopPropagation()}>
45
+ {options.map((item, index) => <button key={item.kind} type="button" role="option" aria-selected={index === highlight} className={index === highlight ? 'active' : ''} onMouseDown={event => { event.preventDefault(); event.stopPropagation(); choose(item.kind); }}>{item.label}</button>)}
46
+ {!options.length && <div className="spagbol-node-frame-type-picker-empty">no match</div>}
47
+ </div>}
48
+ </div>
49
+ </>}
50
+ </span>;
51
+ }
@@ -0,0 +1,111 @@
1
+ import { useEffect, useRef, useState, type PointerEvent } from "react";
2
+ interface SpagbolNodeGroupPortProps {
3
+ name: string;
4
+ editable: boolean;
5
+ draggable?: boolean;
6
+ selected?: boolean;
7
+ preview?: boolean;
8
+ activeDragTarget?: boolean;
9
+ activeDragSource?: boolean;
10
+ onPointerDown?: (event: PointerEvent<HTMLSpanElement>) => void;
11
+ onChange: (nextName: string) => void;
12
+ }
13
+
14
+ export function SpagbolNodeGroupPort({
15
+ name,
16
+ editable,
17
+ draggable = false,
18
+ selected = false,
19
+ preview = false,
20
+ activeDragTarget = false,
21
+ activeDragSource = false,
22
+ onPointerDown,
23
+ onChange,
24
+ }: SpagbolNodeGroupPortProps) {
25
+ const [editing, setEditing] = useState(false);
26
+ const [draft, setDraft] = useState(name);
27
+ const inputRef = useRef<HTMLInputElement | null>(null);
28
+
29
+ useEffect(() => {
30
+ if (!editing) {
31
+ setDraft(name);
32
+ }
33
+ }, [editing, name]);
34
+
35
+ useEffect(() => {
36
+ if (!editing) return;
37
+
38
+ const animationFrame = requestAnimationFrame(() => {
39
+ inputRef.current?.focus();
40
+ inputRef.current?.select();
41
+ });
42
+ return () => cancelAnimationFrame(animationFrame);
43
+ }, [editing]);
44
+
45
+ function commitDraft() {
46
+ onChange(draft);
47
+ setEditing(false);
48
+ }
49
+
50
+ function cancelDraft() {
51
+ setDraft(name);
52
+ setEditing(false);
53
+ }
54
+
55
+ if (editing) {
56
+ return (
57
+ <input
58
+ ref={inputRef}
59
+ className="spagbol-node-group-port-editor nodrag nopan"
60
+ value={draft}
61
+ onChange={(event) => setDraft(event.currentTarget.value)}
62
+ onBlur={commitDraft}
63
+ onKeyDown={(event) => {
64
+ event.stopPropagation();
65
+ if (event.key === 'Enter') {
66
+ commitDraft();
67
+ }
68
+ if (event.key === 'Escape') {
69
+ cancelDraft();
70
+ }
71
+ }}
72
+ onPointerDown={(event) => event.stopPropagation()}
73
+ onMouseDown={(event) => event.stopPropagation()}
74
+ onDoubleClick={(event) => event.stopPropagation()}
75
+ spellCheck={false}
76
+ />
77
+ );
78
+ }
79
+
80
+ return (
81
+ <span
82
+ className={[
83
+ 'spagbol-node-group-port',
84
+ editable ? 'spagbol-node-group-port-editable' : '',
85
+ draggable ? 'spagbol-node-group-port-draggable nodrag nopan' : '',
86
+ selected ? 'spagbol-node-group-port-selected' : '',
87
+ preview ? 'spagbol-node-group-port-preview' : '',
88
+ activeDragTarget ? 'spagbol-node-group-port-drag-target' : '',
89
+ activeDragSource ? 'spagbol-node-group-port-drag-source' : '',
90
+ ].filter(Boolean).join(' ')}
91
+ draggable={draggable}
92
+ title={preview
93
+ ? 'Drop a connection here to create this port'
94
+ : editable
95
+ ? 'Drag to reorder. Double-click to rename'
96
+ : undefined}
97
+ onPointerDown={(event) => {
98
+ onPointerDown?.(event);
99
+ }}
100
+ onDoubleClick={(event) => {
101
+ if (!editable) return;
102
+ event.preventDefault();
103
+ event.stopPropagation();
104
+ setEditing(true);
105
+ }}
106
+ >
107
+ {name}
108
+ </span>
109
+ );
110
+ }
111
+
@@ -0,0 +1,48 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ export function SpagbolNodeGroupPortAdd({ label, onAdd }: { label: string; onAdd: (name: string) => void }) {
3
+ const [editing, setEditing] = useState(false);
4
+ const [draft, setDraft] = useState('');
5
+ const inputRef = useRef<HTMLInputElement | null>(null);
6
+
7
+ useEffect(() => {
8
+ if (editing) inputRef.current?.focus();
9
+ }, [editing]);
10
+
11
+ function finish() {
12
+ if (draft.trim()) onAdd(draft);
13
+ setDraft('');
14
+ setEditing(false);
15
+ }
16
+
17
+ return (
18
+ <div className="spagbol-node-group-port-add nodrag nopan">
19
+ {editing ? (
20
+ <input
21
+ ref={inputRef}
22
+ className="spagbol-node-group-port-add-editor nodrag nopan"
23
+ aria-label={label}
24
+ value={draft}
25
+ onChange={(event) => setDraft(event.currentTarget.value)}
26
+ onBlur={finish}
27
+ onKeyDown={(event) => {
28
+ event.stopPropagation();
29
+ if (event.key === 'Enter') finish();
30
+ if (event.key === 'Escape') { setDraft(''); setEditing(false); }
31
+ }}
32
+ onPointerDown={(event) => event.stopPropagation()}
33
+ onDoubleClick={(event) => event.stopPropagation()}
34
+ spellCheck={false}
35
+ />
36
+ ) : (
37
+ <button
38
+ type="button"
39
+ className="spagbol-node-group-port-add-placeholder nodrag nopan"
40
+ onPointerDown={(event) => event.stopPropagation()}
41
+ onClick={(event) => { event.stopPropagation(); setEditing(true); }}
42
+ onDoubleClick={(event) => event.stopPropagation()}
43
+ >{label}</button>
44
+ )}
45
+ </div>
46
+ );
47
+ }
48
+