From aa27a5bb18dc6705bc98b7872d55cb9fe2ca70b9 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:36:46 -0400 Subject: [PATCH 1/2] Reader: show how much another translation differs from the one being read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/components/reader/DiffText.jsx | 13 ++++ src/components/reader/ReaderText.jsx | 36 ++++++++- src/components/reader/ReaderText.test.jsx | 53 +++++++++++++ src/components/reader/SettingsSheet.jsx | 8 +- src/components/reader/VerseSheet.jsx | 17 ++++- src/hooks/useBibleReader.js | 2 + src/index.css | 7 ++ src/pages/ReaderPage.jsx | 14 +++- src/utils/textDiff.js | 90 +++++++++++++++++++++++ src/utils/textDiff.test.js | 65 ++++++++++++++++ 10 files changed, 296 insertions(+), 9 deletions(-) create mode 100644 src/components/reader/DiffText.jsx create mode 100644 src/components/reader/ReaderText.test.jsx create mode 100644 src/utils/textDiff.js create mode 100644 src/utils/textDiff.test.js diff --git a/src/components/reader/DiffText.jsx b/src/components/reader/DiffText.jsx new file mode 100644 index 0000000..c8c99a0 --- /dev/null +++ b/src/components/reader/DiffText.jsx @@ -0,0 +1,13 @@ +// Text with the words that differ from the other translation marked. +export function DiffText({ text, ranges = [] }) { + if (!ranges.length) return text; + const parts = []; + let cursor = 0; + ranges.forEach((r, i) => { + if (r.start > cursor) parts.push(text.slice(cursor, r.start)); + parts.push({text.slice(r.start, r.end)}); + cursor = r.end; + }); + if (cursor < text.length) parts.push(text.slice(cursor)); + return <>{parts}; +} diff --git a/src/components/reader/ReaderText.jsx b/src/components/reader/ReaderText.jsx index 1ba43b8..c2ad126 100644 --- a/src/components/reader/ReaderText.jsx +++ b/src/components/reader/ReaderText.jsx @@ -1,6 +1,9 @@ // The page itself: chapter heading, translation notice, and verses set in a // serif face. Tap a verse to open the verse sheet; select text to highlight. +import { useMemo } from 'react'; import { formatCrossRef } from '../../utils/wordStudy.js'; +import { diffChapter, differenceLabel } from '../../utils/textDiff.js'; +import { DiffText } from './DiffText.jsx'; // How each typeface choice is set. "Easy to read" is a sans face with extra letter and word spacing. const FONT_STYLES = { @@ -13,10 +16,18 @@ const LINE_HEIGHTS = { snug: 1.6, normal: 1.85, airy: 2.15 }; // `parallel` ({ translation, name, notice, verses, loading, error }) shows a second Bible beside the first. export default function ReaderText({ title, translation, translationId, notice, verses, loading, error, layout, fontSize, wide, - font = 'serif', spacing = 'normal', showVerseNumbers = true, parallel = null, + font = 'serif', spacing = 'normal', showVerseNumbers = true, parallel = null, showDiff = true, bookmarks, bookKey, chapter, selectedVerse, onSelectVerse, renderVerseText, searchLower, crossRefs, showCrossRefs, onSelectionEnd, footer, }) { + // How much the second Bible's wording differs from the first, over the chapter and verse by verse + const diff = useMemo( + () => (parallel && showDiff && !parallel.loading && !parallel.error && parallel.verses?.length ? diffChapter(verses, parallel.verses) : null), + [parallel, showDiff, verses], + ); + const diffByVerse = useMemo(() => new Map((diff?.verses ?? []).map((d) => [d.number, d])), [diff]); + const jumpToVerse = (n) => document.getElementById(`reader-verse-${n}`)?.scrollIntoView?.({ block: 'center', behavior: window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' }); + const handleTap = (n) => { const sel = window.getSelection(); if (sel && !sel.isCollapsed && sel.toString().trim()) return; // user is selecting text, not tapping @@ -92,6 +103,23 @@ export default function ReaderText({ {!loading && !error && sideBySide && !parallel.error && (
+ {diff && ( +
+

+ {parallel.translation}{diff.similarity === 1 ? ' uses the same words as ' : <> is {differenceLabel(diff.similarity)} from }{translationId} in this chapter + {diff.total > 0 && · {diff.differing} of {diff.total} verses differ} +

+ {diff.mostDifferent.length > 0 && ( +

+ Most different:{' '} + {diff.mostDifferent.map((v, i) => ( + {i > 0 && ', '} ({Math.round((1 - v.similarity) * 100)}%) + ))} +

+ )} +

Marked words aren’t in the other wording.

+
+ )}
{translationId}{parallel.translation}
@@ -99,12 +127,14 @@ export default function ReaderText({

{row.first - ? {num(row.first.number)}{renderVerseText(row.first.number, row.first.text, searchLower)}{chips(row.first)} + ? {num(row.first.number)}{renderVerseText(row.first.number, row.first.text, searchLower, diffByVerse.get(row.number)?.a)}{chips(row.first)} : {num(row.number)}—}

{parallel.translation} - {row.second ? <>{num(row.second.number)}{row.second.text} : parallel.loading ? … : '—'} + {row.second ? <>{num(row.second.number)}{diffByVerse.get(row.number) && !diffByVerse.get(row.number).same && ( + {differenceLabel(diffByVerse.get(row.number).similarity)} + )} : parallel.loading ? … : '—'}

))} diff --git a/src/components/reader/ReaderText.test.jsx b/src/components/reader/ReaderText.test.jsx new file mode 100644 index 0000000..bd2ae5f --- /dev/null +++ b/src/components/reader/ReaderText.test.jsx @@ -0,0 +1,53 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { vi, test, expect } from 'vitest'; +import ReaderText from './ReaderText.jsx'; + +const verses = [ + { number: 1, text: 'In the beginning God created the heavens and the earth.' }, + { number: 2, text: 'Now the earth was formless and void.' }, +]; +const second = [ + { number: 1, text: 'In the beginning God created the heavens and the earth.' }, + { number: 2, text: 'Now the earth was without shape and empty.' }, +]; +const base = { + title: 'Genesis 1', translation: 'Berean Standard Bible', translationId: 'BSB', notice: '', verses, loading: false, error: '', + layout: 'flow', fontSize: 1, wide: false, bookmarks: {}, bookKey: 'GEN', chapter: 1, selectedVerse: null, + onSelectVerse: vi.fn(), renderVerseText: (_n, text) => text, searchLower: '', crossRefs: null, showCrossRefs: false, onSelectionEnd: vi.fn(), +}; +const parallel = (over = {}) => ({ translation: 'NET', name: 'NET Bible', notice: '', verses: second, loading: false, error: '', ...over }); + +test('side by side, it says how different the other Bible is and marks the differing words', () => { + const { container } = render(); + expect(screen.getByText(/is/, { selector: 'p' })).toHaveTextContent(/NET is \d+% different from BSB in this chapter/); + expect(screen.getByText(/1 of 2 verses differ/)).toBeInTheDocument(); + const marked = [...container.querySelectorAll('.diff-hl')].map((n) => n.textContent); + expect(marked).toContain('without shape'); // the other Bible's side + expect(screen.getByRole('button', { name: /verse 2/i })).toBeInTheDocument(); // the most different verse + expect(screen.getByText(/% different/, { selector: 'span.ml-2' })).toBeInTheDocument(); // per-verse figure +}); + +test('identical wording is reported as the same words', () => { + render(); + expect(screen.getByText(/uses the same words as/)).toBeInTheDocument(); +}); + +test('with highlighting off there is no summary and no marks', () => { + const { container } = render(); + expect(screen.queryByText(/different from BSB/)).not.toBeInTheDocument(); + expect(container.querySelectorAll('.diff-hl')).toHaveLength(0); +}); + +test('while the other Bible loads there is no comparison yet, and the first Bible still shows', () => { + render(); + expect(screen.queryByText(/different from BSB/)).not.toBeInTheDocument(); + expect(screen.getAllByText(/In the beginning God created/).length).toBeGreaterThan(0); +}); + +test('the left Bible gets its own differing words passed to the verse renderer', () => { + const renderVerseText = vi.fn((_n, text) => text); + render(); + const call = renderVerseText.mock.calls.find(([n]) => n === 2); + expect(call[3].map((r) => verses[1].text.slice(r.start, r.end))).toEqual(['formless', 'void']); + fireEvent.click(screen.getByRole('button', { name: /verse 2/i })); // jumping must not throw even if the element is absent +}); diff --git a/src/components/reader/SettingsSheet.jsx b/src/components/reader/SettingsSheet.jsx index e17d946..15d86f5 100644 --- a/src/components/reader/SettingsSheet.jsx +++ b/src/components/reader/SettingsSheet.jsx @@ -22,7 +22,7 @@ function Segmented({ options, value, onChange, label }) { export default function SettingsSheet({ open, onClose, fontSize, onFontSize, theme, onTheme, layout, onLayout, wide, onWide, font, onFont, spacing, onSpacing, verseNumbers, onVerseNumbers, - translation, parallel, onParallel, + translation, parallel, onParallel, showDiff, onShowDiff, }) { const row = 'mb-4'; const label = 'mb-1.5 block text-xs text-[var(--r-sub)]'; @@ -68,6 +68,12 @@ export default function SettingsSheet({

Shows both side by side on wider screens, and stacked on a phone.

+ {parallel && ( +
+ Highlight differences from {translation} + +
+ )} ); } diff --git a/src/components/reader/VerseSheet.jsx b/src/components/reader/VerseSheet.jsx index 6b29362..cb43bdc 100644 --- a/src/components/reader/VerseSheet.jsx +++ b/src/components/reader/VerseSheet.jsx @@ -6,6 +6,8 @@ import { CommentaryTab, PeoplePlacesTab } from '../StudyPanel.jsx'; import { BookmarkIcon, CopyIcon } from '../../utils/bibleUtils.jsx'; import { formatCrossRef, speakOriginalWord } from '../../utils/wordStudy.js'; import { READER_TRANSLATIONS, fetchChapter } from '../../utils/bibleText.js'; +import { diffVerses, differenceLabel } from '../../utils/textDiff.js'; +import { DiffText } from './DiffText.jsx'; const TABS = [['commentary', 'Commentary'], ['compare', 'Compare'], ['original', 'Original'], ['refs', 'Cross-refs'], ['people', 'People & places']]; @@ -68,20 +70,29 @@ function CompareTab({ book, chapter, verse, current }) { return () => controller.abort(); }, [book, chapter, verse]); + const mine = rows[current]?.text; return (
{READER_TRANSLATIONS.map((t) => { const row = rows[t.id]; + // How this wording differs from the Bible being read, word by word + const d = row?.text && mine && t.id !== current ? diffVerses(mine, row.text) : null; return (
-

{t.id}{t.id === current ? ' · reading' : ''}

+

+ {t.id}{t.id === current ? ' · reading' : ''} + {d && {differenceLabel(d.similarity)}} +

{!row ?

Loading…

: row.error ?

{row.error}

- :

{row.text}

} + :

}
); })} -

Copyright notices for each translation appear at the foot of the chapter when you read it.

+

+ {mine ? <>Marked words aren’t in the {current} wording. : null} + Copyright notices for each translation appear at the foot of the chapter when you read it. +

); } diff --git a/src/hooks/useBibleReader.js b/src/hooks/useBibleReader.js index 7be2624..b5a7b04 100644 --- a/src/hooks/useBibleReader.js +++ b/src/hooks/useBibleReader.js @@ -103,6 +103,7 @@ export function useBibleReader(currentPage, translationOverride = null) { 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(() => { @@ -532,6 +533,7 @@ export function useBibleReader(currentPage, translationOverride = null) { 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, diff --git a/src/index.css b/src/index.css index 6e5020b..149da11 100644 --- a/src/index.css +++ b/src/index.css @@ -82,3 +82,10 @@ select { transition-duration: 0.01ms !important; } } + +/* Words that differ between two translations (Reader → read beside another Bible) */ +.diff-hl { + background: color-mix(in srgb, var(--r-accent, #a84e27) 20%, transparent); + border-bottom: 2px solid color-mix(in srgb, var(--r-accent, #a84e27) 55%, transparent); + border-radius: 2px; +} diff --git a/src/pages/ReaderPage.jsx b/src/pages/ReaderPage.jsx index 2d06105..3fa14a6 100644 --- a/src/pages/ReaderPage.jsx +++ b/src/pages/ReaderPage.jsx @@ -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)} , ); + } else if (s.type === 'diff') { + parts.push({text.slice(s.start, s.end)}); } else { parts.push({text.slice(s.start, s.end)}); } @@ -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} /> the indexes kept in each (classic DP; verses are short). +function commonWords(a, b) { + const n = a.length; + const m = b.length; + const dp = Array.from({ length: n + 1 }, () => new Uint16Array(m + 1)); + for (let i = n - 1; i >= 0; i -= 1) { + for (let j = m - 1; j >= 0; j -= 1) { + dp[i][j] = a[i].word === b[j].word ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]); + } + } + const inA = new Set(); + const inB = new Set(); + let i = 0; + let j = 0; + while (i < n && j < m) { + if (a[i].word === b[j].word) { inA.add(i); inB.add(j); i += 1; j += 1; } + else if (dp[i + 1][j] >= dp[i][j + 1]) i += 1; + else j += 1; + } + return { inA, inB }; +} + +// Joins neighbouring changed words into one range, so a changed phrase is one highlight, not many. +function ranges(tokens, kept, text) { + const out = []; + tokens.forEach((t, i) => { + if (kept.has(i)) return; + const last = out[out.length - 1]; + if (last && /^\s*$/.test(text.slice(last.end, t.start))) last.end = t.end; + else out.push({ start: t.start, end: t.end }); + }); + return out; +} + +// -> { a: [{ start, end }], b: [{ start, end }], similarity: 0..1, same: bool } +// `a` and `b` are the character ranges (in each text) of words the other does not have. +export function diffVerses(textA, textB) { + const a = tokenize(textA); + const b = tokenize(textB); + if (a.length === 0 && b.length === 0) return { a: [], b: [], similarity: 1, same: true }; + const { inA, inB } = commonWords(a, b); + const similarity = (2 * inA.size) / (a.length + b.length); + return { a: ranges(a, inA, textA), b: ranges(b, inB, textB), similarity, same: inA.size === a.length && inB.size === b.length }; +} + +// A whole chapter: pairs of [mainVerse, otherVerse] by verse number -> +// { similarity, differing, total, verses: [{ number, similarity, ...ranges }] sorted most different first } +export function diffChapter(mainVerses, otherVerses) { + const other = new Map(otherVerses.map((v) => [v.number, v])); + let shared = 0; + let words = 0; + const verses = []; + for (const v of mainVerses) { + const o = other.get(v.number); + if (!o) continue; + const d = diffVerses(v.text, o.text); + const na = tokenize(v.text).length; + const nb = tokenize(o.text).length; + shared += (d.similarity * (na + nb)) / 2; + words += (na + nb) / 2; + verses.push({ number: v.number, ...d }); + } + return { + similarity: words === 0 ? 1 : shared / words, + total: verses.length, + differing: verses.filter((v) => !v.same).length, + verses, + mostDifferent: [...verses].filter((v) => !v.same).sort((x, y) => x.similarity - y.similarity).slice(0, 3), + }; +} + +// "12% different" for a similarity of 0.88; "identical words" at 1. +export const differenceLabel = (similarity) => { + const pct = Math.round((1 - similarity) * 100); + return pct === 0 && similarity < 1 ? '<1% different' : pct === 0 ? 'Same words' : `${pct}% different`; +}; diff --git a/src/utils/textDiff.test.js b/src/utils/textDiff.test.js new file mode 100644 index 0000000..4fd68ff --- /dev/null +++ b/src/utils/textDiff.test.js @@ -0,0 +1,65 @@ +import { describe, expect, test } from 'vitest'; +import { diffChapter, diffVerses, differenceLabel, tokenize } from './textDiff.js'; + +const slice = (text, ranges) => ranges.map((r) => text.slice(r.start, r.end)); + +describe('tokenize', () => { + test('words only, lowercased, with their place in the text', () => { + const t = tokenize('God said, “Let there be light,”'); + expect(t.map((x) => x.word)).toEqual(['god', 'said', 'let', 'there', 'be', 'light']); + expect(t[0]).toMatchObject({ start: 0, end: 3 }); + }); + test('possessives and apostrophes do not split or change words', () => { + expect(tokenize('God’s law').map((x) => x.word)).toEqual(['god', 'law']); + expect(tokenize("God's law").map((x) => x.word)).toEqual(['god', 'law']); + }); +}); + +describe('diffVerses', () => { + test('identical wording, whatever the punctuation or case, is the same', () => { + const d = diffVerses('In the beginning God created the heavens and the earth.', 'in the beginning, God created the heavens and the earth'); + expect(d).toMatchObject({ same: true, similarity: 1, a: [], b: [] }); + }); + test('marks the words each side has that the other does not', () => { + const a = 'The earth was formless and void'; + const b = 'The earth was without shape and empty'; + const d = diffVerses(a, b); + expect(slice(a, d.a)).toEqual(['formless', 'void']); + expect(slice(b, d.b)).toEqual(['without shape', 'empty']); // neighbouring words become one range + expect(d.same).toBe(false); + expect(d.similarity).toBeCloseTo((2 * 4) / 13, 5); + }); + test('a pure addition shows only on the side that has it', () => { + const d = diffVerses('God saw the light', 'God saw that the light was good'); + expect(d.a).toEqual([]); + expect(d.b.length).toBeGreaterThan(0); + }); + test('totally different text is not alike at all; two empty ones are the same', () => { + expect(diffVerses('alpha beta', 'gamma delta').similarity).toBe(0); + expect(diffVerses('', '').same).toBe(true); + }); +}); + +describe('diffChapter', () => { + const main = [{ number: 1, text: 'In the beginning God created.' }, { number: 2, text: 'The earth was formless.' }, { number: 3, text: 'Let there be light.' }]; + const other = [{ number: 1, text: 'In the beginning God created.' }, { number: 2, text: 'The earth was shapeless and void.' }, { number: 4, text: 'Extra verse only here.' }]; + test('scores the chapter over verses both have, and lists the most different first', () => { + const c = diffChapter(main, other); + expect(c.total).toBe(2); // verse 3 and 4 are not in both + expect(c.differing).toBe(1); + expect(c.mostDifferent.map((v) => v.number)).toEqual([2]); + expect(c.similarity).toBeGreaterThan(0.7); + expect(c.similarity).toBeLessThan(1); + }); + test('an identical chapter is fully alike', () => { + expect(diffChapter(main, main)).toMatchObject({ similarity: 1, differing: 0, mostDifferent: [] }); + }); +}); + +describe('differenceLabel', () => { + test('says it in percent, and never "0% different" for something that does differ', () => { + expect(differenceLabel(0.88)).toBe('12% different'); + expect(differenceLabel(1)).toBe('Same words'); + expect(differenceLabel(0.9999)).toBe('<1% different'); + }); +}); From 5ab416930b027046e6fe56b724540ecea57e5832 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:40:24 -0400 Subject: [PATCH 2/2] Reader: compare button next to Aa A button beside Aa in the reader's top bar opens a Compare translations sheet (pick the Bible to compare with, show/hide the differing-word highlights). It shows as active while a comparison is on. The controls moved out of the Aa sheet; "C" opens it from the keyboard. Co-Authored-By: Claude Sonnet 5.5 --- src/components/ShortcutsHelp.jsx | 1 + src/components/reader/CompareSheet.jsx | 35 +++++++++++++++++++++++++ src/components/reader/ReaderBars.jsx | 5 ++-- src/components/reader/SettingsSheet.jsx | 20 -------------- src/components/reader/icons.jsx | 1 + src/pages/ReaderPage.jsx | 6 +++++ src/pages/ReaderPage.test.jsx | 11 ++++++++ 7 files changed, 57 insertions(+), 22 deletions(-) create mode 100644 src/components/reader/CompareSheet.jsx diff --git a/src/components/ShortcutsHelp.jsx b/src/components/ShortcutsHelp.jsx index 60bb09d..f315b2b 100644 --- a/src/components/ShortcutsHelp.jsx +++ b/src/components/ShortcutsHelp.jsx @@ -14,6 +14,7 @@ const BY_PAGE = { ['/', 'Search this chapter or the whole Bible'], ['G', 'Pick a book and chapter'], ['B', 'Bookmark the selected verse'], + ['C', 'Compare translations'], [',', 'Reading settings'], ['F', 'Hide or show the controls'], ], diff --git a/src/components/reader/CompareSheet.jsx b/src/components/reader/CompareSheet.jsx new file mode 100644 index 0000000..f8dc8d9 --- /dev/null +++ b/src/components/reader/CompareSheet.jsx @@ -0,0 +1,35 @@ +// Compare translations: pick a second Bible to read beside the one you're reading, and whether the +// words that differ are highlighted. The chapter shows how different the two are. +import Sheet from './Sheet.jsx'; +import { READER_TRANSLATIONS } from '../../utils/bibleText.js'; + +export default function CompareSheet({ open, onClose, translation, parallel, onParallel, showDiff, onShowDiff }) { + const chip = (active) => `rounded-full border px-3 py-1.5 text-sm font-medium ${active ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`; + const options = READER_TRANSLATIONS.filter((t) => t.id !== translation); + return ( + +

+ Read another Bible beside the {translation}. You’ll see how different its wording is for the whole chapter and for each verse. +

+ Compare {translation} with +
+ {options.map((t) => ( + + ))} + +
+ {parallel && ( + <> + Highlight the words that differ +
+ {[[true, 'Show'], [false, 'Hide']].map(([id, text]) => ( + + ))} +
+ + )} +

The wording is compared word by word, ignoring capitals and punctuation. It shows how the wording differs, not which translation is more accurate. Tap any verse and open Compare to see all six translations of it.

+
+ ); +} diff --git a/src/components/reader/ReaderBars.jsx b/src/components/reader/ReaderBars.jsx index 575365d..9e64073 100644 --- a/src/components/reader/ReaderBars.jsx +++ b/src/components/reader/ReaderBars.jsx @@ -1,11 +1,11 @@ // Top and bottom chrome for the reader. Everything stays one tap away; focus // mode hides both. -import { ChevronLeft, ChevronRight, ChevronDown, SearchIcon, LinkIcon, PenIcon, HeadphonesIcon, FocusIcon, CheckIcon } from './icons.jsx'; +import { ChevronLeft, ChevronRight, ChevronDown, SearchIcon, LinkIcon, PenIcon, HeadphonesIcon, FocusIcon, CheckIcon, CompareIcon } from './icons.jsx'; import { BookmarkIcon } from '../../utils/bibleUtils.jsx'; const iconBtn = 'flex h-9 w-9 items-center justify-center rounded-lg text-[var(--r-sub)] transition hover:bg-black/5 disabled:opacity-30 disabled:hover:bg-transparent'; -export function TopBar({ title, onBack, onOpenNavigator, onSearch, onBookmarks, bookmarkCount, onSettings, onDoneDrawing }) { +export function TopBar({ title, onBack, onOpenNavigator, onSearch, onBookmarks, bookmarkCount, onCompare, compareActive, onSettings, onDoneDrawing }) { return (
@@ -25,6 +25,7 @@ export function TopBar({ title, onBack, onOpenNavigator, onSearch, onBookmarks, + )} diff --git a/src/components/reader/SettingsSheet.jsx b/src/components/reader/SettingsSheet.jsx index 15d86f5..0c3a5f9 100644 --- a/src/components/reader/SettingsSheet.jsx +++ b/src/components/reader/SettingsSheet.jsx @@ -2,7 +2,6 @@ // to read beside the first. import Sheet from './Sheet.jsx'; import { READER_THEMES } from './readerThemes.js'; -import { READER_TRANSLATIONS } from '../../utils/bibleText.js'; const SIZES = [['S', 0.875], ['M', 1], ['L', 1.125], ['XL', 1.25], ['XXL', 1.5]]; @@ -22,7 +21,6 @@ function Segmented({ options, value, onChange, label }) { export default function SettingsSheet({ open, onClose, fontSize, onFontSize, theme, onTheme, layout, onLayout, wide, onWide, font, onFont, spacing, onSpacing, verseNumbers, onVerseNumbers, - translation, parallel, onParallel, showDiff, onShowDiff, }) { const row = 'mb-4'; const label = 'mb-1.5 block text-xs text-[var(--r-sub)]'; @@ -56,24 +54,6 @@ export default function SettingsSheet({ Width
-
- Read beside another Bible -
- {[{ id: '', name: 'Off' }, ...READER_TRANSLATIONS.filter((t) => t.id !== translation)].map((t) => ( - - ))} -
-

Shows both side by side on wider screens, and stacked on a phone.

-
- {parallel && ( -
- Highlight differences from {translation} - -
- )} ); } diff --git a/src/components/reader/icons.jsx b/src/components/reader/icons.jsx index 5d1c265..59523f5 100644 --- a/src/components/reader/icons.jsx +++ b/src/components/reader/icons.jsx @@ -11,3 +11,4 @@ export const PenIcon = () => ; export const FocusIcon = () => ; export const CheckIcon = () => ; +export const CompareIcon = () => ; diff --git a/src/pages/ReaderPage.jsx b/src/pages/ReaderPage.jsx index 3fa14a6..0921d10 100644 --- a/src/pages/ReaderPage.jsx +++ b/src/pages/ReaderPage.jsx @@ -15,6 +15,7 @@ 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'; @@ -272,6 +273,7 @@ export default function ReaderPage() { 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 === 'f' || e.key === 'F') setChromeVisible((v) => !v); else if ((e.key === 'b' || e.key === 'B') && selectedKey) { readerBookmarks[selectedKey] ? removeBookmark(selectedKey, readerBookmarks[selectedKey]) : toggleReaderBookmark(selectedKey); @@ -405,6 +407,7 @@ export default function ReaderPage() { onSearch={() => openSheet('search')} onBookmarks={() => openSheet('bookmarks')} bookmarkCount={bookmarkEntries.length + highlightEntries.length} + onCompare={() => openSheet('compare')} compareActive={!!parallelId} onSettings={() => openSheet('settings')} onDoneDrawing={readerDrawMode ? () => setReaderDrawMode(false) : undefined} /> @@ -558,6 +561,9 @@ export default function ReaderPage() { font={readerFont} onFont={setReaderFont} spacing={readerSpacing} onSpacing={setReaderSpacing} verseNumbers={readerVerseNumbers} onVerseNumbers={setReaderVerseNumbers} + /> + diff --git a/src/pages/ReaderPage.test.jsx b/src/pages/ReaderPage.test.jsx index 596093c..1702c9b 100644 --- a/src/pages/ReaderPage.test.jsx +++ b/src/pages/ReaderPage.test.jsx @@ -47,6 +47,17 @@ test('renders the chapter, chrome and opens the tool sheets', () => { expect(screen.getByPlaceholderText(/Search John 3/)).toBeTruthy(); }); +test('the compare button next to Aa opens the comparison sheet, and choosing a Bible turns it on', () => { + ctx.setReaderParallel = vi.fn(); + render(); + const compare = screen.getByLabelText('Compare translations'); + expect(compare.nextElementSibling).toBe(screen.getByLabelText('Reading settings')); // sits right beside Aa + fireEvent.click(compare); + expect(screen.getByText(/how different its wording is/)).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: 'NET' })); + expect(ctx.setReaderParallel).toHaveBeenCalledWith('NET'); +}); + test('tapping a verse selects it', () => { render(); fireEvent.click(screen.getByText(/This man came to Jesus/));