@pyreon/flow 0.5.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/LICENSE +21 -0
- package/lib/analysis/index.js.html +5406 -0
- package/lib/chunk-C8JhGJ3N.js +33 -0
- package/lib/elk.bundled-B9dPTHTZ.js +88591 -0
- package/lib/elk.bundled-B9dPTHTZ.js.map +1 -0
- package/lib/index.js +2526 -0
- package/lib/index.js.map +1 -0
- package/lib/types/chunk.d.ts +2 -0
- package/lib/types/elk.bundled.d.ts +7 -0
- package/lib/types/elk.bundled.d.ts.map +1 -0
- package/lib/types/index.d.ts +2342 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index2.d.ts +708 -0
- package/lib/types/index2.d.ts.map +1 -0
- package/package.json +55 -0
- package/src/components/background.tsx +128 -0
- package/src/components/controls.tsx +158 -0
- package/src/components/flow-component.tsx +918 -0
- package/src/components/handle.tsx +42 -0
- package/src/components/minimap.tsx +136 -0
- package/src/components/node-resizer.tsx +169 -0
- package/src/components/node-toolbar.tsx +74 -0
- package/src/components/panel.tsx +33 -0
- package/src/edges.ts +369 -0
- package/src/flow.ts +1141 -0
- package/src/index.ts +83 -0
- package/src/layout.ts +152 -0
- package/src/styles.ts +115 -0
- package/src/tests/flow-advanced.test.ts +802 -0
- package/src/tests/flow.test.ts +1284 -0
- package/src/types.ts +483 -0
|
@@ -0,0 +1,918 @@
|
|
|
1
|
+
import type { VNodeChild } from '@pyreon/core'
|
|
2
|
+
import { signal } from '@pyreon/reactivity'
|
|
3
|
+
import {
|
|
4
|
+
getEdgePath,
|
|
5
|
+
getHandlePosition,
|
|
6
|
+
getSmartHandlePositions,
|
|
7
|
+
getWaypointPath,
|
|
8
|
+
} from '../edges'
|
|
9
|
+
import type {
|
|
10
|
+
Connection,
|
|
11
|
+
FlowInstance,
|
|
12
|
+
FlowNode,
|
|
13
|
+
NodeComponentProps,
|
|
14
|
+
} from '../types'
|
|
15
|
+
import { Position } from '../types'
|
|
16
|
+
|
|
17
|
+
// ─── Node type registry ──────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
type NodeTypeMap = Record<
|
|
20
|
+
string,
|
|
21
|
+
(props: NodeComponentProps<any>) => VNodeChild
|
|
22
|
+
>
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Default node renderer — simple labeled box.
|
|
26
|
+
*/
|
|
27
|
+
function DefaultNode(props: NodeComponentProps) {
|
|
28
|
+
const borderColor = props.selected ? '#3b82f6' : '#ddd'
|
|
29
|
+
const cursor = props.dragging ? 'grabbing' : 'grab'
|
|
30
|
+
return (
|
|
31
|
+
<div
|
|
32
|
+
style={`padding: 8px 16px; background: white; border: 2px solid ${borderColor}; border-radius: 6px; font-size: 13px; min-width: 80px; text-align: center; cursor: ${cursor}; user-select: none;`}
|
|
33
|
+
>
|
|
34
|
+
{(props.data?.label as string) ?? props.id}
|
|
35
|
+
</div>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ─── Connection line state ───────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
interface ConnectionState {
|
|
42
|
+
active: boolean
|
|
43
|
+
sourceNodeId: string
|
|
44
|
+
sourceHandleId: string
|
|
45
|
+
sourcePosition: Position
|
|
46
|
+
sourceX: number
|
|
47
|
+
sourceY: number
|
|
48
|
+
currentX: number
|
|
49
|
+
currentY: number
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const emptyConnection: ConnectionState = {
|
|
53
|
+
active: false,
|
|
54
|
+
sourceNodeId: '',
|
|
55
|
+
sourceHandleId: '',
|
|
56
|
+
sourcePosition: Position.Right,
|
|
57
|
+
sourceX: 0,
|
|
58
|
+
sourceY: 0,
|
|
59
|
+
currentX: 0,
|
|
60
|
+
currentY: 0,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ─── Selection box state ─────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
interface SelectionBoxState {
|
|
66
|
+
active: boolean
|
|
67
|
+
startX: number
|
|
68
|
+
startY: number
|
|
69
|
+
currentX: number
|
|
70
|
+
currentY: number
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const emptySelectionBox: SelectionBoxState = {
|
|
74
|
+
active: false,
|
|
75
|
+
startX: 0,
|
|
76
|
+
startY: 0,
|
|
77
|
+
currentX: 0,
|
|
78
|
+
currentY: 0,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ─── Drag state ──────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
interface DragState {
|
|
84
|
+
active: boolean
|
|
85
|
+
nodeId: string
|
|
86
|
+
startX: number
|
|
87
|
+
startY: number
|
|
88
|
+
/** Starting positions of all nodes being dragged (for multi-drag) */
|
|
89
|
+
startPositions: Map<string, { x: number; y: number }>
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const emptyDrag: DragState = {
|
|
93
|
+
active: false,
|
|
94
|
+
nodeId: '',
|
|
95
|
+
startX: 0,
|
|
96
|
+
startY: 0,
|
|
97
|
+
startPositions: new Map(),
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ─── Edge Layer ──────────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
function EdgeLayer(props: {
|
|
103
|
+
instance: FlowInstance
|
|
104
|
+
connectionState: () => ConnectionState
|
|
105
|
+
edgeTypes?: EdgeTypeMap
|
|
106
|
+
}): VNodeChild {
|
|
107
|
+
const { instance, connectionState, edgeTypes } = props
|
|
108
|
+
|
|
109
|
+
return () => {
|
|
110
|
+
const nodes = instance.nodes()
|
|
111
|
+
const edges = instance.edges()
|
|
112
|
+
const nodeMap = new Map(nodes.map((n) => [n.id, n]))
|
|
113
|
+
const conn = connectionState()
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<svg
|
|
117
|
+
role="img"
|
|
118
|
+
aria-label="flow edges"
|
|
119
|
+
class="pyreon-flow-edges"
|
|
120
|
+
style="position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; overflow: visible;"
|
|
121
|
+
>
|
|
122
|
+
<defs>
|
|
123
|
+
<marker
|
|
124
|
+
id="flow-arrowhead"
|
|
125
|
+
markerWidth="10"
|
|
126
|
+
markerHeight="7"
|
|
127
|
+
refX="10"
|
|
128
|
+
refY="3.5"
|
|
129
|
+
orient="auto"
|
|
130
|
+
>
|
|
131
|
+
<polygon points="0 0, 10 3.5, 0 7" fill="#999" />
|
|
132
|
+
</marker>
|
|
133
|
+
</defs>
|
|
134
|
+
{edges.map((edge) => {
|
|
135
|
+
const sourceNode = nodeMap.get(edge.source)
|
|
136
|
+
const targetNode = nodeMap.get(edge.target)
|
|
137
|
+
if (!sourceNode || !targetNode) return <g key={edge.id} />
|
|
138
|
+
|
|
139
|
+
const sourceW = sourceNode.width ?? 150
|
|
140
|
+
const sourceH = sourceNode.height ?? 40
|
|
141
|
+
const targetW = targetNode.width ?? 150
|
|
142
|
+
const targetH = targetNode.height ?? 40
|
|
143
|
+
|
|
144
|
+
const { sourcePosition, targetPosition } = getSmartHandlePositions(
|
|
145
|
+
sourceNode,
|
|
146
|
+
targetNode,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
const sourcePos = getHandlePosition(
|
|
150
|
+
sourcePosition,
|
|
151
|
+
sourceNode.position.x,
|
|
152
|
+
sourceNode.position.y,
|
|
153
|
+
sourceW,
|
|
154
|
+
sourceH,
|
|
155
|
+
)
|
|
156
|
+
const targetPos = getHandlePosition(
|
|
157
|
+
targetPosition,
|
|
158
|
+
targetNode.position.x,
|
|
159
|
+
targetNode.position.y,
|
|
160
|
+
targetW,
|
|
161
|
+
targetH,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
const { path, labelX, labelY } = edge.waypoints?.length
|
|
165
|
+
? getWaypointPath({
|
|
166
|
+
sourceX: sourcePos.x,
|
|
167
|
+
sourceY: sourcePos.y,
|
|
168
|
+
targetX: targetPos.x,
|
|
169
|
+
targetY: targetPos.y,
|
|
170
|
+
waypoints: edge.waypoints,
|
|
171
|
+
})
|
|
172
|
+
: getEdgePath(
|
|
173
|
+
edge.type ?? 'bezier',
|
|
174
|
+
sourcePos.x,
|
|
175
|
+
sourcePos.y,
|
|
176
|
+
sourcePosition,
|
|
177
|
+
targetPos.x,
|
|
178
|
+
targetPos.y,
|
|
179
|
+
targetPosition,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
const selectedEdges = instance.selectedEdges()
|
|
183
|
+
const isSelected = edge.id ? selectedEdges.includes(edge.id) : false
|
|
184
|
+
|
|
185
|
+
// Custom edge renderer
|
|
186
|
+
const CustomEdge = edge.type && edgeTypes?.[edge.type]
|
|
187
|
+
if (CustomEdge) {
|
|
188
|
+
return (
|
|
189
|
+
<g
|
|
190
|
+
key={edge.id}
|
|
191
|
+
onClick={() => edge.id && instance.selectEdge(edge.id)}
|
|
192
|
+
>
|
|
193
|
+
<CustomEdge
|
|
194
|
+
edge={edge}
|
|
195
|
+
sourceX={sourcePos.x}
|
|
196
|
+
sourceY={sourcePos.y}
|
|
197
|
+
targetX={targetPos.x}
|
|
198
|
+
targetY={targetPos.y}
|
|
199
|
+
selected={isSelected}
|
|
200
|
+
/>
|
|
201
|
+
</g>
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return (
|
|
206
|
+
<g key={edge.id}>
|
|
207
|
+
<path
|
|
208
|
+
d={path}
|
|
209
|
+
fill="none"
|
|
210
|
+
stroke={isSelected ? '#3b82f6' : '#999'}
|
|
211
|
+
stroke-width={isSelected ? '2' : '1.5'}
|
|
212
|
+
marker-end="url(#flow-arrowhead)"
|
|
213
|
+
class={edge.animated ? 'pyreon-flow-edge-animated' : ''}
|
|
214
|
+
style={`pointer-events: stroke; cursor: pointer; ${edge.style ?? ''}`}
|
|
215
|
+
onClick={() => {
|
|
216
|
+
if (edge.id) instance.selectEdge(edge.id)
|
|
217
|
+
instance._emit.edgeClick(edge)
|
|
218
|
+
}}
|
|
219
|
+
/>
|
|
220
|
+
{edge.label && (
|
|
221
|
+
<text
|
|
222
|
+
x={String(labelX)}
|
|
223
|
+
y={String(labelY)}
|
|
224
|
+
text-anchor="middle"
|
|
225
|
+
dominant-baseline="central"
|
|
226
|
+
style="font-size: 11px; fill: #666; pointer-events: none;"
|
|
227
|
+
>
|
|
228
|
+
{edge.label}
|
|
229
|
+
</text>
|
|
230
|
+
)}
|
|
231
|
+
</g>
|
|
232
|
+
)
|
|
233
|
+
})}
|
|
234
|
+
{conn.active && (
|
|
235
|
+
<path
|
|
236
|
+
d={
|
|
237
|
+
getEdgePath(
|
|
238
|
+
'bezier',
|
|
239
|
+
conn.sourceX,
|
|
240
|
+
conn.sourceY,
|
|
241
|
+
conn.sourcePosition,
|
|
242
|
+
conn.currentX,
|
|
243
|
+
conn.currentY,
|
|
244
|
+
Position.Left,
|
|
245
|
+
).path
|
|
246
|
+
}
|
|
247
|
+
fill="none"
|
|
248
|
+
stroke="#3b82f6"
|
|
249
|
+
stroke-width="2"
|
|
250
|
+
stroke-dasharray="5,5"
|
|
251
|
+
/>
|
|
252
|
+
)}
|
|
253
|
+
</svg>
|
|
254
|
+
)
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ─── Node Layer ──────────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
function NodeLayer(props: {
|
|
261
|
+
instance: FlowInstance
|
|
262
|
+
nodeTypes: NodeTypeMap
|
|
263
|
+
draggingNodeId: () => string
|
|
264
|
+
onNodePointerDown: (e: PointerEvent, node: FlowNode) => void
|
|
265
|
+
onHandlePointerDown: (
|
|
266
|
+
e: PointerEvent,
|
|
267
|
+
nodeId: string,
|
|
268
|
+
handleType: string,
|
|
269
|
+
handleId: string,
|
|
270
|
+
position: Position,
|
|
271
|
+
) => void
|
|
272
|
+
}): VNodeChild {
|
|
273
|
+
const {
|
|
274
|
+
instance,
|
|
275
|
+
nodeTypes,
|
|
276
|
+
draggingNodeId,
|
|
277
|
+
onNodePointerDown,
|
|
278
|
+
onHandlePointerDown,
|
|
279
|
+
} = props
|
|
280
|
+
|
|
281
|
+
return () => {
|
|
282
|
+
const nodes = instance.nodes()
|
|
283
|
+
const selectedIds = instance.selectedNodes()
|
|
284
|
+
const dragId = draggingNodeId()
|
|
285
|
+
|
|
286
|
+
return (
|
|
287
|
+
<>
|
|
288
|
+
{nodes.map((node) => {
|
|
289
|
+
const isSelected = selectedIds.includes(node.id)
|
|
290
|
+
const isDragging = dragId === node.id
|
|
291
|
+
const NodeComponent =
|
|
292
|
+
(node.type && nodeTypes[node.type]) || nodeTypes.default!
|
|
293
|
+
|
|
294
|
+
return (
|
|
295
|
+
<div
|
|
296
|
+
key={node.id}
|
|
297
|
+
class={`pyreon-flow-node ${node.class ?? ''} ${isSelected ? 'selected' : ''} ${isDragging ? 'dragging' : ''}`}
|
|
298
|
+
style={`position: absolute; transform: translate(${node.position.x}px, ${node.position.y}px); z-index: ${isDragging ? 1000 : isSelected ? 100 : 0}; ${node.style ?? ''}`}
|
|
299
|
+
data-nodeid={node.id}
|
|
300
|
+
onClick={(e: MouseEvent) => {
|
|
301
|
+
e.stopPropagation()
|
|
302
|
+
instance.selectNode(node.id, e.shiftKey)
|
|
303
|
+
instance._emit.nodeClick(node)
|
|
304
|
+
}}
|
|
305
|
+
onDblclick={(e: MouseEvent) => {
|
|
306
|
+
e.stopPropagation()
|
|
307
|
+
instance._emit.nodeDoubleClick(node)
|
|
308
|
+
}}
|
|
309
|
+
onPointerdown={(e: PointerEvent) => {
|
|
310
|
+
// Check if clicking a handle
|
|
311
|
+
const target = e.target as HTMLElement
|
|
312
|
+
const handle = target.closest('.pyreon-flow-handle')
|
|
313
|
+
if (handle) {
|
|
314
|
+
const hType =
|
|
315
|
+
handle.getAttribute('data-handletype') ?? 'source'
|
|
316
|
+
const hId = handle.getAttribute('data-handleid') ?? 'source'
|
|
317
|
+
const hPos =
|
|
318
|
+
(handle.getAttribute('data-handleposition') as Position) ??
|
|
319
|
+
Position.Right
|
|
320
|
+
onHandlePointerDown(e, node.id, hType, hId, hPos)
|
|
321
|
+
return
|
|
322
|
+
}
|
|
323
|
+
// Otherwise start dragging node
|
|
324
|
+
if (
|
|
325
|
+
node.draggable !== false &&
|
|
326
|
+
instance.config.nodesDraggable !== false
|
|
327
|
+
) {
|
|
328
|
+
onNodePointerDown(e, node)
|
|
329
|
+
}
|
|
330
|
+
}}
|
|
331
|
+
>
|
|
332
|
+
<NodeComponent
|
|
333
|
+
id={node.id}
|
|
334
|
+
data={node.data}
|
|
335
|
+
selected={isSelected}
|
|
336
|
+
dragging={isDragging}
|
|
337
|
+
/>
|
|
338
|
+
</div>
|
|
339
|
+
)
|
|
340
|
+
})}
|
|
341
|
+
</>
|
|
342
|
+
)
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ─── Flow Component ──────────────────────────────────────────────────────────
|
|
347
|
+
|
|
348
|
+
type EdgeTypeMap = Record<
|
|
349
|
+
string,
|
|
350
|
+
(props: {
|
|
351
|
+
edge: import('../types').FlowEdge
|
|
352
|
+
sourceX: number
|
|
353
|
+
sourceY: number
|
|
354
|
+
targetX: number
|
|
355
|
+
targetY: number
|
|
356
|
+
selected: boolean
|
|
357
|
+
}) => VNodeChild
|
|
358
|
+
>
|
|
359
|
+
|
|
360
|
+
export interface FlowComponentProps {
|
|
361
|
+
instance: FlowInstance
|
|
362
|
+
/** Custom node type renderers */
|
|
363
|
+
nodeTypes?: NodeTypeMap
|
|
364
|
+
/** Custom edge type renderers */
|
|
365
|
+
edgeTypes?: EdgeTypeMap
|
|
366
|
+
style?: string
|
|
367
|
+
class?: string
|
|
368
|
+
children?: VNodeChild
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* The main Flow component — renders the interactive flow diagram.
|
|
373
|
+
*
|
|
374
|
+
* Supports node dragging, connection drawing, custom node types,
|
|
375
|
+
* pan/zoom, and all standard flow interactions.
|
|
376
|
+
*
|
|
377
|
+
* @example
|
|
378
|
+
* ```tsx
|
|
379
|
+
* const flow = createFlow({
|
|
380
|
+
* nodes: [...],
|
|
381
|
+
* edges: [...],
|
|
382
|
+
* })
|
|
383
|
+
*
|
|
384
|
+
* <Flow instance={flow} nodeTypes={{ custom: CustomNode }}>
|
|
385
|
+
* <Background />
|
|
386
|
+
* <MiniMap />
|
|
387
|
+
* <Controls />
|
|
388
|
+
* </Flow>
|
|
389
|
+
* ```
|
|
390
|
+
*/
|
|
391
|
+
export function Flow(props: FlowComponentProps): VNodeChild {
|
|
392
|
+
const { instance, children, edgeTypes } = props
|
|
393
|
+
const nodeTypes: NodeTypeMap = {
|
|
394
|
+
default: DefaultNode,
|
|
395
|
+
input: DefaultNode,
|
|
396
|
+
output: DefaultNode,
|
|
397
|
+
...props.nodeTypes,
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// ── Drag state ─────────────────────────────────────────────────────────
|
|
401
|
+
|
|
402
|
+
const dragState = signal<DragState>({ ...emptyDrag })
|
|
403
|
+
const connectionState = signal<ConnectionState>({ ...emptyConnection })
|
|
404
|
+
const selectionBox = signal<SelectionBoxState>({ ...emptySelectionBox })
|
|
405
|
+
const helperLines = signal<{ x: number | null; y: number | null }>({
|
|
406
|
+
x: null,
|
|
407
|
+
y: null,
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
const draggingNodeId = () => (dragState().active ? dragState().nodeId : '')
|
|
411
|
+
|
|
412
|
+
// ── Node dragging ──────────────────────────────────────────────────────
|
|
413
|
+
|
|
414
|
+
const handleNodePointerDown = (e: PointerEvent, node: FlowNode) => {
|
|
415
|
+
e.stopPropagation()
|
|
416
|
+
|
|
417
|
+
// Capture starting positions of all selected nodes (for multi-drag)
|
|
418
|
+
const selected = instance.selectedNodes()
|
|
419
|
+
const startPositions = new Map<string, { x: number; y: number }>()
|
|
420
|
+
|
|
421
|
+
// Always include the dragged node
|
|
422
|
+
startPositions.set(node.id, { ...node.position })
|
|
423
|
+
|
|
424
|
+
// Include other selected nodes if this node is part of selection
|
|
425
|
+
if (selected.includes(node.id)) {
|
|
426
|
+
for (const nid of selected) {
|
|
427
|
+
if (nid === node.id) continue
|
|
428
|
+
const n = instance.getNode(nid)
|
|
429
|
+
if (n) startPositions.set(nid, { ...n.position })
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Save undo state before drag
|
|
434
|
+
instance.pushHistory()
|
|
435
|
+
|
|
436
|
+
dragState.set({
|
|
437
|
+
active: true,
|
|
438
|
+
nodeId: node.id,
|
|
439
|
+
startX: e.clientX,
|
|
440
|
+
startY: e.clientY,
|
|
441
|
+
startPositions,
|
|
442
|
+
})
|
|
443
|
+
|
|
444
|
+
instance.selectNode(node.id, e.shiftKey)
|
|
445
|
+
|
|
446
|
+
instance._emit.nodeDragStart(node)
|
|
447
|
+
|
|
448
|
+
const container = (e.currentTarget as HTMLElement).closest(
|
|
449
|
+
'.pyreon-flow',
|
|
450
|
+
) as HTMLElement
|
|
451
|
+
if (container) container.setPointerCapture(e.pointerId)
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// ── Connection drawing ─────────────────────────────────────────────────
|
|
455
|
+
|
|
456
|
+
const handleHandlePointerDown = (
|
|
457
|
+
e: PointerEvent,
|
|
458
|
+
nodeId: string,
|
|
459
|
+
_handleType: string,
|
|
460
|
+
handleId: string,
|
|
461
|
+
position: Position,
|
|
462
|
+
) => {
|
|
463
|
+
e.stopPropagation()
|
|
464
|
+
e.preventDefault()
|
|
465
|
+
|
|
466
|
+
const node = instance.getNode(nodeId)
|
|
467
|
+
if (!node) return
|
|
468
|
+
|
|
469
|
+
const w = node.width ?? 150
|
|
470
|
+
const h = node.height ?? 40
|
|
471
|
+
const handlePos = getHandlePosition(
|
|
472
|
+
position,
|
|
473
|
+
node.position.x,
|
|
474
|
+
node.position.y,
|
|
475
|
+
w,
|
|
476
|
+
h,
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
connectionState.set({
|
|
480
|
+
active: true,
|
|
481
|
+
sourceNodeId: nodeId,
|
|
482
|
+
sourceHandleId: handleId,
|
|
483
|
+
sourcePosition: position,
|
|
484
|
+
sourceX: handlePos.x,
|
|
485
|
+
sourceY: handlePos.y,
|
|
486
|
+
currentX: handlePos.x,
|
|
487
|
+
currentY: handlePos.y,
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
const container = (e.target as HTMLElement).closest(
|
|
491
|
+
'.pyreon-flow',
|
|
492
|
+
) as HTMLElement
|
|
493
|
+
if (container) container.setPointerCapture(e.pointerId)
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// ── Zoom ───────────────────────────────────────────────────────────────
|
|
497
|
+
|
|
498
|
+
const handleWheel = (e: WheelEvent) => {
|
|
499
|
+
if (instance.config.zoomable === false) return
|
|
500
|
+
e.preventDefault()
|
|
501
|
+
|
|
502
|
+
const delta = -e.deltaY * 0.001
|
|
503
|
+
const newZoom = Math.min(
|
|
504
|
+
Math.max(
|
|
505
|
+
instance.viewport.peek().zoom * (1 + delta),
|
|
506
|
+
instance.config.minZoom ?? 0.1,
|
|
507
|
+
),
|
|
508
|
+
instance.config.maxZoom ?? 4,
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
|
512
|
+
const mouseX = e.clientX - rect.left
|
|
513
|
+
const mouseY = e.clientY - rect.top
|
|
514
|
+
const vp = instance.viewport.peek()
|
|
515
|
+
const scale = newZoom / vp.zoom
|
|
516
|
+
|
|
517
|
+
instance.viewport.set({
|
|
518
|
+
x: mouseX - (mouseX - vp.x) * scale,
|
|
519
|
+
y: mouseY - (mouseY - vp.y) * scale,
|
|
520
|
+
zoom: newZoom,
|
|
521
|
+
})
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// ── Pan ────────────────────────────────────────────────────────────────
|
|
525
|
+
|
|
526
|
+
let isPanning = false
|
|
527
|
+
let panStartX = 0
|
|
528
|
+
let panStartY = 0
|
|
529
|
+
let panStartVpX = 0
|
|
530
|
+
let panStartVpY = 0
|
|
531
|
+
|
|
532
|
+
const handlePointerDown = (e: PointerEvent) => {
|
|
533
|
+
if (instance.config.pannable === false) return
|
|
534
|
+
|
|
535
|
+
const target = e.target as HTMLElement
|
|
536
|
+
if (target.closest('.pyreon-flow-node')) return
|
|
537
|
+
if (target.closest('.pyreon-flow-handle')) return
|
|
538
|
+
|
|
539
|
+
// Shift+drag on empty space → selection box
|
|
540
|
+
if (e.shiftKey && instance.config.multiSelect !== false) {
|
|
541
|
+
const container = e.currentTarget as HTMLElement
|
|
542
|
+
const rect = container.getBoundingClientRect()
|
|
543
|
+
const vp = instance.viewport.peek()
|
|
544
|
+
const flowX = (e.clientX - rect.left - vp.x) / vp.zoom
|
|
545
|
+
const flowY = (e.clientY - rect.top - vp.y) / vp.zoom
|
|
546
|
+
|
|
547
|
+
selectionBox.set({
|
|
548
|
+
active: true,
|
|
549
|
+
startX: flowX,
|
|
550
|
+
startY: flowY,
|
|
551
|
+
currentX: flowX,
|
|
552
|
+
currentY: flowY,
|
|
553
|
+
})
|
|
554
|
+
container.setPointerCapture(e.pointerId)
|
|
555
|
+
return
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
isPanning = true
|
|
559
|
+
panStartX = e.clientX
|
|
560
|
+
panStartY = e.clientY
|
|
561
|
+
const vp = instance.viewport.peek()
|
|
562
|
+
panStartVpX = vp.x
|
|
563
|
+
panStartVpY = vp.y
|
|
564
|
+
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
|
565
|
+
|
|
566
|
+
instance.clearSelection()
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// ── Unified pointer move/up ────────────────────────────────────────────
|
|
570
|
+
|
|
571
|
+
const handlePointerMove = (e: PointerEvent) => {
|
|
572
|
+
const drag = dragState.peek()
|
|
573
|
+
const conn = connectionState.peek()
|
|
574
|
+
const sel = selectionBox.peek()
|
|
575
|
+
|
|
576
|
+
if (sel.active) {
|
|
577
|
+
const container = e.currentTarget as HTMLElement
|
|
578
|
+
const rect = container.getBoundingClientRect()
|
|
579
|
+
const vp = instance.viewport.peek()
|
|
580
|
+
const flowX = (e.clientX - rect.left - vp.x) / vp.zoom
|
|
581
|
+
const flowY = (e.clientY - rect.top - vp.y) / vp.zoom
|
|
582
|
+
selectionBox.set({ ...sel, currentX: flowX, currentY: flowY })
|
|
583
|
+
return
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
if (drag.active) {
|
|
587
|
+
// Node dragging with snap guides
|
|
588
|
+
const vp = instance.viewport.peek()
|
|
589
|
+
const dx = (e.clientX - drag.startX) / vp.zoom
|
|
590
|
+
const dy = (e.clientY - drag.startY) / vp.zoom
|
|
591
|
+
|
|
592
|
+
const primaryStart = drag.startPositions.get(drag.nodeId)
|
|
593
|
+
if (!primaryStart) return
|
|
594
|
+
|
|
595
|
+
const rawPos = { x: primaryStart.x + dx, y: primaryStart.y + dy }
|
|
596
|
+
const snap = instance.getSnapLines(drag.nodeId, rawPos)
|
|
597
|
+
helperLines.set({ x: snap.x, y: snap.y })
|
|
598
|
+
|
|
599
|
+
// Calculate actual delta (including snap adjustment)
|
|
600
|
+
const actualDx = snap.snappedPosition.x - primaryStart.x
|
|
601
|
+
const actualDy = snap.snappedPosition.y - primaryStart.y
|
|
602
|
+
|
|
603
|
+
// Update all dragged nodes from their starting positions
|
|
604
|
+
instance.nodes.update((nds) =>
|
|
605
|
+
nds.map((n) => {
|
|
606
|
+
const start = drag.startPositions.get(n.id)
|
|
607
|
+
if (!start) return n
|
|
608
|
+
return {
|
|
609
|
+
...n,
|
|
610
|
+
position: { x: start.x + actualDx, y: start.y + actualDy },
|
|
611
|
+
}
|
|
612
|
+
}),
|
|
613
|
+
)
|
|
614
|
+
return
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
if (conn.active) {
|
|
618
|
+
// Connection drawing — convert screen to flow coordinates
|
|
619
|
+
const container = e.currentTarget as HTMLElement
|
|
620
|
+
const rect = container.getBoundingClientRect()
|
|
621
|
+
const vp = instance.viewport.peek()
|
|
622
|
+
const flowX = (e.clientX - rect.left - vp.x) / vp.zoom
|
|
623
|
+
const flowY = (e.clientY - rect.top - vp.y) / vp.zoom
|
|
624
|
+
|
|
625
|
+
connectionState.set({
|
|
626
|
+
...conn,
|
|
627
|
+
currentX: flowX,
|
|
628
|
+
currentY: flowY,
|
|
629
|
+
})
|
|
630
|
+
return
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
if (isPanning) {
|
|
634
|
+
const dx = e.clientX - panStartX
|
|
635
|
+
const dy = e.clientY - panStartY
|
|
636
|
+
instance.viewport.set({
|
|
637
|
+
...instance.viewport.peek(),
|
|
638
|
+
x: panStartVpX + dx,
|
|
639
|
+
y: panStartVpY + dy,
|
|
640
|
+
})
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const handlePointerUp = (e: PointerEvent) => {
|
|
645
|
+
const drag = dragState.peek()
|
|
646
|
+
const conn = connectionState.peek()
|
|
647
|
+
const sel = selectionBox.peek()
|
|
648
|
+
|
|
649
|
+
if (sel.active) {
|
|
650
|
+
// Select all nodes within the selection rectangle
|
|
651
|
+
const minX = Math.min(sel.startX, sel.currentX)
|
|
652
|
+
const minY = Math.min(sel.startY, sel.currentY)
|
|
653
|
+
const maxX = Math.max(sel.startX, sel.currentX)
|
|
654
|
+
const maxY = Math.max(sel.startY, sel.currentY)
|
|
655
|
+
|
|
656
|
+
instance.clearSelection()
|
|
657
|
+
for (const node of instance.nodes.peek()) {
|
|
658
|
+
const w = node.width ?? 150
|
|
659
|
+
const h = node.height ?? 40
|
|
660
|
+
const nx = node.position.x
|
|
661
|
+
const ny = node.position.y
|
|
662
|
+
// Node is within box if any part overlaps
|
|
663
|
+
if (nx + w > minX && nx < maxX && ny + h > minY && ny < maxY) {
|
|
664
|
+
instance.selectNode(node.id, true)
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
selectionBox.set({ ...emptySelectionBox })
|
|
669
|
+
return
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
if (drag.active) {
|
|
673
|
+
const node = instance.getNode(drag.nodeId)
|
|
674
|
+
if (node) instance._emit.nodeDragEnd(node)
|
|
675
|
+
dragState.set({ ...emptyDrag })
|
|
676
|
+
helperLines.set({ x: null, y: null })
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
if (conn.active) {
|
|
680
|
+
// Check if we released over a handle target
|
|
681
|
+
const target = e.target as HTMLElement
|
|
682
|
+
const handle = target.closest('.pyreon-flow-handle')
|
|
683
|
+
if (handle) {
|
|
684
|
+
const targetNodeId =
|
|
685
|
+
handle.closest('.pyreon-flow-node')?.getAttribute('data-nodeid') ?? ''
|
|
686
|
+
const targetHandleId = handle.getAttribute('data-handleid') ?? 'target'
|
|
687
|
+
|
|
688
|
+
if (targetNodeId && targetNodeId !== conn.sourceNodeId) {
|
|
689
|
+
const connection: Connection = {
|
|
690
|
+
source: conn.sourceNodeId,
|
|
691
|
+
target: targetNodeId,
|
|
692
|
+
sourceHandle: conn.sourceHandleId,
|
|
693
|
+
targetHandle: targetHandleId,
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
if (instance.isValidConnection(connection)) {
|
|
697
|
+
instance.addEdge({
|
|
698
|
+
source: connection.source,
|
|
699
|
+
target: connection.target,
|
|
700
|
+
sourceHandle: connection.sourceHandle,
|
|
701
|
+
targetHandle: connection.targetHandle,
|
|
702
|
+
})
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
connectionState.set({ ...emptyConnection })
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
isPanning = false
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// ── Keyboard ───────────────────────────────────────────────────────────
|
|
714
|
+
|
|
715
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
716
|
+
if (e.key === 'Delete' || e.key === 'Backspace') {
|
|
717
|
+
const target = e.target as HTMLElement
|
|
718
|
+
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return
|
|
719
|
+
instance.pushHistory()
|
|
720
|
+
instance.deleteSelected()
|
|
721
|
+
}
|
|
722
|
+
if (e.key === 'Escape') {
|
|
723
|
+
instance.clearSelection()
|
|
724
|
+
connectionState.set({ ...emptyConnection })
|
|
725
|
+
}
|
|
726
|
+
if (e.key === 'a' && (e.metaKey || e.ctrlKey)) {
|
|
727
|
+
e.preventDefault()
|
|
728
|
+
instance.selectAll()
|
|
729
|
+
}
|
|
730
|
+
if (e.key === 'c' && (e.metaKey || e.ctrlKey)) {
|
|
731
|
+
instance.copySelected()
|
|
732
|
+
}
|
|
733
|
+
if (e.key === 'v' && (e.metaKey || e.ctrlKey)) {
|
|
734
|
+
instance.paste()
|
|
735
|
+
}
|
|
736
|
+
if (e.key === 'z' && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
|
|
737
|
+
e.preventDefault()
|
|
738
|
+
instance.undo()
|
|
739
|
+
}
|
|
740
|
+
if (e.key === 'z' && (e.metaKey || e.ctrlKey) && e.shiftKey) {
|
|
741
|
+
e.preventDefault()
|
|
742
|
+
instance.redo()
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// ── Touch support (pinch zoom) ──────────────────────────────────────────
|
|
747
|
+
|
|
748
|
+
let lastTouchDist = 0
|
|
749
|
+
let lastTouchCenter = { x: 0, y: 0 }
|
|
750
|
+
|
|
751
|
+
const handleTouchStart = (e: TouchEvent) => {
|
|
752
|
+
if (e.touches.length === 2) {
|
|
753
|
+
e.preventDefault()
|
|
754
|
+
const t1 = e.touches[0]!
|
|
755
|
+
const t2 = e.touches[1]!
|
|
756
|
+
lastTouchDist = Math.hypot(
|
|
757
|
+
t2.clientX - t1.clientX,
|
|
758
|
+
t2.clientY - t1.clientY,
|
|
759
|
+
)
|
|
760
|
+
lastTouchCenter = {
|
|
761
|
+
x: (t1.clientX + t2.clientX) / 2,
|
|
762
|
+
y: (t1.clientY + t2.clientY) / 2,
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
const handleTouchMove = (e: TouchEvent) => {
|
|
768
|
+
if (e.touches.length === 2 && instance.config.zoomable !== false) {
|
|
769
|
+
e.preventDefault()
|
|
770
|
+
const t1 = e.touches[0]!
|
|
771
|
+
const t2 = e.touches[1]!
|
|
772
|
+
const dist = Math.hypot(t2.clientX - t1.clientX, t2.clientY - t1.clientY)
|
|
773
|
+
const center = {
|
|
774
|
+
x: (t1.clientX + t2.clientX) / 2,
|
|
775
|
+
y: (t1.clientY + t2.clientY) / 2,
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
const vp = instance.viewport.peek()
|
|
779
|
+
const scaleFactor = dist / lastTouchDist
|
|
780
|
+
const newZoom = Math.min(
|
|
781
|
+
Math.max(vp.zoom * scaleFactor, instance.config.minZoom ?? 0.1),
|
|
782
|
+
instance.config.maxZoom ?? 4,
|
|
783
|
+
)
|
|
784
|
+
|
|
785
|
+
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
|
786
|
+
const mouseX = center.x - rect.left
|
|
787
|
+
const mouseY = center.y - rect.top
|
|
788
|
+
const scale = newZoom / vp.zoom
|
|
789
|
+
|
|
790
|
+
// Pan with touch center movement
|
|
791
|
+
const panDx = center.x - lastTouchCenter.x
|
|
792
|
+
const panDy = center.y - lastTouchCenter.y
|
|
793
|
+
|
|
794
|
+
instance.viewport.set({
|
|
795
|
+
x: mouseX - (mouseX - vp.x) * scale + panDx,
|
|
796
|
+
y: mouseY - (mouseY - vp.y) * scale + panDy,
|
|
797
|
+
zoom: newZoom,
|
|
798
|
+
})
|
|
799
|
+
|
|
800
|
+
lastTouchDist = dist
|
|
801
|
+
lastTouchCenter = center
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// ── Container size tracking ─────────────────────────────────────────────
|
|
806
|
+
|
|
807
|
+
let resizeObserver: ResizeObserver | null = null
|
|
808
|
+
|
|
809
|
+
const containerRef = (el: Element | null) => {
|
|
810
|
+
if (resizeObserver) {
|
|
811
|
+
resizeObserver.disconnect()
|
|
812
|
+
resizeObserver = null
|
|
813
|
+
}
|
|
814
|
+
if (!el) return
|
|
815
|
+
|
|
816
|
+
const updateSize = () => {
|
|
817
|
+
const rect = el.getBoundingClientRect()
|
|
818
|
+
instance.containerSize.set({
|
|
819
|
+
width: rect.width,
|
|
820
|
+
height: rect.height,
|
|
821
|
+
})
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
updateSize()
|
|
825
|
+
resizeObserver = new ResizeObserver(updateSize)
|
|
826
|
+
resizeObserver.observe(el)
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
const containerStyle = `position: relative; width: 100%; height: 100%; overflow: hidden; outline: none; touch-action: none; ${props.style ?? ''}`
|
|
830
|
+
|
|
831
|
+
return (
|
|
832
|
+
<div
|
|
833
|
+
ref={containerRef}
|
|
834
|
+
class={`pyreon-flow ${props.class ?? ''}`}
|
|
835
|
+
style={containerStyle}
|
|
836
|
+
tabIndex={0}
|
|
837
|
+
onWheel={handleWheel}
|
|
838
|
+
onPointerdown={handlePointerDown}
|
|
839
|
+
onPointermove={handlePointerMove}
|
|
840
|
+
onPointerup={handlePointerUp}
|
|
841
|
+
onTouchstart={handleTouchStart}
|
|
842
|
+
onTouchmove={handleTouchMove}
|
|
843
|
+
onKeydown={handleKeyDown}
|
|
844
|
+
>
|
|
845
|
+
{children}
|
|
846
|
+
{() => {
|
|
847
|
+
const vp = instance.viewport()
|
|
848
|
+
return (
|
|
849
|
+
<div
|
|
850
|
+
class="pyreon-flow-viewport"
|
|
851
|
+
style={`position: absolute; transform-origin: 0 0; transform: translate(${vp.x}px, ${vp.y}px) scale(${vp.zoom});`}
|
|
852
|
+
>
|
|
853
|
+
<EdgeLayer
|
|
854
|
+
instance={instance}
|
|
855
|
+
connectionState={() => connectionState()}
|
|
856
|
+
edgeTypes={edgeTypes}
|
|
857
|
+
/>
|
|
858
|
+
{() => {
|
|
859
|
+
const sel = selectionBox()
|
|
860
|
+
if (!sel.active) return null
|
|
861
|
+
const x = Math.min(sel.startX, sel.currentX)
|
|
862
|
+
const y = Math.min(sel.startY, sel.currentY)
|
|
863
|
+
const w = Math.abs(sel.currentX - sel.startX)
|
|
864
|
+
const h = Math.abs(sel.currentY - sel.startY)
|
|
865
|
+
return (
|
|
866
|
+
<div
|
|
867
|
+
class="pyreon-flow-selection-box"
|
|
868
|
+
style={`position: absolute; left: ${x}px; top: ${y}px; width: ${w}px; height: ${h}px; border: 1px dashed #3b82f6; background: rgba(59, 130, 246, 0.08); pointer-events: none; z-index: 10;`}
|
|
869
|
+
/>
|
|
870
|
+
)
|
|
871
|
+
}}
|
|
872
|
+
{() => {
|
|
873
|
+
const lines = helperLines()
|
|
874
|
+
if (!lines.x && !lines.y) return null
|
|
875
|
+
return (
|
|
876
|
+
<svg
|
|
877
|
+
role="img"
|
|
878
|
+
aria-label="helper lines"
|
|
879
|
+
style="position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; overflow: visible; z-index: 5;"
|
|
880
|
+
>
|
|
881
|
+
{lines.x !== null && (
|
|
882
|
+
<line
|
|
883
|
+
x1={String(lines.x)}
|
|
884
|
+
y1="-10000"
|
|
885
|
+
x2={String(lines.x)}
|
|
886
|
+
y2="10000"
|
|
887
|
+
stroke="#3b82f6"
|
|
888
|
+
stroke-width="0.5"
|
|
889
|
+
stroke-dasharray="4,4"
|
|
890
|
+
/>
|
|
891
|
+
)}
|
|
892
|
+
{lines.y !== null && (
|
|
893
|
+
<line
|
|
894
|
+
x1="-10000"
|
|
895
|
+
y1={String(lines.y)}
|
|
896
|
+
x2="10000"
|
|
897
|
+
y2={String(lines.y)}
|
|
898
|
+
stroke="#3b82f6"
|
|
899
|
+
stroke-width="0.5"
|
|
900
|
+
stroke-dasharray="4,4"
|
|
901
|
+
/>
|
|
902
|
+
)}
|
|
903
|
+
</svg>
|
|
904
|
+
)
|
|
905
|
+
}}
|
|
906
|
+
<NodeLayer
|
|
907
|
+
instance={instance}
|
|
908
|
+
nodeTypes={nodeTypes}
|
|
909
|
+
draggingNodeId={draggingNodeId}
|
|
910
|
+
onNodePointerDown={handleNodePointerDown}
|
|
911
|
+
onHandlePointerDown={handleHandlePointerDown}
|
|
912
|
+
/>
|
|
913
|
+
</div>
|
|
914
|
+
)
|
|
915
|
+
}}
|
|
916
|
+
</div>
|
|
917
|
+
)
|
|
918
|
+
}
|