@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
|
@@ -0,0 +1,802 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
getBezierPath,
|
|
4
|
+
getSmartHandlePositions,
|
|
5
|
+
getSmoothStepPath,
|
|
6
|
+
getWaypointPath,
|
|
7
|
+
} from '../edges'
|
|
8
|
+
import { createFlow } from '../flow'
|
|
9
|
+
import { Position } from '../types'
|
|
10
|
+
|
|
11
|
+
// ─── Edge paths — additional coverage ────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
describe('edge paths — additional branches', () => {
|
|
14
|
+
it('getBezierPath with Top/Left source positions', () => {
|
|
15
|
+
const top = getBezierPath({
|
|
16
|
+
sourceX: 100,
|
|
17
|
+
sourceY: 100,
|
|
18
|
+
sourcePosition: Position.Top,
|
|
19
|
+
targetX: 200,
|
|
20
|
+
targetY: 0,
|
|
21
|
+
targetPosition: Position.Bottom,
|
|
22
|
+
})
|
|
23
|
+
expect(top.path).toMatch(/^M/)
|
|
24
|
+
|
|
25
|
+
const left = getBezierPath({
|
|
26
|
+
sourceX: 100,
|
|
27
|
+
sourceY: 100,
|
|
28
|
+
sourcePosition: Position.Left,
|
|
29
|
+
targetX: 0,
|
|
30
|
+
targetY: 200,
|
|
31
|
+
targetPosition: Position.Right,
|
|
32
|
+
})
|
|
33
|
+
expect(left.path).toMatch(/^M/)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('getBezierPath with all target positions', () => {
|
|
37
|
+
for (const pos of [
|
|
38
|
+
Position.Top,
|
|
39
|
+
Position.Right,
|
|
40
|
+
Position.Bottom,
|
|
41
|
+
Position.Left,
|
|
42
|
+
]) {
|
|
43
|
+
const result = getBezierPath({
|
|
44
|
+
sourceX: 0,
|
|
45
|
+
sourceY: 0,
|
|
46
|
+
sourcePosition: Position.Right,
|
|
47
|
+
targetX: 200,
|
|
48
|
+
targetY: 100,
|
|
49
|
+
targetPosition: pos,
|
|
50
|
+
})
|
|
51
|
+
expect(result.path).toMatch(/^M/)
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('getSmoothStepPath with horizontal→horizontal positions', () => {
|
|
56
|
+
const result = getSmoothStepPath({
|
|
57
|
+
sourceX: 0,
|
|
58
|
+
sourceY: 0,
|
|
59
|
+
sourcePosition: Position.Right,
|
|
60
|
+
targetX: 200,
|
|
61
|
+
targetY: 100,
|
|
62
|
+
targetPosition: Position.Right,
|
|
63
|
+
})
|
|
64
|
+
expect(result.path).toMatch(/^M/)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('getSmoothStepPath with vertical→vertical positions', () => {
|
|
68
|
+
const result = getSmoothStepPath({
|
|
69
|
+
sourceX: 0,
|
|
70
|
+
sourceY: 0,
|
|
71
|
+
sourcePosition: Position.Bottom,
|
|
72
|
+
targetX: 100,
|
|
73
|
+
targetY: 200,
|
|
74
|
+
targetPosition: Position.Top,
|
|
75
|
+
})
|
|
76
|
+
expect(result.path).toMatch(/^M/)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('getSmoothStepPath with vertical→horizontal', () => {
|
|
80
|
+
const result = getSmoothStepPath({
|
|
81
|
+
sourceX: 0,
|
|
82
|
+
sourceY: 0,
|
|
83
|
+
sourcePosition: Position.Bottom,
|
|
84
|
+
targetX: 200,
|
|
85
|
+
targetY: 100,
|
|
86
|
+
targetPosition: Position.Left,
|
|
87
|
+
})
|
|
88
|
+
expect(result.path).toMatch(/^M/)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('getSmoothStepPath with Left source', () => {
|
|
92
|
+
const result = getSmoothStepPath({
|
|
93
|
+
sourceX: 200,
|
|
94
|
+
sourceY: 0,
|
|
95
|
+
sourcePosition: Position.Left,
|
|
96
|
+
targetX: 0,
|
|
97
|
+
targetY: 100,
|
|
98
|
+
targetPosition: Position.Top,
|
|
99
|
+
})
|
|
100
|
+
expect(result.path).toMatch(/^M/)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('getSmoothStepPath with Bottom target', () => {
|
|
104
|
+
const result = getSmoothStepPath({
|
|
105
|
+
sourceX: 0,
|
|
106
|
+
sourceY: 0,
|
|
107
|
+
sourcePosition: Position.Right,
|
|
108
|
+
targetX: 200,
|
|
109
|
+
targetY: 100,
|
|
110
|
+
targetPosition: Position.Bottom,
|
|
111
|
+
})
|
|
112
|
+
expect(result.path).toMatch(/^M/)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('getSmartHandlePositions with nodes at various positions', () => {
|
|
116
|
+
// Target to the left
|
|
117
|
+
const leftward = getSmartHandlePositions(
|
|
118
|
+
{ id: '1', position: { x: 200, y: 0 }, data: {} },
|
|
119
|
+
{ id: '2', position: { x: 0, y: 0 }, data: {} },
|
|
120
|
+
)
|
|
121
|
+
expect(leftward.sourcePosition).toBe(Position.Left)
|
|
122
|
+
expect(leftward.targetPosition).toBe(Position.Right)
|
|
123
|
+
|
|
124
|
+
// Target below
|
|
125
|
+
const downward = getSmartHandlePositions(
|
|
126
|
+
{ id: '1', position: { x: 0, y: 0 }, data: {} },
|
|
127
|
+
{ id: '2', position: { x: 0, y: 300 }, data: {} },
|
|
128
|
+
)
|
|
129
|
+
expect(downward.sourcePosition).toBe(Position.Bottom)
|
|
130
|
+
expect(downward.targetPosition).toBe(Position.Top)
|
|
131
|
+
|
|
132
|
+
// Target above
|
|
133
|
+
const upward = getSmartHandlePositions(
|
|
134
|
+
{ id: '1', position: { x: 0, y: 300 }, data: {} },
|
|
135
|
+
{ id: '2', position: { x: 0, y: 0 }, data: {} },
|
|
136
|
+
)
|
|
137
|
+
expect(upward.sourcePosition).toBe(Position.Top)
|
|
138
|
+
expect(upward.targetPosition).toBe(Position.Bottom)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('getSmartHandlePositions with configured handles', () => {
|
|
142
|
+
const result = getSmartHandlePositions(
|
|
143
|
+
{
|
|
144
|
+
id: '1',
|
|
145
|
+
position: { x: 0, y: 0 },
|
|
146
|
+
data: {},
|
|
147
|
+
sourceHandles: [
|
|
148
|
+
{ type: 'source', position: Position.Bottom, id: 'out' },
|
|
149
|
+
],
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
id: '2',
|
|
153
|
+
position: { x: 200, y: 200 },
|
|
154
|
+
data: {},
|
|
155
|
+
targetHandles: [{ type: 'target', position: Position.Top, id: 'in' }],
|
|
156
|
+
},
|
|
157
|
+
)
|
|
158
|
+
// Should use configured handle positions
|
|
159
|
+
expect(result.sourcePosition).toBe(Position.Bottom)
|
|
160
|
+
expect(result.targetPosition).toBe(Position.Top)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('getWaypointPath with single waypoint', () => {
|
|
164
|
+
const result = getWaypointPath({
|
|
165
|
+
sourceX: 0,
|
|
166
|
+
sourceY: 0,
|
|
167
|
+
targetX: 200,
|
|
168
|
+
targetY: 0,
|
|
169
|
+
waypoints: [{ x: 100, y: 50 }],
|
|
170
|
+
})
|
|
171
|
+
expect(result.path).toBe('M0,0 L100,50 L200,0')
|
|
172
|
+
expect(result.labelX).toBe(100)
|
|
173
|
+
expect(result.labelY).toBe(50)
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
// ─── Flow — advanced operations ──────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
describe('createFlow — advanced', () => {
|
|
180
|
+
describe('resolveCollisions', () => {
|
|
181
|
+
it('pushes overlapping nodes apart', () => {
|
|
182
|
+
const flow = createFlow({
|
|
183
|
+
nodes: [
|
|
184
|
+
{
|
|
185
|
+
id: '1',
|
|
186
|
+
position: { x: 0, y: 0 },
|
|
187
|
+
width: 100,
|
|
188
|
+
height: 50,
|
|
189
|
+
data: {},
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
id: '2',
|
|
193
|
+
position: { x: 50, y: 0 },
|
|
194
|
+
width: 100,
|
|
195
|
+
height: 50,
|
|
196
|
+
data: {},
|
|
197
|
+
},
|
|
198
|
+
],
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
flow.resolveCollisions('1')
|
|
202
|
+
// Node 2 should have moved (either X or Y)
|
|
203
|
+
const node2 = flow.getNode('2')!
|
|
204
|
+
const moved = node2.position.x !== 50 || node2.position.y !== 0
|
|
205
|
+
expect(moved).toBe(true)
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('does nothing when no overlaps', () => {
|
|
209
|
+
const flow = createFlow({
|
|
210
|
+
nodes: [
|
|
211
|
+
{
|
|
212
|
+
id: '1',
|
|
213
|
+
position: { x: 0, y: 0 },
|
|
214
|
+
width: 100,
|
|
215
|
+
height: 50,
|
|
216
|
+
data: {},
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
id: '2',
|
|
220
|
+
position: { x: 500, y: 500 },
|
|
221
|
+
width: 100,
|
|
222
|
+
height: 50,
|
|
223
|
+
data: {},
|
|
224
|
+
},
|
|
225
|
+
],
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
flow.resolveCollisions('1')
|
|
229
|
+
expect(flow.getNode('2')!.position).toEqual({ x: 500, y: 500 })
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('resolves vertical overlaps', () => {
|
|
233
|
+
const flow = createFlow({
|
|
234
|
+
nodes: [
|
|
235
|
+
{
|
|
236
|
+
id: '1',
|
|
237
|
+
position: { x: 0, y: 0 },
|
|
238
|
+
width: 200,
|
|
239
|
+
height: 50,
|
|
240
|
+
data: {},
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
id: '2',
|
|
244
|
+
position: { x: 0, y: 30 },
|
|
245
|
+
width: 200,
|
|
246
|
+
height: 50,
|
|
247
|
+
data: {},
|
|
248
|
+
},
|
|
249
|
+
],
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
flow.resolveCollisions('1')
|
|
253
|
+
const node2 = flow.getNode('2')!
|
|
254
|
+
// Should push vertically since horizontal overlap is larger
|
|
255
|
+
expect(node2.position.y).not.toBe(30)
|
|
256
|
+
})
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
describe('getChildNodes / getAbsolutePosition', () => {
|
|
260
|
+
it('returns child nodes of a group', () => {
|
|
261
|
+
const flow = createFlow({
|
|
262
|
+
nodes: [
|
|
263
|
+
{
|
|
264
|
+
id: 'group',
|
|
265
|
+
position: { x: 0, y: 0 },
|
|
266
|
+
group: true,
|
|
267
|
+
data: {},
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
id: 'child1',
|
|
271
|
+
position: { x: 10, y: 10 },
|
|
272
|
+
parentId: 'group',
|
|
273
|
+
data: {},
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
id: 'child2',
|
|
277
|
+
position: { x: 20, y: 20 },
|
|
278
|
+
parentId: 'group',
|
|
279
|
+
data: {},
|
|
280
|
+
},
|
|
281
|
+
{ id: 'other', position: { x: 100, y: 100 }, data: {} },
|
|
282
|
+
],
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
const children = flow.getChildNodes('group')
|
|
286
|
+
expect(children).toHaveLength(2)
|
|
287
|
+
expect(children.map((n) => n.id)).toEqual(
|
|
288
|
+
expect.arrayContaining(['child1', 'child2']),
|
|
289
|
+
)
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
it('getAbsolutePosition accounts for parent offset', () => {
|
|
293
|
+
const flow = createFlow({
|
|
294
|
+
nodes: [
|
|
295
|
+
{
|
|
296
|
+
id: 'parent',
|
|
297
|
+
position: { x: 100, y: 200 },
|
|
298
|
+
data: {},
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
id: 'child',
|
|
302
|
+
position: { x: 10, y: 20 },
|
|
303
|
+
parentId: 'parent',
|
|
304
|
+
data: {},
|
|
305
|
+
},
|
|
306
|
+
],
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
const abs = flow.getAbsolutePosition('child')
|
|
310
|
+
expect(abs).toEqual({ x: 110, y: 220 })
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
it('getAbsolutePosition for root node returns position', () => {
|
|
314
|
+
const flow = createFlow({
|
|
315
|
+
nodes: [{ id: '1', position: { x: 50, y: 75 }, data: {} }],
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
expect(flow.getAbsolutePosition('1')).toEqual({ x: 50, y: 75 })
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
it('getAbsolutePosition for missing node returns 0,0', () => {
|
|
322
|
+
const flow = createFlow()
|
|
323
|
+
expect(flow.getAbsolutePosition('missing')).toEqual({ x: 0, y: 0 })
|
|
324
|
+
})
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
describe('moveSelectedNodes', () => {
|
|
328
|
+
it('moves all selected nodes by delta', () => {
|
|
329
|
+
const flow = createFlow({
|
|
330
|
+
nodes: [
|
|
331
|
+
{ id: '1', position: { x: 0, y: 0 }, data: {} },
|
|
332
|
+
{ id: '2', position: { x: 100, y: 0 }, data: {} },
|
|
333
|
+
{ id: '3', position: { x: 200, y: 0 }, data: {} },
|
|
334
|
+
],
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
flow.selectNode('1')
|
|
338
|
+
flow.selectNode('2', true)
|
|
339
|
+
flow.moveSelectedNodes(50, 25)
|
|
340
|
+
|
|
341
|
+
expect(flow.getNode('1')!.position).toEqual({ x: 50, y: 25 })
|
|
342
|
+
expect(flow.getNode('2')!.position).toEqual({ x: 150, y: 25 })
|
|
343
|
+
expect(flow.getNode('3')!.position).toEqual({ x: 200, y: 0 }) // not selected
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
it('does nothing with no selection', () => {
|
|
347
|
+
const flow = createFlow({
|
|
348
|
+
nodes: [{ id: '1', position: { x: 0, y: 0 }, data: {} }],
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
flow.moveSelectedNodes(100, 100)
|
|
352
|
+
expect(flow.getNode('1')!.position).toEqual({ x: 0, y: 0 })
|
|
353
|
+
})
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
describe('getSnapLines', () => {
|
|
357
|
+
it('snaps to aligned nodes', () => {
|
|
358
|
+
const flow = createFlow({
|
|
359
|
+
nodes: [
|
|
360
|
+
{
|
|
361
|
+
id: '1',
|
|
362
|
+
position: { x: 100, y: 100 },
|
|
363
|
+
width: 100,
|
|
364
|
+
height: 50,
|
|
365
|
+
data: {},
|
|
366
|
+
},
|
|
367
|
+
{
|
|
368
|
+
id: '2',
|
|
369
|
+
position: { x: 300, y: 200 },
|
|
370
|
+
width: 100,
|
|
371
|
+
height: 50,
|
|
372
|
+
data: {},
|
|
373
|
+
},
|
|
374
|
+
],
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
// Move node 2 close to node 1's center X
|
|
378
|
+
const snap = flow.getSnapLines('2', { x: 98, y: 200 })
|
|
379
|
+
expect(snap.x).not.toBeNull()
|
|
380
|
+
expect(snap.snappedPosition.x).not.toBe(98)
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
it('returns null lines when no alignment', () => {
|
|
384
|
+
const flow = createFlow({
|
|
385
|
+
nodes: [
|
|
386
|
+
{
|
|
387
|
+
id: '1',
|
|
388
|
+
position: { x: 0, y: 0 },
|
|
389
|
+
width: 100,
|
|
390
|
+
height: 50,
|
|
391
|
+
data: {},
|
|
392
|
+
},
|
|
393
|
+
{
|
|
394
|
+
id: '2',
|
|
395
|
+
position: { x: 500, y: 500 },
|
|
396
|
+
width: 100,
|
|
397
|
+
height: 50,
|
|
398
|
+
data: {},
|
|
399
|
+
},
|
|
400
|
+
],
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
const snap = flow.getSnapLines('2', { x: 500, y: 500 })
|
|
404
|
+
expect(snap.x).toBeNull()
|
|
405
|
+
expect(snap.y).toBeNull()
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
it('snaps to left edge alignment', () => {
|
|
409
|
+
const flow = createFlow({
|
|
410
|
+
nodes: [
|
|
411
|
+
{
|
|
412
|
+
id: '1',
|
|
413
|
+
position: { x: 100, y: 0 },
|
|
414
|
+
width: 100,
|
|
415
|
+
height: 50,
|
|
416
|
+
data: {},
|
|
417
|
+
},
|
|
418
|
+
{
|
|
419
|
+
id: '2',
|
|
420
|
+
position: { x: 100, y: 100 },
|
|
421
|
+
width: 100,
|
|
422
|
+
height: 50,
|
|
423
|
+
data: {},
|
|
424
|
+
},
|
|
425
|
+
],
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
// Node 2 is already aligned on left edge
|
|
429
|
+
const snap = flow.getSnapLines('2', { x: 102, y: 100 })
|
|
430
|
+
expect(snap.snappedPosition.x).toBe(100) // snapped to left edge
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
it('snaps to right edge alignment', () => {
|
|
434
|
+
const flow = createFlow({
|
|
435
|
+
nodes: [
|
|
436
|
+
{
|
|
437
|
+
id: '1',
|
|
438
|
+
position: { x: 100, y: 0 },
|
|
439
|
+
width: 100,
|
|
440
|
+
height: 50,
|
|
441
|
+
data: {},
|
|
442
|
+
},
|
|
443
|
+
{
|
|
444
|
+
id: '2',
|
|
445
|
+
position: { x: 100, y: 100 },
|
|
446
|
+
width: 100,
|
|
447
|
+
height: 50,
|
|
448
|
+
data: {},
|
|
449
|
+
},
|
|
450
|
+
],
|
|
451
|
+
})
|
|
452
|
+
|
|
453
|
+
// Move node 2 so right edges almost align
|
|
454
|
+
const snap = flow.getSnapLines('2', { x: 98, y: 100 })
|
|
455
|
+
// Right edge of node1: 200, right edge of moved node2: 98+100=198
|
|
456
|
+
// Diff = 2, within threshold 5
|
|
457
|
+
expect(snap.snappedPosition.x).toBe(100) // snapped
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
it('snaps to center Y alignment', () => {
|
|
461
|
+
const flow = createFlow({
|
|
462
|
+
nodes: [
|
|
463
|
+
{
|
|
464
|
+
id: '1',
|
|
465
|
+
position: { x: 0, y: 100 },
|
|
466
|
+
width: 100,
|
|
467
|
+
height: 50,
|
|
468
|
+
data: {},
|
|
469
|
+
},
|
|
470
|
+
{
|
|
471
|
+
id: '2',
|
|
472
|
+
position: { x: 200, y: 0 },
|
|
473
|
+
width: 100,
|
|
474
|
+
height: 50,
|
|
475
|
+
data: {},
|
|
476
|
+
},
|
|
477
|
+
],
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
// center Y of node1 = 100+25 = 125
|
|
481
|
+
// Move node2 so its center Y is close: y + 25 ≈ 125 → y ≈ 100
|
|
482
|
+
const snap = flow.getSnapLines('2', { x: 200, y: 102 })
|
|
483
|
+
expect(snap.snappedPosition.y).toBe(100) // snapped to center Y
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
it('snaps to bottom edge alignment', () => {
|
|
487
|
+
const flow = createFlow({
|
|
488
|
+
nodes: [
|
|
489
|
+
{
|
|
490
|
+
id: '1',
|
|
491
|
+
position: { x: 0, y: 0 },
|
|
492
|
+
width: 100,
|
|
493
|
+
height: 50,
|
|
494
|
+
data: {},
|
|
495
|
+
},
|
|
496
|
+
{
|
|
497
|
+
id: '2',
|
|
498
|
+
position: { x: 200, y: 0 },
|
|
499
|
+
width: 100,
|
|
500
|
+
height: 50,
|
|
501
|
+
data: {},
|
|
502
|
+
},
|
|
503
|
+
],
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
// bottom of node1 = 50, bottom of node2 = y + 50
|
|
507
|
+
// For snap: abs(y+50 - 50) < 5 → abs(y) < 5
|
|
508
|
+
const snap = flow.getSnapLines('2', { x: 200, y: 3 })
|
|
509
|
+
expect(snap.snappedPosition.y).toBe(0) // snapped to bottom edge
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
it('returns original position for missing node', () => {
|
|
513
|
+
const flow = createFlow()
|
|
514
|
+
const snap = flow.getSnapLines('missing', { x: 100, y: 200 })
|
|
515
|
+
expect(snap.snappedPosition).toEqual({ x: 100, y: 200 })
|
|
516
|
+
})
|
|
517
|
+
})
|
|
518
|
+
|
|
519
|
+
describe('reconnectEdge', () => {
|
|
520
|
+
it('changes edge source', () => {
|
|
521
|
+
const flow = createFlow({
|
|
522
|
+
nodes: [
|
|
523
|
+
{ id: '1', position: { x: 0, y: 0 }, data: {} },
|
|
524
|
+
{ id: '2', position: { x: 100, y: 0 }, data: {} },
|
|
525
|
+
{ id: '3', position: { x: 200, y: 0 }, data: {} },
|
|
526
|
+
],
|
|
527
|
+
edges: [{ id: 'e1', source: '1', target: '2' }],
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
flow.reconnectEdge('e1', { source: '3' })
|
|
531
|
+
expect(flow.getEdge('e1')!.source).toBe('3')
|
|
532
|
+
expect(flow.getEdge('e1')!.target).toBe('2') // unchanged
|
|
533
|
+
})
|
|
534
|
+
|
|
535
|
+
it('changes edge target', () => {
|
|
536
|
+
const flow = createFlow({
|
|
537
|
+
nodes: [
|
|
538
|
+
{ id: '1', position: { x: 0, y: 0 }, data: {} },
|
|
539
|
+
{ id: '2', position: { x: 100, y: 0 }, data: {} },
|
|
540
|
+
{ id: '3', position: { x: 200, y: 0 }, data: {} },
|
|
541
|
+
],
|
|
542
|
+
edges: [{ id: 'e1', source: '1', target: '2' }],
|
|
543
|
+
})
|
|
544
|
+
|
|
545
|
+
flow.reconnectEdge('e1', { target: '3' })
|
|
546
|
+
expect(flow.getEdge('e1')!.target).toBe('3')
|
|
547
|
+
})
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
describe('edge with custom id and handle', () => {
|
|
551
|
+
it('generates id from source/target handles', () => {
|
|
552
|
+
const flow = createFlow({
|
|
553
|
+
nodes: [
|
|
554
|
+
{ id: '1', position: { x: 0, y: 0 }, data: {} },
|
|
555
|
+
{ id: '2', position: { x: 100, y: 0 }, data: {} },
|
|
556
|
+
],
|
|
557
|
+
edges: [
|
|
558
|
+
{
|
|
559
|
+
source: '1',
|
|
560
|
+
target: '2',
|
|
561
|
+
sourceHandle: 'out',
|
|
562
|
+
targetHandle: 'in',
|
|
563
|
+
},
|
|
564
|
+
],
|
|
565
|
+
})
|
|
566
|
+
|
|
567
|
+
expect(flow.edges()[0]!.id).toBe('e-1-out-2-in')
|
|
568
|
+
})
|
|
569
|
+
})
|
|
570
|
+
|
|
571
|
+
describe('fitView with initial config', () => {
|
|
572
|
+
it('fits view on creation when config.fitView is true', () => {
|
|
573
|
+
const flow = createFlow({
|
|
574
|
+
nodes: [
|
|
575
|
+
{ id: '1', position: { x: 0, y: 0 }, data: {} },
|
|
576
|
+
{ id: '2', position: { x: 500, y: 500 }, data: {} },
|
|
577
|
+
],
|
|
578
|
+
fitView: true,
|
|
579
|
+
})
|
|
580
|
+
|
|
581
|
+
// Viewport should have been adjusted
|
|
582
|
+
const vp = flow.viewport()
|
|
583
|
+
expect(vp.zoom).not.toBe(1)
|
|
584
|
+
})
|
|
585
|
+
})
|
|
586
|
+
|
|
587
|
+
describe('proximity connect with connection rules', () => {
|
|
588
|
+
it('respects connection rules', () => {
|
|
589
|
+
const flow = createFlow({
|
|
590
|
+
nodes: [
|
|
591
|
+
{
|
|
592
|
+
id: '1',
|
|
593
|
+
type: 'output',
|
|
594
|
+
position: { x: 0, y: 0 },
|
|
595
|
+
data: {},
|
|
596
|
+
},
|
|
597
|
+
{
|
|
598
|
+
id: '2',
|
|
599
|
+
type: 'input',
|
|
600
|
+
position: { x: 100, y: 0 },
|
|
601
|
+
data: {},
|
|
602
|
+
},
|
|
603
|
+
],
|
|
604
|
+
connectionRules: {
|
|
605
|
+
output: { outputs: [] }, // output can't connect to anything
|
|
606
|
+
},
|
|
607
|
+
})
|
|
608
|
+
|
|
609
|
+
expect(flow.getProximityConnection('1', 200)).toBeNull()
|
|
610
|
+
})
|
|
611
|
+
})
|
|
612
|
+
|
|
613
|
+
describe('undo/redo edge cases', () => {
|
|
614
|
+
it('undo with empty history does nothing', () => {
|
|
615
|
+
const flow = createFlow({
|
|
616
|
+
nodes: [{ id: '1', position: { x: 0, y: 0 }, data: {} }],
|
|
617
|
+
})
|
|
618
|
+
|
|
619
|
+
flow.undo()
|
|
620
|
+
expect(flow.nodes()).toHaveLength(1)
|
|
621
|
+
})
|
|
622
|
+
|
|
623
|
+
it('redo with empty redo stack does nothing', () => {
|
|
624
|
+
const flow = createFlow({
|
|
625
|
+
nodes: [{ id: '1', position: { x: 0, y: 0 }, data: {} }],
|
|
626
|
+
})
|
|
627
|
+
|
|
628
|
+
flow.redo()
|
|
629
|
+
expect(flow.nodes()).toHaveLength(1)
|
|
630
|
+
})
|
|
631
|
+
|
|
632
|
+
it('multiple undo/redo cycles', () => {
|
|
633
|
+
const flow = createFlow()
|
|
634
|
+
|
|
635
|
+
flow.pushHistory()
|
|
636
|
+
flow.addNode({ id: '1', position: { x: 0, y: 0 }, data: {} })
|
|
637
|
+
|
|
638
|
+
flow.pushHistory()
|
|
639
|
+
flow.addNode({ id: '2', position: { x: 100, y: 0 }, data: {} })
|
|
640
|
+
|
|
641
|
+
expect(flow.nodes()).toHaveLength(2)
|
|
642
|
+
|
|
643
|
+
flow.undo()
|
|
644
|
+
expect(flow.nodes()).toHaveLength(1)
|
|
645
|
+
|
|
646
|
+
flow.undo()
|
|
647
|
+
expect(flow.nodes()).toHaveLength(0)
|
|
648
|
+
|
|
649
|
+
flow.redo()
|
|
650
|
+
expect(flow.nodes()).toHaveLength(1)
|
|
651
|
+
|
|
652
|
+
flow.redo()
|
|
653
|
+
expect(flow.nodes()).toHaveLength(2)
|
|
654
|
+
})
|
|
655
|
+
})
|
|
656
|
+
|
|
657
|
+
describe('copy/paste with edges', () => {
|
|
658
|
+
it('copies connected edges between selected nodes', () => {
|
|
659
|
+
const flow = createFlow({
|
|
660
|
+
nodes: [
|
|
661
|
+
{ id: '1', position: { x: 0, y: 0 }, data: {} },
|
|
662
|
+
{ id: '2', position: { x: 100, y: 0 }, data: {} },
|
|
663
|
+
{ id: '3', position: { x: 200, y: 0 }, data: {} },
|
|
664
|
+
],
|
|
665
|
+
edges: [
|
|
666
|
+
{ source: '1', target: '2' },
|
|
667
|
+
{ source: '2', target: '3' },
|
|
668
|
+
],
|
|
669
|
+
})
|
|
670
|
+
|
|
671
|
+
flow.selectNode('1')
|
|
672
|
+
flow.selectNode('2', true)
|
|
673
|
+
flow.copySelected()
|
|
674
|
+
flow.paste()
|
|
675
|
+
|
|
676
|
+
// Should have 5 nodes (3 original + 2 pasted)
|
|
677
|
+
expect(flow.nodes()).toHaveLength(5)
|
|
678
|
+
// Should have 3 edges (2 original + 1 pasted connecting 1→2)
|
|
679
|
+
expect(flow.edges()).toHaveLength(3)
|
|
680
|
+
})
|
|
681
|
+
|
|
682
|
+
it('copy with no selection does nothing', () => {
|
|
683
|
+
const flow = createFlow({
|
|
684
|
+
nodes: [{ id: '1', position: { x: 0, y: 0 }, data: {} }],
|
|
685
|
+
})
|
|
686
|
+
|
|
687
|
+
flow.copySelected()
|
|
688
|
+
flow.paste()
|
|
689
|
+
expect(flow.nodes()).toHaveLength(1) // nothing pasted because nothing was copied
|
|
690
|
+
})
|
|
691
|
+
})
|
|
692
|
+
|
|
693
|
+
describe('listener callbacks', () => {
|
|
694
|
+
it('onNodeDragStart/End can be registered', () => {
|
|
695
|
+
const flow = createFlow({
|
|
696
|
+
nodes: [{ id: '1', position: { x: 0, y: 0 }, data: {} }],
|
|
697
|
+
})
|
|
698
|
+
|
|
699
|
+
const starts: string[] = []
|
|
700
|
+
const ends: string[] = []
|
|
701
|
+
|
|
702
|
+
flow.onNodeDragStart((n) => starts.push(n.id))
|
|
703
|
+
flow.onNodeDragEnd((n) => ends.push(n.id))
|
|
704
|
+
|
|
705
|
+
// Emit manually via _emit
|
|
706
|
+
flow._emit.nodeDragStart(flow.getNode('1')!)
|
|
707
|
+
flow._emit.nodeDragEnd(flow.getNode('1')!)
|
|
708
|
+
|
|
709
|
+
expect(starts).toEqual(['1'])
|
|
710
|
+
expect(ends).toEqual(['1'])
|
|
711
|
+
})
|
|
712
|
+
|
|
713
|
+
it('onNodeDoubleClick', () => {
|
|
714
|
+
const flow = createFlow({
|
|
715
|
+
nodes: [{ id: '1', position: { x: 0, y: 0 }, data: {} }],
|
|
716
|
+
})
|
|
717
|
+
|
|
718
|
+
const clicked: string[] = []
|
|
719
|
+
flow.onNodeDoubleClick((n) => clicked.push(n.id))
|
|
720
|
+
|
|
721
|
+
flow._emit.nodeDoubleClick(flow.getNode('1')!)
|
|
722
|
+
expect(clicked).toEqual(['1'])
|
|
723
|
+
})
|
|
724
|
+
|
|
725
|
+
it('onNodeClick / onEdgeClick', () => {
|
|
726
|
+
const flow = createFlow({
|
|
727
|
+
nodes: [
|
|
728
|
+
{ id: '1', position: { x: 0, y: 0 }, data: {} },
|
|
729
|
+
{ id: '2', position: { x: 100, y: 0 }, data: {} },
|
|
730
|
+
],
|
|
731
|
+
edges: [{ id: 'e1', source: '1', target: '2' }],
|
|
732
|
+
})
|
|
733
|
+
|
|
734
|
+
const nodeClicks: string[] = []
|
|
735
|
+
const edgeClicks: string[] = []
|
|
736
|
+
|
|
737
|
+
flow.onNodeClick((n) => nodeClicks.push(n.id))
|
|
738
|
+
flow.onEdgeClick((e) => edgeClicks.push(e.id!))
|
|
739
|
+
|
|
740
|
+
flow._emit.nodeClick(flow.getNode('1')!)
|
|
741
|
+
flow._emit.edgeClick(flow.getEdge('e1')!)
|
|
742
|
+
|
|
743
|
+
expect(nodeClicks).toEqual(['1'])
|
|
744
|
+
expect(edgeClicks).toEqual(['e1'])
|
|
745
|
+
})
|
|
746
|
+
})
|
|
747
|
+
|
|
748
|
+
describe('containerSize', () => {
|
|
749
|
+
it('containerSize signal exists with defaults', () => {
|
|
750
|
+
const flow = createFlow()
|
|
751
|
+
expect(flow.containerSize()).toEqual({ width: 800, height: 600 })
|
|
752
|
+
})
|
|
753
|
+
|
|
754
|
+
it('containerSize can be updated', () => {
|
|
755
|
+
const flow = createFlow()
|
|
756
|
+
flow.containerSize.set({ width: 1200, height: 900 })
|
|
757
|
+
expect(flow.containerSize()).toEqual({ width: 1200, height: 900 })
|
|
758
|
+
})
|
|
759
|
+
})
|
|
760
|
+
|
|
761
|
+
describe('clampToExtent', () => {
|
|
762
|
+
it('returns position unchanged when no extent', () => {
|
|
763
|
+
const flow = createFlow()
|
|
764
|
+
expect(flow.clampToExtent({ x: -999, y: -999 })).toEqual({
|
|
765
|
+
x: -999,
|
|
766
|
+
y: -999,
|
|
767
|
+
})
|
|
768
|
+
})
|
|
769
|
+
|
|
770
|
+
it('clamps to extent boundaries', () => {
|
|
771
|
+
const flow = createFlow({
|
|
772
|
+
nodeExtent: [
|
|
773
|
+
[0, 0],
|
|
774
|
+
[500, 500],
|
|
775
|
+
],
|
|
776
|
+
})
|
|
777
|
+
expect(flow.clampToExtent({ x: -10, y: -10 }, 100, 50)).toEqual({
|
|
778
|
+
x: 0,
|
|
779
|
+
y: 0,
|
|
780
|
+
})
|
|
781
|
+
expect(flow.clampToExtent({ x: 999, y: 999 }, 100, 50)).toEqual({
|
|
782
|
+
x: 400,
|
|
783
|
+
y: 450,
|
|
784
|
+
})
|
|
785
|
+
})
|
|
786
|
+
})
|
|
787
|
+
|
|
788
|
+
describe('edge default type', () => {
|
|
789
|
+
it('uses defaultEdgeType from config', () => {
|
|
790
|
+
const flow = createFlow({
|
|
791
|
+
nodes: [
|
|
792
|
+
{ id: '1', position: { x: 0, y: 0 }, data: {} },
|
|
793
|
+
{ id: '2', position: { x: 100, y: 0 }, data: {} },
|
|
794
|
+
],
|
|
795
|
+
edges: [{ source: '1', target: '2' }],
|
|
796
|
+
defaultEdgeType: 'straight',
|
|
797
|
+
})
|
|
798
|
+
|
|
799
|
+
expect(flow.edges()[0]!.type).toBe('straight')
|
|
800
|
+
})
|
|
801
|
+
})
|
|
802
|
+
})
|