Compare commits
No commits in common. "35c1a8556060afdd446c15f11f32dccf9d6ad65c" and "72e47190ea7bd7ca6db29c3c05db70c996eb74e6" have entirely different histories.
35c1a85560
...
72e47190ea
5 changed files with 5 additions and 128 deletions
10
CHANGELOG.md
10
CHANGELOG.md
|
|
@ -1,15 +1,5 @@
|
|||
# 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.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "polaris-task-force",
|
||||
"version": "0.2.55",
|
||||
"version": "0.2.54",
|
||||
"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",
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ import {
|
|||
bookColumnCount,
|
||||
bookColumnOffset,
|
||||
bookPageCount,
|
||||
bookSwipeDirection,
|
||||
} from "@/lib/story/bookLayout";
|
||||
import { READER_BODY_CLASSES } from "@/lib/story/richtextClasses";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -34,8 +33,6 @@ 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]);
|
||||
|
||||
|
|
@ -103,84 +100,6 @@ 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)));
|
||||
|
|
@ -231,9 +150,8 @@ 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, touchAction: "pan-y" }}
|
||||
style={{ maxWidth: BOOK_MAX_WIDTH }}
|
||||
>
|
||||
<div
|
||||
ref={contentRef}
|
||||
|
|
@ -242,8 +160,8 @@ export function BookView({ beats }: { readonly beats: readonly StoryViewerBeat[]
|
|||
columns: columnCount,
|
||||
columnGap: BOOK_COLUMN_GAP,
|
||||
columnFill: "auto",
|
||||
transform: `translateX(${-(offset - drag.dx)}px)`,
|
||||
transition: drag.active ? "none" : "transform 400ms ease",
|
||||
transform: `translateX(-${offset}px)`,
|
||||
transition: "transform 400ms ease",
|
||||
willChange: "transform",
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -56,21 +56,3 @@ 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, bookSwipeDirection } from "@/lib/story/bookLayout";
|
||||
import { bookColumnOffset, bookPageCount } from "@/lib/story/bookLayout";
|
||||
|
||||
let payload: Payload;
|
||||
|
||||
|
|
@ -582,17 +582,4 @@ 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