Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5812fd4ee | ||
|
|
cc2a8cd111 | ||
|
|
a9ca647d5a | ||
|
|
22f9700798 |
No files matched your search
@@ -15,6 +15,7 @@ const BY_PAGE = {
|
||||
['G', 'Pick a book and chapter'],
|
||||
['B', 'Bookmark the selected verse'],
|
||||
['C', 'Compare translations'],
|
||||
['H', 'Your highlights'],
|
||||
[',', 'Reading settings'],
|
||||
['F', 'Hide or show the controls'],
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Everything you have marked in the Bible: highlights (grouped and filtered by colour, each colour
|
||||
// named by you — "Promises", "Commands") and bookmarks. Opens from the bookmark button in the top bar.
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import Sheet from './Sheet.jsx';
|
||||
import { CloseIcon } from './icons.jsx';
|
||||
import { COLOR_NAMES, COLOR_SUGGESTIONS, HIGHLIGHT_COLORS, colorTitle } from '../../utils/highlightColors.js';
|
||||
@@ -8,9 +8,11 @@ import { COLOR_NAMES, COLOR_SUGGESTIONS, HIGHLIGHT_COLORS, colorTitle } from '..
|
||||
const tabBtn = (active) => `rounded-full border px-3 py-1 text-xs 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'}`;
|
||||
|
||||
export default function MarksSheet({
|
||||
open, onClose, bookmarks, highlights, colorLabels, onSetLabel, onJump, onRemoveBookmark, onRemoveHighlight,
|
||||
open, onClose, initialTab = 'highlights', bookmarks, highlights, colorLabels, onSetLabel, onJump, onRemoveBookmark, onRemoveHighlight,
|
||||
}) {
|
||||
const [tab, setTab] = useState('highlights');
|
||||
const [tab, setTab] = useState(initialTab);
|
||||
// Opening from the Highlights button shows highlights; from the Bookmarks button, bookmarks
|
||||
useEffect(() => { if (open) setTab(initialTab); }, [open, initialTab]);
|
||||
const [filter, setFilter] = useState(null); // a highlight colour, or null for all
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
|
||||
@@ -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, CompareIcon } from './icons.jsx';
|
||||
import { ChevronLeft, ChevronRight, ChevronDown, SearchIcon, LinkIcon, PenIcon, HeadphonesIcon, FocusIcon, CheckIcon, CompareIcon, HighlighterIcon } 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, onCompare, compareActive, onSettings, onDoneDrawing }) {
|
||||
export function TopBar({ title, onBack, onOpenNavigator, onSearch, onBookmarks, bookmarkCount, onHighlights, highlightCount, 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">
|
||||
@@ -22,7 +22,10 @@ export function TopBar({ title, onBack, onOpenNavigator, onSearch, onBookmarks,
|
||||
) : (
|
||||
<>
|
||||
<button type="button" onClick={onSearch} className={iconBtn} aria-label="Search" data-tip="Search (/)"><SearchIcon /></button>
|
||||
<button type="button" onClick={onBookmarks} className={`${iconBtn} relative ${bookmarkCount ? 'text-[var(--r-accent)]' : ''}`} aria-label="Bookmarks" data-tip="Bookmarks and highlights">
|
||||
<button type="button" onClick={onHighlights} className={`${iconBtn} ${highlightCount ? 'text-[var(--r-accent)]' : ''}`} aria-label="Highlights" data-tip="Your highlights, by colour (H)">
|
||||
<HighlighterIcon />
|
||||
</button>
|
||||
<button type="button" onClick={onBookmarks} className={`${iconBtn} relative ${bookmarkCount ? 'text-[var(--r-accent)]' : ''}`} aria-label="Bookmarks" data-tip="Your 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} data-tip="Compare translations (C)"><CompareIcon /></button>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { CloseIcon } from './icons.jsx';
|
||||
|
||||
const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
export default function Sheet({ open, onClose, title, label, headerRight, modal = true, pane = false, children }) {
|
||||
export default function Sheet({ open, onClose, title, label, headerRight, modal = true, pane = false, panelRef, edge, children }) {
|
||||
const dialogRef = useRef(null);
|
||||
const onCloseRef = useRef(onClose);
|
||||
onCloseRef.current = onClose;
|
||||
@@ -41,15 +41,16 @@ export default function Sheet({ open, onClose, title, label, headerRight, modal
|
||||
<>
|
||||
{modal && <div className="fixed inset-0 z-40 bg-black/30" onClick={onClose} aria-hidden="true" />}
|
||||
<div
|
||||
ref={dialogRef}
|
||||
ref={(el) => { dialogRef.current = el; if (panelRef) panelRef.current = el; }}
|
||||
role="dialog"
|
||||
aria-modal={modal ? 'true' : undefined}
|
||||
aria-label={typeof title === 'string' ? title : label}
|
||||
tabIndex={-1}
|
||||
className={`fixed inset-x-0 bottom-0 z-50 flex max-h-[72vh] flex-col rounded-t-2xl focus:outline-none border-t border-[var(--r-line)] bg-[var(--r-bar)] text-[var(--r-text)] font-sans ${
|
||||
pane ? 'lg:inset-x-auto lg:bottom-0 lg:right-0 lg:top-12 lg:max-h-none lg:w-[24rem] lg:rounded-none lg:border-l lg:border-t-0' : 'sm:left-1/2 sm:right-auto sm:w-[36rem] sm:-translate-x-1/2'
|
||||
pane ? 'lg:inset-x-auto lg:bottom-0 lg:right-0 lg:top-12 lg:max-h-none lg:w-[var(--reader-pane,24rem)] lg:rounded-none lg:border-l lg:border-t-0' : 'sm:left-1/2 sm:right-auto sm:w-[36rem] sm:-translate-x-1/2'
|
||||
}`}
|
||||
>
|
||||
{edge}
|
||||
<div className="mx-auto mt-2 h-1 w-9 shrink-0 rounded-full bg-[var(--r-line)] lg:hidden" />
|
||||
<div className="flex items-center justify-between gap-2 px-4 pb-2 pt-2">
|
||||
<div className="min-w-0 truncate text-sm font-medium">{title}</div>
|
||||
|
||||
@@ -142,7 +142,7 @@ function CompareTab({ book, chapter, verse, current }) {
|
||||
export default function VerseSheet({
|
||||
verse, book, bookName, chapter, translation, words, crossRefs, crossRefsLoading, onLoadCrossRefs,
|
||||
bookmarkColor, onToggleBookmark, onCycleColor, onJump, onClose, request,
|
||||
verseHighlights = [], colorLabels = {}, onHighlightVerse, onClearVerseHighlights,
|
||||
verseHighlights = [], colorLabels = {}, onHighlightVerse, onClearVerseHighlights, panelRef, resizeHandle,
|
||||
}) {
|
||||
const [tab, setTab] = useState('commentary');
|
||||
const [copied, setCopied] = useState(false);
|
||||
@@ -175,7 +175,7 @@ export default function VerseSheet({
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
open modal={false} pane onClose={onClose}
|
||||
open modal={false} pane onClose={onClose} panelRef={panelRef} edge={resizeHandle}
|
||||
title={`${bookName} ${chapter}:${verse.number}`}
|
||||
headerRight={(
|
||||
<>
|
||||
|
||||
@@ -12,3 +12,4 @@ export const HeadphonesIcon = () => <svg {...base}><path d="M3.5 12V10a6.5 6.5 0
|
||||
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>;
|
||||
export const HighlighterIcon = () => <svg {...base}><path d="M12.5 3.5l4 4-6.8 6.8-4-4 6.8-6.8z" /><path d="M5.7 10.3l-1.6 4.4 1.2 1.2 4.4-1.6" /><path d="M3 17.2h6" /></svg>;
|
||||
@@ -6,7 +6,8 @@ const STEP = 24;
|
||||
const BIG_STEP = 96;
|
||||
|
||||
// containerRef: the element the panel sits at the right edge of (for measuring and clamping).
|
||||
export function useResizableWidth({ storageKey, min = 320, maxAbsolute = 760, fallback = 448, reserve = 600, containerRef }) {
|
||||
// cssVar: the custom property on the container that carries the width while dragging.
|
||||
export function useResizableWidth({ storageKey, min = 320, maxAbsolute = 760, fallback = 448, reserve = 600, containerRef, cssVar = '--study-right' }) {
|
||||
const maxFor = () => {
|
||||
const w = containerRef.current?.getBoundingClientRect().width ?? 0;
|
||||
return w > 0 ? Math.max(min, Math.min(maxAbsolute, w - reserve)) : maxAbsolute;
|
||||
@@ -51,7 +52,7 @@ export function useResizableWidth({ storageKey, min = 320, maxAbsolute = 760, fa
|
||||
const right = containerRef.current?.getBoundingClientRect().right ?? window.innerWidth;
|
||||
const next = clamp(right - e.clientX);
|
||||
dragRef.current.width = next;
|
||||
containerRef.current?.style.setProperty('--study-right', `${next}px`); // smooth: no React render per move
|
||||
containerRef.current?.style.setProperty(cssVar, `${next}px`); // smooth: no React render per move
|
||||
},
|
||||
onPointerUp: (e) => {
|
||||
if (!dragRef.current) return;
|
||||
@@ -65,9 +66,9 @@ export function useResizableWidth({ storageKey, min = 320, maxAbsolute = 760, fa
|
||||
const step = e.shiftKey ? BIG_STEP : STEP;
|
||||
if (e.key === 'ArrowLeft') { e.preventDefault(); commit(clamp(current(panelRef.current) + step)); }
|
||||
else if (e.key === 'ArrowRight') { e.preventDefault(); commit(clamp(current(panelRef.current) - step)); }
|
||||
else if (e.key === 'Home' || e.key === 'Escape') { e.preventDefault(); containerRef.current?.style.removeProperty('--study-right'); commit(null); }
|
||||
else if (e.key === 'Home' || e.key === 'Escape') { e.preventDefault(); containerRef.current?.style.removeProperty(cssVar); commit(null); }
|
||||
},
|
||||
onDoubleClick: () => { containerRef.current?.style.removeProperty('--study-right'); commit(null); },
|
||||
onDoubleClick: () => { containerRef.current?.style.removeProperty(cssVar); commit(null); },
|
||||
title: 'Drag to resize · double-click to reset',
|
||||
});
|
||||
|
||||
|
||||
@@ -79,3 +79,12 @@ test('the handle describes itself for assistive technology', () => {
|
||||
const props = setup().handle();
|
||||
expect(props).toMatchObject({ role: 'separator', 'aria-orientation': 'vertical', 'aria-label': 'Resize side panel', tabIndex: 0, 'aria-valuenow': 448 });
|
||||
});
|
||||
|
||||
test('a different panel can name its own CSS variable', () => {
|
||||
const h = setup({ cssVar: '--reader-pane' });
|
||||
act(() => { down(h); move(h, 1000); });
|
||||
expect(h.style.setProperty).toHaveBeenLastCalledWith('--reader-pane', '400px');
|
||||
act(() => up(h));
|
||||
act(() => { h.handle().onDoubleClick(); });
|
||||
expect(h.style.removeProperty).toHaveBeenLastCalledWith('--reader-pane');
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { bookOptions } from '../utils/bibleUtils.jsx';
|
||||
import { getPlan, getPlanSchedule, findDayIndexForRef, formatDayReadings, nextUnreadDayIndex, computeStreak, nearestReadingDay } from '../utils/readingPlans.js';
|
||||
import DrawCanvas from './DrawCanvas.jsx';
|
||||
import { READER_TRANSLATIONS } from '../utils/bibleText.js';
|
||||
import { useResizableWidth } from '../hooks/useResizableWidth.js';
|
||||
import { useParallelChapter } from '../hooks/useParallelChapter.js';
|
||||
import { useOfflineSave, useOnline } from '../hooks/useOfflineSave.js';
|
||||
import { useBibleSearch } from '../hooks/useBibleSearch.js';
|
||||
@@ -102,12 +103,16 @@ export default function ReaderPage() {
|
||||
const parallelChapter = useParallelChapter(parallelId, readerBookAbbrev, readerChapter);
|
||||
const parallelSource = READER_TRANSLATIONS.find((t) => t.id === parallelId);
|
||||
|
||||
const [sheet, setSheet] = useState(null); // null | 'nav' | 'settings' | 'search' | 'bookmarks' | 'audio'
|
||||
const [sheet, setSheet] = useState(null); // null | 'nav' | 'settings' | 'search' | 'bookmarks' | 'highlights' | 'audio'
|
||||
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);
|
||||
// The side panel's width: drag its left edge (remembered on this device)
|
||||
const rootRef = useRef(null);
|
||||
const paneRef = useRef(null);
|
||||
const pane = useResizableWidth({ storageKey: 'reader-pane-width', containerRef: rootRef, min: 320, maxAbsolute: 800, fallback: 384, reserve: 460, cssVar: '--reader-pane' });
|
||||
const readerPageInkStrokes = readerInkByPage?.[`${readerBookAbbrev}_${readerChapter}`] ?? NO_STROKES;
|
||||
|
||||
|
||||
@@ -275,6 +280,7 @@ export default function ReaderPage() {
|
||||
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 === 'h' || e.key === 'H') { e.preventDefault(); openSheet('highlights'); }
|
||||
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);
|
||||
@@ -411,7 +417,7 @@ export default function ReaderPage() {
|
||||
const paneOpen = !!selectedVerse && !readerDrawMode;
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh bg-[var(--r-bg)] text-[var(--r-text)]" style={themeVars(readerTheme)}
|
||||
<div ref={rootRef} className="min-h-dvh bg-[var(--r-bg)] text-[var(--r-text)]" style={{ ...themeVars(readerTheme), ...(pane.width ? { '--reader-pane': `${pane.width}px` } : {}) }}
|
||||
onTouchStart={handleTouchStart} onTouchEnd={handleTouchEnd}>
|
||||
{chromeVisible && (
|
||||
<TopBar
|
||||
@@ -420,7 +426,9 @@ export default function ReaderPage() {
|
||||
onOpenNavigator={() => openSheet('nav')}
|
||||
onSearch={() => openSheet('search')}
|
||||
onBookmarks={() => openSheet('bookmarks')}
|
||||
bookmarkCount={bookmarkEntries.length + highlightEntries.length}
|
||||
bookmarkCount={bookmarkEntries.length}
|
||||
onHighlights={() => openSheet('highlights')}
|
||||
highlightCount={highlightEntries.length}
|
||||
onCompare={() => openSheet('compare')} compareActive={!!parallelId}
|
||||
onSettings={() => openSheet('settings')}
|
||||
onDoneDrawing={readerDrawMode ? () => setReaderDrawMode(false) : undefined}
|
||||
@@ -449,7 +457,7 @@ export default function ReaderPage() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className={paneOpen ? 'lg:pr-[24rem]' : ''}>
|
||||
<div className={paneOpen ? 'lg:pr-[var(--reader-pane,24rem)]' : ''}>
|
||||
{!readerDrawMode && (
|
||||
<ReaderText
|
||||
title={`${readerBook?.name ?? ''} ${readerChapter}`}
|
||||
@@ -544,6 +552,14 @@ export default function ReaderPage() {
|
||||
onJump={jump}
|
||||
onClose={() => setReaderSelectedVerse(null)}
|
||||
request={xrefRequest}
|
||||
panelRef={paneRef}
|
||||
resizeHandle={(
|
||||
// Drag this edge to resize (arrow keys too; double-click resets). Wide screens only.
|
||||
<div {...pane.handleProps(paneRef)}
|
||||
className="group absolute -left-1.5 top-0 z-20 hidden h-full w-3 cursor-col-resize touch-none select-none focus:outline-none lg:block">
|
||||
<span className="mx-auto block h-full w-px bg-transparent transition group-hover:w-0.5 group-hover:bg-[var(--r-accent)] group-focus-visible:w-0.5 group-focus-visible:bg-[var(--r-accent)]" />
|
||||
</div>
|
||||
)}
|
||||
verseHighlights={verseHighlights} colorLabels={readerColorLabels ?? {}}
|
||||
onHighlightVerse={highlightVerse} onClearVerseHighlights={clearVerseHighlights}
|
||||
/>
|
||||
@@ -596,7 +612,8 @@ export default function ReaderPage() {
|
||||
onJump={jump}
|
||||
/>
|
||||
<MarksSheet
|
||||
open={sheet === 'bookmarks'} onClose={closeSheet}
|
||||
open={sheet === 'bookmarks' || sheet === 'highlights'} onClose={closeSheet}
|
||||
initialTab={sheet === 'bookmarks' ? 'bookmarks' : 'highlights'}
|
||||
bookmarks={bookmarkEntries} highlights={highlightEntries}
|
||||
colorLabels={readerColorLabels ?? {}} onSetLabel={setReaderColorLabel}
|
||||
onJump={jump}
|
||||
|
||||
@@ -117,6 +117,43 @@ test('tapping a verse colour again takes it off, and Clear removes them all (wit
|
||||
Object.assign(ctx, saved);
|
||||
});
|
||||
|
||||
test('a Highlights button sits in the top bar and opens the highlights; Bookmarks opens the bookmarks', () => {
|
||||
const saved = { ...ctx };
|
||||
Object.assign(ctx, {
|
||||
readerTextHighlights: [{ id: 'h1', book: 'JHN', chapter: 3, verse: 2, startOffset: 0, endOffset: 4, color: '#bbf7d0', text: 'This' }],
|
||||
readerBookmarks: { 'JHN-3-1': '#fef08a' },
|
||||
});
|
||||
render(<ReaderPage />);
|
||||
const highlights = screen.getByLabelText('Highlights');
|
||||
expect(highlights.nextElementSibling).toBe(screen.getByLabelText('Bookmarks')); // beside the bookmarks button
|
||||
fireEvent.click(highlights);
|
||||
expect(screen.getByRole('tab', { name: /highlights \(1\)/i })).toHaveAttribute('aria-selected', 'true');
|
||||
expect(screen.getByText(/“This”/)).toBeTruthy();
|
||||
fireEvent.click(screen.getByLabelText('Close'));
|
||||
fireEvent.click(screen.getByLabelText('Bookmarks'));
|
||||
expect(screen.getByRole('tab', { name: /bookmarks \(1\)/i })).toHaveAttribute('aria-selected', 'true');
|
||||
expect(screen.getByText('John 3:1')).toBeTruthy();
|
||||
Object.assign(ctx, saved);
|
||||
});
|
||||
|
||||
test('the H key opens the highlights', () => {
|
||||
render(<ReaderPage />);
|
||||
fireEvent.keyDown(window, { key: 'h' });
|
||||
expect(screen.getByRole('dialog', { name: /bookmarks & highlights/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
test('the side panel has a drag handle to resize it, and no handle when no verse is open', () => {
|
||||
const saved = { ...ctx };
|
||||
render(<ReaderPage />);
|
||||
expect(screen.queryByRole('separator', { name: /resize side panel/i })).toBeNull();
|
||||
Object.assign(ctx, { readerSelectedVerse: 1 });
|
||||
render(<ReaderPage />);
|
||||
const handle = screen.getByRole('separator', { name: /resize side panel/i });
|
||||
expect(handle).toHaveAttribute('aria-orientation', 'vertical');
|
||||
expect(handle).toHaveAttribute('tabindex', '0'); // reachable by keyboard (arrow keys resize)
|
||||
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