From e113a33c870938ea9b7568d7dd6f784affe2dae2 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Thu, 24 Sep 2026 19:23:42 -0400 Subject: [PATCH] feat(story): add two-page flip book view reader Fullscreen reader alongside Immersive view: paginated two-page spread with no vertical scroll, chevron and arrow-key page turns, and a page indicator. Chapters always render in reading order (Chapter 1 first). Layout knobs (spine gap, single-column breakpoint, book max width) live in lib/story/bookLayout with unit-tested paging math. --- src/app/(frontend)/styles.css | 10 + src/components/frontend/story/BookView.tsx | 237 +++++++++++++++++++++ src/lib/story/bookLayout.ts | 58 +++++ 3 files changed, 305 insertions(+) create mode 100644 src/components/frontend/story/BookView.tsx create mode 100644 src/lib/story/bookLayout.ts diff --git a/src/app/(frontend)/styles.css b/src/app/(frontend)/styles.css index 3c18e92..0f5e037 100644 --- a/src/app/(frontend)/styles.css +++ b/src/app/(frontend)/styles.css @@ -3582,3 +3582,13 @@ z-index: 1300 !important; } +/* Book view (story reader): body prose reads left-aligned like a real book. + Center alignment authored on paragraphs in the Lexical editor renders as + inline text-align styles, which only an !important rule can override, so + the rule is scoped to the paginated Book view body (the immersive reader + keeps the authored alignment). */ +.book-view-body, +.book-view-body [style*="text-align"] { + text-align: left !important; +} + diff --git a/src/components/frontend/story/BookView.tsx b/src/components/frontend/story/BookView.tsx new file mode 100644 index 0000000..68c0670 --- /dev/null +++ b/src/components/frontend/story/BookView.tsx @@ -0,0 +1,237 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { BookOpenText, ChevronLeft, ChevronRight, X } from "lucide-react"; +import { RichText } from "@payloadcms/richtext-lexical/react"; +import { Button } from "@/components/ui/button"; +import { BeatReadMarker } from "@/components/frontend/story/BeatReadMarker"; +import { BeatVoiceover } from "@/components/frontend/story/BeatVoiceover"; +import type { StoryViewerBeat } from "@/components/frontend/story/StoryViewer"; +import { + BOOK_COLUMN_GAP, + BOOK_MAX_WIDTH, + bookColumnCount, + bookColumnOffset, + bookPageCount, +} from "@/lib/story/bookLayout"; +import { READER_BODY_CLASSES } from "@/lib/story/richtextClasses"; +import { cn } from "@/lib/utils"; + +/** + * Two-page, flip-style reader: fullscreen like the immersive view but + * paginated horizontally (no vertical scroll). Chapters always appear in + * their actual order, Chapter 1 first, regardless of the /story display + * order. Long chapters flow across pages via CSS columns; each chapter + * starts on a fresh page. + */ +export function BookView({ beats }: { readonly beats: readonly StoryViewerBeat[] }) { + const [open, setOpen] = useState(false); + const [pageIndex, setPageIndex] = useState(0); + const [pageCount, setPageCount] = useState(1); + const [columnCount, setColumnCount] = useState(2); + const [viewportWidth, setViewportWidth] = useState(0); + const triggerRef = useRef(null); + const closeButtonRef = useRef(null); + const contentRef = useRef(null); + + const chapters = useMemo(() => [...beats].sort((a, b) => a.chapter - b.chapter), [beats]); + + const measure = useCallback(() => { + const content = contentRef.current; + if (!content) return; + // The multicol element carries page margins as its own padding, so the + // paginated area is its content box and its columns start after padLeft. + const style = getComputedStyle(content); + const padLeft = parseFloat(style.paddingLeft) || 0; + const padRight = parseFloat(style.paddingRight) || 0; + const width = content.clientWidth - padLeft - padRight; + if (width <= 0) return; + const columns = bookColumnCount(width); + setColumnCount(columns); + setViewportWidth(width); + setPageCount(bookPageCount(content.scrollWidth - padLeft, width, columns, BOOK_COLUMN_GAP)); + }, []); + + // Focus + body scroll lock tied strictly to open/close, not to paging. + useEffect(() => { + if (!open) return; + + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + closeButtonRef.current?.focus(); + + return () => { + document.body.style.overflow = previousOverflow; + triggerRef.current?.focus(); + }; + }, [open]); + + // Arrow keys flip pages; re-registered per page so handlers see fresh state. + useEffect(() => { + if (!open) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setOpen(false); + if (event.key === "ArrowRight") { + setPageIndex((index) => Math.min(index + 1, Math.max(pageCount - 1, 0))); + } + if (event.key === "ArrowLeft") { + setPageIndex((index) => Math.max(index - 1, 0)); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [open, pageCount]); + + // Measure pages while open: viewport resize, font load, and content changes. + useEffect(() => { + if (!open) return; + + measure(); + const observer = new ResizeObserver(measure); + if (contentRef.current) observer.observe(contentRef.current); + window.addEventListener("resize", measure); + document.fonts?.ready.then(measure).catch(() => {}); + + return () => { + observer.disconnect(); + window.removeEventListener("resize", measure); + }; + }, [open, measure]); + + useEffect(() => { + // Keep the current page valid after resizes change the page count. + setPageIndex((index) => Math.min(index, Math.max(pageCount - 1, 0))); + }, [pageCount]); + + if (chapters.length === 0) return null; + + const offset = bookColumnOffset(pageIndex, viewportWidth, columnCount, BOOK_COLUMN_GAP); + const canFlipBack = pageIndex > 0; + const canFlipForward = pageIndex < pageCount - 1; + + return ( + <> + + + {open && ( +
+
+ +
+ +
+
+ {chapters.map((beat, index) => ( +
0 ? "column" : "auto" }} + > +
+

+ Chapter {beat.chapter} +

+ +
+
+

+ {beat.title} +

+ {beat.dateLabel && ( +

{beat.dateLabel}

+ )} +
+ {beat.voiceoverUrl && ( +
+ +
+ )} +
+ +
+
+ ))} +
+
+ +
+ +

+ Page {pageIndex + 1} of {pageCount} +

+ +
+
+ )} + + ); +} diff --git a/src/lib/story/bookLayout.ts b/src/lib/story/bookLayout.ts new file mode 100644 index 0000000..0bae629 --- /dev/null +++ b/src/lib/story/bookLayout.ts @@ -0,0 +1,58 @@ +/** + * Layout math for the BookView two-page reader. Pure, so the flip logic is + * unit-testable and the component only does DOM measurement. + * + * The book renders one column per page. A spread shows `columnCount` pages + * side by side; flipping advances one page (column) at a time by translating + * the multicol content horizontally. + */ + +/** Horizontal gap (px) between book pages: the book's spine. Keep it equal to + * or larger than the reader's horizontal padding so adjacent pages never peek + * into the page margins mid-flip. */ +export const BOOK_COLUMN_GAP = 32; + +/** Maximum width (px) of the whole two-page book window. On larger viewports + * the book stops growing and centers itself, so the capped text blocks stay + * close to the spine instead of drifting apart with huge gutters. Choose a + * value whose page width (content/2) stays close to the text cap. */ +export const BOOK_MAX_WIDTH = 1536; + +/** Viewport width (px) below which the book falls back to a single page. */ +export const BOOK_SINGLE_COLUMN_BREAKPOINT = 768; + +/** Pages shown side by side at the given viewport width. */ +export function bookColumnCount(viewportWidth: number): number { + return viewportWidth > 0 && viewportWidth < BOOK_SINGLE_COLUMN_BREAKPOINT ? 1 : 2; +} + +/** Distance between the left edges of consecutive pages. */ +export function bookPagePitch( + viewportWidth: number, + columnCount: number, + gap = BOOK_COLUMN_GAP, +): number { + return (viewportWidth + gap) / columnCount; +} + +/** Total number of pages the content spans, given the measured scrollWidth. */ +export function bookPageCount( + scrollWidth: number, + viewportWidth: number, + columnCount: number, + gap = BOOK_COLUMN_GAP, +): number { + if (viewportWidth <= 0 || scrollWidth <= 0) return 1; + const pitch = bookPagePitch(viewportWidth, columnCount, gap); + return Math.max(1, Math.ceil((scrollWidth + gap) / pitch)); +} + +/** Horizontal translation (positive px) that brings pageIndex's page into view. */ +export function bookColumnOffset( + pageIndex: number, + viewportWidth: number, + columnCount: number, + gap = BOOK_COLUMN_GAP, +): number { + return pageIndex * bookPagePitch(viewportWidth, columnCount, gap); +}