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; export interface WhiteboardStrokePayload { tool: WhiteboardTool; color: WhiteboardColor; width: WhiteboardWidth; // [x, y, t-ms-since-stroke-start] points: Array<[number, number, number]>; } interface Props { strokes: WhiteboardStroke[]; tool: WhiteboardTool; color: WhiteboardColor; width: WhiteboardWidth; onStroke: (payload: WhiteboardStrokePayload) => void; logicalWidth?: number; logicalHeight?: number; /** Enables live-cursor broadcast when set. */ whiteboardId?: string | null; } const DEFAULT_LOGICAL_W = 1280; const DEFAULT_LOGICAL_H = 720; export function WhiteboardCanvas({ strokes, tool, color, width, 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; cv.width = logicalWidth; cv.height = logicalHeight; const ctx = cv.getContext('2d'); if (!ctx) return; ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, cv.width, cv.height); for (const s of strokes) { const payload = s.strokeJson as Partial | null; if (payload) renderStroke(ctx, payload); } if (draftRef.current) renderStroke(ctx, draftRef.current); }); function canvasPoint(e: React.PointerEvent): [number, number] { const cv = canvasRef.current!; const rect = cv.getBoundingClientRect(); const scaleX = cv.width / rect.width; const scaleY = cv.height / rect.height; return [(e.clientX - rect.left) * scaleX, (e.clientY - rect.top) * scaleY]; } const handlePointerDown = (e: React.PointerEvent) => { const cv = canvasRef.current; if (!cv) return; cv.setPointerCapture(e.pointerId); const [x, y] = canvasPoint(e); strokeStartRef.current = Date.now(); draftRef.current = { tool, color, width, points: [[x, y, 0]], }; forceTick((n) => n + 1); }; const handlePointerMove = (e: React.PointerEvent) => { 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); }; const handlePointerUp = (e: React.PointerEvent) => { const cv = canvasRef.current; if (cv && cv.hasPointerCapture(e.pointerId)) cv.releasePointerCapture(e.pointerId); const draft = draftRef.current; draftRef.current = null; if (!draft) return; if (draft.points.length < 2) { forceTick((n) => n + 1); return; } onStroke(draft); forceTick((n) => n + 1); }; 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, ): void { const points = Array.isArray(s.points) ? s.points : null; if (!points || points.length < 1) return; ctx.save(); ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.lineWidth = typeof s.width === 'number' ? s.width : 4; if (s.tool === 'eraser') { ctx.strokeStyle = '#ffffff'; ctx.lineWidth = Math.max(8, (typeof s.width === 'number' ? s.width : 4) * 4); } else { ctx.strokeStyle = typeof s.color === 'string' ? s.color : '#000000'; } ctx.beginPath(); ctx.moveTo(points[0]![0], points[0]![1]); for (let i = 1; i < points.length; i++) { ctx.lineTo(points[i]![0], points[i]![1]); } ctx.stroke(); ctx.restore(); }