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>,
|
||||
|
||||
Reference in New Issue
Block a user