Skip to content

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:

KeysAction
Delete / BackspaceDelete 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+YUndo · redo.
Ctrl/Cmd+ASelect every node (selectAll()).
Ctrl/Cmd+DDuplicate 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's reactive() and Svelte's $state proxies, 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:

NameTypeDefaultDescription
resizableBooleanfalseOpt this node TYPE into corner-handle resizing. Default OFF — zero drift for every existing <NodeType> that never declares it.
minWidthNumbernullMinimum 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.
minHeightNumbernullMinimum height (px) a resize gesture may shrink this type to. Same 40px fallback.
maxWidthNumbernullMaximum width (px) a resize gesture may grow this type to. Unset = unbounded growth.
maxHeightNumbernullMaximum 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 $refs to a child component resolves to the host element on Angular (a documented parity edge), so the in-template $refs.flow path above is for the other five targets.

See also

Pre-1.0 — APIs may change between minor versions.