Collaboration Provider
The SupabaseRealtimeProvider class provides real-time collaborative editing by combining Yjs CRDT synchronization with Supabase Realtime broadcast channels.
Location: src/lib/components/editor/collaboration-provider.ts
Architecture
Key Features
| Feature | Implementation |
|---|---|
| CRDT Sync | Yjs document updates broadcast via Supabase Realtime |
| Offline Support | IndexedDB persistence via y-indexeddb |
| Cursor Tracking | Dedicated cursor-update broadcasts, rendered by CustomCursorExtension |
| Presence | Supabase Realtime Presence for user tracking |
| Debouncing | 100ms update batching, 50ms cursor throttling |
Usage
Basic Initialization
import {
createCollaborationProvider,
generateUserColor
} from '$lib/components/editor/collaboration-provider';
const provider = createCollaborationProvider({
supabase,
sectionId: 'section-uuid',
user: {
id: currentUser.id,
name: currentUser.full_name,
color: generateUserColor(currentUser.id),
avatarUrl: currentUser.avatar_url
},
onStatusChange: (status) => {
// 'connecting' | 'connected' | 'disconnected'
console.log('Connection status:', status);
},
onUsersChange: (users) => {
// Array of CollaborationUser objects
console.log('Active users:', users);
},
onSynced: (hasLocalContent, receivedRemoteSync) => {
// Called once when initial sync completes
if (receivedRemoteSync) {
// Use remote state (another user shared their content)
} else if (hasLocalContent) {
// Use IndexedDB content (offline edits)
} else {
// Initialize from database
}
},
onCursorChange: (userId, cursor) => {
// Remote cursor position update
if (cursor) {
console.log(`${cursor.user.name} at position ${cursor.position.from}`);
}
}
});TipTap Integration
Remote carets are not drawn by TipTap's stock CollaborationCursor extension. They use the repo's own CustomCursorExtension (src/lib/components/editor/cursor-extension.ts), which renders absolutely positioned overlays outside ProseMirror's transaction system and is driven by the provider's cursor-update broadcasts rather than by Yjs Awareness.
import { Editor } from '@tiptap/core';
import Collaboration from '@tiptap/extension-collaboration';
import {
CustomCursorExtension,
setRemoteCursor,
removeRemoteCursor
} from '$lib/components/editor/cursor-extension';
const editor = new Editor({
extensions: [
// ... other extensions
Collaboration.configure({
document: provider.getDoc(),
field: 'content'
}),
CustomCursorExtension.configure({
currentUserId: currentUser.id,
getContrastColor,
// Fired only while THIS editor has focus
onCursorUpdate: (position) => provider.updateCursorPosition(position),
// Fired on blur, after a 200ms grace period
onCursorClear: () => provider.clearCursor()
})
]
});
// Provider option: route every incoming cursor through the editor that owns
// its section. Passing any other editor is what issue #299 was.
const onCursorChange = (userId: string, cursor: RemoteCursor | null) => {
if (cursor === null) {
removeRemoteCursor(editor, userId);
} else {
setRemoteCursor(editor, userId, cursor);
}
};Section scoping (do not regress)
A page mounts one editor per show-notes section, all at the same time, so the lens switcher can keep Yjs connections alive. Two rules follow, and breaking either one reproduces #299:
- All cursor render state is per editor, held in
editor.storage.customCursorvia the extension'saddStorage(). It was module-level once, which meant a single cursor map, a single overlay container and a single "active view" shared by every editor on the page: remote carets were drawn into whichever editor last updated, with a position index from one document re-resolved against another. That was a rendering defect, not a data leak, since each section has its ownshow-notes:{sectionId}channel and guests only subscribe to sectionsget_guest_show_notesgrants them. - The local caret is broadcast only while its editor has focus. The plugin's
updatehook also runs when a remote Yjs update is applied to a hidden, never-focused editor, where the default selection is position 1. Broadcasting from there parked the local user's marker on the first line of every other participant's section.
A lens-inactive editor is display: none, so coordsAtPos returns zero-height rects and nothing can be measured. Each editor keeps an IntersectionObserver on its own DOM to re-run the position pass when it becomes visible again, so an idle peer's caret still appears after a lens switch.
Cleanup
// On component destroy
onDestroy(() => {
provider.destroy();
});API Reference
CollaborationProviderOptions
interface CollaborationProviderOptions {
supabase: SupabaseClient<Database>;
sectionId: string;
user: CollaborationUser;
onStatusChange?: (status: 'connecting' | 'connected' | 'disconnected') => void;
onUsersChange?: (users: CollaborationUser[]) => void;
onSynced?: (hasLocalContent: boolean, receivedRemoteSync: boolean) => void;
onCursorChange?: (userId: string, cursor: RemoteCursor | null) => void;
}CollaborationUser
interface CollaborationUser {
id: string;
name: string;
color: string; // Hex color for cursor/avatar
avatarUrl?: string;
}RemoteCursor
interface RemoteCursor {
user: CollaborationUser;
position: {
from: number;
to: number;
};
}Instance Methods
| Method | Returns | Description |
|---|---|---|
getDoc() | Y.Doc | Get the Yjs document |
getAwareness() | Awareness | Get Yjs Awareness instance |
getUsers() | CollaborationUser[] | Get all active users |
getRemoteCursors() | Map<string, RemoteCursor> | Get remote cursor positions |
isSyncComplete() | boolean | Check if IndexedDB sync is done |
whenSynced() | Promise<boolean> | Wait for IndexedDB sync |
updateCursorPosition(pos) | void | Broadcast cursor position (focused editors only) |
clearCursor() | void | Retract our caret: broadcasts position: null and drops lastCursorPosition |
clearLocalCache() | Promise<void> | Clear IndexedDB cache |
destroy() | Promise<void> | Clean up all resources |
Utility Functions
generateUserColor(userId: string): string
Generates a deterministic color based on user ID. Same user always gets the same color.
const color = generateUserColor('user-123');
// Returns one of 10 predefined colors: #FF6B6B, #4ECDC4, etc.Broadcast Events
The provider uses these Supabase Realtime broadcast events:
| Event | Direction | Payload | Purpose |
|---|---|---|---|
doc-update | Bidirectional | { update: number[] } | Yjs document changes |
sync-request | Out | { requesterId: string } | Request full state |
sync-response | In | { state: number[], requesterId: string } | Full state from peer |
awareness-update | Bidirectional | { update: number[] } | Yjs Awareness changes |
awareness-request | Out | { requesterId: string } | Request awareness state |
awareness-response | In | { update: number[], requesterId: string } | Awareness from peer |
cursor-update | Bidirectional | { userId, position, user } | Cursor position |
cursor-request | Out | { requesterId: string } | Request cursor positions |
cursor-response | In | { userId, position, user, requesterId } | Cursor from peer |
Connection Lifecycle
Sync Strategy
The provider implements a careful sync strategy to avoid content conflicts:
- IndexedDB First: Local cache is loaded immediately for instant startup
- Remote Check: Request state from other active users
- Grace Period: 500ms wait for remote responses
- Decision Point: The
onSyncedcallback provides flags:receivedRemoteSync = true: Another user shared their contenthasLocalContent = true: IndexedDB had cached content- Both false: No local or remote content, initialize from database
onSynced: (hasLocalContent, receivedRemoteSync) => {
if (receivedRemoteSync) {
// Remote state is authoritative - already applied to Yjs doc
return;
}
if (!hasLocalContent) {
// No one has content - load from database
const dbContent = await loadFromDatabase(sectionId);
// Initialize Yjs document with database content
}
};Performance Optimizations
Update Batching
Document updates are debounced to 100ms and merged before broadcast:
// Multiple rapid edits become one broadcast
this.debounceTimer = setTimeout(() => {
const mergedUpdate = Y.mergeUpdates(this.pendingUpdates);
this.channel.send({
type: 'broadcast',
event: 'doc-update',
payload: { update: Array.from(mergedUpdate) }
});
}, 100);Cursor Throttling
Cursor position updates are throttled to 50ms maximum frequency:
updateCursorPosition(position: CursorPosition): void {
this.lastCursorPosition = position;
if (this.cursorThrottleTimer) {
return; // Already scheduled
}
this.cursorThrottleTimer = setTimeout(() => {
this.cursorThrottleTimer = null;
// Broadcast position
}, 50);
}Awareness Throttling
Yjs Awareness updates are also throttled to 50ms.
IndexedDB Persistence
Each section gets its own IndexedDB database:
const dbName = `show-notes-${sectionId}`;
this.indexeddbProvider = new IndexeddbPersistence(dbName, this.doc);To clear cached content (e.g., after database reset):
await provider.clearLocalCache();Error Handling
The provider handles these error scenarios:
| Scenario | Behavior |
|---|---|
| Connection lost | Updates queued in pendingUpdates |
| Reconnection | Queued updates flushed automatically |
| Channel error | onStatusChange('disconnected') called |
| No peers | Falls back to IndexedDB/database content |
Integration with Presence Store
The provider automatically updates the global presence store:
// On connect
presenceStore.setCurrentUser(this.user);
presenceStore.setStatus('connected');
// On users change
presenceStore.updateSectionPresence(this.sectionId, deduplicatedUsers);
// On destroy
presenceStore.removeSectionPresence(this.sectionId);