@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.
- package/README.md +117 -0
- package/dist/classes.d.ts +31 -0
- package/dist/classes.d.ts.map +1 -0
- package/dist/compat.d.ts +67 -0
- package/dist/compat.d.ts.map +1 -0
- package/dist/connection.d.ts +20 -0
- package/dist/connection.d.ts.map +1 -0
- package/dist/constants.d.ts +4 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/context.d.ts +7 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/edge-path.d.ts +14 -0
- package/dist/edge-path.d.ts.map +1 -0
- package/dist/flow-subsystems.d.ts +28 -0
- package/dist/flow-subsystems.d.ts.map +1 -0
- package/dist/hooks.d.ts +34 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6572 -0
- package/dist/node-resizer.d.ts +52 -0
- package/dist/node-resizer.d.ts.map +1 -0
- package/dist/node-type-dispatch.d.ts +34 -0
- package/dist/node-type-dispatch.d.ts.map +1 -0
- package/dist/selection.d.ts +32 -0
- package/dist/selection.d.ts.map +1 -0
- package/dist/store.d.ts +8 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/types.d.ts +214 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/utils.d.ts +6 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/xyflow.browser.min.js +95 -0
- package/dist/xyflow.browser.min.js.map +129 -0
- package/package.json +58 -0
- package/src/__tests__/clamp-drag-position.test.ts +111 -0
- package/src/__tests__/compat.test.ts +157 -0
- package/src/__tests__/host-element-store.test.ts +33 -0
- package/src/__tests__/jsx-smoke.test.ts +33 -0
- package/src/__tests__/jsx-smoke.tsx +23 -0
- package/src/__tests__/node-type-dispatch.test.ts +104 -0
- package/src/__tests__/store.test.ts +399 -0
- package/src/__tests__/tsconfig.json +23 -0
- package/src/classes.ts +41 -0
- package/src/compat.ts +237 -0
- package/src/connection.ts +459 -0
- package/src/constants.ts +8 -0
- package/src/context.ts +8 -0
- package/src/edge-path.ts +89 -0
- package/src/flow-subsystems.ts +506 -0
- package/src/hooks.ts +72 -0
- package/src/index.ts +134 -0
- package/src/node-resizer.ts +276 -0
- package/src/node-type-dispatch.ts +46 -0
- package/src/selection.ts +407 -0
- package/src/store.ts +526 -0
- package/src/types.ts +329 -0
- 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
|
+
}
|