diff --git a/src/App.jsx b/src/App.jsx index 3513279..ffe54bb 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,5 +1,7 @@ -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import mammoth from 'mammoth'; +import { getStroke } from 'perfect-freehand'; +import { pathFromStroke } from './utils/inkRender.js'; import { AppContext, useApp } from './context/AppContext.js'; import AdminPage from './pages/AdminPage.jsx'; @@ -425,6 +427,28 @@ export function formatRelativeDate(ts) { const escHtml = (s) => String(s ?? '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +function inkStrokesToSvgHtml(strokes) { + if (!strokes?.length) return ''; + const W = 700, H = 500; + const lines = []; + for (let y = 32; y < H; y += 32) + lines.push(``); + lines.push(``); + const paths = strokes.map((s) => { + const pts = s.points.map(([x, y, p]) => [x * W, y * H, p]); + const outline = getStroke(pts, { + size: s.size * H, + thinning: s.tool === 'highlighter' ? 0 : 0.5, + smoothing: 0.5, + streamline: 0.4, + }); + const d = pathFromStroke(outline); + if (!d) return ''; + return ``; + }).filter(Boolean).join(''); + return `
INK NOTES:
${lines.join('')}${paths}
`; +} + export function buildExportHtml(project) { const style = ` body { font-family: Georgia, serif; color: #0f172a; margin: 0; padding: 32px; } @@ -435,7 +459,7 @@ export function buildExportHtml(project) { .chunk { margin-bottom: 2rem; padding: 1.25rem 1.5rem; border: 1px solid #cbd5e1; border-radius: 0.75rem; background: #ffffff; } .verse { margin: 0 0 0.75rem; line-height: 1.7; } .scripture-ref { font-weight: 700; margin-bottom: 0.75rem; } - .oia, .cross-refs, .greek { margin-top: 1rem; } + .oia, .cross-refs, .greek, .ink-notes { margin-top: 1rem; } .oia-section { margin-bottom: 0.75rem; } .greek table { width: 100%; border-collapse: collapse; margin-top: 0.5rem; } .greek th, .greek td { border: 1px solid #d1d5db; padding: 0.65rem; text-align: left; } @@ -506,6 +530,8 @@ export function buildExportHtml(project) { }) .join(''); + const inkHtml = inkStrokesToSvgHtml(chunk.inkStrokes); + return `
${escHtml(scripture)}
@@ -520,6 +546,7 @@ export function buildExportHtml(project) { ${greekRows ? greekTable : '

No Greek word notes.

'} ${extendedDefinitions} + ${inkHtml}
`; }).join(''); @@ -1168,6 +1195,10 @@ const App = () => { const [audioNarrator, setAudioNarrator] = useState('souer'); const [audioState, setAudioState] = useState({ status: 'idle', chapter: 0, total: 0 }); const [readerAudioState, setReaderAudioState] = useState({ status: 'idle', chapter: 0 }); + const [readerInkByPage, setReaderInkByPage] = useState(() => { + try { return JSON.parse(localStorage.getItem('readerInkByPage') ?? '{}'); } + catch { return {}; } + }); const audioRef = useRef(null); const audioModeRef = useRef('book'); // 'book' | 'reader' — which player owns audioRef const audioBookRef = useRef(audioBook); @@ -1520,6 +1551,15 @@ const App = () => { }); }; + const updateReaderPageInk = useCallback((bookAbbrev, chapter, strokes) => { + const key = `${bookAbbrev}_${chapter}`; + setReaderInkByPage((prev) => { + const next = { ...prev, [key]: strokes }; + try { localStorage.setItem('readerInkByPage', JSON.stringify(next)); } catch {} + return next; + }); + }, []); + const cycleBookmarkColor = (verseKey) => { setReaderBookmarks((prev) => { const cur = prev[verseKey]; @@ -3705,6 +3745,8 @@ const deleteProject = (id) => { readerSearchActive, setReaderSearchActive, readerSearchScope, setReaderSearchScope, readerAudioState, + readerInkByPage, + updateReaderPageInk, bibleIndexStatus, loadReaderChapter, _bibleIndexCacheRef, diff --git a/src/pages/DrawCanvas.jsx b/src/pages/DrawCanvas.jsx index 308277f..f7b54b4 100644 --- a/src/pages/DrawCanvas.jsx +++ b/src/pages/DrawCanvas.jsx @@ -9,58 +9,24 @@ const INK_SIZES = [ { label: 'L', value: 0.025 }, ]; -// Read-only ink overlay shown in Stacked / Split mode. -export function InkLayer({ strokes }) { - const canvasRef = useRef(null); - const strokesRef = useRef(strokes); - strokesRef.current = strokes; - - const render = useCallback(() => { - const canvas = canvasRef.current; - if (!canvas) return; - renderInkToCanvas(canvas, strokesRef.current); - }, []); - - useEffect(() => { render(); }, [strokes, render]); - - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - const obs = new ResizeObserver(render); - obs.observe(canvas); - return () => obs.disconnect(); - }, [render]); - - if (!strokes.length) return null; - return ( - - ); -} - -// Interactive draw canvas overlay for Draw mode. -export default function DrawCanvas() { - const { - selectedChunk, - updateChunk, - drawTool, setDrawTool, - drawColor, setDrawColor, - drawSize, setDrawSize, - setStudyLayout, - } = useApp(); +// Standalone notebook-style draw canvas. +// strokes: array of saved stroke objects +// onStrokesChange: (newStrokes) => void +// onDone: optional () => void — shows "Done" button when provided +export default function DrawCanvas({ strokes, onStrokesChange, onDone }) { + const { drawTool, setDrawTool, drawColor, setDrawColor, drawSize, setDrawSize } = useApp(); const canvasRef = useRef(null); const activeStrokeRef = useRef([]); - // Refs prevent stale closures in ResizeObserver callback - const strokesRef = useRef([]); + // 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 = selectedChunk?.inkStrokes ?? []; + strokesRef.current = strokes; + onStrokesChangeRef.current = onStrokesChange; drawToolRef.current = drawTool; drawColorRef.current = drawColor; drawSizeRef.current = drawSize; @@ -78,7 +44,7 @@ export default function DrawCanvas() { ); }, []); - useEffect(() => { render(); }, [selectedChunk?.inkStrokes, drawTool, drawColor, drawSize, render]); + useEffect(() => { render(); }, [strokes, drawTool, drawColor, drawSize, render]); useEffect(() => { const canvas = canvasRef.current; @@ -113,68 +79,47 @@ export default function DrawCanvas() { e.preventDefault(); const pt = getPoint(e); activeStrokeRef.current = [...activeStrokeRef.current, pt]; - if (drawToolRef.current === 'eraser') { 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), + const remaining = strokesRef.current.filter( + (s) => !s.points.some(([sx, sy]) => Math.hypot(sx - ex, sy - ey) < r), ); - if (remaining.length !== strokesRef.current.length) { - updateChunk(selectedChunk.id, { inkStrokes: remaining }); - } + if (remaining.length !== strokesRef.current.length) + onStrokesChangeRef.current(remaining); } render(); - }, [getPoint, render, updateChunk, selectedChunk]); + }, [getPoint, render]); const onPointerUp = useCallback((e) => { if (e.pointerType === 'touch') return; const pts = activeStrokeRef.current; if (drawToolRef.current !== 'eraser' && pts.length > 1) { - updateChunk(selectedChunk.id, { - inkStrokes: [ - ...strokesRef.current, - { - id: crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`, - tool: drawToolRef.current, - color: drawColorRef.current, - size: drawSizeRef.current, - points: pts, - }, - ], - }); + onStrokesChangeRef.current([ + ...strokesRef.current, + { + id: crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`, + tool: drawToolRef.current, + color: drawColorRef.current, + size: drawSizeRef.current, + points: pts, + }, + ]); } activeStrokeRef.current = []; render(); - }, [render, updateChunk, selectedChunk]); + }, [render]); - const strokes = selectedChunk?.inkStrokes ?? []; - - const undo = () => { - if (strokes.length > 0) - updateChunk(selectedChunk.id, { inkStrokes: strokes.slice(0, -1) }); - }; - - const clear = () => { - if (strokes.length > 0 && window.confirm('Clear all annotations on this chunk?')) - updateChunk(selectedChunk.id, { inkStrokes: [] }); - }; + const undo = () => strokes.length > 0 && onStrokesChange(strokes.slice(0, -1)); + const clear = () => + strokes.length > 0 && + window.confirm('Clear all ink notes?') && + onStrokesChange([]); return ( - <> - - - {/* Floating toolbar */} -
- {/* Tool buttons */} +
+ {/* Toolbar */} +
{[ { id: 'pen', label: '✒', title: 'Pen' }, { id: 'highlighter', label: '▐', title: 'Highlighter' }, @@ -185,10 +130,8 @@ export default function DrawCanvas() { type="button" title={t.title} onClick={() => setDrawTool(t.id)} - className={`flex h-8 w-8 items-center justify-center rounded-xl text-sm font-bold transition ${ - drawTool === t.id - ? 'bg-slate-900 text-white' - : 'text-slate-600 hover:bg-slate-100' + 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} @@ -197,7 +140,6 @@ export default function DrawCanvas() {
- {/* Color swatches */} {INK_COLORS.map((c) => ( @@ -244,21 +181,50 @@ export default function DrawCanvas() { type="button" onClick={clear} disabled={strokes.length === 0} - className="rounded-xl px-2.5 py-1 text-xs font-semibold text-rose-600 hover:bg-rose-50 disabled:opacity-40" + className="rounded-lg px-2.5 py-1 text-xs font-semibold text-rose-600 hover:bg-rose-50 disabled:opacity-40" > Clear -
- - + {onDone && ( + <> +
+ + + )}
- + + {/* Notebook canvas */} +
+ {/* Red margin line */} +
+ +
+
); } diff --git a/src/pages/ReaderPage.jsx b/src/pages/ReaderPage.jsx index 5d3c3ae..7e52fef 100644 --- a/src/pages/ReaderPage.jsx +++ b/src/pages/ReaderPage.jsx @@ -1,5 +1,7 @@ +import { useState, useEffect } from 'react'; import { useApp } from '../context/AppContext.js'; import { bookOptions, BookmarkIcon, CopyIcon } from '../App.jsx'; +import DrawCanvas from './DrawCanvas.jsx'; export default function ReaderPage() { const { @@ -41,9 +43,15 @@ export default function ReaderPage() { copyVerse, formatCrossRef, setReaderCrossRefs, + readerInkByPage, + updateReaderPageInk, } = useApp(); const readerBook = bookOptions.find((b) => b.abbrev === readerBookAbbrev); + const [readerDrawMode, setReaderDrawMode] = useState(false); + const readerPageInkStrokes = readerInkByPage?.[`${readerBookAbbrev}_${readerChapter}`] ?? []; + + useEffect(() => { setReaderDrawMode(false); }, [readerBookAbbrev, readerChapter]); const bookmarkEntries = Object.entries(readerBookmarks).map(([key, color]) => { const [bAbbrev, chapterStr, verseStr] = key.split('-'); const bookIndex = bookOptions.findIndex((b) => b.abbrev === bAbbrev); @@ -201,6 +209,14 @@ export default function ReaderPage() { 🔍 Search )} +
+
{/* Whole-Bible search results */} @@ -416,6 +432,17 @@ export default function ReaderPage() { ); })()}
+ + {/* Ink notebook — shown when Draw mode is active */} + {readerDrawMode && ( +
+ updateReaderPageInk(readerBookAbbrev, readerChapter, s)} + onDone={() => setReaderDrawMode(false)} + /> +
+ )}
); diff --git a/src/pages/StudyPage.jsx b/src/pages/StudyPage.jsx index a19cc23..27083fc 100644 --- a/src/pages/StudyPage.jsx +++ b/src/pages/StudyPage.jsx @@ -2,7 +2,7 @@ import { createPortal } from 'react-dom'; import DOMPurify from 'dompurify'; import { useApp } from '../context/AppContext.js'; import { STUDY_TABS, COMMENTARY_OPTIONS, NT_BOOK_NUMBER, formatChunkReference, CrossRefChip } from '../App.jsx'; -import DrawCanvas, { InkLayer } from './DrawCanvas.jsx'; +import DrawCanvas from './DrawCanvas.jsx'; export default function StudyPage() { const { @@ -243,9 +243,36 @@ export default function StudyPage() { {selectedChunk ? (
+ {studyLayout === 'annotate' ? ( +
+ {/* Compact scripture strip */} +
+
+ Scripture · read-only + + {formatChunkReference(project, selectedChunkChapterIndex, selectedChunk, '–')} + +
+

+ {selectedChunkVerses.map((verse) => ( + + {verse.chapter}:{verse.number} + {verse.text}{' '} + + ))} +

+
+ {/* Notebook canvas */} + updateChunk(selectedChunk.id, { inkStrokes: s })} + onDone={() => setStudyLayout('stacked')} + /> +
+ ) : (
{/* Scripture */} -
+

Scripture

@@ -264,9 +291,6 @@ export default function StudyPage() { ))}
- - {studyLayout === 'annotate' && } - {/* Interlinear */}
+ )} {/* Prev / Next — sticky */}
diff --git a/src/utils/inkRender.js b/src/utils/inkRender.js index fe245a4..10d95f9 100644 --- a/src/utils/inkRender.js +++ b/src/utils/inkRender.js @@ -1,6 +1,6 @@ import { getStroke } from 'perfect-freehand'; -function pathFromStroke(points) { +export function pathFromStroke(points) { if (!points.length) return ''; const d = [`M ${points[0][0].toFixed(2)} ${points[0][1].toFixed(2)}`]; for (let i = 1; i < points.length - 1; i++) {