Canvas Apps
Designing collaborative drawing and whiteboard applications.
Document Structure
Document
├── objects: Map<objectId, DrawingObject>
├── layers: Map<layerId, LayerData>
├── layerOrder: Map<layerId, Array<objectId>>
├── paths: Map<pathId, PathData> (large path data)
├── textContent: Map<textId, Y.Text> (rich text)
└── metadata: Map<key, value>Store large data (path points, rich text) separately from object metadata—reference by ID.
Object Model
interface BaseObject {
id: string
type: 'rect' | 'ellipse' | 'path' | 'text' | 'image' | 'group'
layerId: string
x: number; y: number
width: number; height: number
rotation: number
opacity: number
visible: boolean
locked: boolean
}
// Large data stored separately
interface PathObject extends BaseObject {
type: 'path'
pathDataId: string // Reference to paths map
}
interface TextObject extends BaseObject {
type: 'text'
textContentId: string // Reference to Y.Text
}Z-Order (Layering)
Each layer has an ordered array of object IDs:
function bringToFront(objectId: string) {
const obj = objects.get(objectId)
const order = layerOrder.get(obj.layerId) as Y.Array<string>
const index = order.toArray().indexOf(objectId)
if (index !== -1 && index < order.length - 1) {
yDoc.transact(() => {
order.delete(index, 1)
order.push([objectId])
})
}
}Freehand Drawing
For performance, collect points locally, then commit on stroke end:
let currentPoints: Point[] = []
function onPointerMove(x: number, y: number) {
currentPoints.push({ x, y })
renderLocalPreview() // Don't sync yet
}
function onPointerUp() {
const simplified = simplifyPath(currentPoints) // Reduce points
const pathId = nanoid(8)
const objectId = nanoid(8)
yDoc.transact(() => {
paths.set(pathId, { points: simplified })
objects.set(objectId, {
type: 'path',
pathDataId: pathId,
// ... other properties
})
layerOrder.get('default').push([objectId])
})
currentPoints = []
}Collaborative Cursors
Throttle cursor updates to avoid flooding:
const updateCursor = throttle((x: number, y: number) => {
awareness.setLocalStateField('cursor', {
x, y,
user: { name: 'Alice', color: '#f783ac' }
})
}, 50) // Max 20 updates/second
Common Mistakes
Storing render state in CRDT:
// WRONG: UI state in CRDT
objects.set(id, { ...obj, isSelected: true, isDragging: true })
// CORRECT: keep render state local
const localState = new Map<string, { isSelected: boolean }>()Large path data in object:
// WRONG: thousands of points in object
objects.set(id, { ...obj, points: hugePointArray })
// CORRECT: store separately
paths.set(pathId, { points: hugePointArray })
objects.set(id, { ...obj, pathDataId: pathId })See Also
- Separate Content Maps - Type-specific storage
- ID-Based Storage - Object/order separation
- Awareness - Cursor sync