Node Canvas
A pannable, zoomable canvas of draggable nodes and the edges between them — the substrate for an agent pipeline, a retrieval chain or a build DAG. Nodes are real DOM, so you put your own components inside them.
src/components/ui/node-canvas.tsxAbout
From the component's own source, where it was written.
A pannable, zoomable canvas of draggable nodes and the edges between them.
The substrate for anything graph-shaped: an agent pipeline, a retrieval chain, a build DAG, a state machine. Nothing here knows what a node *means* — you register node types, and the canvas only owns position, selection and the wires.
**Why it is not a <canvas>.** Nodes are real DOM, so their contents stay selectable, focusable and readable by a screen reader, and a node can be a whole component — a form, a chart, a query builder — rather than a picture of one. Only the edges are drawn, in one SVG layer underneath.
**Event ownership.** A node's contents belong to whoever wrote them, so every gesture here asks *what was actually hit* before acting on it. Dragging skips controls and anything marked data-nodrag; the wheel skips a scrollable region; keys are only the canvas's when the node itself has focus, not a field inside it. Without those checks a text input inside a node cannot take a space, a Backspace deletes the node instead of a character, and dragging to select text moves the node — which is why this used to need a stopPropagation wrapper that made the region undraggable in exchange.
**A drag starts on movement, not on contact.** Nothing is captured until the pointer travels dragThreshold pixels. A click stays a click, so buttons, switches and links inside a node work, and the pointer capture that a slider or a colour picker takes for itself is never stolen.
**Coordinates.** Nodes are positioned in graph space; the whole layer is moved and scaled by a single transform on a wrapper, so panning and zooming cost one style write rather than one per node. toGraph converts a pointer position back through that transform — without it, dragging a node while zoomed moves it by the wrong distance, which is the classic bug in a hand-rolled canvas.
**Keyboard.** Every node is a tab stop, arrows nudge the focused node, and shift-arrow nudges it by ten. Every edge carries a real button to detach it. A graph editor reachable only by mouse is not an editor for everyone, and the arrow-key path is also the precise one.
Install
npx astralyx-ui add node-canvas
Usage
import { NodeCanvas } from '@/components/ui/node-canvas'
// Node types are ordinary components, declared once, referred to by name.
const nodeTypes = { step: StepNode, webhook: WebhookNode }
<NodeCanvas
nodes={nodes} // [{ id: 'a', type: 'step', x: 0, y: 60 }, …]
edges={edges}
nodeTypes={nodeTypes}
onNodesChange={setNodes}
onConnect={(from, to) => setEdges([...edges, { id: `${from}-${to}`, from, to }])}
onRemoveEdge={(id) => setEdges(edges.filter((edge) => edge.id !== id))}
onAddConnected={(from, at) => addStep(from, at)}
/>Composer
Change the props and the source below follows.
Steps
Incoming message
Plan the reply
search_docs
create_ticket
Send reply
<NodeCanvas
nodes={nodes}
edges={edges}
nodeTypes={{ step: StepNode }}
height={340}
onNodesChange={setNodes}
onConnect={connect}
onAddConnected={addStep}
onDropNode={dropStep}
onRemoveEdge={detach}
/>Examples
An agent pipeline you can edit
<NodeCanvas nodes={nodes} edges={edges} onNodesChange={setNodes} onConnect={connect} />Nodes that are whole components
// Node types are ordinary components, declared once and referred to by name.
function WebhookNode() {
const [url, setUrl] = useState('')
return (
<div className="space-y-2.5">
{/* Marked as the grip, so the body below stays entirely usable. */}
<div data-drag-handle className="…">On completion</div>
<Input size="sm" value={url} onChange={(e) => setUrl(e.target.value)} />
<Switch size="sm" label="Retry on 5xx" defaultChecked />
</div>
)
}
function QueryNode() {
// The node reads and writes itself — no callback threaded down from the top.
const { node, update } = useCanvasNode()
return <QueryConstructor tables={tables} value={node.data} onChange={(q) => update({ data: q })} />
}
const nodeTypes = { webhook: WebhookNode, query: QueryNode, note: NoteNode }
<NodeCanvas
nodes={nodes} // [{ id: 'hook', type: 'webhook', x: 20, y: 30 }, …]
edges={edges}
nodeTypes={nodeTypes}
onNodesChange={setNodes}
onConnect={connect}
onRemoveEdge={(id) => setEdges(edges.filter((e) => e.id !== id))}
/>Adding nodes, and typing in them
<NodeCanvas
nodes={nodes}
edges={edges}
nodeTypes={{ blank: BlankNode }}
onNodesChange={setNodes}
onConnect={connect}
snapToGrid
onAddNode={(at) => add(at)} // double-click empty canvas
onAddConnected={(from, at) => add(at, from)} // the + on a node
onRemoveEdge={(id) => detach(id)} // the × on a hovered wire
/>Read-only
<NodeCanvas nodes={nodes} edges={edges} />API
Generated from the source, so it cannot fall behind the code. One table per exported component, then hooks, functions and types.
NodeCanvas
Extends Omit<ComponentProps<'div'>, 'onSelect'>.
| Prop | Type | Default | Description |
|---|---|---|---|
| nodes | CanvasNode[] | required | Positions are graph units, not pixels. A node carries a type naming its component and a data payload; nothing about how it looks lives in the graph, so it survives JSON. |
| edges | CanvasEdge[] | [] | Positions are graph units, not pixels. A node carries a type naming its component and a data payload; nothing about how it looks lives in the graph, so it survives JSON. |
| nodeTypes | NodeTypes | — | Your node components, by node.type. Write them as ordinary components — a form, a chart, a whole query builder — and the graph only carries the name. Declare the map at module scope: a component created during render is a new component type each time, which remounts every node and takes focus out of the field being typed in. |
| renderNode | (node: CanvasNode, state: { selected: boolean }) => ReactNode | — | Draws any node without a registered type. Still supported, and still the shortest path for a graph of one shape. |
| onNodesChange | (nodes: CanvasNode[]) => void | — | Dragging and arrow-key nudges. Omit it and the canvas is read-only, though nodes stay focusable. |
| selectedId | string | null | — | — |
| onSelect | (id: string | null) => void | — | — |
| nodeWidth | number | 180 | Uniform node width in graph units. Individual nodes may override it. |
| grid | number | 24 | Background dot spacing in graph units. 0 turns the grid off. |
| snapToGrid | boolean | false | Rounds to grid while dragging and nudging. The dots implied an alignment model the canvas did not have. |
| minZoom | number | 0.35 | Clamp for the wheel zoom. |
| maxZoom | number | 2.5 | — |
| zoomOnWheel | boolean | true | Wheel over the canvas zooms, and the page stays put. |
| defaultPan | Point | — | Start position and scale of the viewport. |
| defaultZoom | number | 1 | — |
| height | number | string | 420 | Canvas height. Graphs need a stated box; they have no intrinsic one. |
| label | string | 'Node graph' | Accessible name for the graph region. |
| nodeLabel | (node: CanvasNode) => string | — | Names a node for a screen reader, when its contents are not text. |
| nudge | number | 8 | How far an arrow key moves a node, in graph units. |
| dragThreshold | number | 4 | A drag begins on movement, not on contact, and nothing is captured until then. That is what keeps a click a click, so a button inside a node fires and a slider keeps the pointer capture it took for itself. |
| onAddNode | (position: Point) => void | — | Double-click empty canvas, and drops from NodePalette. Both receive graph coordinates, already corrected for pan and zoom. |
| onDropNode | (payload: string, position: Point) => void | — | Double-click empty canvas, and drops from NodePalette. Both receive graph coordinates, already corrected for pan and zoom. |
| onConnect | (from: string, to: string) => void | — | Drag from a node’s trailing port onto another node — dropping anywhere on it counts, not only on the 12px dot. Also what makes the ports render at all. A self-link, a duplicate, and a target marked connectable: false are refused before you are asked. |
| isValidConnection | (from: string, to: string) => boolean | — | The domain rule on top of those. Whether a cycle is legal is yours to decide — a state machine wants them, a build DAG does not. |
| onRemoveNode | (id: string) => void | — | Backspace or Delete with a node focused, unless the node sets deletable: false. |
| onRemoveEdge | (id: string) => void | — | Detaching a connection. Hovering a wire reveals a × at its midpoint; it is a real button, so a keyboard reaches it too. Omit and edges are inert. |
| onAddConnected | (fromId: string, position: Point) => void | — | The + on a node: create a step already wired to it. A real button, so chaining has a keyboard path even though dragging a wire does not. |
| addConnectedLabel | string | 'Add a connected step' | Accessible name for that button. Receives the source node's label. |
| removeEdgeLabel | string | 'Detach this connection' | Accessible name for an edge's detach button. |
| onViewportChange | (viewport: { pan: Point; zoom: number }) => void | — | Reports pan and zoom, for a caller that wants to persist the viewport. |
| content | automatic | — | A press that lands on a control reaches the control, not the drag: inputs, buttons, switches, links, sliders and anything marked data-nodrag are skipped. Keys belong to the node only while the node itself has focus, so a space types, Backspace edits and the arrows move the caret. A scrollable region inside a node keeps its own wheel. |
NodePalette
The tray of things you can drag onto a canvas.
Extends Omit<ComponentProps<'div'>, 'onSelect'>.
| Prop | Type | Default | Description |
|---|---|---|---|
| items | { id: string; label: ReactNode; hint?: ReactNode }[] | required | — |
| onPick | (id: string) => void | — | Clicked rather than dragged — place it wherever your layout prefers. |
Hooks
| Hook | Signature | Description |
|---|---|---|
| useCanvasNode | () => CanvasContextValue | Read at any depth inside a node. update({ data }) writes the node back without a callback threaded down from the top, which is what lets a node own its own state. |
Functions
| Function | Signature | Description |
|---|---|---|
| NODE_DRAG_TYPE | 'application/x-astralyx-node' | The dataTransfer type NodePalette writes and the canvas reads. |
| edgePath | (from: Point, to: Point) => string | A cubic bezier with horizontal control arms. |
Types
CanvasNode
A pannable, zoomable canvas of draggable nodes and the edges between them.
| Field | Type | Description |
|---|---|---|
| id | string | — |
| x | number | — |
| y | number | — |
| width? | number | 'auto' | Falls back to the canvas-level nodeWidth. 'auto' sizes to content. |
| type? | string | Which registered nodeTypes entry draws this node. |
| label? | ReactNode | — |
| data? | unknown | Mark a title bar with it and the policy inverts — only that strip drags the node. What a node whose whole surface is a form needs, because there is no leftover pixel to grab. Presence, not a selector prop: a selector that matches nothing produces a node that silently cannot be moved. |
| connectable? | boolean | Off for a terminal step that nothing may follow. Defaults to on. |
| draggable? | boolean | Off to pin a node in place while the rest of the graph stays draggable. |
| deletable? | boolean | Off for a node the graph cannot exist without — a trigger, an entry point. |
| z? | number | Stacking order. Selected and dragging nodes rise above it regardless. |
CanvasEdge
| Field | Type | Description |
|---|---|---|
| id | string | — |
| from | string | — |
| to | string | — |
| label? | ReactNode | Rides the middle of the curve — which branch this is, what it carries. |
| dashed? | boolean | Draws dashed — for a path that is speculative or inactive. |
| deletable? | boolean | Off for a connection that must not be detached from the canvas. |
CanvasNodeProps
What a registered node type is rendered with.
| Field | Type | Description |
|---|---|---|
| node | CanvasNode | — |
| selected | boolean | — |
| dragging | boolean | True while this node is being dragged — for a lifted shadow, say. |
| Type | Definition | Description |
|---|---|---|
NodeTypes | Record<string, ComponentType<CanvasNodeProps>> | — |
Behaviour
| Topic | Detail | Description |
|---|---|---|
| zoom / pan | wheel, background drag | Zoom is anchored to the pointer, so the point under the cursor stays put. minZoom, maxZoom, defaultPan and defaultZoom are all props. |
| keyboard | Tab / arrows / Shift+arrows | Every node is a tab stop; arrows nudge by nudge units and Shift by ten times that. Precise placement is easier this way than with a pointer. Keys are only the canvas’s while the node itself holds focus. |
| accessibility | role="group" | Not role="button". A button’s contents are presentational to a screen reader, so every field inside a node used to vanish from the accessibility tree — and a node whose body is a form is not a button. |
| touch | known gap | The viewport sets touch-action: none so a drag is never stolen by a scroll, and that intersects down the tree — so a scrollable region inside a node cannot be flicked with a finger, and there is no pinch-zoom. The wheel and the pointer paths are complete. |