@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.
- package/LICENSE +21 -0
- package/README.md +338 -2
- package/dist/controls.css +122 -0
- package/dist/core/controller.d.ts +89 -0
- package/dist/core/controller.d.ts.map +1 -0
- package/dist/core/controller.js +732 -0
- package/dist/core/controller.js.map +1 -0
- package/dist/core/graph.d.ts +30 -0
- package/dist/core/graph.d.ts.map +1 -0
- package/dist/core/graph.js +318 -0
- package/dist/core/graph.js.map +1 -0
- package/dist/core/grouping.d.ts +10 -0
- package/dist/core/grouping.d.ts.map +1 -0
- package/dist/core/grouping.js +130 -0
- package/dist/core/grouping.js.map +1 -0
- package/dist/core/groups.d.ts +40 -0
- package/dist/core/groups.d.ts.map +1 -0
- package/dist/core/groups.js +164 -0
- package/dist/core/groups.js.map +1 -0
- package/dist/core/index.d.ts +8 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +8 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/model.d.ts +182 -0
- package/dist/core/model.d.ts.map +1 -0
- package/dist/core/model.js +149 -0
- package/dist/core/model.js.map +1 -0
- package/dist/core/observations.d.ts +10 -0
- package/dist/core/observations.d.ts.map +1 -0
- package/dist/core/observations.js +36 -0
- package/dist/core/observations.js.map +1 -0
- package/dist/core/serialization.d.ts +9 -0
- package/dist/core/serialization.d.ts.map +1 -0
- package/dist/core/serialization.js +148 -0
- package/dist/core/serialization.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/link-controls.css +123 -0
- package/dist/react/Controls.d.ts +18 -0
- package/dist/react/Controls.d.ts.map +1 -0
- package/dist/react/Controls.js +301 -0
- package/dist/react/Controls.js.map +1 -0
- package/dist/react/SpagbolCanvas.d.ts +38 -0
- package/dist/react/SpagbolCanvas.d.ts.map +1 -0
- package/dist/react/SpagbolCanvas.js +1248 -0
- package/dist/react/SpagbolCanvas.js.map +1 -0
- package/dist/react/SpagbolCanvasControls.d.ts +36 -0
- package/dist/react/SpagbolCanvasControls.d.ts.map +1 -0
- package/dist/react/SpagbolCanvasControls.js +113 -0
- package/dist/react/SpagbolCanvasControls.js.map +1 -0
- package/dist/react/SpagbolCanvasFlow.d.ts +14 -0
- package/dist/react/SpagbolCanvasFlow.d.ts.map +1 -0
- package/dist/react/SpagbolCanvasFlow.js +6 -0
- package/dist/react/SpagbolCanvasFlow.js.map +1 -0
- package/dist/react/SpagbolCanvasLinkControls.d.ts +16 -0
- package/dist/react/SpagbolCanvasLinkControls.d.ts.map +1 -0
- package/dist/react/SpagbolCanvasLinkControls.js +7 -0
- package/dist/react/SpagbolCanvasLinkControls.js.map +1 -0
- package/dist/react/SpagbolCanvasMenu.d.ts +12 -0
- package/dist/react/SpagbolCanvasMenu.d.ts.map +1 -0
- package/dist/react/SpagbolCanvasMenu.js +65 -0
- package/dist/react/SpagbolCanvasMenu.js.map +1 -0
- package/dist/react/SpagbolControlNumber.d.ts +25 -0
- package/dist/react/SpagbolControlNumber.d.ts.map +1 -0
- package/dist/react/SpagbolControlNumber.js +209 -0
- package/dist/react/SpagbolControlNumber.js.map +1 -0
- package/dist/react/SpagbolControlNumberLink.d.ts +6 -0
- package/dist/react/SpagbolControlNumberLink.d.ts.map +1 -0
- package/dist/react/SpagbolControlNumberLink.js +159 -0
- package/dist/react/SpagbolControlNumberLink.js.map +1 -0
- package/dist/react/SpagbolControlZoom.d.ts +11 -0
- package/dist/react/SpagbolControlZoom.d.ts.map +1 -0
- package/dist/react/SpagbolControlZoom.js +24 -0
- package/dist/react/SpagbolControlZoom.js.map +1 -0
- package/dist/react/SpagbolNodeFrame.d.ts +35 -0
- package/dist/react/SpagbolNodeFrame.d.ts.map +1 -0
- package/dist/react/SpagbolNodeFrame.js +171 -0
- package/dist/react/SpagbolNodeFrame.js.map +1 -0
- package/dist/react/SpagbolNodeFrameTypePicker.d.ts +9 -0
- package/dist/react/SpagbolNodeFrameTypePicker.d.ts.map +1 -0
- package/dist/react/SpagbolNodeFrameTypePicker.js +68 -0
- package/dist/react/SpagbolNodeFrameTypePicker.js.map +1 -0
- package/dist/react/SpagbolNodeGroupPort.d.ts +15 -0
- package/dist/react/SpagbolNodeGroupPort.d.ts.map +1 -0
- package/dist/react/SpagbolNodeGroupPort.js +62 -0
- package/dist/react/SpagbolNodeGroupPort.js.map +1 -0
- package/dist/react/SpagbolNodeGroupPortAdd.d.ts +5 -0
- package/dist/react/SpagbolNodeGroupPortAdd.d.ts.map +1 -0
- package/dist/react/SpagbolNodeGroupPortAdd.js +27 -0
- package/dist/react/SpagbolNodeGroupPortAdd.js.map +1 -0
- package/dist/react/context.d.ts +84 -0
- package/dist/react/context.d.ts.map +1 -0
- package/dist/react/context.js +43 -0
- package/dist/react/context.js.map +1 -0
- package/dist/react/duplicateDrag.d.ts +19 -0
- package/dist/react/duplicateDrag.d.ts.map +1 -0
- package/dist/react/duplicateDrag.js +71 -0
- package/dist/react/duplicateDrag.js.map +1 -0
- package/dist/react/flowPresentation.d.ts +11 -0
- package/dist/react/flowPresentation.d.ts.map +1 -0
- package/dist/react/flowPresentation.js +50 -0
- package/dist/react/flowPresentation.js.map +1 -0
- package/dist/react/geometry.d.ts +13 -0
- package/dist/react/geometry.d.ts.map +1 -0
- package/dist/react/geometry.js +43 -0
- package/dist/react/geometry.js.map +1 -0
- package/dist/react/index.d.ts +11 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +11 -0
- package/dist/react/index.js.map +1 -0
- package/dist/react/nodeDrag.d.ts +7 -0
- package/dist/react/nodeDrag.d.ts.map +1 -0
- package/dist/react/nodeDrag.js +27 -0
- package/dist/react/nodeDrag.js.map +1 -0
- package/dist/react/portState.d.ts +10 -0
- package/dist/react/portState.d.ts.map +1 -0
- package/dist/react/portState.js +35 -0
- package/dist/react/portState.js.map +1 -0
- package/dist/react/viewport.d.ts +24 -0
- package/dist/react/viewport.d.ts.map +1 -0
- package/dist/react/viewport.js +55 -0
- package/dist/react/viewport.js.map +1 -0
- package/dist/styles.css +748 -0
- package/package.json +58 -4
- package/src/core/controller.ts +844 -0
- package/src/core/graph.ts +451 -0
- package/src/core/grouping.ts +159 -0
- package/src/core/groups.ts +125 -0
- package/src/core/index.ts +7 -0
- package/src/core/model.ts +304 -0
- package/src/core/observations.ts +31 -0
- package/src/core/serialization.ts +184 -0
- package/src/index.ts +9 -0
- package/src/react/Controls.tsx +355 -0
- package/src/react/SpagbolCanvas.tsx +1893 -0
- package/src/react/SpagbolCanvasControls.tsx +255 -0
- package/src/react/SpagbolCanvasFlow.tsx +38 -0
- package/src/react/SpagbolCanvasLinkControls.tsx +21 -0
- package/src/react/SpagbolCanvasMenu.tsx +121 -0
- package/src/react/SpagbolControlNumber.tsx +297 -0
- package/src/react/SpagbolControlNumberLink.tsx +209 -0
- package/src/react/SpagbolControlZoom.tsx +102 -0
- package/src/react/SpagbolNodeFrame.tsx +330 -0
- package/src/react/SpagbolNodeFrameTypePicker.tsx +51 -0
- package/src/react/SpagbolNodeGroupPort.tsx +111 -0
- package/src/react/SpagbolNodeGroupPortAdd.tsx +48 -0
- package/src/react/context.tsx +146 -0
- package/src/react/controls.css +122 -0
- package/src/react/duplicateDrag.ts +97 -0
- package/src/react/flowPresentation.ts +56 -0
- package/src/react/geometry.ts +53 -0
- package/src/react/index.ts +61 -0
- package/src/react/link-controls.css +123 -0
- package/src/react/nodeDrag.ts +33 -0
- package/src/react/portState.ts +24 -0
- package/src/react/styles.css +748 -0
- 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
|
+
|