Only the Reader's canvas uses them now. A module-level lastPen keeps the chosen tool/color/size when Draw mode is toggled off and on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
453 lines
16 KiB
React
453 lines
16 KiB
React
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 (
|
|
<div className="overflow-hidden rounded-3xl border border-slate-200 shadow-sm select-none" style={{ WebkitUserSelect: 'none' }}>
|
|
{/* Toolbar */}
|
|
<div className="flex flex-wrap items-center gap-1 border-b border-slate-200 bg-white px-3 py-2">
|
|
{[
|
|
{ id: 'pen', label: '✒', title: 'Pen' },
|
|
{ id: 'highlighter', label: '▐', title: 'Highlighter' },
|
|
{ id: 'eraser', label: '⌫', title: 'Eraser' },
|
|
].map((t) => (
|
|
<button
|
|
key={t.id}
|
|
type="button"
|
|
title={t.title}
|
|
onClick={() => setDrawTool(t.id)}
|
|
className={`flex h-8 w-8 items-center justify-center rounded-lg text-sm font-bold transition ${
|
|
drawTool === t.id ? 'bg-slate-900 text-white' : 'text-slate-600 hover:bg-slate-100'
|
|
}`}
|
|
>
|
|
{t.label}
|
|
</button>
|
|
))}
|
|
|
|
<div className="mx-1 h-5 w-px bg-slate-200" />
|
|
|
|
{INK_COLORS.map((c) => (
|
|
<button
|
|
key={c}
|
|
type="button"
|
|
onClick={() => { setDrawColor(c); if (drawTool === 'eraser') setDrawTool('pen'); }}
|
|
style={{ background: c }}
|
|
className={`h-5 w-5 rounded-full border-2 transition ${
|
|
drawColor === c && drawTool !== 'eraser' ? 'scale-125 border-slate-900' : 'border-white shadow-sm'
|
|
}`}
|
|
/>
|
|
))}
|
|
|
|
<div className="mx-1 h-5 w-px bg-slate-200" />
|
|
|
|
{INK_SIZES.map((s) => (
|
|
<button
|
|
key={s.label}
|
|
type="button"
|
|
onClick={() => setDrawSize(s.value)}
|
|
className={`flex h-8 w-8 items-center justify-center rounded-lg text-xs font-bold transition ${
|
|
drawSize === s.value ? 'bg-slate-900 text-white' : 'text-slate-600 hover:bg-slate-100'
|
|
}`}
|
|
>
|
|
{s.label}
|
|
</button>
|
|
))}
|
|
|
|
<div className="mx-1 h-5 w-px bg-slate-200" />
|
|
|
|
<button
|
|
type="button"
|
|
onClick={undo}
|
|
disabled={realStrokes.length === 0}
|
|
className="rounded-lg px-2.5 py-1 text-xs font-semibold text-slate-600 hover:bg-slate-100 disabled:opacity-40"
|
|
>
|
|
Undo
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={clear}
|
|
disabled={realStrokes.length === 0}
|
|
className="rounded-lg px-2.5 py-1 text-xs font-semibold text-rose-600 hover:bg-rose-50 disabled:opacity-40"
|
|
>
|
|
Clear
|
|
</button>
|
|
|
|
{onDone && (
|
|
<>
|
|
<div className="mx-1 h-5 w-px bg-slate-200" />
|
|
<button
|
|
type="button"
|
|
onClick={onDone}
|
|
className="rounded-lg bg-slate-900 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-slate-800"
|
|
>
|
|
Done ✓
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Draw surface: optional scripture header + ruled notebook, one canvas over both */}
|
|
<div className="relative">
|
|
{/* Scripture content — canvas sits on top so you can draw directly on the text */}
|
|
{headerContent && (
|
|
<div
|
|
className="border-b border-slate-200 bg-slate-50 p-4 font-serif text-sm leading-relaxed text-slate-800"
|
|
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
|
>
|
|
{headerContent}
|
|
</div>
|
|
)}
|
|
{/* Ruled notebook area — height grows with pageCount */}
|
|
<div
|
|
style={{
|
|
minHeight: PAGE_HEIGHT * pageCount,
|
|
background: 'white',
|
|
backgroundImage: 'repeating-linear-gradient(transparent 0px, transparent 31px, #dde3ec 31px, #dde3ec 32px)',
|
|
}}
|
|
/>
|
|
{/* Red margin line — only in pure-notebook mode */}
|
|
{!headerContent && (
|
|
<div className="absolute bottom-0 left-10 top-0 w-px bg-rose-200" style={{ zIndex: 1 }} />
|
|
)}
|
|
{/* Hover cursor — positioned by the pointermove handler, never by React */}
|
|
<div
|
|
ref={hoverRef}
|
|
style={{
|
|
display: 'none',
|
|
position: 'absolute',
|
|
pointerEvents: 'none',
|
|
zIndex: 3,
|
|
border: '1.5px solid',
|
|
boxSizing: 'border-box',
|
|
transform: 'translate(-50%, -50%)',
|
|
opacity: 0.7,
|
|
}}
|
|
/>
|
|
{/* Single canvas spanning the entire area (scripture + notebook) */}
|
|
<canvas
|
|
ref={canvasRef}
|
|
className="absolute inset-0 h-full w-full"
|
|
style={{
|
|
zIndex: 2,
|
|
cursor: drawTool === 'eraser' ? 'cell' : 'crosshair',
|
|
touchAction: 'none',
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* Add more notebook space */}
|
|
<button
|
|
type="button"
|
|
onClick={addSpace}
|
|
className="w-full border-t border-slate-200 bg-slate-50 py-3 text-xs font-semibold text-slate-500 transition hover:bg-slate-100 hover:text-slate-700"
|
|
>
|
|
+ Add More Space
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|