@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.
@@ -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
+ }