- Tooltips on hover and keyboard focus across the app: icon-only buttons show
their label, existing titles show in the app's style without the browser's
delay, and the reader's main buttons show shortcut hints ("Search (/)").
Touch screens are unaffected.
- Reader: cross-references (under verses and in the Cross-refs tab) open the
passage's text in the side panel without leaving the chapter; "Open chapter"
goes there when wanted
- Reader: the verse panel now has a Highlight row of colours for one-tap
whole-verse highlighting (tap again to remove, Clear for all, with Undo);
selecting words still highlights just those
- Fix: a cross-reference preview was cleared when React ran the panel's
effect twice (development builds)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
188 lines
10 KiB
React
188 lines
10 KiB
React
// 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, onOpenCrossRef, 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) => (
|
||
// Opens that passage in the side panel; stopPropagation so the verse itself isn't also toggled
|
||
<button key={i} type="button" onClick={(e) => { e.stopPropagation(); onOpenCrossRef?.(v.number, ref); }}
|
||
onKeyDown={(e) => e.stopPropagation()}
|
||
className="rounded-full border border-[var(--r-line)] px-1.5 py-px text-[10px] text-[var(--r-sub)] hover:border-[var(--r-accent)] hover:text-[var(--r-accent)]"
|
||
data-tip={`Read ${formatCrossRef(ref)} in the side panel`}>{formatCrossRef(ref)}</button>
|
||
))}
|
||
</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>
|
||
);
|
||
}
|