import { useEffect, useRef, useCallback } from 'react'; import { useApp } from '../context/AppContext.js'; import { renderInkToCanvas } from '../utils/inkRender.js'; const INK_COLORS = ['#0f172a', '#ef4444', '#3b82f6', '#16a34a', '#f59e0b', '#a855f7']; const INK_SIZES = [ { label: 'S', value: 0.006 }, { label: 'M', value: 0.012 }, { label: 'L', value: 0.025 }, ]; // Standalone notebook-style draw canvas. // strokes: array of saved stroke objects // onStrokesChange: (newStrokes) => void // onDone: optional () => void — shows "Done" button when provided // headerContent: optional JSX rendered above the notebook area. // The canvas extends over it so you can draw directly on the content. export default function DrawCanvas({ strokes, onStrokesChange, onDone, headerContent }) { const { drawTool, setDrawTool, drawColor, setDrawColor, drawSize, setDrawSize } = useApp(); const canvasRef = useRef(null); const activeStrokeRef = useRef([]); const isDrawingRef = useRef(false); // Refs prevent stale closures in stable callbacks const strokesRef = useRef(strokes); const onStrokesChangeRef = useRef(onStrokesChange); const drawToolRef = useRef(drawTool); const drawColorRef = useRef(drawColor); const drawSizeRef = useRef(drawSize); strokesRef.current = strokes; onStrokesChangeRef.current = onStrokesChange; drawToolRef.current = drawTool; drawColorRef.current = drawColor; drawSizeRef.current = drawSize; const render = useCallback(() => { const canvas = canvasRef.current; if (!canvas) return; renderInkToCanvas( canvas, strokesRef.current, activeStrokeRef.current, drawToolRef.current, drawColorRef.current, drawSizeRef.current, ); }, []); useEffect(() => { render(); }, [strokes, drawTool, drawColor, drawSize, render]); useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const obs = new ResizeObserver(render); obs.observe(canvas); return () => obs.disconnect(); }, [render]); const getPoint = useCallback((e) => { const canvas = canvasRef.current; if (!canvas) return [0, 0, 0.5]; const rect = canvas.getBoundingClientRect(); return [ (e.clientX - rect.left) / rect.width, (e.clientY - rect.top) / rect.height, e.pressure ?? 0.5, ]; }, []); useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; function commitStroke() { const pts = activeStrokeRef.current; if (drawToolRef.current !== 'eraser' && pts.length > 1) { onStrokesChangeRef.current([ ...strokesRef.current, { id: crypto.randomUUID?.() ?? `${Date.now()}-${Math.random()}`, tool: drawToolRef.current, color: drawColorRef.current, size: drawSizeRef.current, points: pts, }, ]); } activeStrokeRef.current = []; isDrawingRef.current = false; render(); } function eraseAt(pt) { const [ex, ey] = pt; const r = drawSizeRef.current * 3; const remaining = strokesRef.current.filter( (s) => !s.points.some(([sx, sy]) => Math.hypot(sx - ex, sy - ey) < r), ); if (remaining.length !== strokesRef.current.length) onStrokesChangeRef.current(remaining); } // iOS Safari: Apple Pencil fires Touch Events with touchType === 'stylus'. // Pointer Events on iPadOS emit pointercancel immediately after pointerdown // (palm detection or canvas-width resets from React re-renders), which forces // a double-tap to start every stroke. Touch Events bypass this entirely. if (typeof Touch !== 'undefined' && 'touchType' in Touch.prototype) { function pointFromTouch(t) { const rect = canvas.getBoundingClientRect(); return [ (t.clientX - rect.left) / rect.width, (t.clientY - rect.top) / rect.height, t.force ?? 0.5, ]; } function tStart(e) { for (const t of e.changedTouches) { if (t.touchType !== 'stylus') continue; e.preventDefault(); isDrawingRef.current = true; activeStrokeRef.current = [pointFromTouch(t)]; render(); return; } } function tMove(e) { if (!isDrawingRef.current) return; for (const t of e.changedTouches) { if (t.touchType !== 'stylus') continue; e.preventDefault(); const pt = pointFromTouch(t); activeStrokeRef.current = [...activeStrokeRef.current, pt]; if (drawToolRef.current === 'eraser') eraseAt(pt); render(); return; } } function tEnd(e) { if (!isDrawingRef.current) return; for (const t of e.changedTouches) { if (t.touchType !== 'stylus') continue; e.preventDefault(); commitStroke(); return; } } canvas.addEventListener('touchstart', tStart, { passive: false }); canvas.addEventListener('touchmove', tMove, { passive: false }); canvas.addEventListener('touchend', tEnd, { passive: false }); canvas.addEventListener('touchcancel', tEnd, { passive: false }); return () => { canvas.removeEventListener('touchstart', tStart); canvas.removeEventListener('touchmove', tMove); canvas.removeEventListener('touchend', tEnd); canvas.removeEventListener('touchcancel', tEnd); }; } // Non-iOS: Pointer Events (mouse, Windows pen, etc.) function down(e) { if (e.pointerType === 'touch') return; isDrawingRef.current = true; activeStrokeRef.current = [getPoint(e)]; render(); } function move(e) { if (e.pointerType === 'touch') return; if (!isDrawingRef.current) return; e.preventDefault(); const pt = getPoint(e); activeStrokeRef.current = [...activeStrokeRef.current, pt]; if (drawToolRef.current === 'eraser') eraseAt(pt); render(); } function up(e) { if (e.pointerType === 'touch') return; if (!isDrawingRef.current) return; commitStroke(); } canvas.addEventListener('pointerdown', down); canvas.addEventListener('pointermove', move, { passive: false }); canvas.addEventListener('pointerup', up); canvas.addEventListener('pointercancel', up); return () => { canvas.removeEventListener('pointerdown', down); canvas.removeEventListener('pointermove', move); canvas.removeEventListener('pointerup', up); canvas.removeEventListener('pointercancel', up); }; }, [getPoint, render]); const undo = () => strokes.length > 0 && onStrokesChange(strokes.slice(0, -1)); const clear = () => strokes.length > 0 && window.confirm('Clear all ink notes?') && onStrokesChange([]); return (