Reader: show how much another translation differs from the one being read

- 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>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-01 14:36:46 -04:00
1 parent 7101be393e
commit aa27a5bb18
10 files changed
+296 -9

No files matched your search

+12 -2
View File
@@ -47,6 +47,7 @@ export default function ReaderPage() {
readerSpacing, setReaderSpacing,
readerVerseNumbers, setReaderVerseNumbers,
readerParallel, setReaderParallel,
readerShowDiff, setReaderShowDiff,
readerBookmarks,
readerCrossRefs, setReaderCrossRefs,
readerCrossRefsLoading,
@@ -302,8 +303,9 @@ export default function ReaderPage() {
// 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) {
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 });
@@ -318,6 +320,11 @@ export default function ReaderPage() {
}
}
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;
@@ -332,6 +339,8 @@ export default function ReaderPage() {
{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>);
}
@@ -429,7 +438,7 @@ export default function ReaderPage() {
title={`${readerBook?.name ?? ''} ${readerChapter}`}
translation={READER_TRANSLATIONS.find((t) => t.id === readerTranslation)?.name ?? readerTranslation}
translationId={readerTranslation}
font={readerFont} spacing={readerSpacing} showVerseNumbers={readerVerseNumbers}
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}
@@ -550,6 +559,7 @@ export default function ReaderPage() {
spacing={readerSpacing} onSpacing={setReaderSpacing}
verseNumbers={readerVerseNumbers} onVerseNumbers={setReaderVerseNumbers}
translation={readerTranslation} parallel={parallelId} onParallel={setReaderParallel}
showDiff={readerShowDiff} onShowDiff={setReaderShowDiff}
/>
<SearchSheet
open={sheet === 'search'} onClose={closeSheet}