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,
|
bookColumnCount,
|
||||||
bookColumnOffset,
|
bookColumnOffset,
|
||||||
bookPageCount,
|
bookPageCount,
|
||||||
|
bookSwipeDirection,
|
||||||
} from "@/lib/story/bookLayout";
|
} from "@/lib/story/bookLayout";
|
||||||
import { READER_BODY_CLASSES } from "@/lib/story/richtextClasses";
|
import { READER_BODY_CLASSES } from "@/lib/story/richtextClasses";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
@ -33,6 +34,8 @@ export function BookView({ beats }: { readonly beats: readonly StoryViewerBeat[]
|
||||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
const contentRef = useRef<HTMLDivElement>(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]);
|
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]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
// Keep the current page valid after resizes change the page count.
|
// Keep the current page valid after resizes change the page count.
|
||||||
setPageIndex((index) => Math.min(index, Math.max(pageCount - 1, 0)));
|
setPageIndex((index) => Math.min(index, Math.max(pageCount - 1, 0)));
|
||||||
|
|
@ -150,8 +231,9 @@ export function BookView({ beats }: { readonly beats: readonly StoryViewerBeat[]
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
ref={pagesRef}
|
||||||
className="mx-auto h-[100dvh] w-full overflow-hidden"
|
className="mx-auto h-[100dvh] w-full overflow-hidden"
|
||||||
style={{ maxWidth: BOOK_MAX_WIDTH }}
|
style={{ maxWidth: BOOK_MAX_WIDTH, touchAction: "pan-y" }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
|
|
@ -160,8 +242,8 @@ export function BookView({ beats }: { readonly beats: readonly StoryViewerBeat[]
|
||||||
columns: columnCount,
|
columns: columnCount,
|
||||||
columnGap: BOOK_COLUMN_GAP,
|
columnGap: BOOK_COLUMN_GAP,
|
||||||
columnFill: "auto",
|
columnFill: "auto",
|
||||||
transform: `translateX(-${offset}px)`,
|
transform: `translateX(${-(offset - drag.dx)}px)`,
|
||||||
transition: "transform 400ms ease",
|
transition: drag.active ? "none" : "transform 400ms ease",
|
||||||
willChange: "transform",
|
willChange: "transform",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -56,3 +56,21 @@ export function bookColumnOffset(
|
||||||
): number {
|
): number {
|
||||||
return pageIndex * bookPagePitch(viewportWidth, columnCount, gap);
|
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 type { StoryBeat, StoryBeatState, StoryVoiceover, User } from "@/payload-types";
|
||||||
import { markBeatRead, setBeatReaction } from "@/lib/story";
|
import { markBeatRead, setBeatReaction } from "@/lib/story";
|
||||||
import { sortBeats, chronicleDisplayOrder } from "@/lib/story/sortBeats";
|
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;
|
let payload: Payload;
|
||||||
|
|
||||||
|
|
@ -582,4 +582,17 @@ describe("bookLayout (Book view paging)", () => {
|
||||||
expect(bookColumnOffset(2, 1200, 2)).toBe(1232);
|
expect(bookColumnOffset(2, 1200, 2)).toBe(1232);
|
||||||
expect(bookColumnOffset(1, 700, 1)).toBe(732);
|
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