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 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 WhiteboardTool = 'pen' | 'eraser';
|
||||||
export type WhiteboardColor = '#000000' | '#ef4444' | '#3b82f6' | '#22c55e' | '#facc15' | '#a855f7';
|
export type WhiteboardColor = '#000000' | '#ef4444' | '#3b82f6' | '#22c55e' | '#facc15' | '#a855f7';
|
||||||
export type WhiteboardWidth = 2 | 4 | 8;
|
export type WhiteboardWidth = 2 | 4 | 8;
|
||||||
@@ -22,6 +25,8 @@ interface Props {
|
|||||||
onStroke: (payload: WhiteboardStrokePayload) => void;
|
onStroke: (payload: WhiteboardStrokePayload) => void;
|
||||||
logicalWidth?: number;
|
logicalWidth?: number;
|
||||||
logicalHeight?: number;
|
logicalHeight?: number;
|
||||||
|
/** Enables live-cursor broadcast when set. */
|
||||||
|
whiteboardId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_LOGICAL_W = 1280;
|
const DEFAULT_LOGICAL_W = 1280;
|
||||||
@@ -35,12 +40,63 @@ export function WhiteboardCanvas({
|
|||||||
onStroke,
|
onStroke,
|
||||||
logicalWidth = DEFAULT_LOGICAL_W,
|
logicalWidth = DEFAULT_LOGICAL_W,
|
||||||
logicalHeight = DEFAULT_LOGICAL_H,
|
logicalHeight = DEFAULT_LOGICAL_H,
|
||||||
|
whiteboardId,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
const draftRef = useRef<WhiteboardStrokePayload | null>(null);
|
const draftRef = useRef<WhiteboardStrokePayload | null>(null);
|
||||||
const strokeStartRef = useRef<number>(0);
|
const strokeStartRef = useRef<number>(0);
|
||||||
const [, forceTick] = useState(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(() => {
|
useEffect(() => {
|
||||||
const cv = canvasRef.current;
|
const cv = canvasRef.current;
|
||||||
if (!cv) return;
|
if (!cv) return;
|
||||||
@@ -81,8 +137,9 @@ export function WhiteboardCanvas({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handlePointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
const handlePointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||||
if (!draftRef.current) return;
|
|
||||||
const [x, y] = canvasPoint(e);
|
const [x, y] = canvasPoint(e);
|
||||||
|
cursorSessionRef.current?.send(x, y);
|
||||||
|
if (!draftRef.current) return;
|
||||||
draftRef.current.points.push([x, y, Date.now() - strokeStartRef.current]);
|
draftRef.current.points.push([x, y, Date.now() - strokeStartRef.current]);
|
||||||
forceTick((n) => n + 1);
|
forceTick((n) => n + 1);
|
||||||
};
|
};
|
||||||
@@ -102,6 +159,10 @@ export function WhiteboardCanvas({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div
|
||||||
|
className="relative"
|
||||||
|
style={{ aspectRatio: logicalWidth + ' / ' + logicalHeight, width: '100%' }}
|
||||||
|
>
|
||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
onPointerDown={handlePointerDown}
|
onPointerDown={handlePointerDown}
|
||||||
@@ -109,9 +170,40 @@ export function WhiteboardCanvas({
|
|||||||
onPointerUp={handlePointerUp}
|
onPointerUp={handlePointerUp}
|
||||||
onPointerLeave={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"
|
className="block max-h-full max-w-full cursor-crosshair touch-none rounded-lg border border-line/40 bg-white shadow-2xl"
|
||||||
style={{ aspectRatio: logicalWidth + ' / ' + logicalHeight, width: '100%' }}
|
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(
|
function renderStroke(
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ export function WhiteboardModal({ whiteboardId, onClose }: Props) {
|
|||||||
color={color}
|
color={color}
|
||||||
width={width}
|
width={width}
|
||||||
onStroke={(payload) => void insertStroke(payload)}
|
onStroke={(payload) => void insertStroke(payload)}
|
||||||
|
whiteboardId={whiteboardId}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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