import { useEffect, useRef, useCallback, useMemo, useState } from 'react'; import { renderInkToCanvas } from '../utils/inkRender.js'; const INK_COLORS = ['#0f172a', '#ef4444', '#3b82f6', '#16a34a', '#f59e0b', '#a855f7']; const INK_SIZES = [ { label: 'XS', value: 0.003 }, { label: 'S', value: 0.006 }, { label: 'M', value: 0.012 }, { label: 'L', value: 0.025 }, ]; const PAGE_HEIGHT = 640; // px per notebook page // The last pen settings, so toggling Draw mode off and on keeps your choice. let lastPen = { tool: 'pen', color: INK_COLORS[0], size: INK_SIZES[0].value }; // Page count is persisted as a hidden metadata stroke at strokes[0] // so the canvas size survives navigation without a separate state key. function extractMeta(strokes) { if (strokes.length > 0 && strokes[0]?._meta) { return { pageCount: strokes[0].pageCount ?? 1, realStrokes: strokes.slice(1) }; } return { pageCount: 1, realStrokes: strokes }; } function packStrokes(realStrokes, pageCount) { return [{ _meta: true, pageCount }, ...realStrokes]; } // Standalone notebook-style draw canvas. // strokes: array of saved stroke objects (may include a leading metadata object) // 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] = useState(lastPen.tool); const [drawColor, setDrawColor] = useState(lastPen.color); const [drawSize, setDrawSize] = useState(lastPen.size); useEffect(() => { lastPen = { tool: drawTool, color: drawColor, size: drawSize }; }, [drawTool, drawColor, drawSize]); const { pageCount: initPages } = extractMeta(strokes); const [pageCount, setPageCount] = useState(initPages); // realStrokes = strokes without the metadata header. Memoized so the canvas // only redraws when the strokes actually change, not on every parent re-render. const realStrokes = useMemo(() => extractMeta(strokes).realStrokes, [strokes]); const canvasRef = useRef(null); const hoverRef = useRef(null); const activeStrokeRef = useRef([]); const isDrawingRef = useRef(false); // Refs prevent stale closures in stable callbacks const strokesRef = useRef(realStrokes); const onStrokesChangeRef = useRef(onStrokesChange); const drawToolRef = useRef(drawTool); const drawColorRef = useRef(drawColor); const drawSizeRef = useRef(drawSize); strokesRef.current = realStrokes; onStrokesChangeRef.current = onStrokesChange; const pageCountRef = useRef(pageCount); pageCountRef.current = pageCount; 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(); }, [realStrokes, drawTool, drawColor, drawSize, render]); useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const obs = new ResizeObserver(render); obs.observe(canvas); return () => obs.disconnect(); }, [render]); // Hover cursor — reads refs so no stale-closure risk; no React re-renders on each move useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; function onMove(e) { if (e.pointerType !== 'pen') return; const el = hoverRef.current; if (!el) return; if (e.pressure > 0) { el.style.display = 'none'; return; } const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; const sizePx = Math.max(6, drawSizeRef.current * rect.height); const tool = drawToolRef.current; const color = tool === 'eraser' ? '#94a3b8' : drawColorRef.current; el.style.display = 'block'; el.style.width = `${sizePx}px`; el.style.height = `${sizePx}px`; el.style.left = `${x}px`; el.style.top = `${y}px`; el.style.borderColor = color; el.style.borderRadius = tool === 'eraser' ? '2px' : '50%'; } function onLeave() { if (hoverRef.current) hoverRef.current.style.display = 'none'; } canvas.addEventListener('pointermove', onMove); canvas.addEventListener('pointerleave', onLeave); return () => { canvas.removeEventListener('pointermove', onMove); canvas.removeEventListener('pointerleave', onLeave); }; }, []); const getPoint = useCallback((e) => { const canvas = canvasRef.current; if (!canvas) return [0, 0, 0.5, 0]; const rect = canvas.getBoundingClientRect(); // tiltX/tiltY range ±90°; normalize to 0-1 where 1 = fully flat (60° threshold) const tilt = e.pointerType === 'pen' ? Math.min(Math.hypot(e.tiltX || 0, e.tiltY || 0) / 60, 1) : 0; return [ (e.clientX - rect.left) / rect.width, (e.clientY - rect.top) / rect.height, e.pressure ?? 0.5, tilt, ]; }, []); useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; function commitStroke() { const pts = activeStrokeRef.current; if (drawToolRef.current !== 'eraser' && pts.length > 1) { onStrokesChangeRef.current(packStrokes( [ ...strokesRef.current, { id: crypto.randomUUID?.() ?? `${Date.now()}-${Math.random()}`, tool: drawToolRef.current, color: drawColorRef.current, size: drawSizeRef.current, points: pts, }, ], pageCountRef.current, )); } 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(packStrokes(remaining, pageCountRef.current)); } // 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(); // radiusX grows as the pencil tilts flat; ~10px radius ≈ fully tilted const tilt = Math.min((t.radiusX || 0) / 10, 1); return [ (t.clientX - rect.left) / rect.width, (t.clientY - rect.top) / rect.height, t.force ?? 0.5, tilt, ]; } function tStart(e) { // Two-finger tap (both non-stylus) = undo last stroke if (e.touches.length === 2 && Array.from(e.touches).every((t) => t.touchType !== 'stylus')) { e.preventDefault(); if (strokesRef.current.length > 0) { onStrokesChangeRef.current(packStrokes(strokesRef.current.slice(0, -1), pageCountRef.current)); render(); } return; } for (const t of e.changedTouches) { if (t.touchType !== 'stylus') continue; e.preventDefault(); if (hoverRef.current) hoverRef.current.style.display = 'none'; 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 = () => realStrokes.length > 0 && onStrokesChange(packStrokes(realStrokes.slice(0, -1), pageCount)); const clear = () => realStrokes.length > 0 && window.confirm('Clear all ink notes?') && onStrokesChange(packStrokes([], pageCount)); // Add another notebook page below existing content. // Rescales all stroke y-coords so existing ink stays at the same pixel position. const addSpace = () => { const newCount = pageCount + 1; const ratio = pageCount / newCount; const rescaled = realStrokes.map((s) => ({ ...s, points: s.points.map(([x, y, p]) => [x, y * ratio, p]), })); onStrokesChange(packStrokes(rescaled, newCount)); setPageCount(newCount); }; return (