Files
study-bible-project/src/components/reader/ReaderText.jsx
T
nmemmertandClaude Sonnet 5.5 aa27a5bb18 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>
2026-10-01 14:36:46 -04:00

184 lines
9.9 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 = {
serif: { fontFamily: undefined },
sans: { fontFamily: "ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif" },
easy: { fontFamily: "'Atkinson Hyperlegible', Verdana, 'Trebuchet MS', ui-sans-serif, sans-serif", letterSpacing: '0.02em', wordSpacing: '0.12em' },
};
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, 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
onSelectVerse(selectedVerse === n ? null : n);
};
const verseProps = (v) => {
const bm = bookmarks[`${bookKey}-${chapter}-${v.number}`];
const selected = selectedVerse === v.number;
return {
id: `reader-verse-${v.number}`,
'data-verse-num': v.number,
onClick: () => handleTap(v.number),
// Verses can be reached and chosen from the keyboard too
role: 'button',
tabIndex: 0,
'aria-pressed': selected,
onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleTap(v.number); } },
className: 'cursor-pointer rounded-sm transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[var(--r-accent)]',
style: selected
? { backgroundColor: 'var(--r-hl)' }
: bm ? { backgroundColor: `${bm}66`, boxShadow: `0 0 0 2px ${bm}66` } : undefined,
};
};
const chips = (v) => showCrossRefs && crossRefs?.[v.number]?.length > 0 && (
<span className="ml-1 inline-flex flex-wrap gap-1 align-baseline font-sans">
{crossRefs[v.number].slice(0, 4).map((ref, i) => (
<span key={i} className="rounded-full border border-[var(--r-line)] px-1.5 py-px text-[10px] text-[var(--r-sub)]">{formatCrossRef(ref)}</span>
))}
</span>
);
const num = (n) => (showVerseNumbers ? <sup className="mr-1 select-none font-sans text-[0.6em] text-[var(--r-sub)]">{n}</sup> : null);
// Both Bibles by verse number, so a verse one translation leaves out still lines up with the other.
const parallelRows = parallel && !parallel.error ? (() => {
const second = new Map(parallel.verses.map((v) => [v.number, v]));
const first = new Map(verses.map((v) => [v.number, v]));
return [...new Set([...first.keys(), ...second.keys()])].sort((a, b) => a - b)
.map((n) => ({ number: n, first: first.get(n), second: second.get(n) }));
})() : null;
const sideBySide = !!parallel;
const bodyStyle = {
...FONT_STYLES[font],
lineHeight: LINE_HEIGHTS[spacing] ?? LINE_HEIGHTS.normal,
};
// The page grows with the screen: type is 18px on a phone, ~20px on an iPad and ~24px on a big
// monitor (times your text-size choice), and the column is measured in the type's own width (em), so
// the line length stays comfortable (~70 characters) instead of becoming a narrow strip on a wide
// screen. Two columns / side-by-side take more room.
const pageStyle = {
fontSize: `calc(clamp(1.125rem, 0.9rem + 0.7vw, 1.55rem) * ${fontSize})`,
maxWidth: wide || sideBySide ? 'min(100%, 72em)' : 'min(100%, 40em)',
};
return (
<article
className="mx-auto px-5 pb-32 pt-6 text-[var(--r-text)] md:px-8"
style={pageStyle}
onPointerUp={onSelectionEnd}
>
<header className="mb-5 text-center">
<h1 className="font-serif text-3xl font-normal tracking-tight">{title}</h1>
<p className="mt-1 font-sans text-xs text-[var(--r-sub)]">{translation}{parallel ? ` · beside ${parallel.name}` : ''}</p>
</header>
{loading && <ChapterSkeleton />}
{error && <p role="alert" className="text-center font-sans text-sm text-[var(--r-danger,#b42318)]">{error}</p>}
{!loading && !error && parallel?.error && (
<p role="alert" className="mb-4 text-center font-sans text-sm text-[var(--r-danger,#b42318)]">Couldn’t load {parallel.name}: {parallel.error}</p>
)}
{!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>
{parallelRows.map((row) => (
<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, 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)}<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>
))}
</div>
)}
{!loading && !error && !sideBySide && (
<div
className={`font-serif ${wide ? 'md:columns-2 md:gap-x-12' : ''}`}
style={bodyStyle}
>
{layout === 'flow' ? (
<p className="text-pretty">
{verses.map((v) => (
<span key={v.number} {...verseProps(v)}>
{num(v.number)}{renderVerseText(v.number, v.text, searchLower)}{chips(v)}{' '}
</span>
))}
</p>
) : (
verses.map((v) => (
<p key={v.number} className="mb-3 break-inside-avoid">
<span {...verseProps(v)}>{num(v.number)}{renderVerseText(v.number, v.text, searchLower)}{chips(v)}</span>
</p>
))
)}
</div>
)}
{!loading && !error && <div style={{ fontSize: '1rem' }}>{footer}</div>}
{notice && !loading && <p className="mt-10 border-t border-[var(--r-line)] pt-3 font-sans text-[11px] leading-snug text-[var(--r-sub)]">{notice}</p>}
{parallel?.notice && !loading && !parallel.loading && <p className="mt-2 font-sans text-[11px] leading-snug text-[var(--r-sub)]">{parallel.notice}</p>}
</article>
);
}
// Grey bars the shape of a page, so a slow chapter load doesn't just show the word "Loading".
function ChapterSkeleton() {
return (
<div role="status" aria-label="Loading chapter" className="animate-pulse motion-reduce:animate-none space-y-3 pt-2">
{[100, 96, 92, 98, 84, 100, 90, 70].map((w, i) => <div key={i} className="h-4 rounded bg-[var(--r-line)]" style={{ width: `${w}%` }} />)}
<span className="sr-only">Loading…</span>
</div>
);
}