// 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 '../utils/bibleText.js'; import { useResizableWidth } from '../hooks/useResizableWidth.js'; import { useParallelChapter } from '../hooks/useParallelChapter.js'; import { useOfflineSave, useOnline } from '../hooks/useOfflineSave.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 CompareSheet from '../components/reader/CompareSheet.jsx'; import SearchSheet from '../components/reader/SearchSheet.jsx'; import MarksSheet from '../components/reader/MarksSheet.jsx'; import { useToast } from '../components/Toasts.jsx'; import { HIGHLIGHT_COLORS, colorTitle } from '../utils/highlightColors.js'; 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 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, readerFont, setReaderFont, readerSpacing, setReaderSpacing, readerVerseNumbers, setReaderVerseNumbers, readerParallel, setReaderParallel, readerShowDiff, setReaderShowDiff, readerBookmarks, readerCrossRefs, setReaderCrossRefs, readerCrossRefsLoading, readerShowCrossRefs, setReaderShowCrossRefs, readerSearch, setReaderSearch, setReaderSearchActive, readerSearchScope, setReaderSearchScope, readerAudioState, audioProgress, bibleIndexStatusFor, audioNarrator, setAudioNarrator, _bibleIndexCacheRef, readerGoToPreviousChapter, readerGoToNextChapter, jumpToReaderVerse, toggleReaderBookmark, restoreReaderBookmark, cycleBookmarkColor, loadReaderCrossRefs, loadBibleIndex, handlePlayReaderAudio, handleToggleReaderAudioPause, handleStopBookAudio, handleSeekAudio, handleSeekAudioTo, readerInkByPage, updateReaderPageInk, readerTextHighlights, addTextHighlight, removeTextHighlight, restoreTextHighlight, readerColorLabels, setReaderColorLabel, planProgress, toggleDay, toggleChapterRead, readingActivity, readerPlan, setReaderPlan, openPlanReading, studyReadingPlanDay, studyBuildBusy, readerRequest, clearReaderRequest, } = useApp(); const { toast } = useToast(); const offline = useOfflineSave(); const online = useOnline(); const readerBook = bookOptions.find((b) => b.abbrev === readerBookAbbrev); const readerNotice = READER_TRANSLATIONS.find((t) => t.id === readerTranslation)?.notice ?? ''; // A second Bible beside the first (never the same one twice, and not while drawing) const parallelId = readerParallel && readerParallel !== readerTranslation ? readerParallel : ''; const parallelChapter = useParallelChapter(parallelId, readerBookAbbrev, readerChapter); const parallelSource = READER_TRANSLATIONS.find((t) => t.id === parallelId); const [sheet, setSheet] = useState(null); // null | 'nav' | 'settings' | 'search' | 'bookmarks' | 'highlights' | 'audio' const [chromeVisible, setChromeVisible] = useState(true); const [readerDrawMode, setReaderDrawMode] = useState(false); const [selectionPicker, setSelectionPicker] = useState(null); const [xrefRequest, setXrefRequest] = useState(null); // a cross-reference tapped in the text: { verse, ref, n } const swipeTouchRef = useRef(null); // The side panel's width: drag its left edge (remembered on this device) const rootRef = useRef(null); const paneRef = useRef(null); const pane = useResizableWidth({ storageKey: 'reader-pane-width', containerRef: rootRef, min: 320, maxAbsolute: 800, fallback: 384, reserve: 460, cssVar: '--reader-pane' }); 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); // "Search the Bible for …" from the ⌘K palette: open the whole-Bible search with the text filled in. useEffect(() => { if (!readerRequest?.search) return; setReaderSearchScope('bible'); setReaderSearch(readerRequest.search); setReaderSearchActive(true); setSheet('search'); if (localSearch && indexStatus === 'idle') loadBibleIndex(readerTranslation); clearReaderRequest(); }, [readerRequest]); // eslint-disable-line react-hooks/exhaustive-deps 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.metaKey || e.ctrlKey || e.altKey) 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); } else if (!sheet && !readerDrawMode) { // One-key shortcuts (the "?" list shows them) if (e.key === '/') { e.preventDefault(); openSheet('search'); } else if (e.key === 'g' || e.key === 'G') { e.preventDefault(); openSheet('nav'); } else if (e.key === ',') { e.preventDefault(); openSheet('settings'); } else if (e.key === 'c' || e.key === 'C') { e.preventDefault(); openSheet('compare'); } else if (e.key === 'h' || e.key === 'H') { e.preventDefault(); openSheet('highlights'); } else if (e.key === 'f' || e.key === 'F') setChromeVisible((v) => !v); else if ((e.key === 'b' || e.key === 'B') && selectedKey) { readerBookmarks[selectedKey] ? removeBookmark(selectedKey, readerBookmarks[selectedKey]) : toggleReaderBookmark(selectedKey); } } }; 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]); // Removing a highlight or bookmark is one tap, so it can always be taken back. const removeHighlight = (id) => { const gone = (readerTextHighlights || []).find((h) => h.id === id); removeTextHighlight(id); if (gone) toast('Highlight removed', { action: { label: 'Undo', onClick: () => restoreTextHighlight(gone) } }); }; // Highlighting a whole verse from the verse panel: tapping the same colour again takes it off. const highlightVerse = (color) => { if (!selectedVerse) return; const existing = verseHighlights.find((h) => h.color === color && h.startOffset === 0 && h.endOffset >= selectedVerse.text.length); if (existing) { removeHighlight(existing.id); return; } addTextHighlight({ book: readerBookAbbrev, chapter: readerChapter, verse: selectedVerse.number, startOffset: 0, endOffset: selectedVerse.text.length, color, text: selectedVerse.text }); }; const clearVerseHighlights = () => { const gone = verseHighlights; gone.forEach((h) => removeTextHighlight(h.id)); if (gone.length) toast(`Removed ${gone.length} highlight${gone.length === 1 ? '' : 's'}`, { action: { label: 'Undo', onClick: () => gone.forEach((h) => restoreTextHighlight(h)) } }); }; const removeBookmark = (key, color) => { toggleReaderBookmark(key); toast('Bookmark removed', { action: { label: 'Undo', onClick: () => restoreReaderBookmark?.(key, color) } }); }; // 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, diffRanges) { const spans = []; for (const r of diffRanges ?? []) spans.push({ start: r.start, end: r.end, type: 'diff' }); 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; // Your highlights and search matches always show; a difference mark that would overlap one is dropped const firm = spans.filter((x) => x.type !== 'diff'); for (let i = spans.length - 1; i >= 0; i -= 1) { if (spans[i].type === 'diff' && firm.some((f) => spans[i].start < f.end && f.start < spans[i].end)) spans.splice(i, 1); } 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(); removeHighlight(s.id); }} title={`${colorTitle(s.color, readerColorLabels ?? {})} · tap to remove`}> {text.slice(s.start, s.end)} , ); } else if (s.type === 'diff') { parts.push({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, text: selectedText }); }, 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); // Highlights newest-last in reading order, with the book's name for the list const highlightEntries = (readerTextHighlights || []).map((h) => { const bookIndex = bookOptions.findIndex((b) => b.abbrev === h.book); return { ...h, bookName: bookOptions[bookIndex]?.name ?? h.book, bookIndex }; }).sort((a, b) => a.bookIndex - b.bookIndex || a.chapter - b.chapter || a.verse - b.verse || a.startOffset - b.startOffset); const selectedVerse = readerSelectedVerse != null ? readerVerses.find((v) => v.number === readerSelectedVerse) : null; const verseHighlights = selectedVerse ? (readerTextHighlights || []).filter((h) => h.book === readerBookAbbrev && h.chapter === readerChapter && h.verse === selectedVerse.number) : []; 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 (
{chromeVisible && ( openSheet('nav')} onSearch={() => openSheet('search')} onBookmarks={() => openSheet('bookmarks')} bookmarkCount={bookmarkEntries.length} onHighlights={() => openSheet('highlights')} highlightCount={highlightEntries.length} onCompare={() => openSheet('compare')} compareActive={!!parallelId} onSettings={() => openSheet('settings')} onDoneDrawing={readerDrawMode ? () => setReaderDrawMode(false) : undefined} /> )} {!chromeVisible && ( )} {chromeVisible && ( openSheet('plan')} onJoin={() => setReaderPlan({ progressId: stripInfo.progress.id, dayIndex: stripInfo.dayIndex })} onDot={goToRef} onBackToDay={() => goDay(ctxInfo.progress.id, ctxInfo.dayIndex)} /> )} {!online && (

You’re offline. Chapters you’ve read or saved still open; save a whole book from “Go to” while you’re connected.

)}
{!readerDrawMode && ( t.id === readerTranslation)?.name ?? readerTranslation} translationId={readerTranslation} font={readerFont} spacing={readerSpacing} showVerseNumbers={readerVerseNumbers} showDiff={readerShowDiff} parallel={parallelId ? { translation: parallelId, name: parallelSource?.name ?? parallelId, notice: parallelSource?.notice ?? '', ...parallelChapter } : null} notice={readerNotice} verses={readerVerses} loading={readerLoading} error={readerError} layout={readerLayout} fontSize={readerFontSize} wide={readerWideLayout} bookmarks={readerBookmarks} bookKey={readerBookAbbrev} chapter={readerChapter} selectedVerse={readerSelectedVerse} onSelectVerse={setReaderSelectedVerse} renderVerseText={renderVerseText} searchLower={chapterSearch} crossRefs={readerCrossRefs} showCrossRefs={readerShowCrossRefs} onOpenCrossRef={(verse, ref) => { setReaderSelectedVerse(verse); setXrefRequest((r) => ({ verse, ref, n: (r?.n ?? 0) + 1 })); }} onSelectionEnd={handleSelectionEnd} footer={( { readCurrent(ctxInfo); goToRef(ctxInfo.refs[ctxInfo.position]); }} onNextDay={() => { readCurrent(ctxInfo); goDay(ctxInfo.progress.id, ctxInfo.dayIndex + 1); }} onFinish={() => { readCurrent(ctxInfo); setAutoMarkedNotice(`Day ${ctxInfo.dayIndex + 1} marked read`); window.setTimeout(() => setAutoMarkedNotice(''), 3500); }} /> )} /> )} {/* Draw mode: scripture beside a notebook canvas */} {readerDrawMode && (
{readerBook?.name} {readerChapter} {readerTranslation}

Select text to highlight

{!readerLoading && readerVerses.map((v) => (

{v.number}. {renderVerseText(v.number, v.text, '')}

))}
updateReaderPageInk(readerBookAbbrev, readerChapter, s)} onDone={() => setReaderDrawMode(false)} />
)}
{chromeVisible && !readerDrawMode && ( = readerTotalChapters} onAudio={() => openSheet('audio')} audioActive={readerAudioState.status === 'playing'} onPen={() => setReaderDrawMode(true)} onCrossRefs={() => { setReaderShowCrossRefs((v) => !v); if (!readerShowCrossRefs) loadReaderCrossRefs(readerBookAbbrev, readerChapter); }} crossRefsOn={readerShowCrossRefs} onFocus={() => { setChromeVisible(false); setSheet(null); }} primary={null} /> )} {/* Verse sheet: bottom sheet on phones, side pane on wide screens */} {selectedVerse && !readerDrawMode && ( { if (!readerCrossRefs) loadReaderCrossRefs(readerBookAbbrev, readerChapter); }} bookmarkColor={readerBookmarks[selectedKey]} onToggleBookmark={() => (readerBookmarks[selectedKey] ? removeBookmark(selectedKey, readerBookmarks[selectedKey]) : toggleReaderBookmark(selectedKey))} onCycleColor={() => cycleBookmarkColor(selectedKey)} onJump={jump} onClose={() => setReaderSelectedVerse(null)} request={xrefRequest} panelRef={paneRef} resizeHandle={( // Drag this edge to resize (arrow keys too; double-click resets). Wide screens only.
)} verseHighlights={verseHighlights} colorLabels={readerColorLabels ?? {}} onHighlightVerse={highlightVerse} onClearVerseHighlights={clearVerseHighlights} /> )} goDay(ctxInfo.progress.id, ctxInfo.dayIndex + d)} onToggleChapter={(key) => toggleChapterRead(ctxInfo.progress.id, ctxInfo.dayIndex, key, ctxInfo.keys, ctxInfo.plan.days)} onGoChapter={(r) => { goToRef(r); setSheet(null); }} onTranslation={setReaderTranslation} onToggleDay={() => toggleDay(ctxInfo.progress.id, ctxInfo.dayIndex, ctxInfo.plan.days)} onSwitchPlan={(o) => { setSheet(null); openPlanReading(o.progress.id, o.dayIndex); }} onListen={() => setSheet('audio')} onStudy={() => studyReadingPlanDay(ctxInfo.refs, ctxInfo.plan.title)} onLeave={() => { setReaderPlan(null); setSheet(null); }} /> loadBibleIndex(readerTranslation)} bibleMatches={bibleMatches} deferredBibleQuery={deferredBibleQuery} remote={remoteSearch} onJump={jump} /> removeBookmark(e.key, e.color)} onRemoveHighlight={(h) => removeHighlight(h.id)} /> = readerTotalChapters} /> {/* Highlight color picker — appears above text selection */} {selectionPicker && (
80 ? selectionPicker.y - 56 : selectionPicker.y + 28, }} onPointerDown={(e) => e.stopPropagation()} > {HIGHLIGHT_COLORS.map((color) => (
)}
); }