Skip to content

Infinite Canvas

@xnet/canvas provides an infinite, zoomable 2D surface where users can place, connect, and arrange nodes. The canvas supports real-time collaboration via Yjs — multiple users can simultaneously add, move, and connect nodes.

import { Canvas } from '@xnet/canvas'
;<Canvas
doc={ydoc} // Required: Yjs document
awareness={awareness} // Cursor presence
renderNode={(node) => <MyCard />} // Custom node content
onNodeDoubleClick={(id) => open(id)}
onBackgroundClick={() => deselect()}
config={{
minZoom: 0.1,
maxZoom: 3,
gridSize: 20,
showGrid: true
}}
/>
ShortcutAction
Delete / BackspaceDelete selected nodes
Cmd+ASelect all
EscapeClear selection
Cmd+1Fit to content
Cmd+0Reset view
  • Scroll — Pan the canvas
  • Ctrl/Cmd + Scroll — Zoom (pinch-to-zoom)
  • Click node — Select it
  • Shift/Cmd + Click — Additive selection
  • Drag node — Move it
  • Drag resize handles — Resize (8 handles on selection)
  • Double-click — Open node

The primary hook that wires together the store, viewport, and layout engine:

const {
// State
nodes,
edges,
selectedNodeIds,
viewport,
// Node operations
addNode,
updateNodePosition,
removeNode,
// Edge operations
addEdge,
removeEdge,
// Selection
selectNode,
selectAll,
clearSelection,
deleteSelected,
// Viewport
pan,
zoomAt,
fitToContent,
resetView,
// Layout
autoLayout,
layoutSelected,
// Queries
findNodeAt,
findNodesInRect,
getVisibleNodes
} = useCanvas({ doc: ydoc, config })

The canvas uses an R-tree (rbush) for efficient spatial queries. This enables viewport culling — only nodes visible on screen need to be rendered.

// Find all nodes in the current viewport
const visibleIds = store.getVisibleNodes(viewport.getVisibleRect())
// Find the topmost node at a point
const nodeId = store.findNodeAt(point)
// Find all nodes overlapping with a given node
const overlapping = store.findIntersecting(nodeId)

The spatial index is automatically updated when nodes are added, moved, or removed.

The LayoutEngine supports automatic layout via ELK.js and two built-in algorithms:

await autoLayout({
algorithm: 'layered', // Sugiyama/hierarchical
direction: 'RIGHT', // RIGHT, LEFT, DOWN, UP
nodeSpacing: 50,
layerSpacing: 100,
edgeRouting: 'ORTHOGONAL' // POLYLINE, ORTHOGONAL, SPLINES
})

Available algorithms: layered, force, mrtree, radial, stress, box.

layoutEngine.layoutGrid(nodes, {
columns: 4,
spacing: 20,
padding: 50
})
layoutEngine.layoutCircle(nodes, {
radius: 300 // Auto-calculated if omitted
})

Nodes with properties.fixed = true keep their positions during layout.

The Viewport class manages the camera:

  • pan(dx, dy) — Move by screen-space delta
  • zoomAt(x, y, factor) — Zoom while keeping the point under cursor stationary
  • fitToRect(rect, padding) — Fit viewport to show a rectangle (never zooms beyond 100%)
  • reset() — Return to origin at zoom 1
  • screenToCanvas(x, y) — Convert screen coordinates to canvas space
  • canvasToScreen(x, y) — Convert canvas coordinates to screen space
  • getVisibleRect() — The canvas-space rectangle currently on screen

Canvas state is stored in three Yjs maps inside the Y.Doc:

MapContents
metadataTitle, created/updated timestamps
nodesCanvasNode objects keyed by ID
edgesCanvasEdge objects keyed by ID

All mutations go through Yjs transactions, so changes sync automatically to other peers. The spatial index updates reactively when the Yjs maps change.

interface CanvasNode {
id: string
type: string // 'page', 'database', 'note', etc.
position: {
x: number
y: number
width: number // Default: 200
height: number // Default: 100
rotation?: number
zIndex?: number
collapsed?: boolean
}
properties: Record<string, unknown>
linkedNodeId?: string // Link to a page/database node
}

Edges connect nodes with optional labels, arrow markers, and routing styles (bezier or orthogonal).

The canvas supports spatial comments via useCanvasComments:

  • Position pins — Fixed canvas coordinates. Never orphaned.
  • Object-attached pins — Follow a node’s position. Become orphaned when the node is deleted.