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>
671 lines
34 KiB
React
671 lines
34 KiB
React
// 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>
|
||
);
|
||
}
|