From 61802ac1fc4102e2e948e739262c686e893e0f0d Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 6 Feb 2026 15:13:52 +0000 Subject: [PATCH 1/4] refactor whiteboard --- src/components/SessionLayout.tsx | 28 +- src/components/Whiteboard.tsx | 1563 ----------------- src/components/whiteboard/Toolbar.tsx | 246 +++ src/components/whiteboard/Whiteboard.tsx | 173 ++ src/components/whiteboard/drawing.ts | 116 ++ src/components/whiteboard/flood-fill.ts | 197 +++ src/components/whiteboard/index.ts | 1 + src/components/whiteboard/types.ts | 60 + src/components/whiteboard/useDrawing.ts | 202 +++ .../whiteboard/usePointerHandlers.ts | 260 +++ src/components/whiteboard/useUndoRedo.ts | 116 ++ src/components/whiteboard/useViewport.ts | 167 ++ .../whiteboard/useWhiteboardCanvas.ts | 365 ++++ 13 files changed, 1917 insertions(+), 1577 deletions(-) delete mode 100644 src/components/Whiteboard.tsx create mode 100644 src/components/whiteboard/Toolbar.tsx create mode 100644 src/components/whiteboard/Whiteboard.tsx create mode 100644 src/components/whiteboard/drawing.ts create mode 100644 src/components/whiteboard/flood-fill.ts create mode 100644 src/components/whiteboard/index.ts create mode 100644 src/components/whiteboard/types.ts create mode 100644 src/components/whiteboard/useDrawing.ts create mode 100644 src/components/whiteboard/usePointerHandlers.ts create mode 100644 src/components/whiteboard/useUndoRedo.ts create mode 100644 src/components/whiteboard/useViewport.ts create mode 100644 src/components/whiteboard/useWhiteboardCanvas.ts diff --git a/src/components/SessionLayout.tsx b/src/components/SessionLayout.tsx index 30c797a..0cdbd09 100644 --- a/src/components/SessionLayout.tsx +++ b/src/components/SessionLayout.tsx @@ -5,7 +5,7 @@ import { useSession } from '../lib/useSession'; import { useTheme } from '../lib/useTheme'; import { type ConnectionStatus } from '../lib/session'; import { CodeEditor } from './CodeEditor'; -import { Whiteboard } from './Whiteboard'; +import { Whiteboard } from './whiteboard'; import { Participants } from './Participants'; import { Chat } from './Chat'; import { @@ -31,7 +31,7 @@ import { } from './ui/dialog'; import { Switch } from './ui/switch'; -type Tab = 'code' | 'diagram'; +type Tab = 'code' | 'whiteboard'; // Sidebar resize constraints const MIN_SIDEBAR_WIDTH = 280; @@ -55,7 +55,7 @@ export function SessionLayout({ status, onCopyLink }: SessionLayoutProps) { const [activeTab, setActiveTab] = useState(() => { if (typeof window === 'undefined') return 'code'; const stored = sessionStorage.getItem(ACTIVE_TAB_STORAGE_KEY); - return stored === 'code' || stored === 'diagram' ? stored : 'code'; + return stored === 'code' || stored === 'whiteboard' ? stored : 'code'; }); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false); @@ -241,7 +241,7 @@ export function SessionLayout({ status, onCopyLink }: SessionLayoutProps) {
@@ -401,7 +401,7 @@ export function SessionLayout({ status, onCopyLink }: SessionLayoutProps) {
@@ -447,7 +447,7 @@ export function SessionLayout({ status, onCopyLink }: SessionLayoutProps) {
30) { - // Clicked on a stroke, don't fill - return; - } - - // Get target color at start position from fill canvas - const startFillIdx = (y * width + x) * 4; - const targetR = fillData[startFillIdx]; - const targetG = fillData[startFillIdx + 1]; - const targetB = fillData[startFillIdx + 2]; - const targetA = fillData[startFillIdx + 3]; - - // Don't fill if clicking on the same color - if ( - Math.abs(targetR - fillRGBA[0]) < 5 && - Math.abs(targetG - fillRGBA[1]) < 5 && - Math.abs(targetB - fillRGBA[2]) < 5 && - Math.abs(targetA - fillRGBA[3]) < 5 - ) { - return; - } - - // Tolerance for matching target color - const tolerance = 32; - - // Match target color on fill canvas (what we're replacing) - const matchesTarget = (idx: number): boolean => { - return ( - Math.abs(fillData[idx] - targetR) <= tolerance && - Math.abs(fillData[idx + 1] - targetG) <= tolerance && - Math.abs(fillData[idx + 2] - targetB) <= tolerance && - Math.abs(fillData[idx + 3] - targetA) <= tolerance - ); - }; - - // Check if pixel is a stroke boundary (from strokeCanvas) - const isBoundary = (pixelIdx: number): boolean => { - const idx = pixelIdx * 4; - // If stroke has significant alpha, it's a boundary - return strokeData[idx + 3] > 30; - }; - - // Use Uint8Array for fast visited tracking - const visited = new Uint8Array(width * height); - - // Scanline fill using spans - const stack: [number, number, number, number][] = []; // [x1, x2, y, direction] - - // Check if starting point is valid - const startPixelIdx = y * width + x; - if (isBoundary(startPixelIdx) || !matchesTarget(startPixelIdx * 4)) { - return; - } - - // Find initial span - let x1 = x; - let x2 = x; - while (x1 > 0) { - const leftIdx = y * width + x1 - 1; - if (isBoundary(leftIdx) || !matchesTarget(leftIdx * 4)) break; - x1--; - } - while (x2 < width - 1) { - const rightIdx = y * width + x2 + 1; - if (isBoundary(rightIdx) || !matchesTarget(rightIdx * 4)) break; - x2++; - } - - // Fill the initial span immediately to prevent gap in the first row - for (let fx = x1; fx <= x2; fx++) { - const pixelIdx = y * width + fx; - visited[pixelIdx] = 1; - const di = pixelIdx * 4; - fillData[di] = fillRGBA[0]; - fillData[di + 1] = fillRGBA[1]; - fillData[di + 2] = fillRGBA[2]; - fillData[di + 3] = fillRGBA[3]; - } - - stack.push([x1, x2, y, 1]); // down - stack.push([x1, x2, y, -1]); // up - - while (stack.length > 0) { - const [sx1, sx2, sy, dy] = stack.pop()!; - const ny = sy + dy; - - if (ny < 0 || ny >= height) continue; - - let cx = sx1; - while (cx <= sx2) { - const pixelIdx = ny * width + cx; - const dataIdx = pixelIdx * 4; - - // Skip if already visited, is a boundary, or doesn't match - if ( - visited[pixelIdx] || - isBoundary(pixelIdx) || - !matchesTarget(dataIdx) - ) { - cx++; - continue; - } - - // Find span boundaries - let spanX1 = cx; - let spanX2 = cx; - - // Extend left - while (spanX1 > 0) { - const leftIdx = ny * width + spanX1 - 1; - if ( - visited[leftIdx] || - isBoundary(leftIdx) || - !matchesTarget(leftIdx * 4) - ) - break; - spanX1--; - } - - // Extend right and fill - while (spanX2 < width) { - const rightIdx = ny * width + spanX2; - if ( - visited[rightIdx] || - isBoundary(rightIdx) || - !matchesTarget(rightIdx * 4) - ) - break; - - // Fill this pixel - visited[rightIdx] = 1; - const di = rightIdx * 4; - fillData[di] = fillRGBA[0]; - fillData[di + 1] = fillRGBA[1]; - fillData[di + 2] = fillRGBA[2]; - fillData[di + 3] = fillRGBA[3]; - - spanX2++; - } - spanX2--; - - // Also mark and fill the left extension - for (let fx = spanX1; fx < cx; fx++) { - const fillIdx = ny * width + fx; - visited[fillIdx] = 1; - const di = fillIdx * 4; - fillData[di] = fillRGBA[0]; - fillData[di + 1] = fillRGBA[1]; - fillData[di + 2] = fillRGBA[2]; - fillData[di + 3] = fillRGBA[3]; - } - - // Add spans for next rows - stack.push([spanX1, spanX2, ny, dy]); - // Check opposite direction if we extended beyond original span - if (spanX1 < sx1) stack.push([spanX1, sx1 - 1, ny, -dy]); - if (spanX2 > sx2) stack.push([sx2 + 1, spanX2, ny, -dy]); - - cx = spanX2 + 1; - } - } - - fillCtx.putImageData(fillImageData, 0, 0); -} - -export function Whiteboard() { - const { doc } = useSession(); - const { isDark } = useTheme(); - const canvasRef = useRef(null); - const containerRef = useRef(null); - - // Layered canvases: - // 1) boundaryStroke: reference for flood fill boundaries (erased by eraser) - // 2) visibleStroke: visible strokes (erased by eraser) - // 3) fill: background + fills (erased by painting background color) - // 4) world: final composite - const worldCanvasRef = useRef(null); - const boundaryStrokeCanvasRef = useRef(null); - const visibleStrokeCanvasRef = useRef(null); - const fillCanvasRef = useRef(null); - - // CSS dimensions for DPR-aware rendering - const canvasCssWidthRef = useRef(0); - const canvasCssHeightRef = useRef(0); - - // Track if world canvas needs rebuild - const worldNeedsRebuildRef = useRef(true); - - // Tool state - const [tool, setTool] = useState('pen'); - const [colour, setColour] = useState('#ffffff'); - const [size, setSize] = useState(5); - - // Drawing state - const isDrawing = useRef(false); - const currentOp = useRef(null); - const startPoint = useRef({ x: 0, y: 0 }); - - // Get Y.Array for drawing ops - const opsArray = doc.getArray('whiteboard'); - - // Undo stack (local only for now) - const [canUndo, setCanUndo] = useState(false); - const [canRedo, setCanRedo] = useState(false); - const undoStack = useRef([]); - const redoStack = useRef([]); - - // Viewport transform (refs for direct manipulation, bypassing React render cycle) - const transformRef = useRef({ - x: 0, - y: 0, - scale: 1, - }); // FIX: initial camera placement happens after resize using CSS size. - const isPanning = useRef(false); - const lastPanPoint = useRef({ x: 0, y: 0 }); // canvas-local CSS pixels - const lastPinchDistance = useRef(0); - const hasInitializedViewport = useRef(false); - const lastResizeSizeRef = useRef<{ width: number; height: number } | null>( - null, - ); - const hasUserViewportChangeRef = useRef(false); - const activePointersRef = useRef>(new Map()); - const MIN_SCALE = 0.25; - const MAX_SCALE = 4; - - // Mobile detection for UI adjustments - const [isMobile, setIsMobile] = useState(false); - useEffect(() => { - const checkMobile = () => { - setIsMobile(window.matchMedia('(max-width: 768px)').matches); - }; - checkMobile(); - window.addEventListener('resize', checkMobile); - return () => window.removeEventListener('resize', checkMobile); - }, []); - - // Update size to appropriate default when switching to/from eraser - // This ensures the size buttons (S/M/L) match the current tool's size range - useEffect(() => { - if (tool === 'eraser') { - // Switch to eraser: use medium eraser size (30) - setSize(ERASER_SIZES[1].value); - } else { - // Switch from eraser to another tool: use medium pen size (5) - // Only do this if current size is an eraser size (not a pen size) - if ( - ERASER_SIZES.some((s) => s.value === size) && - !SIZES.some((s) => s.value === size) - ) { - setSize(SIZES[1].value); - } - } - }, [tool]); // eslint-disable-line react-hooks/exhaustive-deps - - // Generate custom round cursor for pen and eraser tools - // The cursor shows the brush size as a circle (scales with zoom) - const brushCursor = useMemo(() => { - // Only show round brush cursor for pen and eraser - if (tool !== 'eraser' && tool !== 'pen') return 'crosshair'; - - // Scale the cursor size based on the current zoom level - // But clamp it to reasonable screen sizes (min 8px, max 128px) - const screenSize = Math.max( - 8, - Math.min(128, size * transformRef.current.scale), - ); - const halfSize = screenSize / 2; - - // Create an SVG circle cursor - const svg = ` - - - - - `.trim(); - - // Convert to data URL - const dataUrl = `data:image/svg+xml;base64,${btoa(svg)}`; - return `url(${dataUrl}) ${halfSize} ${halfSize}, crosshair`; - }, [tool, size]); - - useEffect(() => { - const canvas = canvasRef.current; - if (canvas) { - // FIX: ensure pointer events can cancel native gestures on mobile. - canvas.style.touchAction = 'none'; - } - }, []); - - // Get canvas context - const getContext = useCallback(() => { - const canvas = canvasRef.current; - if (!canvas) return null; - return canvas.getContext('2d'); - }, []); - - // Get background color based on theme - const getBackgroundColor = useCallback(() => { - return isDark ? '#111827' : '#ffffff'; - }, [isDark]); - - // Resize canvas to match container with proper DPR handling - const resizeCanvas = useCallback(() => { - const canvas = canvasRef.current; - const container = containerRef.current; - if (!canvas || !container) return; - - const rect = container.getBoundingClientRect(); - const dpr = window.devicePixelRatio || 1; - - // Set backing store size (physical pixels) - canvas.width = Math.floor(rect.width * dpr); - canvas.height = Math.floor(rect.height * dpr); - - // Store CSS dimensions for renderViewport - canvasCssWidthRef.current = rect.width; - canvasCssHeightRef.current = rect.height; - }, []); - - // Clamp viewport to world bounds using CSS pixels (not physical pixels). - const clampTransform = useCallback((x: number, y: number, scale: number) => { - const cssWidth = canvasCssWidthRef.current; - const cssHeight = canvasCssHeightRef.current; - - if (cssWidth <= 0 || cssHeight <= 0) { - return { x: 0, y: 0, scale }; - } - - const viewWorldW = cssWidth / scale; - const viewWorldH = cssHeight / scale; - const maxX = Math.max(0, CANVAS_WIDTH - viewWorldW); - const maxY = Math.max(0, CANVAS_HEIGHT - viewWorldH); - - return { - x: Math.max(0, Math.min(maxX, x)), - y: Math.max(0, Math.min(maxY, y)), - scale, - }; - }, []); - - // Center viewport after initial sizing or major orientation/size changes. - const centerViewport = useCallback( - (scale = transformRef.current.scale) => { - const cssWidth = canvasCssWidthRef.current; - const cssHeight = canvasCssHeightRef.current; - if (cssWidth <= 0 || cssHeight <= 0) return; - - const viewWorldW = cssWidth / scale; - const viewWorldH = cssHeight / scale; - const centeredX = (CANVAS_WIDTH - viewWorldW) / 2; - const centeredY = (CANVAS_HEIGHT - viewWorldH) / 2; - - // Center using canvas CSS size, then clamp within world bounds. - transformRef.current = clampTransform(centeredX, centeredY, scale); - }, - [clampTransform], - ); - - const updateViewportForResize = useCallback(() => { - const cssWidth = canvasCssWidthRef.current; - const cssHeight = canvasCssHeightRef.current; - if (cssWidth <= 0 || cssHeight <= 0) return; - - const prev = lastResizeSizeRef.current; - const orientationChanged = - prev !== null && prev.width > prev.height !== cssWidth > cssHeight; - const sizeChangeLarge = - prev !== null && - (Math.abs(cssWidth - prev.width) > prev.width * 0.15 || - Math.abs(cssHeight - prev.height) > prev.height * 0.15); - - const shouldRecenter = - !hasInitializedViewport.current || - ((orientationChanged || sizeChangeLarge) && - !hasUserViewportChangeRef.current); - - if (shouldRecenter) { - // Initial camera placement + sensible recenter on big resize. - centerViewport(); - } else { - // Keep current view but clamp using CSS size on resize. - transformRef.current = clampTransform( - transformRef.current.x, - transformRef.current.y, - transformRef.current.scale, - ); - } - - hasInitializedViewport.current = true; - lastResizeSizeRef.current = { width: cssWidth, height: cssHeight }; - }, [centerViewport, clampTransform]); - - // Initialize offscreen canvases - const initOffscreenCanvases = useCallback(() => { - if (!worldCanvasRef.current) { - worldCanvasRef.current = document.createElement('canvas'); - worldCanvasRef.current.width = CANVAS_WIDTH; - worldCanvasRef.current.height = CANVAS_HEIGHT; - } - if (!boundaryStrokeCanvasRef.current) { - boundaryStrokeCanvasRef.current = document.createElement('canvas'); - boundaryStrokeCanvasRef.current.width = CANVAS_WIDTH; - boundaryStrokeCanvasRef.current.height = CANVAS_HEIGHT; - } - if (!visibleStrokeCanvasRef.current) { - visibleStrokeCanvasRef.current = document.createElement('canvas'); - visibleStrokeCanvasRef.current.width = CANVAS_WIDTH; - visibleStrokeCanvasRef.current.height = CANVAS_HEIGHT; - } - if (!fillCanvasRef.current) { - fillCanvasRef.current = document.createElement('canvas'); - fillCanvasRef.current.width = CANVAS_WIDTH; - fillCanvasRef.current.height = CANVAS_HEIGHT; - } - }, []); - - // Draw a single stroke operation (not fill) - const drawStrokeOp = useCallback( - (ctx: CanvasRenderingContext2D, op: DrawOp) => { - ctx.strokeStyle = op.colour; - ctx.fillStyle = op.colour; - ctx.lineWidth = op.size; - ctx.lineCap = 'round'; - ctx.lineJoin = 'round'; - - switch (op.type) { - case 'path': - if (!op.points || op.points.length < 2) break; - ctx.beginPath(); - ctx.moveTo(op.points[0].x, op.points[0].y); - for (let i = 1; i < op.points.length; i++) { - ctx.lineTo(op.points[i].x, op.points[i].y); - } - ctx.stroke(); - break; - - case 'line': - if ( - op.x1 === undefined || - op.y1 === undefined || - op.x2 === undefined || - op.y2 === undefined - ) - break; - ctx.beginPath(); - ctx.moveTo(op.x1, op.y1); - ctx.lineTo(op.x2, op.y2); - ctx.stroke(); - break; - - case 'rect': - if ( - op.x1 === undefined || - op.y1 === undefined || - op.x2 === undefined || - op.y2 === undefined - ) - break; - ctx.strokeRect( - Math.min(op.x1, op.x2), - Math.min(op.y1, op.y2), - Math.abs(op.x2 - op.x1), - Math.abs(op.y2 - op.y1), - ); - break; - - case 'circle': { - if ( - op.x1 === undefined || - op.y1 === undefined || - op.x2 === undefined || - op.y2 === undefined - ) - break; - const radius = Math.hypot(op.x2 - op.x1, op.y2 - op.y1); - ctx.beginPath(); - ctx.arc(op.x1, op.y1, radius, 0, Math.PI * 2); - ctx.stroke(); - break; - } - } - }, - [], - ); - - // Helper to draw an eraseStroke op with destination-out compositing - const drawEraseStrokePath = useCallback( - (ctx: CanvasRenderingContext2D, op: DrawOp) => { - if (!op.points || op.points.length < 1) return; - - ctx.save(); - ctx.globalCompositeOperation = 'destination-out'; - ctx.lineCap = 'round'; - ctx.lineJoin = 'round'; - ctx.lineWidth = op.size; - ctx.strokeStyle = 'rgba(0,0,0,1)'; // Color doesn't matter for destination-out - - ctx.beginPath(); - ctx.moveTo(op.points[0].x, op.points[0].y); - for (let i = 1; i < op.points.length; i++) { - ctx.lineTo(op.points[i].x, op.points[i].y); - } - ctx.stroke(); - - ctx.restore(); // Restores globalCompositeOperation to previous value - }, - [], - ); - - // For fillCanvas, we paint the background color to "erase" since it's opaque. - const drawEraseStrokeOnFill = useCallback( - (ctx: CanvasRenderingContext2D, op: DrawOp, backgroundColor: string) => { - if (!op.points || op.points.length < 1) return; - - ctx.save(); - ctx.globalCompositeOperation = 'source-over'; // Normal drawing - ctx.lineCap = 'round'; - ctx.lineJoin = 'round'; - ctx.lineWidth = op.size; - ctx.strokeStyle = backgroundColor; // Paint background color to "erase" - - ctx.beginPath(); - ctx.moveTo(op.points[0].x, op.points[0].y); - for (let i = 1; i < op.points.length; i++) { - ctx.lineTo(op.points[i].x, op.points[i].y); - } - ctx.stroke(); - - ctx.restore(); - }, - [], - ); - - // Rebuilds the world canvas by replaying all operations in order. - const rebuildWorldCanvas = useCallback(() => { - initOffscreenCanvases(); - - const worldCanvas = worldCanvasRef.current; - const boundaryStrokeCanvas = boundaryStrokeCanvasRef.current; - const visibleStrokeCanvas = visibleStrokeCanvasRef.current; - const fillCanvas = fillCanvasRef.current; - - if ( - !worldCanvas || - !boundaryStrokeCanvas || - !visibleStrokeCanvas || - !fillCanvas - ) - return; - - const worldCtx = worldCanvas.getContext('2d'); - const boundaryStrokeCtx = boundaryStrokeCanvas.getContext('2d'); - const visibleStrokeCtx = visibleStrokeCanvas.getContext('2d'); - const fillCtx = fillCanvas.getContext('2d'); - - if (!worldCtx || !boundaryStrokeCtx || !visibleStrokeCtx || !fillCtx) - return; - - // Clear canvases - boundaryStrokeCtx.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); - - // visibleStrokeCanvas: transparent (visible strokes after erasing) - visibleStrokeCtx.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); - - // fillCanvas: Use theme background color (solid base for flood fill) - fillCtx.fillStyle = getBackgroundColor(); - fillCtx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); - - // Replay ops in order - - const deletedIds = new Set(); - const ops = opsArray.toArray(); - - for (const op of ops) { - // Handle legacy erase ops: add eraseIds to deleted set - if (op.type === 'erase' && op.eraseIds) { - for (const id of op.eraseIds) { - deletedIds.add(id); - } - continue; - } - - // Skip deleted ops - if (deletedIds.has(op.id)) continue; - - if (op.type === 'eraseStroke') { - const bgColor = getBackgroundColor(); - drawEraseStrokePath(boundaryStrokeCtx, op); - drawEraseStrokePath(visibleStrokeCtx, op); - drawEraseStrokeOnFill(fillCtx, op, bgColor); - } else if (op.type === 'fill') { - // Read CURRENT boundaryStrokeCanvas state ensuring fill only sees strokes before it - if (op.x1 !== undefined && op.y1 !== undefined) { - const currentStrokeData = boundaryStrokeCtx.getImageData( - 0, - 0, - CANVAS_WIDTH, - CANVAS_HEIGHT, - ); - floodFillWithBoundary( - fillCtx, - currentStrokeData, - op.x1, - op.y1, - op.colour, - ); - } - } else { - // Draw stroke to both stroke canvases - // - boundaryStroke: visible to subsequent fills - // - visibleStroke: visible to user - drawStrokeOp(boundaryStrokeCtx, op); - drawStrokeOp(visibleStrokeCtx, op); - } - } - - // Composite to world canvas (transparent background) - worldCtx.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); - - // Order: fillCanvas (bottom) -> visibleStrokeCanvas (top) - worldCtx.drawImage(fillCanvas, 0, 0); - worldCtx.drawImage(visibleStrokeCanvas, 0, 0); - - worldNeedsRebuildRef.current = false; - }, [ - opsArray, - drawStrokeOp, - drawEraseStrokePath, - drawEraseStrokeOnFill, - initOffscreenCanvases, - getBackgroundColor, - ]); - - // Renders the viewport efficiently using physical pixels to prevent seams - const renderViewport = useCallback(() => { - const ctx = getContext(); - const canvas = canvasRef.current; - const worldCanvas = worldCanvasRef.current; - - if (!ctx || !canvas || !worldCanvas) return; - - // Work in physical pixels directly (canvas.width/height are already DPR-scaled) - const physWidth = canvas.width; - const physHeight = canvas.height; - const dpr = window.devicePixelRatio || 1; - - // Reset to identity transform - we work in physical pixels - ctx.setTransform(1, 0, 0, 1, 0, 0); - ctx.globalCompositeOperation = 'source-over'; - ctx.imageSmoothingEnabled = true; // Enable for smooth scaling - ctx.shadowBlur = 0; - ctx.shadowOffsetX = 0; - ctx.shadowOffsetY = 0; - - // Clear canvas (background already in worldCanvas) - ctx.clearRect(0, 0, physWidth, physHeight); - - // Source coordinates in world space - const srcX = Math.floor(Math.max(0, transformRef.current.x)); - const srcY = Math.floor(Math.max(0, transformRef.current.y)); - - // Calculate how much of the world we're viewing - const cssWidth = physWidth / dpr; - const cssHeight = physHeight / dpr; - const viewWorldW = cssWidth / transformRef.current.scale; - const viewWorldH = cssHeight / transformRef.current.scale; - - // Clamp source dimensions to world canvas bounds - const srcW = Math.min(viewWorldW, CANVAS_WIDTH - srcX); - const srcH = Math.min(viewWorldH, CANVAS_HEIGHT - srcY); - - // Draw world canvas on top - if (srcW > 0 && srcH > 0) { - ctx.drawImage( - worldCanvas, - srcX, - srcY, - srcW, - srcH, // Source: portion of world canvas - 0, - 0, - physWidth, - physHeight, // Destination: FULL physical canvas - ); - } - - // Draw current operation preview - if ( - currentOp.current && - currentOp.current.type !== 'erase' && - currentOp.current.type !== 'fill' - ) { - ctx.save(); - - // Scale world coords to physical pixels - const worldToPhys = dpr * transformRef.current.scale; - ctx.scale(worldToPhys, worldToPhys); - ctx.translate(-transformRef.current.x, -transformRef.current.y); - - if (currentOp.current.type === 'eraseStroke') { - // Preview eraseStroke - if (currentOp.current.points && currentOp.current.points.length > 0) { - ctx.globalCompositeOperation = 'source-over'; - ctx.lineCap = 'round'; - ctx.lineJoin = 'round'; - ctx.lineWidth = currentOp.current.size; - // Paint the background color to show erased effect - ctx.strokeStyle = getBackgroundColor(); - - ctx.beginPath(); - ctx.moveTo( - currentOp.current.points[0].x, - currentOp.current.points[0].y, - ); - for (let i = 1; i < currentOp.current.points.length; i++) { - ctx.lineTo( - currentOp.current.points[i].x, - currentOp.current.points[i].y, - ); - } - ctx.stroke(); - } - } else { - drawStrokeOp(ctx, currentOp.current); - } - - ctx.restore(); - } - }, [getContext, drawStrokeOp, getBackgroundColor]); - - // Schedule viewport render - const rafIdRef = useRef(null); - const scheduleViewportRender = useCallback(() => { - if (rafIdRef.current !== null) { - cancelAnimationFrame(rafIdRef.current); - } - rafIdRef.current = requestAnimationFrame(() => { - renderViewport(); - rafIdRef.current = null; - }); - }, [renderViewport]); - - // Rebuild world canvas on data changes - useEffect(() => { - worldNeedsRebuildRef.current = true; - rebuildWorldCanvas(); - scheduleViewportRender(); - }, [opsArray, rebuildWorldCanvas, scheduleViewportRender]); - - // Re-render on theme change - useEffect(() => { - worldNeedsRebuildRef.current = true; - rebuildWorldCanvas(); - scheduleViewportRender(); - }, [isDark, rebuildWorldCanvas, scheduleViewportRender]); - - // Handle resize - useLayoutEffect(() => { - resizeCanvas(); - updateViewportForResize(); - scheduleViewportRender(); - - const container = containerRef.current; - if (!container) return; - - const resizeObserver = new ResizeObserver(() => { - resizeCanvas(); - updateViewportForResize(); - scheduleViewportRender(); - }); - - resizeObserver.observe(container); - - return () => { - resizeObserver.disconnect(); - if (rafIdRef.current !== null) { - cancelAnimationFrame(rafIdRef.current); - } - }; - }, [resizeCanvas, updateViewportForResize, scheduleViewportRender]); - - // Subscribe to Yjs changes - useEffect(() => { - const observer = () => { - worldNeedsRebuildRef.current = true; - rebuildWorldCanvas(); - scheduleViewportRender(); - }; - - opsArray.observe(observer); - - return () => { - opsArray.unobserve(observer); - }; - }, [opsArray, rebuildWorldCanvas, scheduleViewportRender]); - - // Get mouse/touch position relative to canvas, accounting for viewport offset - const getPosition = useCallback( - (e: React.MouseEvent | React.TouchEvent | React.PointerEvent): Point => { - const canvas = canvasRef.current; - if (!canvas) return { x: 0, y: 0 }; - - const rect = canvas.getBoundingClientRect(); - let clientX: number, clientY: number; - - if ('touches' in e) { - clientX = e.touches[0].clientX; - clientY = e.touches[0].clientY; - } else { - clientX = e.clientX; - clientY = e.clientY; - } - - // Convert screen coordinates to world coordinates by accounting for scale and viewport offset - return { - x: - (clientX - rect.left) / transformRef.current.scale + - transformRef.current.x, - y: - (clientY - rect.top) / transformRef.current.scale + - transformRef.current.y, - }; - }, - [], - ); - - const getActiveTouchPoints = useCallback((): PointerState[] => { - const points: PointerState[] = []; - activePointersRef.current.forEach((pointer) => { - if (pointer.pointerType === 'touch') { - points.push(pointer); - } - }); - return points; - }, []); - - const getTouchCentroid = useCallback((points: PointerState[]): Point => { - if (points.length === 0) return { x: 0, y: 0 }; - let sumX = 0; - let sumY = 0; - for (const p of points) { - sumX += p.x; - sumY += p.y; - } - return { - x: sumX / points.length, - y: sumY / points.length, - }; - }, []); - - const getTouchDistance = useCallback((points: PointerState[]): number => { - if (points.length < 2) return 0; - const dx = points[1].x - points[0].x; - const dy = points[1].y - points[0].y; - return Math.hypot(dx, dy); - }, []); - - // Start drawing - const handleStart = useCallback( - (e: React.PointerEvent) => { - const pos = getPosition(e); - isDrawing.current = true; - startPoint.current = pos; - - if (tool === 'fill') { - // Fill operation - const fillOp: DrawOp = { - id: nanoid(8), - ts: Date.now(), - type: 'fill', - colour, - size: 0, - x1: pos.x, - y1: pos.y, - }; - opsArray.push([fillOp]); - undoStack.current.push(fillOp); - redoStack.current = []; - setCanUndo(true); - setCanRedo(false); - isDrawing.current = false; - // World canvas will be rebuilt by the opsArray observer - return; - } else if (tool === 'eraser') { - // Brush eraser - currentOp.current = { - id: nanoid(8), - ts: Date.now(), - type: 'eraseStroke', - colour: '', - size, - points: [pos], - }; - } else if (tool === 'pen') { - currentOp.current = { - id: nanoid(8), - ts: Date.now(), - type: 'path', - colour, - size, - points: [pos], - }; - } else { - currentOp.current = { - id: nanoid(8), - ts: Date.now(), - type: tool as 'line' | 'rect' | 'circle', - colour, - size, - x1: pos.x, - y1: pos.y, - x2: pos.x, - y2: pos.y, - }; - } - - scheduleViewportRender(); - }, - [tool, colour, size, getPosition, scheduleViewportRender, opsArray], - ); - - // Continue drawing - const handleMove = useCallback( - (e: React.PointerEvent) => { - if (!isDrawing.current || !currentOp.current) return; - - const pos = getPosition(e); - - if (tool === 'pen' && currentOp.current.points) { - currentOp.current.points.push(pos); - } else if (tool === 'eraser' && currentOp.current.points) { - // Brush eraser - currentOp.current.points.push(pos); - } else { - currentOp.current.x2 = pos.x; - currentOp.current.y2 = pos.y; - } - - scheduleViewportRender(); - }, - [tool, getPosition, scheduleViewportRender], - ); - - // End drawing - const handleEnd = useCallback(() => { - if (!isDrawing.current || !currentOp.current) return; - - isDrawing.current = false; - - // For pen or eraser, at least 2 points (duplicate first if only 1) - if ( - (tool === 'pen' || tool === 'eraser') && - currentOp.current.points && - currentOp.current.points.length < 2 - ) { - currentOp.current.points.push({ ...currentOp.current.points[0] }); - } - - // Always push to opsArray (eraseStroke always has points) - opsArray.push([currentOp.current]); - undoStack.current.push(currentOp.current); - redoStack.current = []; - setCanUndo(true); - setCanRedo(false); - - currentOp.current = null; - // World canvas will be rebuilt by the opsArray observer - }, [tool, opsArray]); - - // Pointer handlers: touch = pan/zoom, mouse/pen = draw. - const handlePointerDown = useCallback( - (e: React.PointerEvent) => { - const canvas = canvasRef.current; - if (!canvas) return; - - if (e.pointerType === 'touch') { - canvas.setPointerCapture(e.pointerId); - activePointersRef.current.set(e.pointerId, { - x: e.clientX, - y: e.clientY, - pointerType: e.pointerType, - }); - - e.preventDefault(); - - const touchPoints = getActiveTouchPoints(); - if (touchPoints.length >= 2) { - // Pan/zoom - isPanning.current = true; - isDrawing.current = false; - currentOp.current = null; - - const rect = canvas.getBoundingClientRect(); - const centroid = getTouchCentroid(touchPoints); - lastPanPoint.current = { - x: centroid.x - rect.left, - y: centroid.y - rect.top, - }; - lastPinchDistance.current = getTouchDistance(touchPoints); - return; - } - - if (!isPanning.current) { - handleStart(e); - } - return; - } - - handleStart(e); - }, - [getActiveTouchPoints, getTouchCentroid, getTouchDistance, handleStart], - ); - - const handlePointerMove = useCallback( - (e: React.PointerEvent) => { - const canvas = canvasRef.current; - if (!canvas) return; - - if (e.pointerType === 'touch') { - if (!activePointersRef.current.has(e.pointerId)) return; - e.preventDefault(); - activePointersRef.current.set(e.pointerId, { - x: e.clientX, - y: e.clientY, - pointerType: e.pointerType, - }); - - const touchPoints = getActiveTouchPoints(); - if (touchPoints.length >= 2 || isPanning.current) { - // Panning and/or pinch-zoom mode - isPanning.current = true; - - const rect = canvas.getBoundingClientRect(); - const centroid = getTouchCentroid(touchPoints); - const localCenter = { - x: centroid.x - rect.left, - y: centroid.y - rect.top, - }; - - let nextScale = transformRef.current.scale; - let nextX = transformRef.current.x; - let nextY = transformRef.current.y; - - if (touchPoints.length >= 2) { - const currentDistance = getTouchDistance(touchPoints); - if (lastPinchDistance.current > 0 && currentDistance > 0) { - const pinchRatio = currentDistance / lastPinchDistance.current; - const newScale = Math.max( - MIN_SCALE, - Math.min(MAX_SCALE, nextScale * pinchRatio), - ); - - const worldPoint = { - x: localCenter.x / nextScale + nextX, - y: localCenter.y / nextScale + nextY, - }; - - // Zoom top-left world origin - nextScale = newScale; - nextX = worldPoint.x - localCenter.x / newScale; - nextY = worldPoint.y - localCenter.y / newScale; - } - lastPinchDistance.current = currentDistance; - } else { - lastPinchDistance.current = 0; - } - - const deltaCx = localCenter.x - lastPanPoint.current.x; - const deltaCy = localCenter.y - lastPanPoint.current.y; - lastPanPoint.current = localCenter; - - // Pan world units - nextX -= deltaCx / nextScale; - nextY -= deltaCy / nextScale; - - // Clamp CSS size - transformRef.current = clampTransform(nextX, nextY, nextScale); - hasUserViewportChangeRef.current = true; - - scheduleViewportRender(); - return; - } - - handleMove(e); - return; - } - - handleMove(e); - }, - [ - clampTransform, - getActiveTouchPoints, - getTouchCentroid, - getTouchDistance, - handleMove, - scheduleViewportRender, - ], - ); - - const handlePointerUp = useCallback( - (e: React.PointerEvent) => { - const canvas = canvasRef.current; - if (canvas && canvas.hasPointerCapture(e.pointerId)) { - canvas.releasePointerCapture(e.pointerId); - } - - if (e.pointerType === 'touch') { - activePointersRef.current.delete(e.pointerId); - const touchPoints = getActiveTouchPoints(); - - if (touchPoints.length === 0) { - if (isPanning.current) { - isPanning.current = false; - } else { - handleEnd(); - } - lastPinchDistance.current = 0; - return; - } - - if (touchPoints.length === 1 && isPanning.current && canvas) { - // Continue panning with remaining finger - const rect = canvas.getBoundingClientRect(); - const centroid = getTouchCentroid(touchPoints); - lastPanPoint.current = { - x: centroid.x - rect.left, - y: centroid.y - rect.top, - }; - lastPinchDistance.current = 0; - } - return; - } - - handleEnd(); - }, - [getActiveTouchPoints, getTouchCentroid, handleEnd], - ); - - const handlePointerCancel = useCallback( - (e: React.PointerEvent) => { - handlePointerUp(e); - }, - [handlePointerUp], - ); - - const handlePointerLeave = useCallback( - (e: React.PointerEvent) => { - if (e.pointerType !== 'touch') { - handleEnd(); - } - }, - [handleEnd], - ); - - const [isClearDialogOpen, setIsClearDialogOpen] = useState(false); - - // Clear canvas - const handleClear = useCallback(() => { - doc.transact(() => { - opsArray.delete(0, opsArray.length); - }); - undoStack.current = []; - redoStack.current = []; - setCanUndo(false); - setCanRedo(false); - setIsClearDialogOpen(false); - }, [doc, opsArray]); - - // Undo last local op - const handleUndo = useCallback(() => { - if (undoStack.current.length === 0) return; - - const lastOp = undoStack.current.pop(); - if (!lastOp) return; - - // Find and remove from opsArray - const ops = opsArray.toArray(); - const index = ops.findIndex((op) => op.id === lastOp.id); - if (index !== -1) { - opsArray.delete(index, 1); - redoStack.current.push(lastOp); - setCanRedo(true); - } - - setCanUndo(undoStack.current.length > 0); - }, [opsArray]); - - // Redo - const handleRedo = useCallback(() => { - if (redoStack.current.length === 0) return; - - const op = redoStack.current.pop(); - if (!op) return; - - opsArray.push([op]); - undoStack.current.push(op); - setCanUndo(true); - setCanRedo(redoStack.current.length > 0); - }, [opsArray]); - - // Keyboard shortcuts for undo/redo - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - // Check if the event target is an input element (to not interfere with text input) - const target = e.target as HTMLElement; - if ( - target.tagName === 'INPUT' || - target.tagName === 'TEXTAREA' || - target.isContentEditable - ) { - return; - } - - // Ctrl+Z or Cmd+Z for undo - if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) { - e.preventDefault(); - handleUndo(); - } - // Ctrl+Y or Cmd+Y for redo (Windows style) - // Ctrl+Shift+Z or Cmd+Shift+Z for redo (Mac style) - else if ( - (e.ctrlKey || e.metaKey) && - (e.key === 'y' || - (e.key === 'z' && e.shiftKey) || - (e.key === 'Z' && e.shiftKey)) - ) { - e.preventDefault(); - handleRedo(); - } - }; - - document.addEventListener('keydown', handleKeyDown); - return () => document.removeEventListener('keydown', handleKeyDown); - }, [handleUndo, handleRedo]); - - const toolButtonClass = (isActive: boolean) => - `w-9 h-9 rounded-md flex items-center justify-center text-base transition-all - ${ - isActive - ? 'bg-primary border-primary text-white' - : 'bg-panel-2 border border-border text-text hover:bg-border/50' - } - focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary`; - - return ( -
- {/* Toolbar */} -
- {/* Tools */} -
- - - - - - -
- - {/* Mobile pan hint - justified to far right on first row */} - {isMobile && ( - - - - - -

- Use two fingers{' '} - to pan around the whiteboard.{' '} - Pinch to zoom - in/out. -

-
-
- )} - - {/* Colours */} -
- {COLOURS.map((c) => ( -
- - {/* Sizes - use different sizes for eraser vs other tools */} -
- {(tool === 'eraser' ? ERASER_SIZES : SIZES).map((s) => ( - - ))} -
- - {/* Actions */} -
- - - - - - - - - - Clear Whiteboard? - - Are you sure you want to clear the entire whiteboard? This - affects all participants and cannot be undone. - - - - - - - - - - -
-
- - {/* Canvas container */} -
- -
-
- ); -} diff --git a/src/components/whiteboard/Toolbar.tsx b/src/components/whiteboard/Toolbar.tsx new file mode 100644 index 0000000..ba28e46 --- /dev/null +++ b/src/components/whiteboard/Toolbar.tsx @@ -0,0 +1,246 @@ +import { useState } from 'react'; +import { Button } from '../ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, + DialogClose, +} from '../ui/dialog'; +import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'; +import type { Tool } from './types'; +import { COLOURS, SIZES, ERASER_SIZES } from './types'; + +interface ToolbarProps { + tool: Tool; + colour: string; + size: number; + canUndo: boolean; + canRedo: boolean; + isMobile: boolean; + setTool: (tool: Tool) => void; + setColour: (colour: string) => void; + setSize: (size: number) => void; + handleUndo: () => void; + handleRedo: () => void; + handleClear: () => void; +} + +const toolButtonClass = (isActive: boolean) => + `w-9 h-9 rounded-md flex items-center justify-center text-base transition-all + ${ + isActive + ? 'bg-primary border-primary text-white' + : 'bg-panel-2 border border-border text-text hover:bg-border/50' + } + focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary`; + +export function Toolbar({ + tool, + colour, + size, + canUndo, + canRedo, + isMobile, + setTool, + setColour, + setSize, + handleUndo, + handleRedo, + handleClear, +}: ToolbarProps) { + const [isClearDialogOpen, setIsClearDialogOpen] = useState(false); + + const onClear = () => { + handleClear(); + setIsClearDialogOpen(false); + }; + + return ( +
+ {/* Tools */} +
+ + + + + + +
+ + {/* Mobile pan hint - justified to far right on first row */} + {isMobile && ( + + + + + +

+ Use two fingers{' '} + to pan around the whiteboard.{' '} + Pinch to zoom + in/out. +

+
+
+ )} + + {/* Colours */} +
+ {COLOURS.map((c) => ( +
+ + {/* Sizes - use different sizes for eraser vs other tools */} +
+ {(tool === 'eraser' ? ERASER_SIZES : SIZES).map((s) => ( + + ))} +
+ + {/* Actions */} +
+ + + + + + + + + + Clear Whiteboard? + + Are you sure you want to clear the entire whiteboard? This + affects all participants and cannot be undone. + + + + + + + + + + +
+
+ ); +} diff --git a/src/components/whiteboard/Whiteboard.tsx b/src/components/whiteboard/Whiteboard.tsx new file mode 100644 index 0000000..e951a98 --- /dev/null +++ b/src/components/whiteboard/Whiteboard.tsx @@ -0,0 +1,173 @@ +import { useRef, useEffect, useState, useMemo, useCallback } from 'react'; +import { useSession } from '../../lib/useSession'; +import { useTheme } from '../../lib/useTheme'; +import type { Tool, DrawOp } from './types'; +import { SIZES, ERASER_SIZES } from './types'; +import { useViewport } from './useViewport'; +import { useWhiteboardCanvas } from './useWhiteboardCanvas'; +import { useUndoRedo } from './useUndoRedo'; +import { useDrawing } from './useDrawing'; +import { usePointerHandlers } from './usePointerHandlers'; +import { Toolbar } from './Toolbar'; + +export function Whiteboard() { + const { doc } = useSession(); + const { isDark } = useTheme(); + + // Tool state + const [tool, setToolRaw] = useState('pen'); + const [colour, setColour] = useState('#ffffff'); + const [size, setSize] = useState(5); + + // Wrap setTool to adjust size when switching to/from eraser + const setTool = useCallback((newTool: Tool) => { + setToolRaw(newTool); + if (newTool === 'eraser') { + setSize(ERASER_SIZES[1].value); + } else { + setSize((prev) => { + if ( + ERASER_SIZES.some((s) => s.value === prev) && + !SIZES.some((s) => s.value === prev) + ) { + return SIZES[1].value; + } + return prev; + }); + } + }, []); + + // Get Y.Array for drawing ops + const opsArray = doc.getArray('whiteboard'); + + // Shared refs lifted here to break circular deps between hooks + const canvasCssWidthRef = useRef(0); + const canvasCssHeightRef = useRef(0); + const currentOp = useRef(null); + const canvasRef = useRef(null); + const containerRef = useRef(null); + + // Mobile detection for UI adjustments + const [isMobile, setIsMobile] = useState(false); + useEffect(() => { + const checkMobile = () => { + setIsMobile(window.matchMedia('(max-width: 768px)').matches); + }; + checkMobile(); + window.addEventListener('resize', checkMobile); + return () => window.removeEventListener('resize', checkMobile); + }, []); + + // Viewport (pan/zoom/transform) + const viewport = useViewport(canvasCssWidthRef, canvasCssHeightRef); + + // Canvas & rendering + const canvas = useWhiteboardCanvas( + isDark, + opsArray, + viewport.transformRef, + currentOp, + viewport.updateViewportForResize, + canvasCssWidthRef, + canvasCssHeightRef, + canvasRef, + containerRef, + ); + + // Undo/redo + const undoRedo = useUndoRedo(doc, opsArray); + + // Drawing interaction + const drawing = useDrawing( + tool, + colour, + size, + opsArray, + viewport.transformRef, + canvasRef, + canvas.scheduleViewportRender, + undoRedo.undoStack, + undoRedo.redoStack, + undoRedo.setCanUndo, + undoRedo.setCanRedo, + currentOp, + ); + + // Pointer event dispatch + const pointers = usePointerHandlers( + canvasRef, + viewport.transformRef, + viewport.isPanning, + viewport.lastPanPoint, + viewport.lastPinchDistance, + viewport.hasUserViewportChangeRef, + viewport.activePointersRef, + viewport.clampTransform, + viewport.getActiveTouchPoints, + viewport.getTouchCentroid, + viewport.getTouchDistance, + drawing.isDrawing, + currentOp, + drawing.handleStart, + drawing.handleMove, + drawing.handleEnd, + canvas.scheduleViewportRender, + ); + + // Generate custom round cursor for pen and eraser tools + const brushCursor = useMemo(() => { + if (tool !== 'eraser' && tool !== 'pen') return 'crosshair'; + + const screenSize = Math.max( + 8, + Math.min(128, size * viewport.transformRef.current.scale), + ); + const halfSize = screenSize / 2; + + const svg = ` + + + + + `.trim(); + + const dataUrl = `data:image/svg+xml;base64,${btoa(svg)}`; + return `url(${dataUrl}) ${halfSize} ${halfSize}, crosshair`; + }, [tool, size, viewport.transformRef]); + + return ( +
+ + + {/* Canvas container */} +
+ +
+
+ ); +} diff --git a/src/components/whiteboard/drawing.ts b/src/components/whiteboard/drawing.ts new file mode 100644 index 0000000..96d429c --- /dev/null +++ b/src/components/whiteboard/drawing.ts @@ -0,0 +1,116 @@ +import type { DrawOp } from './types'; + +/** Draw a single stroke operation (path, line, rect, circle) */ +export function drawStrokeOp(ctx: CanvasRenderingContext2D, op: DrawOp): void { + ctx.strokeStyle = op.colour; + ctx.fillStyle = op.colour; + ctx.lineWidth = op.size; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + + switch (op.type) { + case 'path': + if (!op.points || op.points.length < 2) break; + ctx.beginPath(); + ctx.moveTo(op.points[0].x, op.points[0].y); + for (let i = 1; i < op.points.length; i++) { + ctx.lineTo(op.points[i].x, op.points[i].y); + } + ctx.stroke(); + break; + + case 'line': + if ( + op.x1 === undefined || + op.y1 === undefined || + op.x2 === undefined || + op.y2 === undefined + ) + break; + ctx.beginPath(); + ctx.moveTo(op.x1, op.y1); + ctx.lineTo(op.x2, op.y2); + ctx.stroke(); + break; + + case 'rect': + if ( + op.x1 === undefined || + op.y1 === undefined || + op.x2 === undefined || + op.y2 === undefined + ) + break; + ctx.strokeRect( + Math.min(op.x1, op.x2), + Math.min(op.y1, op.y2), + Math.abs(op.x2 - op.x1), + Math.abs(op.y2 - op.y1), + ); + break; + + case 'circle': { + if ( + op.x1 === undefined || + op.y1 === undefined || + op.x2 === undefined || + op.y2 === undefined + ) + break; + const radius = Math.hypot(op.x2 - op.x1, op.y2 - op.y1); + ctx.beginPath(); + ctx.arc(op.x1, op.y1, radius, 0, Math.PI * 2); + ctx.stroke(); + break; + } + } +} + +/** Draw an eraseStroke op with destination-out compositing */ +export function drawEraseStrokePath( + ctx: CanvasRenderingContext2D, + op: DrawOp, +): void { + if (!op.points || op.points.length < 1) return; + + ctx.save(); + ctx.globalCompositeOperation = 'destination-out'; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.lineWidth = op.size; + ctx.strokeStyle = 'rgba(0,0,0,1)'; // Color doesn't matter for destination-out + + ctx.beginPath(); + ctx.moveTo(op.points[0].x, op.points[0].y); + for (let i = 1; i < op.points.length; i++) { + ctx.lineTo(op.points[i].x, op.points[i].y); + } + ctx.stroke(); + + ctx.restore(); // Restores globalCompositeOperation to previous value +} + +/** For fillCanvas, paint the background color to "erase" since it's opaque. */ +export function drawEraseStrokeOnFill( + ctx: CanvasRenderingContext2D, + op: DrawOp, + backgroundColor: string, +): void { + if (!op.points || op.points.length < 1) return; + + ctx.save(); + ctx.globalCompositeOperation = 'source-over'; // Normal drawing + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.lineWidth = op.size; + ctx.strokeStyle = backgroundColor; // Paint background color to "erase" + + ctx.beginPath(); + ctx.moveTo(op.points[0].x, op.points[0].y); + for (let i = 1; i < op.points.length; i++) { + ctx.lineTo(op.points[i].x, op.points[i].y); + } + ctx.stroke(); + + ctx.restore(); +} diff --git a/src/components/whiteboard/flood-fill.ts b/src/components/whiteboard/flood-fill.ts new file mode 100644 index 0000000..c79c7ea --- /dev/null +++ b/src/components/whiteboard/flood-fill.ts @@ -0,0 +1,197 @@ +/** + * Flood fill that keeps strokes and fills on separate layers to avoid anti-aliasing artifacts. + */ +export function floodFillWithBoundary( + fillCtx: CanvasRenderingContext2D, + strokeImageData: ImageData, + startX: number, + startY: number, + fillColor: string, +): void { + const width = fillCtx.canvas.width; + const height = fillCtx.canvas.height; + + // Clamp start position to canvas bounds + const x = Math.floor(Math.max(0, Math.min(width - 1, startX))); + const y = Math.floor(Math.max(0, Math.min(height - 1, startY))); + + // Convert fill color to RGBA + const tempCanvas = document.createElement('canvas'); + tempCanvas.width = 1; + tempCanvas.height = 1; + const tempCtx = tempCanvas.getContext('2d')!; + tempCtx.fillStyle = fillColor; + tempCtx.fillRect(0, 0, 1, 1); + const fillRGBA = tempCtx.getImageData(0, 0, 1, 1).data; + + // Get fill canvas image data + const fillImageData = fillCtx.getImageData(0, 0, width, height); + const fillData = fillImageData.data; + const strokeData = strokeImageData.data; + + // Check if start position has a stroke boundary + const startStrokeIdx = (y * width + x) * 4; + if (strokeData[startStrokeIdx + 3] > 30) { + // Clicked on a stroke, don't fill + return; + } + + // Get target color at start position from fill canvas + const startFillIdx = (y * width + x) * 4; + const targetR = fillData[startFillIdx]; + const targetG = fillData[startFillIdx + 1]; + const targetB = fillData[startFillIdx + 2]; + const targetA = fillData[startFillIdx + 3]; + + // Don't fill if clicking on the same color + if ( + Math.abs(targetR - fillRGBA[0]) < 5 && + Math.abs(targetG - fillRGBA[1]) < 5 && + Math.abs(targetB - fillRGBA[2]) < 5 && + Math.abs(targetA - fillRGBA[3]) < 5 + ) { + return; + } + + // Tolerance for matching target color + const tolerance = 32; + + // Match target color on fill canvas (what we're replacing) + const matchesTarget = (idx: number): boolean => { + return ( + Math.abs(fillData[idx] - targetR) <= tolerance && + Math.abs(fillData[idx + 1] - targetG) <= tolerance && + Math.abs(fillData[idx + 2] - targetB) <= tolerance && + Math.abs(fillData[idx + 3] - targetA) <= tolerance + ); + }; + + // Check if pixel is a stroke boundary (from strokeCanvas) + const isBoundary = (pixelIdx: number): boolean => { + const idx = pixelIdx * 4; + // If stroke has significant alpha, it's a boundary + return strokeData[idx + 3] > 30; + }; + + // Use Uint8Array for fast visited tracking + const visited = new Uint8Array(width * height); + + // Scanline fill using spans + const stack: [number, number, number, number][] = []; // [x1, x2, y, direction] + + // Check if starting point is valid + const startPixelIdx = y * width + x; + if (isBoundary(startPixelIdx) || !matchesTarget(startPixelIdx * 4)) { + return; + } + + // Find initial span + let x1 = x; + let x2 = x; + while (x1 > 0) { + const leftIdx = y * width + x1 - 1; + if (isBoundary(leftIdx) || !matchesTarget(leftIdx * 4)) break; + x1--; + } + while (x2 < width - 1) { + const rightIdx = y * width + x2 + 1; + if (isBoundary(rightIdx) || !matchesTarget(rightIdx * 4)) break; + x2++; + } + + // Fill the initial span immediately to prevent gap in the first row + for (let fx = x1; fx <= x2; fx++) { + const pixelIdx = y * width + fx; + visited[pixelIdx] = 1; + const di = pixelIdx * 4; + fillData[di] = fillRGBA[0]; + fillData[di + 1] = fillRGBA[1]; + fillData[di + 2] = fillRGBA[2]; + fillData[di + 3] = fillRGBA[3]; + } + + stack.push([x1, x2, y, 1]); // down + stack.push([x1, x2, y, -1]); // up + + while (stack.length > 0) { + const [sx1, sx2, sy, dy] = stack.pop()!; + const ny = sy + dy; + + if (ny < 0 || ny >= height) continue; + + let cx = sx1; + while (cx <= sx2) { + const pixelIdx = ny * width + cx; + const dataIdx = pixelIdx * 4; + + // Skip if already visited, is a boundary, or doesn't match + if ( + visited[pixelIdx] || + isBoundary(pixelIdx) || + !matchesTarget(dataIdx) + ) { + cx++; + continue; + } + + // Find span boundaries + let spanX1 = cx; + let spanX2 = cx; + + // Extend left + while (spanX1 > 0) { + const leftIdx = ny * width + spanX1 - 1; + if ( + visited[leftIdx] || + isBoundary(leftIdx) || + !matchesTarget(leftIdx * 4) + ) + break; + spanX1--; + } + + // Extend right and fill + while (spanX2 < width) { + const rightIdx = ny * width + spanX2; + if ( + visited[rightIdx] || + isBoundary(rightIdx) || + !matchesTarget(rightIdx * 4) + ) + break; + + // Fill this pixel + visited[rightIdx] = 1; + const di = rightIdx * 4; + fillData[di] = fillRGBA[0]; + fillData[di + 1] = fillRGBA[1]; + fillData[di + 2] = fillRGBA[2]; + fillData[di + 3] = fillRGBA[3]; + + spanX2++; + } + spanX2--; + + // Also mark and fill the left extension + for (let fx = spanX1; fx < cx; fx++) { + const fillIdx = ny * width + fx; + visited[fillIdx] = 1; + const di = fillIdx * 4; + fillData[di] = fillRGBA[0]; + fillData[di + 1] = fillRGBA[1]; + fillData[di + 2] = fillRGBA[2]; + fillData[di + 3] = fillRGBA[3]; + } + + // Add spans for next rows + stack.push([spanX1, spanX2, ny, dy]); + // Check opposite direction if we extended beyond original span + if (spanX1 < sx1) stack.push([spanX1, sx1 - 1, ny, -dy]); + if (spanX2 > sx2) stack.push([sx2 + 1, spanX2, ny, -dy]); + + cx = spanX2 + 1; + } + } + + fillCtx.putImageData(fillImageData, 0, 0); +} diff --git a/src/components/whiteboard/index.ts b/src/components/whiteboard/index.ts new file mode 100644 index 0000000..9ea8148 --- /dev/null +++ b/src/components/whiteboard/index.ts @@ -0,0 +1 @@ +export { Whiteboard } from './Whiteboard'; diff --git a/src/components/whiteboard/types.ts b/src/components/whiteboard/types.ts new file mode 100644 index 0000000..722d7ad --- /dev/null +++ b/src/components/whiteboard/types.ts @@ -0,0 +1,60 @@ +// Drawing operation types +export type Tool = 'pen' | 'line' | 'rect' | 'circle' | 'eraser' | 'fill'; + +export interface Point { + x: number; + y: number; +} + +export interface PointerState { + x: number; + y: number; + pointerType: string; +} + +export interface DrawOp { + id: string; + ts: number; + type: 'path' | 'line' | 'rect' | 'circle' | 'erase' | 'fill' | 'eraseStroke'; + colour: string; + size: number; + points?: Point[]; + x1?: number; + y1?: number; + x2?: number; + y2?: number; + eraseIds?: string[]; +} + +export const COLOURS = [ + '#ffffff', + '#ef4444', + '#f59e0b', + '#22c55e', + '#3b82f6', + '#8b5cf6', + '#ec4899', + '#000000', +]; + +// Brush sizes for pen/shapes +export const SIZES = [ + { label: 'S', value: 2 }, + { label: 'M', value: 5 }, + { label: 'L', value: 10 }, +]; + +// Eraser sizes (world-unit pixels) +export const ERASER_SIZES = [ + { label: 'S', value: 10 }, + { label: 'M', value: 30 }, + { label: 'L', value: 60 }, +]; + +// Virtual canvas size +export const CANVAS_WIDTH = 3200; +export const CANVAS_HEIGHT = 3200; + +// Zoom limits +export const MIN_SCALE = 0.25; +export const MAX_SCALE = 4; diff --git a/src/components/whiteboard/useDrawing.ts b/src/components/whiteboard/useDrawing.ts new file mode 100644 index 0000000..592669e --- /dev/null +++ b/src/components/whiteboard/useDrawing.ts @@ -0,0 +1,202 @@ +import { useRef, useCallback } from 'react'; +import { nanoid } from 'nanoid'; +import type { Point, DrawOp, Tool } from './types'; +import type * as Y from 'yjs'; + +export interface DrawingState { + isDrawing: React.RefObject; + handleStart: (e: React.PointerEvent) => void; + handleMove: (e: React.PointerEvent) => void; + handleEnd: () => void; + getPosition: ( + e: React.MouseEvent | React.TouchEvent | React.PointerEvent, + ) => Point; +} + +export function useDrawing( + tool: Tool, + colour: string, + size: number, + opsArray: Y.Array, + transformRef: React.RefObject<{ x: number; y: number; scale: number }>, + canvasRef: React.RefObject, + scheduleViewportRender: () => void, + undoStackRef: React.RefObject, + redoStackRef: React.RefObject, + setCanUndo: React.Dispatch>, + setCanRedo: React.Dispatch>, + currentOpRef: React.RefObject, +): DrawingState { + const isDrawing = useRef(false); + const startPoint = useRef({ x: 0, y: 0 }); + + // Get mouse/touch position relative to canvas, accounting for viewport offset + const getPosition = useCallback( + (e: React.MouseEvent | React.TouchEvent | React.PointerEvent): Point => { + const canvas = canvasRef.current; + if (!canvas) return { x: 0, y: 0 }; + + const rect = canvas.getBoundingClientRect(); + let clientX: number, clientY: number; + + if ('touches' in e) { + clientX = e.touches[0].clientX; + clientY = e.touches[0].clientY; + } else { + clientX = e.clientX; + clientY = e.clientY; + } + + // Convert screen coordinates to world coordinates by accounting for scale and viewport offset + return { + x: + (clientX - rect.left) / transformRef.current.scale + + transformRef.current.x, + y: + (clientY - rect.top) / transformRef.current.scale + + transformRef.current.y, + }; + }, + [canvasRef, transformRef], + ); + + // Start drawing + const handleStart = useCallback( + (e: React.PointerEvent) => { + const pos = getPosition(e); + isDrawing.current = true; + startPoint.current = pos; + + if (tool === 'fill') { + // Fill operation + const fillOp: DrawOp = { + id: nanoid(8), + ts: Date.now(), + type: 'fill', + colour, + size: 0, + x1: pos.x, + y1: pos.y, + }; + opsArray.push([fillOp]); + undoStackRef.current.push(fillOp); + redoStackRef.current = []; + setCanUndo(true); + setCanRedo(false); + isDrawing.current = false; + // World canvas will be rebuilt by the opsArray observer + return; + } else if (tool === 'eraser') { + // Brush eraser + currentOpRef.current = { + id: nanoid(8), + ts: Date.now(), + type: 'eraseStroke', + colour: '', + size, + points: [pos], + }; + } else if (tool === 'pen') { + currentOpRef.current = { + id: nanoid(8), + ts: Date.now(), + type: 'path', + colour, + size, + points: [pos], + }; + } else { + currentOpRef.current = { + id: nanoid(8), + ts: Date.now(), + type: tool as 'line' | 'rect' | 'circle', + colour, + size, + x1: pos.x, + y1: pos.y, + x2: pos.x, + y2: pos.y, + }; + } + + scheduleViewportRender(); + }, + [ + tool, + colour, + size, + getPosition, + scheduleViewportRender, + opsArray, + undoStackRef, + redoStackRef, + setCanUndo, + setCanRedo, + currentOpRef, + ], + ); + + // Continue drawing + const handleMove = useCallback( + (e: React.PointerEvent) => { + if (!isDrawing.current || !currentOpRef.current) return; + + const pos = getPosition(e); + + if (tool === 'pen' && currentOpRef.current.points) { + currentOpRef.current.points.push(pos); + } else if (tool === 'eraser' && currentOpRef.current.points) { + // Brush eraser + currentOpRef.current.points.push(pos); + } else { + currentOpRef.current.x2 = pos.x; + currentOpRef.current.y2 = pos.y; + } + + scheduleViewportRender(); + }, + [tool, getPosition, scheduleViewportRender, currentOpRef], + ); + + // End drawing + const handleEnd = useCallback(() => { + if (!isDrawing.current || !currentOpRef.current) return; + + isDrawing.current = false; + + // For pen or eraser, at least 2 points (duplicate first if only 1) + if ( + (tool === 'pen' || tool === 'eraser') && + currentOpRef.current.points && + currentOpRef.current.points.length < 2 + ) { + currentOpRef.current.points.push({ ...currentOpRef.current.points[0] }); + } + + // Always push to opsArray (eraseStroke always has points) + opsArray.push([currentOpRef.current]); + undoStackRef.current.push(currentOpRef.current); + redoStackRef.current = []; + setCanUndo(true); + setCanRedo(false); + + currentOpRef.current = null; + // World canvas will be rebuilt by the opsArray observer + }, [ + tool, + opsArray, + undoStackRef, + redoStackRef, + setCanUndo, + setCanRedo, + currentOpRef, + ]); + + return { + isDrawing, + handleStart, + handleMove, + handleEnd, + getPosition, + }; +} diff --git a/src/components/whiteboard/usePointerHandlers.ts b/src/components/whiteboard/usePointerHandlers.ts new file mode 100644 index 0000000..c27a2b8 --- /dev/null +++ b/src/components/whiteboard/usePointerHandlers.ts @@ -0,0 +1,260 @@ +import { useCallback } from 'react'; +import type { Point, PointerState } from './types'; +import { MIN_SCALE, MAX_SCALE } from './types'; + +export interface PointerHandlers { + handlePointerDown: (e: React.PointerEvent) => void; + handlePointerMove: (e: React.PointerEvent) => void; + handlePointerUp: (e: React.PointerEvent) => void; + handlePointerCancel: (e: React.PointerEvent) => void; + handlePointerLeave: (e: React.PointerEvent) => void; +} + +export function usePointerHandlers( + canvasRef: React.RefObject, + // Viewport state + transformRef: React.RefObject<{ x: number; y: number; scale: number }>, + isPanningRef: React.RefObject, + lastPanPointRef: React.RefObject, + lastPinchDistanceRef: React.RefObject, + hasUserViewportChangeRef: React.RefObject, + activePointersRef: React.RefObject>, + // Viewport helpers + clampTransform: ( + x: number, + y: number, + scale: number, + ) => { x: number; y: number; scale: number }, + getActiveTouchPoints: () => PointerState[], + getTouchCentroid: (points: PointerState[]) => Point, + getTouchDistance: (points: PointerState[]) => number, + // Drawing state + isDrawingRef: React.RefObject, + currentOpRef: React.RefObject, + // Drawing handlers + handleStart: (e: React.PointerEvent) => void, + handleMove: (e: React.PointerEvent) => void, + handleEnd: () => void, + // Canvas rendering + scheduleViewportRender: () => void, +): PointerHandlers { + const handlePointerDown = useCallback( + (e: React.PointerEvent) => { + const canvas = canvasRef.current; + if (!canvas) return; + + if (e.pointerType === 'touch') { + canvas.setPointerCapture(e.pointerId); + activePointersRef.current.set(e.pointerId, { + x: e.clientX, + y: e.clientY, + pointerType: e.pointerType, + }); + + e.preventDefault(); + + const touchPoints = getActiveTouchPoints(); + if (touchPoints.length >= 2) { + // Pan/zoom + isPanningRef.current = true; + isDrawingRef.current = false; + currentOpRef.current = null; + + const rect = canvas.getBoundingClientRect(); + const centroid = getTouchCentroid(touchPoints); + lastPanPointRef.current = { + x: centroid.x - rect.left, + y: centroid.y - rect.top, + }; + lastPinchDistanceRef.current = getTouchDistance(touchPoints); + return; + } + + if (!isPanningRef.current) { + handleStart(e); + } + return; + } + + handleStart(e); + }, + [ + canvasRef, + activePointersRef, + isPanningRef, + isDrawingRef, + currentOpRef, + lastPanPointRef, + lastPinchDistanceRef, + getActiveTouchPoints, + getTouchCentroid, + getTouchDistance, + handleStart, + ], + ); + + const handlePointerMove = useCallback( + (e: React.PointerEvent) => { + const canvas = canvasRef.current; + if (!canvas) return; + + if (e.pointerType === 'touch') { + if (!activePointersRef.current.has(e.pointerId)) return; + e.preventDefault(); + activePointersRef.current.set(e.pointerId, { + x: e.clientX, + y: e.clientY, + pointerType: e.pointerType, + }); + + const touchPoints = getActiveTouchPoints(); + if (touchPoints.length >= 2 || isPanningRef.current) { + // Panning and/or pinch-zoom mode + isPanningRef.current = true; + + const rect = canvas.getBoundingClientRect(); + const centroid = getTouchCentroid(touchPoints); + const localCenter = { + x: centroid.x - rect.left, + y: centroid.y - rect.top, + }; + + let nextScale = transformRef.current.scale; + let nextX = transformRef.current.x; + let nextY = transformRef.current.y; + + if (touchPoints.length >= 2) { + const currentDistance = getTouchDistance(touchPoints); + if (lastPinchDistanceRef.current > 0 && currentDistance > 0) { + const pinchRatio = currentDistance / lastPinchDistanceRef.current; + const newScale = Math.max( + MIN_SCALE, + Math.min(MAX_SCALE, nextScale * pinchRatio), + ); + + const worldPoint = { + x: localCenter.x / nextScale + nextX, + y: localCenter.y / nextScale + nextY, + }; + + // Zoom top-left world origin + nextScale = newScale; + nextX = worldPoint.x - localCenter.x / newScale; + nextY = worldPoint.y - localCenter.y / newScale; + } + lastPinchDistanceRef.current = currentDistance; + } else { + lastPinchDistanceRef.current = 0; + } + + const deltaCx = localCenter.x - lastPanPointRef.current.x; + const deltaCy = localCenter.y - lastPanPointRef.current.y; + lastPanPointRef.current = localCenter; + + // Pan world units + nextX -= deltaCx / nextScale; + nextY -= deltaCy / nextScale; + + // Clamp CSS size + transformRef.current = clampTransform(nextX, nextY, nextScale); + hasUserViewportChangeRef.current = true; + + scheduleViewportRender(); + return; + } + + handleMove(e); + return; + } + + handleMove(e); + }, + [ + canvasRef, + activePointersRef, + isPanningRef, + lastPinchDistanceRef, + lastPanPointRef, + transformRef, + hasUserViewportChangeRef, + clampTransform, + getActiveTouchPoints, + getTouchCentroid, + getTouchDistance, + handleMove, + scheduleViewportRender, + ], + ); + + const handlePointerUp = useCallback( + (e: React.PointerEvent) => { + const canvas = canvasRef.current; + if (canvas && canvas.hasPointerCapture(e.pointerId)) { + canvas.releasePointerCapture(e.pointerId); + } + + if (e.pointerType === 'touch') { + activePointersRef.current.delete(e.pointerId); + const touchPoints = getActiveTouchPoints(); + + if (touchPoints.length === 0) { + if (isPanningRef.current) { + isPanningRef.current = false; + } else { + handleEnd(); + } + lastPinchDistanceRef.current = 0; + return; + } + + if (touchPoints.length === 1 && isPanningRef.current && canvas) { + // Continue panning with remaining finger + const rect = canvas.getBoundingClientRect(); + const centroid = getTouchCentroid(touchPoints); + lastPanPointRef.current = { + x: centroid.x - rect.left, + y: centroid.y - rect.top, + }; + lastPinchDistanceRef.current = 0; + } + return; + } + + handleEnd(); + }, + [ + canvasRef, + activePointersRef, + isPanningRef, + lastPanPointRef, + lastPinchDistanceRef, + getActiveTouchPoints, + getTouchCentroid, + handleEnd, + ], + ); + + const handlePointerCancel = useCallback( + (e: React.PointerEvent) => { + handlePointerUp(e); + }, + [handlePointerUp], + ); + + const handlePointerLeave = useCallback( + (e: React.PointerEvent) => { + if (e.pointerType !== 'touch') { + handleEnd(); + } + }, + [handleEnd], + ); + + return { + handlePointerDown, + handlePointerMove, + handlePointerUp, + handlePointerCancel, + handlePointerLeave, + }; +} diff --git a/src/components/whiteboard/useUndoRedo.ts b/src/components/whiteboard/useUndoRedo.ts new file mode 100644 index 0000000..56d7d66 --- /dev/null +++ b/src/components/whiteboard/useUndoRedo.ts @@ -0,0 +1,116 @@ +import { useRef, useState, useCallback, useEffect } from 'react'; +import type { DrawOp } from './types'; +import type * as Y from 'yjs'; +import type { Doc } from 'yjs'; + +export interface UndoRedoState { + canUndo: boolean; + canRedo: boolean; + undoStack: React.RefObject; + redoStack: React.RefObject; + setCanUndo: React.Dispatch>; + setCanRedo: React.Dispatch>; + handleUndo: () => void; + handleRedo: () => void; + handleClear: () => void; +} + +export function useUndoRedo( + doc: Doc, + opsArray: Y.Array, +): UndoRedoState { + const [canUndo, setCanUndo] = useState(false); + const [canRedo, setCanRedo] = useState(false); + const undoStack = useRef([]); + const redoStack = useRef([]); + + // Clear canvas + const handleClear = useCallback(() => { + doc.transact(() => { + opsArray.delete(0, opsArray.length); + }); + undoStack.current = []; + redoStack.current = []; + setCanUndo(false); + setCanRedo(false); + }, [doc, opsArray]); + + // Undo last local op + const handleUndo = useCallback(() => { + if (undoStack.current.length === 0) return; + + const lastOp = undoStack.current.pop(); + if (!lastOp) return; + + // Find and remove from opsArray + const ops = opsArray.toArray(); + const index = ops.findIndex((op) => op.id === lastOp.id); + if (index !== -1) { + opsArray.delete(index, 1); + redoStack.current.push(lastOp); + setCanRedo(true); + } + + setCanUndo(undoStack.current.length > 0); + }, [opsArray]); + + // Redo + const handleRedo = useCallback(() => { + if (redoStack.current.length === 0) return; + + const op = redoStack.current.pop(); + if (!op) return; + + opsArray.push([op]); + undoStack.current.push(op); + setCanUndo(true); + setCanRedo(redoStack.current.length > 0); + }, [opsArray]); + + // Keyboard shortcuts for undo/redo + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Check if the event target is an input element (to not interfere with text input) + const target = e.target as HTMLElement; + if ( + target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.isContentEditable + ) { + return; + } + + // Ctrl+Z or Cmd+Z for undo + if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) { + e.preventDefault(); + handleUndo(); + } + // Ctrl+Y or Cmd+Y for redo (Windows style) + // Ctrl+Shift+Z or Cmd+Shift+Z for redo (Mac style) + else if ( + (e.ctrlKey || e.metaKey) && + (e.key === 'y' || + (e.key === 'z' && e.shiftKey) || + (e.key === 'Z' && e.shiftKey)) + ) { + e.preventDefault(); + handleRedo(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [handleUndo, handleRedo]); + + return { + canUndo, + canRedo, + undoStack, + redoStack, + setCanUndo, + setCanRedo, + handleUndo, + handleRedo, + handleClear, + }; +} diff --git a/src/components/whiteboard/useViewport.ts b/src/components/whiteboard/useViewport.ts new file mode 100644 index 0000000..74a92ed --- /dev/null +++ b/src/components/whiteboard/useViewport.ts @@ -0,0 +1,167 @@ +import { useRef, useCallback } from 'react'; +import type { Point, PointerState } from './types'; +import { CANVAS_WIDTH, CANVAS_HEIGHT } from './types'; + +export interface ViewportState { + transformRef: React.RefObject<{ x: number; y: number; scale: number }>; + isPanning: React.RefObject; + lastPanPoint: React.RefObject; + lastPinchDistance: React.RefObject; + hasInitializedViewport: React.RefObject; + lastResizeSizeRef: React.RefObject<{ + width: number; + height: number; + } | null>; + hasUserViewportChangeRef: React.RefObject; + activePointersRef: React.RefObject>; + clampTransform: ( + x: number, + y: number, + scale: number, + ) => { x: number; y: number; scale: number }; + centerViewport: (scale?: number) => void; + updateViewportForResize: () => void; + getActiveTouchPoints: () => PointerState[]; + getTouchCentroid: (points: PointerState[]) => Point; + getTouchDistance: (points: PointerState[]) => number; +} + +export function useViewport( + canvasCssWidthRef: React.RefObject, + canvasCssHeightRef: React.RefObject, +): ViewportState { + // Viewport transform (refs for direct manipulation, bypassing React render cycle) + const transformRef = useRef({ x: 0, y: 0, scale: 1 }); + const isPanning = useRef(false); + const lastPanPoint = useRef({ x: 0, y: 0 }); // canvas-local CSS pixels + const lastPinchDistance = useRef(0); + const hasInitializedViewport = useRef(false); + const lastResizeSizeRef = useRef<{ width: number; height: number } | null>( + null, + ); + const hasUserViewportChangeRef = useRef(false); + const activePointersRef = useRef>(new Map()); + + // Clamp viewport to world bounds using CSS pixels (not physical pixels). + const clampTransform = useCallback( + (x: number, y: number, scale: number) => { + const cssWidth = canvasCssWidthRef.current; + const cssHeight = canvasCssHeightRef.current; + + if (cssWidth <= 0 || cssHeight <= 0) { + return { x: 0, y: 0, scale }; + } + + const viewWorldW = cssWidth / scale; + const viewWorldH = cssHeight / scale; + const maxX = Math.max(0, CANVAS_WIDTH - viewWorldW); + const maxY = Math.max(0, CANVAS_HEIGHT - viewWorldH); + + return { + x: Math.max(0, Math.min(maxX, x)), + y: Math.max(0, Math.min(maxY, y)), + scale, + }; + }, + [canvasCssWidthRef, canvasCssHeightRef], + ); + + // Center viewport after initial sizing or major orientation/size changes. + const centerViewport = useCallback( + (scale = transformRef.current.scale) => { + const cssWidth = canvasCssWidthRef.current; + const cssHeight = canvasCssHeightRef.current; + if (cssWidth <= 0 || cssHeight <= 0) return; + + const viewWorldW = cssWidth / scale; + const viewWorldH = cssHeight / scale; + const centeredX = (CANVAS_WIDTH - viewWorldW) / 2; + const centeredY = (CANVAS_HEIGHT - viewWorldH) / 2; + + // Center using canvas CSS size, then clamp within world bounds. + transformRef.current = clampTransform(centeredX, centeredY, scale); + }, + [canvasCssWidthRef, canvasCssHeightRef, clampTransform], + ); + + const updateViewportForResize = useCallback(() => { + const cssWidth = canvasCssWidthRef.current; + const cssHeight = canvasCssHeightRef.current; + if (cssWidth <= 0 || cssHeight <= 0) return; + + const prev = lastResizeSizeRef.current; + const orientationChanged = + prev !== null && prev.width > prev.height !== cssWidth > cssHeight; + const sizeChangeLarge = + prev !== null && + (Math.abs(cssWidth - prev.width) > prev.width * 0.15 || + Math.abs(cssHeight - prev.height) > prev.height * 0.15); + + const shouldRecenter = + !hasInitializedViewport.current || + ((orientationChanged || sizeChangeLarge) && + !hasUserViewportChangeRef.current); + + if (shouldRecenter) { + centerViewport(); + } else { + transformRef.current = clampTransform( + transformRef.current.x, + transformRef.current.y, + transformRef.current.scale, + ); + } + + hasInitializedViewport.current = true; + lastResizeSizeRef.current = { width: cssWidth, height: cssHeight }; + }, [canvasCssWidthRef, canvasCssHeightRef, centerViewport, clampTransform]); + + // Touch helpers + const getActiveTouchPoints = useCallback((): PointerState[] => { + const points: PointerState[] = []; + activePointersRef.current.forEach((pointer) => { + if (pointer.pointerType === 'touch') { + points.push(pointer); + } + }); + return points; + }, []); + + const getTouchCentroid = useCallback((points: PointerState[]): Point => { + if (points.length === 0) return { x: 0, y: 0 }; + let sumX = 0; + let sumY = 0; + for (const p of points) { + sumX += p.x; + sumY += p.y; + } + return { + x: sumX / points.length, + y: sumY / points.length, + }; + }, []); + + const getTouchDistance = useCallback((points: PointerState[]): number => { + if (points.length < 2) return 0; + const dx = points[1].x - points[0].x; + const dy = points[1].y - points[0].y; + return Math.hypot(dx, dy); + }, []); + + return { + transformRef, + isPanning, + lastPanPoint, + lastPinchDistance, + hasInitializedViewport, + lastResizeSizeRef, + hasUserViewportChangeRef, + activePointersRef, + clampTransform, + centerViewport, + updateViewportForResize, + getActiveTouchPoints, + getTouchCentroid, + getTouchDistance, + }; +} diff --git a/src/components/whiteboard/useWhiteboardCanvas.ts b/src/components/whiteboard/useWhiteboardCanvas.ts new file mode 100644 index 0000000..e5255d9 --- /dev/null +++ b/src/components/whiteboard/useWhiteboardCanvas.ts @@ -0,0 +1,365 @@ +import { useRef, useCallback, useEffect, useLayoutEffect } from 'react'; +import type { DrawOp } from './types'; +import { CANVAS_WIDTH, CANVAS_HEIGHT } from './types'; +import { + drawStrokeOp, + drawEraseStrokePath, + drawEraseStrokeOnFill, +} from './drawing'; +import { floodFillWithBoundary } from './flood-fill'; +import type * as Y from 'yjs'; + +export interface WhiteboardCanvasState { + getBackgroundColor: () => string; + scheduleViewportRender: () => void; +} + +export function useWhiteboardCanvas( + isDark: boolean, + opsArray: Y.Array, + transformRef: React.RefObject<{ x: number; y: number; scale: number }>, + currentOpRef: React.RefObject, + updateViewportForResize: () => void, + canvasCssWidthRef: React.RefObject, + canvasCssHeightRef: React.RefObject, + canvasRef: React.RefObject, + containerRef: React.RefObject, +): WhiteboardCanvasState { + // Offscreen canvases + const worldCanvasRef = useRef(null); + const boundaryStrokeCanvasRef = useRef(null); + const visibleStrokeCanvasRef = useRef(null); + const fillCanvasRef = useRef(null); + + // Track if world canvas needs rebuild + const worldNeedsRebuildRef = useRef(true); + + // rAF scheduling + const rafIdRef = useRef(null); + + // Get canvas context + const getContext = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas) return null; + return canvas.getContext('2d'); + }, [canvasRef]); + + // Get background color based on theme + const getBackgroundColor = useCallback(() => { + return isDark ? '#111827' : '#ffffff'; + }, [isDark]); + + // Resize canvas to match container with proper DPR handling + const resizeCanvas = useCallback(() => { + const canvas = canvasRef.current; + const container = containerRef.current; + if (!canvas || !container) return; + + const rect = container.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + + // Set backing store size (physical pixels) + canvas.width = Math.floor(rect.width * dpr); + canvas.height = Math.floor(rect.height * dpr); + + // Store CSS dimensions for renderViewport + canvasCssWidthRef.current = rect.width; + canvasCssHeightRef.current = rect.height; + }, [canvasRef, containerRef, canvasCssWidthRef, canvasCssHeightRef]); + + // Initialize offscreen canvases + const initOffscreenCanvases = useCallback(() => { + if (!worldCanvasRef.current) { + worldCanvasRef.current = document.createElement('canvas'); + worldCanvasRef.current.width = CANVAS_WIDTH; + worldCanvasRef.current.height = CANVAS_HEIGHT; + } + if (!boundaryStrokeCanvasRef.current) { + boundaryStrokeCanvasRef.current = document.createElement('canvas'); + boundaryStrokeCanvasRef.current.width = CANVAS_WIDTH; + boundaryStrokeCanvasRef.current.height = CANVAS_HEIGHT; + } + if (!visibleStrokeCanvasRef.current) { + visibleStrokeCanvasRef.current = document.createElement('canvas'); + visibleStrokeCanvasRef.current.width = CANVAS_WIDTH; + visibleStrokeCanvasRef.current.height = CANVAS_HEIGHT; + } + if (!fillCanvasRef.current) { + fillCanvasRef.current = document.createElement('canvas'); + fillCanvasRef.current.width = CANVAS_WIDTH; + fillCanvasRef.current.height = CANVAS_HEIGHT; + } + }, []); + + // Rebuilds the world canvas by replaying all operations in order. + const rebuildWorldCanvas = useCallback(() => { + initOffscreenCanvases(); + + const worldCanvas = worldCanvasRef.current; + const boundaryStrokeCanvas = boundaryStrokeCanvasRef.current; + const visibleStrokeCanvas = visibleStrokeCanvasRef.current; + const fillCanvas = fillCanvasRef.current; + + if ( + !worldCanvas || + !boundaryStrokeCanvas || + !visibleStrokeCanvas || + !fillCanvas + ) + return; + + const worldCtx = worldCanvas.getContext('2d'); + const boundaryStrokeCtx = boundaryStrokeCanvas.getContext('2d'); + const visibleStrokeCtx = visibleStrokeCanvas.getContext('2d'); + const fillCtx = fillCanvas.getContext('2d'); + + if (!worldCtx || !boundaryStrokeCtx || !visibleStrokeCtx || !fillCtx) + return; + + // Clear canvases + boundaryStrokeCtx.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); + + // visibleStrokeCanvas: transparent (visible strokes after erasing) + visibleStrokeCtx.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); + + // fillCanvas: Use theme background color (solid base for flood fill) + fillCtx.fillStyle = getBackgroundColor(); + fillCtx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); + + // Replay ops in order + const deletedIds = new Set(); + const ops = opsArray.toArray(); + + for (const op of ops) { + // Handle legacy erase ops: add eraseIds to deleted set + if (op.type === 'erase' && op.eraseIds) { + for (const id of op.eraseIds) { + deletedIds.add(id); + } + continue; + } + + // Skip deleted ops + if (deletedIds.has(op.id)) continue; + + if (op.type === 'eraseStroke') { + const bgColor = getBackgroundColor(); + drawEraseStrokePath(boundaryStrokeCtx, op); + drawEraseStrokePath(visibleStrokeCtx, op); + drawEraseStrokeOnFill(fillCtx, op, bgColor); + } else if (op.type === 'fill') { + // Read CURRENT boundaryStrokeCanvas state ensuring fill only sees strokes before it + if (op.x1 !== undefined && op.y1 !== undefined) { + const currentStrokeData = boundaryStrokeCtx.getImageData( + 0, + 0, + CANVAS_WIDTH, + CANVAS_HEIGHT, + ); + floodFillWithBoundary( + fillCtx, + currentStrokeData, + op.x1, + op.y1, + op.colour, + ); + } + } else { + // Draw stroke to both stroke canvases + drawStrokeOp(boundaryStrokeCtx, op); + drawStrokeOp(visibleStrokeCtx, op); + } + } + + // Composite to world canvas (transparent background) + worldCtx.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); + + // Order: fillCanvas (bottom) -> visibleStrokeCanvas (top) + worldCtx.drawImage(fillCanvas, 0, 0); + worldCtx.drawImage(visibleStrokeCanvas, 0, 0); + + worldNeedsRebuildRef.current = false; + }, [opsArray, initOffscreenCanvases, getBackgroundColor]); + + // Renders the viewport efficiently using physical pixels to prevent seams + const renderViewport = useCallback(() => { + const ctx = getContext(); + const canvas = canvasRef.current; + const worldCanvas = worldCanvasRef.current; + + if (!ctx || !canvas || !worldCanvas) return; + + // Work in physical pixels directly (canvas.width/height are already DPR-scaled) + const physWidth = canvas.width; + const physHeight = canvas.height; + const dpr = window.devicePixelRatio || 1; + + // Reset to identity transform - we work in physical pixels + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.globalCompositeOperation = 'source-over'; + ctx.imageSmoothingEnabled = true; + ctx.shadowBlur = 0; + ctx.shadowOffsetX = 0; + ctx.shadowOffsetY = 0; + + // Clear canvas (background already in worldCanvas) + ctx.clearRect(0, 0, physWidth, physHeight); + + // Source coordinates in world space + const srcX = Math.floor(Math.max(0, transformRef.current.x)); + const srcY = Math.floor(Math.max(0, transformRef.current.y)); + + // Calculate how much of the world we're viewing + const cssWidth = physWidth / dpr; + const cssHeight = physHeight / dpr; + const viewWorldW = cssWidth / transformRef.current.scale; + const viewWorldH = cssHeight / transformRef.current.scale; + + // Clamp source dimensions to world canvas bounds + const srcW = Math.min(viewWorldW, CANVAS_WIDTH - srcX); + const srcH = Math.min(viewWorldH, CANVAS_HEIGHT - srcY); + + // Draw world canvas on top + if (srcW > 0 && srcH > 0) { + ctx.drawImage( + worldCanvas, + srcX, + srcY, + srcW, + srcH, + 0, + 0, + physWidth, + physHeight, + ); + } + + // Draw current operation preview + if ( + currentOpRef.current && + currentOpRef.current.type !== 'erase' && + currentOpRef.current.type !== 'fill' + ) { + ctx.save(); + + // Scale world coords to physical pixels + const worldToPhys = dpr * transformRef.current.scale; + ctx.scale(worldToPhys, worldToPhys); + ctx.translate(-transformRef.current.x, -transformRef.current.y); + + if (currentOpRef.current.type === 'eraseStroke') { + // Preview eraseStroke + if ( + currentOpRef.current.points && + currentOpRef.current.points.length > 0 + ) { + ctx.globalCompositeOperation = 'source-over'; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.lineWidth = currentOpRef.current.size; + ctx.strokeStyle = getBackgroundColor(); + + ctx.beginPath(); + ctx.moveTo( + currentOpRef.current.points[0].x, + currentOpRef.current.points[0].y, + ); + for (let i = 1; i < currentOpRef.current.points.length; i++) { + ctx.lineTo( + currentOpRef.current.points[i].x, + currentOpRef.current.points[i].y, + ); + } + ctx.stroke(); + } + } else { + drawStrokeOp(ctx, currentOpRef.current); + } + + ctx.restore(); + } + }, [getContext, getBackgroundColor, transformRef, currentOpRef, canvasRef]); + + // Schedule viewport render + const scheduleViewportRender = useCallback(() => { + if (rafIdRef.current !== null) { + cancelAnimationFrame(rafIdRef.current); + } + rafIdRef.current = requestAnimationFrame(() => { + renderViewport(); + rafIdRef.current = null; + }); + }, [renderViewport]); + + // Rebuild world canvas on data changes + useEffect(() => { + worldNeedsRebuildRef.current = true; + rebuildWorldCanvas(); + scheduleViewportRender(); + }, [opsArray, rebuildWorldCanvas, scheduleViewportRender]); + + // Re-render on theme change + useEffect(() => { + worldNeedsRebuildRef.current = true; + rebuildWorldCanvas(); + scheduleViewportRender(); + }, [isDark, rebuildWorldCanvas, scheduleViewportRender]); + + // Handle resize + useLayoutEffect(() => { + resizeCanvas(); + updateViewportForResize(); + scheduleViewportRender(); + + const container = containerRef.current; + if (!container) return; + + const resizeObserver = new ResizeObserver(() => { + resizeCanvas(); + updateViewportForResize(); + scheduleViewportRender(); + }); + + resizeObserver.observe(container); + + return () => { + resizeObserver.disconnect(); + if (rafIdRef.current !== null) { + cancelAnimationFrame(rafIdRef.current); + } + }; + }, [ + resizeCanvas, + updateViewportForResize, + scheduleViewportRender, + containerRef, + ]); + + // Subscribe to Yjs changes + useEffect(() => { + const observer = () => { + worldNeedsRebuildRef.current = true; + rebuildWorldCanvas(); + scheduleViewportRender(); + }; + + opsArray.observe(observer); + + return () => { + opsArray.unobserve(observer); + }; + }, [opsArray, rebuildWorldCanvas, scheduleViewportRender]); + + // Set touch-action on canvas + useEffect(() => { + const canvas = canvasRef.current; + if (canvas) { + canvas.style.touchAction = 'none'; + } + }, [canvasRef]); + + return { + getBackgroundColor, + scheduleViewportRender, + }; +} From 815ef74314c7208616dea81b3255b6fc7bc88358 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 6 Feb 2026 15:44:15 +0000 Subject: [PATCH 2/4] add copilot code review instructions --- .../instructions/code-review.instructions.md | 35 +++++++++++++++++++ .github/instructions/frontend.instructions.md | 29 +++++++++++++++ .github/instructions/server.instructions.md | 23 ++++++++++++ 3 files changed, 87 insertions(+) create mode 100644 .github/instructions/code-review.instructions.md create mode 100644 .github/instructions/frontend.instructions.md create mode 100644 .github/instructions/server.instructions.md diff --git a/.github/instructions/code-review.instructions.md b/.github/instructions/code-review.instructions.md new file mode 100644 index 0000000..03888b8 --- /dev/null +++ b/.github/instructions/code-review.instructions.md @@ -0,0 +1,35 @@ +--- +applyTo: '**' +excludeAgent: 'coding-agent' +--- + +# Architecture at a glance + +- **P2P-first**: all collaborative content (code, chat, whiteboard) syncs over WebRTC data channels using Yjs CRDTs. The server is signalling-only. +- **Signalling server** (`server/`): Socket.IO — room membership, host election, and WebRTC offer/answer/ICE relay. Stores no user content. +- **Client** (`src/`): React + Vite SPA. Core layers: `signalling.ts` → `webrtc.ts` → `yjs-provider.ts` → `session.tsx` (context) → components. +- **Persistence**: `y-indexeddb` gives offline/rejoin state on the client; no server-side persistence. + +# Code review-only baseline + +- Prioritise correctness and regressions in real-time collaboration flows (session join/leave, peer lifecycle, Yjs sync, chat/whiteboard updates). +- Flag changes that weaken privacy assumptions or route shared content through the server. +- Flag breaking changes to signalling events, room membership logic, or host election behavior. +- Be explicit about risk level and impacted user flows in review comments. + +# Client ↔ Server contract + +- Socket.IO event names and payload shapes (see `SignallingEvents` in `signalling.ts` and the server's `io.on` handlers) are a shared contract — changes must land on both sides simultaneously. +- If either side adds/renames/removes an event, flag it and verify the counterpart is updated. + +# Validation expectations + +- If root/client files change, run: + - `npm run lint` + - `npm run build` +- If `server/` files change, run: + - `npm --prefix server run lint` + - `npm --prefix server run build` +- If both areas change, run all four commands. +- If `docker-compose.yml`, `Dockerfile.*`, or `nginx.conf` change, verify the build still works with `docker compose build`. +- In review summaries, state which commands were run and any skipped checks. diff --git a/.github/instructions/frontend.instructions.md b/.github/instructions/frontend.instructions.md new file mode 100644 index 0000000..434995b --- /dev/null +++ b/.github/instructions/frontend.instructions.md @@ -0,0 +1,29 @@ +--- +applyTo: 'src/**/*.ts,src/**/*.tsx,public/**,index.html,vite.config.ts,tailwind.config.js,eslint.config.js' +excludeAgent: 'coding-agent' +--- + +# Frontend review focus + +- Protect collaborative UX behavior across editor, chat, and whiteboard flows. +- Check React hooks for stale closures, missing cleanup, and unnecessary re-renders. +- Watch for Yjs synchronization regressions and awareness-state drift. +- Ensure user-facing errors are actionable and do not silently fail. +- Keep bundles lean; challenge unnecessary new dependencies. + +# Yjs & persistence pitfalls + +- Changes to Yjs shared-type names (e.g., `doc.getText('...')`, `doc.getMap('...')`) break compatibility with data already stored in IndexedDB — flag and require a migration strategy or version bump. +- `origin === 'remote'` guards in Yjs update listeners prevent echo loops — removing or altering these is high-risk. +- Awareness state cleanup must happen on unmount/disconnect; leaked awareness entries cause ghost cursors. + +# Environment & config + +- `src/lib/config.ts` reads `VITE_*` env vars at build time. Changes here affect all deployment targets — note which vars are new/removed. +- STUN/TURN server changes can silently break connectivity for users behind restrictive NATs. + +# Frontend validation + +- Run `npm run lint`. +- Run `npm run build`. +- For runtime-sensitive changes (WebRTC/session/sync), describe manual smoke checks in PR comments. diff --git a/.github/instructions/server.instructions.md b/.github/instructions/server.instructions.md new file mode 100644 index 0000000..79e16ea --- /dev/null +++ b/.github/instructions/server.instructions.md @@ -0,0 +1,23 @@ +--- +applyTo: 'server/**/*.ts,server/**/*.js,server/package.json,server/tsconfig.json,server/eslint.config.js' +excludeAgent: 'coding-agent' +--- + +# Server review focus + +- Treat the signalling service as stateful and long-lived; flag stateless/serverless assumptions. +- Protect Socket.IO room membership, host election, and signalling message integrity. +- Verify CORS and origin-handling changes do not broaden access unintentionally. +- Ensure server changes never store or process collaborative content beyond signalling metadata. + +# Host election & room lifecycle + +- Host = peer with the earliest `joinedAt` timestamp. Changes to `getHostId()` or `joinedAt` assignment affect who is authoritative — test with multi-peer join/leave sequences. +- The duplicate-peerId kick path (`kicked` event) is a reconnection safeguard — removing or loosening it can cause split-brain state. +- Room cleanup runs on an interval (`ROOM_CLEANUP_INTERVAL`); changes to timing or conditions should consider rooms that are briefly empty during reconnects. + +# Server validation + +- Run `npm --prefix server run lint`. +- Run `npm --prefix server run build`. +- If server contract changes affect client behavior, ask for coordinated client validation notes. From e21e2bd99195782bdb84cd1abb1ab5738a2d23ca Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 6 Feb 2026 19:22:13 +0000 Subject: [PATCH 3/4] implement code review recommendations --- src/components/whiteboard/Whiteboard.tsx | 11 +++----- src/components/whiteboard/flood-fill.ts | 25 +++++++++++++------ .../whiteboard/usePointerHandlers.ts | 4 +-- .../whiteboard/useWhiteboardCanvas.ts | 18 ++----------- 4 files changed, 26 insertions(+), 32 deletions(-) diff --git a/src/components/whiteboard/Whiteboard.tsx b/src/components/whiteboard/Whiteboard.tsx index e951a98..a6f8743 100644 --- a/src/components/whiteboard/Whiteboard.tsx +++ b/src/components/whiteboard/Whiteboard.tsx @@ -1,4 +1,4 @@ -import { useRef, useEffect, useState, useMemo, useCallback } from 'react'; +import { useRef, useEffect, useState, useCallback } from 'react'; import { useSession } from '../../lib/useSession'; import { useTheme } from '../../lib/useTheme'; import type { Tool, DrawOp } from './types'; @@ -115,13 +115,10 @@ export function Whiteboard() { ); // Generate custom round cursor for pen and eraser tools - const brushCursor = useMemo(() => { + const brushCursor = (() => { if (tool !== 'eraser' && tool !== 'pen') return 'crosshair'; - const screenSize = Math.max( - 8, - Math.min(128, size * viewport.transformRef.current.scale), - ); + const screenSize = Math.max(8, Math.min(128, size)); const halfSize = screenSize / 2; const svg = ` @@ -133,7 +130,7 @@ export function Whiteboard() { const dataUrl = `data:image/svg+xml;base64,${btoa(svg)}`; return `url(${dataUrl}) ${halfSize} ${halfSize}, crosshair`; - }, [tool, size, viewport.transformRef]); + })(); return (
diff --git a/src/components/whiteboard/flood-fill.ts b/src/components/whiteboard/flood-fill.ts index c79c7ea..79d5b5a 100644 --- a/src/components/whiteboard/flood-fill.ts +++ b/src/components/whiteboard/flood-fill.ts @@ -1,3 +1,20 @@ +// Cached 1x1 canvas for CSS color → RGBA conversion +let colorParseCanvas: HTMLCanvasElement | null = null; +let colorParseCtx: CanvasRenderingContext2D | null = null; + +function parseColorToRGBA(color: string): Uint8ClampedArray { + if (!colorParseCanvas) { + colorParseCanvas = document.createElement('canvas'); + colorParseCanvas.width = 1; + colorParseCanvas.height = 1; + colorParseCtx = colorParseCanvas.getContext('2d')!; + } + colorParseCtx!.clearRect(0, 0, 1, 1); + colorParseCtx!.fillStyle = color; + colorParseCtx!.fillRect(0, 0, 1, 1); + return colorParseCtx!.getImageData(0, 0, 1, 1).data; +} + /** * Flood fill that keeps strokes and fills on separate layers to avoid anti-aliasing artifacts. */ @@ -16,13 +33,7 @@ export function floodFillWithBoundary( const y = Math.floor(Math.max(0, Math.min(height - 1, startY))); // Convert fill color to RGBA - const tempCanvas = document.createElement('canvas'); - tempCanvas.width = 1; - tempCanvas.height = 1; - const tempCtx = tempCanvas.getContext('2d')!; - tempCtx.fillStyle = fillColor; - tempCtx.fillRect(0, 0, 1, 1); - const fillRGBA = tempCtx.getImageData(0, 0, 1, 1).data; + const fillRGBA = parseColorToRGBA(fillColor); // Get fill canvas image data const fillImageData = fillCtx.getImageData(0, 0, width, height); diff --git a/src/components/whiteboard/usePointerHandlers.ts b/src/components/whiteboard/usePointerHandlers.ts index c27a2b8..9956501 100644 --- a/src/components/whiteboard/usePointerHandlers.ts +++ b/src/components/whiteboard/usePointerHandlers.ts @@ -1,5 +1,5 @@ import { useCallback } from 'react'; -import type { Point, PointerState } from './types'; +import type { DrawOp, Point, PointerState } from './types'; import { MIN_SCALE, MAX_SCALE } from './types'; export interface PointerHandlers { @@ -30,7 +30,7 @@ export function usePointerHandlers( getTouchDistance: (points: PointerState[]) => number, // Drawing state isDrawingRef: React.RefObject, - currentOpRef: React.RefObject, + currentOpRef: React.RefObject, // Drawing handlers handleStart: (e: React.PointerEvent) => void, handleMove: (e: React.PointerEvent) => void, diff --git a/src/components/whiteboard/useWhiteboardCanvas.ts b/src/components/whiteboard/useWhiteboardCanvas.ts index e5255d9..67533a1 100644 --- a/src/components/whiteboard/useWhiteboardCanvas.ts +++ b/src/components/whiteboard/useWhiteboardCanvas.ts @@ -31,9 +31,6 @@ export function useWhiteboardCanvas( const visibleStrokeCanvasRef = useRef(null); const fillCanvasRef = useRef(null); - // Track if world canvas needs rebuild - const worldNeedsRebuildRef = useRef(true); - // rAF scheduling const rafIdRef = useRef(null); @@ -177,8 +174,6 @@ export function useWhiteboardCanvas( // Order: fillCanvas (bottom) -> visibleStrokeCanvas (top) worldCtx.drawImage(fillCanvas, 0, 0); worldCtx.drawImage(visibleStrokeCanvas, 0, 0); - - worldNeedsRebuildRef.current = false; }, [opsArray, initOffscreenCanvases, getBackgroundColor]); // Renders the viewport efficiently using physical pixels to prevent seams @@ -291,19 +286,11 @@ export function useWhiteboardCanvas( }); }, [renderViewport]); - // Rebuild world canvas on data changes - useEffect(() => { - worldNeedsRebuildRef.current = true; - rebuildWorldCanvas(); - scheduleViewportRender(); - }, [opsArray, rebuildWorldCanvas, scheduleViewportRender]); - - // Re-render on theme change + // Rebuild world canvas when data or theme changes useEffect(() => { - worldNeedsRebuildRef.current = true; rebuildWorldCanvas(); scheduleViewportRender(); - }, [isDark, rebuildWorldCanvas, scheduleViewportRender]); + }, [opsArray, isDark, rebuildWorldCanvas, scheduleViewportRender]); // Handle resize useLayoutEffect(() => { @@ -338,7 +325,6 @@ export function useWhiteboardCanvas( // Subscribe to Yjs changes useEffect(() => { const observer = () => { - worldNeedsRebuildRef.current = true; rebuildWorldCanvas(); scheduleViewportRender(); }; From 9a43b948520400a433b010cfcbf7332c101b9afc Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 6 Feb 2026 19:33:43 +0000 Subject: [PATCH 4/4] remove dead code in useDrawing.ts --- src/components/whiteboard/useDrawing.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/components/whiteboard/useDrawing.ts b/src/components/whiteboard/useDrawing.ts index 592669e..1071b32 100644 --- a/src/components/whiteboard/useDrawing.ts +++ b/src/components/whiteboard/useDrawing.ts @@ -28,7 +28,6 @@ export function useDrawing( currentOpRef: React.RefObject, ): DrawingState { const isDrawing = useRef(false); - const startPoint = useRef({ x: 0, y: 0 }); // Get mouse/touch position relative to canvas, accounting for viewport offset const getPosition = useCallback( @@ -65,7 +64,6 @@ export function useDrawing( (e: React.PointerEvent) => { const pos = getPosition(e); isDrawing.current = true; - startPoint.current = pos; if (tool === 'fill') { // Fill operation