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
@@ -106,6 +106,7 @@ export default function ReaderPage() {
|
||||
const [chromeVisible, setChromeVisible] = useState(true);
|
||||
const [readerDrawMode, setReaderDrawMode] = useState(false);
|
||||
const [selectionPicker, setSelectionPicker] = useState(null);
|
||||
const [xrefRequest, setXrefRequest] = useState(null); // a cross-reference tapped in the text: { verse, ref, n }
|
||||
const swipeTouchRef = useRef(null);
|
||||
const readerPageInkStrokes = readerInkByPage?.[`${readerBookAbbrev}_${readerChapter}`] ?? NO_STROKES;
|
||||
|
||||
@@ -298,6 +299,18 @@ export default function ReaderPage() {
|
||||
removeTextHighlight(id);
|
||||
if (gone) toast('Highlight removed', { action: { label: 'Undo', onClick: () => restoreTextHighlight(gone) } });
|
||||
};
|
||||
// Highlighting a whole verse from the verse panel: tapping the same colour again takes it off.
|
||||
const highlightVerse = (color) => {
|
||||
if (!selectedVerse) return;
|
||||
const existing = verseHighlights.find((h) => h.color === color && h.startOffset === 0 && h.endOffset >= selectedVerse.text.length);
|
||||
if (existing) { removeHighlight(existing.id); return; }
|
||||
addTextHighlight({ book: readerBookAbbrev, chapter: readerChapter, verse: selectedVerse.number, startOffset: 0, endOffset: selectedVerse.text.length, color, text: selectedVerse.text });
|
||||
};
|
||||
const clearVerseHighlights = () => {
|
||||
const gone = verseHighlights;
|
||||
gone.forEach((h) => removeTextHighlight(h.id));
|
||||
if (gone.length) toast(`Removed ${gone.length} highlight${gone.length === 1 ? '' : 's'}`, { action: { label: 'Undo', onClick: () => gone.forEach((h) => restoreTextHighlight(h)) } });
|
||||
};
|
||||
const removeBookmark = (key, color) => {
|
||||
toggleReaderBookmark(key);
|
||||
toast('Bookmark removed', { action: { label: 'Undo', onClick: () => restoreReaderBookmark?.(key, color) } });
|
||||
@@ -387,6 +400,7 @@ export default function ReaderPage() {
|
||||
}).sort((a, b) => a.bookIndex - b.bookIndex || a.chapter - b.chapter || a.verse - b.verse || a.startOffset - b.startOffset);
|
||||
|
||||
const selectedVerse = readerSelectedVerse != null ? readerVerses.find((v) => v.number === readerSelectedVerse) : null;
|
||||
const verseHighlights = selectedVerse ? (readerTextHighlights || []).filter((h) => h.book === readerBookAbbrev && h.chapter === readerChapter && h.verse === selectedVerse.number) : [];
|
||||
const selectedKey = selectedVerse ? `${readerBookAbbrev}-${readerChapter}-${selectedVerse.number}` : null;
|
||||
const title = `${readerBook?.name ?? ''} ${readerChapter} · ${readerTranslation}`;
|
||||
const audio = {
|
||||
@@ -459,6 +473,7 @@ export default function ReaderPage() {
|
||||
searchLower={chapterSearch}
|
||||
crossRefs={readerCrossRefs}
|
||||
showCrossRefs={readerShowCrossRefs}
|
||||
onOpenCrossRef={(verse, ref) => { setReaderSelectedVerse(verse); setXrefRequest((r) => ({ verse, ref, n: (r?.n ?? 0) + 1 })); }}
|
||||
onSelectionEnd={handleSelectionEnd}
|
||||
footer={(
|
||||
<PlanFooter
|
||||
@@ -528,6 +543,9 @@ export default function ReaderPage() {
|
||||
onCycleColor={() => cycleBookmarkColor(selectedKey)}
|
||||
onJump={jump}
|
||||
onClose={() => setReaderSelectedVerse(null)}
|
||||
request={xrefRequest}
|
||||
verseHighlights={verseHighlights} colorLabels={readerColorLabels ?? {}}
|
||||
onHighlightVerse={highlightVerse} onClearVerseHighlights={clearVerseHighlights}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { render, screen, fireEvent, within, waitFor } from '@testing-library/react';
|
||||
import { StrictMode } from 'react';
|
||||
import { vi, test, expect } from 'vitest';
|
||||
|
||||
const noop = () => {};
|
||||
@@ -58,6 +59,64 @@ test('the compare button next to Aa opens the comparison sheet, and choosing a B
|
||||
expect(ctx.setReaderParallel).toHaveBeenCalledWith('NET');
|
||||
});
|
||||
|
||||
test('a cross-reference under a verse opens the side panel with that passage, without leaving the chapter', async () => {
|
||||
const saved = { ...ctx };
|
||||
Object.assign(ctx, {
|
||||
readerShowCrossRefs: true, readerSelectedVerse: 1,
|
||||
readerCrossRefs: { 1: [{ book: 'ROM', chapter: 5, verse: 8, endVerse: 9 }] },
|
||||
jumpToReaderVerse: vi.fn(),
|
||||
});
|
||||
vi.stubGlobal('fetch', vi.fn((url) => Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(String(url).includes('/api/bible/')
|
||||
? { verses: [{ number: 7, text: 'Seven.' }, { number: 8, text: 'But God shows His love for us.' }, { number: 9, text: 'Much more shall we be saved.' }, { number: 10, text: 'Ten.' }] }
|
||||
: { chapter: { content: [] } }),
|
||||
})));
|
||||
render(<StrictMode><ReaderPage /></StrictMode>); // dev builds run effects twice; the preview must survive that
|
||||
const chip = within(screen.getByRole('article')).getByRole('button', { name: 'Romans 5:8-9' });
|
||||
fireEvent.click(chip);
|
||||
const card = await screen.findByRole('region', { name: /cross-reference romans 5:8-9/i });
|
||||
await waitFor(() => expect(within(card).getByText(/But God shows His love/)).toBeTruthy());
|
||||
expect(within(card).getByText(/Much more shall we be saved/)).toBeTruthy();
|
||||
expect(within(card).queryByText('Seven.')).toBeNull(); // only the verses in the reference
|
||||
expect(ctx.setReaderSelectedVerse).not.toHaveBeenCalledWith(null); // the click didn't toggle the verse off
|
||||
fireEvent.click(within(card).getByRole('button', { name: /open chapter/i }));
|
||||
expect(ctx.jumpToReaderVerse).toHaveBeenCalledWith('ROM', 5, 8); // only now does it leave the chapter
|
||||
fireEvent.click(within(card).getByRole('button', { name: /close cross-reference/i }));
|
||||
expect(screen.queryByRole('region', { name: /cross-reference romans/i })).toBeNull();
|
||||
Object.assign(ctx, saved);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test('the verse panel has one-tap highlight colours for the whole verse', () => {
|
||||
const saved = { ...ctx };
|
||||
Object.assign(ctx, { readerSelectedVerse: 2, addTextHighlight: vi.fn(), removeTextHighlight: vi.fn(), restoreTextHighlight: vi.fn(), readerTextHighlights: [] });
|
||||
render(<ReaderPage />);
|
||||
const row = screen.getByRole('group', { name: /highlight this verse/i });
|
||||
fireEvent.click(within(row).getByRole('button', { name: /highlight verse green/i }));
|
||||
expect(ctx.addTextHighlight).toHaveBeenCalledWith(expect.objectContaining({
|
||||
book: 'JHN', chapter: 3, verse: 2, startOffset: 0, endOffset: 'This man came to Jesus by night.'.length, color: '#bbf7d0', text: 'This man came to Jesus by night.',
|
||||
}));
|
||||
Object.assign(ctx, saved);
|
||||
});
|
||||
|
||||
test('tapping a verse colour again takes it off, and Clear removes them all (with Undo)', () => {
|
||||
const saved = { ...ctx };
|
||||
const text = 'This man came to Jesus by night.';
|
||||
const hl = (id, color, start = 0, end = text.length) => ({ id, book: 'JHN', chapter: 3, verse: 2, startOffset: start, endOffset: end, color });
|
||||
Object.assign(ctx, { readerSelectedVerse: 2, addTextHighlight: vi.fn(), removeTextHighlight: vi.fn(), restoreTextHighlight: vi.fn(), readerTextHighlights: [hl('a', '#bbf7d0'), hl('b', '#fef08a', 5, 8)] });
|
||||
render(<ReaderPage />);
|
||||
const row = screen.getByRole('group', { name: /highlight this verse/i });
|
||||
expect(within(row).getByRole('button', { name: /highlight verse green/i })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(within(row).getByRole('button', { name: /highlight verse yellow/i })).toHaveAttribute('aria-pressed', 'false'); // a part-verse highlight isn't "the verse"
|
||||
fireEvent.click(within(row).getByRole('button', { name: /highlight verse green/i }));
|
||||
expect(ctx.removeTextHighlight).toHaveBeenCalledWith('a');
|
||||
expect(ctx.addTextHighlight).not.toHaveBeenCalled();
|
||||
fireEvent.click(within(row).getByRole('button', { name: /^clear$/i }));
|
||||
expect(ctx.removeTextHighlight).toHaveBeenCalledWith('b');
|
||||
Object.assign(ctx, saved);
|
||||
});
|
||||
|
||||
test('tapping a verse selects it', () => {
|
||||
render(<ReaderPage />);
|
||||
fireEvent.click(screen.getByText(/This man came to Jesus/));
|
||||
|
||||
Reference in new issue
Block a user