Documentation Index
Fetch the complete documentation index at: https://mintlify.com/open-pencil/open-pencil/llms.txt
Use this file to discover all available pages before exploring further.
OpenPencil supports real-time collaboration without requiring a server. Multiple users can edit the same document simultaneously with live cursor tracking, selection awareness, and automatic conflict resolution.
Architecture
Collaboration uses a peer-to-peer (P2P) architecture:
- Trystero: WebRTC signaling via MQTT public brokers
- Yjs: CRDT (Conflict-free Replicated Data Type) for document state
- y-indexeddb: Local persistence (survives page refresh)
- Awareness Protocol: Cursor positions and selection state
From AGENTS.md:99-108:
- P2P via Trystero (WebRTC) — no server relay. Signaling over MQTT public brokers.
- Yjs CRDT for document state sync. Awareness protocol for cursors/selections/presence.
- y-indexeddb for local persistence — room survives page refresh.
- Constants in
src/constants.ts: TRYSTERO_APP_ID, PEER_COLORS, ROOM_ID_LENGTH, ROOM_ID_CHARS, YJS_JSON_FIELDS
src/composables/use-collab.ts — composable: connect/disconnect, cursor/selection broadcasting, follow mode, Yjs ↔ SceneGraph sync
- Provided via
COLLAB_KEY injection — useCollabInjected() in child components
- ICE servers: Google STUN + Cloudflare STUN + Open Relay TURN (TCP + UDP)
- Room IDs use
crypto.getRandomValues() — no Math.random() anywhere in codebase
- Stale cursors cleaned on peer disconnect via
removeAwarenessStates()
How It Works
1. Create a Room
Click the share button to generate a room link:
function shareCurrentDoc(): string {
const roomId = generateRoomId() // Crypto-random 16-char ID
connect(roomId)
syncAllNodesToYjs()
return roomId
}
function generateRoomId(): string {
const bytes = crypto.getRandomValues(new Uint8Array(ROOM_ID_LENGTH))
let result = ''
for (let i = 0; i < ROOM_ID_LENGTH; i++) {
result += ROOM_ID_CHARS[bytes[i] % ROOM_ID_CHARS.length]
}
return result
}
From src/composables/use-collab.ts:384-398
Room IDs are cryptographically random using crypto.getRandomValues(), not Math.random().
2. Join a Room
Share the generated link (app.openpencil.dev/share/<room-id>) with collaborators.
function joinRoom(roomId: string) {
connect(roomId)
}
From src/composables/use-collab.ts:400-402
3. WebRTC Connection
Trystero establishes P2P connections using public STUN/TURN servers:
room = joinTrysteroRoom(
{
appId: TRYSTERO_APP_ID,
rtcConfig: {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun.cloudflare.com:3478' },
{
urls: 'turn:openrelay.metered.ca:443',
username: 'openrelayproject',
credential: 'openrelayproject'
},
{
urls: 'turn:openrelay.metered.ca:443?transport=tcp',
username: 'openrelayproject',
credential: 'openrelayproject'
}
]
}
},
roomId
)
From src/composables/use-collab.ts:84-105
ICE servers:
- STUN: Google and Cloudflare (NAT traversal)
- TURN: Open Relay TCP/UDP (relay fallback for restrictive networks)
4. Document Sync
Yjs syncs the scene graph using a CRDT:
ydoc = new Y.Doc()
ynodes = ydoc.getMap('nodes') // Shared node map
// Sync changes: SceneGraph → Yjs
function syncNodeToYjs(nodeId: string) {
const node = store.graph.getNode(nodeId)
if (!node) return
ydoc.transact(() => {
let ynode = ynodes.get(nodeId)
if (!ynode) {
ynode = new Y.Map()
ynodes.set(nodeId, ynode)
}
syncNodePropsToYMap(node, ynode)
})
}
function syncNodePropsToYMap(node: SceneNode, ynode: Y.Map<unknown>) {
for (const [key, value] of Object.entries(node)) {
if (typeof value === 'object' && value !== null) {
ynode.set(key, JSON.stringify(value)) // Serialize objects
} else {
ynode.set(key, value)
}
}
}
From src/composables/use-collab.ts:225-250
JSON fields (serialized as strings):
export const YJS_JSON_FIELDS = new Set([
'fills',
'strokes',
'effects',
'vectorNetwork',
'styleRuns',
'boundVariables'
])
5. Remote Changes
Apply remote updates from Yjs to the scene graph:
ynodes.observeDeep((events) => {
if (suppressYjsEvents) return
suppressGraphEvents = true
try {
applyYjsToGraph(events)
} finally {
suppressGraphEvents = false
}
store.requestRender()
})
function applyYjsToGraph(events: Y.YEvent<Y.Map<unknown>>[]) {
for (const event of events) {
if (event.target === ynodes) {
for (const [key, change] of event.changes.keys) {
if (change.action === 'add') {
const ynode = ynodes.get(key)
if (ynode) applyYnodeToGraph(key, ynode)
} else if (change.action === 'delete') {
store.graph.deleteNode(key)
}
}
}
}
}
From src/composables/use-collab.ts:73-86 and 268-287
Awareness: Cursors & Selection
Broadcasting Presence
awareness = new awarenessProtocol.Awareness(ydoc)
// Set local user info
function broadcastAwareness() {
awareness.setLocalStateField('user', {
name: state.value.localName,
color: state.value.localColor
})
}
// Update cursor position
function updateCursor(x: number, y: number, pageId: string) {
awareness.setLocalStateField('cursor', {
x, y, pageId, zoom: store.state.zoom
})
}
// Update selection
function updateSelection(ids: string[]) {
awareness.setLocalStateField('selection', ids)
}
From src/composables/use-collab.ts:327-343
Remote Cursor Rendering
Remote cursors are rendered on the canvas as Figma-style colored arrows with name pills:
export interface RemotePeer {
clientId: number
name: string
color: Color
cursor?: { x: number; y: number; pageId: string }
selection?: string[]
}
function updatePeersList() {
const states = awareness.getStates()
const peers: RemotePeer[] = []
const currentPageId = store.state.currentPageId
states.forEach((peerState, clientId) => {
if (clientId === awareness.clientID) return
const user = peerState.user
if (!user) return
peers.push({
clientId,
name: user.name || 'Anonymous',
color: user.color || PEER_COLORS[clientId % PEER_COLORS.length],
cursor: peerState.cursor,
selection: peerState.selection
})
})
// Filter to current page
store.state.remoteCursors = peers
.filter((p) => p.cursor && p.cursor.pageId === currentPageId)
.map((p) => ({
name: p.name,
color: p.color,
x: p.cursor!.x,
y: p.cursor!.y,
selection: p.selection
}))
}
From src/composables/use-collab.ts:20-26 and 345-375
Peer colors:
export const PEER_COLORS: Color[] = [
{ r: 0.2, g: 0.6, b: 1.0, a: 1 }, // Blue
{ r: 1.0, g: 0.4, b: 0.4, a: 1 }, // Red
{ r: 0.3, g: 0.8, b: 0.5, a: 1 }, // Green
{ r: 0.9, g: 0.6, b: 0.2, a: 1 }, // Orange
{ r: 0.7, g: 0.3, b: 0.9, a: 1 }, // Purple
]
Follow Mode
Click a peer’s avatar to follow their viewport:
const followingPeer = ref<number | null>(null)
function followPeer(clientId: number | null) {
followingPeer.value = clientId
}
function tickFollow() {
if (!followingPeer.value || !awareness) return
const peerState = awareness.getStates().get(followingPeer.value)
if (!peerState?.cursor) {
followingPeer.value = null
return
}
const cursor = peerState.cursor
// Switch page if needed
if (cursor.pageId !== store.state.currentPageId) {
store.switchPage(cursor.pageId)
}
// Center viewport on cursor
const canvas = document.querySelector('canvas')
if (!canvas) return
if (cursor.zoom) store.state.zoom = cursor.zoom
const cw = canvas.width / devicePixelRatio
const ch = canvas.height / devicePixelRatio
store.state.panX = cw / 2 - cursor.x * store.state.zoom
store.state.panY = ch / 2 - cursor.y * store.state.zoom
store.requestRender()
}
From src/composables/use-collab.ts:404-429
Follow mode updates continuously via the awareness protocol’s change event.
Local Persistence
Documents persist locally using IndexedDB:
import { IndexeddbPersistence } from 'y-indexeddb'
persistence = new IndexeddbPersistence(`op-room-${roomId}`, ydoc)
From src/composables/use-collab.ts:66
Benefits:
- Refreshing the page preserves your work
- Offline editing (syncs when peers reconnect)
- No server storage required
Conflict Resolution
Yjs CRDT automatically resolves conflicts:
- Last-write-wins for primitive values
- Merges for arrays and maps
- Tombstones track deletions
- Causal ordering via vector clocks
Example: Two users edit the same node simultaneously:
// User A changes fill color
node.fills = [{ type: 'SOLID', color: red }]
// User B changes opacity (same node)
node.opacity = 0.5
// Result: Both changes applied
node.fills = [{ type: 'SOLID', color: red }]
node.opacity = 0.5
Peer Lifecycle
Peer Join
room.onPeerJoin((peerId) => {
state.value.connected = true
// Send full state vector
const sv = Y.encodeStateVector(ydoc)
sendSyncStep1(sv, peerId)
// Send awareness state
const encodedUpdate = awarenessProtocol.encodeAwarenessUpdate(
awareness,
[awareness.clientID]
)
sendAwareness(encodedUpdate, peerId)
})
From src/composables/use-collab.ts:152-163
Peer Leave
Clean up stale cursors:
room.onPeerLeave(() => {
if (awareness) {
const remoteClients = [...awareness.getStates().keys()].filter(
(id) => id !== awareness.clientID
)
awarenessProtocol.removeAwarenessStates(awareness, remoteClients, 'peer-left')
}
updatePeersList()
})
From src/composables/use-collab.ts:165-173
Disconnect
function disconnect() {
room?.leave()
room = null
if (awareness) {
awareness.destroy()
awareness = null
}
if (persistence) {
persistence.destroy()
persistence = null
}
if (ydoc) {
ydoc.destroy()
ydoc = null
}
state.value.connected = false
state.value.roomId = null
state.value.peers = []
store.state.remoteCursors = []
}
From src/composables/use-collab.ts:198-223
Usage Example
import { useCollab } from '@/composables/use-collab'
import { useEditorStore } from '@/stores/editor'
const store = useEditorStore()
const collab = useCollab(store)
// Share document
const roomId = collab.shareCurrentDoc()
const shareUrl = `https://app.openpencil.dev/share/${roomId}`
console.log('Share this link:', shareUrl)
// Join existing room
collab.connect('abc123xyz456')
// Set display name
collab.setLocalName('Alice')
// Update cursor (call on mousemove)
collab.updateCursor(100, 200, currentPageId)
// Update selection (call on selection change)
collab.updateSelection(['node-1', 'node-2'])
// Follow a peer
collab.followPeer(peerClientId)
// Stop following
collab.followPeer(null)
// Disconnect
collab.disconnect()
Implementation Notes
Event Suppression
Prevent infinite loops when syncing:
let suppressGraphEvents = false // Suppress SceneGraph → Yjs
let suppressYjsEvents = false // Suppress Yjs → SceneGraph
// Patch graph.updateNode to sync changes
const origUpdateNode = store.graph.updateNode.bind(store.graph)
store.graph.updateNode = (id: string, changes: Partial<SceneNode>) => {
origUpdateNode(id, changes)
if (!suppressGraphEvents && ydoc && ynodes) {
syncNodeToYjs(id)
}
}
From src/composables/use-collab.ts:50-51 and 189-196
Constants
// Room ID generation
export const ROOM_ID_LENGTH = 16
export const ROOM_ID_CHARS = 'abcdefghijklmnopqrstuvwxyz0123456789'
// Trystero config
export const TRYSTERO_APP_ID = 'openpencil-v1'
Security Considerations
Room IDs are the only authentication. Anyone with the link can join and edit. Do not share sensitive documents via public links.
Future improvements:
- Optional password protection
- Read-only access mode
- Persistent room ownership
See Also