82 lines
2.3 KiB
TypeScript
82 lines
2.3 KiB
TypeScript
// 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<typeof setTimeout> | 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 };
|
|
}
|