@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,42 @@
1
+ import type { VNodeChild } from '@pyreon/core'
2
+ import type { HandleProps } from '../types'
3
+
4
+ const positionOffset: Record<string, string> = {
5
+ top: 'top: -4px; left: 50%; transform: translateX(-50%);',
6
+ right: 'right: -4px; top: 50%; transform: translateY(-50%);',
7
+ bottom: 'bottom: -4px; left: 50%; transform: translateX(-50%);',
8
+ left: 'left: -4px; top: 50%; transform: translateY(-50%);',
9
+ }
10
+
11
+ /**
12
+ * Connection handle — attachment point on a node where edges connect.
13
+ * Place inside custom node components.
14
+ *
15
+ * @example
16
+ * ```tsx
17
+ * function CustomNode({ data }: NodeComponentProps) {
18
+ * return (
19
+ * <div class="custom-node">
20
+ * <Handle type="target" position={Position.Left} />
21
+ * <span>{data.label}</span>
22
+ * <Handle type="source" position={Position.Right} />
23
+ * </div>
24
+ * )
25
+ * }
26
+ * ```
27
+ */
28
+ export function Handle(props: HandleProps): VNodeChild {
29
+ const { type, position, id, style = '' } = props
30
+ const posStyle = positionOffset[position] ?? positionOffset.bottom
31
+ const baseStyle = `position: absolute; ${posStyle} width: 8px; height: 8px; background: #555; border: 2px solid white; border-radius: 50%; cursor: crosshair; z-index: 1; ${style}`
32
+
33
+ return (
34
+ <div
35
+ class={`pyreon-flow-handle pyreon-flow-handle-${type} ${props.class ?? ''}`}
36
+ style={baseStyle}
37
+ data-handletype={type}
38
+ data-handleid={id ?? type}
39
+ data-handleposition={position}
40
+ />
41
+ )
42
+ }
@@ -0,0 +1,136 @@
1
+ import type { VNodeChild } from '@pyreon/core'
2
+ import type { FlowInstance, MiniMapProps } from '../types'
3
+
4
+ /**
5
+ * Miniature overview of the flow diagram showing all nodes
6
+ * and the current viewport position. Click to navigate.
7
+ *
8
+ * @example
9
+ * ```tsx
10
+ * <Flow instance={flow}>
11
+ * <MiniMap nodeColor={(n) => n.type === 'error' ? 'red' : '#ddd'} />
12
+ * </Flow>
13
+ * ```
14
+ */
15
+ export function MiniMap(
16
+ props: MiniMapProps & { instance?: FlowInstance },
17
+ ): VNodeChild {
18
+ const {
19
+ width = 200,
20
+ height = 150,
21
+ nodeColor = '#e2e8f0',
22
+ maskColor = 'rgba(0, 0, 0, 0.08)',
23
+ instance,
24
+ } = props
25
+
26
+ if (!instance) return null
27
+
28
+ const containerStyle = `position: absolute; bottom: 10px; right: 10px; width: ${width}px; height: ${height}px; border: 1px solid #ddd; background: white; border-radius: 4px; overflow: hidden; z-index: 5; cursor: pointer;`
29
+
30
+ return () => {
31
+ const nodes = instance.nodes()
32
+ if (nodes.length === 0)
33
+ return <div class="pyreon-flow-minimap" style={containerStyle} />
34
+
35
+ // Calculate graph bounds
36
+ let minX = Number.POSITIVE_INFINITY
37
+ let minY = Number.POSITIVE_INFINITY
38
+ let maxX = Number.NEGATIVE_INFINITY
39
+ let maxY = Number.NEGATIVE_INFINITY
40
+
41
+ for (const node of nodes) {
42
+ const w = node.width ?? 150
43
+ const h = node.height ?? 40
44
+ minX = Math.min(minX, node.position.x)
45
+ minY = Math.min(minY, node.position.y)
46
+ maxX = Math.max(maxX, node.position.x + w)
47
+ maxY = Math.max(maxY, node.position.y + h)
48
+ }
49
+
50
+ const padding = 40
51
+ const graphW = maxX - minX + padding * 2
52
+ const graphH = maxY - minY + padding * 2
53
+ const scale = Math.min(width / graphW, height / graphH)
54
+
55
+ // Viewport rectangle in minimap coordinates
56
+ const vp = instance.viewport()
57
+ const cs = instance.containerSize()
58
+ const vpLeft = (-vp.x / vp.zoom - minX + padding) * scale
59
+ const vpTop = (-vp.y / vp.zoom - minY + padding) * scale
60
+ const vpWidth = (cs.width / vp.zoom) * scale
61
+ const vpHeight = (cs.height / vp.zoom) * scale
62
+
63
+ const handleClick = (e: MouseEvent) => {
64
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
65
+ const clickX = e.clientX - rect.left
66
+ const clickY = e.clientY - rect.top
67
+
68
+ // Convert minimap click to flow coordinates
69
+ const flowX = clickX / scale + minX - padding
70
+ const flowY = clickY / scale + minY - padding
71
+
72
+ // Center viewport on clicked point
73
+ instance.viewport.set({
74
+ ...vp,
75
+ x: -(flowX * vp.zoom) + cs.width / 2,
76
+ y: -(flowY * vp.zoom) + cs.height / 2,
77
+ })
78
+ }
79
+
80
+ return (
81
+ <div
82
+ class="pyreon-flow-minimap"
83
+ style={containerStyle}
84
+ onClick={handleClick}
85
+ >
86
+ <svg
87
+ role="img"
88
+ aria-label="minimap"
89
+ width={String(width)}
90
+ height={String(height)}
91
+ >
92
+ {/* Mask outside viewport */}
93
+ <rect
94
+ width={String(width)}
95
+ height={String(height)}
96
+ fill={maskColor}
97
+ />
98
+
99
+ {/* Nodes */}
100
+ {nodes.map((node) => {
101
+ const w = (node.width ?? 150) * scale
102
+ const h = (node.height ?? 40) * scale
103
+ const x = (node.position.x - minX + padding) * scale
104
+ const y = (node.position.y - minY + padding) * scale
105
+ const color =
106
+ typeof nodeColor === 'function' ? nodeColor(node) : nodeColor
107
+
108
+ return (
109
+ <rect
110
+ key={node.id}
111
+ x={String(x)}
112
+ y={String(y)}
113
+ width={String(w)}
114
+ height={String(h)}
115
+ fill={color}
116
+ rx="2"
117
+ />
118
+ )
119
+ })}
120
+
121
+ {/* Viewport indicator */}
122
+ <rect
123
+ x={String(Math.max(0, vpLeft))}
124
+ y={String(Math.max(0, vpTop))}
125
+ width={String(Math.min(vpWidth, width))}
126
+ height={String(Math.min(vpHeight, height))}
127
+ fill="none"
128
+ stroke="#3b82f6"
129
+ stroke-width="1.5"
130
+ rx="2"
131
+ />
132
+ </svg>
133
+ </div>
134
+ )
135
+ }
136
+ }
@@ -0,0 +1,169 @@
1
+ import type { VNodeChild } from '@pyreon/core'
2
+ import type { FlowInstance } from '../types'
3
+
4
+ export interface NodeResizerProps {
5
+ nodeId: string
6
+ instance: FlowInstance
7
+ /** Minimum width — default: 50 */
8
+ minWidth?: number
9
+ /** Minimum height — default: 30 */
10
+ minHeight?: number
11
+ /** Handle size in px — default: 8 */
12
+ handleSize?: number
13
+ /** Also show edge (non-corner) resize handles — default: false */
14
+ showEdgeHandles?: boolean
15
+ }
16
+
17
+ type ResizeDirection = 'nw' | 'ne' | 'sw' | 'se' | 'n' | 's' | 'e' | 'w'
18
+
19
+ const directionCursors: Record<ResizeDirection, string> = {
20
+ nw: 'nw-resize',
21
+ ne: 'ne-resize',
22
+ sw: 'sw-resize',
23
+ se: 'se-resize',
24
+ n: 'n-resize',
25
+ s: 's-resize',
26
+ e: 'e-resize',
27
+ w: 'w-resize',
28
+ }
29
+
30
+ const directionPositions: Record<ResizeDirection, string> = {
31
+ nw: 'top: -4px; left: -4px;',
32
+ ne: 'top: -4px; right: -4px;',
33
+ sw: 'bottom: -4px; left: -4px;',
34
+ se: 'bottom: -4px; right: -4px;',
35
+ n: 'top: -4px; left: 50%; transform: translateX(-50%);',
36
+ s: 'bottom: -4px; left: 50%; transform: translateX(-50%);',
37
+ e: 'right: -4px; top: 50%; transform: translateY(-50%);',
38
+ w: 'left: -4px; top: 50%; transform: translateY(-50%);',
39
+ }
40
+
41
+ /**
42
+ * Node resize handles. Place inside a custom node component
43
+ * to allow users to resize the node by dragging corners or edges.
44
+ *
45
+ * Uses pointer capture for clean event handling — no document listener leaks.
46
+ *
47
+ * @example
48
+ * ```tsx
49
+ * function ResizableNode({ id, data, selected }: NodeComponentProps) {
50
+ * return (
51
+ * <div style="min-width: 100px; min-height: 50px; position: relative;">
52
+ * {data.label}
53
+ * <NodeResizer nodeId={id} instance={flow} />
54
+ * </div>
55
+ * )
56
+ * }
57
+ * ```
58
+ */
59
+ export function NodeResizer(props: NodeResizerProps): VNodeChild {
60
+ const {
61
+ nodeId,
62
+ instance,
63
+ minWidth = 50,
64
+ minHeight = 30,
65
+ handleSize = 8,
66
+ showEdgeHandles = false,
67
+ } = props
68
+
69
+ const directions: ResizeDirection[] = showEdgeHandles
70
+ ? ['nw', 'ne', 'sw', 'se', 'n', 's', 'e', 'w']
71
+ : ['nw', 'ne', 'sw', 'se']
72
+
73
+ const createHandler = (dir: ResizeDirection) => {
74
+ let startX = 0
75
+ let startY = 0
76
+ let startWidth = 0
77
+ let startHeight = 0
78
+ let startNodeX = 0
79
+ let startNodeY = 0
80
+ let zoomAtStart = 1
81
+
82
+ const onPointerDown = (e: PointerEvent) => {
83
+ e.stopPropagation()
84
+ e.preventDefault()
85
+
86
+ const node = instance.getNode(nodeId)
87
+ if (!node) return
88
+
89
+ startX = e.clientX
90
+ startY = e.clientY
91
+ startWidth = node.width ?? 150
92
+ startHeight = node.height ?? 40
93
+ startNodeX = node.position.x
94
+ startNodeY = node.position.y
95
+ zoomAtStart = instance.viewport.peek().zoom
96
+
97
+ // Use pointer capture — clean, no leaks
98
+ const el = e.currentTarget as HTMLElement
99
+ el.setPointerCapture(e.pointerId)
100
+ }
101
+
102
+ const onPointerMove = (e: PointerEvent) => {
103
+ const el = e.currentTarget as HTMLElement
104
+ if (!el.hasPointerCapture(e.pointerId)) return
105
+
106
+ const dx = (e.clientX - startX) / zoomAtStart
107
+ const dy = (e.clientY - startY) / zoomAtStart
108
+
109
+ let newW = startWidth
110
+ let newH = startHeight
111
+ let newX = startNodeX
112
+ let newY = startNodeY
113
+
114
+ // Horizontal
115
+ if (dir === 'e' || dir === 'se' || dir === 'ne') {
116
+ newW = Math.max(minWidth, startWidth + dx)
117
+ }
118
+ if (dir === 'w' || dir === 'sw' || dir === 'nw') {
119
+ newW = Math.max(minWidth, startWidth - dx)
120
+ newX = startNodeX + startWidth - newW
121
+ }
122
+
123
+ // Vertical
124
+ if (dir === 's' || dir === 'se' || dir === 'sw') {
125
+ newH = Math.max(minHeight, startHeight + dy)
126
+ }
127
+ if (dir === 'n' || dir === 'ne' || dir === 'nw') {
128
+ newH = Math.max(minHeight, startHeight - dy)
129
+ newY = startNodeY + startHeight - newH
130
+ }
131
+
132
+ instance.updateNode(nodeId, {
133
+ width: newW,
134
+ height: newH,
135
+ position: { x: newX, y: newY },
136
+ })
137
+ }
138
+
139
+ const onPointerUp = (e: PointerEvent) => {
140
+ const el = e.currentTarget as HTMLElement
141
+ if (el.hasPointerCapture(e.pointerId)) {
142
+ el.releasePointerCapture(e.pointerId)
143
+ }
144
+ }
145
+
146
+ return { onPointerDown, onPointerMove, onPointerUp }
147
+ }
148
+
149
+ const size = `${handleSize}px`
150
+ const baseStyle = `position: absolute; width: ${size}; height: ${size}; background: white; border: 1.5px solid #3b82f6; border-radius: 2px; z-index: 2;`
151
+
152
+ return (
153
+ <>
154
+ {directions.map((dir) => {
155
+ const handler = createHandler(dir)
156
+ return (
157
+ <div
158
+ key={dir}
159
+ class={`pyreon-flow-resizer pyreon-flow-resizer-${dir}`}
160
+ style={`${baseStyle} ${directionPositions[dir]} cursor: ${directionCursors[dir]};`}
161
+ onPointerdown={handler.onPointerDown}
162
+ onPointermove={handler.onPointerMove}
163
+ onPointerup={handler.onPointerUp}
164
+ />
165
+ )
166
+ })}
167
+ </>
168
+ )
169
+ }
@@ -0,0 +1,74 @@
1
+ import type { VNodeChild } from '@pyreon/core'
2
+
3
+ export interface NodeToolbarProps {
4
+ /** Position relative to node — default: 'top' */
5
+ position?: 'top' | 'bottom' | 'left' | 'right'
6
+ /** Offset from node in px — default: 8 */
7
+ offset?: number
8
+ /** Only show when node is selected — default: true */
9
+ showOnSelect?: boolean
10
+ /** Whether the node is currently selected */
11
+ selected?: boolean
12
+ style?: string
13
+ class?: string
14
+ children?: VNodeChild
15
+ }
16
+
17
+ const positionStyles: Record<string, string> = {
18
+ top: 'bottom: 100%; left: 50%; transform: translateX(-50%);',
19
+ bottom: 'top: 100%; left: 50%; transform: translateX(-50%);',
20
+ left: 'right: 100%; top: 50%; transform: translateY(-50%);',
21
+ right: 'left: 100%; top: 50%; transform: translateY(-50%);',
22
+ }
23
+
24
+ /**
25
+ * Floating toolbar that appears near a node, typically when selected.
26
+ * Place inside a custom node component.
27
+ *
28
+ * @example
29
+ * ```tsx
30
+ * function EditableNode({ id, data, selected }: NodeComponentProps) {
31
+ * return (
32
+ * <div class="node">
33
+ * {data.label}
34
+ * <NodeToolbar selected={selected}>
35
+ * <button onClick={() => duplicate(id)}>Duplicate</button>
36
+ * <button onClick={() => remove(id)}>Delete</button>
37
+ * </NodeToolbar>
38
+ * </div>
39
+ * )
40
+ * }
41
+ * ```
42
+ */
43
+ export function NodeToolbar(props: NodeToolbarProps): VNodeChild {
44
+ const {
45
+ position = 'top',
46
+ offset = 8,
47
+ showOnSelect = true,
48
+ selected = false,
49
+ children,
50
+ } = props
51
+
52
+ if (showOnSelect && !selected) return null
53
+
54
+ const posStyle = positionStyles[position] ?? positionStyles.top
55
+ const marginProp =
56
+ position === 'top'
57
+ ? `margin-bottom: ${offset}px;`
58
+ : position === 'bottom'
59
+ ? `margin-top: ${offset}px;`
60
+ : position === 'left'
61
+ ? `margin-right: ${offset}px;`
62
+ : `margin-left: ${offset}px;`
63
+
64
+ const baseStyle = `position: absolute; ${posStyle} ${marginProp} z-index: 10; display: flex; gap: 4px; background: white; border: 1px solid #ddd; border-radius: 6px; padding: 4px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); ${props.style ?? ''}`
65
+
66
+ return (
67
+ <div
68
+ class={`pyreon-flow-node-toolbar ${props.class ?? ''}`}
69
+ style={baseStyle}
70
+ >
71
+ {children}
72
+ </div>
73
+ )
74
+ }
@@ -0,0 +1,33 @@
1
+ import type { VNodeChild } from '@pyreon/core'
2
+ import type { PanelProps } from '../types'
3
+
4
+ const positionStyles: Record<string, string> = {
5
+ 'top-left': 'top: 10px; left: 10px;',
6
+ 'top-right': 'top: 10px; right: 10px;',
7
+ 'bottom-left': 'bottom: 10px; left: 10px;',
8
+ 'bottom-right': 'bottom: 10px; right: 10px;',
9
+ }
10
+
11
+ /**
12
+ * Positioned overlay panel for custom content inside the flow canvas.
13
+ *
14
+ * @example
15
+ * ```tsx
16
+ * <Flow instance={flow}>
17
+ * <Panel position="top-right">
18
+ * <SearchBar />
19
+ * </Panel>
20
+ * </Flow>
21
+ * ```
22
+ */
23
+ export function Panel(props: PanelProps): VNodeChild {
24
+ const { position = 'top-left', style = '', children } = props
25
+ const posStyle = positionStyles[position] ?? positionStyles['top-left']
26
+ const baseStyle = `position: absolute; ${posStyle} z-index: 5; ${style}`
27
+
28
+ return (
29
+ <div class={`pyreon-flow-panel ${props.class ?? ''}`} style={baseStyle}>
30
+ {children}
31
+ </div>
32
+ )
33
+ }