// The reading side of the app: a distraction-free page with slim chrome. Every // tool (navigation, search, settings, audio, verse study) lives in a sheet. import { useState, useEffect, useRef, useMemo, useDeferredValue } from 'react'; import { useApp } from '../context/AppContext.js'; import { bookOptions } from '../utils/bibleUtils.jsx'; import { getPlan, getPlanSchedule, findDayIndexForRef, formatDayReadings, nextUnreadDayIndex, computeStreak, nearestReadingDay } from '../utils/readingPlans.js'; import DrawCanvas from './DrawCanvas.jsx'; import { READER_TRANSLATIONS } from '../hooks/useBibleReader.js'; import { useBibleSearch } from '../hooks/useBibleSearch.js'; import { themeVars } from '../components/reader/readerThemes.js'; import { TopBar, BottomBar } from '../components/reader/ReaderBars.jsx'; import ReaderText from '../components/reader/ReaderText.jsx'; import PlanStrip from '../components/reader/PlanStrip.jsx'; import NavigatorSheet from '../components/reader/NavigatorSheet.jsx'; import SettingsSheet from '../components/reader/SettingsSheet.jsx'; import SearchSheet from '../components/reader/SearchSheet.jsx'; import BookmarksSheet from '../components/reader/BookmarksSheet.jsx'; import AudioSheet from '../components/reader/AudioSheet.jsx'; import VerseSheet from '../components/reader/VerseSheet.jsx'; import PlanSheet from '../components/reader/PlanSheet.jsx'; import PlanFooter from '../components/reader/PlanFooter.jsx'; import { FocusIcon } from '../components/reader/icons.jsx'; const NO_STROKES = []; // stable fallback so DrawCanvas doesn't redraw every render const HIGHLIGHT_COLORS = ['#fef08a', '#bbf7d0', '#bfdbfe', '#fecaca', '#e9d5ff', '#fed7aa']; export default function ReaderPage() { const { goHome, readerBookAbbrev, setReaderBookAbbrev, readerTranslation, setReaderTranslation, readerTheme, setReaderTheme, readerLayout, setReaderLayout, readerChapter, setReaderChapter, readerVerses, readerTotalChapters, readerLoading, readerError, readerInterlinear, readerSelectedVerse, setReaderSelectedVerse, readerFontSize, setReaderFontSize, readerWide: readerWideLayout, setReaderWide: setReaderWideLayout, readerBookmarks, readerCrossRefs, setReaderCrossRefs, readerCrossRefsLoading, readerShowCrossRefs, setReaderShowCrossRefs, readerSearch, setReaderSearch, setReaderSearchActive, readerSearchScope, setReaderSearchScope, readerAudioState, audioProgress, bibleIndexStatusFor, audioNarrator, setAudioNarrator, _bibleIndexCacheRef, readerGoToPreviousChapter, readerGoToNextChapter, jumpToReaderVerse, toggleReaderBookmark, cycleBookmarkColor, loadReaderCrossRefs, loadBibleIndex, handlePlayReaderAudio, handleToggleReaderAudioPause, handleStopBookAudio, handleSeekAudio, handleSeekAudioTo, readerInkByPage, updateReaderPageInk, readerTextHighlights, addTextHighlight, removeTextHighlight, planProgress, toggleDay, toggleChapterRead, readingActivity, readerPlan, setReaderPlan, openPlanReading, studyReadingPlanDay, studyBuildBusy, } = useApp(); const readerBook = bookOptions.find((b) => b.abbrev === readerBookAbbrev); const readerNotice = READER_TRANSLATIONS.find((t) => t.id === readerTranslation)?.notice ?? ''; const [sheet, setSheet] = useState(null); // null | 'nav' | 'settings' | 'search' | 'bookmarks' | 'audio' const [chromeVisible, setChromeVisible] = useState(true); const [readerDrawMode, setReaderDrawMode] = useState(false); const [selectionPicker, setSelectionPicker] = useState(null); const swipeTouchRef = useRef(null); const readerPageInkStrokes = readerInkByPage?.[`${readerBookAbbrev}_${readerChapter}`] ?? NO_STROKES; const chapterKey = `${readerBookAbbrev}.${readerChapter}`; const label = (ref) => formatDayReadings([ref]); // Everything the plan UI needs to know about one plan-day, relative to the chapter on screen. const planInfo = (progress, dayIndex) => { const plan = progress && getPlan(progress.planId); if (!plan) return null; const schedule = getPlanSchedule(progress.planId); const refs = schedule[dayIndex] ?? []; const keys = refs.map((r) => `${r.book}.${r.chapter}`); const done = progress.completedDays.includes(dayIndex); const readSet = new Set(done ? keys : progress.chaptersRead?.[dayIndex] ?? []); return { progress, plan, dayIndex, refs, keys, done, readSet, position: keys.indexOf(chapterKey) + 1, // 0 = the chapter on screen isn't part of this day total: refs.length, nextDayRefs: schedule[dayIndex + 1] ?? null, translation: progress.translation, }; }; // The plan day open in the reader (set when you read from a plan) ... const ctxInfo = useMemo(() => { if (!readerPlan) return null; return planInfo(planProgress.find((p) => p.id === readerPlan.progressId), readerPlan.dayIndex); }, [planProgress, readerPlan, chapterKey]); // eslint-disable-line react-hooks/exhaustive-deps // ... and any other active plans whose schedule includes the chapter on screen. const otherMatches = useMemo(() => planProgress .filter((p) => !p.completedAt && p.id !== readerPlan?.progressId) .map((p) => { const plan = getPlan(p.planId); const dayIndex = plan ? findDayIndexForRef(getPlanSchedule(p.planId), readerBookAbbrev, readerChapter) : -1; return dayIndex === -1 ? null : planInfo(p, dayIndex); }) .filter(Boolean), [planProgress, readerPlan, chapterKey]); // eslint-disable-line react-hooks/exhaustive-deps const stripInfo = ctxInfo ?? otherMatches[0] ?? null; const otherPlans = useMemo(() => planProgress .filter((p) => !p.completedAt && p.id !== readerPlan?.progressId) .map((p) => { const plan = getPlan(p.planId); return plan ? { progress: p, plan, dayIndex: nextUnreadDayIndex(plan, p.completedDays) ?? 0 } : null; }) .filter(Boolean), [planProgress, readerPlan]); // After a dwell period on a plan chapter it's ticked off automatically, so you don't // have to remember to. Resets whenever you change chapter; ticks are never removed here. const autoMarkRef = useRef([]); autoMarkRef.current = [ctxInfo?.position > 0 ? ctxInfo : null, ...otherMatches].filter(Boolean); const [autoMarkedNotice, setAutoMarkedNotice] = useState(''); useEffect(() => { const timer = window.setTimeout(() => { const fresh = autoMarkRef.current.filter((m) => !m.readSet.has(chapterKey)); if (fresh.length === 0) return; fresh.forEach((m) => toggleChapterRead(m.progress.id, m.dayIndex, chapterKey, m.keys, m.plan.days, true)); setAutoMarkedNotice(`Marked read: ${label({ book: readerBookAbbrev, chapter: readerChapter })}`); window.setTimeout(() => setAutoMarkedNotice(''), 3500); }, 20000); return () => window.clearTimeout(timer); }, [chapterKey, toggleChapterRead]); // eslint-disable-line react-hooks/exhaustive-deps const goTo = (book, chapter) => { setReaderBookAbbrev(book); setReaderChapter(chapter); setReaderSelectedVerse(null); setReaderCrossRefs(null); window.scrollTo({ top: 0 }); }; const goToRef = (r) => goTo(r.book, r.chapter); // Opens a day of the context plan on its first unread chapter (or the nearest day that has one). const goDay = (progressId, dayIndex) => { const progress = planProgress.find((p) => p.id === progressId); if (!progress) return; const schedule = getPlanSchedule(progress.planId); const day = schedule[dayIndex]?.length ? dayIndex : nearestReadingDay(schedule, dayIndex); const info = day >= 0 ? planInfo(progress, day) : null; if (!info) return; setReaderPlan({ progressId, dayIndex: day }); const target = info.refs.find((r) => !info.readSet.has(`${r.book}.${r.chapter}`)) ?? info.refs[0]; if (target) goToRef(target); }; // Ticks the chapter on screen; finishing a day's last chapter completes it. If earlier // chapters were skipped, the explicit "mark read" buttons still complete the whole day. const readCurrent = (info) => { if (info.readSet.has(chapterKey) && info.keys.every((k) => info.readSet.has(k))) return; const complete = info.keys.every((k) => k === chapterKey || info.readSet.has(k)); if (complete) toggleChapterRead(info.progress.id, info.dayIndex, chapterKey, info.keys, info.plan.days, true); else if (!info.done && info.position === info.total) toggleDay(info.progress.id, info.dayIndex, info.plan.days); else toggleChapterRead(info.progress.id, info.dayIndex, chapterKey, info.keys, info.plan.days, true); }; // Whole-Bible search. BSB and NET are indexed in the browser (lowercased once, not per // keystroke, with typing kept responsive via useDeferredValue); ESV, NLT, CSB and NASB // go through the server's search proxy. const localSearch = READER_TRANSLATIONS.find((t) => t.id === readerTranslation)?.licensed !== true; const indexStatus = bibleIndexStatusFor(readerTranslation); const bibleIndex = localSearch && indexStatus === 'ready' ? (_bibleIndexCacheRef.current[readerTranslation] ?? null) : null; const bibleIndexLower = useMemo(() => (bibleIndex ? bibleIndex.map((v) => v.text.toLowerCase()) : null), [bibleIndex]); const wholeBibleSearching = sheet === 'search' && readerSearchScope === 'bible'; const deferredBibleQuery = useDeferredValue(wholeBibleSearching && localSearch ? readerSearch.trim().toLowerCase() : ''); const bibleMatches = useMemo(() => { if (!bibleIndex || !deferredBibleQuery) return []; const out = []; for (let i = 0; i < bibleIndex.length; i++) { if (bibleIndexLower[i].includes(deferredBibleQuery)) out.push(bibleIndex[i]); } return out; }, [bibleIndex, bibleIndexLower, deferredBibleQuery]); const remoteSearch = useBibleSearch(readerTranslation, readerSearch, wholeBibleSearching && !localSearch); const openSheet = (name) => { setSheet(name); if (name === 'search') setReaderSearchActive(true); }; const closeSheet = () => { if (sheet === 'search') { setReaderSearch(''); setReaderSearchActive(false); } setSheet(null); }; const jump = (book, chapter, verse) => { setSheet(null); jumpToReaderVerse(book, chapter, verse); }; // Swipe left/right to change chapter 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) readerGoToNextChapter(); else readerGoToPreviousChapter(); }; // Arrow keys for desktop; Escape closes the open sheet or verse useEffect(() => { const handleKey = (e) => { if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) return; if (e.key === 'ArrowLeft') readerGoToPreviousChapter(); else if (e.key === 'ArrowRight') readerGoToNextChapter(); else if (e.key === 'Escape') { if (sheet) closeSheet(); else if (readerDrawMode) setReaderDrawMode(false); else setReaderSelectedVerse(null); } }; window.addEventListener('keydown', handleKey); return () => window.removeEventListener('keydown', handleKey); }); // 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 const chapterSearch = sheet === 'search' && readerSearchScope === 'chapter' ? readerSearch.trim().toLowerCase() : ''; 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( { e.stopPropagation(); removeTextHighlight(s.id); }} title="Tap 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); const selectedVerse = readerSelectedVerse != null ? readerVerses.find((v) => v.number === readerSelectedVerse) : null; const selectedKey = selectedVerse ? `${readerBookAbbrev}-${readerChapter}-${selectedVerse.number}` : null; const title = `${readerBook?.name ?? ''} ${readerChapter} · ${readerTranslation}`; const audio = { state: readerAudioState, progress: audioProgress, play: handlePlayReaderAudio, togglePause: handleToggleReaderAudioPause, seek: handleSeekAudio, seekTo: handleSeekAudioTo, stop: handleStopBookAudio, }; const paneOpen = !!selectedVerse && !readerDrawMode; return (
Select text to highlight
{v.number}. {renderVerseText(v.number, v.text, '')}
))}