Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ab416930b | ||
|
|
aa27a5bb18 | ||
|
|
7101be393e |
No files matched your search
@@ -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'],
|
||||
],
|
||||
|
||||
@@ -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 (
|
||||
<Sheet open={open} onClose={onClose} title="Compare translations">
|
||||
<p className="mb-3 text-sm text-[var(--r-sub)]">
|
||||
Read another Bible beside the {translation}. You’ll see how different its wording is for the whole chapter and for each verse.
|
||||
</p>
|
||||
<span className="mb-1.5 block text-xs text-[var(--r-sub)]">Compare {translation} with</span>
|
||||
<div role="group" aria-label="Translation to compare with" className="mb-4 flex flex-wrap gap-1.5">
|
||||
{options.map((t) => (
|
||||
<button key={t.id} type="button" title={t.name} aria-pressed={parallel === t.id} onClick={() => { onParallel(parallel === t.id ? '' : t.id); if (parallel !== t.id) onClose(); }} className={chip(parallel === t.id)}>{t.id}</button>
|
||||
))}
|
||||
<button type="button" aria-pressed={!parallel} onClick={() => onParallel('')} className={chip(!parallel)}>Off</button>
|
||||
</div>
|
||||
{parallel && (
|
||||
<>
|
||||
<span className="mb-1.5 block text-xs text-[var(--r-sub)]">Highlight the words that differ</span>
|
||||
<div role="group" aria-label="Highlight differences" className="flex overflow-hidden rounded-xl border border-[var(--r-line)]">
|
||||
{[[true, 'Show'], [false, 'Hide']].map(([id, text]) => (
|
||||
<button key={String(id)} type="button" aria-pressed={showDiff === id} onClick={() => onShowDiff(id)}
|
||||
className={`flex-1 px-3 py-2 text-sm ${showDiff === id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}>{text}</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<p className="mt-4 text-[11px] text-[var(--r-sub)]">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.</p>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -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(<span key={i} className="diff-hl">{text.slice(r.start, r.end)}</span>);
|
||||
cursor = r.end;
|
||||
});
|
||||
if (cursor < text.length) parts.push(text.slice(cursor));
|
||||
return <>{parts}</>;
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="safe-top sticky top-0 z-30 border-b border-[var(--r-line)] bg-[var(--r-bar)] font-sans">
|
||||
<div className="mx-auto flex h-12 max-w-4xl items-center gap-1 px-2">
|
||||
@@ -25,6 +25,7 @@ export function TopBar({ title, onBack, onOpenNavigator, onSearch, onBookmarks,
|
||||
<button type="button" onClick={onBookmarks} className={`${iconBtn} relative ${bookmarkCount ? 'text-[var(--r-accent)]' : ''}`} aria-label="Bookmarks">
|
||||
<BookmarkIcon filled={bookmarkCount > 0} />
|
||||
</button>
|
||||
<button type="button" onClick={onCompare} className={`${iconBtn} ${compareActive ? 'text-[var(--r-accent)]' : ''}`} aria-label="Compare translations" aria-pressed={!!compareActive} title="Compare translations"><CompareIcon /></button>
|
||||
<button type="button" onClick={onSettings} className={`${iconBtn} w-10 text-sm font-medium`} aria-label="Reading settings">Aa</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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 && (
|
||||
<div style={bodyStyle} className="font-serif">
|
||||
{diff && (
|
||||
<div className="mb-4 rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] px-4 py-3 font-sans text-sm" style={{ fontSize: '0.875rem' }}>
|
||||
<p>
|
||||
<strong>{parallel.translation}</strong>{diff.similarity === 1 ? ' uses the same words as ' : <> is <strong>{differenceLabel(diff.similarity)}</strong> from </>}{translationId} in this chapter
|
||||
{diff.total > 0 && <span className="text-[var(--r-sub)]"> · {diff.differing} of {diff.total} verses differ</span>}
|
||||
</p>
|
||||
{diff.mostDifferent.length > 0 && (
|
||||
<p className="mt-1 text-xs text-[var(--r-sub)]">
|
||||
Most different:{' '}
|
||||
{diff.mostDifferent.map((v, i) => (
|
||||
<span key={v.number}>{i > 0 && ', '}<button type="button" onClick={() => jumpToVerse(v.number)} className="font-medium text-[var(--r-accent)] hover:underline">verse {v.number}</button> ({Math.round((1 - v.similarity) * 100)}%)</span>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-[var(--r-sub)]"><span className="diff-hl">Marked words</span> aren’t in the other wording.</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-3 hidden grid-cols-2 gap-x-10 border-b border-[var(--r-line)] pb-1 font-sans text-[11px] uppercase tracking-wide text-[var(--r-sub)] md:grid">
|
||||
<span>{translationId}</span><span>{parallel.translation}</span>
|
||||
</div>
|
||||
@@ -99,12 +127,14 @@ export default function ReaderText({
|
||||
<div key={row.number} className="mb-4 grid gap-x-10 gap-y-1 md:grid-cols-2">
|
||||
<p>
|
||||
{row.first
|
||||
? <span {...verseProps(row.first)}>{num(row.first.number)}{renderVerseText(row.first.number, row.first.text, searchLower)}{chips(row.first)}</span>
|
||||
? <span {...verseProps(row.first)}>{num(row.first.number)}{renderVerseText(row.first.number, row.first.text, searchLower, diffByVerse.get(row.number)?.a)}{chips(row.first)}</span>
|
||||
: <span className="text-[var(--r-sub)]">{num(row.number)}—</span>}
|
||||
</p>
|
||||
<p className="border-l-2 border-[var(--r-line)] pl-3 text-[0.95em] text-[var(--r-sub)] md:border-l-0 md:pl-0 md:text-[var(--r-text)]">
|
||||
<span className="mr-1.5 font-sans text-[10px] uppercase tracking-wide md:hidden">{parallel.translation}</span>
|
||||
{row.second ? <>{num(row.second.number)}{row.second.text}</> : parallel.loading ? <span className="font-sans text-xs" aria-label="Loading">…</span> : '—'}
|
||||
{row.second ? <>{num(row.second.number)}<DiffText text={row.second.text} ranges={diffByVerse.get(row.number)?.b} />{diffByVerse.get(row.number) && !diffByVerse.get(row.number).same && (
|
||||
<span className="ml-2 whitespace-nowrap font-sans text-[10px] text-[var(--r-sub)]">{differenceLabel(diffByVerse.get(row.number).similarity)}</span>
|
||||
)}</> : parallel.loading ? <span className="font-sans text-xs" aria-label="Loading">…</span> : '—'}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -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(<ReaderText {...base} parallel={parallel()} showDiff />);
|
||||
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(<ReaderText {...base} parallel={parallel({ verses })} showDiff />);
|
||||
expect(screen.getByText(/uses the same words as/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('with highlighting off there is no summary and no marks', () => {
|
||||
const { container } = render(<ReaderText {...base} parallel={parallel()} showDiff={false} />);
|
||||
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(<ReaderText {...base} parallel={parallel({ verses: [], loading: true })} showDiff />);
|
||||
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(<ReaderText {...base} renderVerseText={renderVerseText} parallel={parallel()} showDiff />);
|
||||
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
|
||||
});
|
||||
@@ -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,
|
||||
}) {
|
||||
const row = 'mb-4';
|
||||
const label = 'mb-1.5 block text-xs text-[var(--r-sub)]';
|
||||
@@ -56,18 +54,6 @@ export default function SettingsSheet({
|
||||
<span className={label}>Width</span>
|
||||
<Segmented label="Reading width" options={[[false, 'Column'], [true, 'Two columns']]} value={wide} onChange={onWide} />
|
||||
</div>
|
||||
<div className={row}>
|
||||
<span className={label}>Read beside another Bible</span>
|
||||
<div role="group" aria-label="Second translation" className="flex flex-wrap gap-1.5">
|
||||
{[{ id: '', name: 'Off' }, ...READER_TRANSLATIONS.filter((t) => t.id !== translation)].map((t) => (
|
||||
<button key={t.id || 'off'} type="button" onClick={() => onParallel(t.id)} aria-pressed={parallel === t.id} title={t.name}
|
||||
className={`rounded-full border px-3 py-1 text-xs font-medium ${parallel === t.id ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>
|
||||
{t.id || 'Off'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-1.5 text-[11px] text-[var(--r-sub)]">Shows both side by side on wider screens, and stacked on a phone.</p>
|
||||
</div>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-3">
|
||||
{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 (
|
||||
<div key={t.id}>
|
||||
<p className="mb-0.5 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">{t.id}{t.id === current ? ' · reading' : ''}</p>
|
||||
<p className="mb-0.5 flex items-center gap-2 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">
|
||||
<span>{t.id}{t.id === current ? ' · reading' : ''}</span>
|
||||
{d && <span className="normal-case tracking-normal">{differenceLabel(d.similarity)}</span>}
|
||||
</p>
|
||||
{!row ? <p className="text-sm text-[var(--r-sub)]">Loading…</p>
|
||||
: row.error ? <p className="text-sm text-[var(--r-sub)]">{row.error}</p>
|
||||
: <p className="font-serif text-[15px] leading-relaxed">{row.text}</p>}
|
||||
: <p className="font-serif text-[15px] leading-relaxed"><DiffText text={row.text} ranges={d?.b} /></p>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<p className="border-t border-[var(--r-line)] pt-2 text-[10px] leading-snug text-[var(--r-sub)]">Copyright notices for each translation appear at the foot of the chapter when you read it.</p>
|
||||
<p className="border-t border-[var(--r-line)] pt-2 text-[10px] leading-snug text-[var(--r-sub)]">
|
||||
{mine ? <><span className="diff-hl">Marked words</span> aren’t in the {current} wording. </> : null}
|
||||
Copyright notices for each translation appear at the foot of the chapter when you read it.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,3 +11,4 @@ export const PenIcon = () => <svg {...base}><path d="M13.5 3.5l3 3L7 16l-4 1 1-4
|
||||
export const HeadphonesIcon = () => <svg {...base}><path d="M3.5 12V10a6.5 6.5 0 0113 0v2" /><rect x="2.5" y="11.5" width="3.5" height="5" rx="1.2" /><rect x="14" y="11.5" width="3.5" height="5" rx="1.2" /></svg>;
|
||||
export const FocusIcon = () => <svg {...base}><path d="M3 7V4h3M17 7V4h-3M3 13v3h3M17 13v3h-3" /></svg>;
|
||||
export const CheckIcon = () => <svg {...base}><path d="M4.5 10.5l3.5 3.5 7.5-8" /></svg>;
|
||||
export const CompareIcon = () => <svg {...base}><rect x="2.5" y="3.5" width="6.5" height="13" rx="1.3" /><rect x="11" y="3.5" width="6.5" height="13" rx="1.3" /><path d="M4.5 7h2.5M4.5 10h2.5M13 7h2.5M13 10h2.5" /></svg>;
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -47,6 +48,7 @@ export default function ReaderPage() {
|
||||
readerSpacing, setReaderSpacing,
|
||||
readerVerseNumbers, setReaderVerseNumbers,
|
||||
readerParallel, setReaderParallel,
|
||||
readerShowDiff, setReaderShowDiff,
|
||||
readerBookmarks,
|
||||
readerCrossRefs, setReaderCrossRefs,
|
||||
readerCrossRefsLoading,
|
||||
@@ -271,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);
|
||||
@@ -302,8 +305,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 +322,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 +341,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>);
|
||||
}
|
||||
@@ -396,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}
|
||||
/>
|
||||
@@ -429,7 +441,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}
|
||||
@@ -549,7 +561,11 @@ export default function ReaderPage() {
|
||||
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}
|
||||
|
||||
@@ -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(<ReaderPage />);
|
||||
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(<ReaderPage />);
|
||||
fireEvent.click(screen.getByText(/This man came to Jesus/));
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// How two wordings of the same verse differ: which words are not shared, and how alike they are overall.
|
||||
// Comparison ignores case and punctuation ("light,”" matches "Light"), and works on whole words.
|
||||
|
||||
// Words with their position in the original text: [{ word: 'light', start, end }]
|
||||
export function tokenize(text) {
|
||||
const out = [];
|
||||
const re = /[\p{L}\p{N}][\p{L}\p{N}’'-]*/gu;
|
||||
let m;
|
||||
while ((m = re.exec(text ?? '')) !== null) {
|
||||
out.push({ word: m[0].toLowerCase().replace(/[’']s?$/u, '').replace(/[’'-]/gu, ''), start: m.index, end: m.index + m[0].length });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Longest common subsequence of two word lists -> 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`;
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user