@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,459 @@
|
|
|
1
|
+
import { untrack } from '@barefootjs/client'
|
|
2
|
+
import { getSmoothStepPath, Position, reconnectEdge as reconnectEdgeUtil } from '@xyflow/system'
|
|
3
|
+
import type { FlowStore, NodeBase, EdgeBase, Connection } from './types'
|
|
4
|
+
import { SVG_NS } from './constants'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Build a connection object for the given source handle / target handle pair.
|
|
8
|
+
* Used by both validation and edge creation.
|
|
9
|
+
*/
|
|
10
|
+
function buildConnection(
|
|
11
|
+
sourceNodeId: string,
|
|
12
|
+
targetNodeId: string,
|
|
13
|
+
handleType: 'source' | 'target',
|
|
14
|
+
sourceHandleId?: string | null,
|
|
15
|
+
targetHandleId?: string | null,
|
|
16
|
+
): { source: string; target: string; sourceHandle: string | null; targetHandle: string | null } {
|
|
17
|
+
let source = sourceNodeId
|
|
18
|
+
let target = targetNodeId
|
|
19
|
+
let sourceHandle = sourceHandleId ?? null
|
|
20
|
+
let targetHandle = targetHandleId ?? null
|
|
21
|
+
if (handleType === 'target') {
|
|
22
|
+
source = targetNodeId
|
|
23
|
+
target = sourceNodeId
|
|
24
|
+
// Swap handle IDs when direction is reversed
|
|
25
|
+
const tmp = sourceHandle
|
|
26
|
+
sourceHandle = targetHandle
|
|
27
|
+
targetHandle = tmp
|
|
28
|
+
}
|
|
29
|
+
return { source, target, sourceHandle, targetHandle }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Check whether a proposed connection is valid according to the store's
|
|
34
|
+
* isValidConnection callback. Returns true when no callback is configured.
|
|
35
|
+
*/
|
|
36
|
+
function checkConnectionValidity<
|
|
37
|
+
NodeType extends NodeBase = NodeBase,
|
|
38
|
+
EdgeType extends EdgeBase = EdgeBase,
|
|
39
|
+
>(
|
|
40
|
+
store: FlowStore<NodeType, EdgeType>,
|
|
41
|
+
connection: { source: string; target: string; sourceHandle: string | null; targetHandle: string | null },
|
|
42
|
+
): boolean {
|
|
43
|
+
if (!store.isValidConnection) return true
|
|
44
|
+
return store.isValidConnection(connection)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Attach a connection drag handler to a handle element.
|
|
49
|
+
* Called when creating each handle in node-wrapper.
|
|
50
|
+
*/
|
|
51
|
+
export function attachConnectionHandler<
|
|
52
|
+
NodeType extends NodeBase = NodeBase,
|
|
53
|
+
EdgeType extends EdgeBase = EdgeBase,
|
|
54
|
+
>(
|
|
55
|
+
handleEl: HTMLElement,
|
|
56
|
+
nodeId: string,
|
|
57
|
+
handleType: 'source' | 'target',
|
|
58
|
+
container: HTMLElement,
|
|
59
|
+
_edgesSvg: SVGSVGElement,
|
|
60
|
+
store: FlowStore<NodeType, EdgeType>,
|
|
61
|
+
): void {
|
|
62
|
+
handleEl.addEventListener('mousedown', (e) => {
|
|
63
|
+
if (e.button !== 0) return
|
|
64
|
+
if (!untrack(store.nodesDraggable)) return
|
|
65
|
+
|
|
66
|
+
// Stop propagation to prevent node drag
|
|
67
|
+
e.stopPropagation()
|
|
68
|
+
e.preventDefault()
|
|
69
|
+
|
|
70
|
+
// Compute source position in flow coordinates at mousedown time
|
|
71
|
+
const handleRect = handleEl.getBoundingClientRect()
|
|
72
|
+
const containerRect0 = container.getBoundingClientRect()
|
|
73
|
+
const [, , scale0] = store.getTransform()
|
|
74
|
+
const vp0 = untrack(store.viewport)
|
|
75
|
+
|
|
76
|
+
const sourceX = (handleRect.left + handleRect.width / 2 - containerRect0.left - vp0.x) / scale0
|
|
77
|
+
const sourceY = (handleRect.top + handleRect.height / 2 - containerRect0.top - vp0.y) / scale0
|
|
78
|
+
|
|
79
|
+
// Create a temporary overlay SVG for the connection line, above nodes.
|
|
80
|
+
// This makes the line visible on top of nodes during drag.
|
|
81
|
+
// We hide it briefly before elementFromPoint calls so handles are detected.
|
|
82
|
+
const overlaySvg = document.createElementNS(SVG_NS, 'svg')
|
|
83
|
+
overlaySvg.style.position = 'absolute'
|
|
84
|
+
overlaySvg.style.top = '0'
|
|
85
|
+
overlaySvg.style.left = '0'
|
|
86
|
+
overlaySvg.style.width = '100%'
|
|
87
|
+
overlaySvg.style.height = '100%'
|
|
88
|
+
overlaySvg.style.overflow = 'visible'
|
|
89
|
+
overlaySvg.style.pointerEvents = 'none'
|
|
90
|
+
overlaySvg.style.zIndex = '10'
|
|
91
|
+
container.appendChild(overlaySvg)
|
|
92
|
+
|
|
93
|
+
// The line is drawn in viewport-transformed coordinates
|
|
94
|
+
const lineGroup = document.createElementNS(SVG_NS, 'g')
|
|
95
|
+
overlaySvg.appendChild(lineGroup)
|
|
96
|
+
|
|
97
|
+
const connectionLine = document.createElementNS(SVG_NS, 'path')
|
|
98
|
+
connectionLine.setAttribute('fill', 'none')
|
|
99
|
+
// Apply connectionLineStyle from store if available
|
|
100
|
+
const lineStyle = store.connectionLineStyle
|
|
101
|
+
connectionLine.setAttribute('stroke', lineStyle?.stroke ?? '#b1b1b7')
|
|
102
|
+
connectionLine.setAttribute('stroke-width', lineStyle?.strokeWidth ?? '1')
|
|
103
|
+
lineGroup.appendChild(connectionLine)
|
|
104
|
+
|
|
105
|
+
// Track the currently highlighted handle for validation/snap feedback
|
|
106
|
+
let lastHighlightedHandle: HTMLElement | null = null
|
|
107
|
+
// Track the snapped handle so onMouseUp can use it without re-querying
|
|
108
|
+
let snappedHandle: HTMLElement | null = null
|
|
109
|
+
|
|
110
|
+
const SNAP_THRESHOLD = 30
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Find the nearest valid target handle within SNAP_THRESHOLD pixels of
|
|
114
|
+
* the cursor. Returns null when none is close enough.
|
|
115
|
+
*/
|
|
116
|
+
function findNearestHandle(cursorX: number, cursorY: number): HTMLElement | null {
|
|
117
|
+
const candidates = container.querySelectorAll<HTMLElement>('.bf-flow__handle')
|
|
118
|
+
let nearest: HTMLElement | null = null
|
|
119
|
+
let nearestDist = SNAP_THRESHOLD
|
|
120
|
+
|
|
121
|
+
for (const candidate of candidates) {
|
|
122
|
+
if (candidate === handleEl) continue
|
|
123
|
+
if (!candidate.dataset.nodeId || candidate.dataset.nodeId === nodeId) continue
|
|
124
|
+
// Skip same-type handles — source can only connect to target and vice versa.
|
|
125
|
+
// When source+target handles overlap at the same position, without this
|
|
126
|
+
// check the source handle (first in DOM) would always win and fail validation.
|
|
127
|
+
const candidateType = candidate.classList.contains('bf-flow__handle--target') ? 'target' : 'source'
|
|
128
|
+
if (candidateType === handleType) continue
|
|
129
|
+
|
|
130
|
+
const rect = candidate.getBoundingClientRect()
|
|
131
|
+
const cx = rect.left + rect.width / 2
|
|
132
|
+
const cy = rect.top + rect.height / 2
|
|
133
|
+
const dist = Math.hypot(cursorX - cx, cursorY - cy)
|
|
134
|
+
if (dist < nearestDist) {
|
|
135
|
+
nearestDist = dist
|
|
136
|
+
nearest = candidate
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return nearest
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const onMouseMove = (e: MouseEvent) => {
|
|
143
|
+
// Read fresh viewport and container rect each move — the user
|
|
144
|
+
// may pan/zoom while drawing a connection.
|
|
145
|
+
const containerRect = container.getBoundingClientRect()
|
|
146
|
+
const [, , scale] = store.getTransform()
|
|
147
|
+
const vp = untrack(store.viewport)
|
|
148
|
+
|
|
149
|
+
// Determine if we should snap to a nearby handle
|
|
150
|
+
const nearHandle = findNearestHandle(e.clientX, e.clientY)
|
|
151
|
+
|
|
152
|
+
let targetX: number
|
|
153
|
+
let targetY: number
|
|
154
|
+
|
|
155
|
+
if (nearHandle) {
|
|
156
|
+
const rect = nearHandle.getBoundingClientRect()
|
|
157
|
+
targetX = (rect.left + rect.width / 2 - containerRect.left - vp.x) / scale
|
|
158
|
+
targetY = (rect.top + rect.height / 2 - containerRect.top - vp.y) / scale
|
|
159
|
+
} else {
|
|
160
|
+
targetX = (e.clientX - containerRect.left - vp.x) / scale
|
|
161
|
+
targetY = (e.clientY - containerRect.top - vp.y) / scale
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const [path] = getSmoothStepPath({
|
|
165
|
+
sourceX,
|
|
166
|
+
sourceY,
|
|
167
|
+
sourcePosition: handleType === 'source' ? Position.Bottom : Position.Top,
|
|
168
|
+
targetX,
|
|
169
|
+
targetY,
|
|
170
|
+
targetPosition: handleType === 'source' ? Position.Top : Position.Bottom,
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
connectionLine.setAttribute('d', path)
|
|
174
|
+
|
|
175
|
+
// Sync overlay SVG transform with viewport
|
|
176
|
+
const vpCurrent = untrack(store.viewport)
|
|
177
|
+
lineGroup.setAttribute('transform', `translate(${vpCurrent.x}, ${vpCurrent.y}) scale(${vpCurrent.zoom})`)
|
|
178
|
+
|
|
179
|
+
// Clear classes from the previously highlighted handle
|
|
180
|
+
if (lastHighlightedHandle && lastHighlightedHandle !== nearHandle) {
|
|
181
|
+
lastHighlightedHandle.classList.remove('valid', 'invalid')
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
snappedHandle = null
|
|
185
|
+
|
|
186
|
+
if (nearHandle) {
|
|
187
|
+
const nearHandleType = nearHandle.classList.contains('bf-flow__handle--target') ? 'target' : 'source'
|
|
188
|
+
const isCompatibleType = handleType !== nearHandleType
|
|
189
|
+
const srcHandleId = handleEl.getAttribute("data-handleid") ?? null
|
|
190
|
+
const tgtHandleId = nearHandle.getAttribute("data-handleid") ?? null
|
|
191
|
+
const conn = buildConnection(nodeId, nearHandle.dataset.nodeId!, handleType, srcHandleId, tgtHandleId)
|
|
192
|
+
const isValid = isCompatibleType && checkConnectionValidity(store, conn)
|
|
193
|
+
|
|
194
|
+
nearHandle.classList.remove('valid', 'invalid')
|
|
195
|
+
nearHandle.classList.add(isValid ? 'valid' : 'invalid')
|
|
196
|
+
lastHighlightedHandle = nearHandle
|
|
197
|
+
|
|
198
|
+
if (isValid) snappedHandle = nearHandle
|
|
199
|
+
} else {
|
|
200
|
+
// Fall back to elementFromPoint for hover-only feedback (cursor directly on handle)
|
|
201
|
+
overlaySvg.style.display = 'none'
|
|
202
|
+
const hoverEl = document.elementFromPoint(e.clientX, e.clientY)
|
|
203
|
+
overlaySvg.style.display = ''
|
|
204
|
+
const hoveredHandle = hoverEl?.closest?.('.bf-flow__handle') as HTMLElement | null
|
|
205
|
+
|
|
206
|
+
if (
|
|
207
|
+
hoveredHandle &&
|
|
208
|
+
hoveredHandle !== handleEl &&
|
|
209
|
+
hoveredHandle.dataset.nodeId &&
|
|
210
|
+
hoveredHandle.dataset.nodeId !== nodeId
|
|
211
|
+
) {
|
|
212
|
+
const hoveredHandleType = hoveredHandle.classList.contains('bf-flow__handle--target') ? 'target' : 'source'
|
|
213
|
+
const isCompatibleType = handleType !== hoveredHandleType
|
|
214
|
+
const srcHandleId = handleEl.getAttribute("data-handleid") ?? null
|
|
215
|
+
const tgtHandleId = hoveredHandle.getAttribute("data-handleid") ?? null
|
|
216
|
+
const conn = buildConnection(nodeId, hoveredHandle.dataset.nodeId, handleType, srcHandleId, tgtHandleId)
|
|
217
|
+
const isValid = isCompatibleType && checkConnectionValidity(store, conn)
|
|
218
|
+
|
|
219
|
+
hoveredHandle.classList.remove('valid', 'invalid')
|
|
220
|
+
if (!isValid) hoveredHandle.classList.add('invalid')
|
|
221
|
+
lastHighlightedHandle = hoveredHandle
|
|
222
|
+
} else {
|
|
223
|
+
lastHighlightedHandle = null
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const onMouseUp = (e: MouseEvent) => {
|
|
229
|
+
document.removeEventListener('mousemove', onMouseMove)
|
|
230
|
+
document.removeEventListener('mouseup', onMouseUp)
|
|
231
|
+
|
|
232
|
+
// Clean up validation/snap classes from any highlighted handle
|
|
233
|
+
if (lastHighlightedHandle) {
|
|
234
|
+
lastHighlightedHandle.classList.remove('valid', 'invalid')
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Prefer the snapped handle; fall back to direct hit-test
|
|
238
|
+
let targetHandle = snappedHandle
|
|
239
|
+
|
|
240
|
+
if (!targetHandle) {
|
|
241
|
+
overlaySvg.style.display = 'none'
|
|
242
|
+
const targetEl = document.elementFromPoint(e.clientX, e.clientY)
|
|
243
|
+
overlaySvg.style.display = ''
|
|
244
|
+
targetHandle = targetEl?.closest?.('.bf-flow__handle') as HTMLElement | null
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (
|
|
248
|
+
targetHandle &&
|
|
249
|
+
targetHandle.dataset.nodeId &&
|
|
250
|
+
targetHandle.dataset.nodeId !== nodeId
|
|
251
|
+
) {
|
|
252
|
+
const targetNodeId = targetHandle.dataset.nodeId
|
|
253
|
+
const targetHandleType = targetHandle.classList.contains('bf-flow__handle--target') ? 'target' : 'source'
|
|
254
|
+
const isCompatibleType = handleType !== targetHandleType
|
|
255
|
+
const srcHandleId = handleEl.getAttribute("data-handleid") ?? null
|
|
256
|
+
const tgtHandleId = targetHandle.getAttribute("data-handleid") ?? null
|
|
257
|
+
const conn = buildConnection(nodeId, targetNodeId, handleType, srcHandleId, tgtHandleId)
|
|
258
|
+
|
|
259
|
+
// Validate: handle type must be compatible + custom validation
|
|
260
|
+
const isValid = isCompatibleType && checkConnectionValidity(store, conn)
|
|
261
|
+
|
|
262
|
+
if (isValid) {
|
|
263
|
+
if (store.onConnect) {
|
|
264
|
+
// When onConnect is provided, the consumer is responsible for
|
|
265
|
+
// creating the edge (matching React Flow behaviour).
|
|
266
|
+
store.onConnect(conn)
|
|
267
|
+
} else {
|
|
268
|
+
// Default: auto-create a plain edge when no onConnect handler
|
|
269
|
+
const edgeId = `e-${conn.source}-${conn.target}-${Date.now()}`
|
|
270
|
+
const newEdge = {
|
|
271
|
+
id: edgeId,
|
|
272
|
+
source: conn.source,
|
|
273
|
+
target: conn.target,
|
|
274
|
+
sourceHandle: conn.sourceHandle ?? undefined,
|
|
275
|
+
targetHandle: conn.targetHandle ?? undefined,
|
|
276
|
+
} as EdgeType
|
|
277
|
+
store.addEdge(newEdge)
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Remove connection line overlay
|
|
283
|
+
overlaySvg.remove()
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
document.addEventListener('mousemove', onMouseMove)
|
|
287
|
+
document.addEventListener('mouseup', onMouseUp)
|
|
288
|
+
})
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Attach a reconnection drag handler to an edge endpoint handle.
|
|
293
|
+
* Dragging this handle detaches the edge from its source/target and allows
|
|
294
|
+
* reconnecting to a different handle.
|
|
295
|
+
*
|
|
296
|
+
* @param handleEl - The SVG circle element acting as the reconnection grip
|
|
297
|
+
* @param edge - The edge being reconnected
|
|
298
|
+
* @param endpointType - Which endpoint of the edge is being dragged ('source' | 'target')
|
|
299
|
+
* @param container - The flow container element
|
|
300
|
+
* @param edgesSvg - The SVG element containing edge paths
|
|
301
|
+
* @param store - The flow store
|
|
302
|
+
*/
|
|
303
|
+
export function attachReconnectionHandler<
|
|
304
|
+
NodeType extends NodeBase = NodeBase,
|
|
305
|
+
EdgeType extends EdgeBase = EdgeBase,
|
|
306
|
+
>(
|
|
307
|
+
handleEl: SVGCircleElement,
|
|
308
|
+
edge: EdgeType,
|
|
309
|
+
endpointType: 'source' | 'target',
|
|
310
|
+
container: HTMLElement,
|
|
311
|
+
edgesSvg: SVGSVGElement,
|
|
312
|
+
store: FlowStore<NodeType, EdgeType>,
|
|
313
|
+
): void {
|
|
314
|
+
handleEl.addEventListener('mousedown', (e) => {
|
|
315
|
+
if (e.button !== 0) return
|
|
316
|
+
e.stopPropagation()
|
|
317
|
+
e.preventDefault()
|
|
318
|
+
|
|
319
|
+
// The fixed anchor is the opposite endpoint of the edge
|
|
320
|
+
const anchorNodeId = endpointType === 'source' ? edge.target : edge.source
|
|
321
|
+
|
|
322
|
+
// Determine anchor position from the node
|
|
323
|
+
const nodeLookup = untrack(store.nodeLookup)
|
|
324
|
+
const anchorNode = nodeLookup.get(anchorNodeId)
|
|
325
|
+
if (!anchorNode) return
|
|
326
|
+
|
|
327
|
+
const anchorW = anchorNode.measured.width ?? 150
|
|
328
|
+
const anchorH = anchorNode.measured.height ?? 40
|
|
329
|
+
const anchorPos = anchorNode.internals.positionAbsolute
|
|
330
|
+
|
|
331
|
+
// For the anchor, use the handle position appropriate for the fixed end:
|
|
332
|
+
// If we're dragging the "source" end, the fixed anchor is the "target" end
|
|
333
|
+
// (which has a handle at the top). If dragging "target", anchor is "source" (bottom).
|
|
334
|
+
const anchorX = anchorPos.x + anchorW / 2
|
|
335
|
+
const anchorY = endpointType === 'source'
|
|
336
|
+
? anchorPos.y // target handle is at top
|
|
337
|
+
: anchorPos.y + anchorH // source handle is at bottom
|
|
338
|
+
|
|
339
|
+
// Hide the original edge path while reconnecting
|
|
340
|
+
const edgePathEl = edgesSvg.querySelector(`.bf-flow__edge[data-id="${edge.id}"]`) as SVGPathElement | null
|
|
341
|
+
const hitPathEl = edgesSvg.querySelector(`path[data-hit-id="${edge.id}"]`) as SVGPathElement | null
|
|
342
|
+
if (edgePathEl) edgePathEl.style.opacity = '0.2'
|
|
343
|
+
if (hitPathEl) hitPathEl.style.display = 'none'
|
|
344
|
+
|
|
345
|
+
// Create temporary connection line from anchor to cursor
|
|
346
|
+
const connectionLine = document.createElementNS(SVG_NS, 'path')
|
|
347
|
+
connectionLine.setAttribute('fill', 'none')
|
|
348
|
+
const reconnectLineStyle = store.connectionLineStyle
|
|
349
|
+
connectionLine.setAttribute('stroke', reconnectLineStyle?.stroke ?? '#b1b1b7')
|
|
350
|
+
connectionLine.setAttribute('stroke-width', reconnectLineStyle?.strokeWidth ?? '1')
|
|
351
|
+
connectionLine.setAttribute('pointer-events', 'none')
|
|
352
|
+
edgesSvg.appendChild(connectionLine)
|
|
353
|
+
|
|
354
|
+
let lastHoveredHandle: HTMLElement | null = null
|
|
355
|
+
|
|
356
|
+
const onMouseMove = (ev: MouseEvent) => {
|
|
357
|
+
const containerRect = container.getBoundingClientRect()
|
|
358
|
+
const [, , scale] = store.getTransform()
|
|
359
|
+
const vp = untrack(store.viewport)
|
|
360
|
+
|
|
361
|
+
const cursorX = (ev.clientX - containerRect.left - vp.x) / scale
|
|
362
|
+
const cursorY = (ev.clientY - containerRect.top - vp.y) / scale
|
|
363
|
+
|
|
364
|
+
// Draw smoothstep path from anchor to cursor
|
|
365
|
+
// sourcePosition/targetPosition depends on which endpoint is the anchor
|
|
366
|
+
const sourcePosition = endpointType === 'source' ? Position.Top : Position.Bottom
|
|
367
|
+
const targetPosition = endpointType === 'source' ? Position.Bottom : Position.Top
|
|
368
|
+
|
|
369
|
+
const [path] = getSmoothStepPath({
|
|
370
|
+
sourceX: anchorX,
|
|
371
|
+
sourceY: anchorY,
|
|
372
|
+
sourcePosition,
|
|
373
|
+
targetX: cursorX,
|
|
374
|
+
targetY: cursorY,
|
|
375
|
+
targetPosition,
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
connectionLine.setAttribute('d', path)
|
|
379
|
+
|
|
380
|
+
// Validate on hover over handles
|
|
381
|
+
const hoverEl = document.elementFromPoint(ev.clientX, ev.clientY)
|
|
382
|
+
const hoveredHandle = hoverEl?.closest?.('.bf-flow__handle') as HTMLElement | null
|
|
383
|
+
|
|
384
|
+
if (lastHoveredHandle && lastHoveredHandle !== hoveredHandle) {
|
|
385
|
+
lastHoveredHandle.classList.remove('invalid')
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (
|
|
389
|
+
hoveredHandle &&
|
|
390
|
+
hoveredHandle.dataset.nodeId &&
|
|
391
|
+
hoveredHandle.dataset.nodeId !== anchorNodeId
|
|
392
|
+
) {
|
|
393
|
+
// Build connection based on the hovered handle's type
|
|
394
|
+
const hoveredNodeId = hoveredHandle.dataset.nodeId
|
|
395
|
+
const hoveredHandleType = hoveredHandle.classList.contains('bf-flow__handle--target') ? 'target' : 'source'
|
|
396
|
+
const conn: Connection = hoveredHandleType === 'target'
|
|
397
|
+
? { source: anchorNodeId, target: hoveredNodeId, sourceHandle: null, targetHandle: null }
|
|
398
|
+
: { source: hoveredNodeId, target: anchorNodeId, sourceHandle: null, targetHandle: null }
|
|
399
|
+
|
|
400
|
+
const isValid = checkConnectionValidity(store, conn)
|
|
401
|
+
hoveredHandle.classList.remove('invalid')
|
|
402
|
+
if (!isValid) hoveredHandle.classList.add('invalid')
|
|
403
|
+
lastHoveredHandle = hoveredHandle
|
|
404
|
+
} else {
|
|
405
|
+
lastHoveredHandle = null
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const onMouseUp = (ev: MouseEvent) => {
|
|
410
|
+
document.removeEventListener('mousemove', onMouseMove)
|
|
411
|
+
document.removeEventListener('mouseup', onMouseUp)
|
|
412
|
+
|
|
413
|
+
if (lastHoveredHandle) {
|
|
414
|
+
lastHoveredHandle.classList.remove('invalid')
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Restore the original edge appearance
|
|
418
|
+
if (edgePathEl) edgePathEl.style.opacity = ''
|
|
419
|
+
if (hitPathEl) hitPathEl.style.display = ''
|
|
420
|
+
|
|
421
|
+
// Check if released on a valid handle
|
|
422
|
+
const targetEl = document.elementFromPoint(ev.clientX, ev.clientY)
|
|
423
|
+
const droppedHandle = targetEl?.closest?.('.bf-flow__handle') as HTMLElement | null
|
|
424
|
+
|
|
425
|
+
if (
|
|
426
|
+
droppedHandle &&
|
|
427
|
+
droppedHandle.dataset.nodeId &&
|
|
428
|
+
droppedHandle.dataset.nodeId !== anchorNodeId
|
|
429
|
+
) {
|
|
430
|
+
const droppedNodeId = droppedHandle.dataset.nodeId
|
|
431
|
+
// Determine connection direction from the dropped handle's type
|
|
432
|
+
const droppedHandleType = droppedHandle.classList.contains('bf-flow__handle--target') ? 'target' : 'source'
|
|
433
|
+
const newConnection: Connection = droppedHandleType === 'target'
|
|
434
|
+
? { source: anchorNodeId, target: droppedNodeId, sourceHandle: null, targetHandle: null }
|
|
435
|
+
: { source: droppedNodeId, target: anchorNodeId, sourceHandle: null, targetHandle: null }
|
|
436
|
+
|
|
437
|
+
const isValid = checkConnectionValidity(store, newConnection)
|
|
438
|
+
|
|
439
|
+
if (isValid) {
|
|
440
|
+
// Fire onReconnect callback
|
|
441
|
+
if (store.onReconnect) {
|
|
442
|
+
store.onReconnect(edge, newConnection)
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Update edges using reconnectEdge utility
|
|
446
|
+
const currentEdges = untrack(store.edges)
|
|
447
|
+
const updatedEdges = reconnectEdgeUtil(edge, newConnection, currentEdges)
|
|
448
|
+
store.setEdges(updatedEdges as EdgeType[])
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
// If not dropped on a valid handle, the edge reverts (appearance already restored)
|
|
452
|
+
|
|
453
|
+
connectionLine.remove()
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
document.addEventListener('mousemove', onMouseMove)
|
|
457
|
+
document.addEventListener('mouseup', onMouseUp)
|
|
458
|
+
})
|
|
459
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { CoordinateExtent } from '@xyflow/system'
|
|
2
|
+
|
|
3
|
+
export const SVG_NS = 'http://www.w3.org/2000/svg'
|
|
4
|
+
|
|
5
|
+
export const INFINITE_EXTENT: CoordinateExtent = [
|
|
6
|
+
[Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY],
|
|
7
|
+
[Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY],
|
|
8
|
+
]
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { createContext } from '@barefootjs/client'
|
|
2
|
+
import type { FlowStore } from './types'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Context for sharing the flow store across child components.
|
|
6
|
+
* Provided by initFlow, consumed by child init functions (e.g., handles, custom nodes).
|
|
7
|
+
*/
|
|
8
|
+
export const FlowContext = createContext<FlowStore>()
|
package/src/edge-path.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Pure path-geometry helpers shared between the imperative edge renderer
|
|
2
|
+
// (edge-renderer.ts) and the JSX-native simple-edge component (simple-edge.tsx).
|
|
3
|
+
// Extracted in #1081 step 2 so the JSX path computes geometry the same way
|
|
4
|
+
// the imperative path does, avoiding subtle divergence during the migration.
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
getBezierPath,
|
|
8
|
+
getSmoothStepPath,
|
|
9
|
+
getStraightPath,
|
|
10
|
+
getEdgePosition,
|
|
11
|
+
ConnectionMode,
|
|
12
|
+
Position,
|
|
13
|
+
} from '@xyflow/system'
|
|
14
|
+
import type { EdgeBase, EdgePosition, InternalNodeBase } from '@xyflow/system'
|
|
15
|
+
|
|
16
|
+
export type EdgePathTuple = [string, number, number, number, number]
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Compute endpoint positions for an edge, falling back to a node-center
|
|
20
|
+
* straight line when no handle bounds are available yet (e.g. before the
|
|
21
|
+
* first measure has populated `nodeLookup`).
|
|
22
|
+
*/
|
|
23
|
+
export function computeEdgePosition(
|
|
24
|
+
edge: EdgeBase,
|
|
25
|
+
sourceNode: InternalNodeBase,
|
|
26
|
+
targetNode: InternalNodeBase,
|
|
27
|
+
): EdgePosition | null {
|
|
28
|
+
const hasHandleIds = !!(edge.sourceHandle || edge.targetHandle)
|
|
29
|
+
const edgePos = getEdgePosition({
|
|
30
|
+
id: edge.id,
|
|
31
|
+
sourceNode,
|
|
32
|
+
sourceHandle: edge.sourceHandle ?? null,
|
|
33
|
+
targetNode,
|
|
34
|
+
targetHandle: edge.targetHandle ?? null,
|
|
35
|
+
connectionMode: hasHandleIds ? ConnectionMode.Strict : ConnectionMode.Loose,
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
if (edgePos) return edgePos
|
|
39
|
+
|
|
40
|
+
const sw = sourceNode.measured.width ?? 150
|
|
41
|
+
const sh = sourceNode.measured.height ?? 40
|
|
42
|
+
const tw = targetNode.measured.width ?? 150
|
|
43
|
+
const sourcePos = sourceNode.internals.positionAbsolute
|
|
44
|
+
const targetPos = targetNode.internals.positionAbsolute
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
sourceX: sourcePos.x + sw / 2,
|
|
48
|
+
sourceY: sourcePos.y + sh,
|
|
49
|
+
targetX: targetPos.x + tw / 2,
|
|
50
|
+
targetY: targetPos.y,
|
|
51
|
+
sourcePosition: Position.Bottom,
|
|
52
|
+
targetPosition: Position.Top,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Calculate edge path based on edge type. Returns
|
|
58
|
+
* `[d, labelX, labelY, offsetX, offsetY]` or `null`.
|
|
59
|
+
*/
|
|
60
|
+
export function getEdgePath(
|
|
61
|
+
edge: EdgeBase,
|
|
62
|
+
pos: EdgePosition,
|
|
63
|
+
): EdgePathTuple | null {
|
|
64
|
+
const params = {
|
|
65
|
+
sourceX: pos.sourceX,
|
|
66
|
+
sourceY: pos.sourceY,
|
|
67
|
+
sourcePosition: pos.sourcePosition,
|
|
68
|
+
targetX: pos.targetX,
|
|
69
|
+
targetY: pos.targetY,
|
|
70
|
+
targetPosition: pos.targetPosition,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const edgeType = edge.type ?? 'default'
|
|
74
|
+
|
|
75
|
+
switch (edgeType) {
|
|
76
|
+
case 'straight':
|
|
77
|
+
return getStraightPath(params) as EdgePathTuple
|
|
78
|
+
case 'smoothstep':
|
|
79
|
+
case 'step':
|
|
80
|
+
return getSmoothStepPath({
|
|
81
|
+
...params,
|
|
82
|
+
borderRadius: edgeType === 'step' ? 0 : undefined,
|
|
83
|
+
}) as EdgePathTuple
|
|
84
|
+
case 'default':
|
|
85
|
+
case 'bezier':
|
|
86
|
+
default:
|
|
87
|
+
return getBezierPath(params)
|
|
88
|
+
}
|
|
89
|
+
}
|