@luciole-sh/flow-graph 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.
- package/LICENSE +21 -0
- package/README.md +671 -2
- package/dist/Flow.d.ts +41 -0
- package/dist/Flow.d.ts.map +1 -0
- package/dist/Flow.js +489 -0
- package/dist/Flow.js.map +1 -0
- package/dist/components.d.ts +28 -0
- package/dist/components.d.ts.map +1 -0
- package/dist/components.js +100 -0
- package/dist/components.js.map +1 -0
- package/dist/frame.d.ts +68 -0
- package/dist/frame.d.ts.map +1 -0
- package/dist/frame.js +136 -0
- package/dist/frame.js.map +1 -0
- package/dist/geometry.d.ts +60 -0
- package/dist/geometry.d.ts.map +1 -0
- package/dist/geometry.js +205 -0
- package/dist/geometry.js.map +1 -0
- package/dist/hooks.d.ts +56 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +65 -0
- package/dist/hooks.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/keymap.d.ts +11 -0
- package/dist/keymap.d.ts.map +1 -0
- package/dist/keymap.js +42 -0
- package/dist/keymap.js.map +1 -0
- package/dist/navigation.d.ts +32 -0
- package/dist/navigation.d.ts.map +1 -0
- package/dist/navigation.js +68 -0
- package/dist/navigation.js.map +1 -0
- package/dist/nodes.d.ts +18 -0
- package/dist/nodes.d.ts.map +1 -0
- package/dist/nodes.js +46 -0
- package/dist/nodes.js.map +1 -0
- package/dist/raster.d.ts +112 -0
- package/dist/raster.d.ts.map +1 -0
- package/dist/raster.js +370 -0
- package/dist/raster.js.map +1 -0
- package/dist/store.d.ts +104 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +186 -0
- package/dist/store.js.map +1 -0
- package/dist/theme.d.ts +25 -0
- package/dist/theme.d.ts.map +1 -0
- package/dist/theme.js +39 -0
- package/dist/theme.js.map +1 -0
- package/dist/types.d.ts +140 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/vendor/xyflow/LICENSE +21 -0
- package/dist/vendor/xyflow/bezier.d.ts +26 -0
- package/dist/vendor/xyflow/bezier.d.ts.map +1 -0
- package/dist/vendor/xyflow/bezier.js +47 -0
- package/dist/vendor/xyflow/bezier.js.map +1 -0
- package/dist/vendor/xyflow/changes.d.ts +9 -0
- package/dist/vendor/xyflow/changes.d.ts.map +1 -0
- package/dist/vendor/xyflow/changes.js +133 -0
- package/dist/vendor/xyflow/changes.js.map +1 -0
- package/dist/vendor/xyflow/smoothstep.d.ts +17 -0
- package/dist/vendor/xyflow/smoothstep.d.ts.map +1 -0
- package/dist/vendor/xyflow/smoothstep.js +166 -0
- package/dist/vendor/xyflow/smoothstep.js.map +1 -0
- package/package.json +61 -4
- package/src/Flow.tsx +684 -0
- package/src/components.tsx +180 -0
- package/src/frame.ts +203 -0
- package/src/geometry.ts +274 -0
- package/src/hooks.tsx +121 -0
- package/src/index.ts +21 -0
- package/src/keymap.tsx +45 -0
- package/src/navigation.ts +86 -0
- package/src/nodes.tsx +86 -0
- package/src/raster.ts +453 -0
- package/src/store.ts +240 -0
- package/src/theme.ts +61 -0
- package/src/types.ts +130 -0
- package/src/vendor/xyflow/LICENSE +21 -0
- package/src/vendor/xyflow/README.md +20 -0
- package/src/vendor/xyflow/bezier.ts +102 -0
- package/src/vendor/xyflow/changes.ts +177 -0
- package/src/vendor/xyflow/smoothstep.ts +219 -0
- package/tsconfig.json +22 -0
package/src/Flow.tsx
ADDED
|
@@ -0,0 +1,684 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useContext,
|
|
3
|
+
useEffect,
|
|
4
|
+
useLayoutEffect,
|
|
5
|
+
useMemo,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
type ReactNode,
|
|
9
|
+
} from "react";
|
|
10
|
+
import { RGBA, type BoxRenderable, type MouseEvent, type OptimizedBuffer } from "@opentui/core";
|
|
11
|
+
import { useBindings } from "@opentui/keymap/react";
|
|
12
|
+
import { composeFrame, dropTarget, hitTest, type EdgeType, type Frame } from "./frame.ts";
|
|
13
|
+
import { absolutePositions, detailFor, pickAnchor, placeNodes, type Placed } from "./geometry.ts";
|
|
14
|
+
import { NodeIdContext, StoreContext, useStoreVersion } from "./hooks.tsx";
|
|
15
|
+
import { cycle, follow, nearest, readingOrder, sibling } from "./navigation.ts";
|
|
16
|
+
import { FlowKeymap } from "./keymap.tsx";
|
|
17
|
+
import { builtinNodeTypes } from "./nodes.tsx";
|
|
18
|
+
import { FlowStore, type CanvasNodeChange } from "./store.ts";
|
|
19
|
+
import { defaultTheme, roleColor, ThemeContext, useFlowTheme, type FlowTheme } from "./theme.ts";
|
|
20
|
+
import type {
|
|
21
|
+
Connection,
|
|
22
|
+
Edge,
|
|
23
|
+
EdgeChange,
|
|
24
|
+
Node,
|
|
25
|
+
NodeChange,
|
|
26
|
+
NodeComponent,
|
|
27
|
+
Viewport,
|
|
28
|
+
XY,
|
|
29
|
+
} from "./types.ts";
|
|
30
|
+
|
|
31
|
+
const PAN_X = 4;
|
|
32
|
+
const PAN_Y = 2;
|
|
33
|
+
const ANIMATION_MS = 150;
|
|
34
|
+
const ANIMATION_STEPS = 12;
|
|
35
|
+
const LAYER_Z = 0;
|
|
36
|
+
const NODE_Z = 10;
|
|
37
|
+
const COLOR_CACHE_LIMIT = 256;
|
|
38
|
+
const MIN_COMPACT_WIDTH = 4;
|
|
39
|
+
|
|
40
|
+
export type FlowProps<N extends Node = Node, E extends Edge = Edge> = {
|
|
41
|
+
nodes: N[];
|
|
42
|
+
edges: E[];
|
|
43
|
+
/** What the canvas proposes for nodes (drag, selection, removal); without it they stay put. */
|
|
44
|
+
onNodesChange?: (changes: NodeChange<N>[]) => void;
|
|
45
|
+
onEdgesChange?: (changes: EdgeChange<E>[]) => void;
|
|
46
|
+
/** A connection made by dragging from a handle, or with the keyboard (`c`, then Enter). */
|
|
47
|
+
onConnect?: (connection: Connection) => void;
|
|
48
|
+
onNodeClick?: (node: N) => void;
|
|
49
|
+
onEdgeClick?: (edge: E) => void;
|
|
50
|
+
onPaneClick?: (at: XY) => void;
|
|
51
|
+
/** The end of a mouse drag, with the nodes it moved at their new positions. */
|
|
52
|
+
onNodeDragStop?: (nodes: N[]) => void;
|
|
53
|
+
onViewportChange?: (viewport: Viewport) => void;
|
|
54
|
+
nodeTypes?: Record<string, NodeComponent<N>>;
|
|
55
|
+
edgeTypes?: Record<string, EdgeType>;
|
|
56
|
+
/** The type of edges that name none. Default `smoothstep`. */
|
|
57
|
+
defaultEdgeType?: string;
|
|
58
|
+
/** Fit every node in view once the canvas has a size. */
|
|
59
|
+
fitView?: boolean;
|
|
60
|
+
defaultViewport?: Viewport;
|
|
61
|
+
/** Keyboard bindings (group `flow`); off while a field elsewhere takes the keys. */
|
|
62
|
+
keyboard?: boolean;
|
|
63
|
+
/** Curves in braille. Without, bezier and slanted edges are drawn as smoothstep. */
|
|
64
|
+
braille?: boolean;
|
|
65
|
+
theme?: Partial<FlowTheme>;
|
|
66
|
+
id?: string;
|
|
67
|
+
/** `<Background>`, `<MiniMap>`, `<Controls>`, `<Panel>`. */
|
|
68
|
+
children?: ReactNode;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A node graph on the terminal, after React Flow's `<ReactFlow>`: controlled `nodes` and
|
|
73
|
+
* `edges`, changes proposed through `onNodesChange`/`onEdgesChange`. Pan, zoom and drag
|
|
74
|
+
* stay on the Client; nothing reaches the Server unless the application sends it.
|
|
75
|
+
*/
|
|
76
|
+
export function Flow<N extends Node = Node, E extends Edge = Edge>(props: FlowProps<N, E>) {
|
|
77
|
+
const outer = useContext(StoreContext);
|
|
78
|
+
const [own] = useState(() => (outer ? null : new FlowStore({ viewport: props.defaultViewport })));
|
|
79
|
+
const store = outer ?? own;
|
|
80
|
+
const theme = useMemo(() => ({ ...defaultTheme, ...props.theme }), [props.theme]);
|
|
81
|
+
if (!store) return null;
|
|
82
|
+
return (
|
|
83
|
+
<StoreContext.Provider value={store}>
|
|
84
|
+
<ThemeContext.Provider value={theme}>
|
|
85
|
+
<FlowKeymap>
|
|
86
|
+
<Canvas {...props} />
|
|
87
|
+
</FlowKeymap>
|
|
88
|
+
</ThemeContext.Provider>
|
|
89
|
+
</StoreContext.Provider>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
type Drag =
|
|
94
|
+
| { kind: "nodes"; start: XY; origins: Map<string, XY>; delta: XY | null }
|
|
95
|
+
| { kind: "pan"; start: XY; viewport: Viewport }
|
|
96
|
+
| { kind: "connect" };
|
|
97
|
+
|
|
98
|
+
function Canvas<N extends Node, E extends Edge>(props: FlowProps<N, E>) {
|
|
99
|
+
const store = useStoreVersion();
|
|
100
|
+
const theme = useFlowTheme();
|
|
101
|
+
const canvas = useRef<BoxRenderable>(null);
|
|
102
|
+
const frameRef = useRef<Frame | null>(null);
|
|
103
|
+
const drag = useRef<Drag | null>(null);
|
|
104
|
+
const [focus, setFocus] = useState<string | undefined>(undefined);
|
|
105
|
+
const { nodes, edges, keyboard = true } = props;
|
|
106
|
+
|
|
107
|
+
useLayoutEffect(() =>
|
|
108
|
+
store.sync({
|
|
109
|
+
nodes,
|
|
110
|
+
edges,
|
|
111
|
+
callbacks: {
|
|
112
|
+
onNodesChange: props.onNodesChange,
|
|
113
|
+
onEdgesChange: props.onEdgesChange,
|
|
114
|
+
onConnect: props.onConnect,
|
|
115
|
+
onViewportChange: props.onViewportChange,
|
|
116
|
+
},
|
|
117
|
+
}),
|
|
118
|
+
);
|
|
119
|
+
const fit = props.fitView ?? false;
|
|
120
|
+
useLayoutEffect(() => {
|
|
121
|
+
if (fit) store.fitView();
|
|
122
|
+
}, [store, fit]);
|
|
123
|
+
|
|
124
|
+
const detail = store.detail;
|
|
125
|
+
const placed = placeNodes(nodes, store.viewport, store.measured);
|
|
126
|
+
const byId = useMemo(() => new Map(nodes.map((n) => [n.id, n])), [nodes]);
|
|
127
|
+
const edgeById = useMemo(() => new Map(edges.map((e) => [e.id, e])), [edges]);
|
|
128
|
+
const selectedNodes = nodes.filter((n) => n.selected).map((n) => n.id);
|
|
129
|
+
const selectedEdges = edges.filter((e) => e.selected).map((e) => e.id);
|
|
130
|
+
const current = focus && selectedNodes.includes(focus) ? focus : selectedNodes[0];
|
|
131
|
+
|
|
132
|
+
const local = (event: MouseEvent): XY => ({
|
|
133
|
+
x: event.x - (canvas.current?.x ?? 0),
|
|
134
|
+
y: event.y - (canvas.current?.y ?? 0),
|
|
135
|
+
});
|
|
136
|
+
const selectNode = (id: string, options: { reveal?: boolean } = {}) => {
|
|
137
|
+
setFocus(id);
|
|
138
|
+
store.select({ nodes: [id] });
|
|
139
|
+
if (options.reveal) store.reveal(id);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// ── Mouse ───────────────────────────────────────────────────────────────
|
|
143
|
+
const onMouseDown = (event: MouseEvent) => {
|
|
144
|
+
const frame = frameRef.current;
|
|
145
|
+
if (!frame || event.button !== 0) return;
|
|
146
|
+
const at = local(event);
|
|
147
|
+
const hit = hitTest(frame, at);
|
|
148
|
+
if (hit.kind === "handle" && props.onConnect) {
|
|
149
|
+
store.setConnecting({ source: hit.anchor, pointer: at });
|
|
150
|
+
drag.current = { kind: "connect" };
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (hit.kind === "node" || hit.kind === "handle") {
|
|
154
|
+
const node = hit.kind === "node" ? hit.node : byId.get(hit.anchor.nodeId);
|
|
155
|
+
if (!node) return;
|
|
156
|
+
const original = byId.get(node.id);
|
|
157
|
+
if (node.selectable !== false) {
|
|
158
|
+
if (event.modifiers.shift)
|
|
159
|
+
store.select({
|
|
160
|
+
nodes: node.selected
|
|
161
|
+
? selectedNodes.filter((id) => id !== node.id)
|
|
162
|
+
: [...selectedNodes, node.id],
|
|
163
|
+
edges: selectedEdges,
|
|
164
|
+
});
|
|
165
|
+
else if (!node.selected) selectNode(node.id);
|
|
166
|
+
setFocus(node.id);
|
|
167
|
+
}
|
|
168
|
+
if (original) props.onNodeClick?.(original);
|
|
169
|
+
const moving = (node.selected ? nodes.filter((n) => n.selected) : [node]).filter(
|
|
170
|
+
(n) => n.draggable !== false,
|
|
171
|
+
);
|
|
172
|
+
drag.current = {
|
|
173
|
+
kind: "nodes",
|
|
174
|
+
start: at,
|
|
175
|
+
origins: new Map(moving.map((n) => [n.id, n.position])),
|
|
176
|
+
delta: null,
|
|
177
|
+
};
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (hit.kind === "edge") {
|
|
181
|
+
store.select({
|
|
182
|
+
edges: event.modifiers.shift ? [...selectedEdges, hit.id] : [hit.id],
|
|
183
|
+
});
|
|
184
|
+
const edge = edgeById.get(hit.id);
|
|
185
|
+
if (edge) props.onEdgeClick?.(edge);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
store.select({});
|
|
189
|
+
props.onPaneClick?.(store.toFlow(at));
|
|
190
|
+
drag.current = { kind: "pan", start: at, viewport: store.viewport };
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const onMouseDrag = (event: MouseEvent) => {
|
|
194
|
+
const d = drag.current;
|
|
195
|
+
if (!d) return;
|
|
196
|
+
const at = local(event);
|
|
197
|
+
if (d.kind === "connect") {
|
|
198
|
+
if (store.connecting) store.setConnecting({ ...store.connecting, pointer: at });
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (d.kind === "pan") {
|
|
202
|
+
store.setViewport({
|
|
203
|
+
...d.viewport,
|
|
204
|
+
x: d.viewport.x + at.x - d.start.x,
|
|
205
|
+
y: d.viewport.y + at.y - d.start.y,
|
|
206
|
+
});
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const { zoom } = store.viewport;
|
|
210
|
+
const dx = Math.round((at.x - d.start.x) / zoom);
|
|
211
|
+
const dy = Math.round((at.y - d.start.y) / zoom);
|
|
212
|
+
if (dx === 0 && dy === 0 && !d.delta) return;
|
|
213
|
+
d.delta = { x: dx, y: dy };
|
|
214
|
+
store.proposeNodes(
|
|
215
|
+
[...d.origins].map(([id, o]) => ({
|
|
216
|
+
id,
|
|
217
|
+
type: "position",
|
|
218
|
+
position: { x: o.x + dx, y: o.y + dy },
|
|
219
|
+
dragging: true,
|
|
220
|
+
})),
|
|
221
|
+
);
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const onMouseDragEnd = (event: MouseEvent) => {
|
|
225
|
+
const d = drag.current;
|
|
226
|
+
drag.current = null;
|
|
227
|
+
if (!d) return;
|
|
228
|
+
if (d.kind === "connect") {
|
|
229
|
+
const connecting = store.connecting;
|
|
230
|
+
const frame = frameRef.current;
|
|
231
|
+
store.setConnecting(null);
|
|
232
|
+
if (!connecting || !frame) return;
|
|
233
|
+
const target = dropTarget(frame, local(event), connecting.source);
|
|
234
|
+
if (target)
|
|
235
|
+
store.connect({
|
|
236
|
+
source: connecting.source.nodeId,
|
|
237
|
+
sourceHandle: connecting.source.handle,
|
|
238
|
+
target: target.nodeId,
|
|
239
|
+
targetHandle: target.handle,
|
|
240
|
+
});
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const delta = d.kind === "nodes" ? d.delta : null;
|
|
244
|
+
if (d.kind !== "nodes" || !delta) return;
|
|
245
|
+
// The last proposal, not the nodes: the application may not have rendered it yet.
|
|
246
|
+
const moved = [...d.origins].map(([id, o]) => ({
|
|
247
|
+
id,
|
|
248
|
+
position: { x: o.x + delta.x, y: o.y + delta.y },
|
|
249
|
+
}));
|
|
250
|
+
store.proposeNodes(moved.map((m) => ({ ...m, type: "position", dragging: false })));
|
|
251
|
+
props.onNodeDragStop?.(
|
|
252
|
+
moved.flatMap((m) => {
|
|
253
|
+
const original = byId.get(m.id);
|
|
254
|
+
return original ? [{ ...original, position: m.position, dragging: false }] : [];
|
|
255
|
+
}),
|
|
256
|
+
);
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
// A click without drag still ends a connection started on a handle.
|
|
260
|
+
const onMouseUp = () => {
|
|
261
|
+
if (drag.current?.kind === "connect") {
|
|
262
|
+
drag.current = null;
|
|
263
|
+
store.setConnecting(null);
|
|
264
|
+
} else if (drag.current?.kind !== "nodes") drag.current = null;
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const onMouseScroll = (event: MouseEvent) => {
|
|
268
|
+
const direction = event.scroll?.direction;
|
|
269
|
+
if (!direction) return;
|
|
270
|
+
if (event.modifiers.ctrl || event.modifiers.alt) {
|
|
271
|
+
if (direction === "up" || direction === "down")
|
|
272
|
+
store.zoom(direction === "up" ? 1 : -1, local(event));
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
const horizontal = event.modifiers.shift
|
|
276
|
+
? direction === "up"
|
|
277
|
+
? "left"
|
|
278
|
+
: direction === "down"
|
|
279
|
+
? "right"
|
|
280
|
+
: direction
|
|
281
|
+
: direction;
|
|
282
|
+
if (horizontal === "up") store.panBy(0, PAN_Y);
|
|
283
|
+
else if (horizontal === "down") store.panBy(0, -PAN_Y);
|
|
284
|
+
else if (horizontal === "left") store.panBy(PAN_X, 0);
|
|
285
|
+
else store.panBy(-PAN_X, 0);
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
// ── Keyboard ────────────────────────────────────────────────────────────
|
|
289
|
+
const order = readingOrder(nodes, absolutePositions(nodes));
|
|
290
|
+
const connecting = store.connecting;
|
|
291
|
+
const moveSelected = (dx: number, dy: number) => {
|
|
292
|
+
const step = Math.max(1, Math.round(1 / store.viewport.zoom));
|
|
293
|
+
const changes: CanvasNodeChange[] = nodes
|
|
294
|
+
.filter((n) => n.selected && n.draggable !== false)
|
|
295
|
+
.map((n) => ({
|
|
296
|
+
id: n.id,
|
|
297
|
+
type: "position",
|
|
298
|
+
position: { x: n.position.x + dx * step, y: n.position.y + dy * step },
|
|
299
|
+
}));
|
|
300
|
+
store.proposeNodes(changes);
|
|
301
|
+
if (current) store.reveal(current);
|
|
302
|
+
};
|
|
303
|
+
const go = (id: string | undefined) => {
|
|
304
|
+
if (!id) return;
|
|
305
|
+
if (connecting) {
|
|
306
|
+
store.setConnecting({ ...connecting, target: id });
|
|
307
|
+
store.reveal(id);
|
|
308
|
+
} else selectNode(id, { reveal: true });
|
|
309
|
+
};
|
|
310
|
+
const startConnect = () => {
|
|
311
|
+
if (!current || !props.onConnect) return;
|
|
312
|
+
const p = placed.find((x) => x.node.id === current);
|
|
313
|
+
const source = p && pickAnchor(store.anchors(p), "source", null);
|
|
314
|
+
if (!source) return;
|
|
315
|
+
const others = order.filter((n) => n.id !== current);
|
|
316
|
+
const target = nearest(order, current, "right") ?? others[0]?.id;
|
|
317
|
+
store.setConnecting({ source, target });
|
|
318
|
+
if (target) store.reveal(target);
|
|
319
|
+
};
|
|
320
|
+
const confirmConnect = () => {
|
|
321
|
+
if (!connecting?.target) return;
|
|
322
|
+
const target = connecting.target;
|
|
323
|
+
const p = placed.find((x) => x.node.id === target);
|
|
324
|
+
const anchor = p && pickAnchor(store.anchors(p), "target", null);
|
|
325
|
+
store.setConnecting(null);
|
|
326
|
+
if (!anchor) return;
|
|
327
|
+
store.connect({
|
|
328
|
+
source: connecting.source.nodeId,
|
|
329
|
+
sourceHandle: connecting.source.handle,
|
|
330
|
+
target: anchor.nodeId,
|
|
331
|
+
targetHandle: anchor.handle,
|
|
332
|
+
});
|
|
333
|
+
};
|
|
334
|
+
const at = connecting?.target ?? current;
|
|
335
|
+
const nextEdge = () => {
|
|
336
|
+
const list = edges.filter(
|
|
337
|
+
(e) => !e.hidden && (!current || e.source === current || e.target === current),
|
|
338
|
+
);
|
|
339
|
+
if (list.length === 0) return;
|
|
340
|
+
const index = list.findIndex((e) => e.selected);
|
|
341
|
+
const edge = list[(index + 1) % list.length];
|
|
342
|
+
if (edge) store.select({ nodes: current ? [current] : [], edges: [edge.id] });
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
useBindings(
|
|
346
|
+
() => ({
|
|
347
|
+
bindings: keyboard
|
|
348
|
+
? [
|
|
349
|
+
{ key: "h", cmd: () => store.panBy(PAN_X, 0) },
|
|
350
|
+
{ key: "left", cmd: () => store.panBy(PAN_X, 0) },
|
|
351
|
+
{ key: "l", cmd: () => store.panBy(-PAN_X, 0) },
|
|
352
|
+
{ key: "right", cmd: () => store.panBy(-PAN_X, 0) },
|
|
353
|
+
{ key: "k", cmd: () => store.panBy(0, PAN_Y) },
|
|
354
|
+
{ key: "up", cmd: () => store.panBy(0, PAN_Y) },
|
|
355
|
+
{ key: "j", cmd: () => store.panBy(0, -PAN_Y) },
|
|
356
|
+
{ key: "down", cmd: () => store.panBy(0, -PAN_Y) },
|
|
357
|
+
...(current && !connecting
|
|
358
|
+
? [
|
|
359
|
+
{
|
|
360
|
+
key: "shift+h",
|
|
361
|
+
cmd: () => moveSelected(-1, 0),
|
|
362
|
+
},
|
|
363
|
+
{ key: "shift+left", cmd: () => moveSelected(-1, 0) },
|
|
364
|
+
{ key: "shift+l", cmd: () => moveSelected(1, 0) },
|
|
365
|
+
{ key: "shift+right", cmd: () => moveSelected(1, 0) },
|
|
366
|
+
{ key: "shift+k", cmd: () => moveSelected(0, -1) },
|
|
367
|
+
{ key: "shift+up", cmd: () => moveSelected(0, -1) },
|
|
368
|
+
{ key: "shift+j", cmd: () => moveSelected(0, 1) },
|
|
369
|
+
{ key: "shift+down", cmd: () => moveSelected(0, 1) },
|
|
370
|
+
]
|
|
371
|
+
: []),
|
|
372
|
+
{
|
|
373
|
+
key: "tab",
|
|
374
|
+
cmd: () =>
|
|
375
|
+
go(
|
|
376
|
+
cycle(
|
|
377
|
+
order.filter((n) => n.id !== connecting?.source.nodeId),
|
|
378
|
+
at,
|
|
379
|
+
1,
|
|
380
|
+
),
|
|
381
|
+
),
|
|
382
|
+
desc: "next",
|
|
383
|
+
group: "flow",
|
|
384
|
+
},
|
|
385
|
+
{
|
|
386
|
+
key: "shift+tab",
|
|
387
|
+
cmd: () =>
|
|
388
|
+
go(
|
|
389
|
+
cycle(
|
|
390
|
+
order.filter((n) => n.id !== connecting?.source.nodeId),
|
|
391
|
+
at,
|
|
392
|
+
-1,
|
|
393
|
+
),
|
|
394
|
+
),
|
|
395
|
+
},
|
|
396
|
+
...(at
|
|
397
|
+
? [
|
|
398
|
+
{
|
|
399
|
+
key: "]",
|
|
400
|
+
cmd: () => go(follow(edges, order, at, "down")),
|
|
401
|
+
desc: "downstream",
|
|
402
|
+
group: "flow",
|
|
403
|
+
},
|
|
404
|
+
{
|
|
405
|
+
key: "[",
|
|
406
|
+
cmd: () => go(follow(edges, order, at, "up")),
|
|
407
|
+
desc: "upstream",
|
|
408
|
+
group: "flow",
|
|
409
|
+
},
|
|
410
|
+
{ key: "}", cmd: () => go(sibling(edges, order, at, 1)) },
|
|
411
|
+
{ key: "{", cmd: () => go(sibling(edges, order, at, -1)) },
|
|
412
|
+
]
|
|
413
|
+
: []),
|
|
414
|
+
...(connecting
|
|
415
|
+
? [
|
|
416
|
+
{ key: "return", cmd: confirmConnect, desc: "connect", group: "flow" },
|
|
417
|
+
{
|
|
418
|
+
key: "escape",
|
|
419
|
+
cmd: () => store.setConnecting(null),
|
|
420
|
+
desc: "cancel",
|
|
421
|
+
group: "flow",
|
|
422
|
+
},
|
|
423
|
+
]
|
|
424
|
+
: [
|
|
425
|
+
...(current && props.onConnect
|
|
426
|
+
? [{ key: "c", cmd: startConnect, desc: "connect", group: "flow" }]
|
|
427
|
+
: []),
|
|
428
|
+
...(selectedNodes.length > 0 || selectedEdges.length > 0
|
|
429
|
+
? [
|
|
430
|
+
{
|
|
431
|
+
key: "x",
|
|
432
|
+
cmd: () =>
|
|
433
|
+
store.deleteElements({ nodes: selectedNodes, edges: selectedEdges }),
|
|
434
|
+
desc: "delete",
|
|
435
|
+
group: "flow",
|
|
436
|
+
},
|
|
437
|
+
{
|
|
438
|
+
key: "delete",
|
|
439
|
+
cmd: () =>
|
|
440
|
+
store.deleteElements({ nodes: selectedNodes, edges: selectedEdges }),
|
|
441
|
+
},
|
|
442
|
+
{ key: "escape", cmd: () => store.select({}) },
|
|
443
|
+
]
|
|
444
|
+
: []),
|
|
445
|
+
...(edges.length > 0
|
|
446
|
+
? [{ key: "e", cmd: nextEdge, desc: "edges", group: "flow" }]
|
|
447
|
+
: []),
|
|
448
|
+
]),
|
|
449
|
+
{ key: "=", cmd: () => store.zoom(1), desc: "zoom in", group: "flow" },
|
|
450
|
+
{ key: "+", cmd: () => store.zoom(1) },
|
|
451
|
+
{ key: "-", cmd: () => store.zoom(-1), desc: "zoom out", group: "flow" },
|
|
452
|
+
{ key: "0", cmd: () => store.fitView(), desc: "fit", group: "flow" },
|
|
453
|
+
]
|
|
454
|
+
: [],
|
|
455
|
+
}),
|
|
456
|
+
[
|
|
457
|
+
keyboard,
|
|
458
|
+
store,
|
|
459
|
+
current,
|
|
460
|
+
at,
|
|
461
|
+
connecting,
|
|
462
|
+
order,
|
|
463
|
+
edges,
|
|
464
|
+
nodes,
|
|
465
|
+
selectedNodes.join(),
|
|
466
|
+
selectedEdges.join(),
|
|
467
|
+
],
|
|
468
|
+
);
|
|
469
|
+
|
|
470
|
+
// ── Render ──────────────────────────────────────────────────────────────
|
|
471
|
+
// Compact nodes are as wide as they were at full detail, scaled: spaced as they were.
|
|
472
|
+
const compactWidth = (node: Node) => {
|
|
473
|
+
const full = store.full.get(node.id);
|
|
474
|
+
if (detail !== "compact" || node.type === "group" || !full) return undefined;
|
|
475
|
+
return Math.max(MIN_COMPACT_WIDTH, Math.round(full.width * store.viewport.zoom));
|
|
476
|
+
};
|
|
477
|
+
const typeOf = (type: string | undefined): NodeComponent<N> | undefined =>
|
|
478
|
+
props.nodeTypes?.[type ?? "default"] ??
|
|
479
|
+
builtinNodeTypes[type ?? "default"] ??
|
|
480
|
+
builtinNodeTypes.default;
|
|
481
|
+
return (
|
|
482
|
+
<box
|
|
483
|
+
ref={canvas}
|
|
484
|
+
id={props.id}
|
|
485
|
+
flexGrow={1}
|
|
486
|
+
position="relative"
|
|
487
|
+
overflow="hidden"
|
|
488
|
+
onSizeChange={() => {
|
|
489
|
+
const box = canvas.current;
|
|
490
|
+
if (box) store.setSize({ width: box.width, height: box.height });
|
|
491
|
+
}}
|
|
492
|
+
onMouseDown={onMouseDown}
|
|
493
|
+
onMouseDrag={onMouseDrag}
|
|
494
|
+
onMouseDragEnd={onMouseDragEnd}
|
|
495
|
+
onMouseUp={onMouseUp}
|
|
496
|
+
onMouseScroll={onMouseScroll}
|
|
497
|
+
>
|
|
498
|
+
<EdgeLayer
|
|
499
|
+
store={store}
|
|
500
|
+
nodes={nodes}
|
|
501
|
+
edges={edges}
|
|
502
|
+
theme={theme}
|
|
503
|
+
edgeTypes={props.edgeTypes}
|
|
504
|
+
defaultEdgeType={props.defaultEdgeType}
|
|
505
|
+
braille={props.braille}
|
|
506
|
+
frameRef={frameRef}
|
|
507
|
+
/>
|
|
508
|
+
{placed.map((p, index) => {
|
|
509
|
+
const node = byId.get(p.node.id);
|
|
510
|
+
const Component = typeOf(p.node.type);
|
|
511
|
+
if (!node || !Component) return null;
|
|
512
|
+
return (
|
|
513
|
+
<NodeView
|
|
514
|
+
key={p.node.id}
|
|
515
|
+
store={store}
|
|
516
|
+
placed={p}
|
|
517
|
+
zIndex={NODE_Z + index}
|
|
518
|
+
dot={detail === "dot" && node.type !== "group"}
|
|
519
|
+
maxWidth={compactWidth(node)}
|
|
520
|
+
color={node.color ?? (node.selected ? theme.selected : theme.text)}
|
|
521
|
+
>
|
|
522
|
+
<Component
|
|
523
|
+
id={node.id}
|
|
524
|
+
data={node.data}
|
|
525
|
+
type={node.type ?? "default"}
|
|
526
|
+
selected={Boolean(node.selected)}
|
|
527
|
+
dragging={Boolean(node.dragging)}
|
|
528
|
+
detail={detail}
|
|
529
|
+
connectTarget={connecting?.target === node.id}
|
|
530
|
+
width={node.width === undefined ? undefined : p.rect.width}
|
|
531
|
+
height={node.height === undefined ? undefined : p.rect.height}
|
|
532
|
+
/>
|
|
533
|
+
</NodeView>
|
|
534
|
+
);
|
|
535
|
+
})}
|
|
536
|
+
{props.children}
|
|
537
|
+
</box>
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** A node at its place, measured after each layout. */
|
|
542
|
+
function NodeView({
|
|
543
|
+
store,
|
|
544
|
+
placed,
|
|
545
|
+
zIndex,
|
|
546
|
+
dot,
|
|
547
|
+
color,
|
|
548
|
+
maxWidth,
|
|
549
|
+
children,
|
|
550
|
+
}: {
|
|
551
|
+
store: FlowStore;
|
|
552
|
+
placed: Placed;
|
|
553
|
+
zIndex: number;
|
|
554
|
+
dot: boolean;
|
|
555
|
+
color: string;
|
|
556
|
+
/** Clips what the node draws: a compact label never spills over its neighbours. */
|
|
557
|
+
maxWidth?: number;
|
|
558
|
+
children: ReactNode;
|
|
559
|
+
}) {
|
|
560
|
+
const ref = useRef<BoxRenderable>(null);
|
|
561
|
+
const { node, rect } = placed;
|
|
562
|
+
const detail = detailFor(store.viewport.zoom);
|
|
563
|
+
const measure = () => {
|
|
564
|
+
const box = ref.current;
|
|
565
|
+
if (box && box.width > 0 && box.height > 0)
|
|
566
|
+
store.measure(node.id, { width: box.width, height: box.height, detail });
|
|
567
|
+
};
|
|
568
|
+
return (
|
|
569
|
+
<box
|
|
570
|
+
ref={ref}
|
|
571
|
+
position="absolute"
|
|
572
|
+
left={rect.x}
|
|
573
|
+
top={rect.y}
|
|
574
|
+
zIndex={zIndex}
|
|
575
|
+
flexDirection="column"
|
|
576
|
+
width={maxWidth ?? "auto"}
|
|
577
|
+
overflow={maxWidth === undefined ? "visible" : "hidden"}
|
|
578
|
+
onSizeChange={measure}
|
|
579
|
+
>
|
|
580
|
+
<NodeIdContext.Provider value={node.id}>
|
|
581
|
+
{dot ? (
|
|
582
|
+
<text selectable={false} fg={color}>
|
|
583
|
+
{node.selected ? "◉" : "●"}
|
|
584
|
+
</text>
|
|
585
|
+
) : (
|
|
586
|
+
children
|
|
587
|
+
)}
|
|
588
|
+
</NodeIdContext.Provider>
|
|
589
|
+
</box>
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const colors = new Map<string, RGBA>();
|
|
594
|
+
function rgba(hex: string): RGBA {
|
|
595
|
+
const known = colors.get(hex);
|
|
596
|
+
if (known) return known;
|
|
597
|
+
if (colors.size > COLOR_CACHE_LIMIT) colors.clear();
|
|
598
|
+
const color = RGBA.fromHex(hex);
|
|
599
|
+
colors.set(hex, color);
|
|
600
|
+
return color;
|
|
601
|
+
}
|
|
602
|
+
const TRANSPARENT = RGBA.fromValues(0, 0, 0, 0);
|
|
603
|
+
|
|
604
|
+
/** Edges, labels, handles and the background: one grid, copied under the nodes. */
|
|
605
|
+
function EdgeLayer({
|
|
606
|
+
store,
|
|
607
|
+
nodes,
|
|
608
|
+
edges,
|
|
609
|
+
theme,
|
|
610
|
+
edgeTypes,
|
|
611
|
+
defaultEdgeType,
|
|
612
|
+
braille,
|
|
613
|
+
frameRef,
|
|
614
|
+
}: {
|
|
615
|
+
store: FlowStore;
|
|
616
|
+
nodes: readonly Node[];
|
|
617
|
+
edges: readonly Edge[];
|
|
618
|
+
theme: FlowTheme;
|
|
619
|
+
edgeTypes?: Record<string, EdgeType>;
|
|
620
|
+
defaultEdgeType?: string;
|
|
621
|
+
braille?: boolean;
|
|
622
|
+
frameRef: { current: Frame | null };
|
|
623
|
+
}) {
|
|
624
|
+
const layer = useRef<BoxRenderable>(null);
|
|
625
|
+
const [phase, setPhase] = useState(0);
|
|
626
|
+
const animated = edges.some((e) => e.animated && !e.hidden);
|
|
627
|
+
useEffect(() => {
|
|
628
|
+
if (!animated) return;
|
|
629
|
+
const timer = setInterval(() => setPhase((p) => (p + 1) % ANIMATION_STEPS), ANIMATION_MS);
|
|
630
|
+
return () => clearInterval(timer);
|
|
631
|
+
}, [animated]);
|
|
632
|
+
|
|
633
|
+
const frame = composeFrame({
|
|
634
|
+
size: store.size,
|
|
635
|
+
viewport: store.viewport,
|
|
636
|
+
nodes,
|
|
637
|
+
edges,
|
|
638
|
+
measured: store.measured,
|
|
639
|
+
handles: store.handles,
|
|
640
|
+
background: store.background,
|
|
641
|
+
connecting: store.connecting,
|
|
642
|
+
edgeTypes,
|
|
643
|
+
defaultEdgeType,
|
|
644
|
+
braille,
|
|
645
|
+
phase,
|
|
646
|
+
});
|
|
647
|
+
const labelBg = rgba(theme.labelBg);
|
|
648
|
+
const background = store.background;
|
|
649
|
+
|
|
650
|
+
// Hit tests read the frame the user sees.
|
|
651
|
+
useLayoutEffect(() => {
|
|
652
|
+
frameRef.current = frame;
|
|
653
|
+
layer.current?.requestRender();
|
|
654
|
+
});
|
|
655
|
+
return (
|
|
656
|
+
<box
|
|
657
|
+
ref={layer}
|
|
658
|
+
position="absolute"
|
|
659
|
+
left={0}
|
|
660
|
+
top={0}
|
|
661
|
+
width="100%"
|
|
662
|
+
height="100%"
|
|
663
|
+
zIndex={LAYER_Z}
|
|
664
|
+
renderBefore={function (this: { x: number; y: number }, buffer: OptimizedBuffer) {
|
|
665
|
+
for (const [x, y, cell] of frame.grid.entries()) {
|
|
666
|
+
// A selected edge shows as selected, whatever its own color.
|
|
667
|
+
const color =
|
|
668
|
+
cell.role === "background" && background?.color
|
|
669
|
+
? background.color
|
|
670
|
+
: cell.role === "selected"
|
|
671
|
+
? theme.selected
|
|
672
|
+
: (cell.color ?? roleColor(theme, cell.role));
|
|
673
|
+
buffer.setCellWithAlphaBlending(
|
|
674
|
+
this.x + x,
|
|
675
|
+
this.y + y,
|
|
676
|
+
cell.char,
|
|
677
|
+
rgba(color),
|
|
678
|
+
cell.role === "label" ? labelBg : TRANSPARENT,
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
}}
|
|
682
|
+
/>
|
|
683
|
+
);
|
|
684
|
+
}
|