@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.
- package/README.md +78 -0
- package/bin/arkcn.mjs +78 -0
- package/cli/commands.mjs +251 -0
- package/cli/project.mjs +168 -0
- package/cli/registry.mjs +50 -0
- package/cli/ui.mjs +46 -0
- package/mcp/server.mjs +149 -0
- package/package.json +201 -0
- package/registry/base.css +680 -0
- package/registry/index.json +1833 -0
- package/registry/items/accordion.json +18 -0
- package/registry/items/activity-feed.json +18 -0
- package/registry/items/alert-dialog.json +18 -0
- package/registry/items/alert.json +17 -0
- package/registry/items/angle-slider.json +17 -0
- package/registry/items/app-shell.json +28 -0
- package/registry/items/aspect-ratio.json +13 -0
- package/registry/items/attachment.json +19 -0
- package/registry/items/avatar.json +17 -0
- package/registry/items/badge.json +18 -0
- package/registry/items/breadcrumb.json +18 -0
- package/registry/items/bubble.json +18 -0
- package/registry/items/button-group.json +19 -0
- package/registry/items/button.json +18 -0
- package/registry/items/calendar.json +19 -0
- package/registry/items/canvas.json +24 -0
- package/registry/items/card.json +15 -0
- package/registry/items/carousel.json +19 -0
- package/registry/items/cascader.json +23 -0
- package/registry/items/chart.json +17 -0
- package/registry/items/chat.json +23 -0
- package/registry/items/checkbox.json +18 -0
- package/registry/items/clipboard.json +19 -0
- package/registry/items/collapsible.json +15 -0
- package/registry/items/color-picker.json +19 -0
- package/registry/items/combobox.json +20 -0
- package/registry/items/command.json +21 -0
- package/registry/items/comment-thread.json +25 -0
- package/registry/items/context-menu.json +18 -0
- package/registry/items/controllable.json +14 -0
- package/registry/items/data-grid.json +24 -0
- package/registry/items/data-table-adapter.json +14 -0
- package/registry/items/data-table.json +32 -0
- package/registry/items/date-input.json +17 -0
- package/registry/items/dialog.json +19 -0
- package/registry/items/direction.json +13 -0
- package/registry/items/drag-preview.json +16 -0
- package/registry/items/drawer.json +18 -0
- package/registry/items/dropdown-menu.json +18 -0
- package/registry/items/editable.json +17 -0
- package/registry/items/empty.json +17 -0
- package/registry/items/field.json +19 -0
- package/registry/items/file-upload.json +19 -0
- package/registry/items/floating-panel.json +19 -0
- package/registry/items/floating-toolbar.json +16 -0
- package/registry/items/form.json +14 -0
- package/registry/items/format.json +15 -0
- package/registry/items/gantt.json +22 -0
- package/registry/items/history.json +14 -0
- package/registry/items/hotkeys.json +18 -0
- package/registry/items/hover-card.json +17 -0
- package/registry/items/image-cropper.json +17 -0
- package/registry/items/input-group.json +20 -0
- package/registry/items/input-otp.json +18 -0
- package/registry/items/input.json +15 -0
- package/registry/items/item.json +19 -0
- package/registry/items/json-tree-view.json +17 -0
- package/registry/items/kanban.json +26 -0
- package/registry/items/kbd.json +15 -0
- package/registry/items/label.json +15 -0
- package/registry/items/live-region.json +16 -0
- package/registry/items/marker.json +18 -0
- package/registry/items/marquee.json +17 -0
- package/registry/items/menubar.json +19 -0
- package/registry/items/message.json +15 -0
- package/registry/items/navigation-menu.json +19 -0
- package/registry/items/node-graph.json +22 -0
- package/registry/items/number-input.json +18 -0
- package/registry/items/pagination.json +18 -0
- package/registry/items/password-input.json +18 -0
- package/registry/items/popconfirm.json +20 -0
- package/registry/items/popover.json +18 -0
- package/registry/items/progress.json +17 -0
- package/registry/items/qr-code.json +17 -0
- package/registry/items/query-builder.json +23 -0
- package/registry/items/radio-group.json +17 -0
- package/registry/items/rating-group.json +18 -0
- package/registry/items/resizable.json +17 -0
- package/registry/items/rich-text-editor.json +37 -0
- package/registry/items/row-layout.json +15 -0
- package/registry/items/scheduler.json +23 -0
- package/registry/items/scroll-area.json +17 -0
- package/registry/items/segment-group.json +17 -0
- package/registry/items/select.json +19 -0
- package/registry/items/separator.json +15 -0
- package/registry/items/sheet.json +19 -0
- package/registry/items/sidebar.json +27 -0
- package/registry/items/signature-pad.json +19 -0
- package/registry/items/skeleton.json +15 -0
- package/registry/items/slider.json +17 -0
- package/registry/items/sonner.json +17 -0
- package/registry/items/spinner.json +17 -0
- package/registry/items/steps.json +19 -0
- package/registry/items/swap.json +17 -0
- package/registry/items/switch.json +17 -0
- package/registry/items/table.json +16 -0
- package/registry/items/tabs.json +18 -0
- package/registry/items/tags-input.json +18 -0
- package/registry/items/textarea.json +15 -0
- package/registry/items/time.json +14 -0
- package/registry/items/timer.json +18 -0
- package/registry/items/toc.json +17 -0
- package/registry/items/toggle-group.json +19 -0
- package/registry/items/toggle.json +18 -0
- package/registry/items/tooltip.json +17 -0
- package/registry/items/tour.json +19 -0
- package/registry/items/transfer-list.json +24 -0
- package/registry/items/tree-select.json +24 -0
- package/registry/items/tree-view.json +18 -0
- package/registry/items/use-mobile.json +13 -0
- package/registry/items/utils.json +17 -0
- package/registry/items/virtual-list.json +16 -0
- package/src/components/ui/accordion.tsx +111 -0
- package/src/components/ui/activity-feed.tsx +228 -0
- package/src/components/ui/alert-dialog.tsx +204 -0
- package/src/components/ui/alert.tsx +55 -0
- package/src/components/ui/angle-slider.tsx +117 -0
- package/src/components/ui/app-shell.tsx +985 -0
- package/src/components/ui/aspect-ratio.tsx +35 -0
- package/src/components/ui/attachment.tsx +193 -0
- package/src/components/ui/avatar.tsx +94 -0
- package/src/components/ui/badge.tsx +45 -0
- package/src/components/ui/breadcrumb.tsx +93 -0
- package/src/components/ui/bubble.tsx +119 -0
- package/src/components/ui/button-group.tsx +79 -0
- package/src/components/ui/button.tsx +65 -0
- package/src/components/ui/calendar.tsx +172 -0
- package/src/components/ui/canvas.tsx +723 -0
- package/src/components/ui/card.tsx +69 -0
- package/src/components/ui/carousel.tsx +228 -0
- package/src/components/ui/cascader.tsx +754 -0
- package/src/components/ui/chart.tsx +303 -0
- package/src/components/ui/chat.tsx +544 -0
- package/src/components/ui/checkbox.tsx +94 -0
- package/src/components/ui/clipboard.tsx +94 -0
- package/src/components/ui/collapsible.tsx +32 -0
- package/src/components/ui/color-picker.tsx +441 -0
- package/src/components/ui/combobox.tsx +400 -0
- package/src/components/ui/command.tsx +292 -0
- package/src/components/ui/comment-thread.tsx +923 -0
- package/src/components/ui/context-menu.tsx +331 -0
- package/src/components/ui/data-grid.tsx +1227 -0
- package/src/components/ui/data-table.tsx +993 -0
- package/src/components/ui/date-input.tsx +103 -0
- package/src/components/ui/dialog.tsx +159 -0
- package/src/components/ui/direction.tsx +32 -0
- package/src/components/ui/drawer.tsx +200 -0
- package/src/components/ui/dropdown-menu.tsx +329 -0
- package/src/components/ui/editable.tsx +90 -0
- package/src/components/ui/empty.tsx +86 -0
- package/src/components/ui/field.tsx +218 -0
- package/src/components/ui/file-upload.tsx +204 -0
- package/src/components/ui/floating-panel.tsx +199 -0
- package/src/components/ui/floating-toolbar.tsx +79 -0
- package/src/components/ui/format.tsx +22 -0
- package/src/components/ui/gantt.tsx +950 -0
- package/src/components/ui/hotkeys.tsx +340 -0
- package/src/components/ui/hover-card.tsx +83 -0
- package/src/components/ui/image-cropper.tsx +100 -0
- package/src/components/ui/input-group.tsx +133 -0
- package/src/components/ui/input-otp.tsx +82 -0
- package/src/components/ui/input.tsx +18 -0
- package/src/components/ui/item.tsx +174 -0
- package/src/components/ui/json-tree-view.tsx +37 -0
- package/src/components/ui/kanban.tsx +884 -0
- package/src/components/ui/kbd.tsx +20 -0
- package/src/components/ui/label.tsx +30 -0
- package/src/components/ui/live-region.tsx +54 -0
- package/src/components/ui/marker.tsx +66 -0
- package/src/components/ui/marquee.tsx +61 -0
- package/src/components/ui/menubar.tsx +456 -0
- package/src/components/ui/message.tsx +74 -0
- package/src/components/ui/navigation-menu.tsx +202 -0
- package/src/components/ui/node-graph.tsx +1723 -0
- package/src/components/ui/number-input.tsx +148 -0
- package/src/components/ui/pagination.tsx +88 -0
- package/src/components/ui/password-input.tsx +104 -0
- package/src/components/ui/popconfirm.tsx +149 -0
- package/src/components/ui/popover.tsx +126 -0
- package/src/components/ui/progress.tsx +114 -0
- package/src/components/ui/qr-code.tsx +51 -0
- package/src/components/ui/query-builder.tsx +938 -0
- package/src/components/ui/radio-group.tsx +108 -0
- package/src/components/ui/rating-group.tsx +91 -0
- package/src/components/ui/resizable.tsx +41 -0
- package/src/components/ui/rich-text-editor.tsx +845 -0
- package/src/components/ui/scheduler.tsx +1164 -0
- package/src/components/ui/scroll-area.tsx +82 -0
- package/src/components/ui/segment-group.tsx +95 -0
- package/src/components/ui/select.tsx +354 -0
- package/src/components/ui/separator.tsx +32 -0
- package/src/components/ui/sheet.tsx +130 -0
- package/src/components/ui/sidebar.tsx +665 -0
- package/src/components/ui/signature-pad.tsx +112 -0
- package/src/components/ui/skeleton.tsx +7 -0
- package/src/components/ui/slider.tsx +183 -0
- package/src/components/ui/sonner.tsx +37 -0
- package/src/components/ui/spinner.tsx +16 -0
- package/src/components/ui/steps.tsx +152 -0
- package/src/components/ui/swap.tsx +33 -0
- package/src/components/ui/switch.tsx +83 -0
- package/src/components/ui/table.tsx +72 -0
- package/src/components/ui/tabs.tsx +100 -0
- package/src/components/ui/tags-input.tsx +153 -0
- package/src/components/ui/textarea.tsx +17 -0
- package/src/components/ui/timer.tsx +67 -0
- package/src/components/ui/toc.tsx +64 -0
- package/src/components/ui/toggle-group.tsx +89 -0
- package/src/components/ui/toggle.tsx +46 -0
- package/src/components/ui/tooltip.tsx +113 -0
- package/src/components/ui/tour.tsx +168 -0
- package/src/components/ui/transfer-list.tsx +700 -0
- package/src/components/ui/tree-select.tsx +638 -0
- package/src/components/ui/tree-view.tsx +217 -0
- package/src/components/ui/virtual-list.tsx +343 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/lib/controllable.ts +31 -0
- package/src/lib/data-table-adapter.ts +67 -0
- package/src/lib/drag-preview.ts +39 -0
- package/src/lib/form.ts +39 -0
- package/src/lib/history.ts +55 -0
- package/src/lib/row-layout.ts +81 -0
- package/src/lib/table.ts +483 -0
- package/src/lib/time.ts +37 -0
- package/src/lib/utils.ts +6 -0
- package/src/styles/arkcn.css +764 -0
- 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
|
+
}
|