@pyreon/flow 0.5.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/lib/analysis/index.js.html +5406 -0
- package/lib/chunk-C8JhGJ3N.js +33 -0
- package/lib/elk.bundled-B9dPTHTZ.js +88591 -0
- package/lib/elk.bundled-B9dPTHTZ.js.map +1 -0
- package/lib/index.js +2526 -0
- package/lib/index.js.map +1 -0
- package/lib/types/chunk.d.ts +2 -0
- package/lib/types/elk.bundled.d.ts +7 -0
- package/lib/types/elk.bundled.d.ts.map +1 -0
- package/lib/types/index.d.ts +2342 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index2.d.ts +708 -0
- package/lib/types/index2.d.ts.map +1 -0
- package/package.json +55 -0
- package/src/components/background.tsx +128 -0
- package/src/components/controls.tsx +158 -0
- package/src/components/flow-component.tsx +918 -0
- package/src/components/handle.tsx +42 -0
- package/src/components/minimap.tsx +136 -0
- package/src/components/node-resizer.tsx +169 -0
- package/src/components/node-toolbar.tsx +74 -0
- package/src/components/panel.tsx +33 -0
- package/src/edges.ts +369 -0
- package/src/flow.ts +1141 -0
- package/src/index.ts +83 -0
- package/src/layout.ts +152 -0
- package/src/styles.ts +115 -0
- package/src/tests/flow-advanced.test.ts +802 -0
- package/src/tests/flow.test.ts +1284 -0
- package/src/types.ts +483 -0
package/src/flow.ts
ADDED
|
@@ -0,0 +1,1141 @@
|
|
|
1
|
+
import { batch, computed, signal } from '@pyreon/reactivity'
|
|
2
|
+
import { computeLayout } from './layout'
|
|
3
|
+
import type {
|
|
4
|
+
Connection,
|
|
5
|
+
FlowConfig,
|
|
6
|
+
FlowEdge,
|
|
7
|
+
FlowInstance,
|
|
8
|
+
FlowNode,
|
|
9
|
+
LayoutAlgorithm,
|
|
10
|
+
LayoutOptions,
|
|
11
|
+
NodeChange,
|
|
12
|
+
XYPosition,
|
|
13
|
+
} from './types'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Generate a unique edge id from source/target.
|
|
17
|
+
*/
|
|
18
|
+
function edgeId(edge: FlowEdge): string {
|
|
19
|
+
if (edge.id) return edge.id
|
|
20
|
+
const sh = edge.sourceHandle ? `-${edge.sourceHandle}` : ''
|
|
21
|
+
const th = edge.targetHandle ? `-${edge.targetHandle}` : ''
|
|
22
|
+
return `e-${edge.source}${sh}-${edge.target}${th}`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Create a reactive flow instance — the core state manager for flow diagrams.
|
|
27
|
+
*
|
|
28
|
+
* All state is signal-based. Nodes, edges, viewport, and selection are
|
|
29
|
+
* reactive and update the UI automatically when modified.
|
|
30
|
+
*
|
|
31
|
+
* @param config - Initial configuration with nodes, edges, and options
|
|
32
|
+
* @returns A FlowInstance with signals and methods for managing the diagram
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* ```tsx
|
|
36
|
+
* const flow = createFlow({
|
|
37
|
+
* nodes: [
|
|
38
|
+
* { id: '1', position: { x: 0, y: 0 }, data: { label: 'Start' } },
|
|
39
|
+
* { id: '2', position: { x: 200, y: 100 }, data: { label: 'End' } },
|
|
40
|
+
* ],
|
|
41
|
+
* edges: [{ source: '1', target: '2' }],
|
|
42
|
+
* })
|
|
43
|
+
*
|
|
44
|
+
* flow.nodes() // reactive node list
|
|
45
|
+
* flow.viewport() // { x: 0, y: 0, zoom: 1 }
|
|
46
|
+
* flow.addNode({ id: '3', position: { x: 400, y: 0 }, data: { label: 'New' } })
|
|
47
|
+
* flow.layout('layered', { direction: 'RIGHT' })
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
export function createFlow(config: FlowConfig = {}): FlowInstance {
|
|
51
|
+
const {
|
|
52
|
+
nodes: initialNodes = [],
|
|
53
|
+
edges: initialEdges = [],
|
|
54
|
+
defaultEdgeType = 'bezier',
|
|
55
|
+
minZoom = 0.1,
|
|
56
|
+
maxZoom = 4,
|
|
57
|
+
snapToGrid = false,
|
|
58
|
+
snapGrid = 15,
|
|
59
|
+
connectionRules,
|
|
60
|
+
} = config
|
|
61
|
+
|
|
62
|
+
// Ensure all edges have ids
|
|
63
|
+
const edgesWithIds = initialEdges.map((e) => ({
|
|
64
|
+
...e,
|
|
65
|
+
id: edgeId(e),
|
|
66
|
+
type: e.type ?? defaultEdgeType,
|
|
67
|
+
}))
|
|
68
|
+
|
|
69
|
+
// ── Core signals ─────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
const nodes = signal<FlowNode[]>([...initialNodes])
|
|
72
|
+
const edges = signal<FlowEdge[]>(edgesWithIds)
|
|
73
|
+
const viewport = signal({ x: 0, y: 0, zoom: 1 })
|
|
74
|
+
const containerSize = signal({ width: 800, height: 600 })
|
|
75
|
+
|
|
76
|
+
// Track selected state separately for O(1) lookups
|
|
77
|
+
const selectedNodeIds = signal(new Set<string>())
|
|
78
|
+
const selectedEdgeIds = signal(new Set<string>())
|
|
79
|
+
|
|
80
|
+
// ── Computed ─────────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
const zoom = computed(() => viewport().zoom)
|
|
83
|
+
|
|
84
|
+
const selectedNodes = computed(() => [...selectedNodeIds()])
|
|
85
|
+
const selectedEdges = computed(() => [...selectedEdgeIds()])
|
|
86
|
+
|
|
87
|
+
// ── Listeners ────────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
const connectListeners = new Set<(connection: Connection) => void>()
|
|
90
|
+
const nodesChangeListeners = new Set<(changes: NodeChange[]) => void>()
|
|
91
|
+
const nodeClickListeners = new Set<(node: FlowNode) => void>()
|
|
92
|
+
const edgeClickListeners = new Set<(edge: FlowEdge) => void>()
|
|
93
|
+
const nodeDragStartListeners = new Set<(node: FlowNode) => void>()
|
|
94
|
+
const nodeDragEndListeners = new Set<(node: FlowNode) => void>()
|
|
95
|
+
const nodeDoubleClickListeners = new Set<(node: FlowNode) => void>()
|
|
96
|
+
|
|
97
|
+
function emitNodeChanges(changes: NodeChange[]) {
|
|
98
|
+
for (const cb of nodesChangeListeners) cb(changes)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── Node operations ──────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
function getNode(id: string): FlowNode | undefined {
|
|
104
|
+
return nodes.peek().find((n) => n.id === id)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function addNode(node: FlowNode): void {
|
|
108
|
+
nodes.update((nds) => [...nds, node])
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function removeNode(id: string): void {
|
|
112
|
+
batch(() => {
|
|
113
|
+
nodes.update((nds) => nds.filter((n) => n.id !== id))
|
|
114
|
+
// Remove connected edges
|
|
115
|
+
edges.update((eds) =>
|
|
116
|
+
eds.filter((e) => e.source !== id && e.target !== id),
|
|
117
|
+
)
|
|
118
|
+
selectedNodeIds.update((set) => {
|
|
119
|
+
const next = new Set(set)
|
|
120
|
+
next.delete(id)
|
|
121
|
+
return next
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
emitNodeChanges([{ type: 'remove', id }])
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function updateNode(id: string, update: Partial<FlowNode>): void {
|
|
128
|
+
nodes.update((nds) =>
|
|
129
|
+
nds.map((n) => (n.id === id ? { ...n, ...update } : n)),
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function updateNodePosition(id: string, position: XYPosition): void {
|
|
134
|
+
let pos = snapToGrid
|
|
135
|
+
? {
|
|
136
|
+
x: Math.round(position.x / snapGrid) * snapGrid,
|
|
137
|
+
y: Math.round(position.y / snapGrid) * snapGrid,
|
|
138
|
+
}
|
|
139
|
+
: position
|
|
140
|
+
|
|
141
|
+
// Apply extent clamping
|
|
142
|
+
const node = getNode(id)
|
|
143
|
+
pos = clampToExtent(pos, node?.width, node?.height)
|
|
144
|
+
|
|
145
|
+
nodes.update((nds) =>
|
|
146
|
+
nds.map((n) => (n.id === id ? { ...n, position: pos } : n)),
|
|
147
|
+
)
|
|
148
|
+
emitNodeChanges([{ type: 'position', id, position: pos }])
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── Edge operations ──────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
function getEdge(id: string): FlowEdge | undefined {
|
|
154
|
+
return edges.peek().find((e) => e.id === id)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function addEdge(edge: FlowEdge): void {
|
|
158
|
+
const newEdge = {
|
|
159
|
+
...edge,
|
|
160
|
+
id: edgeId(edge),
|
|
161
|
+
type: edge.type ?? defaultEdgeType,
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Don't add duplicate edges
|
|
165
|
+
const existing = edges.peek()
|
|
166
|
+
if (existing.some((e) => e.id === newEdge.id)) return
|
|
167
|
+
|
|
168
|
+
edges.update((eds) => [...eds, newEdge])
|
|
169
|
+
|
|
170
|
+
// Notify connect listeners
|
|
171
|
+
const connection: Connection = {
|
|
172
|
+
source: edge.source,
|
|
173
|
+
target: edge.target,
|
|
174
|
+
sourceHandle: edge.sourceHandle,
|
|
175
|
+
targetHandle: edge.targetHandle,
|
|
176
|
+
}
|
|
177
|
+
for (const cb of connectListeners) cb(connection)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function removeEdge(id: string): void {
|
|
181
|
+
edges.update((eds) => eds.filter((e) => e.id !== id))
|
|
182
|
+
selectedEdgeIds.update((set) => {
|
|
183
|
+
const next = new Set(set)
|
|
184
|
+
next.delete(id)
|
|
185
|
+
return next
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function isValidConnection(connection: Connection): boolean {
|
|
190
|
+
if (!connectionRules) return true
|
|
191
|
+
|
|
192
|
+
// Find source node type
|
|
193
|
+
const sourceNode = getNode(connection.source)
|
|
194
|
+
if (!sourceNode) return false
|
|
195
|
+
|
|
196
|
+
const sourceType = sourceNode.type ?? 'default'
|
|
197
|
+
const rule = connectionRules[sourceType]
|
|
198
|
+
if (!rule) return true // no rule = allow
|
|
199
|
+
|
|
200
|
+
// Find target node type
|
|
201
|
+
const targetNode = getNode(connection.target)
|
|
202
|
+
if (!targetNode) return false
|
|
203
|
+
|
|
204
|
+
const targetType = targetNode.type ?? 'default'
|
|
205
|
+
return rule.outputs.includes(targetType)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ── Selection ────────────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
function selectNode(id: string, additive = false): void {
|
|
211
|
+
selectedNodeIds.update((set) => {
|
|
212
|
+
const next = additive ? new Set(set) : new Set<string>()
|
|
213
|
+
next.add(id)
|
|
214
|
+
return next
|
|
215
|
+
})
|
|
216
|
+
if (!additive) {
|
|
217
|
+
selectedEdgeIds.set(new Set())
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function deselectNode(id: string): void {
|
|
222
|
+
selectedNodeIds.update((set) => {
|
|
223
|
+
const next = new Set(set)
|
|
224
|
+
next.delete(id)
|
|
225
|
+
return next
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function selectEdge(id: string, additive = false): void {
|
|
230
|
+
selectedEdgeIds.update((set) => {
|
|
231
|
+
const next = additive ? new Set(set) : new Set<string>()
|
|
232
|
+
next.add(id)
|
|
233
|
+
return next
|
|
234
|
+
})
|
|
235
|
+
if (!additive) {
|
|
236
|
+
selectedNodeIds.set(new Set())
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function clearSelection(): void {
|
|
241
|
+
batch(() => {
|
|
242
|
+
selectedNodeIds.set(new Set())
|
|
243
|
+
selectedEdgeIds.set(new Set())
|
|
244
|
+
})
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function selectAll(): void {
|
|
248
|
+
selectedNodeIds.set(new Set(nodes.peek().map((n) => n.id)))
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function deleteSelected(): void {
|
|
252
|
+
batch(() => {
|
|
253
|
+
const nodeIdsToRemove = selectedNodeIds.peek()
|
|
254
|
+
const edgeIdsToRemove = selectedEdgeIds.peek()
|
|
255
|
+
|
|
256
|
+
if (nodeIdsToRemove.size > 0) {
|
|
257
|
+
nodes.update((nds) => nds.filter((n) => !nodeIdsToRemove.has(n.id)))
|
|
258
|
+
// Also remove edges connected to deleted nodes
|
|
259
|
+
edges.update((eds) =>
|
|
260
|
+
eds.filter(
|
|
261
|
+
(e) =>
|
|
262
|
+
!nodeIdsToRemove.has(e.source) &&
|
|
263
|
+
!nodeIdsToRemove.has(e.target) &&
|
|
264
|
+
!edgeIdsToRemove.has(e.id!),
|
|
265
|
+
),
|
|
266
|
+
)
|
|
267
|
+
} else if (edgeIdsToRemove.size > 0) {
|
|
268
|
+
edges.update((eds) => eds.filter((e) => !edgeIdsToRemove.has(e.id!)))
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
selectedNodeIds.set(new Set())
|
|
272
|
+
selectedEdgeIds.set(new Set())
|
|
273
|
+
})
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ── Viewport ─────────────────────────────────────────────────────────────
|
|
277
|
+
|
|
278
|
+
function fitView(
|
|
279
|
+
nodeIds?: string[],
|
|
280
|
+
padding = config.fitViewPadding ?? 0.1,
|
|
281
|
+
): void {
|
|
282
|
+
const targetNodes = nodeIds
|
|
283
|
+
? nodes.peek().filter((n) => nodeIds.includes(n.id))
|
|
284
|
+
: nodes.peek()
|
|
285
|
+
|
|
286
|
+
if (targetNodes.length === 0) return
|
|
287
|
+
|
|
288
|
+
let minX = Number.POSITIVE_INFINITY
|
|
289
|
+
let minY = Number.POSITIVE_INFINITY
|
|
290
|
+
let maxX = Number.NEGATIVE_INFINITY
|
|
291
|
+
let maxY = Number.NEGATIVE_INFINITY
|
|
292
|
+
|
|
293
|
+
for (const node of targetNodes) {
|
|
294
|
+
const w = node.width ?? 150
|
|
295
|
+
const h = node.height ?? 40
|
|
296
|
+
minX = Math.min(minX, node.position.x)
|
|
297
|
+
minY = Math.min(minY, node.position.y)
|
|
298
|
+
maxX = Math.max(maxX, node.position.x + w)
|
|
299
|
+
maxY = Math.max(maxY, node.position.y + h)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const graphWidth = maxX - minX
|
|
303
|
+
const graphHeight = maxY - minY
|
|
304
|
+
|
|
305
|
+
const { width: containerWidth, height: containerHeight } =
|
|
306
|
+
containerSize.peek()
|
|
307
|
+
|
|
308
|
+
const zoomX = containerWidth / (graphWidth * (1 + padding * 2))
|
|
309
|
+
const zoomY = containerHeight / (graphHeight * (1 + padding * 2))
|
|
310
|
+
const newZoom = Math.min(Math.max(Math.min(zoomX, zoomY), minZoom), maxZoom)
|
|
311
|
+
|
|
312
|
+
const centerX = (minX + maxX) / 2
|
|
313
|
+
const centerY = (minY + maxY) / 2
|
|
314
|
+
|
|
315
|
+
viewport.set({
|
|
316
|
+
x: containerWidth / 2 - centerX * newZoom,
|
|
317
|
+
y: containerHeight / 2 - centerY * newZoom,
|
|
318
|
+
zoom: newZoom,
|
|
319
|
+
})
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function zoomTo(z: number): void {
|
|
323
|
+
viewport.update((v) => ({
|
|
324
|
+
...v,
|
|
325
|
+
zoom: Math.min(Math.max(z, minZoom), maxZoom),
|
|
326
|
+
}))
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function zoomIn(): void {
|
|
330
|
+
viewport.update((v) => ({
|
|
331
|
+
...v,
|
|
332
|
+
zoom: Math.min(v.zoom * 1.2, maxZoom),
|
|
333
|
+
}))
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function zoomOut(): void {
|
|
337
|
+
viewport.update((v) => ({
|
|
338
|
+
...v,
|
|
339
|
+
zoom: Math.max(v.zoom / 1.2, minZoom),
|
|
340
|
+
}))
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function panTo(position: XYPosition): void {
|
|
344
|
+
viewport.update((v) => ({
|
|
345
|
+
...v,
|
|
346
|
+
x: -position.x * v.zoom,
|
|
347
|
+
y: -position.y * v.zoom,
|
|
348
|
+
}))
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function isNodeVisible(id: string): boolean {
|
|
352
|
+
const node = getNode(id)
|
|
353
|
+
if (!node) return false
|
|
354
|
+
// Simplified check — actual implementation would use container dimensions
|
|
355
|
+
const v = viewport.peek()
|
|
356
|
+
const w = node.width ?? 150
|
|
357
|
+
const h = node.height ?? 40
|
|
358
|
+
const screenX = node.position.x * v.zoom + v.x
|
|
359
|
+
const screenY = node.position.y * v.zoom + v.y
|
|
360
|
+
const screenW = w * v.zoom
|
|
361
|
+
const screenH = h * v.zoom
|
|
362
|
+
const { width: cw, height: ch } = containerSize.peek()
|
|
363
|
+
return (
|
|
364
|
+
screenX + screenW > 0 &&
|
|
365
|
+
screenX < cw &&
|
|
366
|
+
screenY + screenH > 0 &&
|
|
367
|
+
screenY < ch
|
|
368
|
+
)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ── Layout ───────────────────────────────────────────────────────────────
|
|
372
|
+
|
|
373
|
+
async function layout(
|
|
374
|
+
algorithm: LayoutAlgorithm = 'layered',
|
|
375
|
+
options: LayoutOptions = {},
|
|
376
|
+
): Promise<void> {
|
|
377
|
+
const currentNodes = nodes.peek()
|
|
378
|
+
const currentEdges = edges.peek()
|
|
379
|
+
|
|
380
|
+
const positions = await computeLayout(
|
|
381
|
+
currentNodes,
|
|
382
|
+
currentEdges,
|
|
383
|
+
algorithm,
|
|
384
|
+
options,
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
const animate = options.animate !== false
|
|
388
|
+
const duration = options.animationDuration ?? 300
|
|
389
|
+
|
|
390
|
+
if (!animate) {
|
|
391
|
+
batch(() => {
|
|
392
|
+
nodes.update((nds) =>
|
|
393
|
+
nds.map((node) => {
|
|
394
|
+
const pos = positions.find((p) => p.id === node.id)
|
|
395
|
+
return pos ? { ...node, position: pos.position } : node
|
|
396
|
+
}),
|
|
397
|
+
)
|
|
398
|
+
})
|
|
399
|
+
return
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Animated transition — interpolate positions over duration
|
|
403
|
+
const startPositions = new Map(
|
|
404
|
+
currentNodes.map((n) => [n.id, { ...n.position }]),
|
|
405
|
+
)
|
|
406
|
+
const targetPositions = new Map(positions.map((p) => [p.id, p.position]))
|
|
407
|
+
|
|
408
|
+
const startTime = performance.now()
|
|
409
|
+
|
|
410
|
+
const animateFrame = () => {
|
|
411
|
+
const elapsed = performance.now() - startTime
|
|
412
|
+
const t = Math.min(elapsed / duration, 1)
|
|
413
|
+
// Ease-out cubic
|
|
414
|
+
const eased = 1 - (1 - t) ** 3
|
|
415
|
+
|
|
416
|
+
batch(() => {
|
|
417
|
+
nodes.update((nds) =>
|
|
418
|
+
nds.map((node) => {
|
|
419
|
+
const start = startPositions.get(node.id)
|
|
420
|
+
const end = targetPositions.get(node.id)
|
|
421
|
+
if (!start || !end) return node
|
|
422
|
+
return {
|
|
423
|
+
...node,
|
|
424
|
+
position: {
|
|
425
|
+
x: start.x + (end.x - start.x) * eased,
|
|
426
|
+
y: start.y + (end.y - start.y) * eased,
|
|
427
|
+
},
|
|
428
|
+
}
|
|
429
|
+
}),
|
|
430
|
+
)
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
if (t < 1) requestAnimationFrame(animateFrame)
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
requestAnimationFrame(animateFrame)
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ── Batch ────────────────────────────────────────────────────────────────
|
|
440
|
+
|
|
441
|
+
function batchOp(fn: () => void): void {
|
|
442
|
+
batch(fn)
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ── Graph queries ────────────────────────────────────────────────────────
|
|
446
|
+
|
|
447
|
+
function getConnectedEdges(nodeId: string): FlowEdge[] {
|
|
448
|
+
return edges
|
|
449
|
+
.peek()
|
|
450
|
+
.filter((e) => e.source === nodeId || e.target === nodeId)
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function getIncomers(nodeId: string): FlowNode[] {
|
|
454
|
+
const incomingEdges = edges.peek().filter((e) => e.target === nodeId)
|
|
455
|
+
const sourceIds = new Set(incomingEdges.map((e) => e.source))
|
|
456
|
+
return nodes.peek().filter((n) => sourceIds.has(n.id))
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function getOutgoers(nodeId: string): FlowNode[] {
|
|
460
|
+
const outgoingEdges = edges.peek().filter((e) => e.source === nodeId)
|
|
461
|
+
const targetIds = new Set(outgoingEdges.map((e) => e.target))
|
|
462
|
+
return nodes.peek().filter((n) => targetIds.has(n.id))
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ── Listeners ────────────────────────────────────────────────────────────
|
|
466
|
+
|
|
467
|
+
function onConnect(callback: (connection: Connection) => void): () => void {
|
|
468
|
+
connectListeners.add(callback)
|
|
469
|
+
return () => connectListeners.delete(callback)
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function onNodesChange(
|
|
473
|
+
callback: (changes: NodeChange[]) => void,
|
|
474
|
+
): () => void {
|
|
475
|
+
nodesChangeListeners.add(callback)
|
|
476
|
+
return () => nodesChangeListeners.delete(callback)
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function onNodeClick(callback: (node: FlowNode) => void): () => void {
|
|
480
|
+
nodeClickListeners.add(callback)
|
|
481
|
+
return () => nodeClickListeners.delete(callback)
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function onEdgeClick(callback: (edge: FlowEdge) => void): () => void {
|
|
485
|
+
edgeClickListeners.add(callback)
|
|
486
|
+
return () => edgeClickListeners.delete(callback)
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function onNodeDragStart(callback: (node: FlowNode) => void): () => void {
|
|
490
|
+
nodeDragStartListeners.add(callback)
|
|
491
|
+
return () => nodeDragStartListeners.delete(callback)
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function onNodeDragEnd(callback: (node: FlowNode) => void): () => void {
|
|
495
|
+
nodeDragEndListeners.add(callback)
|
|
496
|
+
return () => nodeDragEndListeners.delete(callback)
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function onNodeDoubleClick(callback: (node: FlowNode) => void): () => void {
|
|
500
|
+
nodeDoubleClickListeners.add(callback)
|
|
501
|
+
return () => nodeDoubleClickListeners.delete(callback)
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// ── Copy / Paste ────────────────────────────────────────────────────────
|
|
505
|
+
|
|
506
|
+
let clipboard: { nodes: FlowNode[]; edges: FlowEdge[] } | null = null
|
|
507
|
+
|
|
508
|
+
function copySelected(): void {
|
|
509
|
+
const selectedNodeSet = selectedNodeIds.peek()
|
|
510
|
+
if (selectedNodeSet.size === 0) return
|
|
511
|
+
|
|
512
|
+
const copiedNodes = nodes.peek().filter((n) => selectedNodeSet.has(n.id))
|
|
513
|
+
const nodeIdSet = new Set(copiedNodes.map((n) => n.id))
|
|
514
|
+
const copiedEdges = edges
|
|
515
|
+
.peek()
|
|
516
|
+
.filter((e) => nodeIdSet.has(e.source) && nodeIdSet.has(e.target))
|
|
517
|
+
|
|
518
|
+
clipboard = { nodes: copiedNodes, edges: copiedEdges }
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function paste(offset: XYPosition = { x: 50, y: 50 }): void {
|
|
522
|
+
if (!clipboard) return
|
|
523
|
+
|
|
524
|
+
const idMap = new Map<string, string>()
|
|
525
|
+
const newNodes: FlowNode[] = []
|
|
526
|
+
|
|
527
|
+
// Create new nodes with offset positions and new ids
|
|
528
|
+
for (const node of clipboard.nodes) {
|
|
529
|
+
const newId = `${node.id}-copy-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
|
|
530
|
+
idMap.set(node.id, newId)
|
|
531
|
+
newNodes.push({
|
|
532
|
+
...node,
|
|
533
|
+
id: newId,
|
|
534
|
+
position: {
|
|
535
|
+
x: node.position.x + offset.x,
|
|
536
|
+
y: node.position.y + offset.y,
|
|
537
|
+
},
|
|
538
|
+
})
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const newEdges: FlowEdge[] = clipboard.edges.map((e) => ({
|
|
542
|
+
...e,
|
|
543
|
+
id: undefined,
|
|
544
|
+
source: idMap.get(e.source) ?? e.source,
|
|
545
|
+
target: idMap.get(e.target) ?? e.target,
|
|
546
|
+
}))
|
|
547
|
+
|
|
548
|
+
batch(() => {
|
|
549
|
+
for (const node of newNodes) addNode(node)
|
|
550
|
+
for (const edge of newEdges) addEdge(edge)
|
|
551
|
+
|
|
552
|
+
// Select pasted nodes
|
|
553
|
+
selectedNodeIds.set(new Set(newNodes.map((n) => n.id)))
|
|
554
|
+
selectedEdgeIds.set(new Set())
|
|
555
|
+
})
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// ── Undo / Redo ────────────────────────────────────────────────────────
|
|
559
|
+
|
|
560
|
+
const undoStack: Array<{ nodes: FlowNode[]; edges: FlowEdge[] }> = []
|
|
561
|
+
const redoStack: Array<{ nodes: FlowNode[]; edges: FlowEdge[] }> = []
|
|
562
|
+
const maxHistory = 50
|
|
563
|
+
|
|
564
|
+
function pushHistory(): void {
|
|
565
|
+
undoStack.push({
|
|
566
|
+
nodes: structuredClone(nodes.peek()),
|
|
567
|
+
edges: structuredClone(edges.peek()),
|
|
568
|
+
})
|
|
569
|
+
if (undoStack.length > maxHistory) undoStack.shift()
|
|
570
|
+
redoStack.length = 0
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function undo(): void {
|
|
574
|
+
const prev = undoStack.pop()
|
|
575
|
+
if (!prev) return
|
|
576
|
+
|
|
577
|
+
redoStack.push({
|
|
578
|
+
nodes: structuredClone(nodes.peek()),
|
|
579
|
+
edges: structuredClone(edges.peek()),
|
|
580
|
+
})
|
|
581
|
+
|
|
582
|
+
batch(() => {
|
|
583
|
+
nodes.set(prev.nodes)
|
|
584
|
+
edges.set(prev.edges)
|
|
585
|
+
clearSelection()
|
|
586
|
+
})
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function redo(): void {
|
|
590
|
+
const next = redoStack.pop()
|
|
591
|
+
if (!next) return
|
|
592
|
+
|
|
593
|
+
undoStack.push({
|
|
594
|
+
nodes: structuredClone(nodes.peek()),
|
|
595
|
+
edges: structuredClone(edges.peek()),
|
|
596
|
+
})
|
|
597
|
+
|
|
598
|
+
batch(() => {
|
|
599
|
+
nodes.set(next.nodes)
|
|
600
|
+
edges.set(next.edges)
|
|
601
|
+
clearSelection()
|
|
602
|
+
})
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// ── Multi-node drag ────────────────────────────────────────────────────
|
|
606
|
+
|
|
607
|
+
function moveSelectedNodes(dx: number, dy: number): void {
|
|
608
|
+
const selected = selectedNodeIds.peek()
|
|
609
|
+
if (selected.size === 0) return
|
|
610
|
+
|
|
611
|
+
nodes.update((nds) =>
|
|
612
|
+
nds.map((n) => {
|
|
613
|
+
if (!selected.has(n.id)) return n
|
|
614
|
+
return {
|
|
615
|
+
...n,
|
|
616
|
+
position: {
|
|
617
|
+
x: n.position.x + dx,
|
|
618
|
+
y: n.position.y + dy,
|
|
619
|
+
},
|
|
620
|
+
}
|
|
621
|
+
}),
|
|
622
|
+
)
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// ── Helper lines (snap guides) ─────────────────────────────────────────
|
|
626
|
+
|
|
627
|
+
function getSnapLines(
|
|
628
|
+
dragNodeId: string,
|
|
629
|
+
position: XYPosition,
|
|
630
|
+
threshold = 5,
|
|
631
|
+
): { x: number | null; y: number | null; snappedPosition: XYPosition } {
|
|
632
|
+
const dragNode = getNode(dragNodeId)
|
|
633
|
+
if (!dragNode) return { x: null, y: null, snappedPosition: position }
|
|
634
|
+
|
|
635
|
+
const w = dragNode.width ?? 150
|
|
636
|
+
const h = dragNode.height ?? 40
|
|
637
|
+
const dragCenterX = position.x + w / 2
|
|
638
|
+
const dragCenterY = position.y + h / 2
|
|
639
|
+
|
|
640
|
+
let snapX: number | null = null
|
|
641
|
+
let snapY: number | null = null
|
|
642
|
+
let snappedX = position.x
|
|
643
|
+
let snappedY = position.y
|
|
644
|
+
|
|
645
|
+
for (const node of nodes.peek()) {
|
|
646
|
+
if (node.id === dragNodeId) continue
|
|
647
|
+
const nw = node.width ?? 150
|
|
648
|
+
const nh = node.height ?? 40
|
|
649
|
+
const nodeCenterX = node.position.x + nw / 2
|
|
650
|
+
const nodeCenterY = node.position.y + nh / 2
|
|
651
|
+
|
|
652
|
+
// Snap to center X
|
|
653
|
+
if (Math.abs(dragCenterX - nodeCenterX) < threshold) {
|
|
654
|
+
snapX = nodeCenterX
|
|
655
|
+
snappedX = nodeCenterX - w / 2
|
|
656
|
+
}
|
|
657
|
+
// Snap to left edge
|
|
658
|
+
if (Math.abs(position.x - node.position.x) < threshold) {
|
|
659
|
+
snapX = node.position.x
|
|
660
|
+
snappedX = node.position.x
|
|
661
|
+
}
|
|
662
|
+
// Snap to right edge
|
|
663
|
+
if (Math.abs(position.x + w - (node.position.x + nw)) < threshold) {
|
|
664
|
+
snapX = node.position.x + nw
|
|
665
|
+
snappedX = node.position.x + nw - w
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Snap to center Y
|
|
669
|
+
if (Math.abs(dragCenterY - nodeCenterY) < threshold) {
|
|
670
|
+
snapY = nodeCenterY
|
|
671
|
+
snappedY = nodeCenterY - h / 2
|
|
672
|
+
}
|
|
673
|
+
// Snap to top edge
|
|
674
|
+
if (Math.abs(position.y - node.position.y) < threshold) {
|
|
675
|
+
snapY = node.position.y
|
|
676
|
+
snappedY = node.position.y
|
|
677
|
+
}
|
|
678
|
+
// Snap to bottom edge
|
|
679
|
+
if (Math.abs(position.y + h - (node.position.y + nh)) < threshold) {
|
|
680
|
+
snapY = node.position.y + nh
|
|
681
|
+
snappedY = node.position.y + nh - h
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
return { x: snapX, y: snapY, snappedPosition: { x: snappedX, y: snappedY } }
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// ── Sub-flows / Groups ──────────────────────────────────────────────────
|
|
689
|
+
|
|
690
|
+
function getChildNodes(parentId: string): FlowNode[] {
|
|
691
|
+
return nodes.peek().filter((n) => n.parentId === parentId)
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function getAbsolutePosition(nodeId: string): XYPosition {
|
|
695
|
+
const node = getNode(nodeId)
|
|
696
|
+
if (!node) return { x: 0, y: 0 }
|
|
697
|
+
|
|
698
|
+
if (node.parentId) {
|
|
699
|
+
const parentPos = getAbsolutePosition(node.parentId)
|
|
700
|
+
return {
|
|
701
|
+
x: parentPos.x + node.position.x,
|
|
702
|
+
y: parentPos.y + node.position.y,
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
return node.position
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// ── Edge reconnecting ──────────────────────────────────────────────────
|
|
710
|
+
|
|
711
|
+
function reconnectEdge(
|
|
712
|
+
targetEdgeId: string,
|
|
713
|
+
newConnection: {
|
|
714
|
+
source?: string
|
|
715
|
+
target?: string
|
|
716
|
+
sourceHandle?: string
|
|
717
|
+
targetHandle?: string
|
|
718
|
+
},
|
|
719
|
+
): void {
|
|
720
|
+
edges.update((eds) =>
|
|
721
|
+
eds.map((e) => {
|
|
722
|
+
if (e.id !== targetEdgeId) return e
|
|
723
|
+
return {
|
|
724
|
+
...e,
|
|
725
|
+
source: newConnection.source ?? e.source,
|
|
726
|
+
target: newConnection.target ?? e.target,
|
|
727
|
+
sourceHandle: newConnection.sourceHandle ?? e.sourceHandle,
|
|
728
|
+
targetHandle: newConnection.targetHandle ?? e.targetHandle,
|
|
729
|
+
}
|
|
730
|
+
}),
|
|
731
|
+
)
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// ── Edge waypoints ──────────────────────────────────────────────────────
|
|
735
|
+
|
|
736
|
+
function addEdgeWaypoint(
|
|
737
|
+
edgeIdentifier: string,
|
|
738
|
+
point: XYPosition,
|
|
739
|
+
index?: number,
|
|
740
|
+
): void {
|
|
741
|
+
edges.update((eds) =>
|
|
742
|
+
eds.map((e) => {
|
|
743
|
+
if (e.id !== edgeIdentifier) return e
|
|
744
|
+
const waypoints = [...(e.waypoints ?? [])]
|
|
745
|
+
if (index !== undefined) {
|
|
746
|
+
waypoints.splice(index, 0, point)
|
|
747
|
+
} else {
|
|
748
|
+
waypoints.push(point)
|
|
749
|
+
}
|
|
750
|
+
return { ...e, waypoints }
|
|
751
|
+
}),
|
|
752
|
+
)
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function removeEdgeWaypoint(edgeIdentifier: string, index: number): void {
|
|
756
|
+
edges.update((eds) =>
|
|
757
|
+
eds.map((e) => {
|
|
758
|
+
if (e.id !== edgeIdentifier) return e
|
|
759
|
+
const waypoints = [...(e.waypoints ?? [])]
|
|
760
|
+
waypoints.splice(index, 1)
|
|
761
|
+
return { ...e, waypoints: waypoints.length > 0 ? waypoints : undefined }
|
|
762
|
+
}),
|
|
763
|
+
)
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function updateEdgeWaypoint(
|
|
767
|
+
edgeIdentifier: string,
|
|
768
|
+
index: number,
|
|
769
|
+
point: XYPosition,
|
|
770
|
+
): void {
|
|
771
|
+
edges.update((eds) =>
|
|
772
|
+
eds.map((e) => {
|
|
773
|
+
if (e.id !== edgeIdentifier) return e
|
|
774
|
+
const waypoints = [...(e.waypoints ?? [])]
|
|
775
|
+
if (index >= 0 && index < waypoints.length) {
|
|
776
|
+
waypoints[index] = point
|
|
777
|
+
}
|
|
778
|
+
return { ...e, waypoints }
|
|
779
|
+
}),
|
|
780
|
+
)
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// ── Proximity connect ───────────────────────────────────────────────────
|
|
784
|
+
|
|
785
|
+
function getProximityConnection(
|
|
786
|
+
nodeId: string,
|
|
787
|
+
threshold = 50,
|
|
788
|
+
): Connection | null {
|
|
789
|
+
const node = getNode(nodeId)
|
|
790
|
+
if (!node) return null
|
|
791
|
+
|
|
792
|
+
const w = node.width ?? 150
|
|
793
|
+
const h = node.height ?? 40
|
|
794
|
+
const centerX = node.position.x + w / 2
|
|
795
|
+
const centerY = node.position.y + h / 2
|
|
796
|
+
|
|
797
|
+
let closest: { nodeId: string; dist: number } | null = null
|
|
798
|
+
|
|
799
|
+
for (const other of nodes.peek()) {
|
|
800
|
+
if (other.id === nodeId) continue
|
|
801
|
+
// Skip if already connected
|
|
802
|
+
const alreadyConnected = edges
|
|
803
|
+
.peek()
|
|
804
|
+
.some(
|
|
805
|
+
(e) =>
|
|
806
|
+
(e.source === nodeId && e.target === other.id) ||
|
|
807
|
+
(e.source === other.id && e.target === nodeId),
|
|
808
|
+
)
|
|
809
|
+
if (alreadyConnected) continue
|
|
810
|
+
|
|
811
|
+
const ow = other.width ?? 150
|
|
812
|
+
const oh = other.height ?? 40
|
|
813
|
+
const ocx = other.position.x + ow / 2
|
|
814
|
+
const ocy = other.position.y + oh / 2
|
|
815
|
+
const dist = Math.hypot(centerX - ocx, centerY - ocy)
|
|
816
|
+
|
|
817
|
+
if (dist < threshold && (!closest || dist < closest.dist)) {
|
|
818
|
+
closest = { nodeId: other.id, dist }
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
if (!closest) return null
|
|
823
|
+
|
|
824
|
+
const connection: Connection = {
|
|
825
|
+
source: nodeId,
|
|
826
|
+
target: closest.nodeId,
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
return isValidConnection(connection) ? connection : null
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// ── Collision detection ────────────────────────────────────────────────
|
|
833
|
+
|
|
834
|
+
function getOverlappingNodes(nodeId: string): FlowNode[] {
|
|
835
|
+
const node = getNode(nodeId)
|
|
836
|
+
if (!node) return []
|
|
837
|
+
|
|
838
|
+
const w = node.width ?? 150
|
|
839
|
+
const h = node.height ?? 40
|
|
840
|
+
const ax1 = node.position.x
|
|
841
|
+
const ay1 = node.position.y
|
|
842
|
+
const ax2 = ax1 + w
|
|
843
|
+
const ay2 = ay1 + h
|
|
844
|
+
|
|
845
|
+
return nodes.peek().filter((other) => {
|
|
846
|
+
if (other.id === nodeId) return false
|
|
847
|
+
const ow = other.width ?? 150
|
|
848
|
+
const oh = other.height ?? 40
|
|
849
|
+
const bx1 = other.position.x
|
|
850
|
+
const by1 = other.position.y
|
|
851
|
+
const bx2 = bx1 + ow
|
|
852
|
+
const by2 = by1 + oh
|
|
853
|
+
|
|
854
|
+
return ax1 < bx2 && ax2 > bx1 && ay1 < by2 && ay2 > by1
|
|
855
|
+
})
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function resolveCollisions(nodeId: string, spacing = 10): void {
|
|
859
|
+
const overlapping = getOverlappingNodes(nodeId)
|
|
860
|
+
if (overlapping.length === 0) return
|
|
861
|
+
|
|
862
|
+
const node = getNode(nodeId)
|
|
863
|
+
if (!node) return
|
|
864
|
+
|
|
865
|
+
const w = node.width ?? 150
|
|
866
|
+
const h = node.height ?? 40
|
|
867
|
+
|
|
868
|
+
for (const other of overlapping) {
|
|
869
|
+
const ow = other.width ?? 150
|
|
870
|
+
const oh = other.height ?? 40
|
|
871
|
+
|
|
872
|
+
// Calculate overlap amounts
|
|
873
|
+
const overlapX = Math.min(
|
|
874
|
+
node.position.x + w - other.position.x,
|
|
875
|
+
other.position.x + ow - node.position.x,
|
|
876
|
+
)
|
|
877
|
+
const overlapY = Math.min(
|
|
878
|
+
node.position.y + h - other.position.y,
|
|
879
|
+
other.position.y + oh - node.position.y,
|
|
880
|
+
)
|
|
881
|
+
|
|
882
|
+
// Push in the direction of least overlap
|
|
883
|
+
if (overlapX < overlapY) {
|
|
884
|
+
const dx =
|
|
885
|
+
node.position.x < other.position.x
|
|
886
|
+
? -(overlapX + spacing) / 2
|
|
887
|
+
: (overlapX + spacing) / 2
|
|
888
|
+
updateNodePosition(other.id, {
|
|
889
|
+
x: other.position.x - dx,
|
|
890
|
+
y: other.position.y,
|
|
891
|
+
})
|
|
892
|
+
} else {
|
|
893
|
+
const dy =
|
|
894
|
+
node.position.y < other.position.y
|
|
895
|
+
? -(overlapY + spacing) / 2
|
|
896
|
+
: (overlapY + spacing) / 2
|
|
897
|
+
updateNodePosition(other.id, {
|
|
898
|
+
x: other.position.x,
|
|
899
|
+
y: other.position.y - dy,
|
|
900
|
+
})
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// ── Node extent (drag boundaries) ──────────────────────────────────────
|
|
906
|
+
|
|
907
|
+
function setNodeExtent(
|
|
908
|
+
extent: [[number, number], [number, number]] | null,
|
|
909
|
+
): void {
|
|
910
|
+
nodeExtent = extent
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
let nodeExtent: [[number, number], [number, number]] | null =
|
|
914
|
+
config.nodeExtent ?? null
|
|
915
|
+
|
|
916
|
+
function clampToExtent(
|
|
917
|
+
position: XYPosition,
|
|
918
|
+
nodeWidth = 150,
|
|
919
|
+
nodeHeight = 40,
|
|
920
|
+
): XYPosition {
|
|
921
|
+
if (!nodeExtent) return position
|
|
922
|
+
return {
|
|
923
|
+
x: Math.min(
|
|
924
|
+
Math.max(position.x, nodeExtent[0][0]),
|
|
925
|
+
nodeExtent[1][0] - nodeWidth,
|
|
926
|
+
),
|
|
927
|
+
y: Math.min(
|
|
928
|
+
Math.max(position.y, nodeExtent[0][1]),
|
|
929
|
+
nodeExtent[1][1] - nodeHeight,
|
|
930
|
+
),
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// ── Custom edge types ──────────────────────────────────────────────────
|
|
935
|
+
// Custom edge rendering is handled in the Flow component via edgeTypes prop.
|
|
936
|
+
// The flow instance provides the data; rendering is delegated to components.
|
|
937
|
+
|
|
938
|
+
// ── Search / Filter ─────────────────────────────────────────────────────
|
|
939
|
+
|
|
940
|
+
function findNodes(predicate: (node: FlowNode) => boolean): FlowNode[] {
|
|
941
|
+
return nodes.peek().filter(predicate)
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
function searchNodes(query: string): FlowNode[] {
|
|
945
|
+
const q = query.toLowerCase()
|
|
946
|
+
return nodes.peek().filter((n) => {
|
|
947
|
+
const label = (n.data?.label as string) ?? n.id
|
|
948
|
+
return label.toLowerCase().includes(q)
|
|
949
|
+
})
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
function focusNode(nodeId: string, focusZoom?: number): void {
|
|
953
|
+
const node = getNode(nodeId)
|
|
954
|
+
if (!node) return
|
|
955
|
+
|
|
956
|
+
const w = node.width ?? 150
|
|
957
|
+
const h = node.height ?? 40
|
|
958
|
+
const centerX = node.position.x + w / 2
|
|
959
|
+
const centerY = node.position.y + h / 2
|
|
960
|
+
const z = focusZoom ?? viewport.peek().zoom
|
|
961
|
+
const { width: cw, height: ch } = containerSize.peek()
|
|
962
|
+
|
|
963
|
+
animateViewport({
|
|
964
|
+
x: -centerX * z + cw / 2,
|
|
965
|
+
y: -centerY * z + ch / 2,
|
|
966
|
+
zoom: z,
|
|
967
|
+
})
|
|
968
|
+
|
|
969
|
+
// Select the focused node
|
|
970
|
+
selectNode(nodeId)
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// ── Export / Import ────────────────────────────────────────────────────
|
|
974
|
+
|
|
975
|
+
function toJSON(): {
|
|
976
|
+
nodes: FlowNode[]
|
|
977
|
+
edges: FlowEdge[]
|
|
978
|
+
viewport: { x: number; y: number; zoom: number }
|
|
979
|
+
} {
|
|
980
|
+
return {
|
|
981
|
+
nodes: structuredClone(nodes.peek()),
|
|
982
|
+
edges: structuredClone(edges.peek()),
|
|
983
|
+
viewport: { ...viewport.peek() },
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
function fromJSON(data: {
|
|
988
|
+
nodes: FlowNode[]
|
|
989
|
+
edges: FlowEdge[]
|
|
990
|
+
viewport?: { x: number; y: number; zoom: number }
|
|
991
|
+
}): void {
|
|
992
|
+
batch(() => {
|
|
993
|
+
nodes.set(data.nodes)
|
|
994
|
+
edges.set(
|
|
995
|
+
data.edges.map((e) => ({
|
|
996
|
+
...e,
|
|
997
|
+
id: e.id ?? edgeId(e),
|
|
998
|
+
type: e.type ?? defaultEdgeType,
|
|
999
|
+
})),
|
|
1000
|
+
)
|
|
1001
|
+
if (data.viewport) viewport.set(data.viewport)
|
|
1002
|
+
clearSelection()
|
|
1003
|
+
})
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// ── Viewport animation ─────────────────────────────────────────────────
|
|
1007
|
+
|
|
1008
|
+
function animateViewport(
|
|
1009
|
+
target: Partial<{ x: number; y: number; zoom: number }>,
|
|
1010
|
+
duration = 300,
|
|
1011
|
+
): void {
|
|
1012
|
+
const start = { ...viewport.peek() }
|
|
1013
|
+
const end = {
|
|
1014
|
+
x: target.x ?? start.x,
|
|
1015
|
+
y: target.y ?? start.y,
|
|
1016
|
+
zoom: target.zoom ?? start.zoom,
|
|
1017
|
+
}
|
|
1018
|
+
const startTime = performance.now()
|
|
1019
|
+
|
|
1020
|
+
const frame = () => {
|
|
1021
|
+
const elapsed = performance.now() - startTime
|
|
1022
|
+
const t = Math.min(elapsed / duration, 1)
|
|
1023
|
+
const eased = 1 - (1 - t) ** 3 // ease-out cubic
|
|
1024
|
+
|
|
1025
|
+
viewport.set({
|
|
1026
|
+
x: start.x + (end.x - start.x) * eased,
|
|
1027
|
+
y: start.y + (end.y - start.y) * eased,
|
|
1028
|
+
zoom: start.zoom + (end.zoom - start.zoom) * eased,
|
|
1029
|
+
})
|
|
1030
|
+
|
|
1031
|
+
if (t < 1) requestAnimationFrame(frame)
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
requestAnimationFrame(frame)
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// ── Dispose ──────────────────────────────────────────────────────────────
|
|
1038
|
+
|
|
1039
|
+
function dispose(): void {
|
|
1040
|
+
connectListeners.clear()
|
|
1041
|
+
nodesChangeListeners.clear()
|
|
1042
|
+
nodeClickListeners.clear()
|
|
1043
|
+
edgeClickListeners.clear()
|
|
1044
|
+
nodeDragStartListeners.clear()
|
|
1045
|
+
nodeDragEndListeners.clear()
|
|
1046
|
+
nodeDoubleClickListeners.clear()
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// ── Initial fitView ──────────────────────────────────────────────────────
|
|
1050
|
+
|
|
1051
|
+
if (config.fitView) {
|
|
1052
|
+
fitView()
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
return {
|
|
1056
|
+
nodes,
|
|
1057
|
+
edges,
|
|
1058
|
+
viewport,
|
|
1059
|
+
zoom,
|
|
1060
|
+
containerSize,
|
|
1061
|
+
selectedNodes,
|
|
1062
|
+
selectedEdges,
|
|
1063
|
+
getNode,
|
|
1064
|
+
addNode,
|
|
1065
|
+
removeNode,
|
|
1066
|
+
updateNode,
|
|
1067
|
+
updateNodePosition,
|
|
1068
|
+
getEdge,
|
|
1069
|
+
addEdge,
|
|
1070
|
+
removeEdge,
|
|
1071
|
+
isValidConnection,
|
|
1072
|
+
selectNode,
|
|
1073
|
+
deselectNode,
|
|
1074
|
+
selectEdge,
|
|
1075
|
+
clearSelection,
|
|
1076
|
+
selectAll,
|
|
1077
|
+
deleteSelected,
|
|
1078
|
+
fitView,
|
|
1079
|
+
zoomTo,
|
|
1080
|
+
zoomIn,
|
|
1081
|
+
zoomOut,
|
|
1082
|
+
panTo,
|
|
1083
|
+
isNodeVisible,
|
|
1084
|
+
layout,
|
|
1085
|
+
batch: batchOp,
|
|
1086
|
+
getConnectedEdges,
|
|
1087
|
+
getIncomers,
|
|
1088
|
+
getOutgoers,
|
|
1089
|
+
onConnect,
|
|
1090
|
+
onNodesChange,
|
|
1091
|
+
onNodeClick,
|
|
1092
|
+
onEdgeClick,
|
|
1093
|
+
onNodeDragStart,
|
|
1094
|
+
onNodeDragEnd,
|
|
1095
|
+
onNodeDoubleClick,
|
|
1096
|
+
/** @internal — used by Flow component to emit events */
|
|
1097
|
+
_emit: {
|
|
1098
|
+
nodeDragStart: (node: FlowNode) => {
|
|
1099
|
+
for (const cb of nodeDragStartListeners) cb(node)
|
|
1100
|
+
},
|
|
1101
|
+
nodeDragEnd: (node: FlowNode) => {
|
|
1102
|
+
for (const cb of nodeDragEndListeners) cb(node)
|
|
1103
|
+
},
|
|
1104
|
+
nodeDoubleClick: (node: FlowNode) => {
|
|
1105
|
+
for (const cb of nodeDoubleClickListeners) cb(node)
|
|
1106
|
+
},
|
|
1107
|
+
nodeClick: (node: FlowNode) => {
|
|
1108
|
+
for (const cb of nodeClickListeners) cb(node)
|
|
1109
|
+
},
|
|
1110
|
+
edgeClick: (edge: FlowEdge) => {
|
|
1111
|
+
for (const cb of edgeClickListeners) cb(edge)
|
|
1112
|
+
},
|
|
1113
|
+
},
|
|
1114
|
+
copySelected,
|
|
1115
|
+
paste,
|
|
1116
|
+
pushHistory,
|
|
1117
|
+
undo,
|
|
1118
|
+
redo,
|
|
1119
|
+
moveSelectedNodes,
|
|
1120
|
+
getSnapLines,
|
|
1121
|
+
getChildNodes,
|
|
1122
|
+
getAbsolutePosition,
|
|
1123
|
+
addEdgeWaypoint,
|
|
1124
|
+
removeEdgeWaypoint,
|
|
1125
|
+
updateEdgeWaypoint,
|
|
1126
|
+
reconnectEdge,
|
|
1127
|
+
getProximityConnection,
|
|
1128
|
+
getOverlappingNodes,
|
|
1129
|
+
resolveCollisions,
|
|
1130
|
+
setNodeExtent,
|
|
1131
|
+
clampToExtent,
|
|
1132
|
+
findNodes,
|
|
1133
|
+
searchNodes,
|
|
1134
|
+
focusNode,
|
|
1135
|
+
toJSON,
|
|
1136
|
+
fromJSON,
|
|
1137
|
+
animateViewport,
|
|
1138
|
+
config,
|
|
1139
|
+
dispose,
|
|
1140
|
+
}
|
|
1141
|
+
}
|