Skip to content

Infinite Canvas Surface & Viewport Interaction

dazeb edited this page Sep 17, 2026 · 2 revisions

Infinite Canvas Surface & Viewport Interaction

The canvas is a React Flow plane that is deliberately unbounded in space: nodes may live at any coordinate, panning is never clamped, and only zoom is bounded (0.05x–3x). Canvas.tsx is the composition root — it mounts <ReactFlow>, owns the node/edge/link/selection state, and exposes a narrow imperative API (via context) to the custom node components rendered inside it. Almost everything a user perceives as "canvas interaction" is either React Flow's built-in behavior or a small, explicit override layered on top of it.

This page describes the mechanics first (viewport, node registry, project loading, links↔edges, menus, organize), then how the files divide labor. The provided excerpts cover Canvas.tsx lines 1–260 only; handlers defined later in that file are called out as not visible rather than guessed at.

Runtime Mechanics

1. The plane and the viewport

<ReactFlow> is rendered with Background/BackgroundVariant, Controls, and MiniMap, plus the vendor stylesheet (reactflow/dist/style.css). The component pulls screenToFlowPosition, getViewport, setViewport, and fitView out of useReactFlow() — these are the only viewport primitives the rest of the code uses, so any new feature that needs to convert between screen and flow coordinates should reuse them rather than computing transforms by hand.

Zoom bounds are module constants:

  • MIN_ZOOM = 0.05 — deliberately generous because the plane is spatially unbounded; at 0.05x the whole sprawl is visible at once.
  • MAX_ZOOM = 3 — nodes contain text; beyond ~3x there is nothing left to read.

Panning has no equivalent clamp. If you add one, be aware that "pull back and see everything" is an explicit design intent recorded in the source comment, not an accident.

2. Wheel zoom, and the inverted-wheel override

By default React Flow owns wheel zoom. Canvas installs a capture-phase wheel listener on its own wrapper element that only acts when invertWheelZoom is true; otherwise it returns early and native behavior is untouched. When it does act it:

  1. Bail-out guards: the event target must be inside .react-flow__renderer, and must not be inside a .nowheel subtree. This is what keeps the context menu, history bar, file tree, controls, minimap, terminals, and editors scrolling themselves.
  2. preventDefault() + stopPropagation() so React Flow never also sees the event.
  3. Convert deltaY into a scale factor (deltaMode === 1 → 0.05 multiplier, other non-pixel modes → 1, pixel mode → 0.002), then negate it: scale = 2 ** -delta.
  4. Anchor at the cursor: the pointer position is converted to flow coordinates with (px - viewport.x) / viewport.zoom, the next zoom is clamped to [MIN_ZOOM, MAX_ZOOM], and setViewport is called with x = px - flowX * nextZoom, y = py - flowY * nextZoom.

Two details matter if you refactor the shell:

  • px/py are measured from the wrapper element's bounding rect, not from the React Flow container. The anchor math therefore assumes the wrapper's top-left is the React Flow origin. If the shell ever offsets the React Flow container inside the wrapper, zoom anchoring will drift.
  • The listener is bound once with deps [getViewport, setViewport] (stable React Flow identities). The live invertWheelZoom value is read through invertWheelZoomRef so toggling the setting never re-binds the capture listener. The removal call passes { capture: true } only — keep that symmetric or you will leak a capture listener on every remount.

3. Node and edge registries (the extension surface)

nodeTypes maps the serialized type strings to components: terminal, sticky, group, diff, editor, browser, chat. diff and editor are wrapped in Suspense around lazy() imports so the ~10 MB Monaco bundle + workers stay out of the boot-critical chunk — the wrappers cast through unknown because React Flow's ComponentType contract wants the exact props type, and the fallback is a plain loading editor… div. Adding a node type means touching three places: this map, a create*/deserializeNodes path in state/workspace.ts, and the node component itself.

edgeTypes currently has a single entry, nodelink: NodeLinkEdge. Edges are not authored directly — they are a projection of NodeLink records (see §5).

4. CanvasContext — the node-facing imperative API

Nodes rendered inside the canvas must be able to mutate their own data, persist it, and participate in undo, without those callbacks being serialized into node data. CanvasContext provides exactly that:

Method Purpose
updateNodeData(id, patch, record?) Merge a patch into node data; record decides whether an undo snapshot is taken.
persistNodeData(id, patch) Write the patch through to persistence (async).
commit() Flush a debounced undo snapshot.
closeNode(id) Remove a node — groups ungroup their children instead of deleting them.

useCanvas() throws useCanvas must be used inside Canvas when the context is missing, which is the failure mode you hit if a node component is ever rendered outside the plane (e.g. in a preview or test harness) — render it through Canvas or supply a test provider.

5. Links are the persisted truth; edges are the mirror

The canvas keeps three related pieces of state:

  • links: NodeLink[] — the persisted reality, loaded from the project file over IPC.
  • linksRef — a synchronous mirror so callbacks and event handlers never read stale links.
  • edges: Edge[] — derived for React Flow via reconcileLinkEdges(next, previous).

replaceLinks(next) is the single funnel: it updates the ref, the state, and the derived edges together. Any new code path that mutates links should go through it (or through deleteLink), otherwise the ref/state/edge triple can diverge.

deleteLink(linkId) filters by id and then re-persists with persistLinks, which calls window.termsprawl.links.update(projectId, serializeLinks(links)) and swallows failures (fire-and-forget). cascadeLinksForNodes(removedIds) exists specifically for node deletion, so removing a node drops every link touching it rather than leaving dangling edges. Link kinds are constrained by connectableLinkKinds / linkDefaultConfig from core/links/registry.

Link execution state is tracked in two forms, mirroring the links/ref pattern: busyLinksRef (a Set for synchronous checks) and linkRunBusy (a Set in state so the UI re-renders). inspectedLinkId selects which link the LinkInspector shows; it is reset whenever the active project changes.

6. Project loading and the generation guard

A single effect keyed on activeProjectId owns the whole swap:

  1. generation = ++projectGeneration.current — every load gets a monotonic id.
  2. loadingRef.current = true, clear inspectedLinkId, reset busyLinksRef / linkRunBusy.
  3. Seed from nodeCache[activeProjectId] (an in-memory serialized snapshot keyed by project id) through deserializeNodes. If there is no active project → []. If there is a project but no cached nodes → [createTerminalNode(cwd)], i.e. a new project always starts with one terminal.
  4. setEdges([]) and replaceLinks([]) immediately; links then arrive asynchronously.
  5. window.termsprawl.links.list(activeProjectId) is awaited, and the result is applied only if it is not cancelled, the generation still matches, and activeProjectIdRef.current still equals the project. Older main-process builds without link support simply reject and leave the canvas linkless.
  6. A setTimeout(…, 0) releases loadingRef so React Flow has a tick to settle before anything observes "not loading".

The cleanup bumps projectGeneration again and clears the timer, which is why a slow IPC response from a previous project can never paint over the current one. activeProjectIdRef exists so unload handlers and the IPC callbacks read the current project without re-subscribing; latestNodesRef plays the same role for nodes.

7. Undo/redo

useHistory(nodes, setNodes) returns { push, undo, redo, invalidate, canUndo, canRedo }. The canvas feeds it the entire nodes array, so a snapshot is a full layout capture rather than a diff. invalidate is used to kill an id inside every snapshot — this is what prevents undo from resurrecting a terminal that was permanently closed. Organize actions take exactly one snapshot each, so one Ctrl+Z reverts one organize click.

The full history semantics live in the Workspace/Project state module; from the canvas's perspective, updateNodeData(..., record) and commit() are the knobs that decide when a snapshot is taken.

8. Selection

selectedIds: string[] is canvas-level state (not derived from React Flow), and nextSelection from state/canvas-knav is the shared selection-advance helper used by keyboard traversal. The context menu carries an optional nodeId, so a menu opened on a node can render node-scoped actions while a pane menu renders canvas-scoped ones. A related linkMenuOpen flag drives edge-scoped menu state.

9. The canvas context menu

CanvasMenu is a positioned role="menu" container that solves four problems the raw coordinates do not:

  • Clamping. A layout effect computes left/top, clamped to [8, innerWidth - width - 8] / [8, innerHeight - height - 8], so the full action list stays reachable at every edge and window size. A ResizeObserver plus a window resize listener re-runs positioning when the menu's own size or the viewport changes.
  • Dismissal. A pointerdown listener on document closes the menu when the click lands outside; the handler is stored in closeRef so the effect does not need to re-run on every render.
  • Focus discipline. On open it focuses the first enabled button and remembers the previously focused element; on unmount it restores focus only if focus is still inside the menu. Escape and Tab close; ArrowDown/ArrowUp/Home/End do roving focus over button:not(:disabled).
  • Event isolation. onClick and onKeyDown call stopPropagation(), so canvas-level shortcuts do not fire while the menu is open, and the nowheel class keeps the inverted-wheel handler (and React Flow's native zoom) off the menu.

AgentMenuItems is the reusable "Open agent" section used inside menus: it lists agentIds() filtered by agentConfig(id).enabled, renders a brand logo via CSS mask when one exists (claude, codex→openai, grok, gemini→antigravity), and otherwise falls back to a monogram (OC for openclaude, >_ for everything else).

10. Auto-organize

Organize is a single button with no dropdown. Each click advances a three-state cycle and the same button click again shows the next layout:

stateDiagram-v2
  [*] --> Cascade
  Cascade --> Flat : click
  Flat --> Restore : click
  Restore --> Cascade : click
Loading
  • Cascade — stacked diagonal arrangement, stickies along the top.
  • Flat — all windows arranged inside the current view, stickies in a row above.
  • Restore — put windows back exactly where they were before the cycle started (OrganizeSnapshot captures the pre-cycle positions).

The layout math is pure and unit-tested in state/workspace.ts (layoutCascade, layoutFlat, layoutRestore, applied via applyLayoutPositions); the Canvas is responsible only for applying positions and recording one undo snapshot. The cycle position lives on the Canvas side, which is why the button itself is stateless.

Because the toolbar renders outside the Canvas provider, the button cannot call the canvas API directly. It publishes a one-shot request instead:

sequenceDiagram
  participant B as OrganizeButton
  participant Q as useCanvasRequests store
  participant C as Canvas
  participant W as state/workspace layout fns
  participant H as useHistory
  B->>Q: spawn({ kind: 'organize' })
  Q-->>C: one-shot request consumed by Canvas
  C->>W: layoutCascade / layoutFlat / layoutRestore + applyLayoutPositions
  W-->>C: next positions (+ OrganizeSnapshot for Restore)
  C->>H: push one undo snapshot
Loading

The key nodes: the request store is the decoupling seam (the same pattern the settings panel uses for spawning nodes); OrganizeSnapshot is what makes "Restore" possible; and pushing a single history entry per action is what makes each organize exactly one Ctrl+Z. The button disables itself when no project is open, since there is nothing to arrange.

File Responsibilities and How They Collaborate

src/renderer/src/canvas/Canvas.tsx — the composition root and the only place that knows about all the moving parts. It mounts <ReactFlow>, registers nodeTypes/edgeTypes, owns nodes/edges/links/selectedIds/menu/inspectedLinkId, performs the project-swap effect, holds the wheel override, and publishes CanvasContext. It pulls creators and serializers from state/workspace.ts, link serialization from state/workspace-links.ts, project state from state/projects.ts, history from state/history.ts, keyboard selection from state/canvas-knav.ts, and the request bus from state/canvas-requests.ts. It is the integration point: each of those modules is individually simple, and the canvas is where their contracts are wired together.

src/renderer/src/canvas/CanvasMenu.tsx — the presentation primitive for menus. It knows nothing about canvas state; it takes coordinates, a close callback, and children, and returns an accessible, clamped, focus-managed menu. AgentMenuItems is a pure section builder over the shared agent config. Because it does not import canvas state, it is reusable by any other menu surface.

src/renderer/src/components/OrganizeButton.tsx — the toolbar entry point. It deliberately contains no layout logic: it renders one button and emits spawn({ kind: 'organize' }). The cycle position, the layout math, and the undo snapshot all live elsewhere, which is why this file stays at ~50 lines.

Collaboration summary: OrganizeButton publishes a request → Canvas consumes it and chooses the next layout → state/workspace.ts computes positions (pure) → Canvas applies them and pushes one history entry. Independently, Canvas renders CanvasMenu with the correct anchor coordinates and, inside it, AgentMenuItems drives node creation through the same agent config used by the rest of the app.

Key State (and why each exists)

State Kind Role
nodes, edges React state React Flow's controlled inputs; edges is derived from links.
links / linksRef State + ref Persisted link records; the ref exists so handlers never read stale data.
projectGeneration Ref Monotonic guard against stale async loads.
busyLinksRef / linkRunBusy Ref + state Same dual pattern, for link execution: sync checks vs. re-render.
inspectedLinkId State Which link the inspector edits; reset on project switch.
menu, linkMenuOpen, selectedIds State Menu anchors/targets and canvas selection.
loadingRef, latestNodesRef, activeProjectIdRef, invertWheelZoomRef Refs Values that event handlers and unload paths must read without re-subscribing.
cleanupError State Surfaces cleanup failures instead of swallowing them.

Boundaries, Gaps and Extension Points

  • Drag and resize are not implemented in the visible excerpt. Node dragging is React Flow's built-in behavior, and applyNodeChanges/applyEdgeChanges are imported (the standard pattern for controlled change application), but the change handlers themselves are past line 260 and are not described here. Resize affordances belong to the individual node components (GroupNode, etc.), not to Canvas.tsx.
  • The excerpt stops mid-file. Everything after cascadeLinksForNodes — node/pane context-menu open handlers, connection validation using connectableLinkKinds, organize request consumption, keyboard shortcuts — is outside the provided range. Treat those as unverified on this page.
  • Adding a node type: extend nodeTypes in Canvas.tsx, provide a creator in state/workspace.ts, and make sure deserializeNodes can rebuild it. Lazy-load it through Suspense if it drags in a heavy dependency, following the Monaco precedent.
  • Adding a link kind: register it in core/links/registry.ts; the canvas consumes connectableLinkKinds and linkDefaultConfig, and NodeLinkEdge renders it.
  • Adding a menu action: append children to CanvasMenu; you get clamping, dismissal, focus management, and nowheel isolation for free. Do not attach a competing wheel or pointer handler inside it.
  • Anything that needs the canvas from outside the provider (toolbar, sidebar, settings) should go through useCanvasRequests, not through a ref or a portal into the tree — that is the established decoupling seam.
  • Viewport limits are intentional. A zoom clamp exists; a pan clamp does not. If you introduce one, document the change, because "the plane is spatially unbounded" is load-bearing for the organize layouts.

Sources: src/renderer/src/canvas/Canvas.tsx, src/renderer/src/canvas/CanvasMenu.tsx, src/renderer/src/components/OrganizeButton.tsx

termsprawl

App Shell & Platform Foundations

Canvas, Nodes & Renderer State

Terminals & Session Continuity

Persistence, Projects & Files

Agent Runtime & Tooling

Chat Nodes & Model Providers

Git & Source Control

Embedded Browser Nodes

Server Edition

Relay & Remote Access

Integrations & Secondary Surfaces

Settings, Updates & Maintenance

Clone this wiki locally