From 837b81b038b1850af08ac487e62fd4366ded0da1 Mon Sep 17 00:00:00 2001 From: nmemmert Date: Thu, 13 Aug 2026 16:38:25 -0400 Subject: [PATCH] =?UTF-8?q?Add=20page-flip=20reader=20mode=20=E2=80=94=20f?= =?UTF-8?q?ixed-height=20CSS-columns=20panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "Page" toggle in the reader tool strip. When enabled, the verse panel becomes a fixed-height container using CSS column-width to create one column per page-width. Content flows naturally into successive pages; long chapters simply have more pages. Bottom nav shows ← Back/Prev ch. and Next/Next ch. → with a page counter (e.g. Genesis 1 · 2/3). Swipe left/right and arrow keys also navigate pages before crossing chapter boundaries. Preference persisted to localStorage. Co-Authored-By: Claude Sonnet 4.6 --- src/pages/ReaderPage.jsx | 440 +++++++++++++++++++++++++++++---------- 1 file changed, 325 insertions(+), 115 deletions(-) diff --git a/src/pages/ReaderPage.jsx b/src/pages/ReaderPage.jsx index 0ea7b8c..4e20343 100644 --- a/src/pages/ReaderPage.jsx +++ b/src/pages/ReaderPage.jsx @@ -58,6 +58,12 @@ export default function ReaderPage() { const swipeTouchRef = useRef(null); const [toolbarVisible, setToolbarVisible] = useState(true); + const [readerPageMode, setReaderPageMode] = useState(() => localStorage.getItem('reader-page-mode') === '1'); + const [flipPage, setFlipPage] = useState(0); + const [flipTotalPages, setFlipTotalPages] = useState(1); + const [flipPageWidth, setFlipPageWidth] = useState(0); + const flipContainerRef = useRef(null); + const flipContentRef = useRef(null); useEffect(() => { localStorage.setItem('reader-wide', readerWideLayout ? '1' : '0'); @@ -65,6 +71,36 @@ export default function ReaderPage() { setToolbarVisible(!readerWideLayout); }, [readerWideLayout]); + useEffect(() => { + localStorage.setItem('reader-page-mode', readerPageMode ? '1' : '0'); + }, [readerPageMode]); + + // Reset to page 0 when navigating chapters + useEffect(() => { + setFlipPage(0); + }, [readerChapter, readerBookAbbrev]); + + // Track container width for CSS column sizing + useEffect(() => { + if (!readerPageMode || !flipContainerRef.current) return; + const el = flipContainerRef.current; + const update = () => setFlipPageWidth(el.clientWidth); + update(); + const ro = new ResizeObserver(update); + ro.observe(el); + return () => ro.disconnect(); + }, [readerPageMode, readerVerses]); + + // Count pages after content renders + useEffect(() => { + if (!readerPageMode || !flipContentRef.current || !flipPageWidth) return; + const t = setTimeout(() => { + const sw = flipContentRef.current?.scrollWidth ?? 0; + setFlipTotalPages(Math.max(1, Math.round(sw / flipPageWidth))); + }, 120); + return () => clearTimeout(t); + }, [readerPageMode, readerVerses, readerFontSize, flipPageWidth]); + // Swipe left/right to navigate chapters const handleTouchStart = (e) => { if (readerDrawMode) return; @@ -78,20 +114,30 @@ export default function ReaderPage() { 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(); + if (dx < 0) { + if (readerPageMode && flipPage < flipTotalPages - 1) setFlipPage((p) => p + 1); + else readerGoToNextChapter(); + } else { + if (readerPageMode && flipPage > 0) setFlipPage((p) => p - 1); + 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(); + if (e.key === 'ArrowLeft') { + if (readerPageMode && flipPage > 0) setFlipPage((p) => p - 1); + else readerGoToPreviousChapter(); + } else if (e.key === 'ArrowRight') { + if (readerPageMode && flipPage < flipTotalPages - 1) setFlipPage((p) => p + 1); + else readerGoToNextChapter(); + } }; window.addEventListener('keydown', handleKey); return () => window.removeEventListener('keydown', handleKey); - }, [readerGoToPreviousChapter, readerGoToNextChapter]); + }, [readerGoToPreviousChapter, readerGoToNextChapter, readerPageMode, flipPage, flipTotalPages]); // Dismiss the highlight picker when tapping outside it useEffect(() => { @@ -360,6 +406,15 @@ export default function ReaderPage() { ✕ Hide )} + {!readerDrawMode && ( + + )} {/* end collapsible toolbar */} @@ -445,128 +500,283 @@ export default function ReaderPage() { - {/* Verses — hidden when draw mode is active */} -
-

- {readerBook?.name} {readerChapter} (BSB) -

- {readerInterlinear && ( -

Click a verse number to see original words & pronunciation · bookmark icon to save · copy icon to copy

- )} - {!readerInterlinear && ( -

Hover a verse for actions

- )} - {readerLoading &&

Loading…

} - {readerError &&

{readerError}

} - {!readerLoading && !readerError && (() => { - const searchLower = readerSearchScope === 'chapter' ? readerSearch.trim().toLowerCase() : ''; - const filtered = searchLower - ? readerVerses.filter((v) => v.text.toLowerCase().includes(searchLower)) - : readerVerses; - if (searchLower && filtered.length === 0) { - return

No verses match "{readerSearch}".

; - } - return ( -
- {filtered.map((verse) => { - const chapterInterlinear = readerInterlinear?.[String(readerChapter)]; - const verseWords = chapterInterlinear?.[String(verse.number)]; - const isOpen = readerSelectedVerse === verse.number; - const verseKey = `${readerBookAbbrev}-${readerChapter}-${verse.number}`; - const bmColor = readerBookmarks[verseKey]; - const crossRefs = readerCrossRefs?.[verse.number]; + {/* Verses — normal scroll mode */} + {!readerDrawMode && !readerPageMode && ( +
+

+ {readerBook?.name} {readerChapter} (BSB) +

+ {readerInterlinear && ( +

Click a verse number to see original words & pronunciation · bookmark icon to save · copy icon to copy

+ )} + {!readerInterlinear && ( +

Hover a verse for actions

+ )} + {readerLoading &&

Loading…

} + {readerError &&

{readerError}

} + {!readerLoading && !readerError && (() => { + const searchLower = readerSearchScope === 'chapter' ? readerSearch.trim().toLowerCase() : ''; + const filtered = searchLower + ? readerVerses.filter((v) => v.text.toLowerCase().includes(searchLower)) + : readerVerses; + if (searchLower && filtered.length === 0) { + return

No verses match "{readerSearch}".

; + } + return ( +
+ {filtered.map((verse) => { + const chapterInterlinear = readerInterlinear?.[String(readerChapter)]; + const verseWords = chapterInterlinear?.[String(verse.number)]; + const isOpen = readerSelectedVerse === verse.number; + const verseKey = `${readerBookAbbrev}-${readerChapter}-${verse.number}`; + const bmColor = readerBookmarks[verseKey]; + const crossRefs = readerCrossRefs?.[verse.number]; - return ( -
-
- -

{renderVerseText(verse.number, verse.text, searchLower)}

- + return ( +
+
- {bmColor && ( +

{renderVerseText(verse.number, verse.text, searchLower)}

+ - )} - - -
- - {readerShowCrossRefs && crossRefs && ( -
- {crossRefs.slice(0, 8).map((ref, i) => { - const label = formatCrossRef(ref); - return ( - - {label} - - ); - })} + {bmColor && ( + + )} + +
- )} - {isOpen && verseWords && ( -
-
- {verseWords.map((w, i) => { - const isHebrew = w.s?.startsWith('H'); - const canSpeak = isHebrew || w.s?.startsWith('G'); + {readerShowCrossRefs && crossRefs && ( +
+ {crossRefs.slice(0, 8).map((ref, i) => { + const label = formatCrossRef(ref); return ( -
-
- {w.o} -
-
{w.t}
-
{w.g}
- {w.p &&
{w.p}
} - {w.s &&
{w.s}
} - {canSpeak && ( - - )} -
+ + {label} + ); })}
+ )} + + {isOpen && verseWords && ( +
+
+ {verseWords.map((w, i) => { + const isHebrew = w.s?.startsWith('H'); + const canSpeak = isHebrew || w.s?.startsWith('G'); + return ( +
+
+ {w.o} +
+
{w.t}
+
{w.g}
+ {w.p &&
{w.p}
} + {w.s &&
{w.s}
} + {canSpeak && ( + + )} +
+ ); + })} +
+
+ )} +
+ ); + })} +
+ ); + })()} +
+ )} + + {/* Verses — page-flip mode */} + {!readerDrawMode && readerPageMode && ( +
+ {readerLoading &&

Loading…

} + {readerError &&

{readerError}

} + {!readerLoading && !readerError && (() => { + const searchLower = readerSearchScope === 'chapter' ? readerSearch.trim().toLowerCase() : ''; + const filtered = searchLower + ? readerVerses.filter((v) => v.text.toLowerCase().includes(searchLower)) + : readerVerses; + if (searchLower && filtered.length === 0) { + return

No verses match "{readerSearch}".

; + } + return ( +
+
+

+ {readerBook?.name} {readerChapter} (BSB) +

+ {readerInterlinear + ?

Click a verse number to see original words & pronunciation

+ :

Hover a verse for actions

+ } +
+ {filtered.map((verse) => { + const chapterInterlinear = readerInterlinear?.[String(readerChapter)]; + const verseWords = chapterInterlinear?.[String(verse.number)]; + const isOpen = readerSelectedVerse === verse.number; + const verseKey = `${readerBookAbbrev}-${readerChapter}-${verse.number}`; + const bmColor = readerBookmarks[verseKey]; + const crossRefs = readerCrossRefs?.[verse.number]; + + return ( +
+
+ +

{renderVerseText(verse.number, verse.text, searchLower)}

+ + + {bmColor && ( + + )} + +
- )} -
- ); - })} -
- ); - })()} -
+ + {readerShowCrossRefs && crossRefs && ( +
+ {crossRefs.slice(0, 8).map((ref, i) => { + const label = formatCrossRef(ref); + return ( + + {label} + + ); + })} +
+ )} + + {isOpen && verseWords && ( +
+
+ {verseWords.map((w, i) => { + const isHebrew = w.s?.startsWith('H'); + const canSpeak = isHebrew || w.s?.startsWith('G'); + return ( +
+
+ {w.o} +
+
{w.t}
+
{w.g}
+ {w.p &&
{w.p}
} + {w.s &&
{w.s}
} + {canSpeak && ( + + )} +
+ ); + })} +
+
+ )} +
+ ); + })} +
+ ); + })()} + {/* Page navigation bar */} +
+ + + {readerBook?.name} {readerChapter} · {flipPage + 1} / {flipTotalPages} + + +
+
+ )} {/* Draw mode — Bible text on left, notebook canvas on right */} {readerDrawMode && (