Collaboration
Live editing between people and devices. How merging works, how to connect, presence and follow mode, offline edits, and running your own server.
Two editors joined by a transport, live on this page:
A live CanvasFlow editor runs here when JavaScript is on.
Connect in one line#
import { connect } from 'canvasflow/collab'
const client = connect(editor, { room: 'board-42' }) // every tab on this device
const shared = connect(editor, { room: 'board-42', url: 'wss://sync.example.com' }) // over the network
Without a url, tabs on the same device sync through a BroadcastChannel. With one, the client connects to a WebSocket server at url/room. Either way you get a CollabClient; call client.stop() to disconnect.
How merging works#
Collaboration in CanvasFlow is built on a CRDT, a data structure where every replica can accept edits independently and still end up identical. In practice that means:
- Edits merge field by field. If you change a shape's color while someone moves it, both changes survive. Nobody's edit is lost to a "last save wins".
- Deletes win. If you delete a shape while someone edits it, it stays deleted for everyone, instead of half-coming back.
- Z-order never conflicts. Shapes are ordered with fractional indexes, so two people reordering at once can't produce a tie.
- Undo only undoes your own changes. Undo after a collaborator's edit takes back your last change, not theirs.
- Offline edits merge later. Changes made without a connection are queued and merged when it returns.
The engine behind this, SyncDoc, is part of the core. You never use it directly unless you're writing a server.
The client#
import { CollabClient, WebSocketTransport } from 'canvasflow/collab'
const client = new CollabClient(editor, new WebSocketTransport('wss://sync.example.com/board-42'), {
room: 'board-42',
user: { name: 'Maya', color: '#7c5cff' },
cache: myStorageAdapter, // offline cache, survives restarts
}).start()
client.status // 'connecting' | 'online' | 'offline'
client.onChange(() => updateStatusBadge(client.status))
client.setUser({ name: 'Maya K.' })
| Option | What it does |
|---|---|
room |
Which document to join |
user |
{ id, name, color } shown to others. Missing fields are generated |
cache |
A storage adapter that keeps the replica (with merge info) for offline use |
presenceInterval |
How often presence is sent, in milliseconds |
peerTimeout |
When a silent peer is considered gone |
Presence#
Presence is what makes a shared board feel alive: cursors, names, selections, the laser pointer. It's sent separately from the document and never saved.
import { createPresenceAvatars, createPresenceLayer } from 'canvasflow/collab'
app.addOverlayLayer(createPresenceLayer(client), 10) // cursors, names, selections, carets
header.append(createPresenceAvatars(client, editor)) // who's here; click someone to follow them
client.peers // Map of everyone connected, with their presence
client.follow(peerId) // keep your camera on theirs until you move
Call client.setCursorInside(false) when the pointer leaves your canvas so others don't see a frozen cursor.
The presence layer redraws whenever a peer moves, even while nothing changes on your own canvas, and eases each cursor between the positions the network delivers, so cursors glide instead of hopping. createPresenceLayer(client, { smoothing: 0 }) turns the easing off. A peer who jumps across the board is not dragged across it: the cursor moves there at once.
Run a server#
The package includes a small WebSocket relay with no dependencies. Each room keeps a merged replica, so late joiners get the full document at once, and rooms are saved to disk.
npx canvasflow-server --port 8787 --data ./canvasflow-data
Or start it from your own Node code:
import { startServer } from 'canvasflow/server'
startServer({ port: 8787, dataDir: './rooms', maxPayload: 4_000_000 })
Clients connect to ws://host:8787/<room>. Put it behind your own proxy for TLS and authentication.
Other transports#
A transport is five methods, so anything that can deliver messages can carry a CanvasFlow document: WebRTC, a message bus, your existing realtime service.
interface Transport {
connect(): void
disconnect(): void
send(message: Message): void
onMessage(fn: (message: Message) => void): () => void
onStatus(fn: (status: 'connecting' | 'online' | 'offline') => void): () => void
readonly status: 'connecting' | 'online' | 'offline'
}
Built in: BroadcastChannelTransport, WebSocketTransport (with reconnection and an offline queue), createMemoryTransportPair() for tests and demos like the one above, and ConvexTransport for Convex.
A store and a live relay#
A database-backed transport keeps the board, but every message is a write and a query, which is too slow for cursors. RelayTransport pairs it with a socket relay that only forwards: edits go to both, so peers see them at socket speed while the store keeps them, and cursors go to the relay alone while it is up (the store still hears them every ten seconds, and takes over completely when the relay drops). A late or duplicated message is harmless: edits merge field by field, and the client never steps back to an older cursor.
import { ConvexClient } from 'convex/browser'
import type { Transport } from 'canvasflow/collab'
import { CollabClient, RelayTransport } from 'canvasflow/collab'
import { ConvexTransport } from 'canvasflow/convex'
import { api } from '../convex/_generated/api'
declare const live: Transport // your socket relay (any Transport that forwards messages to the room)
const convex = new ConvexClient(import.meta.env.VITE_CONVEX_URL)
const store = new ConvexTransport(convex, api.canvasflow, boardId)
const client = new CollabClient(editor, new RelayTransport(store, live), { room: boardId }).start()