feat(whiteboard): live cursors via broadcast channel
This commit is contained in:
@@ -2,6 +2,9 @@ import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { WhiteboardStroke } from '@chat-app/shared/chat';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { openCursorSession, type CursorEvent, type CursorSession } from '../lib/whiteboardCursors';
|
||||
|
||||
export type WhiteboardTool = 'pen' | 'eraser';
|
||||
export type WhiteboardColor = '#000000' | '#ef4444' | '#3b82f6' | '#22c55e' | '#facc15' | '#a855f7';
|
||||
export type WhiteboardWidth = 2 | 4 | 8;
|
||||
@@ -22,6 +25,8 @@ interface Props {
|
||||
onStroke: (payload: WhiteboardStrokePayload) => void;
|
||||
logicalWidth?: number;
|
||||
logicalHeight?: number;
|
||||
/** Enables live-cursor broadcast when set. */
|
||||
whiteboardId?: string | null;
|
||||
}
|
||||
|
||||
const DEFAULT_LOGICAL_W = 1280;
|
||||
@@ -35,12 +40,63 @@ export function WhiteboardCanvas({
|
||||
onStroke,
|
||||
logicalWidth = DEFAULT_LOGICAL_W,
|
||||
logicalHeight = DEFAULT_LOGICAL_H,
|
||||
whiteboardId,
|
||||
}: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const draftRef = useRef<WhiteboardStrokePayload | null>(null);
|
||||
const strokeStartRef = useRef<number>(0);
|
||||
const [, forceTick] = useState(0);
|
||||
|
||||
const { session, profile } = useAuth();
|
||||
const [remoteCursors, setRemoteCursors] = useState<Map<string, CursorEvent & { lastSeen: number }>>(
|
||||
() => new Map(),
|
||||
);
|
||||
const cursorSessionRef = useRef<CursorSession | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!whiteboardId) return;
|
||||
const me = session?.user;
|
||||
if (!me) return;
|
||||
const displayName = profile?.displayName ?? me.email ?? me.id.slice(0, 8);
|
||||
const s = openCursorSession(
|
||||
whiteboardId,
|
||||
{ userId: me.id, displayName },
|
||||
(ev) => {
|
||||
setRemoteCursors((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(ev.userId, { ...ev, lastSeen: Date.now() });
|
||||
return next;
|
||||
});
|
||||
},
|
||||
);
|
||||
cursorSessionRef.current = s;
|
||||
return () => {
|
||||
s.close();
|
||||
cursorSessionRef.current = null;
|
||||
};
|
||||
}, [whiteboardId, session?.user, profile?.displayName]);
|
||||
|
||||
// Stale-cursor sweep: drop cursors that haven't been heard from in 2s. Cheap
|
||||
// poll because the Map is tiny (at most one entry per active collaborator).
|
||||
useEffect(() => {
|
||||
if (remoteCursors.size === 0) return;
|
||||
const id = setInterval(() => {
|
||||
const now = Date.now();
|
||||
setRemoteCursors((prev) => {
|
||||
let changed = false;
|
||||
const next = new Map(prev);
|
||||
for (const [k, v] of next) {
|
||||
if (now - v.lastSeen > 2000) {
|
||||
next.delete(k);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [remoteCursors.size]);
|
||||
|
||||
useEffect(() => {
|
||||
const cv = canvasRef.current;
|
||||
if (!cv) return;
|
||||
@@ -81,8 +137,9 @@ export function WhiteboardCanvas({
|
||||
};
|
||||
|
||||
const handlePointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
if (!draftRef.current) return;
|
||||
const [x, y] = canvasPoint(e);
|
||||
cursorSessionRef.current?.send(x, y);
|
||||
if (!draftRef.current) return;
|
||||
draftRef.current.points.push([x, y, Date.now() - strokeStartRef.current]);
|
||||
forceTick((n) => n + 1);
|
||||
};
|
||||
@@ -102,18 +159,53 @@ export function WhiteboardCanvas({
|
||||
};
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerLeave={handlePointerUp}
|
||||
className="block max-h-full max-w-full cursor-crosshair touch-none rounded-lg border border-line/40 bg-white shadow-2xl"
|
||||
<div
|
||||
className="relative"
|
||||
style={{ aspectRatio: logicalWidth + ' / ' + logicalHeight, width: '100%' }}
|
||||
/>
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerLeave={handlePointerUp}
|
||||
className="block max-h-full max-w-full cursor-crosshair touch-none rounded-lg border border-line/40 bg-white shadow-2xl"
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
/>
|
||||
{Array.from(remoteCursors.values()).map((c) => {
|
||||
const pctX = (c.x / logicalWidth) * 100;
|
||||
const pctY = (c.y / logicalHeight) * 100;
|
||||
return (
|
||||
<div
|
||||
key={c.userId}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute"
|
||||
style={{ left: pctX + '%', top: pctY + '%', transform: 'translate(-2px, -2px)' }}
|
||||
>
|
||||
<span
|
||||
className="block h-2 w-2 rounded-full border-2 border-white shadow"
|
||||
style={{ backgroundColor: colorForUserId(c.userId) }}
|
||||
/>
|
||||
<span className="ml-2 inline-block translate-y-[-2px] rounded-full bg-black/60 px-1.5 py-0.5 text-[9px] font-semibold text-white">
|
||||
{c.displayName}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function colorForUserId(userId: string): string {
|
||||
// Deterministic hue from the user id so each collaborator gets a stable
|
||||
// colour across sessions. Saturation/lightness fixed to keep the cursor
|
||||
// legible against the white canvas.
|
||||
let hash = 0;
|
||||
for (let i = 0; i < userId.length; i++) hash = (hash * 31 + userId.charCodeAt(i)) | 0;
|
||||
const hue = Math.abs(hash) % 360;
|
||||
return 'hsl(' + hue + ', 70%, 50%)';
|
||||
}
|
||||
|
||||
function renderStroke(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
s: Partial<WhiteboardStrokePayload>,
|
||||
|
||||
@@ -80,6 +80,7 @@ export function WhiteboardModal({ whiteboardId, onClose }: Props) {
|
||||
color={color}
|
||||
width={width}
|
||||
onStroke={(payload) => void insertStroke(payload)}
|
||||
whiteboardId={whiteboardId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user