diff --git a/apps/desktop/src/components/WhiteboardCanvas.tsx b/apps/desktop/src/components/WhiteboardCanvas.tsx new file mode 100644 index 0000000..f7b9617 --- /dev/null +++ b/apps/desktop/src/components/WhiteboardCanvas.tsx @@ -0,0 +1,142 @@ +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(); +}