import { useEffect, useRef, useState } from 'react'; import type { WhiteboardStroke } from '@chat-app/shared/chat'; 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; } 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, }: Props) { const canvasRef = useRef(null); const draftRef = useRef(null); const strokeStartRef = useRef(0); const [, forceTick] = useState(0); 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) => { if (!draftRef.current) return; const [x, y] = canvasPoint(e); 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 ( ); } 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(); }