From f9f8e3fdb8e91ab6f82b602195debceb0b7ec91e Mon Sep 17 00:00:00 2001 From: byGalax Date: Sun, 17 May 2026 17:24:34 +0200 Subject: [PATCH] feat(whiteboard): live cursors via broadcast channel --- .../src/components/WhiteboardCanvas.tsx | 110 ++++++++++++++++-- .../src/components/WhiteboardModal.tsx | 1 + apps/desktop/src/lib/whiteboardCursors.ts | 81 +++++++++++++ 3 files changed, 183 insertions(+), 9 deletions(-) create mode 100644 apps/desktop/src/lib/whiteboardCursors.ts diff --git a/apps/desktop/src/components/WhiteboardCanvas.tsx b/apps/desktop/src/components/WhiteboardCanvas.tsx index f7b9617..9dcf3de 100644 --- a/apps/desktop/src/components/WhiteboardCanvas.tsx +++ b/apps/desktop/src/components/WhiteboardCanvas.tsx @@ -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(null); const draftRef = useRef(null); const strokeStartRef = useRef(0); const [, forceTick] = useState(0); + const { session, profile } = useAuth(); + const [remoteCursors, setRemoteCursors] = useState>( + () => new Map(), + ); + const cursorSessionRef = useRef(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) => { - 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 ( - + > + + {Array.from(remoteCursors.values()).map((c) => { + const pctX = (c.x / logicalWidth) * 100; + const pctY = (c.y / logicalHeight) * 100; + return ( + + ); + })} + ); } +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, diff --git a/apps/desktop/src/components/WhiteboardModal.tsx b/apps/desktop/src/components/WhiteboardModal.tsx index 9f044bd..2ebc4ff 100644 --- a/apps/desktop/src/components/WhiteboardModal.tsx +++ b/apps/desktop/src/components/WhiteboardModal.tsx @@ -80,6 +80,7 @@ export function WhiteboardModal({ whiteboardId, onClose }: Props) { color={color} width={width} onStroke={(payload) => void insertStroke(payload)} + whiteboardId={whiteboardId} /> )} diff --git a/apps/desktop/src/lib/whiteboardCursors.ts b/apps/desktop/src/lib/whiteboardCursors.ts new file mode 100644 index 0000000..75e2180 --- /dev/null +++ b/apps/desktop/src/lib/whiteboardCursors.ts @@ -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 | 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 }; +}