Canvas

A node editor on UI SDK's canvas behaviors: pan and zoom, drag nodes or move them with Alt and arrows, spatial focus, Shift-drag box selection, wires drawn by pointer or with C and arrows, a minimap, zoom controls, a toolbar, an inspector, undo, and agent actions.

A node editor composed from UI SDK's canvas behaviors: createViewport pans and zooms, createMove drags nodes, createSelection and createLasso choose them, spatialMove sends focus to the nearest node, and createConnect draws wires. People use a pointer or the keyboard; agents use the same actions by the names people see.

pnpm dlx shadcn@latest add superzero11/uiset/canvas

Parts

PartWhat it is
CanvasThe editor: dot grid, nodes with ports, wires, zoom controls and a minimap
CanvasToolbarOn UI SDK's Toolbar: Add node (by kind), remove the selection, undo, redo, fit view
CanvasInspectorThe selected node: rename it, list its wires with Disconnect, and your own fields
useCanvasHistoryNodes and edges with undo and redo; one step per move, add, wire or removal
createCanvasControllerConnects the toolbar, inspector or your code to the canvas

Use

tsx
const [canvas] = useState(createCanvasController)
const history = useCanvasHistory({ nodes, edges })
const [selected, setSelected] = useState<string[]>([])

<CanvasToolbar canvas={canvas} kinds={kinds} selectedCount={selected.length}
  onUndo={history.undo} onRedo={history.redo} canUndo={history.canUndo} canRedo={history.canRedo} />
<Canvas
  label="Workflow"
  controller={canvas}
  nodes={history.nodes}
  edges={history.edges}
  onNodesChange={history.onNodesChange}
  onEdgesChange={history.onEdgesChange}
  selected={selected}
  onSelectedChange={setSelected}
  kinds={kinds}
  onAddNode={(kind, at, title) => ({ id: crypto.randomUUID(), kind, title: title ?? "New step", ...at, ...portsFor(kind) })}
  renderNode={(node) => node.data?.summary}
  onUndo={history.undo}
  onRedo={history.redo}
  fitView
/>
<CanvasInspector node={one(selected)} nodes={history.nodes} edges={history.edges} canvas={canvas}
  onRename={rename}>{(node) => <YourFields node={node} />}</CanvasInspector>
  • A node is { id, title, x, y, inputs?, outputs?, kind?, color?, width?, data? }; a port is { id, label }. Ids may not contain :.
  • An edge is { id, from, to }, with ports keyed "node:out:port" and "node:in:port". Wires run from an output to an input on another node; each input takes one wire, and a new one replaces it. canConnect(from, to) adds your own rules, like matching types.
  • Nodes, edges and the selection are controlled or not (defaultNodes, defaultEdges, defaultSelected). Removing nodes removes their wires.
  • renderNode draws under a node's ports, as text or pictures: node faces aren't interactive, so the nodes can be a listbox. Its text is part of the node's description. Put fields in the inspector: its children start over for each node shown.
  • A node is named by its title, then its kind, as it reads on screen; its description adds the body and its wires.
  • CanvasToolbar's label is its own name, "Canvas tools" by default. Keep the canvas's name out of it: agents find components by names that contain what they ask for.
  • grid is where dropped nodes snap and how far Alt with an arrow moves them (8).
  • locked(node) keeps a node in place. fitView frames every node on first show; controls and minimap turn the corner parts off.
  • Ports are drawn from fixed sizes (a 36px header and 28px rows, in canvas units), so wires never wait on measuring. Keep custom styles to those sizes.

Keys

KeysDoes
TabInto the canvas (one stop, on the last node focused) and out
ArrowsThe nearest node in that direction
Alt with arrowsMove the node (or the selection) by the grid; Shift for ten steps
SpaceSelect or unselect the node
Ctrl/⌘ A, EscapeSelect every node; clear the selection
CStart a wire from the node's output (C again: its next output)
Arrows, Enter, EscapeWhile wiring: choose an input, connect, stop
Delete, BackspaceRemove the node, or the selection; focus moves to the nearest node
EnterOpen the node (your onNodeOpen, like focusing the inspector)
+, −, 0Zoom in, out, back to 100%
Ctrl/⌘ Z, Ctrl/⌘ Shift ZUndo, redo (with onUndo and onRedo)

Focus never falls to the page: removing a node (by Delete, the toolbar, an undo or an agent) moves it to the nearest node, the toolbar keeps disabled tools focusable, and Disconnect in the inspector moves to the next wire. A node added from the toolbar takes focus; one an agent adds doesn't take it from the person. Focus rings and the selection edge keep their width on screen at any zoom.

With a pointer: drag empty space to pan, scroll or pinch to zoom, Shift-drag to select a box, drag a node (or the selection) to move it, drag from an output dot to an input to wire them, and press a wire's × to remove it. The minimap pans where you press. Every move, wire and removal is announced, and each node's description says what it's wired to ("Inputs: Prompt from Answer template Prompt.").

For agents

addNode(kind, title?, x?, y?), moveNode(node, x, y), connect(from, to, fromPort?, toPort?), disconnect(from, to), select(...nodes), openNode(node) and fitView(), by titles (or ids, when two share a title). A wire that isn't allowed, an unknown kind or port, or a locked node says why. query({ within: "Workflow" }) lists each node as { value: id, label: "Draft answer (at 600, 130; Inputs: …; Outputs: …)" }. Removing goes through the toolbar's Remove button, marked data-agent-confirm, so a person approves it.

Known limits

Find in page doesn't reveal nodes off screen yet (UI SDK's bindFindable can add it). Very large graphs (thousands of nodes) render every node; virtualizing them is future work.