// Live-cursor pubsub for the multi-user whiteboard. Uses Supabase's // `broadcast` channel rather than `presence` because we want fire-and-forget // position updates (no need to track join/leave) and presence has higher // minimum latency due to its diff-and-merge semantics. // // Throttled to ~30 fps so a continuous drag doesn't flood the channel. import type { RealtimeChannel } from '@supabase/supabase-js'; import { supabase } from './supabase'; const THROTTLE_MS = 33; // ~30 fps export interface CursorEvent { userId: string; displayName: string; // logical canvas coordinates (matches WhiteboardCanvas internal space) x: number; y: number; } export interface CursorSession { send: (x: number, y: number) => void; close: () => void; } export function openCursorSession( whiteboardId: string, self: { userId: string; displayName: string }, onCursor: (ev: CursorEvent) => void, ): CursorSession { const channel: RealtimeChannel = supabase.channel('wb-cursor:' + whiteboardId, { config: { broadcast: { self: false } }, }); channel.on('broadcast', { event: 'cursor' }, (payload) => { const ev = payload.payload as CursorEvent | undefined; if (!ev || ev.userId === self.userId) return; onCursor(ev); }); channel.subscribe(); let lastSentAt = 0; let pending: { x: number; y: number } | null = null; let flushTimer: ReturnType | null = null; const flush = (): void => { flushTimer = null; if (!pending) return; const { x, y } = pending; pending = null; lastSentAt = Date.now(); void channel.send({ type: 'broadcast', event: 'cursor', payload: { userId: self.userId, displayName: self.displayName, x, y } satisfies CursorEvent, }); }; const send = (x: number, y: number): void => { const now = Date.now(); const since = now - lastSentAt; if (since >= THROTTLE_MS) { pending = { x, y }; flush(); } else { pending = { x, y }; if (flushTimer === null) { flushTimer = setTimeout(flush, THROTTLE_MS - since); } } }; const close = (): void => { if (flushTimer !== null) clearTimeout(flushTimer); flushTimer = null; pending = null; void supabase.removeChannel(channel); }; return { send, close }; }