@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,407 @@
1
+ import { onCleanup, untrack } from '@barefootjs/client'
2
+ import type { NodeBase, EdgeBase, InternalNodeBase, Transform } from '@xyflow/system'
3
+ import type { FlowStore, InternalFlowStore, SelectionMode } from './types'
4
+
5
+ /**
6
+ * Set up keyboard handlers for the flow container.
7
+ * Needs InternalFlowStore for setMultiSelectionActive (Shift key).
8
+ */
9
+ export function setupKeyboardHandlers<
10
+ NodeType extends NodeBase = NodeBase,
11
+ EdgeType extends EdgeBase = EdgeBase,
12
+ >(
13
+ store: InternalFlowStore<NodeType, EdgeType>,
14
+ container: HTMLElement,
15
+ ): void {
16
+ function handleKeyDown(event: KeyboardEvent) {
17
+ const target = event.target as HTMLElement
18
+ if (
19
+ target.tagName === 'INPUT' ||
20
+ target.tagName === 'TEXTAREA' ||
21
+ target.isContentEditable
22
+ ) {
23
+ return
24
+ }
25
+
26
+ // Delete key handling (configurable, null disables)
27
+ const deleteKeys = store.deleteKeyCode
28
+ if (deleteKeys && deleteKeys.includes(event.key)) {
29
+ if (!untrack(store.nodesDraggable)) return
30
+ const selectedNodes = untrack(store.nodes).filter((n) => n.selected)
31
+ const selectedEdges = untrack(store.edges).filter((e) => e.selected)
32
+
33
+ if (selectedNodes.length > 0 || selectedEdges.length > 0) {
34
+ // Fire deletion callbacks before deleting
35
+ if (selectedNodes.length > 0 && store.onNodesDelete) {
36
+ store.onNodesDelete(selectedNodes)
37
+ }
38
+ if (selectedEdges.length > 0 && store.onEdgesDelete) {
39
+ store.onEdgesDelete(selectedEdges)
40
+ }
41
+
42
+ store.deleteElements({
43
+ nodes: selectedNodes,
44
+ edges: selectedEdges,
45
+ })
46
+ event.preventDefault()
47
+ }
48
+ }
49
+
50
+ if (event.key === 'Escape') {
51
+ store.unselectNodesAndEdges()
52
+ }
53
+
54
+ // Selection key handling (configurable)
55
+ const selKey = store.selectionKeyCode
56
+ if (selKey && event.key === selKey) {
57
+ store.setMultiSelectionActive(true)
58
+ }
59
+ }
60
+
61
+ function handleKeyUp(event: KeyboardEvent) {
62
+ const selKey = store.selectionKeyCode
63
+ if (selKey && event.key === selKey) {
64
+ store.setMultiSelectionActive(false)
65
+ }
66
+ }
67
+
68
+ container.setAttribute('tabindex', '0')
69
+ container.style.outline = 'none'
70
+ container.addEventListener('keydown', handleKeyDown)
71
+ container.addEventListener('keyup', handleKeyUp)
72
+
73
+ onCleanup(() => {
74
+ container.removeEventListener('keydown', handleKeyDown)
75
+ container.removeEventListener('keyup', handleKeyUp)
76
+ })
77
+ }
78
+
79
+ /**
80
+ * Set up click-to-select on node elements.
81
+ * Called from node-wrapper when creating each node.
82
+ */
83
+ export function setupNodeSelection<NodeType extends NodeBase>(
84
+ nodeElement: HTMLElement,
85
+ nodeId: string,
86
+ store: FlowStore<NodeType>,
87
+ ): void {
88
+ // Use mousedown instead of click — D3 zoom's mousedown handler on the
89
+ // container calls stopImmediatePropagation, which prevents the native
90
+ // click event from reaching the node element.
91
+ nodeElement.addEventListener('mousedown', (event) => {
92
+ if (event.button !== 0) return
93
+
94
+ const multiSelect = untrack(store.multiSelectionActive) || event.shiftKey
95
+
96
+ if (!multiSelect) {
97
+ // Deselect all, then select this one
98
+ store.unselectNodesAndEdges()
99
+ }
100
+
101
+ // Focus the container so keyboard events (Delete) work
102
+ const container = untrack(store.domNode)
103
+ if (container) container.focus()
104
+
105
+ // Toggle this node's selection
106
+ store.setNodes((prev) =>
107
+ prev.map((n) =>
108
+ n.id === nodeId
109
+ ? { ...n, selected: multiSelect ? !n.selected : true }
110
+ : n,
111
+ ),
112
+ )
113
+ })
114
+ }
115
+
116
+ /**
117
+ * Simple rect type for selection calculations.
118
+ */
119
+ type SelectionRect = { x: number; y: number; width: number; height: number }
120
+
121
+ /**
122
+ * Find nodes whose absolute positions overlap a screen-space rectangle.
123
+ *
124
+ * Unlike @xyflow/system's getNodesInside, this does NOT require handleBounds
125
+ * to be set — it works directly with positionAbsolute and measured dimensions.
126
+ * This avoids the forceInitialRender fallback that returns ALL nodes.
127
+ */
128
+ function findNodesInRect<NodeType extends NodeBase>(
129
+ nodeLookup: Map<string, InternalNodeBase<NodeType>>,
130
+ rect: SelectionRect,
131
+ [tx, ty, tScale]: Transform,
132
+ partially: boolean,
133
+ ): InternalNodeBase<NodeType>[] {
134
+ // Convert the screen-space rect to flow-space (undo viewport transform)
135
+ const flowRect = {
136
+ x: (rect.x - tx) / tScale,
137
+ y: (rect.y - ty) / tScale,
138
+ width: rect.width / tScale,
139
+ height: rect.height / tScale,
140
+ }
141
+
142
+ const result: InternalNodeBase<NodeType>[] = []
143
+
144
+ for (const node of nodeLookup.values()) {
145
+ if (node.hidden) continue
146
+
147
+ const nodeW = node.measured.width ?? 0
148
+ const nodeH = node.measured.height ?? 0
149
+ if (nodeW === 0 && nodeH === 0) continue
150
+
151
+ const pos = node.internals.positionAbsolute
152
+
153
+ // Calculate overlap between the flow-space selection rect and node rect
154
+ const overlapX = Math.max(0,
155
+ Math.min(flowRect.x + flowRect.width, pos.x + nodeW) -
156
+ Math.max(flowRect.x, pos.x),
157
+ )
158
+ const overlapY = Math.max(0,
159
+ Math.min(flowRect.y + flowRect.height, pos.y + nodeH) -
160
+ Math.max(flowRect.y, pos.y),
161
+ )
162
+ const overlapArea = overlapX * overlapY
163
+ const nodeArea = nodeW * nodeH
164
+
165
+ if (partially) {
166
+ // Partial mode: any overlap counts
167
+ if (overlapArea > 0) result.push(node)
168
+ } else {
169
+ // Full mode: node must be fully contained
170
+ if (overlapArea >= nodeArea) result.push(node)
171
+ }
172
+ }
173
+
174
+ return result
175
+ }
176
+
177
+ /**
178
+ * Compute screen-space bounding box around the given internal nodes.
179
+ */
180
+ function getSelectedNodesBBox<NodeType extends NodeBase>(
181
+ nodes: InternalNodeBase<NodeType>[],
182
+ [tx, ty, tScale]: Transform,
183
+ ): SelectionRect | null {
184
+ if (nodes.length === 0) return null
185
+
186
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
187
+
188
+ for (const node of nodes) {
189
+ const pos = node.internals.positionAbsolute
190
+ const nw = node.measured.width ?? 0
191
+ const nh = node.measured.height ?? 0
192
+ // Convert flow-space to screen-space
193
+ const sx = pos.x * tScale + tx
194
+ const sy = pos.y * tScale + ty
195
+ const sw = nw * tScale
196
+ const sh = nh * tScale
197
+ minX = Math.min(minX, sx)
198
+ minY = Math.min(minY, sy)
199
+ maxX = Math.max(maxX, sx + sw)
200
+ maxY = Math.max(maxY, sy + sh)
201
+ }
202
+
203
+ return {
204
+ x: minX,
205
+ y: minY,
206
+ width: maxX - minX,
207
+ height: maxY - minY,
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Options for the selection rectangle behavior.
213
+ */
214
+ export type SelectionRectOptions = {
215
+ /** When true, drag on pane starts selection without Shift key */
216
+ selectionOnDrag?: boolean
217
+ /** 'partial' selects overlapping nodes; 'full' requires full containment */
218
+ selectionMode?: SelectionMode
219
+ }
220
+
221
+ /**
222
+ * Set up selection rectangle (rubber-band / lasso) on the flow container.
223
+ *
224
+ * The rectangle is drawn when:
225
+ * - Shift+drag on empty pane, OR
226
+ * - Drag on empty pane when `selectionOnDrag` is true
227
+ *
228
+ * Nodes inside the rectangle are selected on mouse up.
229
+ */
230
+ export function setupSelectionRectangle<
231
+ NodeType extends NodeBase = NodeBase,
232
+ EdgeType extends EdgeBase = EdgeBase,
233
+ >(
234
+ store: InternalFlowStore<NodeType, EdgeType>,
235
+ container: HTMLElement,
236
+ options: SelectionRectOptions = {},
237
+ ): void {
238
+ const selectionOnDrag = options.selectionOnDrag ?? false
239
+ const selectionMode: SelectionMode = options.selectionMode ?? 'partial'
240
+
241
+ let selectionRect: HTMLDivElement | null = null
242
+ let isSelecting = false
243
+ let startX = 0
244
+ let startY = 0
245
+
246
+ function handleMouseDown(event: MouseEvent) {
247
+ if (event.button !== 0) return
248
+
249
+ // Remove any lingering selection bounding box from previous selection
250
+ if (selectionRect) {
251
+ selectionRect.remove()
252
+ selectionRect = null
253
+ }
254
+
255
+ // Only start selection on the container or viewport (empty pane),
256
+ // not on nodes, handles, controls, etc.
257
+ const target = event.target as HTMLElement
258
+ const isPane =
259
+ target === container ||
260
+ target.classList.contains('bf-flow__viewport') ||
261
+ target.classList.contains('bf-flow__nodes') ||
262
+ target.classList.contains('bf-flow__edges')
263
+
264
+ if (!isPane) return
265
+
266
+ // Determine if selection should activate:
267
+ // - Shift+drag always triggers selection
268
+ // - Plain drag triggers selection only if selectionOnDrag is true
269
+ const shiftHeld = event.shiftKey
270
+ if (!shiftHeld && !selectionOnDrag) return
271
+
272
+ // Stop propagation so D3 pan/zoom doesn't compete with selection drag
273
+ event.stopPropagation()
274
+ event.preventDefault()
275
+
276
+ isSelecting = true
277
+ startX = event.clientX
278
+ startY = event.clientY
279
+
280
+ // Create the selection rectangle element
281
+ selectionRect = document.createElement('div')
282
+ selectionRect.className = 'bf-flow__selection'
283
+ selectionRect.style.position = 'absolute'
284
+ selectionRect.style.pointerEvents = 'none'
285
+ selectionRect.style.left = '0'
286
+ selectionRect.style.top = '0'
287
+ selectionRect.style.width = '0'
288
+ selectionRect.style.height = '0'
289
+ selectionRect.style.zIndex = '5'
290
+ container.appendChild(selectionRect)
291
+
292
+ document.addEventListener('mousemove', handleMouseMove)
293
+ document.addEventListener('mouseup', handleMouseUp)
294
+ }
295
+
296
+ function handleMouseMove(event: MouseEvent) {
297
+ if (!isSelecting || !selectionRect) return
298
+
299
+ const containerRect = container.getBoundingClientRect()
300
+ const currentX = event.clientX
301
+ const currentY = event.clientY
302
+
303
+ // Calculate rectangle bounds relative to the container
304
+ const left = Math.min(startX, currentX) - containerRect.left
305
+ const top = Math.min(startY, currentY) - containerRect.top
306
+ const width = Math.abs(currentX - startX)
307
+ const height = Math.abs(currentY - startY)
308
+
309
+ selectionRect.style.left = `${left}px`
310
+ selectionRect.style.top = `${top}px`
311
+ selectionRect.style.width = `${width}px`
312
+ selectionRect.style.height = `${height}px`
313
+ }
314
+
315
+ function handleMouseUp(event: MouseEvent) {
316
+ if (!isSelecting) return
317
+
318
+ document.removeEventListener('mousemove', handleMouseMove)
319
+ document.removeEventListener('mouseup', handleMouseUp)
320
+
321
+ // Determine which nodes are inside the selection rectangle
322
+ const containerRect = container.getBoundingClientRect()
323
+ const currentX = event.clientX
324
+ const currentY = event.clientY
325
+
326
+ const left = Math.min(startX, currentX) - containerRect.left
327
+ const top = Math.min(startY, currentY) - containerRect.top
328
+ const width = Math.abs(currentX - startX)
329
+ const height = Math.abs(currentY - startY)
330
+
331
+ // Only process selection if the rectangle has meaningful size
332
+ // (avoid accidental clicks registering as selection)
333
+ if (width > 5 || height > 5) {
334
+ const transform = store.getTransform()
335
+ const nodeLookup = untrack(store.nodeLookup)
336
+ const partially = selectionMode === 'partial'
337
+
338
+ const nodesInside = findNodesInRect(
339
+ nodeLookup,
340
+ { x: left, y: top, width, height },
341
+ transform,
342
+ partially,
343
+ )
344
+
345
+ const selectedIds = new Set(nodesInside.map((n) => n.id))
346
+
347
+ if (selectedIds.size > 0) {
348
+ store.setNodes((prev) =>
349
+ prev.map((n) =>
350
+ selectedIds.has(n.id) ? { ...n, selected: true } : { ...n, selected: false },
351
+ ),
352
+ )
353
+
354
+ // Reposition selection rect as bounding box around selected nodes
355
+ if (selectionRect) {
356
+ const transform = store.getTransform()
357
+ const bbox = getSelectedNodesBBox(nodesInside, transform)
358
+ if (bbox) {
359
+ selectionRect.style.left = `${bbox.x}px`
360
+ selectionRect.style.top = `${bbox.y}px`
361
+ selectionRect.style.width = `${bbox.width}px`
362
+ selectionRect.style.height = `${bbox.height}px`
363
+ selectionRect.classList.add('bf-flow__selection--active')
364
+ // Focus container so keyboard Delete/Escape works immediately
365
+ container.focus()
366
+ } else {
367
+ selectionRect.remove()
368
+ selectionRect = null
369
+ }
370
+ }
371
+ } else {
372
+ store.unselectNodesAndEdges()
373
+ if (selectionRect) { selectionRect.remove(); selectionRect = null }
374
+ }
375
+ } else {
376
+ // Small drag = click on empty pane, deselect all
377
+ store.unselectNodesAndEdges()
378
+ if (selectionRect) { selectionRect.remove(); selectionRect = null }
379
+ }
380
+
381
+ isSelecting = false
382
+ }
383
+
384
+ // Clean up selection rect when selected nodes are deleted
385
+ function handleSelectionKeyDown(event: KeyboardEvent) {
386
+ if (!selectionRect) return
387
+ if (event.key === 'Delete' || event.key === 'Backspace') {
388
+ selectionRect.remove()
389
+ selectionRect = null
390
+ }
391
+ }
392
+
393
+ // Use capture phase so we can intercept before D3 zoom when needed
394
+ container.addEventListener('mousedown', handleMouseDown, true)
395
+ container.addEventListener('keydown', handleSelectionKeyDown)
396
+
397
+ onCleanup(() => {
398
+ container.removeEventListener('mousedown', handleMouseDown, true)
399
+ container.removeEventListener('keydown', handleSelectionKeyDown)
400
+ document.removeEventListener('mousemove', handleMouseMove)
401
+ document.removeEventListener('mouseup', handleMouseUp)
402
+ if (selectionRect) {
403
+ selectionRect.remove()
404
+ selectionRect = null
405
+ }
406
+ })
407
+ }