@realflow/core 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/dist/algorithms.d.ts +25 -0
- package/dist/algorithms.d.ts.map +1 -0
- package/dist/algorithms.js +158 -0
- package/dist/algorithms.js.map +1 -0
- package/dist/collab.d.ts +105 -0
- package/dist/collab.d.ts.map +1 -0
- package/dist/collab.js +182 -0
- package/dist/collab.js.map +1 -0
- package/dist/geometry.d.ts +33 -0
- package/dist/geometry.d.ts.map +1 -0
- package/dist/geometry.js +113 -0
- package/dist/geometry.js.map +1 -0
- package/dist/guides.d.ts +14 -0
- package/dist/guides.d.ts.map +1 -0
- package/dist/guides.js +52 -0
- package/dist/guides.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/layout-worker.d.ts +53 -0
- package/dist/layout-worker.d.ts.map +1 -0
- package/dist/layout-worker.js +65 -0
- package/dist/layout-worker.js.map +1 -0
- package/dist/layout.d.ts +97 -0
- package/dist/layout.d.ts.map +1 -0
- package/dist/layout.js +644 -0
- package/dist/layout.js.map +1 -0
- package/dist/ops.d.ts +142 -0
- package/dist/ops.d.ts.map +1 -0
- package/dist/ops.js +351 -0
- package/dist/ops.js.map +1 -0
- package/dist/paths.d.ts +41 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +179 -0
- package/dist/paths.js.map +1 -0
- package/dist/routing.d.ts +36 -0
- package/dist/routing.d.ts.map +1 -0
- package/dist/routing.js +185 -0
- package/dist/routing.js.map +1 -0
- package/dist/spatial.d.ts +43 -0
- package/dist/spatial.d.ts.map +1 -0
- package/dist/spatial.js +175 -0
- package/dist/spatial.js.map +1 -0
- package/dist/store.d.ts +274 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +1610 -0
- package/dist/store.js.map +1 -0
- package/dist/types.d.ts +207 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/utils.d.ts +6 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +25 -0
- package/dist/utils.js.map +1 -0
- package/package.json +40 -0
package/dist/store.js
ADDED
|
@@ -0,0 +1,1610 @@
|
|
|
1
|
+
import { clamp, expandRect, fitRect, rectContainsRect, rectCenter, rectUnion, sideAnchor, snapToGrid, visibleRect, zoomAt, } from './geometry.js';
|
|
2
|
+
import { SpatialIndex } from './spatial.js';
|
|
3
|
+
import { computeGuides } from './guides.js';
|
|
4
|
+
import { setsEqual, uid } from './utils.js';
|
|
5
|
+
export const DEFAULT_NODE_WIDTH = 172;
|
|
6
|
+
export const DEFAULT_NODE_HEIGHT = 44;
|
|
7
|
+
/** Synthetic default-handle ids are internal; store them as undefined. */
|
|
8
|
+
const normalizeHandleId = (id) => id == null || id === '__source' || id === '__target' ? undefined : id;
|
|
9
|
+
const rafSchedule = (fn) => {
|
|
10
|
+
if (typeof requestAnimationFrame !== 'undefined')
|
|
11
|
+
requestAnimationFrame(fn);
|
|
12
|
+
else
|
|
13
|
+
fn();
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* FlowStore is ReFlow's reactive heart: a Map-backed graph store with
|
|
17
|
+
* fine-grained topic subscriptions.
|
|
18
|
+
*
|
|
19
|
+
* Every mutation notifies only the topics it touches (`node:<id>`,
|
|
20
|
+
* `edge:<id>`, `viewport`, ...), so a renderer can subscribe each component
|
|
21
|
+
* to exactly the state it draws — dragging one node re-renders one node,
|
|
22
|
+
* not the whole flow.
|
|
23
|
+
*/
|
|
24
|
+
export class FlowStore {
|
|
25
|
+
nodes = new Map();
|
|
26
|
+
edges = new Map();
|
|
27
|
+
nodeOrder = [];
|
|
28
|
+
edgeOrder = [];
|
|
29
|
+
viewport = { x: 0, y: 0, zoom: 1 };
|
|
30
|
+
selectedNodes = new Set();
|
|
31
|
+
selectedEdges = new Set();
|
|
32
|
+
connection = null;
|
|
33
|
+
/** Set while dragging an existing edge's endpoint to a new handle. */
|
|
34
|
+
reconnecting = null;
|
|
35
|
+
guides = [];
|
|
36
|
+
/** Node ids inside the culling viewport (all depths). */
|
|
37
|
+
visibleNodes = new Set();
|
|
38
|
+
/** Root node ids that should be mounted by a renderer. */
|
|
39
|
+
visibleRoots = new Set();
|
|
40
|
+
visibleEdges = new Set();
|
|
41
|
+
/** When false (small graphs / no screen yet), everything renders. */
|
|
42
|
+
cullingActive = false;
|
|
43
|
+
/** Bumped whenever node membership/order changes ('nodes' topic). */
|
|
44
|
+
nodesVersion = 0;
|
|
45
|
+
/** Bumped whenever edge membership/order changes ('edges' topic). */
|
|
46
|
+
edgesVersion = 0;
|
|
47
|
+
/** Bumped on every committed mutation boundary ('commit' topic). */
|
|
48
|
+
commitVersion = 0;
|
|
49
|
+
/** Bumped whenever any node geometry changes ('graph' topic) — lets
|
|
50
|
+
* obstacle-dependent renderers (orthogonal edges) re-route live. */
|
|
51
|
+
graphVersion = 0;
|
|
52
|
+
screen = { width: 0, height: 0 };
|
|
53
|
+
options;
|
|
54
|
+
spatial = new SpatialIndex();
|
|
55
|
+
/** Renderer-measured sizes, kept apart from node objects so measuring
|
|
56
|
+
* never re-creates (and re-renders) a node. node.width/height wins. */
|
|
57
|
+
measured = new Map();
|
|
58
|
+
nodeEdges = new Map();
|
|
59
|
+
children = new Map();
|
|
60
|
+
handles = new Map();
|
|
61
|
+
/** Per-edge render versions: bumped when the edge object OR the
|
|
62
|
+
* geometry it depends on (endpoint nodes, handles) changes. */
|
|
63
|
+
edgeVer = new Map();
|
|
64
|
+
listeners = new Map();
|
|
65
|
+
pending = new Set();
|
|
66
|
+
batchDepth = 0;
|
|
67
|
+
undoStack = [];
|
|
68
|
+
redoStack = [];
|
|
69
|
+
txn = null;
|
|
70
|
+
recording = true;
|
|
71
|
+
drag = null;
|
|
72
|
+
cullScheduled = false;
|
|
73
|
+
cullForced = false;
|
|
74
|
+
/** Hysteresis: skip viewport-only re-culls while the raw view stays
|
|
75
|
+
* inside the last culled region (minus a half-margin buffer). */
|
|
76
|
+
lastCullRect = null;
|
|
77
|
+
/** Zoom at the last cull. Containment hysteresis only applies while zoom
|
|
78
|
+
* is unchanged — zooming IN from an overview shrinks the view inside the
|
|
79
|
+
* old region, which must still re-cull or nothing gets culled. */
|
|
80
|
+
lastCullZoom = 0;
|
|
81
|
+
vpAnim = null;
|
|
82
|
+
constructor(options = {}) {
|
|
83
|
+
this.options = options;
|
|
84
|
+
if (options.viewport)
|
|
85
|
+
this.viewport = { ...options.viewport };
|
|
86
|
+
if (options.nodes || options.edges) {
|
|
87
|
+
this.recording = false;
|
|
88
|
+
this.batch(() => {
|
|
89
|
+
for (const n of options.nodes ?? [])
|
|
90
|
+
this._insertNode({ ...n });
|
|
91
|
+
for (const e of options.edges ?? [])
|
|
92
|
+
this._insertEdge({ ...e });
|
|
93
|
+
});
|
|
94
|
+
this.recording = true;
|
|
95
|
+
}
|
|
96
|
+
this.cull();
|
|
97
|
+
}
|
|
98
|
+
// ── events ────────────────────────────────────────────────────────────
|
|
99
|
+
subscribe(topic, fn) {
|
|
100
|
+
let set = this.listeners.get(topic);
|
|
101
|
+
if (!set) {
|
|
102
|
+
set = new Set();
|
|
103
|
+
this.listeners.set(topic, set);
|
|
104
|
+
}
|
|
105
|
+
set.add(fn);
|
|
106
|
+
return () => {
|
|
107
|
+
set.delete(fn);
|
|
108
|
+
if (set.size === 0)
|
|
109
|
+
this.listeners.delete(topic);
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
/** Monotonic version for an edge's rendered output. */
|
|
113
|
+
edgeVersion(id) {
|
|
114
|
+
return this.edgeVer.get(id) ?? 0;
|
|
115
|
+
}
|
|
116
|
+
bumpEdge(id) {
|
|
117
|
+
this.edgeVer.set(id, (this.edgeVer.get(id) ?? 0) + 1);
|
|
118
|
+
this.emit(`edge:${id}`);
|
|
119
|
+
}
|
|
120
|
+
bumpNodes() {
|
|
121
|
+
this.nodesVersion++;
|
|
122
|
+
this.emit('nodes');
|
|
123
|
+
this.emit('graph');
|
|
124
|
+
}
|
|
125
|
+
bumpEdges() {
|
|
126
|
+
this.edgesVersion++;
|
|
127
|
+
this.emit('edges');
|
|
128
|
+
this.emit('graph');
|
|
129
|
+
}
|
|
130
|
+
emit(topic) {
|
|
131
|
+
if (topic === 'graph')
|
|
132
|
+
this.graphVersion++;
|
|
133
|
+
if (this.batchDepth > 0) {
|
|
134
|
+
this.pending.add(topic);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const set = this.listeners.get(topic);
|
|
138
|
+
if (set)
|
|
139
|
+
for (const fn of [...set])
|
|
140
|
+
fn();
|
|
141
|
+
}
|
|
142
|
+
/** Group mutations; duplicate notifications are coalesced. */
|
|
143
|
+
batch(fn) {
|
|
144
|
+
this.batchDepth++;
|
|
145
|
+
try {
|
|
146
|
+
fn();
|
|
147
|
+
}
|
|
148
|
+
finally {
|
|
149
|
+
this.batchDepth--;
|
|
150
|
+
if (this.batchDepth === 0) {
|
|
151
|
+
const topics = [...this.pending];
|
|
152
|
+
this.pending.clear();
|
|
153
|
+
for (const t of topics) {
|
|
154
|
+
const set = this.listeners.get(t);
|
|
155
|
+
if (set)
|
|
156
|
+
for (const l of [...set])
|
|
157
|
+
l();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
commit() {
|
|
163
|
+
this.commitVersion++;
|
|
164
|
+
this.emit('commit');
|
|
165
|
+
}
|
|
166
|
+
// ── history ───────────────────────────────────────────────────────────
|
|
167
|
+
record(label, op) {
|
|
168
|
+
if (!this.recording)
|
|
169
|
+
return;
|
|
170
|
+
if (this.txn) {
|
|
171
|
+
this.txn.ops.push(op);
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
this.undoStack.push({ label, ops: [op] });
|
|
175
|
+
const limit = this.options.historyLimit ?? 200;
|
|
176
|
+
if (this.undoStack.length > limit)
|
|
177
|
+
this.undoStack.shift();
|
|
178
|
+
this.redoStack = [];
|
|
179
|
+
this.emit('history');
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/** Group several mutations into a single undo entry. */
|
|
183
|
+
transact(label, fn) {
|
|
184
|
+
if (this.txn) {
|
|
185
|
+
fn();
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
this.txn = { label, ops: [] };
|
|
189
|
+
try {
|
|
190
|
+
this.batch(fn);
|
|
191
|
+
}
|
|
192
|
+
finally {
|
|
193
|
+
const entry = this.txn;
|
|
194
|
+
this.txn = null;
|
|
195
|
+
if (entry.ops.length > 0 && this.recording) {
|
|
196
|
+
this.undoStack.push(entry);
|
|
197
|
+
const limit = this.options.historyLimit ?? 200;
|
|
198
|
+
if (this.undoStack.length > limit)
|
|
199
|
+
this.undoStack.shift();
|
|
200
|
+
this.redoStack = [];
|
|
201
|
+
this.emit('history');
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
get canUndo() {
|
|
206
|
+
return this.undoStack.length > 0;
|
|
207
|
+
}
|
|
208
|
+
get canRedo() {
|
|
209
|
+
return this.redoStack.length > 0;
|
|
210
|
+
}
|
|
211
|
+
undo() {
|
|
212
|
+
const entry = this.undoStack.pop();
|
|
213
|
+
if (!entry)
|
|
214
|
+
return;
|
|
215
|
+
this.recording = false;
|
|
216
|
+
this.batch(() => {
|
|
217
|
+
for (let i = entry.ops.length - 1; i >= 0; i--)
|
|
218
|
+
entry.ops[i].undo();
|
|
219
|
+
});
|
|
220
|
+
this.recording = true;
|
|
221
|
+
this.redoStack.push(entry);
|
|
222
|
+
this.cull();
|
|
223
|
+
this.emit('history');
|
|
224
|
+
this.commit();
|
|
225
|
+
}
|
|
226
|
+
redo() {
|
|
227
|
+
const entry = this.redoStack.pop();
|
|
228
|
+
if (!entry)
|
|
229
|
+
return;
|
|
230
|
+
this.recording = false;
|
|
231
|
+
this.batch(() => {
|
|
232
|
+
for (const op of entry.ops)
|
|
233
|
+
op.redo();
|
|
234
|
+
});
|
|
235
|
+
this.recording = true;
|
|
236
|
+
this.undoStack.push(entry);
|
|
237
|
+
this.cull();
|
|
238
|
+
this.emit('history');
|
|
239
|
+
this.commit();
|
|
240
|
+
}
|
|
241
|
+
clearHistory() {
|
|
242
|
+
this.undoStack = [];
|
|
243
|
+
this.redoStack = [];
|
|
244
|
+
this.emit('history');
|
|
245
|
+
}
|
|
246
|
+
// ── raw graph ops (no history) ────────────────────────────────────────
|
|
247
|
+
_insertNode(node) {
|
|
248
|
+
this.nodes.set(node.id, node);
|
|
249
|
+
this.nodeOrder.push(node.id);
|
|
250
|
+
if (node.parentId) {
|
|
251
|
+
let set = this.children.get(node.parentId);
|
|
252
|
+
if (!set) {
|
|
253
|
+
set = new Set();
|
|
254
|
+
this.children.set(node.parentId, set);
|
|
255
|
+
}
|
|
256
|
+
set.add(node.id);
|
|
257
|
+
}
|
|
258
|
+
if (node.selected)
|
|
259
|
+
this.selectedNodes.add(node.id);
|
|
260
|
+
this.spatial.set(node.id, this.nodeRect(node.id));
|
|
261
|
+
this.bumpNodes();
|
|
262
|
+
this.emit(`node:${node.id}`);
|
|
263
|
+
this.scheduleCull();
|
|
264
|
+
}
|
|
265
|
+
_removeNode(id) {
|
|
266
|
+
const node = this.nodes.get(id);
|
|
267
|
+
if (!node)
|
|
268
|
+
return;
|
|
269
|
+
this.nodes.delete(id);
|
|
270
|
+
const i = this.nodeOrder.indexOf(id);
|
|
271
|
+
if (i >= 0)
|
|
272
|
+
this.nodeOrder.splice(i, 1);
|
|
273
|
+
if (node.parentId)
|
|
274
|
+
this.children.get(node.parentId)?.delete(id);
|
|
275
|
+
this.children.delete(id);
|
|
276
|
+
this.handles.delete(id);
|
|
277
|
+
this.measured.delete(id);
|
|
278
|
+
this.selectedNodes.delete(id);
|
|
279
|
+
this.spatial.delete(id);
|
|
280
|
+
this.bumpNodes();
|
|
281
|
+
this.emit(`node:${id}`);
|
|
282
|
+
this.scheduleCull();
|
|
283
|
+
}
|
|
284
|
+
_replaceNode(node) {
|
|
285
|
+
const prev = this.nodes.get(node.id);
|
|
286
|
+
this.nodes.set(node.id, node);
|
|
287
|
+
if (prev?.parentId !== node.parentId) {
|
|
288
|
+
if (prev?.parentId)
|
|
289
|
+
this.children.get(prev.parentId)?.delete(node.id);
|
|
290
|
+
if (node.parentId) {
|
|
291
|
+
let set = this.children.get(node.parentId);
|
|
292
|
+
if (!set) {
|
|
293
|
+
set = new Set();
|
|
294
|
+
this.children.set(node.parentId, set);
|
|
295
|
+
}
|
|
296
|
+
set.add(node.id);
|
|
297
|
+
}
|
|
298
|
+
this.bumpNodes();
|
|
299
|
+
}
|
|
300
|
+
if (node.selected)
|
|
301
|
+
this.selectedNodes.add(node.id);
|
|
302
|
+
else
|
|
303
|
+
this.selectedNodes.delete(node.id);
|
|
304
|
+
this.touchNode(node.id);
|
|
305
|
+
}
|
|
306
|
+
_insertEdge(edge) {
|
|
307
|
+
this.edges.set(edge.id, edge);
|
|
308
|
+
this.edgeOrder.push(edge.id);
|
|
309
|
+
for (const nid of [edge.source, edge.target]) {
|
|
310
|
+
let set = this.nodeEdges.get(nid);
|
|
311
|
+
if (!set) {
|
|
312
|
+
set = new Set();
|
|
313
|
+
this.nodeEdges.set(nid, set);
|
|
314
|
+
}
|
|
315
|
+
set.add(edge.id);
|
|
316
|
+
}
|
|
317
|
+
if (edge.selected)
|
|
318
|
+
this.selectedEdges.add(edge.id);
|
|
319
|
+
this.bumpEdges();
|
|
320
|
+
this.bumpEdge(edge.id);
|
|
321
|
+
this.scheduleCull();
|
|
322
|
+
}
|
|
323
|
+
_removeEdge(id) {
|
|
324
|
+
const edge = this.edges.get(id);
|
|
325
|
+
if (!edge)
|
|
326
|
+
return;
|
|
327
|
+
this.edges.delete(id);
|
|
328
|
+
const i = this.edgeOrder.indexOf(id);
|
|
329
|
+
if (i >= 0)
|
|
330
|
+
this.edgeOrder.splice(i, 1);
|
|
331
|
+
this.nodeEdges.get(edge.source)?.delete(id);
|
|
332
|
+
this.nodeEdges.get(edge.target)?.delete(id);
|
|
333
|
+
this.selectedEdges.delete(id);
|
|
334
|
+
this.bumpEdges();
|
|
335
|
+
this.bumpEdge(id);
|
|
336
|
+
this.edgeVer.delete(id);
|
|
337
|
+
}
|
|
338
|
+
_replaceEdge(edge) {
|
|
339
|
+
const prev = this.edges.get(edge.id);
|
|
340
|
+
if (prev && (prev.source !== edge.source || prev.target !== edge.target)) {
|
|
341
|
+
this.nodeEdges.get(prev.source)?.delete(edge.id);
|
|
342
|
+
this.nodeEdges.get(prev.target)?.delete(edge.id);
|
|
343
|
+
for (const nid of [edge.source, edge.target]) {
|
|
344
|
+
let set = this.nodeEdges.get(nid);
|
|
345
|
+
if (!set) {
|
|
346
|
+
set = new Set();
|
|
347
|
+
this.nodeEdges.set(nid, set);
|
|
348
|
+
}
|
|
349
|
+
set.add(edge.id);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
this.edges.set(edge.id, edge);
|
|
353
|
+
if (edge.selected)
|
|
354
|
+
this.selectedEdges.add(edge.id);
|
|
355
|
+
else
|
|
356
|
+
this.selectedEdges.delete(edge.id);
|
|
357
|
+
this.bumpEdge(edge.id);
|
|
358
|
+
}
|
|
359
|
+
/** Re-index a node and notify it plus every edge touching it. */
|
|
360
|
+
touchNode(id) {
|
|
361
|
+
this.spatial.set(id, this.nodeRect(id));
|
|
362
|
+
this.emit(`node:${id}`);
|
|
363
|
+
this.emit('graph');
|
|
364
|
+
const edges = this.nodeEdges.get(id);
|
|
365
|
+
if (edges)
|
|
366
|
+
for (const eid of edges)
|
|
367
|
+
this.bumpEdge(eid);
|
|
368
|
+
const kids = this.children.get(id);
|
|
369
|
+
if (kids)
|
|
370
|
+
for (const kid of kids)
|
|
371
|
+
this.touchNode(kid);
|
|
372
|
+
this.scheduleCull();
|
|
373
|
+
}
|
|
374
|
+
// ── public graph API (history-aware) ──────────────────────────────────
|
|
375
|
+
getNode(id) {
|
|
376
|
+
return this.nodes.get(id);
|
|
377
|
+
}
|
|
378
|
+
getEdge(id) {
|
|
379
|
+
return this.edges.get(id);
|
|
380
|
+
}
|
|
381
|
+
getNodes() {
|
|
382
|
+
return this.nodeOrder.map((id) => this.nodes.get(id));
|
|
383
|
+
}
|
|
384
|
+
/** All edges connected to a node. */
|
|
385
|
+
edgesOf(id) {
|
|
386
|
+
const set = this.nodeEdges.get(id);
|
|
387
|
+
if (!set)
|
|
388
|
+
return [];
|
|
389
|
+
const out = [];
|
|
390
|
+
for (const eid of set)
|
|
391
|
+
out.push(this.edges.get(eid));
|
|
392
|
+
return out;
|
|
393
|
+
}
|
|
394
|
+
/** Direct children ids of a node (subflow members). */
|
|
395
|
+
childrenOf(id) {
|
|
396
|
+
const set = this.children.get(id);
|
|
397
|
+
return set ? [...set] : [];
|
|
398
|
+
}
|
|
399
|
+
getEdges() {
|
|
400
|
+
return this.edgeOrder.map((id) => this.edges.get(id));
|
|
401
|
+
}
|
|
402
|
+
addNodes(nodes) {
|
|
403
|
+
if (nodes.length === 0)
|
|
404
|
+
return;
|
|
405
|
+
const copies = nodes.map((n) => ({ ...n }));
|
|
406
|
+
this.batch(() => {
|
|
407
|
+
for (const n of copies)
|
|
408
|
+
this._insertNode(n);
|
|
409
|
+
});
|
|
410
|
+
this.cull();
|
|
411
|
+
this.record('add nodes', {
|
|
412
|
+
undo: () => this.batch(() => {
|
|
413
|
+
for (const n of copies)
|
|
414
|
+
this._removeNode(n.id);
|
|
415
|
+
}),
|
|
416
|
+
redo: () => this.batch(() => {
|
|
417
|
+
for (const n of copies)
|
|
418
|
+
this._insertNode(n);
|
|
419
|
+
}),
|
|
420
|
+
});
|
|
421
|
+
this.commit();
|
|
422
|
+
}
|
|
423
|
+
addNode(node) {
|
|
424
|
+
this.addNodes([node]);
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Remove nodes plus their connected edges. Children of removed nodes are
|
|
428
|
+
* re-parented to the flow root (keeping their absolute position) instead
|
|
429
|
+
* of being orphaned.
|
|
430
|
+
*/
|
|
431
|
+
removeNodes(ids) {
|
|
432
|
+
const doomed = ids.filter((id) => this.nodes.has(id) && this.nodes.get(id).deletable !== false);
|
|
433
|
+
if (doomed.length === 0)
|
|
434
|
+
return;
|
|
435
|
+
const doomedSet = new Set(doomed);
|
|
436
|
+
const removedNodes = doomed.map((id) => this.nodes.get(id));
|
|
437
|
+
const removedEdges = [];
|
|
438
|
+
for (const id of doomed) {
|
|
439
|
+
const set = this.nodeEdges.get(id);
|
|
440
|
+
if (set) {
|
|
441
|
+
for (const eid of set) {
|
|
442
|
+
const e = this.edges.get(eid);
|
|
443
|
+
if (e && !removedEdges.includes(e))
|
|
444
|
+
removedEdges.push(e);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
// Children that survive get re-parented to root at absolute position.
|
|
449
|
+
const reparented = [];
|
|
450
|
+
for (const id of doomed) {
|
|
451
|
+
const kids = this.children.get(id);
|
|
452
|
+
if (!kids)
|
|
453
|
+
continue;
|
|
454
|
+
for (const kid of kids) {
|
|
455
|
+
if (doomedSet.has(kid))
|
|
456
|
+
continue;
|
|
457
|
+
const child = this.nodes.get(kid);
|
|
458
|
+
const abs = this.absolutePosition(kid);
|
|
459
|
+
reparented.push({
|
|
460
|
+
before: child,
|
|
461
|
+
after: { ...child, parentId: undefined, position: abs },
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
this.batch(() => {
|
|
466
|
+
for (const e of removedEdges)
|
|
467
|
+
this._removeEdge(e.id);
|
|
468
|
+
for (const r of reparented)
|
|
469
|
+
this._replaceNode(r.after);
|
|
470
|
+
for (const id of doomed)
|
|
471
|
+
this._removeNode(id);
|
|
472
|
+
});
|
|
473
|
+
this.cull();
|
|
474
|
+
this.record('remove nodes', {
|
|
475
|
+
undo: () => this.batch(() => {
|
|
476
|
+
for (const n of removedNodes)
|
|
477
|
+
this._insertNode(n);
|
|
478
|
+
for (const r of reparented)
|
|
479
|
+
this._replaceNode(r.before);
|
|
480
|
+
for (const e of removedEdges)
|
|
481
|
+
this._insertEdge(e);
|
|
482
|
+
}),
|
|
483
|
+
redo: () => this.batch(() => {
|
|
484
|
+
for (const e of removedEdges)
|
|
485
|
+
this._removeEdge(e.id);
|
|
486
|
+
for (const r of reparented)
|
|
487
|
+
this._replaceNode(r.after);
|
|
488
|
+
for (const id of doomed)
|
|
489
|
+
this._removeNode(id);
|
|
490
|
+
}),
|
|
491
|
+
});
|
|
492
|
+
this.emit('selection');
|
|
493
|
+
this.commit();
|
|
494
|
+
}
|
|
495
|
+
updateNode(id, patch) {
|
|
496
|
+
const prev = this.nodes.get(id);
|
|
497
|
+
if (!prev)
|
|
498
|
+
return;
|
|
499
|
+
const next = { ...prev, ...patch, id };
|
|
500
|
+
this._replaceNode(next);
|
|
501
|
+
this.record('update node', {
|
|
502
|
+
undo: () => this._replaceNode(prev),
|
|
503
|
+
redo: () => this._replaceNode(next),
|
|
504
|
+
});
|
|
505
|
+
this.commit();
|
|
506
|
+
}
|
|
507
|
+
updateNodeData(id, dataPatch) {
|
|
508
|
+
const prev = this.nodes.get(id);
|
|
509
|
+
if (!prev)
|
|
510
|
+
return;
|
|
511
|
+
this.updateNode(id, { data: { ...prev.data, ...dataPatch } });
|
|
512
|
+
}
|
|
513
|
+
setNodePosition(id, position) {
|
|
514
|
+
const prev = this.nodes.get(id);
|
|
515
|
+
if (!prev)
|
|
516
|
+
return;
|
|
517
|
+
const snapped = snapToGrid(position, this.options.snapGrid ?? 0);
|
|
518
|
+
const next = { ...prev, position: snapped };
|
|
519
|
+
this._replaceNode(next);
|
|
520
|
+
this.record('move node', {
|
|
521
|
+
undo: () => this._replaceNode(prev),
|
|
522
|
+
redo: () => this._replaceNode(next),
|
|
523
|
+
});
|
|
524
|
+
this.commit();
|
|
525
|
+
}
|
|
526
|
+
/** Measured size update from the renderer — not recorded in history. */
|
|
527
|
+
setNodeSize(id, width, height) {
|
|
528
|
+
if (!this.nodes.has(id))
|
|
529
|
+
return;
|
|
530
|
+
const prev = this.measured.get(id);
|
|
531
|
+
if (prev && prev.width === width && prev.height === height)
|
|
532
|
+
return;
|
|
533
|
+
this.measured.set(id, { width, height });
|
|
534
|
+
this.touchNode(id);
|
|
535
|
+
}
|
|
536
|
+
addEdges(edges) {
|
|
537
|
+
const fresh = edges.filter((e) => !this.edges.has(e.id)).map((e) => ({ ...e }));
|
|
538
|
+
if (fresh.length === 0)
|
|
539
|
+
return;
|
|
540
|
+
this.batch(() => {
|
|
541
|
+
for (const e of fresh)
|
|
542
|
+
this._insertEdge(e);
|
|
543
|
+
});
|
|
544
|
+
this.cull();
|
|
545
|
+
this.record('add edges', {
|
|
546
|
+
undo: () => this.batch(() => {
|
|
547
|
+
for (const e of fresh)
|
|
548
|
+
this._removeEdge(e.id);
|
|
549
|
+
}),
|
|
550
|
+
redo: () => this.batch(() => {
|
|
551
|
+
for (const e of fresh)
|
|
552
|
+
this._insertEdge(e);
|
|
553
|
+
}),
|
|
554
|
+
});
|
|
555
|
+
this.commit();
|
|
556
|
+
}
|
|
557
|
+
addEdge(edge) {
|
|
558
|
+
this.addEdges([edge]);
|
|
559
|
+
}
|
|
560
|
+
removeEdges(ids) {
|
|
561
|
+
const removed = ids
|
|
562
|
+
.map((id) => this.edges.get(id))
|
|
563
|
+
.filter((e) => !!e && e.deletable !== false);
|
|
564
|
+
if (removed.length === 0)
|
|
565
|
+
return;
|
|
566
|
+
this.batch(() => {
|
|
567
|
+
for (const e of removed)
|
|
568
|
+
this._removeEdge(e.id);
|
|
569
|
+
});
|
|
570
|
+
this.cull();
|
|
571
|
+
this.record('remove edges', {
|
|
572
|
+
undo: () => this.batch(() => {
|
|
573
|
+
for (const e of removed)
|
|
574
|
+
this._insertEdge(e);
|
|
575
|
+
}),
|
|
576
|
+
redo: () => this.batch(() => {
|
|
577
|
+
for (const e of removed)
|
|
578
|
+
this._removeEdge(e.id);
|
|
579
|
+
}),
|
|
580
|
+
});
|
|
581
|
+
this.emit('selection');
|
|
582
|
+
this.commit();
|
|
583
|
+
}
|
|
584
|
+
updateEdge(id, patch) {
|
|
585
|
+
const prev = this.edges.get(id);
|
|
586
|
+
if (!prev)
|
|
587
|
+
return;
|
|
588
|
+
const next = { ...prev, ...patch, id };
|
|
589
|
+
this._replaceEdge(next);
|
|
590
|
+
this.record('update edge', {
|
|
591
|
+
undo: () => this._replaceEdge(prev),
|
|
592
|
+
redo: () => this._replaceEdge(next),
|
|
593
|
+
});
|
|
594
|
+
this.commit();
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* Diff-sync the whole graph (used by controlled mode and loadSnapshot).
|
|
598
|
+
* Unchanged elements are left untouched, so selection, measured sizes and
|
|
599
|
+
* fine-grained subscriptions survive a controlled-props round trip.
|
|
600
|
+
*/
|
|
601
|
+
setGraph(nodes, edges) {
|
|
602
|
+
this.recording = false;
|
|
603
|
+
this.batch(() => {
|
|
604
|
+
const nextNodeIds = new Set(nodes.map((n) => n.id));
|
|
605
|
+
const nextEdgeIds = new Set(edges.map((e) => e.id));
|
|
606
|
+
for (const id of [...this.edgeOrder]) {
|
|
607
|
+
if (!nextEdgeIds.has(id))
|
|
608
|
+
this._removeEdge(id);
|
|
609
|
+
}
|
|
610
|
+
for (const id of [...this.nodeOrder]) {
|
|
611
|
+
if (!nextNodeIds.has(id))
|
|
612
|
+
this._removeNode(id);
|
|
613
|
+
}
|
|
614
|
+
for (const n of nodes) {
|
|
615
|
+
const prev = this.nodes.get(n.id);
|
|
616
|
+
if (!prev)
|
|
617
|
+
this._insertNode({ ...n });
|
|
618
|
+
else if (prev !== n)
|
|
619
|
+
this._replaceNode({ ...n });
|
|
620
|
+
}
|
|
621
|
+
for (const e of edges) {
|
|
622
|
+
const prev = this.edges.get(e.id);
|
|
623
|
+
if (!prev)
|
|
624
|
+
this._insertEdge({ ...e });
|
|
625
|
+
else if (prev !== e)
|
|
626
|
+
this._replaceEdge({ ...e });
|
|
627
|
+
}
|
|
628
|
+
this.emit('selection');
|
|
629
|
+
});
|
|
630
|
+
this.cull();
|
|
631
|
+
this.recording = true;
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* Apply a remote collaborative change: upsert/remove specific nodes and
|
|
635
|
+
* edges WITHOUT recording history and WITHOUT emitting 'commit' (so it
|
|
636
|
+
* never echoes back through the local broadcast diff). Remote edits aren't
|
|
637
|
+
* part of your local undo stack — you don't undo a teammate's change.
|
|
638
|
+
*/
|
|
639
|
+
applyRemotePatch(patch) {
|
|
640
|
+
this.recording = false;
|
|
641
|
+
this.batch(() => {
|
|
642
|
+
for (const id of patch.edges?.remove ?? [])
|
|
643
|
+
this._removeEdge(id);
|
|
644
|
+
for (const id of patch.nodes?.remove ?? [])
|
|
645
|
+
this._removeNode(id);
|
|
646
|
+
for (const n of patch.nodes?.upsert ?? []) {
|
|
647
|
+
if (this.nodes.has(n.id))
|
|
648
|
+
this._replaceNode({ ...n });
|
|
649
|
+
else
|
|
650
|
+
this._insertNode({ ...n });
|
|
651
|
+
}
|
|
652
|
+
for (const e of patch.edges?.upsert ?? []) {
|
|
653
|
+
if (this.edges.has(e.id))
|
|
654
|
+
this._replaceEdge({ ...e });
|
|
655
|
+
else
|
|
656
|
+
this._insertEdge({ ...e });
|
|
657
|
+
}
|
|
658
|
+
});
|
|
659
|
+
this.recording = true;
|
|
660
|
+
this.cull();
|
|
661
|
+
}
|
|
662
|
+
// ── geometry helpers ──────────────────────────────────────────────────
|
|
663
|
+
absolutePosition(id) {
|
|
664
|
+
let node = this.nodes.get(id);
|
|
665
|
+
if (!node)
|
|
666
|
+
return { x: 0, y: 0 };
|
|
667
|
+
let x = node.position.x;
|
|
668
|
+
let y = node.position.y;
|
|
669
|
+
let guard = 0;
|
|
670
|
+
while (node?.parentId && guard++ < 100) {
|
|
671
|
+
node = this.nodes.get(node.parentId);
|
|
672
|
+
if (!node)
|
|
673
|
+
break;
|
|
674
|
+
x += node.position.x;
|
|
675
|
+
y += node.position.y;
|
|
676
|
+
}
|
|
677
|
+
return { x, y };
|
|
678
|
+
}
|
|
679
|
+
nodeSize(id) {
|
|
680
|
+
const n = this.nodes.get(id);
|
|
681
|
+
const m = this.measured.get(id);
|
|
682
|
+
return {
|
|
683
|
+
width: n?.width ?? m?.width ?? DEFAULT_NODE_WIDTH,
|
|
684
|
+
height: n?.height ?? m?.height ?? DEFAULT_NODE_HEIGHT,
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
/** Absolute rect of a node in flow coordinates. */
|
|
688
|
+
nodeRect(id) {
|
|
689
|
+
const pos = this.absolutePosition(id);
|
|
690
|
+
const size = this.nodeSize(id);
|
|
691
|
+
return { x: pos.x, y: pos.y, width: size.width, height: size.height };
|
|
692
|
+
}
|
|
693
|
+
/** Union of all node rects (or the given subset). */
|
|
694
|
+
nodesBounds(ids) {
|
|
695
|
+
let acc = null;
|
|
696
|
+
for (const id of ids ?? this.nodeOrder) {
|
|
697
|
+
if (!this.nodes.has(id) || this.nodes.get(id).hidden)
|
|
698
|
+
continue;
|
|
699
|
+
acc = rectUnion(acc, this.nodeRect(id));
|
|
700
|
+
}
|
|
701
|
+
return acc ?? { x: 0, y: 0, width: 0, height: 0 };
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* The nearest visible node to `fromId` in a cardinal direction — for
|
|
705
|
+
* keyboard/screen-reader spatial navigation. Uses a cone test so
|
|
706
|
+
* "right" prefers nodes actually to the right, breaking ties by distance.
|
|
707
|
+
*/
|
|
708
|
+
nearestNodeInDirection(fromId, dir) {
|
|
709
|
+
const from = this.nodes.get(fromId);
|
|
710
|
+
if (!from)
|
|
711
|
+
return null;
|
|
712
|
+
const a = rectCenter(this.nodeRect(fromId));
|
|
713
|
+
let best = null;
|
|
714
|
+
for (const id of this.nodeOrder) {
|
|
715
|
+
if (id === fromId)
|
|
716
|
+
continue;
|
|
717
|
+
const node = this.nodes.get(id);
|
|
718
|
+
if (node.hidden || node.selectable === false)
|
|
719
|
+
continue;
|
|
720
|
+
const b = rectCenter(this.nodeRect(id));
|
|
721
|
+
const dx = b.x - a.x;
|
|
722
|
+
const dy = b.y - a.y;
|
|
723
|
+
let along = 0;
|
|
724
|
+
let across = 0;
|
|
725
|
+
if (dir === 'left') {
|
|
726
|
+
along = -dx;
|
|
727
|
+
across = Math.abs(dy);
|
|
728
|
+
}
|
|
729
|
+
else if (dir === 'right') {
|
|
730
|
+
along = dx;
|
|
731
|
+
across = Math.abs(dy);
|
|
732
|
+
}
|
|
733
|
+
else if (dir === 'up') {
|
|
734
|
+
along = -dy;
|
|
735
|
+
across = Math.abs(dx);
|
|
736
|
+
}
|
|
737
|
+
else {
|
|
738
|
+
along = dy;
|
|
739
|
+
across = Math.abs(dx);
|
|
740
|
+
}
|
|
741
|
+
if (along <= 0)
|
|
742
|
+
continue; // wrong direction
|
|
743
|
+
// Prefer aligned + close: penalize perpendicular offset.
|
|
744
|
+
const score = along + across * 2;
|
|
745
|
+
if (!best || score < best.score)
|
|
746
|
+
best = { id, score };
|
|
747
|
+
}
|
|
748
|
+
return best?.id ?? null;
|
|
749
|
+
}
|
|
750
|
+
/** Nodes whose rects intersect (or are fully inside) the given rect. */
|
|
751
|
+
nodesInRect(rect, partially = true) {
|
|
752
|
+
const hits = this.spatial.query(rect);
|
|
753
|
+
const out = [];
|
|
754
|
+
for (const id of hits) {
|
|
755
|
+
const n = this.nodes.get(id);
|
|
756
|
+
if (!n || n.hidden || n.selectable === false)
|
|
757
|
+
continue;
|
|
758
|
+
if (partially) {
|
|
759
|
+
out.push(id);
|
|
760
|
+
}
|
|
761
|
+
else {
|
|
762
|
+
const r = this.nodeRect(id);
|
|
763
|
+
if (r.x >= rect.x &&
|
|
764
|
+
r.y >= rect.y &&
|
|
765
|
+
r.x + r.width <= rect.x + rect.width &&
|
|
766
|
+
r.y + r.height <= rect.y + rect.height) {
|
|
767
|
+
out.push(id);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
return out;
|
|
772
|
+
}
|
|
773
|
+
// ── handles & edge geometry ───────────────────────────────────────────
|
|
774
|
+
registerHandle(info) {
|
|
775
|
+
let map = this.handles.get(info.nodeId);
|
|
776
|
+
if (!map) {
|
|
777
|
+
map = new Map();
|
|
778
|
+
this.handles.set(info.nodeId, map);
|
|
779
|
+
}
|
|
780
|
+
const prev = map.get(info.id);
|
|
781
|
+
if (prev &&
|
|
782
|
+
prev.x === info.x &&
|
|
783
|
+
prev.y === info.y &&
|
|
784
|
+
prev.side === info.side &&
|
|
785
|
+
prev.kind === info.kind) {
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
map.set(info.id, info);
|
|
789
|
+
const edges = this.nodeEdges.get(info.nodeId);
|
|
790
|
+
if (edges)
|
|
791
|
+
for (const eid of edges)
|
|
792
|
+
this.bumpEdge(eid);
|
|
793
|
+
this.emit('connection');
|
|
794
|
+
}
|
|
795
|
+
unregisterHandle(nodeId, handleId) {
|
|
796
|
+
this.handles.get(nodeId)?.delete(handleId);
|
|
797
|
+
}
|
|
798
|
+
getHandles(nodeId) {
|
|
799
|
+
const map = this.handles.get(nodeId);
|
|
800
|
+
return map ? [...map.values()] : [];
|
|
801
|
+
}
|
|
802
|
+
/**
|
|
803
|
+
* Resolve the handle an edge endpoint attaches to. Falls back to a
|
|
804
|
+
* synthetic border-midpoint handle when nothing is registered (headless
|
|
805
|
+
* usage, or nodes without <Handle> children).
|
|
806
|
+
*/
|
|
807
|
+
resolveHandle(nodeId, kind, handleId) {
|
|
808
|
+
const map = this.handles.get(nodeId);
|
|
809
|
+
if (map) {
|
|
810
|
+
if (handleId != null) {
|
|
811
|
+
const h = map.get(handleId);
|
|
812
|
+
if (h)
|
|
813
|
+
return h;
|
|
814
|
+
}
|
|
815
|
+
for (const h of map.values())
|
|
816
|
+
if (h.kind === kind)
|
|
817
|
+
return h;
|
|
818
|
+
}
|
|
819
|
+
const size = this.nodeSize(nodeId);
|
|
820
|
+
const side = kind === 'source' ? 'right' : 'left';
|
|
821
|
+
const anchor = sideAnchor({ x: 0, y: 0, ...size }, side);
|
|
822
|
+
return { id: handleId ?? `__${kind}`, nodeId, kind, side, x: anchor.x, y: anchor.y };
|
|
823
|
+
}
|
|
824
|
+
/** Absolute anchor point of a handle. */
|
|
825
|
+
handleAnchor(h) {
|
|
826
|
+
const pos = this.absolutePosition(h.nodeId);
|
|
827
|
+
return { x: pos.x + h.x, y: pos.y + h.y };
|
|
828
|
+
}
|
|
829
|
+
/**
|
|
830
|
+
* Node rectangles an orthogonal edge should route around: nodes near the
|
|
831
|
+
* edge's bounding box, excluding the two endpoint nodes. Uses the spatial
|
|
832
|
+
* index so this stays cheap even with thousands of nodes.
|
|
833
|
+
*/
|
|
834
|
+
edgeObstacles(edge, margin = 120) {
|
|
835
|
+
const geo = this.edgeGeometry(edge);
|
|
836
|
+
if (!geo)
|
|
837
|
+
return [];
|
|
838
|
+
const x0 = Math.min(geo.source.x, geo.target.x) - margin;
|
|
839
|
+
const y0 = Math.min(geo.source.y, geo.target.y) - margin;
|
|
840
|
+
const x1 = Math.max(geo.source.x, geo.target.x) + margin;
|
|
841
|
+
const y1 = Math.max(geo.source.y, geo.target.y) + margin;
|
|
842
|
+
const near = this.spatial.query({ x: x0, y: y0, width: x1 - x0, height: y1 - y0 });
|
|
843
|
+
const out = [];
|
|
844
|
+
for (const id of near) {
|
|
845
|
+
if (id === edge.source || id === edge.target)
|
|
846
|
+
continue;
|
|
847
|
+
const n = this.nodes.get(id);
|
|
848
|
+
if (!n || n.hidden || n.type === 'group')
|
|
849
|
+
continue;
|
|
850
|
+
out.push(this.nodeRect(id));
|
|
851
|
+
}
|
|
852
|
+
return out;
|
|
853
|
+
}
|
|
854
|
+
/** Endpoint geometry for rendering an edge. */
|
|
855
|
+
edgeGeometry(edge) {
|
|
856
|
+
if (!this.nodes.has(edge.source) || !this.nodes.has(edge.target))
|
|
857
|
+
return null;
|
|
858
|
+
const sh = this.resolveHandle(edge.source, 'source', edge.sourceHandle);
|
|
859
|
+
const th = this.resolveHandle(edge.target, 'target', edge.targetHandle);
|
|
860
|
+
return {
|
|
861
|
+
source: this.handleAnchor(sh),
|
|
862
|
+
sourceSide: sh.side,
|
|
863
|
+
target: this.handleAnchor(th),
|
|
864
|
+
targetSide: th.side,
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
// ── selection ─────────────────────────────────────────────────────────
|
|
868
|
+
setSelection(nodeIds, edgeIds = []) {
|
|
869
|
+
const nextNodes = new Set(nodeIds);
|
|
870
|
+
const nextEdges = new Set(edgeIds);
|
|
871
|
+
if (setsEqual(nextNodes, this.selectedNodes) && setsEqual(nextEdges, this.selectedEdges)) {
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
this.batch(() => {
|
|
875
|
+
for (const id of [...this.selectedNodes]) {
|
|
876
|
+
if (!nextNodes.has(id)) {
|
|
877
|
+
const n = this.nodes.get(id);
|
|
878
|
+
if (n)
|
|
879
|
+
this._replaceNode({ ...n, selected: false });
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
for (const id of nextNodes) {
|
|
883
|
+
const n = this.nodes.get(id);
|
|
884
|
+
if (n && !n.selected)
|
|
885
|
+
this._replaceNode({ ...n, selected: true });
|
|
886
|
+
}
|
|
887
|
+
for (const id of [...this.selectedEdges]) {
|
|
888
|
+
if (!nextEdges.has(id)) {
|
|
889
|
+
const e = this.edges.get(id);
|
|
890
|
+
if (e)
|
|
891
|
+
this._replaceEdge({ ...e, selected: false });
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
for (const id of nextEdges) {
|
|
895
|
+
const e = this.edges.get(id);
|
|
896
|
+
if (e && !e.selected)
|
|
897
|
+
this._replaceEdge({ ...e, selected: true });
|
|
898
|
+
}
|
|
899
|
+
this.emit('selection');
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
addToSelection(nodeIds, edgeIds = []) {
|
|
903
|
+
this.setSelection([...this.selectedNodes, ...nodeIds], [...this.selectedEdges, ...edgeIds]);
|
|
904
|
+
}
|
|
905
|
+
toggleSelection(nodeId, edgeId) {
|
|
906
|
+
const nodes = new Set(this.selectedNodes);
|
|
907
|
+
const edges = new Set(this.selectedEdges);
|
|
908
|
+
if (nodeId) {
|
|
909
|
+
if (nodes.has(nodeId))
|
|
910
|
+
nodes.delete(nodeId);
|
|
911
|
+
else
|
|
912
|
+
nodes.add(nodeId);
|
|
913
|
+
}
|
|
914
|
+
if (edgeId) {
|
|
915
|
+
if (edges.has(edgeId))
|
|
916
|
+
edges.delete(edgeId);
|
|
917
|
+
else
|
|
918
|
+
edges.add(edgeId);
|
|
919
|
+
}
|
|
920
|
+
this.setSelection(nodes, edges);
|
|
921
|
+
}
|
|
922
|
+
clearSelection() {
|
|
923
|
+
this.setSelection([], []);
|
|
924
|
+
}
|
|
925
|
+
selectAll() {
|
|
926
|
+
this.setSelection(this.nodeOrder.filter((id) => this.nodes.get(id).selectable !== false), this.edgeOrder);
|
|
927
|
+
}
|
|
928
|
+
deleteSelection() {
|
|
929
|
+
const nodeIds = [...this.selectedNodes];
|
|
930
|
+
const edgeIds = [...this.selectedEdges];
|
|
931
|
+
if (nodeIds.length === 0 && edgeIds.length === 0)
|
|
932
|
+
return;
|
|
933
|
+
this.transact('delete selection', () => {
|
|
934
|
+
this.removeEdges(edgeIds);
|
|
935
|
+
this.removeNodes(nodeIds);
|
|
936
|
+
});
|
|
937
|
+
this.commit();
|
|
938
|
+
}
|
|
939
|
+
// ── clipboard: copy / paste / duplicate ───────────────────────────────
|
|
940
|
+
/**
|
|
941
|
+
* Serialize a set of nodes (default: the selection) plus the edges wholly
|
|
942
|
+
* between them. The result is JSON-serializable — stash it in app state or
|
|
943
|
+
* the system clipboard.
|
|
944
|
+
*/
|
|
945
|
+
copy(nodeIds = [...this.selectedNodes]) {
|
|
946
|
+
const idSet = new Set(nodeIds);
|
|
947
|
+
// Include descendants so groups copy with their children.
|
|
948
|
+
for (const id of nodeIds) {
|
|
949
|
+
const stack = this.childrenOf(id);
|
|
950
|
+
while (stack.length) {
|
|
951
|
+
const kid = stack.pop();
|
|
952
|
+
idSet.add(kid);
|
|
953
|
+
stack.push(...this.childrenOf(kid));
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
const nodes = [...idSet].filter((id) => this.nodes.has(id)).map((id) => ({ ...this.nodes.get(id) }));
|
|
957
|
+
const seen = new Set();
|
|
958
|
+
const edges = [];
|
|
959
|
+
for (const id of idSet) {
|
|
960
|
+
for (const e of this.edgesOf(id)) {
|
|
961
|
+
if (!seen.has(e.id) && idSet.has(e.source) && idSet.has(e.target)) {
|
|
962
|
+
seen.add(e.id);
|
|
963
|
+
edges.push({ ...e });
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
return { nodes, edges };
|
|
968
|
+
}
|
|
969
|
+
/**
|
|
970
|
+
* Insert a copied payload with fresh ids, offset so pasted nodes don't sit
|
|
971
|
+
* exactly on the originals, and select the result. Returns the new ids.
|
|
972
|
+
* One undo entry.
|
|
973
|
+
*/
|
|
974
|
+
paste(payload, offset = { x: 24, y: 24 }) {
|
|
975
|
+
const idMap = new Map();
|
|
976
|
+
for (const n of payload.nodes)
|
|
977
|
+
idMap.set(n.id, uid('n'));
|
|
978
|
+
const newNodes = payload.nodes.map((n) => ({
|
|
979
|
+
...n,
|
|
980
|
+
id: idMap.get(n.id),
|
|
981
|
+
// Only offset roots of the pasted set (children keep relative pos).
|
|
982
|
+
parentId: n.parentId != null && idMap.has(n.parentId) ? idMap.get(n.parentId) : undefined,
|
|
983
|
+
position: n.parentId != null && idMap.has(n.parentId)
|
|
984
|
+
? { ...n.position }
|
|
985
|
+
: { x: n.position.x + offset.x, y: n.position.y + offset.y },
|
|
986
|
+
selected: true,
|
|
987
|
+
}));
|
|
988
|
+
const newEdges = payload.edges
|
|
989
|
+
.filter((e) => idMap.has(e.source) && idMap.has(e.target))
|
|
990
|
+
.map((e) => ({
|
|
991
|
+
...e,
|
|
992
|
+
id: uid('e'),
|
|
993
|
+
source: idMap.get(e.source),
|
|
994
|
+
target: idMap.get(e.target),
|
|
995
|
+
selected: false,
|
|
996
|
+
}));
|
|
997
|
+
this.transact('paste', () => {
|
|
998
|
+
this.addNodes(newNodes);
|
|
999
|
+
this.addEdges(newEdges);
|
|
1000
|
+
this.setSelection(newNodes.map((n) => n.id), []);
|
|
1001
|
+
});
|
|
1002
|
+
this.commit();
|
|
1003
|
+
return { nodeIds: newNodes.map((n) => n.id), edgeIds: newEdges.map((e) => e.id) };
|
|
1004
|
+
}
|
|
1005
|
+
/** Copy + paste the selection in one step (⌘D-style). */
|
|
1006
|
+
duplicateSelection(offset = { x: 24, y: 24 }) {
|
|
1007
|
+
return this.paste(this.copy(), offset);
|
|
1008
|
+
}
|
|
1009
|
+
// ── dragging (with grid snap + alignment guides) ──────────────────────
|
|
1010
|
+
startDrag(ids) {
|
|
1011
|
+
const moving = ids.filter((id) => {
|
|
1012
|
+
const n = this.nodes.get(id);
|
|
1013
|
+
return n && n.draggable !== false;
|
|
1014
|
+
});
|
|
1015
|
+
if (moving.length === 0)
|
|
1016
|
+
return;
|
|
1017
|
+
const movingSet = new Set(moving);
|
|
1018
|
+
const start = new Map();
|
|
1019
|
+
for (const id of moving)
|
|
1020
|
+
start.set(id, { ...this.nodes.get(id).position });
|
|
1021
|
+
const guideRects = [];
|
|
1022
|
+
if (this.options.alignmentGuides !== false) {
|
|
1023
|
+
const view = this.cullingActive
|
|
1024
|
+
? this.visibleNodes
|
|
1025
|
+
: new Set(this.nodeOrder);
|
|
1026
|
+
for (const id of view) {
|
|
1027
|
+
if (movingSet.has(id))
|
|
1028
|
+
continue;
|
|
1029
|
+
// Skip nodes inside the moving subtree.
|
|
1030
|
+
let p = this.nodes.get(id)?.parentId;
|
|
1031
|
+
let inside = false;
|
|
1032
|
+
while (p) {
|
|
1033
|
+
if (movingSet.has(p)) {
|
|
1034
|
+
inside = true;
|
|
1035
|
+
break;
|
|
1036
|
+
}
|
|
1037
|
+
p = this.nodes.get(p)?.parentId;
|
|
1038
|
+
}
|
|
1039
|
+
if (!inside && !this.nodes.get(id)?.hidden)
|
|
1040
|
+
guideRects.push(this.nodeRect(id));
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
this.drag = { ids: moving, start, guideRects, moved: false };
|
|
1044
|
+
}
|
|
1045
|
+
/** Move the dragged nodes by a flow-space delta from the drag origin. */
|
|
1046
|
+
dragBy(delta) {
|
|
1047
|
+
const drag = this.drag;
|
|
1048
|
+
if (!drag)
|
|
1049
|
+
return;
|
|
1050
|
+
let dx = delta.x;
|
|
1051
|
+
let dy = delta.y;
|
|
1052
|
+
const grid = this.options.snapGrid ?? 0;
|
|
1053
|
+
let guides = [];
|
|
1054
|
+
// Alignment guides: snap the union bounds of the moving nodes.
|
|
1055
|
+
if (this.options.alignmentGuides !== false && drag.guideRects.length > 0) {
|
|
1056
|
+
let bounds = null;
|
|
1057
|
+
for (const id of drag.ids) {
|
|
1058
|
+
const startPos = drag.start.get(id);
|
|
1059
|
+
const node = this.nodes.get(id);
|
|
1060
|
+
const abs = this.absolutePosition(id);
|
|
1061
|
+
const size = this.nodeSize(id);
|
|
1062
|
+
// Absolute rect at the tentative position.
|
|
1063
|
+
const rect = {
|
|
1064
|
+
x: abs.x - node.position.x + startPos.x + dx,
|
|
1065
|
+
y: abs.y - node.position.y + startPos.y + dy,
|
|
1066
|
+
width: size.width,
|
|
1067
|
+
height: size.height,
|
|
1068
|
+
};
|
|
1069
|
+
bounds = rectUnion(bounds, rect);
|
|
1070
|
+
}
|
|
1071
|
+
if (bounds) {
|
|
1072
|
+
const res = computeGuides(bounds, drag.guideRects, this.options.guideThreshold ?? 6);
|
|
1073
|
+
dx += res.dx;
|
|
1074
|
+
dy += res.dy;
|
|
1075
|
+
guides = res.guides;
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
this.batch(() => {
|
|
1079
|
+
for (const id of drag.ids) {
|
|
1080
|
+
const node = this.nodes.get(id);
|
|
1081
|
+
const startPos = drag.start.get(id);
|
|
1082
|
+
if (!node || !startPos)
|
|
1083
|
+
continue;
|
|
1084
|
+
let pos = { x: startPos.x + dx, y: startPos.y + dy };
|
|
1085
|
+
if (grid > 0)
|
|
1086
|
+
pos = snapToGrid(pos, grid);
|
|
1087
|
+
if (node.extent === 'parent' && node.parentId) {
|
|
1088
|
+
const parentSize = this.nodeSize(node.parentId);
|
|
1089
|
+
const size = this.nodeSize(id);
|
|
1090
|
+
pos = {
|
|
1091
|
+
x: clamp(pos.x, 0, Math.max(0, parentSize.width - size.width)),
|
|
1092
|
+
y: clamp(pos.y, 0, Math.max(0, parentSize.height - size.height)),
|
|
1093
|
+
};
|
|
1094
|
+
}
|
|
1095
|
+
if (pos.x !== node.position.x || pos.y !== node.position.y) {
|
|
1096
|
+
this.nodes.set(id, { ...node, position: pos });
|
|
1097
|
+
this.touchNode(id);
|
|
1098
|
+
drag.moved = true;
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
this.setGuides(guides);
|
|
1102
|
+
});
|
|
1103
|
+
}
|
|
1104
|
+
endDrag() {
|
|
1105
|
+
const drag = this.drag;
|
|
1106
|
+
this.drag = null;
|
|
1107
|
+
this.setGuides([]);
|
|
1108
|
+
if (!drag || !drag.moved)
|
|
1109
|
+
return;
|
|
1110
|
+
const before = drag.start;
|
|
1111
|
+
const after = new Map();
|
|
1112
|
+
for (const id of drag.ids) {
|
|
1113
|
+
const n = this.nodes.get(id);
|
|
1114
|
+
if (n)
|
|
1115
|
+
after.set(id, { ...n.position });
|
|
1116
|
+
}
|
|
1117
|
+
this.record('move', {
|
|
1118
|
+
undo: () => this.batch(() => {
|
|
1119
|
+
for (const [id, pos] of before) {
|
|
1120
|
+
const n = this.nodes.get(id);
|
|
1121
|
+
if (n) {
|
|
1122
|
+
this.nodes.set(id, { ...n, position: pos });
|
|
1123
|
+
this.touchNode(id);
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
}),
|
|
1127
|
+
redo: () => this.batch(() => {
|
|
1128
|
+
for (const [id, pos] of after) {
|
|
1129
|
+
const n = this.nodes.get(id);
|
|
1130
|
+
if (n) {
|
|
1131
|
+
this.nodes.set(id, { ...n, position: pos });
|
|
1132
|
+
this.touchNode(id);
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
}),
|
|
1136
|
+
});
|
|
1137
|
+
this.commit();
|
|
1138
|
+
}
|
|
1139
|
+
get dragging() {
|
|
1140
|
+
return this.drag !== null;
|
|
1141
|
+
}
|
|
1142
|
+
setGuides(guides) {
|
|
1143
|
+
if (guides.length === 0 && this.guides.length === 0)
|
|
1144
|
+
return;
|
|
1145
|
+
this.guides = guides;
|
|
1146
|
+
this.emit('guides');
|
|
1147
|
+
}
|
|
1148
|
+
// ── connections ───────────────────────────────────────────────────────
|
|
1149
|
+
startConnection(nodeId, handleId, kind = 'source') {
|
|
1150
|
+
const node = this.nodes.get(nodeId);
|
|
1151
|
+
if (!node || node.connectable === false)
|
|
1152
|
+
return;
|
|
1153
|
+
const handle = this.resolveHandle(nodeId, kind, handleId);
|
|
1154
|
+
this.reconnecting = null;
|
|
1155
|
+
this.connection = {
|
|
1156
|
+
fromNode: nodeId,
|
|
1157
|
+
fromHandle: handle,
|
|
1158
|
+
to: this.handleAnchor(handle),
|
|
1159
|
+
toHandle: null,
|
|
1160
|
+
valid: null,
|
|
1161
|
+
};
|
|
1162
|
+
this.emit('connection');
|
|
1163
|
+
}
|
|
1164
|
+
/**
|
|
1165
|
+
* Begin reconnecting one end of an existing edge. The opposite (anchored)
|
|
1166
|
+
* end becomes the connection origin; dropping on a new handle moves this
|
|
1167
|
+
* edge's endpoint. Dropping on nothing deletes the edge (React Flow's
|
|
1168
|
+
* default reconnect-to-void behavior).
|
|
1169
|
+
*/
|
|
1170
|
+
startReconnect(edgeId, end) {
|
|
1171
|
+
const edge = this.edges.get(edgeId);
|
|
1172
|
+
if (!edge)
|
|
1173
|
+
return;
|
|
1174
|
+
// The anchored end is the OPPOSITE of the one being dragged.
|
|
1175
|
+
const anchorEnd = end === 'source' ? 'target' : 'source';
|
|
1176
|
+
const anchorNode = anchorEnd === 'source' ? edge.source : edge.target;
|
|
1177
|
+
const anchorHandleId = anchorEnd === 'source' ? edge.sourceHandle : edge.targetHandle;
|
|
1178
|
+
const handle = this.resolveHandle(anchorNode, anchorEnd, anchorHandleId);
|
|
1179
|
+
this.reconnecting = { edgeId, end };
|
|
1180
|
+
this.connection = {
|
|
1181
|
+
fromNode: anchorNode,
|
|
1182
|
+
fromHandle: handle,
|
|
1183
|
+
to: this.handleAnchor(handle),
|
|
1184
|
+
toHandle: null,
|
|
1185
|
+
valid: null,
|
|
1186
|
+
};
|
|
1187
|
+
this.emit('connection');
|
|
1188
|
+
}
|
|
1189
|
+
get isReconnecting() {
|
|
1190
|
+
return this.reconnecting !== null;
|
|
1191
|
+
}
|
|
1192
|
+
moveConnection(to) {
|
|
1193
|
+
if (!this.connection)
|
|
1194
|
+
return;
|
|
1195
|
+
const snap = this.findCompatibleHandle(to, 28);
|
|
1196
|
+
this.connection = {
|
|
1197
|
+
...this.connection,
|
|
1198
|
+
to: snap ? this.handleAnchor(snap.handle) : to,
|
|
1199
|
+
toHandle: snap?.handle ?? null,
|
|
1200
|
+
valid: snap ? snap.valid : null,
|
|
1201
|
+
};
|
|
1202
|
+
this.emit('connection');
|
|
1203
|
+
}
|
|
1204
|
+
/** Complete the pending connection; returns the new/updated edge. */
|
|
1205
|
+
endConnection() {
|
|
1206
|
+
const conn = this.connection;
|
|
1207
|
+
const reconnect = this.reconnecting;
|
|
1208
|
+
this.connection = null;
|
|
1209
|
+
this.reconnecting = null;
|
|
1210
|
+
this.emit('connection');
|
|
1211
|
+
if (!conn)
|
|
1212
|
+
return null;
|
|
1213
|
+
// Reconnection: move the dragged end, or delete the edge on drop-to-void.
|
|
1214
|
+
if (reconnect) {
|
|
1215
|
+
const edge = this.edges.get(reconnect.edgeId);
|
|
1216
|
+
if (!edge)
|
|
1217
|
+
return null;
|
|
1218
|
+
// Dropped on empty space (no handle): delete the edge (drop-to-void).
|
|
1219
|
+
if (!conn.toHandle) {
|
|
1220
|
+
this.removeEdges([reconnect.edgeId]);
|
|
1221
|
+
return null;
|
|
1222
|
+
}
|
|
1223
|
+
// Dropped on an incompatible handle (duplicate/cycle/type): revert,
|
|
1224
|
+
// leaving the edge untouched.
|
|
1225
|
+
if (!conn.valid)
|
|
1226
|
+
return null;
|
|
1227
|
+
const patch = reconnect.end === 'source'
|
|
1228
|
+
? { source: conn.toHandle.nodeId, sourceHandle: normalizeHandleId(conn.toHandle.id) }
|
|
1229
|
+
: { target: conn.toHandle.nodeId, targetHandle: normalizeHandleId(conn.toHandle.id) };
|
|
1230
|
+
// Guard against creating an invalid edge (self-loop, dup, cycle).
|
|
1231
|
+
const candidate = {
|
|
1232
|
+
source: reconnect.end === 'source' ? conn.toHandle.nodeId : edge.source,
|
|
1233
|
+
target: reconnect.end === 'target' ? conn.toHandle.nodeId : edge.target,
|
|
1234
|
+
sourceHandle: reconnect.end === 'source' ? conn.toHandle.id : edge.sourceHandle,
|
|
1235
|
+
targetHandle: reconnect.end === 'target' ? conn.toHandle.id : edge.targetHandle,
|
|
1236
|
+
};
|
|
1237
|
+
if (this.validateCandidate(candidate) !== true)
|
|
1238
|
+
return null;
|
|
1239
|
+
this.updateEdge(reconnect.edgeId, patch);
|
|
1240
|
+
return this.edges.get(reconnect.edgeId) ?? null;
|
|
1241
|
+
}
|
|
1242
|
+
if (!conn.toHandle || !conn.valid)
|
|
1243
|
+
return null;
|
|
1244
|
+
const from = conn.fromHandle;
|
|
1245
|
+
const to = conn.toHandle;
|
|
1246
|
+
const candidate = from.kind === 'source'
|
|
1247
|
+
? {
|
|
1248
|
+
source: from.nodeId,
|
|
1249
|
+
sourceHandle: from.id,
|
|
1250
|
+
target: to.nodeId,
|
|
1251
|
+
targetHandle: to.id,
|
|
1252
|
+
}
|
|
1253
|
+
: {
|
|
1254
|
+
source: to.nodeId,
|
|
1255
|
+
sourceHandle: to.id,
|
|
1256
|
+
target: from.nodeId,
|
|
1257
|
+
targetHandle: from.id,
|
|
1258
|
+
};
|
|
1259
|
+
return this.connect(candidate);
|
|
1260
|
+
}
|
|
1261
|
+
cancelConnection() {
|
|
1262
|
+
if (!this.connection)
|
|
1263
|
+
return;
|
|
1264
|
+
this.connection = null;
|
|
1265
|
+
this.reconnecting = null;
|
|
1266
|
+
this.emit('connection');
|
|
1267
|
+
}
|
|
1268
|
+
/** Validate and create an edge (respecting defaultEdgeOptions). */
|
|
1269
|
+
connect(candidate, props = {}) {
|
|
1270
|
+
const verdict = this.validateCandidate(candidate);
|
|
1271
|
+
if (verdict !== true)
|
|
1272
|
+
return null;
|
|
1273
|
+
const edge = {
|
|
1274
|
+
id: uid('e'),
|
|
1275
|
+
type: 'bezier',
|
|
1276
|
+
...this.options.defaultEdgeOptions,
|
|
1277
|
+
...props,
|
|
1278
|
+
source: candidate.source,
|
|
1279
|
+
target: candidate.target,
|
|
1280
|
+
sourceHandle: normalizeHandleId(candidate.sourceHandle),
|
|
1281
|
+
targetHandle: normalizeHandleId(candidate.targetHandle),
|
|
1282
|
+
};
|
|
1283
|
+
this.addEdges([edge]);
|
|
1284
|
+
return this.edges.get(edge.id) ?? null;
|
|
1285
|
+
}
|
|
1286
|
+
/** Returns true, or a string describing why the connection is invalid. */
|
|
1287
|
+
validateCandidate(c) {
|
|
1288
|
+
if (!this.nodes.has(c.source) || !this.nodes.has(c.target))
|
|
1289
|
+
return 'missing node';
|
|
1290
|
+
if (c.source === c.target)
|
|
1291
|
+
return 'self loop';
|
|
1292
|
+
if (!this.options.allowDuplicateEdges) {
|
|
1293
|
+
// Synthetic default-handle ids ("__source"/"__target") are equivalent
|
|
1294
|
+
// to an unset handle, so normalize before comparing.
|
|
1295
|
+
const norm = (h) => h == null || h === '__source' || h === '__target' ? null : h;
|
|
1296
|
+
const set = this.nodeEdges.get(c.source);
|
|
1297
|
+
if (set) {
|
|
1298
|
+
for (const eid of set) {
|
|
1299
|
+
const e = this.edges.get(eid);
|
|
1300
|
+
if (e.source === c.source &&
|
|
1301
|
+
e.target === c.target &&
|
|
1302
|
+
norm(e.sourceHandle) === norm(c.sourceHandle) &&
|
|
1303
|
+
norm(e.targetHandle) === norm(c.targetHandle)) {
|
|
1304
|
+
return 'duplicate edge';
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
const sh = this.resolveHandle(c.source, 'source', c.sourceHandle);
|
|
1310
|
+
const th = this.resolveHandle(c.target, 'target', c.targetHandle);
|
|
1311
|
+
if (sh.dataType && th.dataType && sh.dataType !== th.dataType)
|
|
1312
|
+
return 'incompatible types';
|
|
1313
|
+
for (const h of [sh, th]) {
|
|
1314
|
+
if (h.maxConnections != null && this.handleConnectionCount(h) >= h.maxConnections) {
|
|
1315
|
+
return 'handle full';
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
if (this.options.preventCycles && this.wouldCreateCycle(c.source, c.target)) {
|
|
1319
|
+
return 'cycle';
|
|
1320
|
+
}
|
|
1321
|
+
const custom = this.options.validateConnection;
|
|
1322
|
+
if (custom) {
|
|
1323
|
+
const res = custom(c, { sourceHandle: sh, targetHandle: th });
|
|
1324
|
+
if (res !== true)
|
|
1325
|
+
return typeof res === 'string' ? res : 'rejected';
|
|
1326
|
+
}
|
|
1327
|
+
return true;
|
|
1328
|
+
}
|
|
1329
|
+
handleConnectionCount(h) {
|
|
1330
|
+
const set = this.nodeEdges.get(h.nodeId);
|
|
1331
|
+
if (!set)
|
|
1332
|
+
return 0;
|
|
1333
|
+
let count = 0;
|
|
1334
|
+
for (const eid of set) {
|
|
1335
|
+
const e = this.edges.get(eid);
|
|
1336
|
+
if (h.kind === 'source' && e.source === h.nodeId && (e.sourceHandle ?? `__source`) === h.id)
|
|
1337
|
+
count++;
|
|
1338
|
+
if (h.kind === 'target' && e.target === h.nodeId && (e.targetHandle ?? `__target`) === h.id)
|
|
1339
|
+
count++;
|
|
1340
|
+
}
|
|
1341
|
+
return count;
|
|
1342
|
+
}
|
|
1343
|
+
/** Would adding source→target create a directed cycle? */
|
|
1344
|
+
wouldCreateCycle(source, target) {
|
|
1345
|
+
if (source === target)
|
|
1346
|
+
return true;
|
|
1347
|
+
const stack = [target];
|
|
1348
|
+
const seen = new Set();
|
|
1349
|
+
while (stack.length > 0) {
|
|
1350
|
+
const id = stack.pop();
|
|
1351
|
+
if (id === source)
|
|
1352
|
+
return true;
|
|
1353
|
+
if (seen.has(id))
|
|
1354
|
+
continue;
|
|
1355
|
+
seen.add(id);
|
|
1356
|
+
const set = this.nodeEdges.get(id);
|
|
1357
|
+
if (set) {
|
|
1358
|
+
for (const eid of set) {
|
|
1359
|
+
const e = this.edges.get(eid);
|
|
1360
|
+
if (e.source === id)
|
|
1361
|
+
stack.push(e.target);
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
return false;
|
|
1366
|
+
}
|
|
1367
|
+
/** Nearest compatible opposite-kind handle within `radius` flow px. */
|
|
1368
|
+
findCompatibleHandle(p, radius) {
|
|
1369
|
+
const conn = this.connection;
|
|
1370
|
+
if (!conn)
|
|
1371
|
+
return null;
|
|
1372
|
+
const wantKind = conn.fromHandle.kind === 'source' ? 'target' : 'source';
|
|
1373
|
+
const searchRect = {
|
|
1374
|
+
x: p.x - radius,
|
|
1375
|
+
y: p.y - radius,
|
|
1376
|
+
width: radius * 2,
|
|
1377
|
+
height: radius * 2,
|
|
1378
|
+
};
|
|
1379
|
+
// Handles sit on node borders, so search nodes near the pointer
|
|
1380
|
+
// (expanded to catch handles that poke outside the node rect).
|
|
1381
|
+
const near = this.spatial.query(expandRect(searchRect, 16));
|
|
1382
|
+
let best = null;
|
|
1383
|
+
for (const nodeId of near) {
|
|
1384
|
+
if (nodeId === conn.fromNode)
|
|
1385
|
+
continue;
|
|
1386
|
+
const node = this.nodes.get(nodeId);
|
|
1387
|
+
if (!node || node.hidden || node.connectable === false)
|
|
1388
|
+
continue;
|
|
1389
|
+
const registered = this.handles.get(nodeId);
|
|
1390
|
+
const candidates = registered
|
|
1391
|
+
? [...registered.values()].filter((h) => h.kind === wantKind)
|
|
1392
|
+
: [this.resolveHandle(nodeId, wantKind)];
|
|
1393
|
+
for (const h of candidates) {
|
|
1394
|
+
const anchor = this.handleAnchor(h);
|
|
1395
|
+
const d = Math.hypot(anchor.x - p.x, anchor.y - p.y);
|
|
1396
|
+
if (d <= radius && (!best || d < best.d))
|
|
1397
|
+
best = { handle: h, d };
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
if (!best)
|
|
1401
|
+
return null;
|
|
1402
|
+
const from = conn.fromHandle;
|
|
1403
|
+
const candidate = from.kind === 'source'
|
|
1404
|
+
? {
|
|
1405
|
+
source: from.nodeId,
|
|
1406
|
+
sourceHandle: from.id,
|
|
1407
|
+
target: best.handle.nodeId,
|
|
1408
|
+
targetHandle: best.handle.id,
|
|
1409
|
+
}
|
|
1410
|
+
: {
|
|
1411
|
+
source: best.handle.nodeId,
|
|
1412
|
+
sourceHandle: best.handle.id,
|
|
1413
|
+
target: from.nodeId,
|
|
1414
|
+
targetHandle: from.id,
|
|
1415
|
+
};
|
|
1416
|
+
return { handle: best.handle, valid: this.validateCandidate(candidate) === true };
|
|
1417
|
+
}
|
|
1418
|
+
// ── viewport ──────────────────────────────────────────────────────────
|
|
1419
|
+
setScreenSize(width, height) {
|
|
1420
|
+
if (this.screen.width === width && this.screen.height === height)
|
|
1421
|
+
return;
|
|
1422
|
+
this.screen = { width, height };
|
|
1423
|
+
this.scheduleCull();
|
|
1424
|
+
}
|
|
1425
|
+
setViewport(v) {
|
|
1426
|
+
const minZoom = this.options.minZoom ?? 0.1;
|
|
1427
|
+
const maxZoom = this.options.maxZoom ?? 2.5;
|
|
1428
|
+
const next = { x: v.x, y: v.y, zoom: clamp(v.zoom, minZoom, maxZoom) };
|
|
1429
|
+
if (next.x === this.viewport.x &&
|
|
1430
|
+
next.y === this.viewport.y &&
|
|
1431
|
+
next.zoom === this.viewport.zoom) {
|
|
1432
|
+
return;
|
|
1433
|
+
}
|
|
1434
|
+
this.viewport = next;
|
|
1435
|
+
this.emit('viewport');
|
|
1436
|
+
this.scheduleCull(false);
|
|
1437
|
+
}
|
|
1438
|
+
panBy(dx, dy) {
|
|
1439
|
+
this.setViewport({ x: this.viewport.x + dx, y: this.viewport.y + dy, zoom: this.viewport.zoom });
|
|
1440
|
+
}
|
|
1441
|
+
/** Zoom by factor around a screen-space pivot (defaults to center). */
|
|
1442
|
+
zoomBy(factor, pivot) {
|
|
1443
|
+
const p = pivot ?? { x: this.screen.width / 2, y: this.screen.height / 2 };
|
|
1444
|
+
this.setViewport(zoomAt(this.viewport, factor, p, this.options.minZoom ?? 0.1, this.options.maxZoom ?? 2.5));
|
|
1445
|
+
}
|
|
1446
|
+
zoomTo(zoom, duration = 0) {
|
|
1447
|
+
const c = { x: this.screen.width / 2, y: this.screen.height / 2 };
|
|
1448
|
+
const target = zoomAt(this.viewport, zoom / this.viewport.zoom, c, this.options.minZoom ?? 0.1, this.options.maxZoom ?? 2.5);
|
|
1449
|
+
this.animateViewport(target, duration);
|
|
1450
|
+
}
|
|
1451
|
+
fitView(opts = {}) {
|
|
1452
|
+
if (this.screen.width === 0 || this.screen.height === 0)
|
|
1453
|
+
return;
|
|
1454
|
+
const bounds = this.nodesBounds(opts.nodes);
|
|
1455
|
+
if (bounds.width === 0 && bounds.height === 0)
|
|
1456
|
+
return;
|
|
1457
|
+
const target = fitRect(bounds, this.screen.width, this.screen.height, opts.padding ?? 0.1, opts.minZoom ?? this.options.minZoom ?? 0.05, opts.maxZoom ?? this.options.maxZoom ?? 2.5);
|
|
1458
|
+
this.animateViewport(target, opts.duration ?? 0);
|
|
1459
|
+
}
|
|
1460
|
+
/** Center the view on a node (nice for search/jump UX). */
|
|
1461
|
+
centerNode(id, duration = 300) {
|
|
1462
|
+
const r = this.spatial.getBounds(id);
|
|
1463
|
+
if (!r || this.screen.width === 0)
|
|
1464
|
+
return;
|
|
1465
|
+
const zoom = this.viewport.zoom;
|
|
1466
|
+
this.animateViewport({
|
|
1467
|
+
x: this.screen.width / 2 - (r.x + r.width / 2) * zoom,
|
|
1468
|
+
y: this.screen.height / 2 - (r.y + r.height / 2) * zoom,
|
|
1469
|
+
zoom,
|
|
1470
|
+
}, duration);
|
|
1471
|
+
}
|
|
1472
|
+
animateViewport(target, duration = 300) {
|
|
1473
|
+
if (this.vpAnim != null && typeof cancelAnimationFrame !== 'undefined') {
|
|
1474
|
+
cancelAnimationFrame(this.vpAnim);
|
|
1475
|
+
this.vpAnim = null;
|
|
1476
|
+
}
|
|
1477
|
+
if (duration <= 0 || typeof requestAnimationFrame === 'undefined') {
|
|
1478
|
+
this.setViewport(target);
|
|
1479
|
+
return;
|
|
1480
|
+
}
|
|
1481
|
+
const from = { ...this.viewport };
|
|
1482
|
+
const start = performance.now();
|
|
1483
|
+
const ease = (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
|
|
1484
|
+
const tick = (now) => {
|
|
1485
|
+
const t = clamp((now - start) / duration, 0, 1);
|
|
1486
|
+
const k = ease(t);
|
|
1487
|
+
this.setViewport({
|
|
1488
|
+
x: from.x + (target.x - from.x) * k,
|
|
1489
|
+
y: from.y + (target.y - from.y) * k,
|
|
1490
|
+
zoom: from.zoom + (target.zoom - from.zoom) * k,
|
|
1491
|
+
});
|
|
1492
|
+
this.vpAnim = t < 1 ? requestAnimationFrame(tick) : null;
|
|
1493
|
+
};
|
|
1494
|
+
this.vpAnim = requestAnimationFrame(tick);
|
|
1495
|
+
}
|
|
1496
|
+
// ── culling ───────────────────────────────────────────────────────────
|
|
1497
|
+
scheduleCull(force = true) {
|
|
1498
|
+
this.cullForced = this.cullForced || force;
|
|
1499
|
+
if (this.cullScheduled)
|
|
1500
|
+
return;
|
|
1501
|
+
this.cullScheduled = true;
|
|
1502
|
+
rafSchedule(() => {
|
|
1503
|
+
this.cullScheduled = false;
|
|
1504
|
+
const forced = this.cullForced;
|
|
1505
|
+
this.cullForced = false;
|
|
1506
|
+
this.cull(forced);
|
|
1507
|
+
});
|
|
1508
|
+
}
|
|
1509
|
+
/** Recompute the visible node/edge sets; emits 'visible' on change. */
|
|
1510
|
+
cull(force = true) {
|
|
1511
|
+
const total = this.nodes.size;
|
|
1512
|
+
const threshold = 200;
|
|
1513
|
+
const active = this.screen.width > 0 && total > threshold;
|
|
1514
|
+
let nextNodes;
|
|
1515
|
+
if (active) {
|
|
1516
|
+
const margin = this.options.cullingMargin ?? 300;
|
|
1517
|
+
const raw = visibleRect(this.viewport, this.screen.width, this.screen.height);
|
|
1518
|
+
// Skip only on a pure pan (same zoom) still inside the overscan buffer.
|
|
1519
|
+
if (!force &&
|
|
1520
|
+
this.lastCullRect &&
|
|
1521
|
+
this.viewport.zoom === this.lastCullZoom &&
|
|
1522
|
+
rectContainsRect(this.lastCullRect, raw)) {
|
|
1523
|
+
return; // still covered by the last cull's overscan
|
|
1524
|
+
}
|
|
1525
|
+
const view = expandRect(raw, margin);
|
|
1526
|
+
this.lastCullRect = expandRect(raw, margin / 2);
|
|
1527
|
+
this.lastCullZoom = this.viewport.zoom;
|
|
1528
|
+
nextNodes = this.spatial.query(view);
|
|
1529
|
+
// Selected + dragged nodes always render.
|
|
1530
|
+
for (const id of this.selectedNodes)
|
|
1531
|
+
nextNodes.add(id);
|
|
1532
|
+
if (this.drag)
|
|
1533
|
+
for (const id of this.drag.ids)
|
|
1534
|
+
nextNodes.add(id);
|
|
1535
|
+
}
|
|
1536
|
+
else {
|
|
1537
|
+
nextNodes = new Set(this.nodeOrder);
|
|
1538
|
+
}
|
|
1539
|
+
const nextRoots = new Set();
|
|
1540
|
+
for (const id of nextNodes) {
|
|
1541
|
+
let node = this.nodes.get(id);
|
|
1542
|
+
let cur = id;
|
|
1543
|
+
let guard = 0;
|
|
1544
|
+
while (node?.parentId && guard++ < 100) {
|
|
1545
|
+
cur = node.parentId;
|
|
1546
|
+
node = this.nodes.get(cur);
|
|
1547
|
+
}
|
|
1548
|
+
if (node)
|
|
1549
|
+
nextRoots.add(cur);
|
|
1550
|
+
}
|
|
1551
|
+
// Children of visible roots render with their parent.
|
|
1552
|
+
const stack = [...nextRoots];
|
|
1553
|
+
while (stack.length > 0) {
|
|
1554
|
+
const id = stack.pop();
|
|
1555
|
+
const kids = this.children.get(id);
|
|
1556
|
+
if (kids) {
|
|
1557
|
+
for (const kid of kids) {
|
|
1558
|
+
if (!nextNodes.has(kid))
|
|
1559
|
+
nextNodes.add(kid);
|
|
1560
|
+
stack.push(kid);
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
const nextEdges = new Set();
|
|
1565
|
+
if (active) {
|
|
1566
|
+
for (const eid of this.edgeOrder) {
|
|
1567
|
+
const e = this.edges.get(eid);
|
|
1568
|
+
if (e.hidden)
|
|
1569
|
+
continue;
|
|
1570
|
+
if (nextNodes.has(e.source) || nextNodes.has(e.target))
|
|
1571
|
+
nextEdges.add(eid);
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
else {
|
|
1575
|
+
for (const eid of this.edgeOrder)
|
|
1576
|
+
nextEdges.add(eid);
|
|
1577
|
+
}
|
|
1578
|
+
// Only swap the exposed sets when membership changed, so renderers can
|
|
1579
|
+
// use them as useSyncExternalStore snapshots (stable identity).
|
|
1580
|
+
const changed = this.cullingActive !== active ||
|
|
1581
|
+
!setsEqual(nextRoots, this.visibleRoots) ||
|
|
1582
|
+
!setsEqual(nextEdges, this.visibleEdges);
|
|
1583
|
+
this.visibleNodes = nextNodes;
|
|
1584
|
+
this.cullingActive = active;
|
|
1585
|
+
if (changed) {
|
|
1586
|
+
this.visibleRoots = nextRoots;
|
|
1587
|
+
this.visibleEdges = nextEdges;
|
|
1588
|
+
this.emit('visible');
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
// ── serialization ─────────────────────────────────────────────────────
|
|
1592
|
+
toSnapshot() {
|
|
1593
|
+
return {
|
|
1594
|
+
version: 1,
|
|
1595
|
+
nodes: this.getNodes().map((n) => ({ ...n })),
|
|
1596
|
+
edges: this.getEdges().map((e) => ({ ...e })),
|
|
1597
|
+
viewport: { ...this.viewport },
|
|
1598
|
+
};
|
|
1599
|
+
}
|
|
1600
|
+
loadSnapshot(snap) {
|
|
1601
|
+
this.batch(() => {
|
|
1602
|
+
this.setGraph(snap.nodes, snap.edges);
|
|
1603
|
+
if (snap.viewport)
|
|
1604
|
+
this.setViewport(snap.viewport);
|
|
1605
|
+
});
|
|
1606
|
+
this.clearHistory();
|
|
1607
|
+
this.commit();
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
//# sourceMappingURL=store.js.map
|