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
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
→ 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.
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.
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 “{q.trim()}”</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.