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 (

Bible Study Project

Read the Bible (BSB)

{authStatus}
{/* Collapsible toolbar — hidden in wide focus mode */}
{/* Navigation + tools bar */}
{/* Tool strip: font size · cross-refs · search */}
Text size {[['S', 0.875], ['M', 1], ['L', 1.125], ['XL', 1.25]].map(([label, size]) => ( ))}
{readerBookmarksPanelOpen && (
{bookmarkEntries.length === 0 ? (

No bookmarks yet. Tap the bookmark icon next to any verse to save it here.

) : (
{bookmarkEntries.map((entry) => (
))}
)}
)}
{readerSearchActive ? (
setReaderSearch(e.target.value)} placeholder={readerSearchScope === 'bible' ? 'Search the whole Bible…' : 'Search this chapter…'} className="rounded-xl border border-slate-300 bg-slate-50 px-3 py-1 text-sm text-slate-900 focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200" />
) : ( )}
{readerWideLayout && ( )} {!readerDrawMode && ( )}
{/* 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.

)} {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 ( ); })}
); })()}
)} {/* 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.`}

{readerAudioState.status === 'playing' || readerAudioState.status === 'paused' ? ( <> ) : ( )}
{/* 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 (

{renderVerseText(verse.number, verse.text, searchLower)}

{bmColor && ( )}
{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 && ( )}
); })}
)}
); })}
); })()}
)} {/* 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 (

{renderVerseText(verse.number, verse.text, searchLower)}

{bmColor && ( )}
{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 && ( )}
); })}
)}
); })}
); })()} {/* Page navigation bar */}
{readerBook?.name} {readerChapter} · {flipPage + 1} / {flipTotalPages}
)} {/* 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}
)} {/* Side chapter nav — fixed left/right arrows */} {!readerDrawMode && ( <> )} {/* 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 => (
)}
); }