@barefootjs/xyflow 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.
Files changed (58) hide show
  1. package/README.md +117 -0
  2. package/dist/classes.d.ts +31 -0
  3. package/dist/classes.d.ts.map +1 -0
  4. package/dist/compat.d.ts +67 -0
  5. package/dist/compat.d.ts.map +1 -0
  6. package/dist/connection.d.ts +20 -0
  7. package/dist/connection.d.ts.map +1 -0
  8. package/dist/constants.d.ts +4 -0
  9. package/dist/constants.d.ts.map +1 -0
  10. package/dist/context.d.ts +7 -0
  11. package/dist/context.d.ts.map +1 -0
  12. package/dist/edge-path.d.ts +14 -0
  13. package/dist/edge-path.d.ts.map +1 -0
  14. package/dist/flow-subsystems.d.ts +28 -0
  15. package/dist/flow-subsystems.d.ts.map +1 -0
  16. package/dist/hooks.d.ts +34 -0
  17. package/dist/hooks.d.ts.map +1 -0
  18. package/dist/index.d.ts +19 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +6572 -0
  21. package/dist/node-resizer.d.ts +52 -0
  22. package/dist/node-resizer.d.ts.map +1 -0
  23. package/dist/node-type-dispatch.d.ts +34 -0
  24. package/dist/node-type-dispatch.d.ts.map +1 -0
  25. package/dist/selection.d.ts +32 -0
  26. package/dist/selection.d.ts.map +1 -0
  27. package/dist/store.d.ts +8 -0
  28. package/dist/store.d.ts.map +1 -0
  29. package/dist/types.d.ts +214 -0
  30. package/dist/types.d.ts.map +1 -0
  31. package/dist/utils.d.ts +6 -0
  32. package/dist/utils.d.ts.map +1 -0
  33. package/dist/xyflow.browser.min.js +95 -0
  34. package/dist/xyflow.browser.min.js.map +129 -0
  35. package/package.json +58 -0
  36. package/src/__tests__/clamp-drag-position.test.ts +111 -0
  37. package/src/__tests__/compat.test.ts +157 -0
  38. package/src/__tests__/host-element-store.test.ts +33 -0
  39. package/src/__tests__/jsx-smoke.test.ts +33 -0
  40. package/src/__tests__/jsx-smoke.tsx +23 -0
  41. package/src/__tests__/node-type-dispatch.test.ts +104 -0
  42. package/src/__tests__/store.test.ts +399 -0
  43. package/src/__tests__/tsconfig.json +23 -0
  44. package/src/classes.ts +41 -0
  45. package/src/compat.ts +237 -0
  46. package/src/connection.ts +459 -0
  47. package/src/constants.ts +8 -0
  48. package/src/context.ts +8 -0
  49. package/src/edge-path.ts +89 -0
  50. package/src/flow-subsystems.ts +506 -0
  51. package/src/hooks.ts +72 -0
  52. package/src/index.ts +134 -0
  53. package/src/node-resizer.ts +276 -0
  54. package/src/node-type-dispatch.ts +46 -0
  55. package/src/selection.ts +407 -0
  56. package/src/store.ts +526 -0
  57. package/src/types.ts +329 -0
  58. package/src/utils.ts +13 -0
@@ -0,0 +1,399 @@
1
+ import { describe, test, expect } from 'bun:test'
2
+ import { createEffect, createRoot } from '@barefootjs/client'
3
+ import { createFlowStore } from '../store'
4
+
5
+ describe('createFlowStore', () => {
6
+ test('returns initial empty state', () => {
7
+ createRoot(() => {
8
+ const store = createFlowStore()
9
+ expect(store.nodes()).toEqual([])
10
+ expect(store.edges()).toEqual([])
11
+ expect(store.viewport()).toEqual({ x: 0, y: 0, zoom: 1 })
12
+ expect(store.width()).toBe(0)
13
+ expect(store.height()).toBe(0)
14
+ expect(store.dragging()).toBe(false)
15
+ })
16
+ })
17
+
18
+ test('accepts initial nodes and edges', () => {
19
+ createRoot(() => {
20
+ const nodes = [
21
+ { id: '1', position: { x: 0, y: 0 }, data: { label: 'A' } },
22
+ { id: '2', position: { x: 100, y: 50 }, data: { label: 'B' } },
23
+ ]
24
+ const edges = [{ id: 'e1-2', source: '1', target: '2' }]
25
+
26
+ const store = createFlowStore({ nodes, edges })
27
+ expect(store.nodes()).toHaveLength(2)
28
+ expect(store.edges()).toHaveLength(1)
29
+ })
30
+ })
31
+
32
+ test('accepts custom viewport', () => {
33
+ createRoot(() => {
34
+ const store = createFlowStore({
35
+ defaultViewport: { x: 50, y: 100, zoom: 1.5 },
36
+ })
37
+ expect(store.viewport()).toEqual({ x: 50, y: 100, zoom: 1.5 })
38
+ })
39
+ })
40
+
41
+ test('setNodes triggers reactive updates', () => {
42
+ createRoot(() => {
43
+ const store = createFlowStore()
44
+ const updates: number[] = []
45
+
46
+ createEffect(() => {
47
+ updates.push(store.nodes().length)
48
+ })
49
+
50
+ expect(updates).toEqual([0])
51
+
52
+ store.setNodes([
53
+ { id: '1', position: { x: 0, y: 0 }, data: { label: 'A' } },
54
+ ])
55
+
56
+ expect(updates).toEqual([0, 1])
57
+ })
58
+ })
59
+
60
+ test('setEdges triggers reactive updates', () => {
61
+ createRoot(() => {
62
+ const store = createFlowStore()
63
+ const updates: number[] = []
64
+
65
+ createEffect(() => {
66
+ updates.push(store.edges().length)
67
+ })
68
+
69
+ expect(updates).toEqual([0])
70
+
71
+ store.setEdges([{ id: 'e1', source: '1', target: '2' }])
72
+
73
+ expect(updates).toEqual([0, 1])
74
+ })
75
+ })
76
+
77
+ test('per-node selected getter tracks setNodes updates', () => {
78
+ // Exercises the pattern NodeComponentProps.selected uses internally:
79
+ // a getter that looks the node up in store.nodes() so custom components
80
+ // can observe selection changes after mount via createEffect.
81
+ createRoot(() => {
82
+ const store = createFlowStore({
83
+ nodes: [
84
+ { id: '1', position: { x: 0, y: 0 }, data: {} },
85
+ { id: '2', position: { x: 10, y: 0 }, data: {} },
86
+ ],
87
+ })
88
+
89
+ const isSelected = (id: string) => () => {
90
+ const n = store.nodes().find((n) => n.id === id) as
91
+ | { selected?: boolean }
92
+ | undefined
93
+ return n?.selected ?? false
94
+ }
95
+
96
+ const log: boolean[] = []
97
+ const selectedOne = isSelected('1')
98
+ createEffect(() => {
99
+ log.push(selectedOne())
100
+ })
101
+
102
+ expect(log).toEqual([false])
103
+
104
+ store.setNodes((nds) =>
105
+ nds.map((n) => (n.id === '1' ? { ...n, selected: true } : n)),
106
+ )
107
+ expect(log).toEqual([false, true])
108
+
109
+ // Selecting a different node should not re-emit for node 1 since
110
+ // the underlying value didn't change — but store.nodes() did, so
111
+ // the effect re-runs. We just assert the latest value is still true.
112
+ store.setNodes((nds) =>
113
+ nds.map((n) => (n.id === '2' ? { ...n, selected: true } : n)),
114
+ )
115
+ expect(log[log.length - 1]).toBe(true)
116
+
117
+ store.setNodes((nds) =>
118
+ nds.map((n) => (n.id === '1' ? { ...n, selected: false } : n)),
119
+ )
120
+ expect(log[log.length - 1]).toBe(false)
121
+ })
122
+ })
123
+
124
+ test('setViewport triggers reactive updates', () => {
125
+ createRoot(() => {
126
+ const store = createFlowStore()
127
+ const viewports: Array<{ x: number; y: number; zoom: number }> = []
128
+
129
+ createEffect(() => {
130
+ viewports.push(store.viewport())
131
+ })
132
+
133
+ expect(viewports).toEqual([{ x: 0, y: 0, zoom: 1 }])
134
+
135
+ store.setViewport({ x: 10, y: 20, zoom: 2 })
136
+
137
+ expect(viewports).toEqual([
138
+ { x: 0, y: 0, zoom: 1 },
139
+ { x: 10, y: 20, zoom: 2 },
140
+ ])
141
+ })
142
+ })
143
+
144
+ test('nodesInitialized processes nodes through adoptUserNodes', () => {
145
+ createRoot(() => {
146
+ const nodes = [
147
+ { id: '1', position: { x: 0, y: 0 }, data: { label: 'A' } },
148
+ { id: '2', position: { x: 100, y: 50 }, data: { label: 'B' } },
149
+ ]
150
+ const store = createFlowStore({ nodes })
151
+
152
+ // nodesInitialized triggers node processing
153
+ const initialized = store.nodesInitialized()
154
+
155
+ // Nodes without measured dimensions are not "initialized"
156
+ expect(initialized).toBe(false)
157
+
158
+ // But nodeLookup should be populated
159
+ const lookup = store.nodeLookup()
160
+ expect(lookup.size).toBe(2)
161
+ expect(lookup.has('1')).toBe(true)
162
+ expect(lookup.has('2')).toBe(true)
163
+
164
+ // Internal node should have positionAbsolute
165
+ const node1 = lookup.get('1')!
166
+ expect(node1.internals.positionAbsolute).toEqual({ x: 0, y: 0 })
167
+ })
168
+ })
169
+
170
+ test('nodeLookup emits a fresh Map on setNodes-driven update (#1270)', () => {
171
+ // Regression for #1270: `adoptUserNodes` mutates the underlying
172
+ // Map in place, and the old code re-emitted the same reference.
173
+ // barefoot's Object.is dedupe swallowed the notification so per-
174
+ // node consumers stayed stuck on the initial value.
175
+ createRoot(() => {
176
+ const store = createFlowStore({
177
+ nodes: [
178
+ { id: '1', position: { x: 0, y: 0 }, data: { count: 0 } },
179
+ { id: '2', position: { x: 10, y: 0 }, data: {} },
180
+ ],
181
+ })
182
+ // Touch `nodesInitialized` so the memo evaluates and the lookup
183
+ // is populated before the assertions below.
184
+ store.nodesInitialized()
185
+
186
+ let fires = 0
187
+ let lastSeen: unknown
188
+ createEffect(() => {
189
+ const lookup = store.nodeLookup()
190
+ const entry = lookup.get('1')
191
+ lastSeen = (entry?.internals.userNode.data as { count?: number } | undefined)?.count
192
+ fires++
193
+ })
194
+ expect(fires).toBe(1)
195
+ expect(lastSeen).toBe(0)
196
+
197
+ store.setNodes((nds) =>
198
+ nds.map((n) => (n.id === '1' ? { ...n, data: { count: 1 } } : n)) as typeof nds,
199
+ )
200
+
201
+ expect(fires).toBe(2)
202
+ expect(lastSeen).toBe(1)
203
+ })
204
+ })
205
+
206
+ test('nodeSignal(id) fires only for the changed id (#1270 acceptance)', () => {
207
+ // Acceptance criterion from #1270: a single-node setNodes update
208
+ // must NOT wake up every per-node consumer. `nodeSignal(id)`
209
+ // delivers the fine-grained subscription that satisfies this.
210
+ createRoot(() => {
211
+ const store = createFlowStore({
212
+ nodes: [
213
+ { id: '1', position: { x: 0, y: 0 }, data: { count: 0 } },
214
+ { id: '2', position: { x: 10, y: 0 }, data: { count: 0 } },
215
+ ],
216
+ })
217
+ store.nodesInitialized()
218
+
219
+ let firesForOne = 0
220
+ let firesForTwo = 0
221
+ createEffect(() => {
222
+ store.nodeSignal('1')
223
+ firesForOne++
224
+ })
225
+ createEffect(() => {
226
+ store.nodeSignal('2')
227
+ firesForTwo++
228
+ })
229
+ expect([firesForOne, firesForTwo]).toEqual([1, 1])
230
+
231
+ // Mutate only node '1' — node '2's subscriber must NOT wake up.
232
+ store.setNodes((nds) =>
233
+ nds.map((n) => (n.id === '1' ? { ...n, data: { count: 1 } } : n)) as typeof nds,
234
+ )
235
+ expect([firesForOne, firesForTwo]).toEqual([2, 1])
236
+
237
+ // Mutate only node '2' via `selected` — node '1's subscriber stays asleep.
238
+ store.setNodes((nds) =>
239
+ nds.map((n) => (n.id === '2' ? { ...n, selected: true } : n)) as typeof nds,
240
+ )
241
+ expect([firesForOne, firesForTwo]).toEqual([2, 2])
242
+ })
243
+ })
244
+
245
+ test('nodeSignal(id) survives remove and re-add of the same id (#1270)', () => {
246
+ // Slots are retained across structural changes so a consumer that
247
+ // subscribed before the node was removed (or before it existed)
248
+ // resumes receiving updates when the node reappears.
249
+ createRoot(() => {
250
+ const store = createFlowStore({
251
+ nodes: [
252
+ { id: '1', position: { x: 0, y: 0 }, data: { count: 7 } },
253
+ ],
254
+ })
255
+ store.nodesInitialized()
256
+
257
+ const seen: Array<number | undefined> = []
258
+ createEffect(() => {
259
+ const entry = store.nodeSignal('1')
260
+ seen.push((entry?.internals.userNode.data as { count?: number } | undefined)?.count)
261
+ })
262
+ expect(seen).toEqual([7])
263
+
264
+ store.setNodes([])
265
+ expect(seen).toEqual([7, undefined])
266
+
267
+ store.setNodes([{ id: '1', position: { x: 0, y: 0 }, data: { count: 99 } }])
268
+ expect(seen).toEqual([7, undefined, 99])
269
+ })
270
+ })
271
+
272
+ test('edgeLookup is derived from edges', () => {
273
+ createRoot(() => {
274
+ const edges = [
275
+ { id: 'e1', source: '1', target: '2' },
276
+ { id: 'e2', source: '2', target: '3' },
277
+ ]
278
+ const store = createFlowStore({ edges })
279
+
280
+ const lookup = store.edgeLookup()
281
+ expect(lookup.size).toBe(2)
282
+ expect(lookup.has('e1')).toBe(true)
283
+ expect(lookup.has('e2')).toBe(true)
284
+ })
285
+ })
286
+
287
+ test('getTransform returns [x, y, zoom] tuple', () => {
288
+ createRoot(() => {
289
+ const store = createFlowStore({
290
+ defaultViewport: { x: 10, y: 20, zoom: 1.5 },
291
+ })
292
+ expect(store.getTransform()).toEqual([10, 20, 1.5])
293
+ })
294
+ })
295
+
296
+ test('configuration defaults', () => {
297
+ createRoot(() => {
298
+ const store = createFlowStore()
299
+ expect(store.minZoom).toBe(0.5)
300
+ expect(store.maxZoom).toBe(2)
301
+ expect(store.nodeOrigin).toEqual([0, 0])
302
+ expect(store.snapToGrid).toBe(false)
303
+ expect(store.snapGrid).toEqual([15, 15])
304
+ })
305
+ })
306
+
307
+ test('configuration overrides', () => {
308
+ createRoot(() => {
309
+ const store = createFlowStore({
310
+ minZoom: 0.1,
311
+ maxZoom: 5,
312
+ snapToGrid: true,
313
+ snapGrid: [10, 10],
314
+ nodeOrigin: [0.5, 0.5],
315
+ })
316
+ expect(store.minZoom).toBe(0.1)
317
+ expect(store.maxZoom).toBe(5)
318
+ expect(store.snapToGrid).toBe(true)
319
+ expect(store.snapGrid).toEqual([10, 10])
320
+ expect(store.nodeOrigin).toEqual([0.5, 0.5])
321
+ })
322
+ })
323
+
324
+ test('addEdge adds to edges', () => {
325
+ createRoot(() => {
326
+ const store = createFlowStore()
327
+ expect(store.edges()).toHaveLength(0)
328
+
329
+ store.addEdge({ id: 'e1', source: '1', target: '2' })
330
+ expect(store.edges()).toHaveLength(1)
331
+ expect(store.edges()[0].id).toBe('e1')
332
+ })
333
+ })
334
+
335
+ test('deleteElements removes nodes and connected edges', () => {
336
+ createRoot(() => {
337
+ const store = createFlowStore({
338
+ nodes: [
339
+ { id: '1', position: { x: 0, y: 0 }, data: {} },
340
+ { id: '2', position: { x: 100, y: 0 }, data: {} },
341
+ { id: '3', position: { x: 200, y: 0 }, data: {} },
342
+ ],
343
+ edges: [
344
+ { id: 'e1-2', source: '1', target: '2' },
345
+ { id: 'e2-3', source: '2', target: '3' },
346
+ ],
347
+ })
348
+
349
+ store.deleteElements({ nodes: [{ id: '2', position: { x: 100, y: 0 }, data: {} }] })
350
+
351
+ expect(store.nodes()).toHaveLength(2)
352
+ expect(store.nodes().map((n) => n.id)).toEqual(['1', '3'])
353
+ // Both edges connected to node 2 should be removed
354
+ expect(store.edges()).toHaveLength(0)
355
+ })
356
+ })
357
+
358
+ test('deleteElements removes only specified edges', () => {
359
+ createRoot(() => {
360
+ const store = createFlowStore({
361
+ edges: [
362
+ { id: 'e1', source: '1', target: '2' },
363
+ { id: 'e2', source: '2', target: '3' },
364
+ ],
365
+ })
366
+
367
+ store.deleteElements({ edges: [{ id: 'e1', source: '1', target: '2' }] })
368
+
369
+ expect(store.edges()).toHaveLength(1)
370
+ expect(store.edges()[0].id).toBe('e2')
371
+ })
372
+ })
373
+
374
+ test('unselectNodesAndEdges deselects all', () => {
375
+ createRoot(() => {
376
+ const store = createFlowStore({
377
+ nodes: [
378
+ { id: '1', position: { x: 0, y: 0 }, data: {}, selected: true },
379
+ { id: '2', position: { x: 100, y: 0 }, data: {}, selected: true },
380
+ ],
381
+ edges: [
382
+ { id: 'e1', source: '1', target: '2', selected: true },
383
+ ],
384
+ })
385
+
386
+ store.unselectNodesAndEdges()
387
+
388
+ expect(store.nodes().every((n) => !n.selected)).toBe(true)
389
+ expect(store.edges().every((e) => !e.selected)).toBe(true)
390
+ })
391
+ })
392
+
393
+ test('multiSelectionActive defaults to false', () => {
394
+ createRoot(() => {
395
+ const store = createFlowStore()
396
+ expect(store.multiSelectionActive()).toBe(false)
397
+ })
398
+ })
399
+ })
@@ -0,0 +1,23 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
7
+ "strict": true,
8
+ "noEmit": true,
9
+ "skipLibCheck": true,
10
+ "esModuleInterop": true,
11
+ "jsx": "react-jsx",
12
+ "jsxImportSource": "@barefootjs/jsx",
13
+ "types": ["bun"],
14
+ "paths": {
15
+ "@barefootjs/jsx": ["../../../jsx/src"],
16
+ "@barefootjs/jsx/jsx-runtime": ["../../../jsx/src/jsx-runtime/index.d.ts"],
17
+ "@barefootjs/jsx/jsx-dev-runtime": ["../../../jsx/src/jsx-dev-runtime/index.d.ts"],
18
+ "@barefootjs/client": ["../../../client/src"],
19
+ "@barefootjs/test": ["../../../test/src"]
20
+ }
21
+ },
22
+ "include": ["./**/*.ts", "./**/*.tsx"]
23
+ }
package/src/classes.ts ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Stable CSS class names for xyflow primitives.
3
+ *
4
+ * Exported as constants so the registry-side `<Flow>` / `<Background>` /
5
+ * etc. can reference them via `className={BF_FLOW}` instead of
6
+ * `className="bf-flow"`. site/ui's `cssLayerPrefixer` only rewrites
7
+ * locally-declared static `className` literals; imported identifiers are
8
+ * left alone, which keeps `bf-flow*` un-prefixed for the e2e selectors
9
+ * (the same trick chart uses with `CHART_CLASS_GRID` etc.).
10
+ */
11
+
12
+ export const BF_FLOW = 'bf-flow'
13
+ export const BF_FLOW_VIEWPORT = 'bf-flow__viewport'
14
+ export const BF_FLOW_EDGES = 'bf-flow__edges'
15
+ export const BF_FLOW_NODES = 'bf-flow__nodes'
16
+
17
+ export const BF_FLOW_NODE = 'bf-flow__node'
18
+ export const BF_FLOW_NODE_GROUP = 'bf-flow__node--group'
19
+ export const BF_FLOW_NODE_CHILD = 'bf-flow__node--child'
20
+ export const BF_FLOW_NODE_SELECTED = 'bf-flow__node--selected'
21
+ // Strips the default node card styling (padding / border / background)
22
+ // so a `renderNode` / `nodeTypes` body provides its own visual.
23
+ export const BF_FLOW_NODE_CUSTOM = 'bf-flow__node--custom'
24
+
25
+ export const BF_FLOW_EDGE = 'bf-flow__edge'
26
+ export const BF_FLOW_EDGE_SELECTED = 'bf-flow__edge--selected'
27
+ export const BF_FLOW_EDGE_ANIMATED = 'bf-flow__edge--animated'
28
+
29
+ export const BF_FLOW_HANDLE = 'bf-flow__handle'
30
+ export const BF_FLOW_HANDLE_TARGET = 'bf-flow__handle--target'
31
+ export const BF_FLOW_HANDLE_SOURCE = 'bf-flow__handle--source'
32
+
33
+ export const BF_FLOW_CONTROLS = 'bf-flow__controls'
34
+ export const BF_FLOW_CONTROLS_BUTTON = 'bf-flow__controls-button'
35
+
36
+ export const BF_FLOW_MINIMAP = 'bf-flow__minimap'
37
+ export const BF_FLOW_MINIMAP_MASK = 'bf-flow__minimap-mask'
38
+
39
+ // `xyflow__viewport` is a @xyflow/system compatibility class kept on the
40
+ // viewport wrapper.
41
+ export const XYFLOW_VIEWPORT = 'xyflow__viewport'
package/src/compat.ts ADDED
@@ -0,0 +1,237 @@
1
+ /**
2
+ * React Flow compatibility shims for desk migration.
3
+ *
4
+ * These functions mirror the React Flow hooks API so that
5
+ * desk components can be ported with minimal changes.
6
+ */
7
+
8
+ import { untrack } from '@barefootjs/client'
9
+ import { addEdge as addEdgeUtil, reconnectEdge as reconnectEdgeUtil, pointToRendererPoint } from '@xyflow/system'
10
+ import { useFlow } from './hooks'
11
+ import type { NodeBase, EdgeBase, Viewport, XYPosition } from './types'
12
+ import type { Connection, NodeChange, EdgeChange } from '@xyflow/system'
13
+
14
+ /**
15
+ * useNodesState — mirrors React Flow's useNodesState.
16
+ * Returns [nodes getter, setNodes, onNodesChange handler].
17
+ */
18
+ export function useNodesState<NodeType extends NodeBase = NodeBase>(
19
+ initialNodes: NodeType[],
20
+ ): [() => NodeType[], (updater: NodeType[] | ((prev: NodeType[]) => NodeType[])) => void, (changes: NodeChange[]) => void] {
21
+ const store = useFlow<NodeType>()
22
+ store.setNodes(initialNodes)
23
+
24
+ function onNodesChange(changes: NodeChange[]) {
25
+ store.setNodes((prev) => applyNodeChanges(prev, changes))
26
+ }
27
+
28
+ return [store.nodes, store.setNodes, onNodesChange]
29
+ }
30
+
31
+ /**
32
+ * useEdgesState — mirrors React Flow's useEdgesState.
33
+ * Returns [edges getter, setEdges, onEdgesChange handler].
34
+ */
35
+ export function useEdgesState<EdgeType extends EdgeBase = EdgeBase>(
36
+ initialEdges: EdgeType[],
37
+ ): [() => EdgeType[], (updater: EdgeType[] | ((prev: EdgeType[]) => EdgeType[])) => void, (changes: EdgeChange[]) => void] {
38
+ const store = useFlow<NodeBase, EdgeType>()
39
+ store.setEdges(initialEdges)
40
+
41
+ function onEdgesChange(changes: EdgeChange[]) {
42
+ store.setEdges((prev) => applyEdgeChanges(prev, changes))
43
+ }
44
+
45
+ return [store.edges, store.setEdges, onEdgesChange]
46
+ }
47
+
48
+ /**
49
+ * useReactFlow — mirrors React Flow's useReactFlow hook.
50
+ * Returns an object with common flow manipulation methods.
51
+ */
52
+ export function useReactFlow<
53
+ NodeType extends NodeBase = NodeBase,
54
+ EdgeType extends EdgeBase = EdgeBase,
55
+ >() {
56
+ const store = useFlow<NodeType, EdgeType>()
57
+
58
+ return {
59
+ // Getters
60
+ getNodes: (): NodeType[] => untrack(store.nodes),
61
+ getEdges: (): EdgeType[] => untrack(store.edges),
62
+ getNode: (id: string): NodeType | undefined =>
63
+ untrack(store.nodes).find((n) => n.id === id),
64
+ getZoom: (): number => untrack(store.viewport).zoom,
65
+ getViewport: (): Viewport => untrack(store.viewport),
66
+
67
+ // Setters
68
+ setNodes: store.setNodes,
69
+ setEdges: store.setEdges,
70
+ setViewport: (vp: Viewport) => {
71
+ const pz = untrack(store.panZoom)
72
+ if (pz) pz.setViewport(vp)
73
+ },
74
+ setCenter: (x: number, y: number, options?: { zoom?: number; duration?: number }) => {
75
+ const pz = untrack(store.panZoom)
76
+ if (!pz) return
77
+ const w = untrack(store.width)
78
+ const h = untrack(store.height)
79
+ const zoom = options?.zoom ?? untrack(store.viewport).zoom
80
+ pz.setViewport(
81
+ { x: w / 2 - x * zoom, y: h / 2 - y * zoom, zoom },
82
+ { duration: options?.duration },
83
+ )
84
+ },
85
+
86
+ // Actions
87
+ fitView: store.fitView,
88
+ zoomIn: (options?: { duration?: number }) => {
89
+ const pz = untrack(store.panZoom)
90
+ pz?.scaleBy(1.2, options)
91
+ },
92
+ zoomOut: (options?: { duration?: number }) => {
93
+ const pz = untrack(store.panZoom)
94
+ pz?.scaleBy(1 / 1.2, options)
95
+ },
96
+ zoomTo: (zoom: number, options?: { duration?: number }) => {
97
+ const pz = untrack(store.panZoom)
98
+ pz?.scaleTo(zoom, options)
99
+ },
100
+
101
+ // Node mutations
102
+ updateNode: (id: string, update: Partial<NodeType> | ((node: NodeType) => Partial<NodeType>)) => {
103
+ store.setNodes((prev) =>
104
+ prev.map((n) => {
105
+ if (n.id !== id) return n
106
+ const patch = typeof update === 'function' ? update(n) : update
107
+ return { ...n, ...patch }
108
+ }),
109
+ )
110
+ },
111
+ updateNodeData: (id: string, data: Partial<NodeType['data']> | ((prev: NodeType['data']) => Partial<NodeType['data']>)) => {
112
+ store.setNodes((prev) =>
113
+ prev.map((n) => {
114
+ if (n.id !== id) return n
115
+ const newData = typeof data === 'function' ? data(n.data) : data
116
+ return { ...n, data: { ...n.data, ...newData } }
117
+ }),
118
+ )
119
+ },
120
+
121
+ // Edge mutations
122
+ addEdges: (newEdges: EdgeType[]) => {
123
+ store.setEdges((prev) => [...prev, ...newEdges])
124
+ },
125
+
126
+ // Deletion
127
+ deleteElements: store.deleteElements,
128
+
129
+ // Coordinate conversion
130
+ screenToFlowPosition: (position: XYPosition): XYPosition => {
131
+ const domNode = untrack(store.domNode)
132
+ if (!domNode) return position
133
+ const rect = domNode.getBoundingClientRect()
134
+ const transform = store.getTransform()
135
+ return pointToRendererPoint(
136
+ { x: position.x - rect.left, y: position.y - rect.top },
137
+ transform,
138
+ )
139
+ },
140
+ }
141
+ }
142
+
143
+ /**
144
+ * useViewport — reactive viewport getter.
145
+ */
146
+ export { useViewport } from './hooks'
147
+
148
+ /**
149
+ * addEdge utility — wraps @xyflow/system's addEdge.
150
+ */
151
+ export function addEdge<EdgeType extends EdgeBase = EdgeBase>(
152
+ connection: Connection,
153
+ edges: EdgeType[],
154
+ ): EdgeType[] {
155
+ return addEdgeUtil(connection, edges) as EdgeType[]
156
+ }
157
+
158
+ /**
159
+ * reconnectEdge utility — wraps @xyflow/system's reconnectEdge.
160
+ */
161
+ export function reconnectEdge<EdgeType extends EdgeBase = EdgeBase>(
162
+ oldEdge: EdgeType,
163
+ newConnection: Connection,
164
+ edges: EdgeType[],
165
+ ): EdgeType[] {
166
+ return reconnectEdgeUtil(oldEdge, newConnection, edges) as EdgeType[]
167
+ }
168
+
169
+ // --- Internal helpers ---
170
+
171
+ function applyNodeChanges<NodeType extends NodeBase>(
172
+ nodes: NodeType[],
173
+ changes: NodeChange[],
174
+ ): NodeType[] {
175
+ let result = [...nodes]
176
+
177
+ for (const change of changes) {
178
+ switch (change.type) {
179
+ case 'position':
180
+ result = result.map((n) =>
181
+ n.id === change.id
182
+ ? {
183
+ ...n,
184
+ ...(change.position ? { position: change.position } : {}),
185
+ dragging: change.dragging ?? n.dragging,
186
+ }
187
+ : n,
188
+ )
189
+ break
190
+ case 'dimensions':
191
+ result = result.map((n) =>
192
+ n.id === change.id && change.dimensions
193
+ ? { ...n, width: change.dimensions.width, height: change.dimensions.height }
194
+ : n,
195
+ )
196
+ break
197
+ case 'select':
198
+ result = result.map((n) =>
199
+ n.id === change.id ? { ...n, selected: change.selected } : n,
200
+ )
201
+ break
202
+ case 'remove':
203
+ result = result.filter((n) => n.id !== change.id)
204
+ break
205
+ case 'add':
206
+ result.push(change.item as NodeType)
207
+ break
208
+ }
209
+ }
210
+
211
+ return result
212
+ }
213
+
214
+ function applyEdgeChanges<EdgeType extends EdgeBase>(
215
+ edges: EdgeType[],
216
+ changes: EdgeChange[],
217
+ ): EdgeType[] {
218
+ let result = [...edges]
219
+
220
+ for (const change of changes) {
221
+ switch (change.type) {
222
+ case 'select':
223
+ result = result.map((e) =>
224
+ e.id === change.id ? { ...e, selected: change.selected } : e,
225
+ )
226
+ break
227
+ case 'remove':
228
+ result = result.filter((e) => e.id !== change.id)
229
+ break
230
+ case 'add':
231
+ result.push(change.item as EdgeType)
232
+ break
233
+ }
234
+ }
235
+
236
+ return result
237
+ }