@multicomma/arkcn 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.
Files changed (237) hide show
  1. package/README.md +78 -0
  2. package/bin/arkcn.mjs +78 -0
  3. package/cli/commands.mjs +251 -0
  4. package/cli/project.mjs +168 -0
  5. package/cli/registry.mjs +50 -0
  6. package/cli/ui.mjs +46 -0
  7. package/mcp/server.mjs +149 -0
  8. package/package.json +201 -0
  9. package/registry/base.css +680 -0
  10. package/registry/index.json +1833 -0
  11. package/registry/items/accordion.json +18 -0
  12. package/registry/items/activity-feed.json +18 -0
  13. package/registry/items/alert-dialog.json +18 -0
  14. package/registry/items/alert.json +17 -0
  15. package/registry/items/angle-slider.json +17 -0
  16. package/registry/items/app-shell.json +28 -0
  17. package/registry/items/aspect-ratio.json +13 -0
  18. package/registry/items/attachment.json +19 -0
  19. package/registry/items/avatar.json +17 -0
  20. package/registry/items/badge.json +18 -0
  21. package/registry/items/breadcrumb.json +18 -0
  22. package/registry/items/bubble.json +18 -0
  23. package/registry/items/button-group.json +19 -0
  24. package/registry/items/button.json +18 -0
  25. package/registry/items/calendar.json +19 -0
  26. package/registry/items/canvas.json +24 -0
  27. package/registry/items/card.json +15 -0
  28. package/registry/items/carousel.json +19 -0
  29. package/registry/items/cascader.json +23 -0
  30. package/registry/items/chart.json +17 -0
  31. package/registry/items/chat.json +23 -0
  32. package/registry/items/checkbox.json +18 -0
  33. package/registry/items/clipboard.json +19 -0
  34. package/registry/items/collapsible.json +15 -0
  35. package/registry/items/color-picker.json +19 -0
  36. package/registry/items/combobox.json +20 -0
  37. package/registry/items/command.json +21 -0
  38. package/registry/items/comment-thread.json +25 -0
  39. package/registry/items/context-menu.json +18 -0
  40. package/registry/items/controllable.json +14 -0
  41. package/registry/items/data-grid.json +24 -0
  42. package/registry/items/data-table-adapter.json +14 -0
  43. package/registry/items/data-table.json +32 -0
  44. package/registry/items/date-input.json +17 -0
  45. package/registry/items/dialog.json +19 -0
  46. package/registry/items/direction.json +13 -0
  47. package/registry/items/drag-preview.json +16 -0
  48. package/registry/items/drawer.json +18 -0
  49. package/registry/items/dropdown-menu.json +18 -0
  50. package/registry/items/editable.json +17 -0
  51. package/registry/items/empty.json +17 -0
  52. package/registry/items/field.json +19 -0
  53. package/registry/items/file-upload.json +19 -0
  54. package/registry/items/floating-panel.json +19 -0
  55. package/registry/items/floating-toolbar.json +16 -0
  56. package/registry/items/form.json +14 -0
  57. package/registry/items/format.json +15 -0
  58. package/registry/items/gantt.json +22 -0
  59. package/registry/items/history.json +14 -0
  60. package/registry/items/hotkeys.json +18 -0
  61. package/registry/items/hover-card.json +17 -0
  62. package/registry/items/image-cropper.json +17 -0
  63. package/registry/items/input-group.json +20 -0
  64. package/registry/items/input-otp.json +18 -0
  65. package/registry/items/input.json +15 -0
  66. package/registry/items/item.json +19 -0
  67. package/registry/items/json-tree-view.json +17 -0
  68. package/registry/items/kanban.json +26 -0
  69. package/registry/items/kbd.json +15 -0
  70. package/registry/items/label.json +15 -0
  71. package/registry/items/live-region.json +16 -0
  72. package/registry/items/marker.json +18 -0
  73. package/registry/items/marquee.json +17 -0
  74. package/registry/items/menubar.json +19 -0
  75. package/registry/items/message.json +15 -0
  76. package/registry/items/navigation-menu.json +19 -0
  77. package/registry/items/node-graph.json +22 -0
  78. package/registry/items/number-input.json +18 -0
  79. package/registry/items/pagination.json +18 -0
  80. package/registry/items/password-input.json +18 -0
  81. package/registry/items/popconfirm.json +20 -0
  82. package/registry/items/popover.json +18 -0
  83. package/registry/items/progress.json +17 -0
  84. package/registry/items/qr-code.json +17 -0
  85. package/registry/items/query-builder.json +23 -0
  86. package/registry/items/radio-group.json +17 -0
  87. package/registry/items/rating-group.json +18 -0
  88. package/registry/items/resizable.json +17 -0
  89. package/registry/items/rich-text-editor.json +37 -0
  90. package/registry/items/row-layout.json +15 -0
  91. package/registry/items/scheduler.json +23 -0
  92. package/registry/items/scroll-area.json +17 -0
  93. package/registry/items/segment-group.json +17 -0
  94. package/registry/items/select.json +19 -0
  95. package/registry/items/separator.json +15 -0
  96. package/registry/items/sheet.json +19 -0
  97. package/registry/items/sidebar.json +27 -0
  98. package/registry/items/signature-pad.json +19 -0
  99. package/registry/items/skeleton.json +15 -0
  100. package/registry/items/slider.json +17 -0
  101. package/registry/items/sonner.json +17 -0
  102. package/registry/items/spinner.json +17 -0
  103. package/registry/items/steps.json +19 -0
  104. package/registry/items/swap.json +17 -0
  105. package/registry/items/switch.json +17 -0
  106. package/registry/items/table.json +16 -0
  107. package/registry/items/tabs.json +18 -0
  108. package/registry/items/tags-input.json +18 -0
  109. package/registry/items/textarea.json +15 -0
  110. package/registry/items/time.json +14 -0
  111. package/registry/items/timer.json +18 -0
  112. package/registry/items/toc.json +17 -0
  113. package/registry/items/toggle-group.json +19 -0
  114. package/registry/items/toggle.json +18 -0
  115. package/registry/items/tooltip.json +17 -0
  116. package/registry/items/tour.json +19 -0
  117. package/registry/items/transfer-list.json +24 -0
  118. package/registry/items/tree-select.json +24 -0
  119. package/registry/items/tree-view.json +18 -0
  120. package/registry/items/use-mobile.json +13 -0
  121. package/registry/items/utils.json +17 -0
  122. package/registry/items/virtual-list.json +16 -0
  123. package/src/components/ui/accordion.tsx +111 -0
  124. package/src/components/ui/activity-feed.tsx +228 -0
  125. package/src/components/ui/alert-dialog.tsx +204 -0
  126. package/src/components/ui/alert.tsx +55 -0
  127. package/src/components/ui/angle-slider.tsx +117 -0
  128. package/src/components/ui/app-shell.tsx +985 -0
  129. package/src/components/ui/aspect-ratio.tsx +35 -0
  130. package/src/components/ui/attachment.tsx +193 -0
  131. package/src/components/ui/avatar.tsx +94 -0
  132. package/src/components/ui/badge.tsx +45 -0
  133. package/src/components/ui/breadcrumb.tsx +93 -0
  134. package/src/components/ui/bubble.tsx +119 -0
  135. package/src/components/ui/button-group.tsx +79 -0
  136. package/src/components/ui/button.tsx +65 -0
  137. package/src/components/ui/calendar.tsx +172 -0
  138. package/src/components/ui/canvas.tsx +723 -0
  139. package/src/components/ui/card.tsx +69 -0
  140. package/src/components/ui/carousel.tsx +228 -0
  141. package/src/components/ui/cascader.tsx +754 -0
  142. package/src/components/ui/chart.tsx +303 -0
  143. package/src/components/ui/chat.tsx +544 -0
  144. package/src/components/ui/checkbox.tsx +94 -0
  145. package/src/components/ui/clipboard.tsx +94 -0
  146. package/src/components/ui/collapsible.tsx +32 -0
  147. package/src/components/ui/color-picker.tsx +441 -0
  148. package/src/components/ui/combobox.tsx +400 -0
  149. package/src/components/ui/command.tsx +292 -0
  150. package/src/components/ui/comment-thread.tsx +923 -0
  151. package/src/components/ui/context-menu.tsx +331 -0
  152. package/src/components/ui/data-grid.tsx +1227 -0
  153. package/src/components/ui/data-table.tsx +993 -0
  154. package/src/components/ui/date-input.tsx +103 -0
  155. package/src/components/ui/dialog.tsx +159 -0
  156. package/src/components/ui/direction.tsx +32 -0
  157. package/src/components/ui/drawer.tsx +200 -0
  158. package/src/components/ui/dropdown-menu.tsx +329 -0
  159. package/src/components/ui/editable.tsx +90 -0
  160. package/src/components/ui/empty.tsx +86 -0
  161. package/src/components/ui/field.tsx +218 -0
  162. package/src/components/ui/file-upload.tsx +204 -0
  163. package/src/components/ui/floating-panel.tsx +199 -0
  164. package/src/components/ui/floating-toolbar.tsx +79 -0
  165. package/src/components/ui/format.tsx +22 -0
  166. package/src/components/ui/gantt.tsx +950 -0
  167. package/src/components/ui/hotkeys.tsx +340 -0
  168. package/src/components/ui/hover-card.tsx +83 -0
  169. package/src/components/ui/image-cropper.tsx +100 -0
  170. package/src/components/ui/input-group.tsx +133 -0
  171. package/src/components/ui/input-otp.tsx +82 -0
  172. package/src/components/ui/input.tsx +18 -0
  173. package/src/components/ui/item.tsx +174 -0
  174. package/src/components/ui/json-tree-view.tsx +37 -0
  175. package/src/components/ui/kanban.tsx +884 -0
  176. package/src/components/ui/kbd.tsx +20 -0
  177. package/src/components/ui/label.tsx +30 -0
  178. package/src/components/ui/live-region.tsx +54 -0
  179. package/src/components/ui/marker.tsx +66 -0
  180. package/src/components/ui/marquee.tsx +61 -0
  181. package/src/components/ui/menubar.tsx +456 -0
  182. package/src/components/ui/message.tsx +74 -0
  183. package/src/components/ui/navigation-menu.tsx +202 -0
  184. package/src/components/ui/node-graph.tsx +1723 -0
  185. package/src/components/ui/number-input.tsx +148 -0
  186. package/src/components/ui/pagination.tsx +88 -0
  187. package/src/components/ui/password-input.tsx +104 -0
  188. package/src/components/ui/popconfirm.tsx +149 -0
  189. package/src/components/ui/popover.tsx +126 -0
  190. package/src/components/ui/progress.tsx +114 -0
  191. package/src/components/ui/qr-code.tsx +51 -0
  192. package/src/components/ui/query-builder.tsx +938 -0
  193. package/src/components/ui/radio-group.tsx +108 -0
  194. package/src/components/ui/rating-group.tsx +91 -0
  195. package/src/components/ui/resizable.tsx +41 -0
  196. package/src/components/ui/rich-text-editor.tsx +845 -0
  197. package/src/components/ui/scheduler.tsx +1164 -0
  198. package/src/components/ui/scroll-area.tsx +82 -0
  199. package/src/components/ui/segment-group.tsx +95 -0
  200. package/src/components/ui/select.tsx +354 -0
  201. package/src/components/ui/separator.tsx +32 -0
  202. package/src/components/ui/sheet.tsx +130 -0
  203. package/src/components/ui/sidebar.tsx +665 -0
  204. package/src/components/ui/signature-pad.tsx +112 -0
  205. package/src/components/ui/skeleton.tsx +7 -0
  206. package/src/components/ui/slider.tsx +183 -0
  207. package/src/components/ui/sonner.tsx +37 -0
  208. package/src/components/ui/spinner.tsx +16 -0
  209. package/src/components/ui/steps.tsx +152 -0
  210. package/src/components/ui/swap.tsx +33 -0
  211. package/src/components/ui/switch.tsx +83 -0
  212. package/src/components/ui/table.tsx +72 -0
  213. package/src/components/ui/tabs.tsx +100 -0
  214. package/src/components/ui/tags-input.tsx +153 -0
  215. package/src/components/ui/textarea.tsx +17 -0
  216. package/src/components/ui/timer.tsx +67 -0
  217. package/src/components/ui/toc.tsx +64 -0
  218. package/src/components/ui/toggle-group.tsx +89 -0
  219. package/src/components/ui/toggle.tsx +46 -0
  220. package/src/components/ui/tooltip.tsx +113 -0
  221. package/src/components/ui/tour.tsx +168 -0
  222. package/src/components/ui/transfer-list.tsx +700 -0
  223. package/src/components/ui/tree-select.tsx +638 -0
  224. package/src/components/ui/tree-view.tsx +217 -0
  225. package/src/components/ui/virtual-list.tsx +343 -0
  226. package/src/hooks/use-mobile.ts +19 -0
  227. package/src/lib/controllable.ts +31 -0
  228. package/src/lib/data-table-adapter.ts +67 -0
  229. package/src/lib/drag-preview.ts +39 -0
  230. package/src/lib/form.ts +39 -0
  231. package/src/lib/history.ts +55 -0
  232. package/src/lib/row-layout.ts +81 -0
  233. package/src/lib/table.ts +483 -0
  234. package/src/lib/time.ts +37 -0
  235. package/src/lib/utils.ts +6 -0
  236. package/src/styles/arkcn.css +764 -0
  237. package/src/styles/index.css +3 -0
@@ -0,0 +1,1723 @@
1
+ import * as React from "react"
2
+ import { ark } from "@ark-ui/react"
3
+ import { cva, type VariantProps } from "class-variance-authority"
4
+ import { MaximizeIcon, MinusIcon, PlusIcon } from "lucide-react"
5
+ import { Button } from "@/components/ui/button"
6
+ import { cn } from "@/lib/utils"
7
+ import { LiveRegion, useLiveRegion } from "@/components/ui/live-region"
8
+
9
+ /**
10
+ * NodeGraph — a compositional, data-agnostic node editor in the spirit of
11
+ * visual scripting graphs. The consumer owns nodes and edges; the graph owns
12
+ * only the viewport (pan/zoom), the selection, and in-flight interactions
13
+ * (dragging nodes, drawing a connection, marquee selection). Everything else
14
+ * is reported as an intent: `onNodesMove`, `onConnect`, `onConnectEnd`,
15
+ * `onDisconnect` (alt+click on a pin), `onDelete`, `onSelectionChange`, and
16
+ * `onPane/Node/Edge/PortContextMenu` (right-click without a drag).
17
+ *
18
+ * Anatomy:
19
+ * NodeGraph
20
+ * NodeGraphViewport pan/zoom pane, keyboard target
21
+ * NodeGraphBackground dotted/lined grid that follows the viewport
22
+ * NodeGraphSurface the transformed layer
23
+ * NodeGraphEdges svg layer with NodeGraphEdge children
24
+ * NodeGraphConnectionLine the edge being drawn
25
+ * NodeGraphNode* absolutely positioned nodes
26
+ * NodeGraphNodeHeader/Title/Subtitle/Body/Inputs/Outputs/Footer
27
+ * NodeGraphPort > NodeGraphPortPin + NodeGraphPortLabel
28
+ * NodeGraphSelectionBox marquee
29
+ * NodeGraphControls zoom in/out/fit triggers + NodeGraphZoomValue
30
+ * NodeGraphMinimap
31
+ * NodeGraphEmpty
32
+ */
33
+
34
+ type XY = { x: number; y: number }
35
+ type NodeGraphViewportState = { x: number; y: number; zoom: number }
36
+ type NodeGraphPortRef = { nodeId: string; portId: string }
37
+ type NodeGraphPortSide = "input" | "output"
38
+ type NodeGraphConnection = { source: NodeGraphPortRef; target: NodeGraphPortRef }
39
+ type NodeGraphSelection = { nodes: string[]; edges: string[] }
40
+ type NodeGraphNodeMove = { id: string; position: XY }
41
+ type NodeGraphConnectingState = {
42
+ source: NodeGraphPortRef & { side: NodeGraphPortSide; type: string }
43
+ /** Cursor position in graph coordinates. */
44
+ position: XY
45
+ target: (NodeGraphPortRef & { side: NodeGraphPortSide; type: string }) | null
46
+ valid: boolean
47
+ }
48
+ type NodeGraphRect = { x: number; y: number; width: number; height: number }
49
+ type NodeGraphPortDetails = NodeGraphPortRef & { side: NodeGraphPortSide; type: string }
50
+
51
+ const EMPTY_SELECTION: NodeGraphSelection = { nodes: [], edges: [] }
52
+ const INTERACTIVE = "input, textarea, select, button, a, [contenteditable], [data-slot=node-graph-port-pin]"
53
+
54
+ /* ---------------------------------------------------------------------------
55
+ * Layout store: node positions/sizes and port offsets, measured from the DOM.
56
+ * Edges and the minimap subscribe to it instead of re-measuring themselves.
57
+ * ------------------------------------------------------------------------- */
58
+
59
+ type PortLayout = { side: NodeGraphPortSide; type: string; el: HTMLElement | null; offset: XY }
60
+ type NodeLayout = { position: XY; width: number; height: number; ports: Map<string, PortLayout> }
61
+
62
+ function createLayoutStore() {
63
+ const nodes = new Map<string, NodeLayout>()
64
+ const listeners = new Set<() => void>()
65
+ let version = 0
66
+ const emit = () => {
67
+ version++
68
+ listeners.forEach((l) => l())
69
+ }
70
+ return {
71
+ nodes,
72
+ subscribe: (l: () => void) => {
73
+ listeners.add(l)
74
+ return () => {
75
+ listeners.delete(l)
76
+ }
77
+ },
78
+ getVersion: () => version,
79
+ emit,
80
+ ensure(id: string) {
81
+ let node = nodes.get(id)
82
+ if (!node) {
83
+ node = { position: { x: 0, y: 0 }, width: 0, height: 0, ports: new Map() }
84
+ nodes.set(id, node)
85
+ }
86
+ return node
87
+ },
88
+ getPortPosition(ref: NodeGraphPortRef): XY | null {
89
+ const node = nodes.get(ref.nodeId)
90
+ const port = node?.ports.get(ref.portId)
91
+ if (!node || !port) return null
92
+ return { x: node.position.x + port.offset.x, y: node.position.y + port.offset.y }
93
+ },
94
+ getPort(ref: NodeGraphPortRef) {
95
+ return nodes.get(ref.nodeId)?.ports.get(ref.portId) ?? null
96
+ },
97
+ getBounds(ids?: Iterable<string>): NodeGraphRect | null {
98
+ let minX = Infinity
99
+ let minY = Infinity
100
+ let maxX = -Infinity
101
+ let maxY = -Infinity
102
+ for (const id of ids ?? nodes.keys()) {
103
+ const node = nodes.get(id)
104
+ if (!node) continue
105
+ minX = Math.min(minX, node.position.x)
106
+ minY = Math.min(minY, node.position.y)
107
+ maxX = Math.max(maxX, node.position.x + node.width)
108
+ maxY = Math.max(maxY, node.position.y + node.height)
109
+ }
110
+ if (!Number.isFinite(minX)) return null
111
+ return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }
112
+ },
113
+ }
114
+ }
115
+
116
+ type LayoutStore = ReturnType<typeof createLayoutStore>
117
+
118
+ /* ---------------------------------------------------------------------------
119
+ * Context
120
+ * ------------------------------------------------------------------------- */
121
+
122
+ type NodeGraphContextValue = {
123
+ store: LayoutStore
124
+ viewport: NodeGraphViewportState
125
+ setViewport: (next: NodeGraphViewportState | ((prev: NodeGraphViewportState) => NodeGraphViewportState)) => void
126
+ viewportRef: React.RefObject<HTMLDivElement | null>
127
+ size: { width: number; height: number }
128
+ minZoom: number
129
+ maxZoom: number
130
+ snapGrid: number
131
+ selection: NodeGraphSelection
132
+ setSelection: (next: NodeGraphSelection) => void
133
+ connecting: NodeGraphConnectingState | null
134
+ marquee: NodeGraphRect | null
135
+ draggingNodes: boolean
136
+ panning: boolean
137
+ spaceHeld: boolean
138
+ /** Convert a client (screen) point to graph coordinates. */
139
+ screenToGraph: (client: XY) => XY
140
+ graphToScreen: (point: XY) => XY
141
+ zoomIn: () => void
142
+ zoomOut: () => void
143
+ zoomTo: (zoom: number, client?: XY) => void
144
+ fitView: (options?: { padding?: number; ids?: string[]; maxZoom?: number }) => void
145
+ centerOn: (point: XY) => void
146
+ announce: (message: string) => void
147
+ // internal interaction hooks used by parts
148
+ startNodeDrag: (nodeId: string, event: React.PointerEvent) => void
149
+ startConnection: (source: NodeGraphConnectingState["source"], event: React.PointerEvent) => void
150
+ disconnect: (details: NodeGraphPortDetails) => void
151
+ edgeAt: (details: NodeGraphPortDetails) => NodeGraphPortDetails | null
152
+ deleteSelection: () => void
153
+ nudgeSelection: (delta: XY, focusedNodeId?: string) => void
154
+ selectAll: () => void
155
+ onNodeClick: (nodeId: string, event: React.MouseEvent | React.KeyboardEvent) => void
156
+ onEdgeClick: (edgeId: string, event: React.MouseEvent) => void
157
+ }
158
+
159
+ const NodeGraphContext = React.createContext<NodeGraphContextValue | null>(null)
160
+
161
+ /** Access the graph (viewport, selection, coordinate helpers) from any part or consumer code inside `NodeGraph`. */
162
+ function useNodeGraph() {
163
+ const ctx = React.useContext(NodeGraphContext)
164
+ if (!ctx) throw new Error("NodeGraph parts must be used within <NodeGraph>")
165
+ return ctx
166
+ }
167
+
168
+ /** Re-render when node/port layout changes. */
169
+ function useLayoutVersion(store: LayoutStore) {
170
+ return React.useSyncExternalStore(store.subscribe, store.getVersion, store.getVersion)
171
+ }
172
+
173
+ const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value))
174
+ const snap = (value: number, grid: number) => (grid > 0 ? Math.round(value / grid) * grid : value)
175
+ const sameSelection = (a: NodeGraphSelection, b: NodeGraphSelection) =>
176
+ a.nodes.length === b.nodes.length &&
177
+ a.edges.length === b.edges.length &&
178
+ a.nodes.every((id, i) => b.nodes[i] === id) &&
179
+ a.edges.every((id, i) => b.edges[i] === id)
180
+
181
+ function useControllable<T>(value: T | undefined, defaultValue: T, onChange?: (value: T) => void) {
182
+ const [internal, setInternal] = React.useState(defaultValue)
183
+ const controlled = value !== undefined
184
+ const current = controlled ? value : internal
185
+ // Tracks the latest value synchronously so back-to-back functional updates (wheel zoom) compose.
186
+ const ref = React.useRef(current)
187
+ React.useLayoutEffect(() => {
188
+ ref.current = current
189
+ }, [current])
190
+ const onChangeRef = React.useRef(onChange)
191
+ React.useEffect(() => {
192
+ onChangeRef.current = onChange
193
+ }, [onChange])
194
+ const set = React.useCallback(
195
+ (next: T | ((prev: T) => T)) => {
196
+ const resolved = typeof next === "function" ? (next as (prev: T) => T)(ref.current) : next
197
+ ref.current = resolved
198
+ if (!controlled) setInternal(resolved)
199
+ onChangeRef.current?.(resolved)
200
+ },
201
+ [controlled]
202
+ )
203
+ return [current, set] as const
204
+ }
205
+
206
+ /* ---------------------------------------------------------------------------
207
+ * Root
208
+ * ------------------------------------------------------------------------- */
209
+
210
+ type NodeGraphProps = Omit<React.ComponentProps<"div">, "onSelect"> & {
211
+ viewport?: NodeGraphViewportState
212
+ defaultViewport?: NodeGraphViewportState
213
+ onViewportChange?: (viewport: NodeGraphViewportState) => void
214
+ selection?: NodeGraphSelection
215
+ defaultSelection?: NodeGraphSelection
216
+ onSelectionChange?: (selection: NodeGraphSelection) => void
217
+ minZoom?: number
218
+ maxZoom?: number
219
+ /** Grid size nodes snap to while dragging and nudging. `0` disables snapping. */
220
+ snapGrid?: number
221
+ /** Live position updates while nodes are dragged or nudged. */
222
+ onNodesMove?: (moves: NodeGraphNodeMove[]) => void
223
+ /** Fired once when a drag or nudge finishes, with the final positions. */
224
+ onNodesMoveEnd?: (moves: NodeGraphNodeMove[]) => void
225
+ /** A connection was completed on a valid port. `source` is always the output side. */
226
+ onConnect?: (connection: NodeGraphConnection) => void
227
+ /** A connection was released on empty space; `position` is in graph coordinates. */
228
+ onConnectEnd?: (details: { source: NodeGraphConnectingState["source"]; position: XY; client: XY }) => void
229
+ /** Override the built-in check (sides differ, nodes differ, port types match or are "any"). */
230
+ isValidConnection?: (connection: NodeGraphConnection & { sourceType: string; targetType: string }) => boolean
231
+ /** Delete/Backspace with a selection. */
232
+ onDelete?: (selection: NodeGraphSelection) => void
233
+ /** Right-click on empty space (a right-drag pans instead). */
234
+ onPaneContextMenu?: (details: { position: XY; client: XY }) => void
235
+ /** Right-click on a node (selects it first unless already selected). */
236
+ onNodeContextMenu?: (details: { id: string; position: XY; client: XY }) => void
237
+ /** Right-click on an edge. */
238
+ onEdgeContextMenu?: (details: { id: string; position: XY; client: XY }) => void
239
+ /** Right-click on a port pin. */
240
+ onPortContextMenu?: (details: NodeGraphPortDetails & { position: XY; client: XY }) => void
241
+ /** Alt+click on a port pin: the conventional "break links" gesture. */
242
+ onDisconnect?: (details: NodeGraphPortDetails) => void
243
+ /**
244
+ * Enables re-routing: return the other end of the single link attached to `port` (or null).
245
+ * Dragging from such a pin then calls `onDisconnect(port)` and continues the drag from that other end.
246
+ */
247
+ edgeAt?: (port: NodeGraphPortDetails) => NodeGraphPortDetails | null
248
+ }
249
+
250
+ function NodeGraph({
251
+ viewport: viewportProp,
252
+ defaultViewport = { x: 0, y: 0, zoom: 1 },
253
+ onViewportChange,
254
+ selection: selectionProp,
255
+ defaultSelection = EMPTY_SELECTION,
256
+ onSelectionChange,
257
+ minZoom = 0.25,
258
+ maxZoom = 2,
259
+ snapGrid = 20,
260
+ onNodesMove,
261
+ onNodesMoveEnd,
262
+ onConnect,
263
+ onConnectEnd,
264
+ isValidConnection,
265
+ onDelete,
266
+ onPaneContextMenu,
267
+ onNodeContextMenu,
268
+ onEdgeContextMenu,
269
+ onPortContextMenu,
270
+ onDisconnect,
271
+ edgeAt,
272
+ className,
273
+ children,
274
+ ...props
275
+ }: NodeGraphProps) {
276
+ const store = React.useMemo(() => createLayoutStore(), [])
277
+ const viewportRef = React.useRef<HTMLDivElement>(null)
278
+ const [viewport, setViewportState] = useControllable(viewportProp, defaultViewport, onViewportChange)
279
+ const [selection, setSelectionState] = useControllable(selectionProp, defaultSelection, onSelectionChange)
280
+ const [size, setSize] = React.useState({ width: 0, height: 0 })
281
+ const [connecting, setConnecting] = React.useState<NodeGraphConnectingState | null>(null)
282
+ const [marquee, setMarquee] = React.useState<NodeGraphRect | null>(null)
283
+ const [draggingNodes, setDraggingNodes] = React.useState(false)
284
+ const [panning, setPanning] = React.useState(false)
285
+ const [spaceHeld, setSpaceHeld] = React.useState(false)
286
+ const { message: announcement, announce } = useLiveRegion()
287
+ const spaceRef = React.useRef(false)
288
+ React.useEffect(() => {
289
+ const down = (event: KeyboardEvent) => {
290
+ if (event.key !== " " || event.repeat) return
291
+ const target = event.target as HTMLElement
292
+ if (target.closest("input, textarea, select, button, [contenteditable]")) return
293
+ if (!viewportRef.current?.contains(target) && target !== document.body) return
294
+ spaceRef.current = true
295
+ setSpaceHeld(true)
296
+ }
297
+ const up = (event: KeyboardEvent) => {
298
+ if (event.key !== " ") return
299
+ spaceRef.current = false
300
+ setSpaceHeld(false)
301
+ }
302
+ const blur = () => {
303
+ spaceRef.current = false
304
+ setSpaceHeld(false)
305
+ }
306
+ window.addEventListener("keydown", down)
307
+ window.addEventListener("keyup", up)
308
+ window.addEventListener("blur", blur)
309
+ return () => {
310
+ window.removeEventListener("keydown", down)
311
+ window.removeEventListener("keyup", up)
312
+ window.removeEventListener("blur", blur)
313
+ }
314
+ }, [])
315
+
316
+ const latest = React.useRef({
317
+ viewport,
318
+ selection,
319
+ size,
320
+ onNodesMove,
321
+ onNodesMoveEnd,
322
+ onConnect,
323
+ onConnectEnd,
324
+ onPaneContextMenu,
325
+ onNodeContextMenu,
326
+ onEdgeContextMenu,
327
+ onPortContextMenu,
328
+ onDisconnect,
329
+ edgeAt,
330
+ })
331
+ React.useEffect(() => {
332
+ latest.current = {
333
+ viewport,
334
+ selection,
335
+ size,
336
+ onNodesMove,
337
+ onNodesMoveEnd,
338
+ onConnect,
339
+ onConnectEnd,
340
+ onPaneContextMenu,
341
+ onNodeContextMenu,
342
+ onEdgeContextMenu,
343
+ onPortContextMenu,
344
+ onDisconnect,
345
+ edgeAt,
346
+ }
347
+ })
348
+
349
+ const setViewport = React.useCallback(
350
+ (next: NodeGraphViewportState | ((prev: NodeGraphViewportState) => NodeGraphViewportState)) =>
351
+ setViewportState((prev) => {
352
+ const resolved = typeof next === "function" ? next(prev) : next
353
+ return { ...resolved, zoom: clamp(resolved.zoom, minZoom, maxZoom) }
354
+ }),
355
+ [setViewportState, minZoom, maxZoom]
356
+ )
357
+ const setSelection = React.useCallback(
358
+ (next: NodeGraphSelection) => {
359
+ if (!sameSelection(latest.current.selection, next)) setSelectionState(next)
360
+ },
361
+ [setSelectionState]
362
+ )
363
+
364
+ const screenToGraph = React.useCallback((client: XY): XY => {
365
+ const rect = viewportRef.current?.getBoundingClientRect()
366
+ const vp = latest.current.viewport
367
+ return {
368
+ x: (client.x - (rect?.left ?? 0) - vp.x) / vp.zoom,
369
+ y: (client.y - (rect?.top ?? 0) - vp.y) / vp.zoom,
370
+ }
371
+ }, [])
372
+ const graphToScreen = React.useCallback((point: XY): XY => {
373
+ const rect = viewportRef.current?.getBoundingClientRect()
374
+ const vp = latest.current.viewport
375
+ return { x: point.x * vp.zoom + vp.x + (rect?.left ?? 0), y: point.y * vp.zoom + vp.y + (rect?.top ?? 0) }
376
+ }, [])
377
+
378
+ /** Zoom keeping the graph point under `client` (defaults to the pane centre) fixed. */
379
+ const zoomTo = React.useCallback(
380
+ (zoom: number, client?: XY) => {
381
+ const rect = viewportRef.current?.getBoundingClientRect()
382
+ const cx = client ? client.x - (rect?.left ?? 0) : latest.current.size.width / 2
383
+ const cy = client ? client.y - (rect?.top ?? 0) : latest.current.size.height / 2
384
+ setViewport((prev) => {
385
+ const next = clamp(zoom, minZoom, maxZoom)
386
+ const gx = (cx - prev.x) / prev.zoom
387
+ const gy = (cy - prev.y) / prev.zoom
388
+ return { x: cx - gx * next, y: cy - gy * next, zoom: next }
389
+ })
390
+ },
391
+ [setViewport, minZoom, maxZoom]
392
+ )
393
+ const zoomIn = React.useCallback(() => zoomTo(latest.current.viewport.zoom * 1.2), [zoomTo])
394
+ const zoomOut = React.useCallback(() => zoomTo(latest.current.viewport.zoom / 1.2), [zoomTo])
395
+ const fitView = React.useCallback(
396
+ (options?: { padding?: number; ids?: string[]; maxZoom?: number }) => {
397
+ const bounds = store.getBounds(options?.ids)
398
+ const { width, height } = latest.current.size
399
+ if (!bounds || !width || !height) return
400
+ const padding = options?.padding ?? 40
401
+ const zoom = clamp(
402
+ Math.min(
403
+ (width - padding * 2) / Math.max(bounds.width, 1),
404
+ (height - padding * 2) / Math.max(bounds.height, 1)
405
+ ),
406
+ minZoom,
407
+ options?.maxZoom ?? 1
408
+ )
409
+ setViewport({
410
+ x: (width - bounds.width * zoom) / 2 - bounds.x * zoom,
411
+ y: (height - bounds.height * zoom) / 2 - bounds.y * zoom,
412
+ zoom,
413
+ })
414
+ },
415
+ [store, setViewport, minZoom]
416
+ )
417
+ const centerOn = React.useCallback(
418
+ (point: XY) =>
419
+ setViewport((prev) => ({
420
+ ...prev,
421
+ x: latest.current.size.width / 2 - point.x * prev.zoom,
422
+ y: latest.current.size.height / 2 - point.y * prev.zoom,
423
+ })),
424
+ [setViewport]
425
+ )
426
+
427
+ // Pane size for fitView / minimap.
428
+ React.useLayoutEffect(() => {
429
+ const el = viewportRef.current
430
+ if (!el) return
431
+ const update = () => setSize({ width: el.clientWidth, height: el.clientHeight })
432
+ update()
433
+ const observer = new ResizeObserver(update)
434
+ observer.observe(el)
435
+ return () => observer.disconnect()
436
+ }, [])
437
+
438
+ /* ----- pointer interactions: they attach move/up listeners to the window ----- */
439
+
440
+ const track = React.useCallback((onMove: (event: PointerEvent) => void, onUp: (event: PointerEvent) => void) => {
441
+ const up = (event: PointerEvent) => {
442
+ window.removeEventListener("pointermove", onMove)
443
+ window.removeEventListener("pointerup", up)
444
+ window.removeEventListener("pointercancel", up)
445
+ onUp(event)
446
+ }
447
+ window.addEventListener("pointermove", onMove)
448
+ window.addEventListener("pointerup", up)
449
+ window.addEventListener("pointercancel", up)
450
+ }, [])
451
+
452
+ const startNodeDrag = React.useCallback(
453
+ (nodeId: string, event: React.PointerEvent) => {
454
+ const startClient = { x: event.clientX, y: event.clientY }
455
+ const current = latest.current.selection
456
+ const additive = event.shiftKey || event.metaKey || event.ctrlKey
457
+ const wasSelected = current.nodes.includes(nodeId)
458
+ // Selection resolves on pointer down so a drag moves the whole group.
459
+ const nodes = wasSelected ? current.nodes : additive ? [...current.nodes, nodeId] : [nodeId]
460
+ if (!wasSelected) setSelection({ nodes, edges: additive ? current.edges : [] })
461
+ const starts = new Map(nodes.map((id) => [id, { ...store.ensure(id).position }]))
462
+ let moved = false
463
+ let lastMoves: NodeGraphNodeMove[] = []
464
+ track(
465
+ (e) => {
466
+ const zoom = latest.current.viewport.zoom
467
+ const dx = (e.clientX - startClient.x) / zoom
468
+ const dy = (e.clientY - startClient.y) / zoom
469
+ if (!moved && Math.hypot(dx * zoom, dy * zoom) < 3) return
470
+ if (!moved) {
471
+ moved = true
472
+ setDraggingNodes(true)
473
+ }
474
+ lastMoves = [...starts].map(([id, start]) => ({
475
+ id,
476
+ position: { x: snap(start.x + dx, snapGrid), y: snap(start.y + dy, snapGrid) },
477
+ }))
478
+ latest.current.onNodesMove?.(lastMoves)
479
+ },
480
+ () => {
481
+ if (moved) {
482
+ setDraggingNodes(false)
483
+ latest.current.onNodesMoveEnd?.(lastMoves)
484
+ } else if (wasSelected && additive) {
485
+ setSelection({ nodes: current.nodes.filter((id) => id !== nodeId), edges: current.edges })
486
+ } else if (wasSelected && current.nodes.length > 1) {
487
+ setSelection({ nodes: [nodeId], edges: [] })
488
+ }
489
+ }
490
+ )
491
+ },
492
+ [store, track, setSelection, snapGrid]
493
+ )
494
+
495
+ const validate = React.useCallback(
496
+ (a: NodeGraphConnectingState["source"], b: NodeGraphConnectingState["source"]) => {
497
+ if (a.side === b.side || a.nodeId === b.nodeId) return false
498
+ const [source, target] = a.side === "output" ? [a, b] : [b, a]
499
+ if (isValidConnection) {
500
+ return isValidConnection({
501
+ source: { nodeId: source.nodeId, portId: source.portId },
502
+ target: { nodeId: target.nodeId, portId: target.portId },
503
+ sourceType: source.type,
504
+ targetType: target.type,
505
+ })
506
+ }
507
+ return source.type === target.type || source.type === "any" || target.type === "any"
508
+ },
509
+ [isValidConnection]
510
+ )
511
+
512
+ const startConnection = React.useCallback(
513
+ (source: NodeGraphConnectingState["source"], event: React.PointerEvent) => {
514
+ const position = screenToGraph({ x: event.clientX, y: event.clientY })
515
+ let state: NodeGraphConnectingState = { source, position, target: null, valid: false }
516
+ setConnecting(state)
517
+ track(
518
+ (e) => {
519
+ const el = document
520
+ .elementFromPoint(e.clientX, e.clientY)
521
+ ?.closest<HTMLElement>("[data-slot=node-graph-port-pin]")
522
+ const target =
523
+ el && el.dataset.nodeId && el.dataset.portId
524
+ ? {
525
+ nodeId: el.dataset.nodeId,
526
+ portId: el.dataset.portId,
527
+ side: (el.dataset.side as NodeGraphPortSide) ?? "input",
528
+ type: el.dataset.type ?? "any",
529
+ }
530
+ : null
531
+ const anchored = target && validate(source, target) ? store.getPortPosition(target) : null
532
+ state = {
533
+ source,
534
+ position: anchored ?? screenToGraph({ x: e.clientX, y: e.clientY }),
535
+ target,
536
+ valid: !!anchored,
537
+ }
538
+ setConnecting(state)
539
+ },
540
+ (e) => {
541
+ setConnecting(null)
542
+ if (state.target && state.valid) {
543
+ const [from, to] = source.side === "output" ? [source, state.target] : [state.target, source]
544
+ latest.current.onConnect?.({
545
+ source: { nodeId: from.nodeId, portId: from.portId },
546
+ target: { nodeId: to.nodeId, portId: to.portId },
547
+ })
548
+ } else if (!state.target) {
549
+ latest.current.onConnectEnd?.({
550
+ source,
551
+ position: screenToGraph({ x: e.clientX, y: e.clientY }),
552
+ client: { x: e.clientX, y: e.clientY },
553
+ })
554
+ }
555
+ }
556
+ )
557
+ },
558
+ [screenToGraph, store, track, validate]
559
+ )
560
+
561
+ /** Pointer down on the pane itself: pan (middle/right button, space, or alt) or marquee (left). */
562
+ const onPanePointerDown = React.useCallback(
563
+ (event: React.PointerEvent) => {
564
+ const target = event.target as HTMLElement
565
+ if (target.closest("[data-slot=node-graph-controls], [data-slot=node-graph-minimap]")) return
566
+ const pin = target.closest<HTMLElement>("[data-slot=node-graph-port-pin]")
567
+ const nodeEl = target.closest<HTMLElement>("[data-slot=node-graph-node]")
568
+ const edgeEl = target.closest<HTMLElement>("[data-slot=node-graph-edge]")
569
+ const startClient = { x: event.clientX, y: event.clientY }
570
+ const touch = event.pointerType === "touch"
571
+ const isPan =
572
+ event.button === 1 ||
573
+ event.button === 2 ||
574
+ spaceRef.current ||
575
+ ((event.altKey || touch) && !nodeEl && !edgeEl && !pin)
576
+ if (!isPan && (nodeEl || edgeEl)) return
577
+ if (isPan) {
578
+ event.preventDefault()
579
+ const startViewport = latest.current.viewport
580
+ let moved = false
581
+ setPanning(true)
582
+ // Right-click selects the node under the cursor like a left click would.
583
+ const nodeId = nodeEl?.dataset.value
584
+ if (event.button === 2 && nodeId && !latest.current.selection.nodes.includes(nodeId)) {
585
+ setSelection({ nodes: [nodeId], edges: [] })
586
+ }
587
+ track(
588
+ (e) => {
589
+ const dx = e.clientX - startClient.x
590
+ const dy = e.clientY - startClient.y
591
+ if (!moved && Math.hypot(dx, dy) < 3) return
592
+ moved = true
593
+ setViewport({ ...startViewport, x: startViewport.x + dx, y: startViewport.y + dy })
594
+ },
595
+ (e) => {
596
+ setPanning(false)
597
+ if (!moved && event.button === 2) {
598
+ const client = { x: e.clientX, y: e.clientY }
599
+ const position = screenToGraph(client)
600
+ const handlers = latest.current
601
+ if (pin && pin.dataset.nodeId && pin.dataset.portId) {
602
+ handlers.onPortContextMenu?.({
603
+ nodeId: pin.dataset.nodeId,
604
+ portId: pin.dataset.portId,
605
+ side: (pin.dataset.side as NodeGraphPortSide) ?? "input",
606
+ type: pin.dataset.type ?? "any",
607
+ position,
608
+ client,
609
+ })
610
+ } else if (nodeId) {
611
+ handlers.onNodeContextMenu?.({ id: nodeId, position, client })
612
+ } else if (edgeEl?.dataset.value) {
613
+ handlers.onEdgeContextMenu?.({ id: edgeEl.dataset.value, position, client })
614
+ } else {
615
+ handlers.onPaneContextMenu?.({ position, client })
616
+ }
617
+ }
618
+ }
619
+ )
620
+ return
621
+ }
622
+ if (event.button !== 0) return
623
+ const additive = event.shiftKey || event.metaKey || event.ctrlKey
624
+ const base = additive ? latest.current.selection : EMPTY_SELECTION
625
+ if (!additive) setSelection(EMPTY_SELECTION)
626
+ const origin = screenToGraph(startClient)
627
+ let box: NodeGraphRect | null = null
628
+ track(
629
+ (e) => {
630
+ const point = screenToGraph({ x: e.clientX, y: e.clientY })
631
+ if (!box && Math.hypot(e.clientX - startClient.x, e.clientY - startClient.y) < 3) return
632
+ box = {
633
+ x: Math.min(origin.x, point.x),
634
+ y: Math.min(origin.y, point.y),
635
+ width: Math.abs(point.x - origin.x),
636
+ height: Math.abs(point.y - origin.y),
637
+ }
638
+ setMarquee(box)
639
+ const hits: string[] = []
640
+ for (const [id, node] of store.nodes) {
641
+ const intersects =
642
+ node.position.x < box.x + box.width &&
643
+ node.position.x + node.width > box.x &&
644
+ node.position.y < box.y + box.height &&
645
+ node.position.y + node.height > box.y
646
+ if (intersects) hits.push(id)
647
+ }
648
+ setSelection({ nodes: [...new Set([...base.nodes, ...hits])], edges: base.edges })
649
+ },
650
+ () => setMarquee(null)
651
+ )
652
+ },
653
+ [track, setViewport, setSelection, screenToGraph, store]
654
+ )
655
+
656
+ const disconnect = React.useCallback(
657
+ (details: NodeGraphPortDetails) => {
658
+ latest.current.onDisconnect?.(details)
659
+ announce(`Broke links on ${details.side} ${details.portId}.`)
660
+ },
661
+ [announce]
662
+ )
663
+
664
+ const edgeAtStable = React.useCallback(
665
+ (details: NodeGraphPortDetails) => latest.current.edgeAt?.(details) ?? null,
666
+ []
667
+ )
668
+
669
+ const deleteSelection = React.useCallback(() => {
670
+ const current = latest.current.selection
671
+ if (!current.nodes.length && !current.edges.length) return
672
+ onDelete?.(current)
673
+ setSelection(EMPTY_SELECTION)
674
+ announce(
675
+ `Deleted ${current.nodes.length} node${current.nodes.length === 1 ? "" : "s"} and ${current.edges.length} connection${current.edges.length === 1 ? "" : "s"}.`
676
+ )
677
+ }, [onDelete, setSelection, announce])
678
+
679
+ const nudgeSelection = React.useCallback(
680
+ (delta: XY, focusedNodeId?: string) => {
681
+ const current = latest.current.selection
682
+ const ids = current.nodes.length ? current.nodes : focusedNodeId ? [focusedNodeId] : []
683
+ if (!ids.length) return
684
+ const moves = ids.map((id) => {
685
+ const { position } = store.ensure(id)
686
+ return { id, position: { x: position.x + delta.x, y: position.y + delta.y } }
687
+ })
688
+ latest.current.onNodesMove?.(moves)
689
+ latest.current.onNodesMoveEnd?.(moves)
690
+ announce(
691
+ `Moved ${ids.length} node${ids.length === 1 ? "" : "s"} ${delta.x < 0 ? "left" : delta.x > 0 ? "right" : delta.y < 0 ? "up" : "down"}.`
692
+ )
693
+ },
694
+ [store, announce]
695
+ )
696
+
697
+ const selectAll = React.useCallback(
698
+ () => setSelection({ nodes: [...store.nodes.keys()], edges: latest.current.selection.edges }),
699
+ [store, setSelection]
700
+ )
701
+
702
+ const onNodeClick = React.useCallback(
703
+ (nodeId: string, event: React.MouseEvent | React.KeyboardEvent) => {
704
+ const current = latest.current.selection
705
+ const additive = event.shiftKey || event.metaKey || event.ctrlKey
706
+ if (additive) {
707
+ setSelection({
708
+ nodes: current.nodes.includes(nodeId)
709
+ ? current.nodes.filter((id) => id !== nodeId)
710
+ : [...current.nodes, nodeId],
711
+ edges: current.edges,
712
+ })
713
+ } else {
714
+ setSelection({ nodes: [nodeId], edges: [] })
715
+ }
716
+ },
717
+ [setSelection]
718
+ )
719
+ const onEdgeClick = React.useCallback(
720
+ (edgeId: string, event: React.MouseEvent) => {
721
+ const current = latest.current.selection
722
+ const additive = event.shiftKey || event.metaKey || event.ctrlKey
723
+ if (additive) {
724
+ setSelection({
725
+ nodes: current.nodes,
726
+ edges: current.edges.includes(edgeId)
727
+ ? current.edges.filter((id) => id !== edgeId)
728
+ : [...current.edges, edgeId],
729
+ })
730
+ } else {
731
+ setSelection({ nodes: [], edges: [edgeId] })
732
+ }
733
+ },
734
+ [setSelection]
735
+ )
736
+
737
+ const ctx = React.useMemo<NodeGraphContextValue>(
738
+ () => ({
739
+ store,
740
+ viewport,
741
+ setViewport,
742
+ viewportRef,
743
+ size,
744
+ minZoom,
745
+ maxZoom,
746
+ snapGrid,
747
+ selection,
748
+ setSelection,
749
+ connecting,
750
+ marquee,
751
+ draggingNodes,
752
+ panning,
753
+ spaceHeld,
754
+ screenToGraph,
755
+ graphToScreen,
756
+ zoomIn,
757
+ zoomOut,
758
+ zoomTo,
759
+ fitView,
760
+ centerOn,
761
+ announce,
762
+ startNodeDrag,
763
+ startConnection,
764
+ disconnect,
765
+ edgeAt: edgeAtStable,
766
+ deleteSelection,
767
+ nudgeSelection,
768
+ selectAll,
769
+ onNodeClick,
770
+ onEdgeClick,
771
+ }),
772
+ [
773
+ store,
774
+ viewport,
775
+ setViewport,
776
+ size,
777
+ minZoom,
778
+ maxZoom,
779
+ snapGrid,
780
+ selection,
781
+ setSelection,
782
+ connecting,
783
+ marquee,
784
+ draggingNodes,
785
+ panning,
786
+ spaceHeld,
787
+ screenToGraph,
788
+ graphToScreen,
789
+ zoomIn,
790
+ zoomOut,
791
+ zoomTo,
792
+ fitView,
793
+ centerOn,
794
+ announce,
795
+ startNodeDrag,
796
+ startConnection,
797
+ disconnect,
798
+ edgeAtStable,
799
+ deleteSelection,
800
+ nudgeSelection,
801
+ selectAll,
802
+ onNodeClick,
803
+ onEdgeClick,
804
+ ]
805
+ )
806
+
807
+ return (
808
+ <NodeGraphContext.Provider value={ctx}>
809
+ <PanePointerDownContext.Provider value={onPanePointerDown}>
810
+ <div
811
+ data-slot="node-graph"
812
+ data-connecting={connecting ? "" : undefined}
813
+ data-dragging={draggingNodes ? "" : undefined}
814
+ data-panning={panning ? "" : undefined}
815
+ className={cn("relative flex min-h-0 flex-1 flex-col", className)}
816
+ {...props}
817
+ >
818
+ {children}
819
+ <LiveRegion data-slot="node-graph-live-region" message={announcement} />
820
+ </div>
821
+ </PanePointerDownContext.Provider>
822
+ </NodeGraphContext.Provider>
823
+ )
824
+ }
825
+
826
+ const PanePointerDownContext = React.createContext<((event: React.PointerEvent) => void) | null>(null)
827
+
828
+ /* ---------------------------------------------------------------------------
829
+ * Viewport
830
+ * ------------------------------------------------------------------------- */
831
+
832
+ function NodeGraphViewport({
833
+ className,
834
+ children,
835
+ panOnScroll = false,
836
+ onKeyDown,
837
+ ...props
838
+ }: React.ComponentProps<"div"> & {
839
+ /** Plain wheel pans instead of zooming (pinch / ctrl+wheel always zooms). */
840
+ panOnScroll?: boolean
841
+ }) {
842
+ const ctx = useNodeGraph()
843
+ const onPanePointerDown = React.useContext(PanePointerDownContext)
844
+ const { viewportRef, setViewport, zoomTo, connecting, panning, spaceHeld } = ctx
845
+ const ctxRef = React.useRef(ctx)
846
+ React.useEffect(() => {
847
+ ctxRef.current = ctx
848
+ })
849
+
850
+ // Wheel must be non-passive to prevent page scroll.
851
+ React.useEffect(() => {
852
+ const el = viewportRef.current
853
+ if (!el) return
854
+ const onWheel = (event: WheelEvent) => {
855
+ event.preventDefault()
856
+ const zooming = event.ctrlKey || event.metaKey || !panOnScroll
857
+ if (zooming) {
858
+ const factor = Math.exp(-event.deltaY * (event.ctrlKey ? 0.01 : 0.002))
859
+ zoomTo(ctxRef.current.viewport.zoom * factor, { x: event.clientX, y: event.clientY })
860
+ } else {
861
+ setViewport((prev) => ({ ...prev, x: prev.x - event.deltaX, y: prev.y - event.deltaY }))
862
+ }
863
+ }
864
+ el.addEventListener("wheel", onWheel, { passive: false })
865
+ return () => el.removeEventListener("wheel", onWheel)
866
+ }, [viewportRef, setViewport, zoomTo, panOnScroll])
867
+
868
+ const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
869
+ onKeyDown?.(event)
870
+ if (event.defaultPrevented) return
871
+ const target = event.target as HTMLElement
872
+ if (target.closest("input, textarea, select, [contenteditable]")) return
873
+ const focusedNode = target.closest<HTMLElement>("[data-slot=node-graph-node]")?.dataset.value
874
+ const step = (event.shiftKey ? 10 : 1) * (ctx.snapGrid || 10)
875
+ const meta = event.metaKey || event.ctrlKey
876
+ switch (event.key) {
877
+ case " ":
878
+ if (!focusedNode) event.preventDefault()
879
+ break
880
+ case "Delete":
881
+ case "Backspace":
882
+ event.preventDefault()
883
+ ctx.deleteSelection()
884
+ break
885
+ case "Escape":
886
+ ctx.setSelection(EMPTY_SELECTION)
887
+ break
888
+ case "a":
889
+ case "A":
890
+ if (meta) {
891
+ event.preventDefault()
892
+ ctx.selectAll()
893
+ }
894
+ break
895
+ case "ArrowLeft":
896
+ event.preventDefault()
897
+ ctx.nudgeSelection({ x: -step, y: 0 }, focusedNode)
898
+ break
899
+ case "ArrowRight":
900
+ event.preventDefault()
901
+ ctx.nudgeSelection({ x: step, y: 0 }, focusedNode)
902
+ break
903
+ case "ArrowUp":
904
+ event.preventDefault()
905
+ ctx.nudgeSelection({ x: 0, y: -step }, focusedNode)
906
+ break
907
+ case "ArrowDown":
908
+ event.preventDefault()
909
+ ctx.nudgeSelection({ x: 0, y: step }, focusedNode)
910
+ break
911
+ case "=":
912
+ case "+":
913
+ if (meta) {
914
+ event.preventDefault()
915
+ ctx.zoomIn()
916
+ }
917
+ break
918
+ case "-":
919
+ if (meta) {
920
+ event.preventDefault()
921
+ ctx.zoomOut()
922
+ }
923
+ break
924
+ case "0":
925
+ if (meta) {
926
+ event.preventDefault()
927
+ ctx.fitView()
928
+ }
929
+ break
930
+ }
931
+ }
932
+
933
+ return (
934
+ <div
935
+ ref={viewportRef}
936
+ data-slot="node-graph-viewport"
937
+ tabIndex={0}
938
+ role="application"
939
+ aria-label="Node graph"
940
+ data-connecting={connecting ? "" : undefined}
941
+ data-panning={panning ? "" : undefined}
942
+ data-space={spaceHeld ? "" : undefined}
943
+ className={cn(
944
+ "relative min-h-0 flex-1 touch-none overflow-hidden rounded-xl border bg-muted/30 outline-none select-none focus-visible:ring-2 focus-visible:ring-ring/50",
945
+ "data-connecting:cursor-crosshair data-panning:cursor-grabbing data-space:cursor-grab",
946
+ className
947
+ )}
948
+ onPointerDown={(event) => onPanePointerDown?.(event)}
949
+ onContextMenu={(event) => event.preventDefault()}
950
+ onKeyDown={handleKeyDown}
951
+ {...props}
952
+ >
953
+ {children}
954
+ </div>
955
+ )
956
+ }
957
+
958
+ /* ---------------------------------------------------------------------------
959
+ * Background grid
960
+ * ------------------------------------------------------------------------- */
961
+
962
+ function NodeGraphBackground({
963
+ className,
964
+ variant = "dots",
965
+ gap = 20,
966
+ ...props
967
+ }: React.ComponentProps<"svg"> & { variant?: "dots" | "lines"; gap?: number }) {
968
+ const { viewport } = useNodeGraph()
969
+ const id = React.useId()
970
+ const size = gap * viewport.zoom
971
+ const major = size * 5
972
+ return (
973
+ <svg
974
+ data-slot="node-graph-background"
975
+ aria-hidden
976
+ className={cn("pointer-events-none absolute inset-0 size-full text-foreground/60", className)}
977
+ {...props}
978
+ >
979
+ <pattern
980
+ id={`${id}-minor`}
981
+ x={viewport.x % size}
982
+ y={viewport.y % size}
983
+ width={size}
984
+ height={size}
985
+ patternUnits="userSpaceOnUse"
986
+ >
987
+ {variant === "dots" ? (
988
+ <circle
989
+ cx={size / 2}
990
+ cy={size / 2}
991
+ r={Math.max(0.6, viewport.zoom * 0.9)}
992
+ fill="currentColor"
993
+ opacity={0.28}
994
+ />
995
+ ) : (
996
+ <path d={`M ${size} 0 L 0 0 0 ${size}`} fill="none" stroke="currentColor" strokeWidth={1} opacity={0.08} />
997
+ )}
998
+ </pattern>
999
+ <pattern
1000
+ id={`${id}-major`}
1001
+ x={viewport.x % major}
1002
+ y={viewport.y % major}
1003
+ width={major}
1004
+ height={major}
1005
+ patternUnits="userSpaceOnUse"
1006
+ >
1007
+ <path d={`M ${major} 0 L 0 0 0 ${major}`} fill="none" stroke="currentColor" strokeWidth={1} opacity={0.1} />
1008
+ </pattern>
1009
+ <rect width="100%" height="100%" fill={`url(#${id}-minor)`} />
1010
+ <rect width="100%" height="100%" fill={`url(#${id}-major)`} />
1011
+ </svg>
1012
+ )
1013
+ }
1014
+
1015
+ /* ---------------------------------------------------------------------------
1016
+ * Surface (transformed layer), edges, connection line, marquee
1017
+ * ------------------------------------------------------------------------- */
1018
+
1019
+ function NodeGraphSurface({ className, style, ...props }: React.ComponentProps<"div">) {
1020
+ const { viewport } = useNodeGraph()
1021
+ return (
1022
+ <div
1023
+ data-slot="node-graph-surface"
1024
+ className={cn("absolute top-0 left-0 origin-top-left", className)}
1025
+ style={{ transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.zoom})`, ...style }}
1026
+ {...props}
1027
+ />
1028
+ )
1029
+ }
1030
+
1031
+ function NodeGraphEdges({ className, ...props }: React.ComponentProps<"svg">) {
1032
+ return (
1033
+ <svg
1034
+ data-slot="node-graph-edges"
1035
+ className={cn("pointer-events-none absolute top-0 left-0 size-px overflow-visible", className)}
1036
+ {...props}
1037
+ />
1038
+ )
1039
+ }
1040
+
1041
+ /** Cubic path between two points; horizontal handles scale with distance like a scripting graph. */
1042
+ function getBezierPath(source: XY, target: XY) {
1043
+ const dx = target.x - source.x
1044
+ const offset = clamp(Math.abs(dx) * 0.5, 40, 240) + (dx < 0 ? Math.min(Math.abs(dx) * 0.25, 120) : 0)
1045
+ return `M ${source.x} ${source.y} C ${source.x + offset} ${source.y}, ${target.x - offset} ${target.y}, ${target.x} ${target.y}`
1046
+ }
1047
+
1048
+ function NodeGraphEdge({
1049
+ value,
1050
+ source,
1051
+ target,
1052
+ selected: selectedProp,
1053
+ className,
1054
+ onClick,
1055
+ children,
1056
+ ...props
1057
+ }: Omit<React.ComponentProps<"g">, "children" | "target"> & {
1058
+ value: string
1059
+ source: NodeGraphPortRef
1060
+ target: NodeGraphPortRef
1061
+ selected?: boolean
1062
+ /** Render prop for labels; receives the path midpoint. */
1063
+ children?: (details: { midpoint: XY; from: XY; to: XY }) => React.ReactNode
1064
+ }) {
1065
+ const { store, selection, onEdgeClick } = useNodeGraph()
1066
+ useLayoutVersion(store)
1067
+ const from = store.getPortPosition(source)
1068
+ const to = store.getPortPosition(target)
1069
+ if (!from || !to) return null
1070
+ const selected = selectedProp ?? selection.edges.includes(value)
1071
+ const d = getBezierPath(from, to)
1072
+ const midpoint = { x: (from.x + to.x) / 2, y: (from.y + to.y) / 2 }
1073
+ const type = store.getPort(source)?.type
1074
+ return (
1075
+ <g
1076
+ data-slot="node-graph-edge"
1077
+ data-value={value}
1078
+ data-selected={selected ? "" : undefined}
1079
+ data-type={type}
1080
+ className={cn("group/edge pointer-events-auto cursor-pointer text-muted-foreground", className)}
1081
+ onClick={(event) => {
1082
+ onClick?.(event)
1083
+ if (!event.defaultPrevented) onEdgeClick(value, event)
1084
+ }}
1085
+ {...props}
1086
+ >
1087
+ <path d={d} fill="none" stroke="transparent" strokeWidth={14} />
1088
+ <path
1089
+ d={d}
1090
+ fill="none"
1091
+ stroke="currentColor"
1092
+ strokeWidth={2}
1093
+ className="transition-[stroke-width] group-hover/edge:stroke-3 group-data-selected/edge:stroke-3 group-data-selected/edge:drop-shadow-[0_0_4px_currentColor]"
1094
+ />
1095
+ {children?.({ midpoint, from, to })}
1096
+ </g>
1097
+ )
1098
+ }
1099
+
1100
+ /** The edge being drawn from a pin. Renders nothing when idle. */
1101
+ function NodeGraphConnectionLine({ className, ...props }: React.ComponentProps<"svg">) {
1102
+ const { store, connecting } = useNodeGraph()
1103
+ useLayoutVersion(store)
1104
+ if (!connecting) return null
1105
+ const from = store.getPortPosition(connecting.source)
1106
+ if (!from) return null
1107
+ const [a, b] = connecting.source.side === "output" ? [from, connecting.position] : [connecting.position, from]
1108
+ return (
1109
+ <svg
1110
+ data-slot="node-graph-connection-line"
1111
+ data-type={connecting.source.type}
1112
+ data-valid={connecting.valid ? "" : undefined}
1113
+ data-invalid={connecting.target && !connecting.valid ? "" : undefined}
1114
+ className={cn(
1115
+ "pointer-events-none absolute top-0 left-0 size-px overflow-visible text-foreground data-invalid:text-destructive",
1116
+ className
1117
+ )}
1118
+ {...props}
1119
+ >
1120
+ <path
1121
+ d={getBezierPath(a, b)}
1122
+ fill="none"
1123
+ stroke="currentColor"
1124
+ strokeWidth={2}
1125
+ strokeDasharray={connecting.valid ? undefined : "6 4"}
1126
+ />
1127
+ <circle cx={b.x} cy={b.y} r={3} fill="currentColor" />
1128
+ </svg>
1129
+ )
1130
+ }
1131
+
1132
+ /** Marquee selection rectangle. Renders nothing when idle. */
1133
+ function NodeGraphSelectionBox({ className, style, ...props }: React.ComponentProps<"div">) {
1134
+ const { marquee, viewport } = useNodeGraph()
1135
+ if (!marquee) return null
1136
+ return (
1137
+ <div
1138
+ data-slot="node-graph-selection-box"
1139
+ className={cn("pointer-events-none absolute rounded-sm border border-primary/60 bg-primary/10", className)}
1140
+ style={{
1141
+ left: marquee.x * viewport.zoom + viewport.x,
1142
+ top: marquee.y * viewport.zoom + viewport.y,
1143
+ width: marquee.width * viewport.zoom,
1144
+ height: marquee.height * viewport.zoom,
1145
+ ...style,
1146
+ }}
1147
+ {...props}
1148
+ />
1149
+ )
1150
+ }
1151
+
1152
+ /* ---------------------------------------------------------------------------
1153
+ * Nodes
1154
+ * ------------------------------------------------------------------------- */
1155
+
1156
+ type NodeContextValue = {
1157
+ value: string
1158
+ registerPort: (portId: string, layout: PortLayout) => () => void
1159
+ measure: () => void
1160
+ }
1161
+ const NodeContext = React.createContext<NodeContextValue | null>(null)
1162
+
1163
+ function useNodeGraphNode() {
1164
+ const ctx = React.useContext(NodeContext)
1165
+ if (!ctx) throw new Error("Node parts must be used within <NodeGraphNode>")
1166
+ return ctx
1167
+ }
1168
+
1169
+ function NodeGraphNode({
1170
+ value,
1171
+ position,
1172
+ selected: selectedProp,
1173
+ className,
1174
+ style,
1175
+ onPointerDown,
1176
+ onKeyDown,
1177
+ onDoubleClick,
1178
+ children,
1179
+ ...props
1180
+ }: React.ComponentProps<"div"> & { value: string; position: XY; selected?: boolean }) {
1181
+ const graph = useNodeGraph()
1182
+ const { store, selection, draggingNodes, startNodeDrag, onNodeClick } = graph
1183
+ const ref = React.useRef<HTMLDivElement>(null)
1184
+ const selected = selectedProp ?? selection.nodes.includes(value)
1185
+ const zoomRef = React.useRef(graph.viewport.zoom)
1186
+ React.useLayoutEffect(() => {
1187
+ zoomRef.current = graph.viewport.zoom
1188
+ }, [graph.viewport.zoom])
1189
+
1190
+ const measure = React.useCallback(() => {
1191
+ const el = ref.current
1192
+ if (!el) return
1193
+ const zoom = zoomRef.current || 1
1194
+ const rect = el.getBoundingClientRect()
1195
+ const node = store.ensure(value)
1196
+ node.width = rect.width / zoom
1197
+ node.height = rect.height / zoom
1198
+ for (const port of node.ports.values()) {
1199
+ const pin = port.el?.getBoundingClientRect()
1200
+ if (!pin) continue
1201
+ port.offset = {
1202
+ x: (pin.left + pin.width / 2 - rect.left) / zoom,
1203
+ y: (pin.top + pin.height / 2 - rect.top) / zoom,
1204
+ }
1205
+ }
1206
+ store.emit()
1207
+ }, [store, value])
1208
+
1209
+ // Position from props is the source of truth; keep the store in sync for edges/minimap.
1210
+ React.useLayoutEffect(() => {
1211
+ const node = store.ensure(value)
1212
+ node.position = position
1213
+ store.emit()
1214
+ }, [store, value, position])
1215
+ React.useLayoutEffect(() => {
1216
+ measure()
1217
+ const el = ref.current
1218
+ if (!el) return
1219
+ const observer = new ResizeObserver(() => measure())
1220
+ observer.observe(el)
1221
+ return () => observer.disconnect()
1222
+ }, [measure])
1223
+ React.useEffect(
1224
+ () => () => {
1225
+ store.nodes.delete(value)
1226
+ store.emit()
1227
+ },
1228
+ [store, value]
1229
+ )
1230
+
1231
+ const nodeCtx = React.useMemo<NodeContextValue>(
1232
+ () => ({
1233
+ value,
1234
+ registerPort: (portId, layout) => {
1235
+ const node = store.ensure(value)
1236
+ node.ports.set(portId, layout)
1237
+ measure()
1238
+ return () => {
1239
+ node.ports.delete(portId)
1240
+ store.emit()
1241
+ }
1242
+ },
1243
+ measure,
1244
+ }),
1245
+ [store, value, measure]
1246
+ )
1247
+
1248
+ return (
1249
+ <NodeContext.Provider value={nodeCtx}>
1250
+ <div
1251
+ ref={ref}
1252
+ data-slot="node-graph-node"
1253
+ data-value={value}
1254
+ data-selected={selected ? "" : undefined}
1255
+ data-dragging={draggingNodes && selected ? "" : undefined}
1256
+ tabIndex={0}
1257
+ className={cn(
1258
+ "group/node absolute top-0 left-0 flex w-56 flex-col rounded-lg border bg-card text-card-foreground shadow-md outline-none",
1259
+ "cursor-grab data-dragging:cursor-grabbing data-dragging:shadow-xl",
1260
+ "focus-visible:ring-2 focus-visible:ring-ring/50 data-selected:border-primary data-selected:ring-2 data-selected:ring-primary/40",
1261
+ className
1262
+ )}
1263
+ style={{ transform: `translate(${position.x}px, ${position.y}px)`, ...style }}
1264
+ onPointerDown={(event) => {
1265
+ onPointerDown?.(event)
1266
+ if (event.defaultPrevented || event.button !== 0) return
1267
+ if ((event.target as HTMLElement).closest(INTERACTIVE)) return
1268
+ if (graph.spaceHeld) return
1269
+ event.stopPropagation()
1270
+ startNodeDrag(value, event)
1271
+ }}
1272
+ onDoubleClick={(event) => {
1273
+ onDoubleClick?.(event)
1274
+ if (!event.defaultPrevented) graph.fitView({ ids: [value], padding: 80, maxZoom: 1.5 })
1275
+ }}
1276
+ onKeyDown={(event) => {
1277
+ onKeyDown?.(event)
1278
+ if (event.defaultPrevented || event.target !== event.currentTarget) return
1279
+ if (event.key === " " || event.key === "Enter") {
1280
+ event.preventDefault()
1281
+ onNodeClick(value, event)
1282
+ }
1283
+ }}
1284
+ {...props}
1285
+ >
1286
+ {children}
1287
+ </div>
1288
+ </NodeContext.Provider>
1289
+ )
1290
+ }
1291
+
1292
+ /** Header slot for buttons (menus, run triggers). Revealed on hover/selection; clicks never start a drag. */
1293
+ function NodeGraphNodeActions({ className, onPointerDown, ...props }: React.ComponentProps<"div">) {
1294
+ return (
1295
+ <div
1296
+ data-slot="node-graph-node-actions"
1297
+ className={cn(
1298
+ "ml-auto flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-focus-within/node:opacity-100 group-hover/node:opacity-100 group-data-selected/node:opacity-100",
1299
+ className
1300
+ )}
1301
+ onPointerDown={(event) => {
1302
+ onPointerDown?.(event)
1303
+ event.stopPropagation()
1304
+ }}
1305
+ {...props}
1306
+ />
1307
+ )
1308
+ }
1309
+
1310
+ function NodeGraphNodeHeader({ className, ...props }: React.ComponentProps<"div">) {
1311
+ return (
1312
+ <div
1313
+ data-slot="node-graph-node-header"
1314
+ className={cn(
1315
+ "flex items-center gap-2 rounded-t-[inherit] border-b bg-muted/60 px-3 py-2 text-sm font-medium [&_svg:not([class*='size-'])]:size-4",
1316
+ className
1317
+ )}
1318
+ {...props}
1319
+ />
1320
+ )
1321
+ }
1322
+
1323
+ function NodeGraphNodeTitle({ className, ...props }: React.ComponentProps<"div">) {
1324
+ return <div data-slot="node-graph-node-title" className={cn("truncate leading-tight", className)} {...props} />
1325
+ }
1326
+
1327
+ function NodeGraphNodeSubtitle({ className, ...props }: React.ComponentProps<"div">) {
1328
+ return (
1329
+ <div
1330
+ data-slot="node-graph-node-subtitle"
1331
+ className={cn("truncate text-xs font-normal text-muted-foreground", className)}
1332
+ {...props}
1333
+ />
1334
+ )
1335
+ }
1336
+
1337
+ function NodeGraphNodeBody({ className, ...props }: React.ComponentProps<"div">) {
1338
+ return <div data-slot="node-graph-node-body" className={cn("grid grid-cols-2 gap-x-2 py-2", className)} {...props} />
1339
+ }
1340
+
1341
+ function NodeGraphNodeInputs({ className, ...props }: React.ComponentProps<"div">) {
1342
+ return <div data-slot="node-graph-node-inputs" className={cn("flex flex-col gap-1", className)} {...props} />
1343
+ }
1344
+
1345
+ function NodeGraphNodeOutputs({ className, ...props }: React.ComponentProps<"div">) {
1346
+ return (
1347
+ <div
1348
+ data-slot="node-graph-node-outputs"
1349
+ className={cn("col-start-2 flex flex-col items-end gap-1", className)}
1350
+ {...props}
1351
+ />
1352
+ )
1353
+ }
1354
+
1355
+ function NodeGraphNodeFooter({ className, ...props }: React.ComponentProps<"div">) {
1356
+ return (
1357
+ <div
1358
+ data-slot="node-graph-node-footer"
1359
+ className={cn("rounded-b-[inherit] border-t px-3 py-1.5 text-xs text-muted-foreground", className)}
1360
+ {...props}
1361
+ />
1362
+ )
1363
+ }
1364
+
1365
+ /* ---------------------------------------------------------------------------
1366
+ * Ports
1367
+ * ------------------------------------------------------------------------- */
1368
+
1369
+ type PortContextValue = { value: string; side: NodeGraphPortSide; type: string; connected: boolean }
1370
+ const PortContext = React.createContext<PortContextValue | null>(null)
1371
+
1372
+ function NodeGraphPort({
1373
+ value,
1374
+ side,
1375
+ type = "any",
1376
+ connected = false,
1377
+ className,
1378
+ ...props
1379
+ }: React.ComponentProps<"div"> & { value: string; side: NodeGraphPortSide; type?: string; connected?: boolean }) {
1380
+ const ctx = React.useMemo(() => ({ value, side, type, connected }), [value, side, type, connected])
1381
+ return (
1382
+ <PortContext.Provider value={ctx}>
1383
+ <div
1384
+ data-slot="node-graph-port"
1385
+ data-side={side}
1386
+ data-type={type}
1387
+ data-connected={connected ? "" : undefined}
1388
+ className={cn(
1389
+ "flex min-h-6 items-center gap-1.5 text-xs",
1390
+ side === "input" ? "pr-2 pl-0" : "flex-row-reverse pr-0 pl-2",
1391
+ className
1392
+ )}
1393
+ {...props}
1394
+ />
1395
+ </PortContext.Provider>
1396
+ )
1397
+ }
1398
+
1399
+ const pinVariants = cva(
1400
+ "relative z-10 box-content shrink-0 cursor-crosshair border-2 border-current bg-background transition-transform hover:scale-125 data-connected:bg-current data-target:scale-125 data-target:ring-2 data-target:ring-current/40",
1401
+ {
1402
+ variants: {
1403
+ variant: {
1404
+ circle: "size-2.5 rounded-full",
1405
+ exec: "size-3 border-0 bg-current opacity-45 [clip-path:polygon(0_0,60%_0,100%_50%,60%_100%,0_100%)] data-connected:opacity-100",
1406
+ },
1407
+ },
1408
+ defaultVariants: { variant: "circle" },
1409
+ }
1410
+ )
1411
+
1412
+ /** The connection handle. Sits on the node's edge: inputs are pulled left, outputs right. */
1413
+ function NodeGraphPortPin({
1414
+ className,
1415
+ variant,
1416
+ onPointerDown,
1417
+ ...props
1418
+ }: React.ComponentProps<"div"> & VariantProps<typeof pinVariants>) {
1419
+ const graph = useNodeGraph()
1420
+ const node = useNodeGraphNode()
1421
+ const port = React.useContext(PortContext)
1422
+ if (!port) throw new Error("NodeGraphPortPin must be used within <NodeGraphPort>")
1423
+ const ref = React.useRef<HTMLDivElement>(null)
1424
+ const { registerPort } = node
1425
+ React.useLayoutEffect(
1426
+ () => registerPort(port.value, { side: port.side, type: port.type, el: ref.current, offset: { x: 0, y: 0 } }),
1427
+ [registerPort, port.value, port.side, port.type]
1428
+ )
1429
+ const connecting = graph.connecting
1430
+ const isTarget = connecting?.target?.nodeId === node.value && connecting.target.portId === port.value
1431
+ const isSource = connecting?.source.nodeId === node.value && connecting.source.portId === port.value
1432
+ return (
1433
+ <div
1434
+ ref={ref}
1435
+ data-slot="node-graph-port-pin"
1436
+ data-node-id={node.value}
1437
+ data-port-id={port.value}
1438
+ data-side={port.side}
1439
+ data-type={port.type}
1440
+ data-connected={port.connected || isSource ? "" : undefined}
1441
+ data-target={isTarget ? (connecting?.valid ? "valid" : "invalid") : undefined}
1442
+ aria-label={`${port.side} ${port.value}`}
1443
+ className={cn(
1444
+ pinVariants({ variant }),
1445
+ port.side === "input" ? "-ml-1.75" : "-mr-1.75",
1446
+ isTarget && !connecting?.valid && "cursor-not-allowed text-destructive",
1447
+ className
1448
+ )}
1449
+ onPointerDown={(event) => {
1450
+ onPointerDown?.(event)
1451
+ if (event.defaultPrevented || event.button !== 0) return
1452
+ event.stopPropagation()
1453
+ event.preventDefault()
1454
+ const details = { nodeId: node.value, portId: port.value, side: port.side, type: port.type }
1455
+ if (event.altKey) {
1456
+ graph.disconnect(details)
1457
+ return
1458
+ }
1459
+ const other = port.connected ? graph.edgeAt(details) : null
1460
+ if (other) {
1461
+ // Pick the existing link up: drop it, then keep dragging from its far end.
1462
+ graph.disconnect(details)
1463
+ graph.startConnection(other, event)
1464
+ return
1465
+ }
1466
+ graph.startConnection(details, event)
1467
+ }}
1468
+ {...props}
1469
+ />
1470
+ )
1471
+ }
1472
+
1473
+ function NodeGraphPortLabel({ className, ...props }: React.ComponentProps<"span">) {
1474
+ return <span data-slot="node-graph-port-label" className={cn("truncate", className)} {...props} />
1475
+ }
1476
+
1477
+ /* ---------------------------------------------------------------------------
1478
+ * Controls, minimap, empty
1479
+ * ------------------------------------------------------------------------- */
1480
+
1481
+ function NodeGraphControls({ className, ...props }: React.ComponentProps<"div">) {
1482
+ return (
1483
+ <div
1484
+ data-slot="node-graph-controls"
1485
+ className={cn(
1486
+ "absolute bottom-3 left-3 z-20 flex items-center gap-1 rounded-lg border bg-background/90 p-1 shadow-sm backdrop-blur-sm",
1487
+ className
1488
+ )}
1489
+ onPointerDown={(event) => event.stopPropagation()}
1490
+ {...props}
1491
+ />
1492
+ )
1493
+ }
1494
+
1495
+ type ControlTriggerProps = React.ComponentProps<typeof Button>
1496
+
1497
+ function NodeGraphZoomInTrigger({ asChild, children, onClick, ...props }: ControlTriggerProps) {
1498
+ const { zoomIn } = useNodeGraph()
1499
+ return (
1500
+ <Button
1501
+ data-slot="node-graph-zoom-in-trigger"
1502
+ variant="ghost"
1503
+ size="icon-sm"
1504
+ asChild={asChild}
1505
+ aria-label="Zoom in"
1506
+ onClick={(event) => {
1507
+ onClick?.(event)
1508
+ if (!event.defaultPrevented) zoomIn()
1509
+ }}
1510
+ {...props}
1511
+ >
1512
+ {asChild ? children : (children ?? <PlusIcon />)}
1513
+ </Button>
1514
+ )
1515
+ }
1516
+
1517
+ function NodeGraphZoomOutTrigger({ asChild, children, onClick, ...props }: ControlTriggerProps) {
1518
+ const { zoomOut } = useNodeGraph()
1519
+ return (
1520
+ <Button
1521
+ data-slot="node-graph-zoom-out-trigger"
1522
+ variant="ghost"
1523
+ size="icon-sm"
1524
+ asChild={asChild}
1525
+ aria-label="Zoom out"
1526
+ onClick={(event) => {
1527
+ onClick?.(event)
1528
+ if (!event.defaultPrevented) zoomOut()
1529
+ }}
1530
+ {...props}
1531
+ >
1532
+ {asChild ? children : (children ?? <MinusIcon />)}
1533
+ </Button>
1534
+ )
1535
+ }
1536
+
1537
+ function NodeGraphFitViewTrigger({ asChild, children, onClick, ...props }: ControlTriggerProps) {
1538
+ const { fitView } = useNodeGraph()
1539
+ return (
1540
+ <Button
1541
+ data-slot="node-graph-fit-view-trigger"
1542
+ variant="ghost"
1543
+ size="icon-sm"
1544
+ asChild={asChild}
1545
+ aria-label="Fit view"
1546
+ onClick={(event) => {
1547
+ onClick?.(event)
1548
+ if (!event.defaultPrevented) fitView()
1549
+ }}
1550
+ {...props}
1551
+ >
1552
+ {asChild ? children : (children ?? <MaximizeIcon />)}
1553
+ </Button>
1554
+ )
1555
+ }
1556
+
1557
+ /** Current zoom as a percentage; click resets to 100%. */
1558
+ function NodeGraphZoomValue({
1559
+ className,
1560
+ asChild,
1561
+ children,
1562
+ onClick,
1563
+ ...props
1564
+ }: React.ComponentProps<typeof ark.button>) {
1565
+ const { viewport, zoomTo } = useNodeGraph()
1566
+ return (
1567
+ <ark.button
1568
+ data-slot="node-graph-zoom-value"
1569
+ type="button"
1570
+ asChild={asChild}
1571
+ aria-label="Reset zoom"
1572
+ className={cn(
1573
+ "h-7 min-w-12 rounded-md px-2 text-xs font-medium text-muted-foreground tabular-nums hover:bg-muted hover:text-foreground",
1574
+ className
1575
+ )}
1576
+ onClick={(event) => {
1577
+ onClick?.(event)
1578
+ if (!event.defaultPrevented) zoomTo(1)
1579
+ }}
1580
+ {...props}
1581
+ >
1582
+ {asChild ? children : (children ?? `${Math.round(viewport.zoom * 100)}%`)}
1583
+ </ark.button>
1584
+ )
1585
+ }
1586
+
1587
+ function NodeGraphMinimap({
1588
+ className,
1589
+ width = 180,
1590
+ height = 120,
1591
+ ...props
1592
+ }: React.ComponentProps<"svg"> & { width?: number; height?: number }) {
1593
+ const { store, viewport, size, selection, centerOn } = useNodeGraph()
1594
+ useLayoutVersion(store)
1595
+ const nodes = [...store.nodes]
1596
+ const view = {
1597
+ x: -viewport.x / viewport.zoom,
1598
+ y: -viewport.y / viewport.zoom,
1599
+ width: size.width / viewport.zoom,
1600
+ height: size.height / viewport.zoom,
1601
+ }
1602
+ const bounds = store.getBounds() ?? view
1603
+ const minX = Math.min(bounds.x, view.x)
1604
+ const minY = Math.min(bounds.y, view.y)
1605
+ const maxX = Math.max(bounds.x + bounds.width, view.x + view.width)
1606
+ const maxY = Math.max(bounds.y + bounds.height, view.y + view.height)
1607
+ const pad = Math.max(maxX - minX, maxY - minY) * 0.05
1608
+ const viewBox = `${minX - pad} ${minY - pad} ${maxX - minX + pad * 2} ${maxY - minY + pad * 2}`
1609
+ const scale = (maxX - minX + pad * 2) / width
1610
+
1611
+ // Match "meet" scaling: the viewBox is centred inside the svg box.
1612
+ const pointToGraph = (svg: SVGSVGElement, client: XY) => {
1613
+ const rect = svg.getBoundingClientRect()
1614
+ const vbW = maxX - minX + pad * 2
1615
+ const vbH = maxY - minY + pad * 2
1616
+ const s = Math.max(vbW / rect.width, vbH / rect.height)
1617
+ const offsetX = (rect.width * s - vbW) / 2
1618
+ const offsetY = (rect.height * s - vbH) / 2
1619
+ return { x: minX - pad - offsetX + (client.x - rect.left) * s, y: minY - pad - offsetY + (client.y - rect.top) * s }
1620
+ }
1621
+
1622
+ return (
1623
+ <svg
1624
+ data-slot="node-graph-minimap"
1625
+ viewBox={viewBox}
1626
+ width={width}
1627
+ height={height}
1628
+ className={cn(
1629
+ "absolute right-3 bottom-3 z-20 cursor-pointer rounded-lg border bg-background/90 shadow-sm backdrop-blur-sm",
1630
+ className
1631
+ )}
1632
+ onPointerDown={(event) => {
1633
+ event.stopPropagation()
1634
+ event.preventDefault()
1635
+ const svg = event.currentTarget
1636
+ centerOn(pointToGraph(svg, { x: event.clientX, y: event.clientY }))
1637
+ const move = (e: PointerEvent) => centerOn(pointToGraph(svg, { x: e.clientX, y: e.clientY }))
1638
+ const up = () => {
1639
+ window.removeEventListener("pointermove", move)
1640
+ window.removeEventListener("pointerup", up)
1641
+ }
1642
+ window.addEventListener("pointermove", move)
1643
+ window.addEventListener("pointerup", up)
1644
+ }}
1645
+ {...props}
1646
+ >
1647
+ {nodes.map(([id, node]) => (
1648
+ <rect
1649
+ key={id}
1650
+ data-slot="node-graph-minimap-node"
1651
+ data-selected={selection.nodes.includes(id) ? "" : undefined}
1652
+ x={node.position.x}
1653
+ y={node.position.y}
1654
+ width={node.width}
1655
+ height={node.height}
1656
+ rx={4 * scale}
1657
+ className="fill-muted-foreground/40 data-selected:fill-primary"
1658
+ />
1659
+ ))}
1660
+ <rect
1661
+ data-slot="node-graph-minimap-viewport"
1662
+ x={view.x}
1663
+ y={view.y}
1664
+ width={view.width}
1665
+ height={view.height}
1666
+ className="fill-primary/10 stroke-primary/60"
1667
+ strokeWidth={scale}
1668
+ />
1669
+ </svg>
1670
+ )
1671
+ }
1672
+
1673
+ function NodeGraphEmpty({ className, ...props }: React.ComponentProps<"div">) {
1674
+ return (
1675
+ <div
1676
+ data-slot="node-graph-empty"
1677
+ className={cn(
1678
+ "pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-1 text-center text-sm text-muted-foreground",
1679
+ className
1680
+ )}
1681
+ {...props}
1682
+ />
1683
+ )
1684
+ }
1685
+
1686
+ export {
1687
+ NodeGraph,
1688
+ NodeGraphViewport,
1689
+ NodeGraphBackground,
1690
+ NodeGraphSurface,
1691
+ NodeGraphEdges,
1692
+ NodeGraphEdge,
1693
+ NodeGraphConnectionLine,
1694
+ NodeGraphSelectionBox,
1695
+ NodeGraphNode,
1696
+ NodeGraphNodeHeader,
1697
+ NodeGraphNodeActions,
1698
+ NodeGraphNodeTitle,
1699
+ NodeGraphNodeSubtitle,
1700
+ NodeGraphNodeBody,
1701
+ NodeGraphNodeInputs,
1702
+ NodeGraphNodeOutputs,
1703
+ NodeGraphNodeFooter,
1704
+ NodeGraphPort,
1705
+ NodeGraphPortPin,
1706
+ NodeGraphPortLabel,
1707
+ NodeGraphControls,
1708
+ NodeGraphZoomInTrigger,
1709
+ NodeGraphZoomOutTrigger,
1710
+ NodeGraphFitViewTrigger,
1711
+ NodeGraphZoomValue,
1712
+ NodeGraphMinimap,
1713
+ NodeGraphEmpty,
1714
+ useNodeGraph,
1715
+ getBezierPath,
1716
+ type NodeGraphViewportState,
1717
+ type NodeGraphPortRef,
1718
+ type NodeGraphPortDetails,
1719
+ type NodeGraphPortSide,
1720
+ type NodeGraphConnection,
1721
+ type NodeGraphSelection,
1722
+ type NodeGraphNodeMove,
1723
+ }