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: `;
+}
+
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) => (
-
-
-
setStudyLayout('stacked')}
- className="rounded-xl bg-slate-900 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-slate-800"
- >
- Done ✓
-
+ {onDone && (
+ <>
+
+
+ Done ✓
+
+ >
+ )}
- >
+
+ {/* 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
)}
+
+
setReaderDrawMode((v) => !v)}
+ className={`rounded-lg px-3 py-1 text-xs font-semibold transition ${readerDrawMode ? 'bg-slate-900 text-white' : 'border border-slate-300 text-slate-600 hover:bg-slate-50'}`}
+ >
+ ✏ Draw
+
{/* 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++) {