1
0
Fork 0

Compare commits

...

3 commits

Author SHA1 Message Date
35c1a85560 v0.2.55 - book view touch swipes 2026-09-25 15:55:33 -04:00
a8cb690383 docs: add v0.2.55 changelog 2026-09-25 15:55:33 -04:00
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
5 changed files with 128 additions and 5 deletions

View file

@ -1,5 +1,15 @@
# Changelog
# v0.2.55
Book view goes touch: turn pages with a swipe.
### Features
- **Book view**: on touch screens you can now swipe left or right to turn pages. The page follows your finger as you drag, small or vertical movements are ignored, and the first and last page give a soft rubber-band. Voiceover controls are untouched by swipes.
---
# v0.2.54
The Chronicle becomes a proper reading room.

View file

@ -1,6 +1,6 @@
{
"name": "polaris-task-force",
"version": "0.2.54",
"version": "0.2.55",
"description": "Web app for the Arma 3 unit Polaris Task Force. Uses Next.js for client- and server-side rendering, with a Postgres database and some other neat libraries.",
"license": "MIT",
"type": "module",

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