Hover tooltips, clickable cross-references, easier highlighting
- 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>
This commit is contained in:
1 parent
bb80623c45
commit
7f0256be06
9 files changed
+394
-28
No files matched your search
@@ -1,6 +1,6 @@
|
||||
// The single home for per-verse tools: original words, cross-references,
|
||||
// commentary, and people & places, plus bookmark / copy.
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import Sheet from './Sheet.jsx';
|
||||
import { CommentaryTab, PeoplePlacesTab } from '../StudyPanel.jsx';
|
||||
import { BookmarkIcon, CopyIcon } from '../../utils/bibleUtils.jsx';
|
||||
@@ -8,6 +8,7 @@ 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';
|
||||
import { HIGHLIGHT_COLORS, colorTitle } from '../../utils/highlightColors.js';
|
||||
|
||||
const TABS = [['commentary', 'Commentary'], ['compare', 'Compare'], ['original', 'Original'], ['refs', 'Cross-refs'], ['people', 'People & places']];
|
||||
|
||||
@@ -37,18 +38,59 @@ function OriginalTab({ words }) {
|
||||
);
|
||||
}
|
||||
|
||||
function RefsTab({ refs, loading, onJump }) {
|
||||
// A cross-referenced passage shown in the panel: its words in the translation being read, without
|
||||
// leaving the chapter. "Open chapter" goes there when you want more.
|
||||
function RefPreview({ refItem, translation, onOpen, onClose }) {
|
||||
const [state, setState] = useState({ key: '', verses: [], error: '' });
|
||||
const key = `${translation}/${refItem.book}/${refItem.chapter}`;
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
fetchChapter(translation, refItem.book, refItem.chapter, { signal: controller.signal })
|
||||
.then(({ verses }) => setState({ key, verses, error: '' }))
|
||||
.catch((err) => { if (!controller.signal.aborted) setState({ key, verses: [], error: err.message || 'Unable to load that passage.' }); });
|
||||
return () => controller.abort();
|
||||
}, [key]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const end = refItem.endVerse && refItem.endVerse > refItem.verse ? refItem.endVerse : refItem.verse;
|
||||
const picked = state.key === key ? state.verses.filter((v) => v.number >= refItem.verse && v.number <= end) : [];
|
||||
const loading = state.key !== key;
|
||||
return (
|
||||
<div className="mb-3 rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] p-3" role="region" aria-label={`Cross-reference ${formatCrossRef(refItem)}`}>
|
||||
<div className="mb-1.5 flex items-center justify-between gap-2">
|
||||
<p className="text-sm font-medium">{formatCrossRef(refItem)} <span className="text-xs font-normal text-[var(--r-sub)]">{translation}</span></p>
|
||||
<button type="button" onClick={onClose} aria-label="Close cross-reference" className="rounded p-1 text-[var(--r-sub)] hover:bg-black/5">×</button>
|
||||
</div>
|
||||
{loading ? <p className="text-sm text-[var(--r-sub)]">Loading…</p>
|
||||
: state.error ? <p className="text-sm text-[var(--r-danger,#b42318)]">{state.error}</p>
|
||||
: picked.length === 0 ? <p className="text-sm text-[var(--r-sub)]">Those verses weren’t found.</p>
|
||||
: (
|
||||
<p className="font-serif text-[15px] leading-relaxed">
|
||||
{picked.map((v) => <span key={v.number}>{picked.length > 1 && <sup className="mr-0.5 font-sans text-[0.65em] text-[var(--r-sub)]">{v.number}</sup>}{v.text}{' '}</span>)}
|
||||
</p>
|
||||
)}
|
||||
<button type="button" onClick={() => onOpen(refItem.book, refItem.chapter, refItem.verse)} className="mt-2 text-xs font-medium text-[var(--r-accent)] hover:underline">Open chapter</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const sameRef = (a, b) => a && b && a.book === b.book && a.chapter === b.chapter && a.verse === b.verse && (a.endVerse ?? a.verse) === (b.endVerse ?? b.verse);
|
||||
|
||||
function RefsTab({ refs, loading, active, onPick, preview }) {
|
||||
if (loading && !refs) return <p className="text-sm text-[var(--r-sub)]">Loading…</p>;
|
||||
if (!refs?.length) return <p className="text-sm text-[var(--r-sub)]">No cross-references for this verse.</p>;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{refs.slice(0, 20).map((ref, i) => (
|
||||
<button key={i} type="button" onClick={() => onJump(ref.book, ref.chapter, ref.verse)}
|
||||
className="rounded-full border border-[var(--r-line)] px-2.5 py-1 text-xs hover:bg-black/5">
|
||||
{formatCrossRef(ref)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
{preview}
|
||||
<p className="mb-1.5 text-[11px] text-[var(--r-sub)]">Tap one to read it here.</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{refs.slice(0, 20).map((ref, i) => (
|
||||
<button key={i} type="button" onClick={() => onPick(ref)} aria-pressed={sameRef(active, ref)}
|
||||
className={`rounded-full border px-2.5 py-1 text-xs ${sameRef(active, ref) ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] hover:bg-black/5'}`}>
|
||||
{formatCrossRef(ref)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -99,10 +141,27 @@ function CompareTab({ book, chapter, verse, current }) {
|
||||
|
||||
export default function VerseSheet({
|
||||
verse, book, bookName, chapter, translation, words, crossRefs, crossRefsLoading, onLoadCrossRefs,
|
||||
bookmarkColor, onToggleBookmark, onCycleColor, onJump, onClose,
|
||||
bookmarkColor, onToggleBookmark, onCycleColor, onJump, onClose, request,
|
||||
verseHighlights = [], colorLabels = {}, onHighlightVerse, onClearVerseHighlights,
|
||||
}) {
|
||||
const [tab, setTab] = useState('commentary');
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [preview, setPreview] = useState(null); // the cross-reference being read in the panel
|
||||
const appliedRequest = useRef(0);
|
||||
// Leaving this verse closes the preview; a cross-reference tapped in the text opens it here. (Tracks the
|
||||
// previous verse so React's double-run of effects in development can't clear a preview it just opened.)
|
||||
const previousVerse = useRef(null);
|
||||
useEffect(() => {
|
||||
const verseChanged = previousVerse.current !== verse?.number;
|
||||
previousVerse.current = verse?.number;
|
||||
if (request && request.verse === verse?.number && request.n !== appliedRequest.current) {
|
||||
appliedRequest.current = request.n;
|
||||
setTab('refs');
|
||||
setPreview(request.ref);
|
||||
} else if (verseChanged) {
|
||||
setPreview(null);
|
||||
}
|
||||
}, [verse?.number, request?.n]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
useEffect(() => { if (verse && tab === 'refs') onLoadCrossRefs(); }, [verse?.number, tab]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
useEffect(() => { setCopied(false); }, [verse?.number]);
|
||||
if (!verse) return null;
|
||||
@@ -134,6 +193,27 @@ export default function VerseSheet({
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{onHighlightVerse && (
|
||||
// One-tap highlighting of the whole verse (to mark just some words, select them in the text)
|
||||
<div className="mb-3 flex flex-wrap items-center gap-1.5" role="group" aria-label="Highlight this verse">
|
||||
<span className="mr-1 text-xs text-[var(--r-sub)]">Highlight</span>
|
||||
{HIGHLIGHT_COLORS.map((color) => {
|
||||
const on = verseHighlights.some((h) => h.color === color && h.startOffset === 0 && h.endOffset >= verse.text.length);
|
||||
return (
|
||||
<button key={color} type="button" onClick={() => onHighlightVerse(color)} aria-pressed={on}
|
||||
aria-label={`Highlight verse ${colorTitle(color, colorLabels).toLowerCase()}`} data-tip={`${colorTitle(color, colorLabels)}${on ? ' — tap to remove' : ''}`}
|
||||
className={`h-6 w-6 rounded-full border-2 transition hover:scale-110 ${on ? 'border-[var(--r-text)]' : 'border-transparent ring-1 ring-black/10'}`}
|
||||
style={{ backgroundColor: color }}>
|
||||
{on && <span className="text-[10px] leading-none text-[#2b2620]" aria-hidden="true">✓</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{verseHighlights.length > 0 && (
|
||||
<button type="button" onClick={onClearVerseHighlights} className="ml-1 text-xs text-[var(--r-sub)] underline hover:text-[var(--r-text)]">Clear</button>
|
||||
)}
|
||||
<span className="basis-full text-[11px] text-[var(--r-sub)]">Or select words in the text to highlight just those.</span>
|
||||
</div>
|
||||
)}
|
||||
<div role="tablist" aria-label="About this verse" className="mb-3 flex gap-1.5 overflow-x-auto pb-1">
|
||||
{TABS.map(([id, label]) => (
|
||||
<button key={id} type="button" role="tab" aria-selected={tab === id} onClick={() => setTab(id)}
|
||||
@@ -145,7 +225,10 @@ export default function VerseSheet({
|
||||
{tab === 'commentary' && <CommentaryTab book={book} chapter={chapter} verse={verse.number} />}
|
||||
{tab === 'compare' && <CompareTab book={book} chapter={chapter} verse={verse.number} current={translation} />}
|
||||
{tab === 'original' && <OriginalTab words={words} />}
|
||||
{tab === 'refs' && <RefsTab refs={crossRefs} loading={crossRefsLoading} onJump={onJump} />}
|
||||
{tab === 'refs' && (
|
||||
<RefsTab refs={crossRefs} loading={crossRefsLoading} active={preview} onPick={setPreview}
|
||||
preview={preview && <RefPreview refItem={preview} translation={translation} onOpen={onJump} onClose={() => setPreview(null)} />} />
|
||||
)}
|
||||
{tab === 'people' && <PeoplePlacesTab book={book} chapter={chapter} verse={verse.number} />}
|
||||
</Sheet>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user