@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
package/src/store.ts ADDED
@@ -0,0 +1,526 @@
1
+ import {
2
+ createSignal,
3
+ createEffect,
4
+ createMemo,
5
+ untrack,
6
+ } from '@barefootjs/client'
7
+ import {
8
+ adoptUserNodes,
9
+ updateAbsolutePositions,
10
+ updateConnectionLookup,
11
+ fitViewport,
12
+ panBy as panByUtil,
13
+ } from '@xyflow/system'
14
+ import type {
15
+ NodeBase,
16
+ EdgeBase,
17
+ InternalNodeBase,
18
+ Viewport,
19
+ NodeLookup,
20
+ ParentLookup,
21
+ EdgeLookup,
22
+ ConnectionLookup,
23
+ SnapGrid,
24
+ NodeOrigin,
25
+ Transform,
26
+ PanZoomInstance,
27
+ NodeDragItem,
28
+ XYPosition,
29
+ } from '@xyflow/system'
30
+ import type { FlowStoreOptions, InternalFlowStore, FitViewOptions } from './types'
31
+ import { INFINITE_EXTENT } from './constants'
32
+
33
+ const DEFAULT_VIEWPORT: Viewport = { x: 0, y: 0, zoom: 1 }
34
+
35
+ /**
36
+ * Create a signal-based reactive store that bridges @xyflow/system
37
+ * with BarefootJS reactivity.
38
+ */
39
+ export function createFlowStore<
40
+ NodeType extends NodeBase = NodeBase,
41
+ EdgeType extends EdgeBase = EdgeBase,
42
+ >(options: FlowStoreOptions<NodeType, EdgeType> = {}): InternalFlowStore<NodeType, EdgeType> {
43
+ // --- Configuration ---
44
+ const minZoom = options.minZoom ?? 0.5
45
+ const maxZoom = options.maxZoom ?? 2
46
+ const nodeOrigin: NodeOrigin = options.nodeOrigin ?? [0, 0]
47
+ const nodeExtent = options.nodeExtent ?? INFINITE_EXTENT
48
+ const snapToGrid = options.snapToGrid ?? false
49
+ const snapGrid: SnapGrid = options.snapGrid ?? [15, 15]
50
+ const edgesReconnectable = options.edgesReconnectable ?? false
51
+
52
+ // --- Core state signals ---
53
+ const [nodes, setNodes] = createSignal<NodeType[]>(options.nodes ?? [])
54
+ const [edges, setEdges] = createSignal<EdgeType[]>(options.edges ?? [])
55
+ const [viewport, setViewport] = createSignal<Viewport>(
56
+ options.defaultViewport ?? DEFAULT_VIEWPORT
57
+ )
58
+ const [width, setWidth] = createSignal(0)
59
+ const [height, setHeight] = createSignal(0)
60
+ const [dragging, setDragging] = createSignal(false)
61
+
62
+ // --- Internal refs ---
63
+ const [panZoom, setPanZoom] = createSignal<PanZoomInstance | null>(null)
64
+ const [domNode, setDomNode] = createSignal<HTMLElement | null>(null)
65
+
66
+ // --- Lookups ---
67
+ //
68
+ // The outer signals hold the lookup `Map` for iteration-style
69
+ // consumers (edge-path effect, adapters that walk every node). They
70
+ // emit a fresh `Map` reference on each change so barefoot's
71
+ // identity-based dedupe does not suppress notification.
72
+ //
73
+ // The internal map of per-node signals (`nodeSignals`) is what
74
+ // delivers the fine-grained channel: per-node consumers subscribe
75
+ // through `nodeSignal(id)` and only wake up when *that* node's entry
76
+ // changes. This removes the recurring "read `nodes()` first to wake
77
+ // up the lookup" workaround at its source (#1270).
78
+ const [nodeLookup, setNodeLookup] = createSignal<NodeLookup<InternalNodeBase<NodeType>>>(
79
+ new Map()
80
+ )
81
+ const [parentLookup, setParentLookup] = createSignal<ParentLookup<InternalNodeBase<NodeType>>>(
82
+ new Map()
83
+ )
84
+ const [edgeLookup, setEdgeLookup] = createSignal<EdgeLookup<EdgeType>>(new Map())
85
+ const [connectionLookup, setConnectionLookup] = createSignal<ConnectionLookup>(new Map())
86
+
87
+ type NodeSignalSlot = {
88
+ get: () => InternalNodeBase<NodeType> | undefined
89
+ set: (
90
+ value:
91
+ | InternalNodeBase<NodeType>
92
+ | undefined
93
+ | ((prev: InternalNodeBase<NodeType> | undefined) => InternalNodeBase<NodeType> | undefined),
94
+ ) => void
95
+ }
96
+ const nodeSignals = new Map<string, NodeSignalSlot>()
97
+
98
+ // Slots are retained across remove → re-add so a consumer that
99
+ // called `nodeSignal(id)` before the node was added (or after it was
100
+ // removed) keeps subscribing to the same slot and is notified when
101
+ // the node appears.
102
+ function getOrCreateNodeSlot(id: string): NodeSignalSlot {
103
+ let slot = nodeSignals.get(id)
104
+ if (!slot) {
105
+ const [get, set] = createSignal<InternalNodeBase<NodeType> | undefined>(undefined)
106
+ slot = { get, set: set as NodeSignalSlot['set'] }
107
+ nodeSignals.set(id, slot)
108
+ }
109
+ return slot
110
+ }
111
+
112
+ function nodeSignal(id: string): InternalNodeBase<NodeType> | undefined {
113
+ return getOrCreateNodeSlot(id).get()
114
+ }
115
+
116
+ // Lightweight counter for notifying position-dependent subscribers (edges,
117
+ // per-node position effects) without triggering the full adoptUserNodes
118
+ // pipeline. Bumped by the drag handler after mutating nodeLookup in-place.
119
+ const [positionEpoch, setPositionEpoch] = createSignal(0)
120
+
121
+ // --- Derived state ---
122
+
123
+ /**
124
+ * Process user nodes through @xyflow/system's adoptUserNodes.
125
+ * This populates nodeLookup/parentLookup and calculates internals.
126
+ * Returns whether all nodes have measured dimensions.
127
+ */
128
+ const nodesInitialized = createMemo(() => {
129
+ const currentNodes = nodes()
130
+ const lookup = untrack(nodeLookup)
131
+ const parents = untrack(parentLookup)
132
+
133
+ // Preserve measured dimensions from existing internal nodes.
134
+ // adoptUserNodes reads userNode.measured but setNodes callers
135
+ // may not include it. Inject from existing lookup before rebuild.
136
+ for (const userNode of currentNodes) {
137
+ if (userNode.measured?.width) continue
138
+ const existing = lookup.get(userNode.id)
139
+ if (existing?.measured.width) {
140
+ ;(userNode as NodeBase).measured = {
141
+ width: existing.measured.width,
142
+ height: existing.measured.height,
143
+ }
144
+ }
145
+ }
146
+
147
+ const result = adoptUserNodes(currentNodes, lookup, parents, {
148
+ nodeOrigin,
149
+ nodeExtent,
150
+ checkEquality: false,
151
+ })
152
+
153
+ updateAbsolutePositions(lookup, parents, {
154
+ nodeOrigin,
155
+ nodeExtent,
156
+ })
157
+
158
+ // Reconcile the per-node signal cache with the freshly-rebuilt
159
+ // lookup. `adoptUserNodes(..., checkEquality: false)` constructs
160
+ // a new InternalNode wrapper for every entry on every call, so
161
+ // entry identity is **not** a reliable change signal. The
162
+ // underlying `internals.userNode` reference, however, only flips
163
+ // when `setNodes` actually produced a new node identity (callers
164
+ // use `prev.map(n => n.id === target ? { ...n, ... } : n)`), so
165
+ // we gate on that — sibling ids whose user node is unchanged stay
166
+ // silent. Removed ids see `undefined`.
167
+ for (const [id, entry] of lookup) {
168
+ const slot = getOrCreateNodeSlot(id)
169
+ const current = untrack(slot.get)
170
+ if (current?.internals.userNode !== entry.internals.userNode) {
171
+ slot.set(entry)
172
+ }
173
+ }
174
+ for (const [id, slot] of nodeSignals) {
175
+ if (!lookup.has(id) && untrack(slot.get) !== undefined) {
176
+ slot.set(undefined)
177
+ }
178
+ }
179
+
180
+ // Emit fresh outer Map references so coarse consumers (iterators
181
+ // that walk every node) re-run. `adoptUserNodes` mutates the
182
+ // existing `Map` in place, so re-emitting the same reference
183
+ // would be a no-op under barefoot's Object.is dedupe (#1270).
184
+ setNodeLookup(() => new Map(lookup))
185
+ setParentLookup(() => new Map(parents))
186
+
187
+ return result.nodesInitialized
188
+ })
189
+
190
+ // Process edges into lookup when edges change
191
+ createEffect(() => {
192
+ const currentEdges = edges()
193
+ const eLookup = new Map<string, EdgeType>()
194
+ for (const edge of currentEdges) {
195
+ eLookup.set(edge.id, edge)
196
+ }
197
+ setEdgeLookup(() => eLookup)
198
+
199
+ const connLookup = untrack(connectionLookup)
200
+ updateConnectionLookup(connLookup, eLookup, currentEdges)
201
+ // `updateConnectionLookup` mutates `connLookup` in place; emit a
202
+ // fresh `Map` reference so subscribers actually see the change.
203
+ setConnectionLookup(() => new Map(connLookup))
204
+ })
205
+
206
+ // --- Selection state ---
207
+ const [multiSelectionActive, setMultiSelectionActive] = createSignal(false)
208
+
209
+ // --- Interactivity ---
210
+ const [nodesDraggable, setNodesDraggable] = createSignal(options.nodesDraggable ?? true)
211
+ const [nodesConnectable, setNodesConnectable] = createSignal(options.nodesConnectable ?? true)
212
+ const [elementsSelectable, setElementsSelectable] = createSignal(options.elementsSelectable ?? true)
213
+
214
+ // --- Pan/zoom config (reactive signals for dynamic changes) ---
215
+ const [panOnDrag, setPanOnDrag] = createSignal(options.panOnDrag ?? true)
216
+ const [panOnScroll, setPanOnScroll] = createSignal(options.panOnScroll ?? false)
217
+ const [zoomOnScroll, setZoomOnScroll] = createSignal(options.zoomOnScroll ?? true)
218
+
219
+ // --- Static config ---
220
+ const deleteKeyCode = options.deleteKeyCode !== undefined ? options.deleteKeyCode : ['Delete', 'Backspace']
221
+ const selectionKeyCode = options.selectionKeyCode !== undefined ? options.selectionKeyCode : 'Shift'
222
+ const connectionLineStyle = options.connectionLineStyle
223
+ const defaultEdgeOptions = options.defaultEdgeOptions
224
+ const elevateNodesOnSelect = options.elevateNodesOnSelect ?? false
225
+ const reconnectRadius = options.reconnectRadius ?? 20
226
+ const zoomOnDoubleClick = options.zoomOnDoubleClick ?? true
227
+
228
+ /**
229
+ * Update pan/zoom instance configuration.
230
+ * Called by initFlow on setup and reactively when settings change.
231
+ */
232
+ function updatePanZoomConfig() {
233
+ const pz = untrack(panZoom)
234
+ if (!pz) return
235
+ pz.update({
236
+ noWheelClassName: 'nowheel',
237
+ noPanClassName: 'nopan',
238
+ preventScrolling: true,
239
+ panOnScroll: panOnScroll(),
240
+ panOnDrag: panOnDrag(),
241
+ panOnScrollMode: 'free' as any,
242
+ panOnScrollSpeed: 0.5,
243
+ userSelectionActive: false,
244
+ zoomOnPinch: true,
245
+ zoomOnScroll: zoomOnScroll(),
246
+ zoomOnDoubleClick,
247
+ zoomActivationKeyPressed: false,
248
+ lib: 'bf',
249
+ onTransformChange: (transform: Transform) => {
250
+ setViewport({ x: transform[0], y: transform[1], zoom: transform[2] })
251
+ },
252
+ connectionInProgress: false,
253
+ paneClickDistance: 0,
254
+ })
255
+ }
256
+
257
+ // --- Actions ---
258
+
259
+ function getTransform(): Transform {
260
+ const vp = untrack(viewport)
261
+ return [vp.x, vp.y, vp.zoom]
262
+ }
263
+
264
+ /**
265
+ * Bump the position epoch counter to notify position-dependent effects
266
+ * (edges, per-node position) without triggering adoptUserNodes.
267
+ */
268
+ function triggerPositionUpdate(): void {
269
+ setPositionEpoch((n) => n + 1)
270
+ }
271
+
272
+ /**
273
+ * Update node positions during drag operations.
274
+ * Called by XYDrag with the current drag items.
275
+ */
276
+ function updateNodePositions(
277
+ dragItems: Map<string, NodeDragItem | InternalNodeBase>,
278
+ isDragging = true,
279
+ ) {
280
+ const lookup = untrack(nodeLookup)
281
+ let mutated = false
282
+
283
+ for (const [id, item] of dragItems) {
284
+ const internalNode = lookup.get(id)
285
+ if (!internalNode) continue
286
+
287
+ internalNode.internals.positionAbsolute = item.internals
288
+ ? (item as InternalNodeBase).internals.positionAbsolute
289
+ : { x: (item as NodeDragItem).position.x, y: (item as NodeDragItem).position.y }
290
+
291
+ internalNode.internals.userNode.position = item.position
292
+ internalNode.internals.userNode.dragging = isDragging
293
+
294
+ // Per-node fine-grained emit: shallow-clone so the entry identity
295
+ // flips (barefoot's Object.is dedupe would otherwise swallow the
296
+ // notification — the mutations above don't change the entry
297
+ // reference). The inner `internals` object stays shared, so
298
+ // consumers reading `.internals.positionAbsolute` still see the
299
+ // updated position.
300
+ const fresh = { ...internalNode } as InternalNodeBase<NodeType>
301
+ lookup.set(id, fresh)
302
+ getOrCreateNodeSlot(id).set(fresh)
303
+ mutated = true
304
+ }
305
+
306
+ if (mutated) {
307
+ // Coarse emit so iteration-style consumers re-run too. Per-node
308
+ // bridges that switched to `nodeSignal(id)` only wake up for
309
+ // their own id and skip this channel.
310
+ setNodeLookup(() => new Map(lookup))
311
+ }
312
+ // Keep positionEpoch as the dedicated drag-rate channel for
313
+ // existing subscribers (edge-path effect).
314
+ triggerPositionUpdate()
315
+ }
316
+
317
+ /**
318
+ * Deselect all nodes and edges, or specific ones.
319
+ */
320
+ function unselectNodesAndEdges(params?: {
321
+ nodes?: NodeBase[]
322
+ edges?: EdgeBase[]
323
+ }) {
324
+ const currentNodes = untrack(nodes)
325
+ const currentEdges = untrack(edges)
326
+
327
+ if (params?.nodes) {
328
+ const idsToDeselect = new Set(params.nodes.map((n) => n.id))
329
+ setNodes(
330
+ currentNodes.map((n) =>
331
+ idsToDeselect.has(n.id) ? { ...n, selected: false } : n,
332
+ ) as NodeType[],
333
+ )
334
+ } else {
335
+ setNodes(
336
+ currentNodes.map((n) =>
337
+ n.selected ? { ...n, selected: false } : n,
338
+ ) as NodeType[],
339
+ )
340
+ }
341
+
342
+ if (params?.edges) {
343
+ const idsToDeselect = new Set(params.edges.map((e) => e.id))
344
+ setEdges(
345
+ currentEdges.map((e) =>
346
+ idsToDeselect.has(e.id) ? { ...e, selected: false } : e,
347
+ ) as EdgeType[],
348
+ )
349
+ } else {
350
+ setEdges(
351
+ currentEdges.map((e) =>
352
+ e.selected ? { ...e, selected: false } : e,
353
+ ) as EdgeType[],
354
+ )
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Pan the viewport by a delta amount.
360
+ */
361
+ async function panByDelta(delta: XYPosition): Promise<boolean> {
362
+ return panByUtil({
363
+ delta,
364
+ panZoom: untrack(panZoom),
365
+ transform: getTransform(),
366
+ translateExtent: INFINITE_EXTENT,
367
+ width: untrack(width),
368
+ height: untrack(height),
369
+ })
370
+ }
371
+
372
+ /**
373
+ * Add a new edge to the store.
374
+ */
375
+ function addEdge(edge: EdgeType) {
376
+ setEdges((prev) => [...prev, edge])
377
+ }
378
+
379
+ /**
380
+ * Delete nodes and edges from the store.
381
+ */
382
+ function deleteElements(params: {
383
+ nodes?: NodeType[]
384
+ edges?: EdgeType[]
385
+ }) {
386
+ if (params.nodes?.length) {
387
+ const idsToRemove = new Set(params.nodes.map((n) => n.id))
388
+ setNodes((prev) => prev.filter((n) => !idsToRemove.has(n.id)))
389
+ // Also remove connected edges
390
+ setEdges((prev) =>
391
+ prev.filter(
392
+ (e) => !idsToRemove.has(e.source) && !idsToRemove.has(e.target),
393
+ ),
394
+ )
395
+ }
396
+ if (params.edges?.length) {
397
+ const idsToRemove = new Set(params.edges.map((e) => e.id))
398
+ setEdges((prev) => prev.filter((e) => !idsToRemove.has(e.id)))
399
+ }
400
+ }
401
+
402
+ function fitView(fitViewOptions?: FitViewOptions) {
403
+ const pz = untrack(panZoom)
404
+ if (!pz) return
405
+
406
+ const lookup = untrack(nodeLookup)
407
+ const w = untrack(width)
408
+ const h = untrack(height)
409
+
410
+ fitViewport(
411
+ {
412
+ nodes: lookup,
413
+ width: w,
414
+ height: h,
415
+ panZoom: pz,
416
+ minZoom,
417
+ maxZoom,
418
+ },
419
+ { padding: 0.1, ...fitViewOptions }
420
+ )
421
+ }
422
+
423
+ return {
424
+ // Signal getters
425
+ nodes,
426
+ edges,
427
+ viewport,
428
+ width,
429
+ height,
430
+ dragging,
431
+ nodesInitialized,
432
+
433
+ // Lookups
434
+ nodeLookup,
435
+ parentLookup,
436
+ edgeLookup,
437
+ connectionLookup,
438
+ nodeSignal,
439
+
440
+ // Internal refs
441
+ panZoom,
442
+ domNode,
443
+
444
+ // Setters
445
+ setNodes,
446
+ setEdges,
447
+ setViewport,
448
+ setWidth,
449
+ setHeight,
450
+
451
+ // Selection state
452
+ multiSelectionActive,
453
+
454
+ // Interactivity
455
+ nodesDraggable,
456
+ setNodesDraggable,
457
+ nodesConnectable,
458
+ setNodesConnectable,
459
+ elementsSelectable,
460
+ setElementsSelectable,
461
+ panOnDrag,
462
+ setPanOnDrag,
463
+ panOnScroll,
464
+ setPanOnScroll,
465
+ zoomOnScroll,
466
+ setZoomOnScroll,
467
+
468
+ // Static config
469
+ deleteKeyCode,
470
+ selectionKeyCode,
471
+ connectionLineStyle,
472
+ defaultEdgeOptions,
473
+ elevateNodesOnSelect,
474
+ reconnectRadius,
475
+
476
+ // Lightweight position change notification (avoids full adoptUserNodes)
477
+ positionEpoch,
478
+ triggerPositionUpdate,
479
+
480
+ setDragging,
481
+ setPanZoom,
482
+ setDomNode,
483
+ setMultiSelectionActive,
484
+ updatePanZoomConfig,
485
+
486
+ // Actions
487
+ fitView,
488
+ updateNodePositions,
489
+ unselectNodesAndEdges,
490
+ panByDelta,
491
+ addEdge,
492
+ deleteElements,
493
+
494
+ // Configuration
495
+ minZoom,
496
+ maxZoom,
497
+ nodeOrigin,
498
+ nodeExtent,
499
+ snapToGrid,
500
+ snapGrid,
501
+ edgesReconnectable,
502
+
503
+ getTransform,
504
+
505
+ // Custom types
506
+ nodeTypes: options.nodeTypes,
507
+ edgeTypes: options.edgeTypes,
508
+
509
+ // Connection callbacks
510
+ onConnect: options.onConnect,
511
+ onConnectStart: options.onConnectStart,
512
+ onConnectEnd: options.onConnectEnd,
513
+ isValidConnection: options.isValidConnection,
514
+ onReconnect: options.onReconnect,
515
+
516
+ // Lifecycle callbacks
517
+ onInit: options.onInit,
518
+ onNodeDragStart: options.onNodeDragStart,
519
+ onNodeDragStop: options.onNodeDragStop,
520
+ onMoveEnd: options.onMoveEnd,
521
+ onPaneClick: options.onPaneClick,
522
+ onPaneMouseMove: options.onPaneMouseMove,
523
+ onNodesDelete: options.onNodesDelete,
524
+ onEdgesDelete: options.onEdgesDelete,
525
+ }
526
+ }