Manual Recipes Collaborative Canvas

Collaborative Canvas

elements man recipes/collaborative-canvas Read as markdown

A shared drawing canvas where every browser sees every other browser's strokes appear live, plus a "clear" button that wipes the canvas for everyone. The recipe stores nothing: there is no database table and no LiveTable. A single Channel carries every stroke and every clear event. Strokes that arrive after the page loads are drawn; strokes that arrived before the page loaded are gone.

This is the canonical Channel-only recipe. Nothing about it persists. When that fits the problem (cursors, presence pings, drawing strokes, ephemeral UI state), the channel is the lowest-overhead primitive available.

Page setup

elements create page canvas

There is no migration: the canvas owns no rows. The channel, the event type, and the broadcast rpc are used only by the /canvas page, so they live in the page's own services.ts rather than under app/shared/.

app/pages/canvas/services.ts:

import { Channel } from "@elements/app";

export interface Stroke {
  color: string;
  points: { x: number; y: number }[];
}

export type CanvasEvent =
  | { kind: "stroke"; stroke: Stroke }
  | { kind: "clear" };

export const events = new Channel<CanvasEvent>("canvas");

/** @rpc */
export function broadcastStroke(stroke: Stroke) {
  events.notify({ kind: "stroke", stroke });
}

/** @rpc */
export function broadcastClear() {
  events.notify({ kind: "clear" });
}

CanvasEvent is a discriminated union: a "stroke" event carries the points and color, a "clear" event carries no data. The rpc wrappers exist because Channel.notify is server-only; the browser calls the rpc, and the rpc relays to the channel.

app/pages/canvas/index.ts:

import canvas from "./template";
import { events } from "./services";

export default function route(req, res) {
  return new canvas({ listener: events.listen() });
}

The route hands the template a fresh listener. Every browser on the page receives every event.

app/pages/canvas/template.html:

import "./style.css";
import type { Listener } from "@elements/app";
import { CanvasEvent, Stroke, broadcastStroke, broadcastClear } from "./services";

let canvasEl: HTMLCanvasElement | null = null;
let ctx: CanvasRenderingContext2D | null = null;
let currentStroke: Stroke | null = null;

function onInsert(el: HTMLCanvasElement) {
  canvasEl = el;
  ctx = el.getContext("2d");
}

function onStrokeBegin(x: number, y: number) {
  if (!ctx) {
    return;
  }

  currentStroke = { color: "#222", points: [{ x, y }] };
  ctx.beginPath();
  ctx.strokeStyle = currentStroke.color;
  ctx.lineWidth = 2;
  ctx.moveTo(x, y);
}

function onStrokeMove(x: number, y: number) {
  if (!ctx) {
    return;
  }
  if (!currentStroke) {
    return;
  }

  currentStroke.points.push({ x, y });
  ctx.lineTo(x, y);
  ctx.stroke();
}

function onStrokeEnd() {
  if (!currentStroke) {
    return;
  }

  broadcastStroke(currentStroke);
  currentStroke = null;
}

function onClear() {
  broadcastClear();
}

function onIncoming(event: CanvasEvent) {
  if (event.kind === "stroke") {
    drawStroke(event.stroke);
  } else {
    clearCanvas();
  }
}

function drawStroke(stroke: Stroke) {
  if (!ctx) {
    return;
  }
  if (stroke.points.length === 0) {
    return;
  }

  ctx.beginPath();
  ctx.strokeStyle = stroke.color;
  ctx.lineWidth = 2;
  ctx.moveTo(stroke.points[0].x, stroke.points[0].y);

  for (let i = 1; i < stroke.points.length; i++) {
    ctx.lineTo(stroke.points[i].x, stroke.points[i].y);
  }

  ctx.stroke();
}

function clearCanvas() {
  if (!ctx) {
    return;
  }
  if (!canvasEl) {
    return;
  }

  ctx.clearRect(0, 0, canvasEl.width, canvasEl.height);
}

<html class="canvas"
      (listener: Listener<CanvasEvent>)
      oninit={() => listener.on("notify", onIncoming)}>
  <h1>shared canvas</h1>

  <canvas width="800"
          height="600"
          oninsert={(e) => onInsert(e.target as HTMLCanvasElement)}
          onpointerdown={(e) => onStrokeBegin(e.offsetX, e.offsetY)}
          onpointermove={(e) => onStrokeMove(e.offsetX, e.offsetY)}
          onpointerup={() => onStrokeEnd()}
          onpointerleave={() => onStrokeEnd()}/>

  <button onclick={() => onClear()}>clear</button>
</html>

Every function attached to an event attribute is named onX. Functions called as helpers from inside the onX handlers (drawStroke, clearCanvas) use action verbs. Module-level ctx, currentStroke, and canvasEl are set once on onInsert and read by the rest.

onStrokeEnd is used by both onpointerup and onpointerleave. The latter covers the case where the user drags off the canvas mid-stroke; the in-progress stroke is finalized and broadcast. The handler's domain-concept name reads correctly for both events.

The e.offsetX / e.offsetY coordinates are relative to the canvas element, so coordinates are consistent across browsers regardless of viewport scroll or layout differences.

Routes

Register the page in index.ts:

import canvas from "#app/pages/canvas";

// ...
app.route("/canvas", canvas);

Notes

  • No persistence. A page refresh starts from a blank canvas. To persist, add a strokes table and write each stroke from the rpc inside a tx() alongside the notify. The route loads existing rows and the template draws them on onInsert.
  • Per-room canvases. Add roomId to CanvasEvent and pass filter: (event) => event.roomId === roomId to events.listen({ ... }). The same channel handles every room; the filter runs server-side so a browser only receives events for its room.
  • Stroke compression. A 1-second stroke at 60 Hz produces 60 points. For low-end devices or shaky networks, run-length encode adjacent identical movements or downsample to every Nth point before broadcasting. The channel does no compression of its own.
  • Color and brush size. Pass them in Stroke. A small palette (Stroke.color is just a string) and a brush-size slider on the page become local state plus a property on the stroke. The recipe uses a fixed color and line width to keep the focus on the channel.
  • Why no LiveTable. LiveTable is row-based: each stroke would be a row with optimistic insert, scope auto-fill, and broadcast on every mutation. The optimistic insert and auto-fill add nothing here (the browser already draws locally before sending), and the row storage actively gets in the way (clearing the canvas means deleting every row). The channel matches what the recipe actually does: broadcast an event, listen for events.