Files
study-bible-project/src/pages/ReaderPage.jsx
T
nmemmertandClaude Sonnet 5.5 e5812fd4ee Reader: resizable side panel
Drag the left edge of the verse side panel to resize it (arrow keys too,
Shift for bigger steps; double-click resets). The text reflows to make room,
and the width is remembered on this device. Reuses the Study page's resize
handle; the hook now takes the CSS variable to drive.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:37:50 -04:00

671 lines
34 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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(
<mark key={s.id} style={{ backgroundColor: s.color, color: '#2b2620', borderRadius: '2px', cursor: 'pointer' }}
onClick={(e) => { e.stopPropagation(); removeHighlight(s.id); }}
title={`${colorTitle(s.color, readerColorLabels ?? {})} · tap to remove`}>
{text.slice(s.start, s.end)}
</mark>,
);
} else if (s.type === 'diff') {
parts.push(<span key={`diff${s.start}`} className="diff-hl">{text.slice(s.start, s.end)}</span>);
} else {
parts.push(<mark key={`srch${s.start}`} className="rounded bg-[var(--r-hl)] px-0.5 text-[var(--r-text)]">{text.slice(s.start, s.end)}</mark>);
}
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 (
<div ref={rootRef} className="min-h-dvh bg-[var(--r-bg)] text-[var(--r-text)]" style={{ ...themeVars(readerTheme), ...(pane.width ? { '--reader-pane': `${pane.width}px` } : {}) }}
onTouchStart={handleTouchStart} onTouchEnd={handleTouchEnd}>
{chromeVisible && (
<TopBar
title={title}
onBack={goHome}
onOpenNavigator={() => 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 && (
<button type="button" onClick={() => setChromeVisible(true)} aria-label="Show controls"
className="fixed right-3 top-[calc(0.75rem+env(safe-area-inset-top,0px))] z-30 rounded-full border border-[var(--r-line)] bg-[var(--r-bar)] p-2 text-[var(--r-sub)] opacity-70 hover:opacity-100">
<FocusIcon />
</button>
)}
{chromeVisible && (
<PlanStrip
info={stripInfo} translation={readerTranslation} inPlan={!!ctxInfo} notice={autoMarkedNotice}
onOpen={() => openSheet('plan')}
onJoin={() => setReaderPlan({ progressId: stripInfo.progress.id, dayIndex: stripInfo.dayIndex })}
onDot={goToRef}
onBackToDay={() => goDay(ctxInfo.progress.id, ctxInfo.dayIndex)}
/>
)}
{!online && (
<p role="status" className="border-b border-[var(--r-line)] bg-[var(--r-bar)] px-4 py-1.5 text-center font-sans text-xs text-[var(--r-sub)]">
You’re offline. Chapters you’ve read or saved still open; save a whole book from “Go to” while you’re connected.
</p>
)}
<div className={paneOpen ? 'lg:pr-[var(--reader-pane,24rem)]' : ''}>
{!readerDrawMode && (
<ReaderText
title={`${readerBook?.name ?? ''} ${readerChapter}`}
translation={READER_TRANSLATIONS.find((t) => 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={(
<PlanFooter
info={ctxInfo} label={label}
onNext={() => { 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 && (
<div className="mx-auto flex max-w-7xl flex-col items-start gap-4 px-4 py-4 pb-8 sm:flex-row">
<div className="w-full rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-4 font-serif text-sm leading-relaxed sm:sticky sm:top-16 sm:max-h-[80vh] sm:w-72 sm:shrink-0 sm:overflow-y-auto"
onPointerUp={handleSelectionEnd}>
<div className="mb-3">
<span className="font-medium">{readerBook?.name} {readerChapter}</span>
<span className="ml-2 text-xs text-[var(--r-sub)]">{readerTranslation}</span>
<p className="mt-1 font-sans text-xs text-[var(--r-sub)]">Select text to highlight</p>
</div>
{!readerLoading && readerVerses.map((v) => (
<p key={v.number} className="mb-2" data-verse-num={v.number}>
<span className="font-medium text-[var(--r-sub)]">{v.number}.</span> {renderVerseText(v.number, v.text, '')}
</p>
))}
</div>
<div className="min-w-0 flex-1">
<DrawCanvas
strokes={readerPageInkStrokes}
onStrokesChange={(s) => updateReaderPageInk(readerBookAbbrev, readerChapter, s)}
onDone={() => setReaderDrawMode(false)}
/>
</div>
</div>
)}
</div>
{chromeVisible && !readerDrawMode && (
<BottomBar
onPrev={readerGoToPreviousChapter} onNext={readerGoToNextChapter}
prevDisabled={readerChapter <= 1} nextDisabled={readerChapter >= 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 && (
<VerseSheet
verse={selectedVerse}
book={readerBookAbbrev}
bookName={readerBook?.name ?? readerBookAbbrev}
chapter={readerChapter}
translation={readerTranslation}
words={readerInterlinear?.[String(readerChapter)]?.[String(selectedVerse.number)]}
crossRefs={readerCrossRefs?.[selectedVerse.number]}
crossRefsLoading={readerCrossRefsLoading}
onLoadCrossRefs={() => { 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.
<div {...pane.handleProps(paneRef)}
className="group absolute -left-1.5 top-0 z-20 hidden h-full w-3 cursor-col-resize touch-none select-none focus:outline-none lg:block">
<span className="mx-auto block h-full w-px bg-transparent transition group-hover:w-0.5 group-hover:bg-[var(--r-accent)] group-focus-visible:w-0.5 group-focus-visible:bg-[var(--r-accent)]" />
</div>
)}
verseHighlights={verseHighlights} colorLabels={readerColorLabels ?? {}}
onHighlightVerse={highlightVerse} onClearVerseHighlights={clearVerseHighlights}
/>
)}
<PlanSheet
open={sheet === 'plan'} onClose={closeSheet}
info={ctxInfo} translation={readerTranslation} currentKey={chapterKey} streak={computeStreak(readingActivity)}
otherPlans={otherPlans} studyBusy={studyBuildBusy}
onDay={(d) => 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); }}
/>
<NavigatorSheet
open={sheet === 'nav'} onClose={closeSheet}
bookAbbrev={readerBookAbbrev} chapter={readerChapter}
translation={readerTranslation} onTranslation={setReaderTranslation}
translationNote={ctxInfo ? `Reading “${ctxInfo.plan.title}”: this sets the plan’s translation.` : ''}
onGo={goTo} offline={offline}
/>
<SettingsSheet
open={sheet === 'settings'} onClose={closeSheet}
fontSize={readerFontSize} onFontSize={setReaderFontSize}
theme={readerTheme} onTheme={setReaderTheme}
layout={readerLayout} onLayout={setReaderLayout}
wide={readerWideLayout} onWide={setReaderWideLayout}
font={readerFont} onFont={setReaderFont}
spacing={readerSpacing} onSpacing={setReaderSpacing}
verseNumbers={readerVerseNumbers} onVerseNumbers={setReaderVerseNumbers}
/>
<CompareSheet
open={sheet === 'compare'} onClose={closeSheet}
translation={readerTranslation} parallel={parallelId} onParallel={setReaderParallel}
showDiff={readerShowDiff} onShowDiff={setReaderShowDiff}
/>
<SearchSheet
open={sheet === 'search'} onClose={closeSheet}
query={readerSearch} onQuery={setReaderSearch}
scope={readerSearchScope} onScope={setReaderSearchScope}
verses={readerVerses} bookName={readerBook?.name} chapter={readerChapter} currentBook={readerBookAbbrev}
translation={readerTranslation} isLocal={localSearch}
indexStatus={indexStatus} onLoadIndex={() => loadBibleIndex(readerTranslation)}
bibleMatches={bibleMatches} deferredBibleQuery={deferredBibleQuery} remote={remoteSearch}
onJump={jump}
/>
<MarksSheet
open={sheet === 'bookmarks' || sheet === 'highlights'} onClose={closeSheet}
initialTab={sheet === 'bookmarks' ? 'bookmarks' : 'highlights'}
bookmarks={bookmarkEntries} highlights={highlightEntries}
colorLabels={readerColorLabels ?? {}} onSetLabel={setReaderColorLabel}
onJump={jump}
onRemoveBookmark={(e) => removeBookmark(e.key, e.color)}
onRemoveHighlight={(h) => removeHighlight(h.id)}
/>
<AudioSheet
open={sheet === 'audio'} onClose={closeSheet}
translation={readerTranslation} bookName={readerBook?.name ?? ''} chapter={readerChapter}
audio={audio} narrator={audioNarrator} onNarrator={setAudioNarrator}
onNext={readerGoToNextChapter} nextDisabled={readerChapter >= readerTotalChapters}
/>
{/* Highlight color picker — appears above text selection */}
{selectionPicker && (
<div
className="fixed z-[60] flex items-center gap-1.5 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] px-2.5 py-2 shadow-2xl"
style={{
left: Math.max(8, Math.min(selectionPicker.x - 105, window.innerWidth - 220)),
top: selectionPicker.y > 80 ? selectionPicker.y - 56 : selectionPicker.y + 28,
}}
onPointerDown={(e) => e.stopPropagation()}
>
{HIGHLIGHT_COLORS.map((color) => (
<button
key={color}
aria-label={`Highlight ${colorTitle(color, readerColorLabels ?? {}).toLowerCase()}`}
title={colorTitle(color, readerColorLabels ?? {})}
className="h-7 w-7 rounded-full border-2 border-white shadow-sm transition-transform hover:scale-110 active:scale-95"
style={{ backgroundColor: color }}
onPointerDown={(e) => {
e.preventDefault();
addTextHighlight({
book: readerBookAbbrev, chapter: readerChapter, verse: selectionPicker.verse,
startOffset: selectionPicker.startOffset, endOffset: selectionPicker.endOffset, color,
text: selectionPicker.text,
});
window.getSelection()?.removeAllRanges();
setSelectionPicker(null);
}}
/>
))}
<button
aria-label="Cancel"
className="ml-1 flex h-7 w-7 items-center justify-center rounded-full bg-black/5 text-xs text-[var(--r-sub)] hover:bg-black/10"
onPointerDown={(e) => { e.preventDefault(); window.getSelection()?.removeAllRanges(); setSelectionPicker(null); }}
>
✕
</button>
</div>
)}
</div>
);
}