Documentation Index
Fetch the complete documentation index at: https://docs.deep.space/llms.txt
Use this file to discover all available pages before exploring further.
The useCanvas hook gives you a complete collaborative canvas surface: add and move shapes, broadcast viewports across peers, undo and redo, all synchronized over a CanvasRoom Durable Object. Use it for whiteboards, mood boards, diagram tools, and any “shapes on an infinite plane” UI.
Connect to a canvas
import { useCanvas } from 'deepspace'
function Whiteboard({ canvasId }: { canvasId: string }) {
const {
shapes,
viewports,
connected,
addShape,
moveShape,
resizeShape,
updateShape,
deleteShape,
setViewport,
undo,
redo,
} = useCanvas(canvasId)
// shapes is an array of CanvasShapeClient.
// viewports is an array of ViewportClient - one entry per remote peer.
}
canvasId is any string. Two clients passing the same canvasId connect to the same CanvasRoom.
Shape lifecycle
A shape is an object with a position, size, and an arbitrary props payload. Each shape has:
type CanvasShapeClient = {
id: string
type: string
x: number
y: number
width: number
height: number
rotation?: number
props: Record<string, unknown>
createdBy: string
createdAt: string
updatedAt: string
}
Add one with:
function onAddRect(x: number, y: number) {
// addShape is synchronous and returns void. The server assigns the shape id
// and broadcasts it back; let the next `shapes` render pick it up.
addShape({
type: 'rect',
x, y,
width: 100,
height: 60,
props: { color: 'blue', label: 'New' },
})
}
The type is a free-form string - define your own vocabulary (rect, circle, arrow, text, image). The props field holds whatever metadata you need.
Mutation methods are all positional and return void:
moveShape(shapeId, 200, 150) // (shapeId, x, y)
resizeShape(shapeId, 200, 120) // (shapeId, width, height, x?, y?)
resizeShape(shapeId, 200, 120, 50, 80) // also reposition while resizing
updateShape(shapeId, { color: 'red' }) // (shapeId, props) - props is merged
deleteShape(shapeId)
Rendering shapes
shapes is a flat array of CanvasShapeClient records. Render them however you like - SVG, canvas, DOM:
<svg width={800} height={600}>
{shapes.map((s) => {
if (s.type === 'rect') {
return (
<rect
key={s.id}
x={s.x}
y={s.y}
width={s.width}
height={s.height}
fill={(s.props.color as string) ?? 'gray'}
/>
)
}
if (s.type === 'circle') {
return (
<circle key={s.id} cx={s.x} cy={s.y} r={s.width / 2} />
)
}
return null
})}
</svg>
When any peer adds, moves, or deletes a shape, the shapes array re-renders. Note that addShape and deleteShape round-trip through the server, so the calling peer also gets the update from the broadcast. moveShape, resizeShape, and updateShape broadcast only to other peers - if you want the local peer to see the change immediately, update your UI optimistically.
Viewports
Each connected peer broadcasts a viewport (position, size, and zoom). Use it to show “where everyone is looking” in minimap-style UIs:
const { viewports, setViewport } = useCanvas(canvasId)
function onPan(x: number, y: number, zoom: number, width: number, height: number) {
// setViewport requires width and height (the visible viewport rect) in addition to x/y/zoom.
setViewport({ x, y, zoom, width, height })
}
// viewports is a ViewportClient[] - one entry per remote peer (your own
// viewport is not included). Each entry includes `userId`.
for (const vp of viewports) {
console.log(`${vp.userId} is at (${vp.x}, ${vp.y}) zoomed ${vp.zoom}`)
}
Viewports are ephemeral - they’re not persisted. When a peer disconnects, their viewport disappears.
Undo and redo
Each peer has an independent undo stack:
const { undo, redo } = useCanvas(canvasId)
<button onClick={undo}>Undo</button>
<button onClick={redo}>Redo</button>
Undo reverses the calling peer’s most recent edits only. It doesn’t touch other users’ actions.
Worker setup
The scaffold already wires AppCanvasRoom:
// worker.ts
export class AppCanvasRoom extends CanvasRoom<Env> {}
const app = new Hono<{ Bindings: Env }>()
app.get(
'/ws/canvas/:docId',
wsRoute((env) => env.CANVAS_ROOMS, () => ({ role: 'member' })),
)
You don’t need to edit either side unless you’re customizing shape persistence or adding server-side validation.
A complete whiteboard
import { useState } from 'react'
import { useCanvas } from 'deepspace'
export default function Whiteboard({ canvasId }: { canvasId: string }) {
const { shapes, addShape, moveShape, deleteShape } = useCanvas(canvasId)
const [selected, setSelected] = useState<string | null>(null)
function onCanvasClick(e: React.MouseEvent<SVGSVGElement>) {
const rect = e.currentTarget.getBoundingClientRect()
addShape({
type: 'rect',
x: e.clientX - rect.left,
y: e.clientY - rect.top,
width: 80,
height: 50,
props: { color: '#1D4ED8' },
})
}
return (
<svg width="100%" height="100vh" onClick={onCanvasClick}>
{shapes.map((s) => (
<g key={s.id} onClick={(e) => { e.stopPropagation(); setSelected(s.id) }}>
<rect
x={s.x}
y={s.y}
width={s.width}
height={s.height}
fill={s.props.color as string}
stroke={selected === s.id ? '#000' : 'none'}
/>
</g>
))}
</svg>
)
}
Open the page in two tabs. Click in each tab to add shapes; both surfaces stay in sync.
When to use canvas vs Yjs
Both can model “shapes that multiple users edit.” Pick based on the merging needs:
| Use canvas when… | Use Yjs when… |
|---|
| Shapes are independent units | Shapes form a unified document |
| Last-write-wins per shape is fine | You need CRDT merging of overlapping edits |
| You need a built-in undo stack | You need fine-grained operation-log merging |
| Viewports / cursors are part of the surface | You want to use a third-party editor binding |
Canvas is the right pick for whiteboards, mood boards, and most diagram tools. Yjs is the right pick when you’re integrating Tiptap, ProseMirror, or a similar collaborative document editor.
Next steps