Add reader navigation: swipe, keyboard, side arrows, and wide layout

- Swipe left/right on mobile to go between chapters (60px threshold,
  ignores vertical-dominant gestures so scrolling still works)
- Left/right arrow keys on desktop navigate chapters (skips inputs)
- Fixed side ‹ › buttons at viewport midpoint for one-tap chapter nav;
  disappear when at the first/last chapter, hidden in draw mode
- Wide toggle (⊞) switches between narrow reading column (max-w-3xl)
  and full-width two-column layout (max-w-7xl, md:columns-2); persisted
  to localStorage so the preference survives page reloads

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmert
2026-08-13 16:17:41 -04:00
parent 90fd5c4e9e
commit f2de985190
+74 -4
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef } from 'react';
import { useApp } from '../context/AppContext.js'; import { useApp } from '../context/AppContext.js';
import { bookOptions, BookmarkIcon, CopyIcon } from '../App.jsx'; import { bookOptions, BookmarkIcon, CopyIcon } from '../App.jsx';
import DrawCanvas from './DrawCanvas.jsx'; import DrawCanvas from './DrawCanvas.jsx';
@@ -52,8 +52,42 @@ export default function ReaderPage() {
const readerBook = bookOptions.find((b) => b.abbrev === readerBookAbbrev); const readerBook = bookOptions.find((b) => b.abbrev === readerBookAbbrev);
const [readerDrawMode, setReaderDrawMode] = useState(false); const [readerDrawMode, setReaderDrawMode] = useState(false);
const [readerWideLayout, setReaderWideLayout] = useState(() => localStorage.getItem('reader-wide') === '1');
const readerPageInkStrokes = readerInkByPage?.[`${readerBookAbbrev}_${readerChapter}`] ?? []; const readerPageInkStrokes = readerInkByPage?.[`${readerBookAbbrev}_${readerChapter}`] ?? [];
const [selectionPicker, setSelectionPicker] = useState(null); const [selectionPicker, setSelectionPicker] = useState(null);
const swipeTouchRef = useRef(null);
useEffect(() => {
localStorage.setItem('reader-wide', readerWideLayout ? '1' : '0');
}, [readerWideLayout]);
// Swipe left/right to navigate chapters
const handleTouchStart = (e) => {
if (readerDrawMode) return;
const t = e.changedTouches[0];
swipeTouchRef.current = { x: t.clientX, y: t.clientY };
};
const handleTouchEnd = (e) => {
if (readerDrawMode || !swipeTouchRef.current) return;
const t = e.changedTouches[0];
const dx = t.clientX - swipeTouchRef.current.x;
const dy = t.clientY - swipeTouchRef.current.y;
swipeTouchRef.current = null;
if (Math.abs(dx) < 60 || Math.abs(dy) > Math.abs(dx) * 0.8) return;
if (dx < 0) readerGoToNextChapter();
else readerGoToPreviousChapter();
};
// Arrow keys for desktop
useEffect(() => {
const handleKey = (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) return;
if (e.key === 'ArrowLeft') readerGoToPreviousChapter();
else if (e.key === 'ArrowRight') readerGoToNextChapter();
};
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, [readerGoToPreviousChapter, readerGoToNextChapter]);
// Dismiss the highlight picker when tapping outside it // Dismiss the highlight picker when tapping outside it
useEffect(() => { useEffect(() => {
@@ -167,7 +201,11 @@ export default function ReaderPage() {
</div> </div>
</div> </div>
</header> </header>
<main className={`mx-auto px-4 py-8 sm:px-6 lg:px-8 ${readerDrawMode ? 'max-w-full' : 'max-w-3xl'}`}> <main
className={`mx-auto px-4 py-8 sm:px-6 lg:px-8 ${readerDrawMode ? 'max-w-full' : readerWideLayout ? 'max-w-7xl' : 'max-w-3xl'}`}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
{/* Navigation + tools bar */} {/* Navigation + tools bar */}
<div className="mb-4 flex flex-wrap items-center gap-3 rounded-3xl border border-slate-200 bg-white p-4 shadow-panel"> <div className="mb-4 flex flex-wrap items-center gap-3 rounded-3xl border border-slate-200 bg-white p-4 shadow-panel">
<label className="text-sm text-slate-600"> <label className="text-sm text-slate-600">
@@ -299,6 +337,13 @@ export default function ReaderPage() {
> >
Draw Draw
</button> </button>
<button
type="button"
onClick={() => setReaderWideLayout((v) => !v)}
className={`rounded-lg px-3 py-1 text-xs font-semibold transition ${readerWideLayout ? 'bg-slate-900 text-white' : 'border border-slate-300 text-slate-600 hover:bg-slate-50'}`}
>
Wide
</button>
</div> </div>
{/* Whole-Bible search results */} {/* Whole-Bible search results */}
@@ -404,7 +449,8 @@ export default function ReaderPage() {
return <p className="text-sm text-slate-500">No verses match "{readerSearch}".</p>; return <p className="text-sm text-slate-500">No verses match "{readerSearch}".</p>;
} }
return ( return (
<div className="space-y-3 leading-relaxed text-slate-800" style={{ fontSize: `${readerFontSize}em` }} <div className={`leading-relaxed text-slate-800 ${readerWideLayout ? 'md:columns-2 md:gap-x-10 space-y-0' : 'space-y-3'}`}
style={{ fontSize: `${readerFontSize}em` }}
onPointerUp={handleSelectionEnd}> onPointerUp={handleSelectionEnd}>
{filtered.map((verse) => { {filtered.map((verse) => {
const chapterInterlinear = readerInterlinear?.[String(readerChapter)]; const chapterInterlinear = readerInterlinear?.[String(readerChapter)];
@@ -416,7 +462,7 @@ export default function ReaderPage() {
return ( return (
<div key={verse.number} id={`reader-verse-${verse.number}`} data-verse-num={verse.number} <div key={verse.number} id={`reader-verse-${verse.number}`} data-verse-num={verse.number}
className="group rounded-xl transition" className="group break-inside-avoid rounded-xl transition mb-3"
style={bmColor ? { backgroundColor: bmColor + '55', borderLeft: `3px solid ${bmColor}`, paddingLeft: '0.5rem' } : {}}> style={bmColor ? { backgroundColor: bmColor + '55', borderLeft: `3px solid ${bmColor}`, paddingLeft: '0.5rem' } : {}}>
<div className="flex items-start gap-1"> <div className="flex items-start gap-1">
<button type="button" <button type="button"
@@ -534,6 +580,30 @@ export default function ReaderPage() {
)} )}
</main> </main>
{/* Side chapter nav — fixed left/right arrows */}
{!readerDrawMode && (
<>
<button
type="button"
onClick={readerGoToPreviousChapter}
disabled={readerChapter <= 1}
aria-label="Previous chapter"
className="fixed left-3 top-1/2 -translate-y-1/2 z-10 flex h-10 w-10 items-center justify-center rounded-full border border-slate-200 bg-white/80 text-slate-500 shadow-md backdrop-blur-sm transition hover:bg-white hover:text-slate-900 disabled:opacity-0 disabled:pointer-events-none"
>
</button>
<button
type="button"
onClick={readerGoToNextChapter}
disabled={readerChapter >= readerTotalChapters}
aria-label="Next chapter"
className="fixed right-3 top-1/2 -translate-y-1/2 z-10 flex h-10 w-10 items-center justify-center rounded-full border border-slate-200 bg-white/80 text-slate-500 shadow-md backdrop-blur-sm transition hover:bg-white hover:text-slate-900 disabled:opacity-0 disabled:pointer-events-none"
>
</button>
</>
)}
{/* Highlight color picker — appears above text selection */} {/* Highlight color picker — appears above text selection */}
{selectionPicker && ( {selectionPicker && (
<div <div