From 9b90377f502db914d4ba0a749753909e439864f2 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Fri, 25 Sep 2026 15:55:21 -0400 Subject: [PATCH] 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. --- src/components/frontend/story/BookView.tsx | 88 +++++++++++++++++++++- src/lib/story/bookLayout.ts | 18 +++++ tests/int/story-beats.int.spec.ts | 15 +++- 3 files changed, 117 insertions(+), 4 deletions(-) diff --git a/src/components/frontend/story/BookView.tsx b/src/components/frontend/story/BookView.tsx index 68c0670..570e444 100644 --- a/src/components/frontend/story/BookView.tsx +++ b/src/components/frontend/story/BookView.tsx @@ -13,6 +13,7 @@ import { bookColumnCount, bookColumnOffset, bookPageCount, + bookSwipeDirection, } from "@/lib/story/bookLayout"; import { READER_BODY_CLASSES } from "@/lib/story/richtextClasses"; import { cn } from "@/lib/utils"; @@ -33,6 +34,8 @@ export function BookView({ beats }: { readonly beats: readonly StoryViewerBeat[] const triggerRef = useRef(null); const closeButtonRef = useRef(null); const contentRef = useRef(null); + const pagesRef = useRef(null); + const [drag, setDrag] = useState<{ active: boolean; dx: number }>({ active: false, dx: 0 }); const chapters = useMemo(() => [...beats].sort((a, b) => a.chapter - b.chapter), [beats]); @@ -100,6 +103,84 @@ export function BookView({ beats }: { readonly beats: readonly StoryViewerBeat[] }; }, [open, measure]); + // Touch swipes flip pages on mobile; the page follows the finger live. + // Native listeners are required because React's synthetic touchmove is + // passive and would ignore preventDefault. + useEffect(() => { + if (!open) return; + const el = pagesRef.current; + if (!el) return; + + let start: { id: number; x: number; y: number } | null = null; + let last = { dx: 0, dy: 0 }; + let horizontal: boolean | null = null; + + const onTouchStart = (event: TouchEvent) => { + if (event.touches.length !== 1) return; + const touch = event.touches[0]; + const target = touch.target as HTMLElement | null; + // Never hijack touches that begin on an interactive control. + if (target?.closest("button, input, a, select, textarea")) return; + start = { id: touch.identifier, x: touch.clientX, y: touch.clientY }; + last = { dx: 0, dy: 0 }; + horizontal = null; + }; + + const onTouchMove = (event: TouchEvent) => { + const active = start; + if (!active) return; + const touch = Array.from(event.touches).find((t) => t.identifier === active.id); + if (!touch) return; + const dx = touch.clientX - active.x; + const dy = touch.clientY - active.y; + + // Lock the axis on the first significant movement. + if (horizontal === null) { + if (Math.abs(dx) < 10 && Math.abs(dy) < 10) return; + horizontal = Math.abs(dx) > Math.abs(dy); + if (!horizontal) { + start = null; + return; + } + } + if (!horizontal) return; + + event.preventDefault(); + last = { dx, dy }; + // Rubber-band resistance when there is no page in that direction. + const atEdge = (pageIndex === 0 && dx > 0) || (pageIndex >= pageCount - 1 && dx < 0); + setDrag({ active: true, dx: atEdge ? dx * 0.3 : dx }); + }; + + const onTouchEnd = (event: TouchEvent) => { + const active = start; + if (!active) return; + const touch = Array.from(event.changedTouches).find((t) => t.identifier === active.id); + if (touch) { + const direction = bookSwipeDirection(last.dx, last.dy, viewportWidth); + if (direction !== 0) { + setPageIndex((index) => + Math.min(Math.max(index + direction, 0), Math.max(pageCount - 1, 0)), + ); + } + } + start = null; + horizontal = null; + setDrag({ active: false, dx: 0 }); + }; + + el.addEventListener("touchstart", onTouchStart, { passive: true }); + el.addEventListener("touchmove", onTouchMove, { passive: false }); + el.addEventListener("touchend", onTouchEnd); + el.addEventListener("touchcancel", onTouchEnd); + return () => { + el.removeEventListener("touchstart", onTouchStart); + el.removeEventListener("touchmove", onTouchMove); + el.removeEventListener("touchend", onTouchEnd); + el.removeEventListener("touchcancel", onTouchEnd); + }; + }, [open, pageIndex, pageCount, viewportWidth]); + useEffect(() => { // Keep the current page valid after resizes change the page count. setPageIndex((index) => Math.min(index, Math.max(pageCount - 1, 0))); @@ -150,8 +231,9 @@ export function BookView({ beats }: { readonly beats: readonly StoryViewerBeat[]
diff --git a/src/lib/story/bookLayout.ts b/src/lib/story/bookLayout.ts index 0bae629..fdd8d33 100644 --- a/src/lib/story/bookLayout.ts +++ b/src/lib/story/bookLayout.ts @@ -56,3 +56,21 @@ export function bookColumnOffset( ): 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; +} diff --git a/tests/int/story-beats.int.spec.ts b/tests/int/story-beats.int.spec.ts index e1ac3d1..50ceb43 100644 --- a/tests/int/story-beats.int.spec.ts +++ b/tests/int/story-beats.int.spec.ts @@ -5,7 +5,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import type { StoryBeat, StoryBeatState, StoryVoiceover, User } from "@/payload-types"; import { markBeatRead, setBeatReaction } from "@/lib/story"; import { sortBeats, chronicleDisplayOrder } from "@/lib/story/sortBeats"; -import { bookColumnOffset, bookPageCount } from "@/lib/story/bookLayout"; +import { bookColumnOffset, bookPageCount, bookSwipeDirection } from "@/lib/story/bookLayout"; let payload: Payload; @@ -582,4 +582,17 @@ describe("bookLayout (Book view paging)", () => { expect(bookColumnOffset(2, 1200, 2)).toBe(1232); expect(bookColumnOffset(1, 700, 1)).toBe(732); }); + + it("decides swipe intent from direction and travel", () => { + // Swipe left (negative dx) flips forward once past the threshold. + expect(bookSwipeDirection(-100, 0, 400)).toBe(1); + // Swipe right flips back. + expect(bookSwipeDirection(100, 0, 400)).toBe(-1); + // Threshold is max(48, 18% of the page width): 72px at 400 wide. + expect(bookSwipeDirection(-72, 0, 400)).toBe(1); + expect(bookSwipeDirection(-30, 0, 400)).toBe(0); + // Vertical or diagonal drags never flip pages. + expect(bookSwipeDirection(0, 100, 400)).toBe(0); + expect(bookSwipeDirection(60, 80, 400)).toBe(0); + }); });