Skip to content
Starter Kits— browse docs
On this page

Canvas (tool)

An infinite board — a pan/zoom camera that's yours alone, pins that everyone shares, and live cursors.

Put two people on the same board and you immediately have a question most collaborative apps get wrong: if I zoom in, does the view move for you too?

It shouldn't. The board is shared; the window onto it is personal. This kit is the smallest complete demonstration of that split — pins in the document, camera in React state, and never the two shall meet.

You will learn

  • How to keep content shared and the viewport local, and why that's the right line
  • How to convert between screen space and document space without the maths escaping
  • How peer cursors travel in document space and stay the right size at any zoom
  • What "infinite" actually costs (almost nothing)

Try it

Canvas, running
Starting the example…

Double-click the board to drop a pin, drag pins around, scroll to zoom, drag the background to pan. The camera is yours alone; the pins are shared.

Double-click empty board to drop a pin. Drag a pin to move it, drag the background to pan, scroll to zoom. Right-click a pin for its menu. Notice that zooming changes nothing about the pins' stored positions — they're in document space, and the camera is a lens.

Create it

vvd create star-map --tool --template=canvas
Expected output:
→ Creating tool star-map in /Users/you/dev/star-map — from the Canvas template

✦  ah — a star-map tool. let's build it.

Next:
cd star-map
vvd run    # render it live in your world — hot-reloads as you edit
vvd save   # save a new version (a private draft)
✓ Created star-map (tool) → /Users/you/dev/star-map

What you'd build with it

Anything where where a thing is carries meaning:

  • A world map with linked locations — a pin per settlement, each pointing at its card.
  • A star chart or a system map — the reason the example is called star-map.
  • A relationship board — characters as pins, arranged by faction, moved as allegiances change.
  • A dungeon or floorplan sketch — drop an image behind it and pin the rooms.
  • A mood board — pins with images, arranged by feel rather than by list order.
  • A planning wall — the sticky-notes-on-glass layout, where clustering is the whole message and a list would destroy it.

What's in it

src/codec.ts17 lines

The data model. One declaration of what the document holds and how two people's edits merge — and the file `vvd save` reads to derive what an agent can do with your creation.

src/codec.ts
import { defineStateCodec, field } from "@vvd/sdk"

/** One pin. Position is DOCUMENT space — plain numbers; the camera is never stored. */
export type Pin = {
  x: number
  y: number
  label: string
  /** A reference to a world document — always the id, never a copy. */
  linkedDocumentId: string | null
}

export const codec = defineStateCodec({
  // A per-KEY map: two people moving different pins at once BOTH land.
  pins: field.map<Pin>(),
})

export default codec
src/tool.tsx357 lines

The tool itself: a `defineTool` with a `render` function. This is the file you edit first.

src/tool.tsx
import { type CSSProperties, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, useEffect, useMemo, useRef, useState } from "react"

import { DocumentGate, HostIcon, defineTool, useContextMenu, useDocDropTarget, useDocument, useDocumentAwareness, useDocumentPresence, useHostCapability } from "@vvd/sdk"

import { type Pin, codec } from "@/codec"

const NAME = "star-map"
const FILE = "src/tool.tsx"

// star-map is an infinite canvas. The document holds the CONTENT (pins — document-space
// positions plus world references by id); the CAMERA is yours alone (pan/zoom is local
// React state, never synced — two people on the same canvas each keep their own view).
// Peer cursors broadcast in document space and counter-scale by 1/zoom.

type Camera = { x: number; y: number; zoom: number }
type Drag =
  | { mode: "pan"; sx: number; sy: number; cam0: Camera }
  | { mode: "pin"; id: string; sx: number; sy: number; p0: Pin; moved: boolean }
type Cursor = { id: number; color: string; name: string; x: number; y: number }
type Hit = { id: string; name: string; entityTypeName: string | null; entityTypeIcon: string | null }

const newId = () => crypto.randomUUID()
const toDoc = (p: { x: number; y: number }, cam: Camera) => ({ x: (p.x - cam.x) / cam.zoom, y: (p.y - cam.y) / cam.zoom })

const ROWBTN: CSSProperties = { textAlign: "left", padding: "6px 10px", border: "none", background: "transparent", color: "#fff", cursor: "pointer", font: "inherit", fontSize: 13 }
const PANEL_INPUT: CSSProperties = { padding: "7px 10px", borderRadius: 8, border: "1px solid rgba(255,255,255,0.2)", background: "rgba(255,255,255,0.08)", color: "#fff", font: "inherit", fontSize: 13, outline: "none" }
const ZOOM_BTN: CSSProperties = { width: 28, height: 28, borderRadius: 8, border: "1px solid rgba(127,127,127,0.4)", background: "rgba(127,127,127,0.12)", color: "inherit", cursor: "pointer", fontSize: 14, lineHeight: 1 }

function PresenceStrip({ handle }: { handle: Parameters<typeof useDocumentPresence>[0] }) {
  const others = useDocumentPresence(handle).filter((p) => !p.isSelf)
  if (others.length === 0) return null
  return (
    <span style={{ display: "flex", alignItems: "center" }} title={others.map((p) => p.name).join(", ")}>
      {others.slice(0, 5).map((p) => (
        <span key={p.userId} style={{ width: 24, height: 24, borderRadius: "50%", marginLeft: -8, display: "inline-flex", alignItems: "center", justifyContent: "center", background: p.color, color: "#fff", fontSize: 11, fontWeight: 700, border: "2px solid rgba(255,255,255,0.35)" }}>
          {p.name ? p.name.slice(0, 1).toUpperCase() : ""}
        </span>
      ))}
      {others.length > 5 && <span style={{ marginLeft: 6, fontSize: 12, opacity: 0.7 }}>+{others.length - 5}</span>}
    </span>
  )
}

/** Peers' cursors, in DOCUMENT space (each broadcast under this tool's own awareness field). */
function usePeerCursors(awareness: ReturnType<typeof useDocumentAwareness>): Cursor[] {
  const [list, setList] = useState<Cursor[]>([])
  useEffect(() => {
    const a = awareness
    if (!a) { setList([]); return }
    const read = () => {
      const out: Cursor[] = []
      a.getStates().forEach((raw, id) => {
        const s = raw as { user?: { color?: string; name?: string }; starMapCursor?: { x: number; y: number } | null }
        if (id === a.clientID || !s.starMapCursor) return
        out.push({ id, color: s.user?.color || "#7c8cff", name: s.user?.name || "", x: s.starMapCursor.x, y: s.starMapCursor.y })
      })
      setList(out)
    }
    read()
    a.on("change", read)
    return () => a.off("change", read)
  }, [awareness])
  return list
}

/** The native reference-picker pattern (the Board/Canvas/relation-tree pickers):
 *  search the world IMMEDIATELY (an empty query shows suggestions), arrow keys +
 *  Enter to pick, a create-as-card fallback — and always hand back the document
 *  ID, never the text. Entity icons render through the platform icon engine. */
function ReferencePicker({ onPick, onClose }: { onPick: (hit: Hit) => void; onClose: () => void }) {
  const search = useHostCapability("search")
  const [q, setQ] = useState("")
  const [hits, setHits] = useState<Hit[]>([])
  const [loaded, setLoaded] = useState(false)
  const [active, setActive] = useState(0)
  useEffect(() => {
    let live = true
    const t = setTimeout(() => {
      search.query(q, { types: ["card"], limit: 12 }).then((r) => {
        if (!live) return
        setHits(r)
        setLoaded(true)
        setActive(0)
      })
    }, q ? 150 : 0)
    return () => { live = false; clearTimeout(t) }
  }, [q, search])
  const canCreate = !!search.create && q.trim() !== "" && !hits.some((h) => h.name.toLowerCase() === q.trim().toLowerCase())
  const total = hits.length + (canCreate ? 1 : 0)
  const choose = (i: number) => {
    if (i < hits.length) onPick(hits[i])
    else if (canCreate) void search.create?.(q.trim(), null).then((made) => { if (made) onPick(made) })
  }
  return (
    <span style={{ display: "grid", gap: 2, minWidth: 220, padding: 4, borderRadius: 10, background: "rgba(20,22,29,0.97)", color: "#fff", border: "1px solid rgba(255,255,255,0.14)", boxShadow: "0 14px 32px rgba(0,0,0,0.45)" }}>
      <input autoFocus value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search your world…"
        onKeyDown={(e) => {
          if (e.key === "Escape") { e.preventDefault(); onClose() }
          else if (e.key === "ArrowDown") { e.preventDefault(); setActive((a) => Math.min(a + 1, Math.max(0, total - 1))) }
          else if (e.key === "ArrowUp") { e.preventDefault(); setActive((a) => Math.max(a - 1, 0)) }
          else if (e.key === "Enter") { e.preventDefault(); if (total > 0) choose(active) }
        }}
        onBlur={() => setTimeout(onClose, 120)}
        style={{ padding: "7px 10px", borderRadius: 7, border: "none", outline: "none", background: "rgba(255,255,255,0.08)", color: "#fff", font: "inherit", fontSize: 13 }} />
      <span style={{ display: "grid", maxHeight: 240, overflowY: "auto" }}>
        {hits.map((h, i) => (
          <button key={h.id} type="button" onMouseDown={(e) => { e.preventDefault(); choose(i) }} onMouseEnter={() => setActive(i)}
            style={{ display: "flex", alignItems: "center", gap: 8, padding: "6px 9px", borderRadius: 7, border: "none", textAlign: "left", cursor: "pointer", font: "inherit", fontSize: 13, color: "#fff", background: i === active ? "rgba(255,255,255,0.12)" : "transparent" }}>
            <HostIcon icon={h.entityTypeIcon || "file-text"} size={13} />
            <span style={{ flex: 1, minWidth: 0, overflow: "hidden", whiteSpace: "nowrap", textOverflow: "ellipsis" }}>{h.name || "Untitled"}</span>
            {h.entityTypeName ? <span style={{ fontSize: 10.5, opacity: 0.5 }}>{h.entityTypeName}</span> : null}
          </button>
        ))}
        {canCreate && (
          <button type="button" onMouseDown={(e) => { e.preventDefault(); choose(hits.length) }} onMouseEnter={() => setActive(hits.length)}
            style={{ display: "flex", alignItems: "center", gap: 8, padding: "6px 9px", borderRadius: 7, border: "none", textAlign: "left", cursor: "pointer", font: "inherit", fontSize: 13, color: "#fff", background: active === hits.length ? "rgba(255,255,255,0.12)" : "transparent" }}>
            <HostIcon icon="plus" size={13} />
            <span style={{ flex: 1 }}>Create &ldquo;{q.trim()}&rdquo;</span>
          </button>
        )}
        {loaded && hits.length === 0 && !canCreate && <span style={{ padding: "10px 12px", fontSize: 12, opacity: 0.5 }}>Nothing found</span>}
      </span>
    </span>
  )
}

function Canvas({ data, actions, handle, canEdit, worldId }: {
  data: { pins: Readonly<Record<string, Pin>> }
  actions: { map(name: "pins"): { set(key: string, value: Pin): void; delete(key: string): void } }
  handle: Parameters<typeof useDocumentAwareness>[0]
  canEdit: boolean
  worldId: string
}) {
  const awareness = useDocumentAwareness(handle)
  const cursors = usePeerCursors(awareness)
  const refs = useHostCapability("refs")
  const nav = useHostCapability("nav")
  const { openContextMenu } = useContextMenu()
  const areaRef = useRef<HTMLDivElement | null>(null)
  const dragRef = useRef<Drag | null>(null)
  const rafRef = useRef(0)
  const pendingRef = useRef<{ id: string; pin: Pin } | null>(null)
  const [camera, setCamera] = useState<Camera>({ x: 0, y: 0, zoom: 1 })
  const cameraRef = useRef(camera)
  cameraRef.current = camera
  const [selectedId, setSelectedId] = useState<string | null>(null)
  const [linking, setLinking] = useState(false)

  // Coalesce drag writes to one per frame — smooth locally, calm on the wire.
  const commit = (id: string, pin: Pin) => {
    pendingRef.current = { id, pin }
    if (!rafRef.current) {
      rafRef.current = requestAnimationFrame(() => {
        rafRef.current = 0
        const p = pendingRef.current
        if (p) actions.map("pins").set(p.id, p.pin)
      })
    }
  }

  // Zoom AT the cursor — the point under the pointer stays fixed. Functional update: never stale.
  const zoomAt = (at: { x: number; y: number }, factor: number) =>
    setCamera((cam) => {
      const zoom = Math.min(4, Math.max(0.25, cam.zoom * factor))
      const scale = zoom / cam.zoom
      return { zoom, x: at.x - (at.x - cam.x) * scale, y: at.y - (at.y - cam.y) * scale }
    })
  const zoomAtRef = useRef(zoomAt)
  zoomAtRef.current = zoomAt
  useEffect(() => {
    // React wheel listeners are passive — attach our own so zoom can preventDefault scrolling.
    const el = areaRef.current
    if (!el) return
    const onWheel = (e: WheelEvent) => {
      e.preventDefault()
      const rect = el.getBoundingClientRect()
      zoomAtRef.current({ x: e.clientX - rect.left, y: e.clientY - rect.top }, Math.exp(-e.deltaY * 0.0015))
    }
    el.addEventListener("wheel", onWheel, { passive: false })
    return () => el.removeEventListener("wheel", onWheel)
  }, [])

  const onPointerDown = (e: ReactPointerEvent) => {
    setSelectedId(null)
    setLinking(false)
    dragRef.current = { mode: "pan", sx: e.clientX, sy: e.clientY, cam0: cameraRef.current }
    areaRef.current?.setPointerCapture?.(e.pointerId)
  }
  const beginPin = (id: string, e: ReactPointerEvent) => {
    e.stopPropagation()
    if (!canEdit) { setSelectedId(id); return }
    const p0 = data.pins[id]
    if (!p0) return
    dragRef.current = { mode: "pin", id, sx: e.clientX, sy: e.clientY, p0, moved: false }
    areaRef.current?.setPointerCapture?.(e.pointerId)
  }
  const onPointerMove = (e: ReactPointerEvent) => {
    const rect = areaRef.current?.getBoundingClientRect()
    if (!rect) return
    const cam = cameraRef.current
    awareness?.setLocalStateField("starMapCursor", toDoc({ x: e.clientX - rect.left, y: e.clientY - rect.top }, cam))
    const d = dragRef.current
    if (!d) return
    if (d.mode === "pan") {
      setCamera({ ...d.cam0, x: d.cam0.x + (e.clientX - d.sx), y: d.cam0.y + (e.clientY - d.sy) })
    } else {
      if (Math.hypot(e.clientX - d.sx, e.clientY - d.sy) > 3) d.moved = true
      commit(d.id, { ...d.p0, x: d.p0.x + (e.clientX - d.sx) / cam.zoom, y: d.p0.y + (e.clientY - d.sy) / cam.zoom })
    }
  }
  const onPointerUp = () => {
    const d = dragRef.current
    if (d && d.mode === "pin" && !d.moved) { setSelectedId(d.id); setLinking(false) }
    dragRef.current = null
  }
  const onDoubleClick = (e: ReactMouseEvent) => {
    if (!canEdit) return
    const rect = areaRef.current?.getBoundingClientRect()
    if (!rect) return
    const p = toDoc({ x: e.clientX - rect.left, y: e.clientY - rect.top }, cameraRef.current)
    const id = newId()
    actions.map("pins").set(id, { x: p.x, y: p.y, label: "New pin", linkedDocumentId: null })
    setSelectedId(id)
  }

  // The platform dnd contract: drop a sidebar document anywhere on the canvas and
  // it lands as a pin at that point, linked by id (never a copy).
  const drop = useDocDropTarget({
    zone: "star-map-canvas",
    accepts: (p) => canEdit && (!p.worldId || p.worldId === worldId),
    onDrop: (p, point) => {
      const rect = areaRef.current?.getBoundingClientRect()
      const at = rect ? toDoc({ x: point.x - rect.left, y: point.y - rect.top }, cameraRef.current) : { x: 0, y: 0 }
      const id = newId()
      actions.map("pins").set(id, { x: at.x, y: at.y, label: p.name, linkedDocumentId: p.documentId })
      setSelectedId(id)
    },
  })

  // Resolve linked ids → names through the host (batched; never stored).
  const idsKey = useMemo(
    () => [...new Set(Object.values(data.pins).map((p) => p.linkedDocumentId).filter((x): x is string => !!x))].join(","),
    [data.pins],
  )
  const [names, setNames] = useState<Record<string, string>>({})
  useEffect(() => {
    const ids = idsKey ? idsKey.split(",") : []
    if (!ids.length) return
    let live = true
    refs.meta(ids).then((m) => {
      if (!live) return
      const out: Record<string, string> = {}
      m.forEach((r, docId) => { out[docId] = r.name })
      setNames(out)
    })
    return () => { live = false }
  }, [idsKey, refs])
  const openDoc = (docId: string) => nav.openDocument(refs.coordsFor(docId) ?? { worldId, documentId: docId })

  const sid = selectedId
  const selected = sid ? data.pins[sid] : undefined

  return (
    <div ref={(el) => { areaRef.current = el; drop.ref(el) }} {...drop.props}
      tabIndex={0}
      onKeyDown={(e) => {
        // Del/Backspace removes the selected pin (undo restores it via the platform timeline).
        if (!canEdit || !sid || (e.target as HTMLElement).tagName === "INPUT") return
        if (e.key === "Delete" || e.key === "Backspace") { e.preventDefault(); actions.map("pins").delete(sid); setSelectedId(null) }
      }}
      onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp}
      onPointerLeave={() => { awareness?.setLocalStateField("starMapCursor", null); dragRef.current = null }}
      onDoubleClick={onDoubleClick}
      style={{ position: "relative", height: "100%", width: "100%", overflow: "hidden", touchAction: "none", cursor: dragRef.current?.mode === "pan" ? "grabbing" : "default", outline: drop.canDrop ? "2px dashed rgba(127,127,127,0.55)" : "none", outlineOffset: -8, backgroundImage: "radial-gradient(rgba(127,127,127,0.35) 1px, transparent 1px)", backgroundSize: 24 * camera.zoom + "px " + 24 * camera.zoom + "px", backgroundPosition: camera.x + "px " + camera.y + "px" }}>
      {/* the world layer — everything inside is document space, moved by YOUR camera */}
      <div style={{ position: "absolute", left: 0, top: 0, transform: "translate(" + camera.x + "px, " + camera.y + "px) scale(" + camera.zoom + ")", transformOrigin: "0 0" }}>
        {Object.entries(data.pins).map(([id, pin]) => (
          <div key={id} onPointerDown={(e) => beginPin(id, e)}
            onContextMenu={(e) => {
              // Pin actions live on the platform right-click menu.
              openContextMenu(e, [
                ...(pin.linkedDocumentId
                  ? [{ kind: "item" as const, id: "open", label: "Open linked document", icon: "external-link", onSelect: () => pin.linkedDocumentId && openDoc(pin.linkedDocumentId) },
                     ...(canEdit ? [{ kind: "item" as const, id: "unlink", label: "Unlink", icon: "tabler:circle-x", onSelect: () => actions.map("pins").set(id, { ...pin, linkedDocumentId: null }) }] : [])]
                  : []),
                ...(canEdit ? [{ kind: "item" as const, id: "delete", label: "Delete pin", icon: "tabler:trash", variant: "destructive" as const, onSelect: () => { actions.map("pins").delete(id); setSelectedId(null) } }] : []),
              ])
            }}
            style={{ position: "absolute", left: pin.x, top: pin.y, transform: "translate(-50%, -50%)", cursor: canEdit ? "grab" : "pointer", touchAction: "none" }}>
            <div style={{ display: "flex", alignItems: "center", gap: 6, padding: "5px 11px", borderRadius: 999, background: "rgba(22,24,30,0.88)", color: "#fff", fontSize: 12.5, fontWeight: 500, whiteSpace: "nowrap", border: sid === id ? "1px solid rgba(255,255,255,0.9)" : "1px solid rgba(255,255,255,0.25)", userSelect: "none" }}>
              {pin.label || "Untitled"}
              {pin.linkedDocumentId ? <HostIcon icon="link" size={11} /> : null}
            </div>
          </div>
        ))}
        {cursors.map((p) => (
          <div key={p.id} style={{ position: "absolute", left: p.x, top: p.y, transform: "scale(" + 1 / camera.zoom + ")", transformOrigin: "0 0", pointerEvents: "none", zIndex: 40 }}>
            <svg width="16" height="16" viewBox="0 0 20 20" fill={p.color}><path d="M3 2l13 7-5.5 1.6L8 17z" /></svg>
            {p.name ? <span style={{ marginLeft: 8, padding: "0 6px", borderRadius: 6, background: p.color, color: "#fff", fontSize: 10, fontWeight: 600, whiteSpace: "nowrap" }}>{p.name}</span> : null}
          </div>
        ))}
      </div>

      {/* chrome — screen space */}
      <div style={{ position: "absolute", top: 14, left: 18, right: 18, display: "flex", alignItems: "center", gap: 12, pointerEvents: "none" }}>
        <h1 style={{ margin: 0, fontSize: 17, fontWeight: 700, opacity: 0.85 }}>{NAME}</h1>
        <span style={{ flex: 1 }} />
        <span style={{ pointerEvents: "auto" }}><PresenceStrip handle={handle} /></span>
      </div>
      <div style={{ position: "absolute", right: 14, bottom: 14, display: "flex", gap: 6 }} onPointerDown={(e) => e.stopPropagation()}>
        <button type="button" style={ZOOM_BTN} title="Zoom out" onClick={() => zoomAt({ x: (areaRef.current?.clientWidth || 0) / 2, y: (areaRef.current?.clientHeight || 0) / 2 }, 1 / 1.25)}><HostIcon icon="zoom-out" size={13} /></button>
        <button type="button" style={{ ...ZOOM_BTN, width: "auto", padding: "0 8px", fontSize: 11 }} title="Reset view" onClick={() => setCamera({ x: 0, y: 0, zoom: 1 })}>{Math.round(camera.zoom * 100)}%</button>
        <button type="button" style={ZOOM_BTN} title="Zoom in" onClick={() => zoomAt({ x: (areaRef.current?.clientWidth || 0) / 2, y: (areaRef.current?.clientHeight || 0) / 2 }, 1.25)}><HostIcon icon="zoom-in" size={13} /></button>
      </div>

      {sid && selected && (
        <div onPointerDown={(e) => e.stopPropagation()} onDoubleClick={(e) => e.stopPropagation()}
          style={{ position: "absolute", left: 16, bottom: 16, zIndex: 30, width: 264, display: "grid", gap: 8, padding: 12, borderRadius: 14, background: "rgba(18,20,26,0.94)", color: "#fff", border: "1px solid rgba(255,255,255,0.14)", boxShadow: "0 18px 40px rgba(0,0,0,0.45)" }}>
          <input value={selected.label} readOnly={!canEdit} placeholder="Label"
            onChange={(e) => actions.map("pins").set(sid, { ...selected, label: e.target.value })} style={PANEL_INPUT} />
          {selected.linkedDocumentId ? (
            <button type="button" onClick={() => selected.linkedDocumentId && openDoc(selected.linkedDocumentId)}
              style={{ ...ROWBTN, display: "flex", alignItems: "center", gap: 6, padding: "6px 10px", borderRadius: 8, background: "rgba(255,255,255,0.08)", overflow: "hidden", whiteSpace: "nowrap", textOverflow: "ellipsis" }}>
              <HostIcon icon="link" size={11} /> {names[selected.linkedDocumentId] || "linked document"}
            </button>
          ) : canEdit && linking ? (
            <ReferencePicker onClose={() => setLinking(false)} onPick={(h) => { actions.map("pins").set(sid, { ...selected, linkedDocumentId: h.id }); setLinking(false) }} />
          ) : canEdit ? (
            <button type="button" onClick={() => setLinking(true)} style={{ ...ROWBTN, padding: "6px 10px", borderRadius: 8, background: "rgba(255,255,255,0.08)", opacity: 0.7 }}>
              Link a document
            </button>
          ) : null}
        </div>
      )}
    </div>
  )
}

export default defineTool({
  id: "star-map",
  name: "star-map",
  documentTypes: ["star-map"],
  needs: [],
  surface: "plain",
  render: function StarMapView({ document, context }) {
    const coords = useMemo(
      () => ({ worldId: document.worldId, documentId: document.id }),
      [document.worldId, document.id],
    )
    const { data, status, actions, retry, handle } = useDocument(coords, codec)
    return (
      <DocumentGate status={status} onRetry={retry}>
        {data && actions && <Canvas data={data} actions={actions} handle={handle} canEdit={context.canEdit} worldId={document.worldId} />}
      </DocumentGate>
    )
  },
})

The codec is fifteen lines and holds pins only. Everything about the camera lives in src/tool.tsx as ordinary useState, which is the point: nothing about zoom is synchronised because nothing about zoom is shared.

Why is the camera not in the document?Deep dive

Because a camera is not content — it's how one person is looking at content, and shipping it in the document has two costs that both show up immediately.

The obvious one is the experience: if the camera syncs, your collaborator's scroll wheel moves your screen. Every "let me just look over here" becomes an interruption for everyone else.

The subtler one is write volume. A pan gesture at 60 fps produces hundreds of updates a second. Put that in the CRDT and you're broadcasting, merging and persisting a value nobody will ever read again. Put it in React state and it costs nothing at all.

Cursors are the counter-example, and the difference is instructive: a peer cursor is worth sharing, so it goes over awareness rather than the document. Awareness is ephemeral — no history, no persistence, gone when the tab closes — which is exactly right for "where my mouse is." Positions are broadcast in document space and counter-scaled by 1/zoom on the way out, so a cursor stays the same size on screen no matter how far in you are. Presence covers awareness properly.

How it works

Content shared, view local. The single most portable idea in this kit, and it applies far beyond canvases. Storing data is where the line between "in the codec" and "in React" is drawn.

Pins merge per key. pins: field.map<Pin>() — two people dragging different pins both land. Same reasoning as the notes board in Hello World.

Sidebar drops land as pins. The board is a drop target through the platform's drag-and-drop contract, so dragging a document from the sidebar creates a pin pointing at it. See Linking documents.

Next steps

  • Document — the same collaboration story, in prose.
  • Presence — awareness, cursors, and who's here.
  • Host capabilities — the context menu and search capabilities this kit uses.