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:
parent
72e47190ea
commit
9b90377f50
3 changed files with 117 additions and 4 deletions
|
|
@ -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",
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue