@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,506 @@
1
+ // Flow imperative subsystems extracted from `flow.ts` (#1081 cutover step C4).
2
+ //
3
+ // Pointer-paced subsystems that the JSX-native `<Flow>` component
4
+ // attaches via a `ref` callback. JSX gives these no leverage — pan/zoom
5
+ // is owned by `XYPanZoom` (D3-zoom-derived), `setupKeyboardHandlers`
6
+ // listens at document level, the selection rectangle owns global
7
+ // pointer capture, and pane-click detection needs a `mousedown`/
8
+ // `mouseup` pair to bypass D3's event suppression.
9
+ //
10
+ // Calling shape:
11
+ //
12
+ // <div ref={(el) => attachFlowSubsystems(el, store, props)} class="bf-flow">
13
+ // ...
14
+ // </div>
15
+ //
16
+ // `injectDefaultStyles` runs idempotently on first call, so multiple
17
+ // `<Flow>` instances on the same page share one `<style id="bf-flow-styles">`.
18
+
19
+ import { createEffect, onCleanup, untrack } from '@barefootjs/client'
20
+ import { PanOnScrollMode, XYPanZoom } from '@xyflow/system'
21
+ import type { InternalNodeBase, NodeLookup, Transform, Viewport, XYPosition } from '@xyflow/system'
22
+ import { INFINITE_EXTENT } from './constants'
23
+ import { computeEdgePosition, getEdgePath } from './edge-path'
24
+ import { setupKeyboardHandlers, setupSelectionRectangle } from './selection'
25
+ import type { FlowProps, InternalFlowStore, NodeBase, EdgeBase } from './types'
26
+
27
+ /**
28
+ * Clamp a child node's drag position so the node's rect stays inside
29
+ * its parent's rect — implements xyflow's `extent: 'parent'` contract
30
+ * for the pointer-paced single-node drag handler.
31
+ *
32
+ * Operates on the **relative** coordinate the parent-relative model
33
+ * stores in `userNode.position`: the constraint per axis is
34
+ * `0 ≤ pos ≤ parentSize − childSize`. Returns the input unchanged when
35
+ * the node has no `parentId`, isn't `extent: 'parent'`, the parent is
36
+ * missing, or either node is unmeasured.
37
+ *
38
+ * Exposed (non-default-export) so the same primitive can be reused if
39
+ * the C4 XYDrag integration replaces the inline drag handler — the
40
+ * extent contract remains the same.
41
+ */
42
+ export function clampDragPositionToParent(
43
+ position: XYPosition,
44
+ nodeId: string,
45
+ lookup: NodeLookup<InternalNodeBase<NodeBase>>,
46
+ ): XYPosition {
47
+ const internal = lookup.get(nodeId)
48
+ if (!internal) return position
49
+ const userNode = internal.internals.userNode as NodeBase & {
50
+ parentId?: string
51
+ extent?: 'parent' | unknown
52
+ }
53
+ if (!userNode.parentId || userNode.extent !== 'parent') return position
54
+ const parent = lookup.get(userNode.parentId)
55
+ if (!parent) return position
56
+ const myW = internal.measured?.width
57
+ const myH = internal.measured?.height
58
+ const pw = parent.measured?.width
59
+ const ph = parent.measured?.height
60
+ if (myW == null || myH == null || pw == null || ph == null) return position
61
+ const maxX = Math.max(0, pw - myW)
62
+ const maxY = Math.max(0, ph - myH)
63
+ return {
64
+ x: Math.min(Math.max(position.x, 0), maxX),
65
+ y: Math.min(Math.max(position.y, 0), maxY),
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Attach all pointer-paced + ResizeObserver-based subsystems to the
71
+ * outer `<div class="bf-flow">` rendered by the JSX `<Flow>` component.
72
+ *
73
+ * Inside a reactive root (`createRoot`), this also registers
74
+ * `onCleanup` callbacks for the panZoom destroy / ResizeObserver
75
+ * disconnect / keyboard listener removal lifecycle.
76
+ */
77
+ export function attachFlowSubsystems<
78
+ NodeType extends NodeBase = NodeBase,
79
+ EdgeType extends EdgeBase = EdgeBase,
80
+ >(
81
+ el: HTMLElement,
82
+ store: InternalFlowStore<NodeType, EdgeType>,
83
+ props: FlowProps<NodeType, EdgeType>,
84
+ ): void {
85
+ injectDefaultStyles()
86
+
87
+ el.style.position = 'relative'
88
+ el.style.overflow = 'hidden'
89
+
90
+ store.setDomNode(el)
91
+ // Expose the store on the host `<div class="bf-flow">` element so
92
+ // descendants that miss `FlowContext` — e.g. children passed through
93
+ // `<Flow renderNode={Fn}>` whose returned JSX is hydrated as a
94
+ // top-level scope outside of Flow's `FlowContext.Provider` — can
95
+ // still locate the store via `el.closest('.bf-flow').__bfFlowStore`.
96
+ // Always-set, even on hot remount, so callers can rely on a single
97
+ // canonical reference.
98
+ ;(el as HTMLElement & { __bfFlowStore?: typeof store }).__bfFlowStore = store
99
+ store.setWidth(el.offsetWidth)
100
+ store.setHeight(el.offsetHeight)
101
+
102
+ const resizeObserver = new ResizeObserver(() => {
103
+ store.setWidth(el.offsetWidth)
104
+ store.setHeight(el.offsetHeight)
105
+ })
106
+ resizeObserver.observe(el)
107
+ onCleanup(() => resizeObserver.disconnect())
108
+
109
+ const panZoomInstance = XYPanZoom({
110
+ domNode: el,
111
+ minZoom: store.minZoom,
112
+ maxZoom: store.maxZoom,
113
+ viewport: untrack(store.viewport),
114
+ translateExtent: INFINITE_EXTENT,
115
+ onDraggingChange: (isDragging: boolean) => {
116
+ store.setDragging(isDragging)
117
+ },
118
+ onPanZoom: (_event: MouseEvent | TouchEvent | null, vp: Viewport) => {
119
+ store.setViewport(vp)
120
+ },
121
+ onPanZoomStart: undefined,
122
+ onPanZoomEnd: (_event: MouseEvent | TouchEvent | null, vp: Viewport) => {
123
+ if (store.onMoveEnd) {
124
+ store.onMoveEnd(_event, vp)
125
+ }
126
+ },
127
+ })
128
+
129
+ store.setPanZoom(panZoomInstance)
130
+
131
+ const baseUpdate = (zoomActivationKeyPressed: boolean) => ({
132
+ noWheelClassName: 'nowheel',
133
+ noPanClassName: 'nopan',
134
+ preventScrolling: true,
135
+ panOnScroll: props.panOnScroll ?? false,
136
+ panOnDrag: props.panOnDrag ?? true,
137
+ panOnScrollMode: PanOnScrollMode.Free,
138
+ panOnScrollSpeed: 0.5,
139
+ userSelectionActive: false,
140
+ zoomOnPinch: true,
141
+ zoomOnScroll: props.zoomOnScroll ?? true,
142
+ zoomOnDoubleClick: props.zoomOnDoubleClick ?? true,
143
+ zoomActivationKeyPressed,
144
+ lib: 'bf' as const,
145
+ onTransformChange: (transform: Transform) => {
146
+ store.setViewport({ x: transform[0], y: transform[1], zoom: transform[2] })
147
+ },
148
+ connectionInProgress: false,
149
+ paneClickDistance: 0,
150
+ })
151
+
152
+ panZoomInstance.update(baseUpdate(false))
153
+
154
+ onCleanup(() => panZoomInstance.destroy())
155
+
156
+ // Zoom activation key — held to convert scroll-pan into scroll-zoom.
157
+ const zoomKeyCode = (props as { zoomActivationKeyCode?: string | null }).zoomActivationKeyCode
158
+ if (zoomKeyCode) {
159
+ let zoomKeyPressed = false
160
+ const onKeyDown = (e: KeyboardEvent) => {
161
+ if (e.key === zoomKeyCode && !zoomKeyPressed) {
162
+ zoomKeyPressed = true
163
+ panZoomInstance.update(baseUpdate(true))
164
+ }
165
+ }
166
+ const onKeyUp = (e: KeyboardEvent) => {
167
+ if (e.key === zoomKeyCode && zoomKeyPressed) {
168
+ zoomKeyPressed = false
169
+ panZoomInstance.update(baseUpdate(false))
170
+ }
171
+ }
172
+ document.addEventListener('keydown', onKeyDown)
173
+ document.addEventListener('keyup', onKeyUp)
174
+ onCleanup(() => {
175
+ document.removeEventListener('keydown', onKeyDown)
176
+ document.removeEventListener('keyup', onKeyUp)
177
+ })
178
+ }
179
+
180
+ // Viewport transform sync — the JSX `<Flow>` component already binds
181
+ // its `.bf-flow__viewport` transform reactively from `store.viewport()`,
182
+ // so no DOM mutation here. The createEffect below is intentionally
183
+ // omitted; the JSX layer owns the transform binding.
184
+
185
+ setupKeyboardHandlers(store, el)
186
+ setupSelectionRectangle(store, el, {
187
+ selectionOnDrag: props.selectionOnDrag,
188
+ selectionMode: props.selectionMode,
189
+ })
190
+
191
+ // Pane click + mousemove. D3 zoom captures `mousedown` and may
192
+ // suppress click events; use a `mousedown` + `mouseup` pair so a
193
+ // genuine click on empty pane (no node / handle / edge underneath)
194
+ // still fires `onPaneClick`. Drag distance > 5px → not a click.
195
+ let paneMouseDownPos: { x: number; y: number } | null = null
196
+ const onMouseDown = (event: MouseEvent) => {
197
+ const target = event.target as HTMLElement
198
+ if (
199
+ !target.closest('.bf-flow__node') &&
200
+ !target.closest('.bf-flow__handle') &&
201
+ !target.closest('.bf-flow__edge, [data-hit-id]')
202
+ ) {
203
+ paneMouseDownPos = { x: event.clientX, y: event.clientY }
204
+ } else {
205
+ paneMouseDownPos = null
206
+ }
207
+ }
208
+ const onMouseUp = (event: MouseEvent) => {
209
+ if (!paneMouseDownPos) return
210
+ const dx = event.clientX - paneMouseDownPos.x
211
+ const dy = event.clientY - paneMouseDownPos.y
212
+ paneMouseDownPos = null
213
+ if (Math.abs(dx) > 5 || Math.abs(dy) > 5) return
214
+ const target = event.target as HTMLElement
215
+ if (
216
+ target.closest('.bf-flow__node') ||
217
+ target.closest('.bf-flow__handle') ||
218
+ target.closest('.bf-flow__edge, [data-hit-id]')
219
+ )
220
+ return
221
+ store.unselectNodesAndEdges()
222
+ if (store.onPaneClick) {
223
+ store.onPaneClick(event)
224
+ }
225
+ }
226
+ const onMouseMove = (event: MouseEvent) => {
227
+ if (store.onPaneMouseMove) {
228
+ store.onPaneMouseMove(event)
229
+ }
230
+ }
231
+ el.addEventListener('mousedown', onMouseDown, true)
232
+ el.addEventListener('mouseup', onMouseUp, true)
233
+ el.addEventListener('mousemove', onMouseMove)
234
+ onCleanup(() => {
235
+ el.removeEventListener('mousedown', onMouseDown, true)
236
+ el.removeEventListener('mouseup', onMouseUp, true)
237
+ el.removeEventListener('mousemove', onMouseMove)
238
+ })
239
+
240
+ // Delegated single-node drag. The cutover-step C4 XYDrag integration
241
+ // will eventually replace this with multi-select / snap / extent
242
+ // support, but a Flow that does not let users move nodes is a poor
243
+ // default — wire a minimal pointer-paced handler so the JSX renderer
244
+ // is interactive out of the box.
245
+ let dragState: {
246
+ nodeId: string
247
+ pointerId: number
248
+ startClientX: number
249
+ startClientY: number
250
+ startNodeX: number
251
+ startNodeY: number
252
+ captureEl: HTMLElement
253
+ } | null = null
254
+
255
+ const onNodePointerDown = (event: PointerEvent) => {
256
+ if (event.button !== 0) return
257
+ if (!untrack(store.nodesDraggable)) return
258
+ const target = event.target as HTMLElement | null
259
+ if (!target) return
260
+ if (target.closest('.bf-flow__handle')) return
261
+ if (target.closest('.nodrag')) return
262
+ const nodeEl = target.closest<HTMLElement>('.bf-flow__node')
263
+ if (!nodeEl || !el.contains(nodeEl)) return
264
+ const nodeId = nodeEl.dataset.id
265
+ if (!nodeId) return
266
+ const internal = untrack(store.nodeLookup).get(nodeId)
267
+ if (!internal) return
268
+
269
+ event.stopPropagation()
270
+
271
+ dragState = {
272
+ nodeId,
273
+ pointerId: event.pointerId,
274
+ startClientX: event.clientX,
275
+ startClientY: event.clientY,
276
+ startNodeX: internal.position.x,
277
+ startNodeY: internal.position.y,
278
+ captureEl: nodeEl,
279
+ }
280
+ nodeEl.setPointerCapture?.(event.pointerId)
281
+ store.setDragging(true)
282
+ }
283
+ // Push the new position straight onto the nodes signal so JSX
284
+ // consumers (NodeWrapper's `transform` memo, edge path memos) see a
285
+ // fresh node object each frame. `updateNodePositions` alone only
286
+ // mutates the `internals.positionAbsolute` reference and bumps
287
+ // `positionEpoch`, but barefoot signals dedupe on Object.is — the
288
+ // downstream `transform` memo therefore wouldn't re-render.
289
+ const writeDragPosition = (nodeId: string, x: number, y: number, isDragging: boolean) => {
290
+ const currentNodes = untrack(store.nodes)
291
+ let changed = false
292
+ const next = currentNodes.map((n) => {
293
+ if (n.id !== nodeId) return n
294
+ const prev = n.position
295
+ if (prev.x === x && prev.y === y) return n
296
+ changed = true
297
+ return { ...n, position: { x, y } }
298
+ })
299
+ if (changed) store.setNodes(next)
300
+ store.setDragging(isDragging)
301
+ }
302
+
303
+ const onNodePointerMove = (event: PointerEvent) => {
304
+ if (!dragState || event.pointerId !== dragState.pointerId) return
305
+ const zoom = untrack(store.viewport).zoom || 1
306
+ const dx = (event.clientX - dragState.startClientX) / zoom
307
+ const dy = (event.clientY - dragState.startClientY) / zoom
308
+ // Clamp child nodes that opt in via `extent: 'parent'`. Done in the
309
+ // relative coordinate the drag handler already manipulates, so the
310
+ // commit shape (`writeDragPosition` → `setNodes` → `adoptUserNodes`)
311
+ // stays unchanged.
312
+ const clamped = clampDragPositionToParent(
313
+ { x: dragState.startNodeX + dx, y: dragState.startNodeY + dy },
314
+ dragState.nodeId,
315
+ untrack(store.nodeLookup),
316
+ )
317
+ writeDragPosition(dragState.nodeId, clamped.x, clamped.y, true)
318
+ }
319
+ const onNodePointerUp = (event: PointerEvent) => {
320
+ if (!dragState || event.pointerId !== dragState.pointerId) return
321
+ const captureEl = dragState.captureEl
322
+ captureEl.releasePointerCapture?.(event.pointerId)
323
+ const finalNodeId = dragState.nodeId
324
+ dragState = null
325
+ // Final commit clears the dragging flag without changing the
326
+ // position; reuse writeDragPosition for the same code path.
327
+ const lookup = untrack(store.nodeLookup)
328
+ const internal = lookup.get(finalNodeId)
329
+ if (internal) {
330
+ writeDragPosition(finalNodeId, internal.position.x, internal.position.y, false)
331
+ } else {
332
+ store.setDragging(false)
333
+ }
334
+ }
335
+ el.addEventListener('pointerdown', onNodePointerDown)
336
+ el.addEventListener('pointermove', onNodePointerMove)
337
+ el.addEventListener('pointerup', onNodePointerUp)
338
+ el.addEventListener('pointercancel', onNodePointerUp)
339
+ onCleanup(() => {
340
+ el.removeEventListener('pointerdown', onNodePointerDown)
341
+ el.removeEventListener('pointermove', onNodePointerMove)
342
+ el.removeEventListener('pointerup', onNodePointerUp)
343
+ el.removeEventListener('pointercancel', onNodePointerUp)
344
+ })
345
+
346
+ // Edge path keep-in-sync. The SimpleEdge component owns a memo over
347
+ // `pathD()` that should re-run on `positionEpoch()` / `nodes()`
348
+ // changes, but barefoot's signal dedupe (Object.is on the cached
349
+ // string) plus the fact that node measurement and drag mutate
350
+ // `internal.measured` / `internal.positionAbsolute` *in place*
351
+ // (without producing a fresh wrapper object the lookup signal
352
+ // notices) means the edge's `d` attribute can stick at its first
353
+ // computed value. Walk the SVG edges from a top-level effect that
354
+ // tracks `positionEpoch` + `nodes` / `edges` and write `d`
355
+ // directly — this is the load-bearing path for both initial measure
356
+ // and drag.
357
+ createEffect(() => {
358
+ store.positionEpoch()
359
+ const currentEdges = store.edges()
360
+ // `nodeLookup()` now emits a fresh `Map` reference on every
361
+ // setNodes-driven change (see #1270), so the previous
362
+ // `store.nodes()` wake-up call is no longer needed here.
363
+ const lookup = store.nodeLookup()
364
+ const edgesSvg = el.querySelector('.bf-flow__edges')
365
+ if (!edgesSvg) return
366
+ for (const edge of currentEdges) {
367
+ const sourceNode = lookup.get(edge.source)
368
+ const targetNode = lookup.get(edge.target)
369
+ if (!sourceNode || !targetNode) continue
370
+ const pos = computeEdgePosition(edge, sourceNode, targetNode)
371
+ if (!pos) continue
372
+ const result = getEdgePath(edge, pos)
373
+ if (!result) continue
374
+ const d = result[0]
375
+ const paths = edgesSvg.querySelectorAll<SVGPathElement>(
376
+ `path[data-id="${edge.id}"], path[data-hit-id="${edge.id}"]`,
377
+ )
378
+ for (const p of paths) {
379
+ if (p.getAttribute('d') !== d) p.setAttribute('d', d)
380
+ }
381
+ }
382
+ })
383
+
384
+ // onInit lifecycle callback fires once the subsystems are wired and
385
+ // the store is ready. Mirrors initFlow's existing semantics.
386
+ if (typeof props.onInit === 'function') {
387
+ props.onInit(store)
388
+ }
389
+ }
390
+
391
+ /**
392
+ * Inject default CSS styles for xyflow components. Idempotent — first
393
+ * call inserts a `<style id="bf-flow-styles">`; subsequent calls
394
+ * (e.g. multiple `<Flow>` instances on one page) early-return.
395
+ */
396
+ function injectDefaultStyles() {
397
+ if (typeof document === 'undefined') return
398
+ if (document.getElementById('bf-flow-styles')) return
399
+
400
+ const style = document.createElement('style')
401
+ style.id = 'bf-flow-styles'
402
+ style.textContent = DEFAULT_STYLES
403
+ document.head.appendChild(style)
404
+ }
405
+
406
+ // Default styles use design-system CSS variables (with hard-coded
407
+ // fallbacks for environments that don't define them, e.g. raw <Flow>
408
+ // usage outside a themed shell). Handle placement is driven by the
409
+ // `data-handlepos` attribute the JSX <Handle> already emits, so the
410
+ // `position` prop drives where each handle sits regardless of whether
411
+ // the inline style on the element ever lands (the barefoot compiler
412
+ // currently strips dynamic-string `style={memo()}` props on JSX
413
+ // components, so attribute-driven CSS is the load-bearing path).
414
+ const DEFAULT_STYLES = `
415
+ .bf-flow__node {
416
+ padding: 10px 24px;
417
+ border: 2px solid var(--foreground, #1a192b);
418
+ border-radius: 6px;
419
+ background-color: var(--card, #fff);
420
+ color: var(--card-foreground, #222);
421
+ font-size: 14px;
422
+ font-weight: 600;
423
+ text-align: center;
424
+ cursor: grab;
425
+ user-select: none;
426
+ box-sizing: border-box;
427
+ }
428
+ .bf-flow__node--custom { border: none; background: transparent; padding: 0; border-radius: 0; }
429
+ .bf-flow__node--custom.bf-flow__node--selected { box-shadow: none; }
430
+ .bf-flow__node--selected { box-shadow: 0 0 0 1px var(--ring, #1a192b); }
431
+ .bf-flow__handle {
432
+ position: absolute;
433
+ width: 8px; height: 8px;
434
+ border-radius: 50%;
435
+ background-color: var(--primary, #1a192b);
436
+ cursor: crosshair;
437
+ pointer-events: all;
438
+ z-index: 1;
439
+ }
440
+ /* Negative offset compensates for the node's 2px border:
441
+ .bf-flow__node uses border-box, but absolutely-positioned children
442
+ resolve against the padding box, so top/left:0 lands inside the
443
+ border. Pulling each side by -2px snaps the handle's transform-
444
+ centred dot to the visible outer edge of the node. */
445
+ .bf-flow__handle[data-handlepos="top"] { top: -2px; left: 50%; transform: translate(-50%, -50%); }
446
+ .bf-flow__handle[data-handlepos="bottom"] { bottom: -2px; left: 50%; transform: translate(-50%, 50%); }
447
+ .bf-flow__handle[data-handlepos="left"] { left: -2px; top: 50%; transform: translate(-50%, -50%); }
448
+ .bf-flow__handle[data-handlepos="right"] { right: -2px; top: 50%; transform: translate(50%, -50%); }
449
+ .bf-flow__handle:hover { width: 10px; height: 10px; }
450
+ .bf-flow__handle.valid { background-color: #22c55e; }
451
+ .bf-flow__handle.invalid { background-color: #ef4444; }
452
+ .bf-flow__edge { fill: none; stroke: var(--muted-foreground, #b1b1b7); stroke-width: 1.5; pointer-events: none; }
453
+ .bf-flow__edge--selected { stroke: var(--foreground, #555); stroke-width: 2; }
454
+ .bf-flow__edge--animated { stroke-dasharray: 5; animation: bf-dashdraw 0.5s linear infinite; }
455
+ @keyframes bf-dashdraw { from { stroke-dashoffset: 10; } }
456
+ .bf-flow__edge-reconnect { fill: transparent; stroke: transparent; cursor: move; pointer-events: all; }
457
+ path.bf-flow__edge.bf-flow__edge--reconnect-hover { stroke: var(--text-primary, #222); }
458
+ .bf-flow__controls-button:hover { background: var(--accent, #f4f4f4) !important; }
459
+ .bf-flow__controls-button:last-child { border-bottom: none !important; }
460
+ .bf-flow__edge-label {
461
+ position: absolute; top: 0; left: 0; background: #fff;
462
+ padding: 2px 4px; font-size: 11px; color: #222;
463
+ white-space: nowrap; cursor: default;
464
+ }
465
+ .bf-flow__edge-toolbar {
466
+ position: absolute; top: 0; left: 0;
467
+ display: flex; gap: 4px; z-index: 10;
468
+ }
469
+ .bf-flow__edge-toolbar-button {
470
+ display: flex; align-items: center; justify-content: center;
471
+ width: 20px; height: 20px; border-radius: 4px; border: 1px solid #e2e2e2;
472
+ background: #fff; color: #666; font-size: 14px; line-height: 1;
473
+ cursor: pointer; padding: 0;
474
+ }
475
+ .bf-flow__edge-toolbar-button:hover { background: #fee; color: #c00; border-color: #c00; }
476
+ .bf-flow__selection {
477
+ background: rgba(0, 89, 220, 0.08);
478
+ border: 1px dashed rgba(0, 89, 220, 0.5);
479
+ border-radius: 2px;
480
+ pointer-events: none;
481
+ }
482
+ .bf-flow__node-resizer { position: absolute; inset: 0; pointer-events: none; }
483
+ .bf-flow__resize-handle { position: absolute; pointer-events: all; z-index: 10; }
484
+ .bf-flow__resize-handle--corner { width: 8px; height: 8px; background: var(--bf-resize-color, #4a90d9); border: none; border-radius: 0; }
485
+ .bf-flow__resize-handle--top-left { top: -4px; left: -4px; cursor: nwse-resize; }
486
+ .bf-flow__resize-handle--top-right { top: -4px; right: -4px; cursor: nesw-resize; }
487
+ .bf-flow__resize-handle--bottom-left { bottom: -4px; left: -4px; cursor: nesw-resize; }
488
+ .bf-flow__resize-handle--bottom-right { bottom: -4px; right: -4px; cursor: nwse-resize; }
489
+ .bf-flow__resize-handle--line { background: transparent; }
490
+ .bf-flow__resize-handle--line.bf-flow__resize-handle--top { top: -2px; left: 0; right: 0; height: 4px; cursor: ns-resize; }
491
+ .bf-flow__resize-handle--line.bf-flow__resize-handle--bottom { bottom: -2px; left: 0; right: 0; height: 4px; cursor: ns-resize; }
492
+ .bf-flow__resize-handle--line.bf-flow__resize-handle--left { left: -2px; top: 0; bottom: 0; width: 4px; cursor: ew-resize; }
493
+ .bf-flow__resize-handle--line.bf-flow__resize-handle--right { right: -2px; top: 0; bottom: 0; width: 4px; cursor: ew-resize; }
494
+ .bf-flow__resize-handle--line:hover { background: rgba(26, 25, 43, 0.1); }
495
+ .bf-flow__resize-handle--corner:hover { background: var(--bf-resize-color, #3a7bd5); }
496
+ .bf-flow__node--group {
497
+ background-color: rgba(240, 240, 240, 0.7);
498
+ border: 1px dashed #999;
499
+ border-radius: 8px;
500
+ padding: 40px 10px 10px 10px;
501
+ }
502
+ .bf-flow__node--child {
503
+ /* Child nodes render above parents via z-index from @xyflow/system */
504
+ }
505
+ `
506
+
package/src/hooks.ts ADDED
@@ -0,0 +1,72 @@
1
+ import { useContext } from '@barefootjs/client/runtime'
2
+ import { createMemo, untrack } from '@barefootjs/client/runtime'
3
+ import { pointToRendererPoint } from '@xyflow/system'
4
+ import { FlowContext } from './context'
5
+ import type { FlowStore, Viewport, NodeBase, EdgeBase, XYPosition } from './types'
6
+ import type { Signal, Memo } from '@barefootjs/client/runtime'
7
+
8
+ /**
9
+ * Access the flow store from any child component.
10
+ * Must be called within a component rendered inside a Flow.
11
+ */
12
+ export function useFlow<
13
+ NodeType extends NodeBase = NodeBase,
14
+ EdgeType extends EdgeBase = EdgeBase,
15
+ >(): FlowStore<NodeType, EdgeType> {
16
+ return useContext(FlowContext) as unknown as FlowStore<NodeType, EdgeType>
17
+ }
18
+
19
+ /**
20
+ * Access the current viewport (reactive getter).
21
+ */
22
+ export function useViewport(): Signal<Viewport>[0] {
23
+ return useFlow().viewport
24
+ }
25
+
26
+ /**
27
+ * Access the nodes array (reactive getter).
28
+ */
29
+ export function useNodes<NodeType extends NodeBase = NodeBase>(): Signal<NodeType[]>[0] {
30
+ return useFlow<NodeType>().nodes
31
+ }
32
+
33
+ /**
34
+ * Access the edges array (reactive getter).
35
+ */
36
+ export function useEdges<EdgeType extends EdgeBase = EdgeBase>(): Signal<EdgeType[]>[0] {
37
+ return useFlow<NodeBase, EdgeType>().edges
38
+ }
39
+
40
+ /**
41
+ * Access whether nodes have been initialized (all measured).
42
+ */
43
+ export function useNodesInitialized(): Memo<boolean> {
44
+ return useFlow().nodesInitialized
45
+ }
46
+
47
+ /**
48
+ * Select derived state from the flow store.
49
+ * Similar to React Flow's useStore(selector).
50
+ */
51
+ export function useStore<T>(selector: (store: FlowStore) => T): Memo<T> {
52
+ const store = useFlow()
53
+ return createMemo(() => selector(store))
54
+ }
55
+
56
+ /**
57
+ * Convert a screen position to flow coordinates.
58
+ * Accounts for viewport transform (pan/zoom) and container offset.
59
+ */
60
+ export function screenToFlowPosition(position: XYPosition): XYPosition {
61
+ const store = useFlow()
62
+ const domNode = untrack(store.domNode)
63
+ if (!domNode) return position
64
+
65
+ const rect = domNode.getBoundingClientRect()
66
+ const transform = store.getTransform()
67
+
68
+ return pointToRendererPoint(
69
+ { x: position.x - rect.left, y: position.y - rect.top },
70
+ transform,
71
+ )
72
+ }