1
0
Fork 0

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.
This commit is contained in:
Jason Fraley 2026-09-25 15:55:21 -04:00
parent 72e47190ea
commit 9b90377f50
3 changed files with 117 additions and 4 deletions

View file

@ -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<HTMLButtonElement>(null);
const closeButtonRef = useRef<HTMLButtonElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const pagesRef = useRef<HTMLDivElement>(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[]
</div>
<div
ref={pagesRef}
className="mx-auto h-[100dvh] w-full overflow-hidden"
style={{ maxWidth: BOOK_MAX_WIDTH }}
style={{ maxWidth: BOOK_MAX_WIDTH, touchAction: "pan-y" }}
>
<div
ref={contentRef}
@ -160,8 +242,8 @@ export function BookView({ beats }: { readonly beats: readonly StoryViewerBeat[]
columns: columnCount,
columnGap: BOOK_COLUMN_GAP,
columnFill: "auto",
transform: `translateX(-${offset}px)`,
transition: "transform 400ms ease",
transform: `translateX(${-(offset - drag.dx)}px)`,
transition: drag.active ? "none" : "transform 400ms ease",
willChange: "transform",
}}
>

View file

@ -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;
}

View file

@ -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);
});
});