- Word-level comparison (ignores case and punctuation): differing words are
marked in both columns of the side-by-side view
- Chapter summary ("NET is 18% different from BSB · 37 of 39 verses differ")
with links to the most different verses, and a percentage on each verse
- Compare tab on a verse shows each translation's difference from the one
being read, with the differing words marked
- "Highlight differences" setting; your own highlights and search matches
always win where they overlap a difference mark
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
572 lines
25 KiB
JavaScript
572 lines
25 KiB
JavaScript
// State, data loading, and helpers for the read-only Bible reader page.
|
|
// Audio playback stays in App because the study page's book player shares
|
|
// the same <audio> element.
|
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { bookOptions, CHAPTER_COUNTS, parseBibleChapter } from '../utils/bibleUtils.jsx';
|
|
import { useReaderMarksSync } from './useReaderMarksSync.js';
|
|
import { usePrefsSync } from './usePrefsSync.js';
|
|
import { COMMENTARIES } from './useVerseStudy.js';
|
|
import { READER_TRANSLATIONS, fetchChapter } from '../utils/bibleText.js';
|
|
import { mergeMarks, normalizeMarks } from '../utils/readerMarks.js';
|
|
import { BIBLE_IDB_TTL, getBibleIndexFromIDB, saveBibleIndexToIDB } from '../utils/bibleIndexCache.js';
|
|
|
|
export { READER_TRANSLATIONS };
|
|
|
|
// A per-device choice kept in localStorage: useStoredChoice('reader-font', 'serif', ['serif', 'sans']).
|
|
function useStoredChoice(key, fallback, allowed) {
|
|
const [value, setValue] = useState(() => {
|
|
try {
|
|
const saved = localStorage.getItem(key);
|
|
return saved !== null && allowed.includes(saved) ? saved : fallback;
|
|
} catch { return fallback; }
|
|
});
|
|
const set = (next) => {
|
|
setValue(next);
|
|
try { localStorage.setItem(key, next); } catch {}
|
|
};
|
|
return [value, set];
|
|
}
|
|
|
|
export const READER_FONTS = ['serif', 'sans', 'easy'];
|
|
export const READER_SPACINGS = ['snug', 'normal', 'airy'];
|
|
|
|
const BOOKMARK_COLORS = ['#fef08a', '#bbf7d0', '#bfdbfe', '#fecaca', '#e9d5ff'];
|
|
|
|
// `translationOverride` lets a reading plan pin the Bible it is read in while it is
|
|
// open; otherwise the reader's own (remembered) translation is used.
|
|
export function useBibleReader(currentPage, translationOverride = null) {
|
|
// Where you left off (saved on this device, and synced to the account)
|
|
const [savedPosition] = useState(() => {
|
|
try {
|
|
const p = JSON.parse(localStorage.getItem('reader-position') || 'null');
|
|
return p && CHAPTER_COUNTS[p.book] && Number.isInteger(p.chapter) && p.chapter >= 1 && p.chapter <= CHAPTER_COUNTS[p.book] ? p : null;
|
|
} catch { return null; }
|
|
});
|
|
const [readerBookAbbrev, setReaderBookAbbrev] = useState(savedPosition?.book ?? bookOptions[0].abbrev);
|
|
const [readerChapter, setReaderChapter] = useState(savedPosition?.chapter ?? 1);
|
|
const [readerCommentary, setReaderCommentaryState] = useState(() => {
|
|
try {
|
|
const saved = localStorage.getItem('reader-commentary');
|
|
return COMMENTARIES.some((c) => c.id === saved) ? saved : COMMENTARIES[0].id;
|
|
} catch { return COMMENTARIES[0].id; }
|
|
});
|
|
useEffect(() => {
|
|
try { localStorage.setItem('reader-position', JSON.stringify({ book: readerBookAbbrev, chapter: readerChapter })); } catch {}
|
|
}, [readerBookAbbrev, readerChapter]);
|
|
const pageRef = useRef(currentPage);
|
|
pageRef.current = currentPage;
|
|
const [baseTranslation, setReaderTranslationState] = useState(() => {
|
|
try {
|
|
const saved = localStorage.getItem('reader-translation');
|
|
return READER_TRANSLATIONS.some((t) => t.id === saved) ? saved : 'BSB';
|
|
} catch { return 'BSB'; }
|
|
});
|
|
const readerTranslation = translationOverride ?? baseTranslation;
|
|
const [readerCopyright, setReaderCopyright] = useState('');
|
|
const [readerTheme, setReaderThemeState] = useState(() => {
|
|
try {
|
|
const saved = localStorage.getItem('reader-theme');
|
|
return ['paper', 'sepia', 'dark'].includes(saved) ? saved : 'paper';
|
|
} catch { return 'paper'; }
|
|
});
|
|
// 'flow' = verses run together like a printed page; 'verses' = one verse per line
|
|
const [readerLayout, setReaderLayoutState] = useState(() => {
|
|
try { return localStorage.getItem('reader-layout') === 'verses' ? 'verses' : 'flow'; } catch { return 'flow'; }
|
|
});
|
|
const [readerVerses, setReaderVerses] = useState([]);
|
|
const [readerTotalChapters, setReaderTotalChapters] = useState(1);
|
|
const [readerLoading, setReaderLoading] = useState(false);
|
|
const [readerError, setReaderError] = useState('');
|
|
const [readerInterlinear, setReaderInterlinear] = useState(null); // full book interlinear
|
|
const [readerInterlinearLoading, setReaderInterlinearLoading] = useState(false);
|
|
const [readerSelectedVerse, setReaderSelectedVerse] = useState(null); // verse number with open interlinear panel
|
|
const _readerInterlinearCacheRef = useRef({});
|
|
// Text size and reading width are per-device (a phone and a tablet want different ones)
|
|
const [readerFontSize, setReaderFontSizeState] = useState(() => {
|
|
try {
|
|
const n = Number(localStorage.getItem('reader-font-size'));
|
|
return [0.875, 1, 1.125, 1.25, 1.5].includes(n) ? n : 1;
|
|
} catch { return 1; }
|
|
}); // em multiplier: 0.875 | 1 | 1.125 | 1.25 | 1.5
|
|
const setReaderFontSize = (n) => {
|
|
setReaderFontSizeState(n);
|
|
try { localStorage.setItem('reader-font-size', String(n)); } catch {}
|
|
};
|
|
const [readerWide, setReaderWideState] = useState(() => {
|
|
try { return localStorage.getItem('reader-wide') === '1'; } catch { return false; }
|
|
});
|
|
const setReaderWide = (on) => {
|
|
setReaderWideState(!!on);
|
|
try { localStorage.setItem('reader-wide', on ? '1' : '0'); } catch {}
|
|
};
|
|
// Typography and a second Bible shown beside the first — all per-device, like text size.
|
|
const [readerFont, setReaderFont] = useStoredChoice('reader-font', 'serif', READER_FONTS);
|
|
const [readerSpacing, setReaderSpacing] = useStoredChoice('reader-spacing', 'normal', READER_SPACINGS);
|
|
const [readerVerseNumbers, setReaderVerseNumbersRaw] = useStoredChoice('reader-verse-numbers', 'on', ['on', 'off']);
|
|
const [readerShowDiff, setReaderShowDiffRaw] = useStoredChoice('reader-show-diff', 'on', ['on', 'off']);
|
|
const [readerParallel, setReaderParallel] = useStoredChoice('reader-parallel', '', ['', ...READER_TRANSLATIONS.map((t) => t.id)]);
|
|
// The page the app opens to, and where every Home button goes: 'studies' | 'reader' | 'plans'
|
|
const [startPagePref, setStartPagePrefState] = useState(() => {
|
|
try {
|
|
const saved = localStorage.getItem('start-page');
|
|
return ['studies', 'reader', 'plans'].includes(saved) ? saved : 'studies';
|
|
} catch { return 'studies'; }
|
|
});
|
|
const setStartPagePref = (id) => {
|
|
setStartPagePrefState(id);
|
|
try { localStorage.setItem('start-page', id); } catch {}
|
|
};
|
|
// Which Bible a newly started reading plan begins in: 'same' = whatever the default Bible is
|
|
const [planTranslationPref, setPlanTranslationPrefState] = useState(() => {
|
|
try {
|
|
const saved = localStorage.getItem('plan-translation');
|
|
return saved === 'same' || READER_TRANSLATIONS.some((t) => t.id === saved) ? saved : 'same';
|
|
} catch { return 'same'; }
|
|
});
|
|
const setPlanTranslationPref = (id) => {
|
|
setPlanTranslationPrefState(id);
|
|
try { localStorage.setItem('plan-translation', id); } catch {}
|
|
};
|
|
const [readerBookmarks, setReaderBookmarks] = useState(() => {
|
|
try { return JSON.parse(localStorage.getItem('reader-bookmarks') || '{}'); } catch { return {}; }
|
|
});
|
|
const [readerBookmarksPanelOpen, setReaderBookmarksPanelOpen] = useState(false);
|
|
const [readerJumpVerse, setReaderJumpVerse] = useState(null); // verse number to scroll to + flash after a jump
|
|
const [readerCrossRefs, setReaderCrossRefs] = useState(null);
|
|
const [readerCrossRefsLoading, setReaderCrossRefsLoading] = useState(false);
|
|
const [readerShowCrossRefs, setReaderShowCrossRefs] = useState(false);
|
|
const _readerCrossRefCacheRef = useRef({});
|
|
const [readerSearch, setReaderSearch] = useState('');
|
|
const [readerSearchActive, setReaderSearchActive] = useState(false);
|
|
const [readerSearchScope, setReaderSearchScope] = useState('chapter'); // 'chapter' | 'bible'
|
|
const _bibleIndexCacheRef = useRef({}); // translation -> flat verse array
|
|
const [readerInkByPage, setReaderInkByPage] = useState(() => {
|
|
try { return JSON.parse(localStorage.getItem('readerInkByPage') ?? '{}'); }
|
|
catch { return {}; }
|
|
});
|
|
const [readerTextHighlights, setReaderTextHighlights] = useState(() => {
|
|
try {
|
|
const val = JSON.parse(localStorage.getItem('readerTextHighlights') || '[]');
|
|
return Array.isArray(val) ? val : [];
|
|
} catch { return []; }
|
|
});
|
|
// What each highlight colour means to this reader ({ '#fef08a': 'Promises' }); follows the account.
|
|
const [readerColorLabels, setReaderColorLabelsState] = useState(() => {
|
|
try {
|
|
const val = JSON.parse(localStorage.getItem('reader-color-labels') || '{}');
|
|
return val && typeof val === 'object' && !Array.isArray(val) ? val : {};
|
|
} catch { return {}; }
|
|
});
|
|
const setReaderColorLabel = (color, label) => {
|
|
setReaderColorLabelsState((prev) => {
|
|
const next = { ...prev };
|
|
if (label.trim()) next[color] = label.slice(0, 30); else delete next[color];
|
|
try { localStorage.setItem('reader-color-labels', JSON.stringify(next)); } catch {}
|
|
return next;
|
|
});
|
|
};
|
|
// Debounce timer ref so rapid stroke updates don't flood the server
|
|
const inkSaveTimerRef = useRef({});
|
|
|
|
// The HelloAO API has no search endpoint, so whole-Bible search for the free
|
|
// translations (BSB, NET) fetches the entire translation once (~7MB) and searches
|
|
// an in-memory flat verse index client-side. Cached per translation for the rest of
|
|
// the visit, and in IndexedDB for a while. Licensed translations use the server
|
|
// search proxy instead (see useBibleSearch).
|
|
const [bibleIndexState, setBibleIndexState] = useState({ translation: null, status: 'idle' });
|
|
const bibleIndexStatusFor = (translation) => {
|
|
if (_bibleIndexCacheRef.current[translation]) return 'ready';
|
|
return bibleIndexState.translation === translation ? bibleIndexState.status : 'idle';
|
|
};
|
|
|
|
const loadBibleIndex = async (translation = 'BSB') => {
|
|
const set = (status) => setBibleIndexState({ translation, status });
|
|
if (_bibleIndexCacheRef.current[translation]) { set('ready'); return; }
|
|
const source = READER_TRANSLATIONS.find((t) => t.id === translation);
|
|
if (!source || source.licensed) { set('error'); return; }
|
|
set('loading');
|
|
try {
|
|
// Check IndexedDB cache before hitting the network (~7 MB fetch)
|
|
const cached = await getBibleIndexFromIDB(translation);
|
|
if (cached?.flat && Date.now() - cached.cachedAt < BIBLE_IDB_TTL) {
|
|
_bibleIndexCacheRef.current[translation] = cached.flat;
|
|
set('ready');
|
|
return;
|
|
}
|
|
|
|
const res = await fetch(`https://bible.helloao.org/api/${source.helloao ?? source.id}/complete.json`);
|
|
if (!res.ok) throw new Error('Unable to load the full Bible for search.');
|
|
const data = await res.json();
|
|
const flat = [];
|
|
for (const book of data.books ?? []) {
|
|
for (const ch of book.chapters ?? []) {
|
|
const verses = parseBibleChapter({ chapter: ch.chapter });
|
|
for (const v of verses) {
|
|
if (v.text) {
|
|
flat.push({ bookAbbrev: book.id, bookName: book.commonName || book.name, chapter: ch.chapter.number, verse: v.number, text: v.text });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
_bibleIndexCacheRef.current[translation] = flat;
|
|
set('ready');
|
|
// Persist for future visits — non-blocking
|
|
saveBibleIndexToIDB(translation, flat);
|
|
} catch {
|
|
set('error');
|
|
}
|
|
};
|
|
|
|
const setReaderTheme = (id) => {
|
|
setReaderThemeState(id);
|
|
try { localStorage.setItem('reader-theme', id); } catch {}
|
|
};
|
|
|
|
const setReaderLayout = (id) => {
|
|
setReaderLayoutState(id);
|
|
try { localStorage.setItem('reader-layout', id); } catch {}
|
|
};
|
|
|
|
const setReaderCommentary = (id) => {
|
|
setReaderCommentaryState(id);
|
|
try { localStorage.setItem('reader-commentary', id); } catch {}
|
|
};
|
|
|
|
const setReaderTranslation = (id) => {
|
|
setReaderTranslationState(id);
|
|
try { localStorage.setItem('reader-translation', id); } catch {}
|
|
};
|
|
|
|
// BSB comes straight from HelloAO; licensed translations (NLT/CSB/NASB) go
|
|
// through the server's api.bible proxy so the API key never reaches the browser.
|
|
// The most recent request wins: flipping chapters quickly (or changing translation) must never let a
|
|
// slow earlier response replace the page you are now on.
|
|
const chapterRequestRef = useRef(0);
|
|
const loadReaderChapter = async (bookAbbrev, chapterNumber, translation = readerTranslation) => {
|
|
const requestId = ++chapterRequestRef.current;
|
|
setReaderLoading(true);
|
|
setReaderError('');
|
|
try {
|
|
const { verses, copyright, totalChapters } = await fetchChapter(translation, bookAbbrev, chapterNumber);
|
|
if (requestId !== chapterRequestRef.current) return;
|
|
setReaderVerses(verses);
|
|
setReaderCopyright(copyright);
|
|
setReaderTotalChapters(totalChapters);
|
|
} catch (err) {
|
|
if (requestId !== chapterRequestRef.current) return;
|
|
setReaderError(err.message || 'Failed to load chapter.');
|
|
setReaderVerses([]);
|
|
} finally {
|
|
if (requestId === chapterRequestRef.current) setReaderLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (currentPage !== 'reader') return;
|
|
loadReaderChapter(readerBookAbbrev, readerChapter, readerTranslation);
|
|
}, [currentPage, readerBookAbbrev, readerChapter, readerTranslation]);
|
|
|
|
// After navigating to a verse (from the bookmarks panel or a whole-Bible search
|
|
// result), scroll to it and briefly flash its background so it's easy to spot.
|
|
useEffect(() => {
|
|
if (readerJumpVerse == null || readerLoading) return;
|
|
const el = document.getElementById(`reader-verse-${readerJumpVerse}`);
|
|
if (!el) return;
|
|
el.scrollIntoView({ behavior: window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth', block: 'center' });
|
|
const prevTransition = el.style.transition;
|
|
const prevBackground = el.style.backgroundColor;
|
|
el.style.transition = 'background-color 0.4s';
|
|
el.style.backgroundColor = '#fef08a';
|
|
const t = window.setTimeout(() => {
|
|
el.style.backgroundColor = prevBackground;
|
|
window.setTimeout(() => { el.style.transition = prevTransition; }, 400);
|
|
}, 1200);
|
|
setReaderJumpVerse(null);
|
|
return () => window.clearTimeout(t);
|
|
}, [readerVerses, readerLoading, readerJumpVerse]);
|
|
|
|
// Navigates the reader to a specific verse (bookmarks panel, search results) and
|
|
// scrolls/flashes it once that chapter's verses have loaded.
|
|
const jumpToReaderVerse = (bookAbbrev, chapter, verseNumber) => {
|
|
setReaderBookAbbrev(bookAbbrev);
|
|
setReaderChapter(chapter);
|
|
setReaderSelectedVerse(null);
|
|
setReaderInterlinear(null);
|
|
setReaderCrossRefs(null);
|
|
setReaderSearch('');
|
|
setReaderSearchActive(false);
|
|
setReaderBookmarksPanelOpen(false);
|
|
setReaderJumpVerse(verseNumber);
|
|
};
|
|
|
|
const readerGoToPreviousChapter = () => {
|
|
if (readerChapter > 1) { setReaderChapter((c) => c - 1); setReaderSelectedVerse(null); setReaderCrossRefs(null); setReaderSearch(''); setReaderSearchActive(false); }
|
|
};
|
|
|
|
const readerGoToNextChapter = () => {
|
|
if (readerChapter < readerTotalChapters) { setReaderChapter((c) => c + 1); setReaderSelectedVerse(null); setReaderCrossRefs(null); setReaderSearch(''); setReaderSearchActive(false); }
|
|
};
|
|
|
|
const handleReaderBookChange = (abbrev) => {
|
|
setReaderBookAbbrev(abbrev);
|
|
setReaderChapter(1);
|
|
setReaderSelectedVerse(null);
|
|
setReaderInterlinear(null);
|
|
setReaderCrossRefs(null);
|
|
setReaderSearch('');
|
|
setReaderSearchActive(false);
|
|
};
|
|
|
|
const loadReaderInterlinear = async (bookAbbrev) => {
|
|
if (_readerInterlinearCacheRef.current[bookAbbrev]) {
|
|
setReaderInterlinear(_readerInterlinearCacheRef.current[bookAbbrev]);
|
|
return;
|
|
}
|
|
setReaderInterlinearLoading(true);
|
|
try {
|
|
const res = await fetch(`/interlinear/${bookAbbrev}.json`);
|
|
if (!res.ok) throw new Error('no interlinear');
|
|
const data = await res.json();
|
|
_readerInterlinearCacheRef.current[bookAbbrev] = data;
|
|
setReaderInterlinear(data);
|
|
} catch {
|
|
setReaderInterlinear(null);
|
|
} finally {
|
|
setReaderInterlinearLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (currentPage !== 'reader') return;
|
|
loadReaderInterlinear(readerBookAbbrev);
|
|
setReaderSelectedVerse(null);
|
|
}, [currentPage, readerBookAbbrev]);
|
|
|
|
useEffect(() => {
|
|
if (currentPage !== 'reader' || !readerShowCrossRefs) return;
|
|
loadReaderCrossRefs(readerBookAbbrev, readerChapter);
|
|
}, [currentPage, readerBookAbbrev, readerChapter, readerShowCrossRefs]);
|
|
|
|
// Bookmarks, ink, highlights, cross-refs
|
|
const toggleReaderBookmark = (verseKey) => {
|
|
setReaderBookmarks((prev) => {
|
|
const next = { ...prev };
|
|
if (next[verseKey]) {
|
|
delete next[verseKey];
|
|
} else {
|
|
const usedColors = Object.values(next);
|
|
const color = BOOKMARK_COLORS.find((c) => !usedColors.includes(c)) ?? BOOKMARK_COLORS[0];
|
|
next[verseKey] = color;
|
|
}
|
|
try { localStorage.setItem('reader-bookmarks', JSON.stringify(next)); } catch {}
|
|
return next;
|
|
});
|
|
};
|
|
|
|
// Puts a removed bookmark back with its colour (used by "Undo").
|
|
const restoreReaderBookmark = (verseKey, color) => {
|
|
setReaderBookmarks((prev) => {
|
|
if (prev[verseKey]) return prev;
|
|
const next = { ...prev, [verseKey]: color };
|
|
try { localStorage.setItem('reader-bookmarks', JSON.stringify(next)); } catch {}
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const updateReaderPageInk = useCallback((bookAbbrev, chapter, strokes) => {
|
|
const key = `${bookAbbrev}_${chapter}`;
|
|
setReaderInkByPage((prev) => {
|
|
const next = { ...prev, [key]: strokes };
|
|
try { localStorage.setItem('readerInkByPage', JSON.stringify(next)); } catch {}
|
|
return next;
|
|
});
|
|
// Debounce server sync — wait 1 s after last stroke before sending
|
|
clearTimeout(inkSaveTimerRef.current[key]);
|
|
inkSaveTimerRef.current[key] = setTimeout(() => {
|
|
fetch(`/api/reader/ink/${encodeURIComponent(bookAbbrev)}/${encodeURIComponent(chapter)}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ strokes }),
|
|
}).catch(() => {}); // silent — localStorage already saved locally
|
|
}, 1000);
|
|
}, []);
|
|
|
|
const cycleBookmarkColor = (verseKey) => {
|
|
setReaderBookmarks((prev) => {
|
|
const cur = prev[verseKey];
|
|
if (!cur) return prev;
|
|
const idx = BOOKMARK_COLORS.indexOf(cur);
|
|
const next = { ...prev, [verseKey]: BOOKMARK_COLORS[(idx + 1) % BOOKMARK_COLORS.length] };
|
|
try { localStorage.setItem('reader-bookmarks', JSON.stringify(next)); } catch {}
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const addTextHighlight = (highlight) => {
|
|
const id = (typeof crypto !== 'undefined' && crypto.randomUUID) ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`;
|
|
setReaderTextHighlights(prev => {
|
|
const next = [...prev, { id, ...highlight }];
|
|
try { localStorage.setItem('readerTextHighlights', JSON.stringify(next)); } catch {}
|
|
return next;
|
|
});
|
|
};
|
|
|
|
// Puts a removed highlight back exactly as it was (used by "Undo").
|
|
const restoreTextHighlight = (highlight) => {
|
|
setReaderTextHighlights((prev) => {
|
|
if (prev.some((h) => h.id === highlight.id)) return prev;
|
|
const next = [...prev, highlight];
|
|
try { localStorage.setItem('readerTextHighlights', JSON.stringify(next)); } catch {}
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const removeTextHighlight = (id) => {
|
|
setReaderTextHighlights(prev => {
|
|
const next = prev.filter(h => h.id !== id);
|
|
try { localStorage.setItem('readerTextHighlights', JSON.stringify(next)); } catch {}
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const loadReaderCrossRefs = async (bookAbbrev, chapterNumber) => {
|
|
const cacheKey = `${bookAbbrev}/${chapterNumber}`;
|
|
if (_readerCrossRefCacheRef.current[cacheKey]) {
|
|
setReaderCrossRefs(_readerCrossRefCacheRef.current[cacheKey]);
|
|
return;
|
|
}
|
|
setReaderCrossRefsLoading(true);
|
|
try {
|
|
const res = await fetch(`https://bible.helloao.org/api/d/open-cross-ref/${bookAbbrev}/${chapterNumber}.json`);
|
|
if (!res.ok) throw new Error('no cross-refs');
|
|
const data = await res.json();
|
|
const byVerse = {};
|
|
for (const item of data.chapter?.content ?? []) {
|
|
if (item.references?.length) byVerse[item.verse] = item.references;
|
|
}
|
|
_readerCrossRefCacheRef.current[cacheKey] = byVerse;
|
|
setReaderCrossRefs(byVerse);
|
|
} catch {
|
|
setReaderCrossRefs(null);
|
|
} finally {
|
|
setReaderCrossRefsLoading(false);
|
|
}
|
|
};
|
|
|
|
// Bookmarks, highlights, colour names and ink from a backup file are added to what is here (nothing is
|
|
// removed, and where both have something the copy already here wins).
|
|
const importReaderMarks = ({ bookmarks, highlights, colorLabels, ink }) => {
|
|
const merged = mergeMarks(normalizeMarks({ bookmarks, highlights }), normalizeMarks({ bookmarks: readerBookmarks, highlights: readerTextHighlights }));
|
|
setReaderBookmarks(merged.bookmarks);
|
|
setReaderTextHighlights(merged.highlights);
|
|
try {
|
|
localStorage.setItem('reader-bookmarks', JSON.stringify(merged.bookmarks));
|
|
localStorage.setItem('readerTextHighlights', JSON.stringify(merged.highlights));
|
|
} catch {}
|
|
if (colorLabels && Object.keys(colorLabels).length > 0) {
|
|
setReaderColorLabelsState((prev) => {
|
|
const next = { ...colorLabels, ...prev };
|
|
try { localStorage.setItem('reader-color-labels', JSON.stringify(next)); } catch {}
|
|
return next;
|
|
});
|
|
}
|
|
// Ink goes through the normal save so it also reaches the account
|
|
for (const [key, strokes] of Object.entries(ink ?? {})) {
|
|
const [book, chapter] = key.split('_');
|
|
if (!readerInkByPage[key] && book && chapter && Array.isArray(strokes)) updateReaderPageInk(book, chapter, strokes);
|
|
}
|
|
};
|
|
|
|
// Merges reader ink loaded from the server at sign-in; server wins over local.
|
|
const mergeServerInk = useCallback((serverInk) => {
|
|
setReaderInkByPage((local) => {
|
|
const merged = { ...local, ...serverInk };
|
|
try { localStorage.setItem('readerInkByPage', JSON.stringify(merged)); } catch {}
|
|
return merged;
|
|
});
|
|
}, []);
|
|
|
|
// Bookmarks and highlights follow the signed-in user across devices.
|
|
const marksSync = useReaderMarksSync({
|
|
bookmarks: readerBookmarks, highlights: readerTextHighlights,
|
|
setBookmarks: setReaderBookmarks, setHighlights: setReaderTextHighlights,
|
|
});
|
|
|
|
// Theme, default translation, layout, commentary and reading position follow the account too.
|
|
const prefsSync = usePrefsSync({
|
|
values: {
|
|
theme: readerTheme, translation: baseTranslation, layout: readerLayout, commentarySource: readerCommentary,
|
|
planTranslation: planTranslationPref, startPage: startPagePref,
|
|
position: { book: readerBookAbbrev, chapter: readerChapter },
|
|
colorLabels: readerColorLabels,
|
|
},
|
|
apply: (key, v) => {
|
|
if (key === 'theme') { setReaderThemeState(v); try { localStorage.setItem('reader-theme', v); } catch {} }
|
|
else if (key === 'translation') { setReaderTranslationState(v); try { localStorage.setItem('reader-translation', v); } catch {} }
|
|
else if (key === 'layout') { setReaderLayoutState(v); try { localStorage.setItem('reader-layout', v); } catch {} }
|
|
else if (key === 'startPage') { setStartPagePrefState(v); try { localStorage.setItem('start-page', v); } catch {} }
|
|
else if (key === 'planTranslation') { setPlanTranslationPrefState(v); try { localStorage.setItem('plan-translation', v); } catch {} }
|
|
else if (key === 'commentarySource') { setReaderCommentaryState(v); try { localStorage.setItem('reader-commentary', v); } catch {} }
|
|
else if (key === 'colorLabels') { setReaderColorLabelsState(v); try { localStorage.setItem('reader-color-labels', JSON.stringify(v)); } catch {} }
|
|
else if (key === 'position') { setReaderBookAbbrev(v.book); setReaderChapter(v.chapter); setReaderSelectedVerse(null); }
|
|
},
|
|
canApplyPosition: () => pageRef.current !== 'reader',
|
|
});
|
|
|
|
return {
|
|
readerBookAbbrev, setReaderBookAbbrev,
|
|
readerCommentary, setReaderCommentary,
|
|
readerChapter, setReaderChapter,
|
|
readerTranslation, setReaderTranslation,
|
|
readerCopyright,
|
|
readerTheme, setReaderTheme,
|
|
readerLayout, setReaderLayout,
|
|
readerVerses,
|
|
readerTotalChapters,
|
|
readerLoading,
|
|
readerError,
|
|
readerInterlinear,
|
|
readerSelectedVerse, setReaderSelectedVerse,
|
|
readerFontSize, setReaderFontSize,
|
|
readerWide, setReaderWide,
|
|
readerFont, setReaderFont,
|
|
readerSpacing, setReaderSpacing,
|
|
readerVerseNumbers: readerVerseNumbers === 'on', setReaderVerseNumbers: (on) => setReaderVerseNumbersRaw(on ? 'on' : 'off'),
|
|
readerParallel, setReaderParallel,
|
|
readerShowDiff: readerShowDiff === 'on', setReaderShowDiff: (on) => setReaderShowDiffRaw(on ? 'on' : 'off'),
|
|
planTranslationPref, setPlanTranslationPref,
|
|
startPagePref, setStartPagePref,
|
|
readerBookmarks, setReaderBookmarks,
|
|
readerBookmarksPanelOpen, setReaderBookmarksPanelOpen,
|
|
readerCrossRefs, setReaderCrossRefs,
|
|
readerCrossRefsLoading,
|
|
readerShowCrossRefs, setReaderShowCrossRefs,
|
|
readerSearch, setReaderSearch,
|
|
readerSearchActive, setReaderSearchActive,
|
|
readerSearchScope, setReaderSearchScope,
|
|
readerInkByPage,
|
|
updateReaderPageInk,
|
|
mergeServerInk,
|
|
importReaderMarks,
|
|
readerTextHighlights,
|
|
...marksSync,
|
|
...prefsSync,
|
|
addTextHighlight,
|
|
removeTextHighlight,
|
|
restoreTextHighlight,
|
|
readerColorLabels, setReaderColorLabel,
|
|
bibleIndexStatusFor,
|
|
loadBibleIndex,
|
|
_bibleIndexCacheRef,
|
|
loadReaderChapter,
|
|
handleReaderBookChange,
|
|
readerGoToPreviousChapter,
|
|
readerGoToNextChapter,
|
|
jumpToReaderVerse,
|
|
toggleReaderBookmark,
|
|
restoreReaderBookmark,
|
|
cycleBookmarkColor,
|
|
loadReaderCrossRefs,
|
|
};
|
|
}
|