Files
study-bible-project/src/pages/ReaderPage.jsx
T
nmemmertandClaude Sonnet 5.5 7fc5e4b534
Build and Push Docker Image / build-and-push (push) Successful in 4m30s
Match the browser/OS chrome to the theme and clear the status bar
The app now sets theme-color, color-scheme and the page background from the chosen theme,
so the iPad status-bar area no longer shows a light band over a dark theme. Every top bar
leaves room for the status bar via a safe-top class (replacing a rule that padded every
<header>). Install defaults match the paper theme.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 11:24:58 -04:00

543 lines
26 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 '../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(
<mark key={s.id} style={{ backgroundColor: s.color, color: '#2b2620', borderRadius: '2px', cursor: 'pointer' }}
onClick={(e) => { e.stopPropagation(); removeTextHighlight(s.id); }} title="Tap to remove highlight">
{text.slice(s.start, s.end)}
</mark>,
);
} 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 });
}, 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 (
<div className="min-h-screen bg-[var(--r-bg)] text-[var(--r-text)]" style={themeVars(readerTheme)}
onTouchStart={handleTouchStart} onTouchEnd={handleTouchEnd}>
{chromeVisible && (
<TopBar
title={title}
onBack={goHome}
onOpenNavigator={() => openSheet('nav')}
onSearch={() => openSheet('search')}
onBookmarks={() => openSheet('bookmarks')}
bookmarkCount={bookmarkEntries.length}
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)}
/>
)}
<div className={paneOpen ? 'lg:pr-[24rem]' : ''}>
{!readerDrawMode && (
<ReaderText
title={`${readerBook?.name ?? ''} ${readerChapter}`}
translation={READER_TRANSLATIONS.find((t) => t.id === readerTranslation)?.name ?? readerTranslation}
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}
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={() => toggleReaderBookmark(selectedKey)}
onCycleColor={() => cycleBookmarkColor(selectedKey)}
onJump={jump}
onClose={() => setReaderSelectedVerse(null)}
/>
)}
<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}
/>
<SettingsSheet
open={sheet === 'settings'} onClose={closeSheet}
fontSize={readerFontSize} onFontSize={setReaderFontSize}
theme={readerTheme} onTheme={setReaderTheme}
layout={readerLayout} onLayout={setReaderLayout}
wide={readerWideLayout} onWide={setReaderWideLayout}
/>
<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}
/>
<BookmarksSheet
open={sheet === 'bookmarks'} onClose={closeSheet}
entries={bookmarkEntries} onJump={jump} onRemove={toggleReaderBookmark}
/>
<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"
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,
});
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>
);
}