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.
This commit is contained in:
parent
2b554ec8a3
commit
e113a33c87
3 changed files with 305 additions and 0 deletions
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
237
src/components/frontend/story/BookView.tsx
Normal file
237
src/components/frontend/story/BookView.tsx
Normal file
|
|
@ -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<HTMLButtonElement>(null);
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(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 (
|
||||
<>
|
||||
<Button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
variant="outline"
|
||||
aria-label="Open book view"
|
||||
onClick={() => {
|
||||
setPageIndex(0);
|
||||
setOpen(true);
|
||||
}}
|
||||
className="gap-2"
|
||||
>
|
||||
<BookOpenText className="size-4" />
|
||||
Book view
|
||||
</Button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="book-view-title"
|
||||
className="fixed inset-0 z-50 overflow-hidden bg-black text-white"
|
||||
>
|
||||
<div className="pointer-events-none fixed inset-x-0 top-0 z-10 flex justify-end p-4 sm:p-6">
|
||||
<Button
|
||||
ref={closeButtonRef}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Exit book view"
|
||||
onClick={() => setOpen(false)}
|
||||
className="pointer-events-auto text-white hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
<X className="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="mx-auto h-[100dvh] w-full overflow-hidden"
|
||||
style={{ maxWidth: BOOK_MAX_WIDTH }}
|
||||
>
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="h-full w-full px-8 py-14 motion-reduce:transition-none sm:py-16"
|
||||
style={{
|
||||
columns: columnCount,
|
||||
columnGap: BOOK_COLUMN_GAP,
|
||||
columnFill: "auto",
|
||||
transform: `translateX(-${offset}px)`,
|
||||
transition: "transform 400ms ease",
|
||||
willChange: "transform",
|
||||
}}
|
||||
>
|
||||
{chapters.map((beat, index) => (
|
||||
<article
|
||||
key={beat.id}
|
||||
data-beat-id={beat.id}
|
||||
className="mx-auto max-w-2xl"
|
||||
style={{ breakBefore: index > 0 ? "column" : "auto" }}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<p className="text-xs uppercase tracking-[0.3em] text-neutral-500">
|
||||
Chapter {beat.chapter}
|
||||
</p>
|
||||
<BeatReadMarker beatId={beat.id} initialRead={beat.initialRead} />
|
||||
</div>
|
||||
<div className="mt-6 text-center">
|
||||
<h2 className="font-serif text-3xl font-semibold leading-tight text-white sm:text-4xl">
|
||||
{beat.title}
|
||||
</h2>
|
||||
{beat.dateLabel && (
|
||||
<p className="mt-3 text-sm text-neutral-500">{beat.dateLabel}</p>
|
||||
)}
|
||||
</div>
|
||||
{beat.voiceoverUrl && (
|
||||
<div className="mt-6 [&_.text-muted-foreground]:text-neutral-500">
|
||||
<BeatVoiceover
|
||||
src={beat.voiceoverUrl}
|
||||
label={beat.title}
|
||||
aiGenerated={beat.voiceoverAiGenerated}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="book-view-body mt-8">
|
||||
<RichText data={beat.body} className={cn(READER_BODY_CLASSES)} />
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="fixed inset-x-0 bottom-0 z-10 flex items-center justify-center gap-4 p-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Previous page"
|
||||
disabled={!canFlipBack}
|
||||
onClick={() => setPageIndex((index) => Math.max(index - 1, 0))}
|
||||
className="text-white hover:bg-white/10 hover:text-white disabled:text-white/30"
|
||||
>
|
||||
<ChevronLeft className="size-5" />
|
||||
</Button>
|
||||
<p aria-live="polite" className="font-mono text-xs tabular-nums text-neutral-500">
|
||||
Page {pageIndex + 1} of {pageCount}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Next page"
|
||||
disabled={!canFlipForward}
|
||||
onClick={() => setPageIndex((index) => Math.min(index + 1, pageCount - 1))}
|
||||
className="text-white hover:bg-white/10 hover:text-white disabled:text-white/30"
|
||||
>
|
||||
<ChevronRight className="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
58
src/lib/story/bookLayout.ts
Normal file
58
src/lib/story/bookLayout.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
Loading…
Reference in a new issue