Appearance
Editing the graph
The editing feature reference for FlowCanvas: everything beyond drag and connect that ships in the box.
FlowCanvas is an editor, not just a viewer. Selection, deletion, undo/redo, edge styling, reconnection, marquee selection, a per-node toolbar, and auto-layout all ship in the box, and every edit flows through the same controlled-graph contract as drag and connect: the canvas writes a fresh graph object back through the two-way model, and the consumer never hand-reconciles. The full bundle is on by default behind the existing gates — :readonly="true" turns the whole canvas into a static viewer (no selection, no delete, no editing), and the individual opt-outs / opt-ins below let you trim it to taste.
Keyboard shortcuts
A focused canvas (it carries tabindex="0") binds four shortcuts, all gated on selectable && !readonly — evaluated live, per keystroke, so toggling either prop takes effect immediately — and all suppressed while focus is inside a node-body text field (input / textarea / contenteditable), so typing in a node never triggers them. The gate covers the keyboard shortcuts only: undo() / redo() as handle verbs are never gated, so a read-only canvas can still be driven by its consumer:
| Keys | Action |
|---|---|
| Delete / Backspace | Delete the selected node(s) and their incident edges — or, when no node is picked, the selected edge. |
| Ctrl/Cmd+Z · Ctrl/Cmd+Shift+Z / Ctrl/Cmd+Y | Undo · redo. |
| Ctrl/Cmd+A | Select every node (selectAll()). |
| Ctrl/Cmd+D | Duplicate the current selection (duplicateNode per node) — one undo step for the whole gesture, however many nodes are selected. |
Selecting and deleting edges
Clicking a committed connection's path selects it (the edge gets an .is-selected class you can style through the :root {} engine-DOM hatch) and fires @edge-click + @edge-selected with { id }. With an edge selected, Delete / Backspace removes it — written back through the bound graph as a fresh connections array. Node deletion takes precedence: if a node is selected, the key deletes the node (and its incident edges) first. Edge selection is gated selectable && !readonly and, like node selection, is surfaced purely via events — it is never written into graph.
html
<FlowCanvas r-model:graph="$data.graph" @edge-selected="onEdgeSelected" />Edge types — step / smoothstep / straight
Each connection carries an optional type on the bound graph — 'bezier' (default), 'step', 'smoothstep', or 'straight' — selecting the path shape, matching React Flow's edge types. It is a per-edge property, so a single graph can mix orthogonal routing for some edges and curves for others; editing connection.type on the bound graph re-renders just that edge in place (the same restyle path as label / stroke / dashed). An unknown value falls back to the unchanged bezier.
js
$data.graph = {
nodes: [/* … */],
connections: [
{ source: 'a', sourceOutput: 'out', target: 'b', targetInput: 'in', type: 'smoothstep' },
],
}Undo / redo
On by default (history prop). Every gesture — drag, connect, disconnect, delete, reconnect, auto-arrange — pushes one capped (~100) snapshot of the bound graph (nodes incl. x/y + connections; not the viewport). Ctrl/Cmd+Z undoes, Ctrl/Cmd+Shift+Z / Ctrl/Cmd+Y redo, and the undo() / redo() / canUndo() / canRedo() handle verbs drive the same stack from your own toolbar. One gesture = one step; a fresh edit after an undo discards the redo branch. Restores are echo-guarded through the graph model. Opt out with :history="false" (the stack stays empty; the verbs no-op).
Snapshotting note (cross-framework): the wrapper clones graph snapshots JSON-first — a bare
structuredClone()throws on Vue'sreactive()and Svelte's$stateproxies, so it is never used on the bound graph. If you keep your own history outside the component, clone the same way.
Marquee selection and the pan ↔ select mode
The two-way mode prop is a Figma-style toggle. 'pan' (default) pans the viewport on an empty-canvas drag — unchanged. 'select' draws a rubber-band marquee box on an empty-canvas drag and multi-selects the intersecting nodes (surfacing @selection-change); a node drag still drags the node in both modes. Bind it with r-model:mode and drive it from your own UI, or set :marquee="true" to render a built-in 4th Controls button that toggles the mode for you. marquee defaults OFF so the default Controls overlay keeps its three buttons (the screenshot baseline is byte-identical); the marquee behavior works whenever mode === 'select', independent of the button.
html
<FlowCanvas r-model:graph="$data.graph" r-model:mode="$data.mode" :marquee="true" />Reconnectable edges
Dragging an existing edge's endpoint onto a different compatible socket rewrites that connection rather than dropping it — the edge count is unchanged and it counts as one undoable gesture (the internal remove + add are coalesced into a single history snapshot). Reconnection is on whenever !readonly — evaluated live, so toggling readonly mid-session takes effect immediately (a read-only canvas refuses the endpoint grab outright, drawing no ghost path) — and honors the same :validate-types / canConnect rules as drawing a fresh edge.
Node toolbar
Set :node-toolbar="true" to float a small toolbar over the single selected node (positioned from the engine node-view rect + the area transform, re-tracked on pan / zoom / drag). The default content is Delete (cascading controlled-graph deleteNode) and Duplicate (clone the node spec at an offset with a new id into a fresh graph); both fire @node-action with name: 'delete' | 'duplicate'. Fill the #toolbar reactive slot (scope { node, emit }) to replace the buttons with your own. Default OFF, so existing canvases are pixel-identical — selecting a node pops nothing until you opt in.
html
<FlowCanvas r-model:graph="$data.graph" :node-toolbar="true">
<template #toolbar="{ node, emit }">
<button @click="emit('rename', { id: node.id })">Rename</button>
<button @click="emit('delete')">✕</button>
</template>
</FlowCanvas>Node resizer
Opt a node TYPE into corner-handle resizing (the React Flow <NodeResizer/> parity) with <NodeType resizable>:
html
<FlowCanvas r-model:graph="$data.graph">
<NodeType type="card" resizable :min-width="160" :min-height="80" :max-width="480">
<template #body="{ node }">{{ node.data.label }}</template>
<Port output="out" type="any" />
</NodeType>
</FlowCanvas>Selecting a node of a resizable type shows 4 corner drag handles. Dragging one persists an explicit node.width / node.height (a fixed box that overrides the node's normal auto-sizing) back through the two-way graph model, coalesced through the same rAF-batched write-back path as everything else. Each corner resizes anchored on the opposite corner — dragging se (bottom-right) grows the node while the top-left corner stays put; dragging nw grows it while the bottom-right corner stays put (and so on for ne / sw), matching the React Flow NodeResizer semantics.
<NodeType> accepts four optional bound props to constrain the drag:
| Name | Type | Default | Description |
|---|---|---|---|
resizable | Boolean | false | Opt this node TYPE into corner-handle resizing. Default OFF — zero drift for every existing <NodeType> that never declares it. |
minWidth | Number | null | Minimum width (px) a resize gesture may shrink this type to. Falls back to a small sane default (40px) when resizable is true and this is unset, so a node can never be dragged to 0px. |
minHeight | Number | null | Minimum height (px) a resize gesture may shrink this type to. Same 40px fallback. |
maxWidth | Number | null | Maximum width (px) a resize gesture may grow this type to. Unset = unbounded growth. |
maxHeight | Number | null | Maximum height (px) a resize gesture may grow this type to. Unset = unbounded growth. |
A misconfigured maxWidth / maxHeight below the effective minWidth / minHeight never wins silently — the min bound always takes precedence, and the component logs a one-time console.warn per type so the misconfiguration is visible during development.
Double-click a handle to reset a node back to auto-size, clearing its explicit width / height so the node returns to its natural content-driven size on the next render. (Rete swallows native click/dblclick events during node interaction, so this is detected by timing two stationary handle releases within 400ms — a genuine drag never counts as a "click".) Like every other edit, the reset goes through the same controlled-graph write-back path and is one undoable gesture (:history-gated).
js
$data.graph = {
nodes: [{ id: 'n1', type: 'card', x: 0, y: 0, width: 320, height: 180, data: { label: 'Card' } }],
connections: [],
}Auto-layout
The autoArrange(opts?) handle verb relayouts the whole graph into a non-overlapping layered arrangement (elkjs-backed) and writes the arranged positions back through the two-way graph model as one undoable gesture. It is verb-only and never auto-triggered — nothing reflows unless you call it (e.g. from a "Tidy up" button). It is await-able, and opts.options forwards elk layout options (direction, spacing). The three layout packages (rete-auto-arrange-plugin, elkjs, web-worker) are optional peers — only consumers who call autoArrange() need them installed.
js
await $refs.flow.autoArrange()
// or with options:
await $refs.flow.autoArrange({ options: { 'elk.direction': 'RIGHT' } })Connect-end-on-pane (quick-add menus)
When a connection drag starts at an output socket and ends on empty canvas (no target socket), FlowCanvas fires @connect-end with { source, sourceOutput, position } — position in graph coordinates. This is a pure signal, the React Flow onConnectEnd parity: the canvas creates no node and shows no menu. The consumer decides what happens — pop a node picker at position, quick-add a default node, or ignore the drop. Because position is already in graph space, a node you push into graph at that point lands exactly where the drag ended.
js
const onConnectEnd = ({ source, sourceOutput, position }) => {
// open your own "create node" menu at `position`, then write the new node
// (and an edge from source/sourceOutput) back into $data.graph.
}Palette drag-drop (screenToFlowPosition)
Dropping a node from a sidebar palette onto the canvas — the bread-and-butter no-code-builder interaction — works like React Flow: you own the drag/drop, the canvas owns the projection. Grab the canvas handle, mark a palette item draggable, and on the canvas @drop translate the pointer to graph coordinates and append a node into the bound graph:
html
<!-- palette item -->
<div draggable="true">+ New node</div>
<!-- canvas wrapper owns dragover/drop -->
<div @dragover.prevent @drop.prevent="onDrop">
<FlowCanvas ref="flow" r-model:graph="$data.graph">
<NodeType type="task"><template #body="{ node }">{{ node.data.label }}</template></NodeType>
</FlowCanvas>
</div>js
const onDrop = (e) => {
// name the local anything BUT `flow` — `const flow = $refs.flow` self-shadows the ref.
const canvas = $refs.flow
const pos = canvas?.screenToFlowPosition(e.clientX, e.clientY)
if (!pos) return
// controlled-graph write-back: a FRESH graph object (in-place mutation is dropped on 4/6).
$data.graph = { ...$data.graph, nodes: [...$data.graph.nodes,
{ id: crypto.randomUUID(), type: 'task', x: pos.x, y: pos.y, data: { label: 'New' } }] }
}screenToFlowPosition(clientX, clientY) inverts the viewport transform (pan + zoom), so a node placed at the result renders exactly under the drop point regardless of how the canvas is panned or zoomed.
Angular consumers: reach the handle with a native
@ViewChild(FlowCanvas)query (this.flow.screenToFlowPosition(...)). Rozie's$refsto a child component resolves to the host element on Angular (a documented parity edge), so the in-template$refs.flowpath above is for the other five targets.
See also
- FlowCanvas showcase & API: the full props / events / imperative-handle reference and the
<NodeType>/<Port>authoring model. - FlowCanvas live demo: the editor running in the page.
- Node-flow editor comparison: how the editing bundle stacks up against the per-framework incumbents.