import { useState, useEffect, useRef } 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 {
authStatus,
goHome,
readerBookAbbrev,
readerChapter, setReaderChapter,
readerVerses,
readerTotalChapters,
readerLoading,
readerError,
readerInterlinear,
readerSelectedVerse, setReaderSelectedVerse,
readerFontSize, setReaderFontSize,
readerBookmarks,
readerBookmarksPanelOpen, setReaderBookmarksPanelOpen,
readerCrossRefs,
readerCrossRefsLoading,
readerShowCrossRefs, setReaderShowCrossRefs,
readerSearch, setReaderSearch,
readerSearchActive, setReaderSearchActive,
readerSearchScope, setReaderSearchScope,
readerAudioState,
bibleIndexStatus,
audioNarrator, setAudioNarrator,
_bibleIndexCacheRef,
handleReaderBookChange,
readerGoToPreviousChapter,
readerGoToNextChapter,
jumpToReaderVerse,
toggleReaderBookmark,
cycleBookmarkColor,
loadReaderCrossRefs,
loadBibleIndex,
handlePlayReaderAudio,
handleToggleReaderAudioPause,
handleStopBookAudio,
speakOriginalWord,
copyVerse,
formatCrossRef,
setReaderCrossRefs,
readerInkByPage,
updateReaderPageInk,
readerTextHighlights,
addTextHighlight,
removeTextHighlight,
} = useApp();
const readerBook = bookOptions.find((b) => b.abbrev === readerBookAbbrev);
const [readerDrawMode, setReaderDrawMode] = useState(false);
const [readerWideLayout, setReaderWideLayout] = useState(() => localStorage.getItem('reader-wide') === '1');
const readerPageInkStrokes = readerInkByPage?.[`${readerBookAbbrev}_${readerChapter}`] ?? [];
const [selectionPicker, setSelectionPicker] = useState(null);
const swipeTouchRef = useRef(null);
const [toolbarVisible, setToolbarVisible] = useState(true);
const [readerPageMode, setReaderPageMode] = useState(() => localStorage.getItem('reader-page-mode') === '1');
const [flipPage, setFlipPage] = useState(0);
const [flipTotalPages, setFlipTotalPages] = useState(1);
const [flipPageWidth, setFlipPageWidth] = useState(0);
const flipContainerRef = useRef(null);
const flipContentRef = useRef(null);
useEffect(() => {
localStorage.setItem('reader-wide', readerWideLayout ? '1' : '0');
// Auto-collapse toolbar when entering wide mode, restore when leaving
setToolbarVisible(!readerWideLayout);
}, [readerWideLayout]);
useEffect(() => {
localStorage.setItem('reader-page-mode', readerPageMode ? '1' : '0');
}, [readerPageMode]);
// Reset to page 0 when navigating chapters
useEffect(() => {
setFlipPage(0);
}, [readerChapter, readerBookAbbrev]);
// Track container width for CSS column sizing
useEffect(() => {
if (!readerPageMode || !flipContainerRef.current) return;
const el = flipContainerRef.current;
const update = () => setFlipPageWidth(el.clientWidth);
update();
const ro = new ResizeObserver(update);
ro.observe(el);
return () => ro.disconnect();
}, [readerPageMode, readerVerses]);
// Count pages after content renders
useEffect(() => {
if (!readerPageMode || !flipContentRef.current || !flipPageWidth) return;
const t = setTimeout(() => {
const sw = flipContentRef.current?.scrollWidth ?? 0;
setFlipTotalPages(Math.max(1, Math.round(sw / flipPageWidth)));
}, 120);
return () => clearTimeout(t);
}, [readerPageMode, readerVerses, readerFontSize, flipPageWidth]);
// Swipe left/right to navigate chapters
const handleTouchStart = (e) => {
if (readerDrawMode) return;
const t = e.changedTouches[0];
swipeTouchRef.current = { x: t.clientX, y: t.clientY };
};
const handleTouchEnd = (e) => {
if (readerDrawMode || !swipeTouchRef.current) return;
const t = e.changedTouches[0];
const dx = t.clientX - swipeTouchRef.current.x;
const dy = t.clientY - swipeTouchRef.current.y;
swipeTouchRef.current = null;
if (Math.abs(dx) < 60 || Math.abs(dy) > Math.abs(dx) * 0.8) return;
if (dx < 0) {
if (readerPageMode && flipPage < flipTotalPages - 1) setFlipPage((p) => p + 1);
else readerGoToNextChapter();
} else {
if (readerPageMode && flipPage > 0) setFlipPage((p) => p - 1);
else readerGoToPreviousChapter();
}
};
// Arrow keys for desktop
useEffect(() => {
const handleKey = (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) return;
if (e.key === 'ArrowLeft') {
if (readerPageMode && flipPage > 0) setFlipPage((p) => p - 1);
else readerGoToPreviousChapter();
} else if (e.key === 'ArrowRight') {
if (readerPageMode && flipPage < flipTotalPages - 1) setFlipPage((p) => p + 1);
else readerGoToNextChapter();
}
};
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, [readerGoToPreviousChapter, readerGoToNextChapter, readerPageMode, flipPage, flipTotalPages]);
// Dismiss the highlight picker when tapping outside it
useEffect(() => {
if (!selectionPicker) return;
const dismiss = () => setSelectionPicker(null);
document.addEventListener('pointerdown', dismiss);
return () => document.removeEventListener('pointerdown', dismiss);
}, [selectionPicker]);
// Render verse text with colored highlights and optional search match highlight
function renderVerseText(verseNum, text, searchLow) {
const spans = [];
for (const h of (readerTextHighlights || [])) {
if (h.book === readerBookAbbrev && h.chapter === readerChapter && h.verse === verseNum) {
spans.push({ start: h.startOffset, end: h.endOffset, type: 'hl', id: h.id, color: h.color });
}
}
if (searchLow) {
const lower = text.toLowerCase();
let i = 0;
while ((i = lower.indexOf(searchLow, i)) !== -1) {
spans.push({ start: i, end: i + searchLow.length, type: 'search' });
i += searchLow.length;
}
}
if (!spans.length) return text;
spans.sort((a, b) => a.start - b.start);
const parts = [];
let cursor = 0;
for (const s of spans) {
if (s.start < cursor) continue;
if (s.start > cursor) parts.push(text.slice(cursor, s.start));
if (s.type === 'hl') {
parts.push(
removeTextHighlight(s.id)} title="Click to remove highlight">
{text.slice(s.start, s.end)}
);
} else {
parts.push(
{text.slice(s.start, s.end)}
);
}
cursor = s.end;
}
if (cursor < text.length) parts.push(text.slice(cursor));
return <>{parts}>;
}
// Detect text selection and show the highlight color picker
function handleSelectionEnd() {
setTimeout(() => {
const sel = window.getSelection();
if (!sel || sel.isCollapsed || !sel.toString().trim()) return;
const range = sel.getRangeAt(0);
const rect = range.getBoundingClientRect();
if (!rect.width) return;
let node = range.commonAncestorContainer;
if (node.nodeType !== 1) node = node.parentNode;
while (node && !(node.dataset && node.dataset.verseNum)) node = node.parentElement;
if (!node) return;
const verseNum = Number(node.dataset.verseNum);
const verse = readerVerses.find(v => v.number === verseNum);
if (!verse) return;
const selectedText = sel.toString();
const startOffset = verse.text.indexOf(selectedText);
if (startOffset === -1) return;
setSelectionPicker({
x: (rect.left + rect.right) / 2,
y: rect.top,
verse: verseNum,
startOffset,
endOffset: startOffset + selectedText.length,
});
}, 20);
}
const bookmarkEntries = Object.entries(readerBookmarks).map(([key, color]) => {
const [bAbbrev, chapterStr, verseStr] = key.split('-');
const bookIndex = bookOptions.findIndex((b) => b.abbrev === bAbbrev);
return {
key, color,
bookAbbrev: bAbbrev,
bookName: bookOptions[bookIndex]?.name ?? bAbbrev,
chapter: Number(chapterStr),
verse: Number(verseStr),
bookIndex,
};
}).sort((a, b) => a.bookIndex - b.bookIndex || a.chapter - b.chapter || a.verse - b.verse);
return (
{/* Collapsible toolbar — hidden in wide focus mode */}
{/* Navigation + tools bar */}
Book{' '}
handleReaderBookChange(e.target.value)}
className="ml-1 rounded-xl border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
>
{bookOptions.map((b) => (
{b.name}
))}
Chapter{' '}
{ setReaderChapter(Number(e.target.value)); setReaderSelectedVerse(null); setReaderCrossRefs(null); setReaderSearch(''); setReaderSearchActive(false); }}
className="ml-1 rounded-xl border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
>
{Array.from({ length: readerTotalChapters }, (_, i) => i + 1).map((num) => (
{num}
))}
‹ Prev
= readerTotalChapters}
className="rounded-xl border border-slate-300 bg-white px-4 py-1.5 text-sm font-semibold text-slate-700 transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40">
Next ›
{/* Tool strip: font size · cross-refs · search */}
Text size
{[['S', 0.875], ['M', 1], ['L', 1.125], ['XL', 1.25]].map(([label, size]) => (
setReaderFontSize(size)}
className={`rounded-lg px-2.5 py-1 text-xs font-semibold transition ${readerFontSize === size ? 'bg-slate-900 text-white' : 'border border-slate-300 text-slate-600 hover:bg-slate-50'}`}>
{label}
))}
{ setReaderShowCrossRefs((v) => !v); if (!readerShowCrossRefs) loadReaderCrossRefs(readerBookAbbrev, readerChapter); }}
className={`rounded-lg px-3 py-1 text-xs font-semibold transition ${readerShowCrossRefs ? 'bg-amber-100 text-amber-800' : 'border border-slate-300 text-slate-600 hover:bg-slate-50'}`}>
{readerCrossRefsLoading ? 'Loading refs…' : '🔗 Cross-Refs'}
setReaderBookmarksPanelOpen((v) => !v)}
className={`flex items-center gap-1.5 rounded-lg px-3 py-1 text-xs font-semibold transition ${readerBookmarksPanelOpen ? 'bg-sky-100 text-sky-800' : 'border border-slate-300 text-slate-600 hover:bg-slate-50'}`}>
0} />
Bookmarks{bookmarkEntries.length > 0 ? ` (${bookmarkEntries.length})` : ''}
{readerBookmarksPanelOpen && (
{bookmarkEntries.length === 0 ? (
No bookmarks yet. Tap the bookmark icon next to any verse to save it here.
) : (
{bookmarkEntries.map((entry) => (
jumpToReaderVerse(entry.bookAbbrev, entry.chapter, entry.verse)}
className="flex-1 truncate text-left text-sm font-medium text-slate-700 hover:text-sky-700">
{entry.bookName} {entry.chapter}:{entry.verse}
toggleReaderBookmark(entry.key)}
title="Remove bookmark"
className="rounded p-1 text-slate-400 hover:bg-slate-100 hover:text-rose-600">
✕
))}
)}
)}
{readerSearchActive ? (
) : (
setReaderSearchActive(true)}
className="rounded-lg border border-slate-300 px-3 py-1 text-xs font-semibold text-slate-600 hover:bg-slate-50">
🔍 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
setReaderWideLayout((v) => !v)}
className={`rounded-lg px-3 py-1 text-xs font-semibold transition ${readerWideLayout ? 'bg-slate-900 text-white' : 'border border-slate-300 text-slate-600 hover:bg-slate-50'}`}
>
⊞ Wide
{readerWideLayout && (
setToolbarVisible(false)}
className="rounded-lg px-3 py-1 text-xs font-semibold border border-slate-300 text-slate-600 hover:bg-slate-50 transition"
>
✕ Hide
)}
{!readerDrawMode && (
setReaderPageMode((v) => !v)}
className={`rounded-lg px-3 py-1 text-xs font-semibold transition ${readerPageMode ? 'bg-slate-900 text-white' : 'border border-slate-300 text-slate-600 hover:bg-slate-50'}`}
>
⎕ Page
)}
{/* end collapsible toolbar */}
{/* Whole-Bible search results */}
{readerSearchActive && readerSearchScope === 'bible' && (
{bibleIndexStatus === 'loading' && (
Loading the full Bible for search — this happens once per visit (~7MB)…
)}
{bibleIndexStatus === 'error' && (
Couldn't load the full Bible for search. Try again
)}
{bibleIndexStatus === 'ready' && (() => {
const q = readerSearch.trim().toLowerCase();
if (!q) return
Type at least a word to search all 66 books.
;
const index = _bibleIndexCacheRef.current.BSB ?? [];
const matches = index.filter((v) => v.text.toLowerCase().includes(q));
if (matches.length === 0) return
No verses match "{readerSearch}".
;
const shown = matches.slice(0, 100);
return (
{matches.length} match{matches.length === 1 ? '' : 'es'}{matches.length > shown.length ? ` (showing first ${shown.length})` : ''}
{shown.map((v) => {
const idx = v.text.toLowerCase().indexOf(q);
return (
jumpToReaderVerse(v.bookAbbrev, v.chapter, v.verse)}
className="block w-full rounded-xl px-3 py-2 text-left text-sm hover:bg-slate-50">
{v.bookName} {v.chapter}:{v.verse} {' '}
{v.text.slice(0, idx)}
{v.text.slice(idx, idx + q.length)}
{v.text.slice(idx + q.length)}
);
})}
);
})()}
)}
{/* Audio player */}
Listen to this chapter
{readerAudioState.status === 'error'
? "Couldn't load audio for this chapter/narrator."
: `Audio follows ${readerBook?.name} ${readerChapter} as you browse.`}
setAudioNarrator(e.target.value)}
className="rounded-xl border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200">
David
Hays
Souer
{readerAudioState.status === 'playing' || readerAudioState.status === 'paused' ? (
<>
{readerAudioState.status === 'paused' ? 'Resume' : 'Pause'}
Stop
>
) : (
Play
)}
{/* Verses — normal scroll mode */}
{!readerDrawMode && !readerPageMode && (
{readerBook?.name} {readerChapter} (BSB)
{readerInterlinear && (
Click a verse number to see original words & pronunciation · bookmark icon to save · copy icon to copy
)}
{!readerInterlinear && (
Hover a verse for actions
)}
{readerLoading &&
Loading…
}
{readerError &&
{readerError}
}
{!readerLoading && !readerError && (() => {
const searchLower = readerSearchScope === 'chapter' ? readerSearch.trim().toLowerCase() : '';
const filtered = searchLower
? readerVerses.filter((v) => v.text.toLowerCase().includes(searchLower))
: readerVerses;
if (searchLower && filtered.length === 0) {
return
No verses match "{readerSearch}".
;
}
return (
{filtered.map((verse) => {
const chapterInterlinear = readerInterlinear?.[String(readerChapter)];
const verseWords = chapterInterlinear?.[String(verse.number)];
const isOpen = readerSelectedVerse === verse.number;
const verseKey = `${readerBookAbbrev}-${readerChapter}-${verse.number}`;
const bmColor = readerBookmarks[verseKey];
const crossRefs = readerCrossRefs?.[verse.number];
return (
setReaderSelectedVerse(isOpen ? null : verse.number)}
className={`mt-0.5 shrink-0 rounded px-1 text-xs font-bold transition ${
verseWords
? isOpen ? 'bg-sky-600 text-white' : 'text-sky-600 hover:bg-sky-50'
: 'cursor-default text-slate-400'
}`}
title={verseWords ? 'Show original words' : undefined}>
{verse.number}
{renderVerseText(verse.number, verse.text, searchLower)}
toggleReaderBookmark(verseKey)}
className={`rounded p-1 leading-none hover:bg-slate-100 ${bmColor ? 'text-amber-600' : 'text-slate-400'}`}
title={bmColor ? 'Remove bookmark' : 'Bookmark this verse'}>
{bmColor && (
cycleBookmarkColor(verseKey)}
className="rounded p-1 text-sm leading-none hover:bg-slate-100"
title="Change highlight colour">
🎨
)}
copyVerse(readerBook?.name, readerChapter, verse.number, verse.text)}
className="rounded p-1 leading-none text-slate-400 hover:bg-slate-100 hover:text-slate-700"
title="Copy verse">
{readerShowCrossRefs && crossRefs && (
{crossRefs.slice(0, 8).map((ref, i) => {
const label = formatCrossRef(ref);
return (
{label}
);
})}
)}
{isOpen && verseWords && (
{verseWords.map((w, i) => {
const isHebrew = w.s?.startsWith('H');
const canSpeak = isHebrew || w.s?.startsWith('G');
return (
{w.o}
{w.t}
{w.g}
{w.p &&
{w.p}
}
{w.s &&
{w.s}
}
{canSpeak && (
speakOriginalWord(w.o, w.s)}
className="mt-1.5 rounded-lg bg-sky-100 px-2 py-0.5 text-[11px] font-medium text-sky-700 hover:bg-sky-200 transition"
title={`Pronounce in ${isHebrew ? 'Hebrew' : 'Greek'}`}>
🔊 Speak
)}
);
})}
)}
);
})}
);
})()}
)}
{/* Verses — page-flip mode */}
{!readerDrawMode && readerPageMode && (
{readerLoading &&
Loading…
}
{readerError &&
{readerError}
}
{!readerLoading && !readerError && (() => {
const searchLower = readerSearchScope === 'chapter' ? readerSearch.trim().toLowerCase() : '';
const filtered = searchLower
? readerVerses.filter((v) => v.text.toLowerCase().includes(searchLower))
: readerVerses;
if (searchLower && filtered.length === 0) {
return
No verses match "{readerSearch}".
;
}
return (
{readerBook?.name} {readerChapter} (BSB)
{readerInterlinear
?
Click a verse number to see original words & pronunciation
:
Hover a verse for actions
}
{filtered.map((verse) => {
const chapterInterlinear = readerInterlinear?.[String(readerChapter)];
const verseWords = chapterInterlinear?.[String(verse.number)];
const isOpen = readerSelectedVerse === verse.number;
const verseKey = `${readerBookAbbrev}-${readerChapter}-${verse.number}`;
const bmColor = readerBookmarks[verseKey];
const crossRefs = readerCrossRefs?.[verse.number];
return (
setReaderSelectedVerse(isOpen ? null : verse.number)}
className={`mt-0.5 shrink-0 rounded px-1 text-xs font-bold transition ${
verseWords
? isOpen ? 'bg-sky-600 text-white' : 'text-sky-600 hover:bg-sky-50'
: 'cursor-default text-slate-400'
}`}
title={verseWords ? 'Show original words' : undefined}>
{verse.number}
{renderVerseText(verse.number, verse.text, searchLower)}
toggleReaderBookmark(verseKey)}
className={`rounded p-1 leading-none hover:bg-slate-100 ${bmColor ? 'text-amber-600' : 'text-slate-400'}`}
title={bmColor ? 'Remove bookmark' : 'Bookmark this verse'}>
{bmColor && (
cycleBookmarkColor(verseKey)}
className="rounded p-1 text-sm leading-none hover:bg-slate-100"
title="Change highlight colour">
🎨
)}
copyVerse(readerBook?.name, readerChapter, verse.number, verse.text)}
className="rounded p-1 leading-none text-slate-400 hover:bg-slate-100 hover:text-slate-700"
title="Copy verse">
{readerShowCrossRefs && crossRefs && (
{crossRefs.slice(0, 8).map((ref, i) => {
const label = formatCrossRef(ref);
return (
{label}
);
})}
)}
{isOpen && verseWords && (
{verseWords.map((w, i) => {
const isHebrew = w.s?.startsWith('H');
const canSpeak = isHebrew || w.s?.startsWith('G');
return (
{w.o}
{w.t}
{w.g}
{w.p &&
{w.p}
}
{w.s &&
{w.s}
}
{canSpeak && (
speakOriginalWord(w.o, w.s)}
className="mt-1.5 rounded-lg bg-sky-100 px-2 py-0.5 text-[11px] font-medium text-sky-700 hover:bg-sky-200 transition"
title={`Pronounce in ${isHebrew ? 'Hebrew' : 'Greek'}`}>
🔊 Speak
)}
);
})}
)}
);
})}
);
})()}
{/* Page navigation bar */}
flipPage > 0 ? setFlipPage((p) => p - 1) : readerGoToPreviousChapter()}
disabled={flipPage === 0 && readerChapter <= 1}
className="rounded-xl border border-slate-200 bg-white px-4 py-1.5 text-sm font-semibold text-slate-600 shadow-sm hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40">
{flipPage === 0 ? '← Prev chapter' : '← Back'}
{readerBook?.name} {readerChapter} · {flipPage + 1} / {flipTotalPages}
flipPage < flipTotalPages - 1 ? setFlipPage((p) => p + 1) : readerGoToNextChapter()}
disabled={flipPage === flipTotalPages - 1 && readerChapter >= readerTotalChapters}
className="rounded-xl border border-slate-200 bg-white px-4 py-1.5 text-sm font-semibold text-slate-600 shadow-sm hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40">
{flipPage === flipTotalPages - 1 ? 'Next chapter →' : 'Next →'}
)}
{/* Draw mode — Bible text on left, notebook canvas on right */}
{readerDrawMode && (
{/* Scripture panel — sticky so it stays in view while notebook scrolls */}
{readerBook?.name} {readerChapter}
BSB
Select text to highlight
{!readerLoading && readerVerses.map((v) => (
{v.number}. {' '}
{renderVerseText(v.number, v.text, '')}
))}
{/* Notebook canvas */}
updateReaderPageInk(readerBookAbbrev, readerChapter, s)}
onDone={() => setReaderDrawMode(false)}
/>
)}
{/* Wide focus mode — fixed top bar to restore toolbar */}
{readerWideLayout && !toolbarVisible && (
{readerBook?.name} {readerChapter}
‹ Prev
= readerTotalChapters}
className="rounded-lg border border-slate-200 bg-white px-3 py-1 text-xs font-semibold text-slate-600 hover:bg-slate-50 disabled:opacity-40">
Next ›
setToolbarVisible(true)}
className="rounded-lg border border-slate-200 bg-white px-3 py-1 text-xs font-semibold text-slate-600 hover:bg-slate-50">
⚙ Tools
)}
{/* Side chapter nav — fixed left/right arrows */}
{!readerDrawMode && (
<>
‹
= readerTotalChapters}
aria-label="Next chapter"
className="fixed right-3 top-1/2 -translate-y-1/2 z-10 flex h-10 w-10 items-center justify-center rounded-full border border-slate-200 bg-white/80 text-slate-500 shadow-md backdrop-blur-sm transition hover:bg-white hover:text-slate-900 disabled:opacity-0 disabled:pointer-events-none"
>
›
>
)}
{/* Highlight color picker — appears above text selection */}
{selectionPicker && (
80 ? selectionPicker.y - 56 : selectionPicker.y + 28,
}}
onPointerDown={(e) => e.stopPropagation()}
>
{['#fef08a', '#bbf7d0', '#bfdbfe', '#fecaca', '#e9d5ff', '#fed7aa'].map(color => (
{
e.preventDefault();
addTextHighlight({
book: readerBookAbbrev,
chapter: readerChapter,
verse: selectionPicker.verse,
startOffset: selectionPicker.startOffset,
endOffset: selectionPicker.endOffset,
color,
});
window.getSelection()?.removeAllRanges();
setSelectionPicker(null);
}}
/>
))}
{
e.preventDefault();
window.getSelection()?.removeAllRanges();
setSelectionPicker(null);
}}
>
✕
)}
);
}