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