1
0
Fork 0
polaris-task-force/src/lib/story/bookLayout.ts
Z8MB1E 9b90377f50 feat(story): swipe page turns in book view
Touch drags flip pages: the page follows the finger live, the axis locks
on first movement so vertical pans never flip, and edge drags get
rubber-band resistance. Swipes starting on interactive controls are
ignored. Swipe intent lives in bookSwipeDirection with unit tests.
2026-09-25 15:55:21 -04:00

76 lines
2.7 KiB
TypeScript

/**
* 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);
}
/**
* Swipe intent for a touch drag on the book: -1 = previous page, 1 = next
* page, 0 = no flip. Requires horizontal dominance (a vertical scroll gesture
* never flips) and travel past a threshold proportional to the page width.
* Swiping left reveals the next page; swiping right goes back.
*/
export function bookSwipeDirection(
deltaX: number,
deltaY: number,
pageAreaWidth: number,
): -1 | 0 | 1 {
if (Math.abs(deltaX) <= Math.abs(deltaY)) return 0;
const threshold = Math.max(48, pageAreaWidth * 0.18);
if (deltaX <= -threshold) return 1;
if (deltaX >= threshold) return -1;
return 0;
}