feat(reader): swipe left/right to turn pages in single mode

Wire horizontal swipe into the mobile tap-zone overlay (single mode only,
so continuous mode keeps native vertical scroll). A leftward swipe past the
threshold turns to the next page, rightward to the previous; a mostly-
vertical drag is ignored so panning a tall page never turns it. The gesture
suppresses the synthesized zone tap so it doesn't double-fire. Swipe
classification is a pure, unit-tested helper; e2e drives real touch pointer
events for horizontal (both directions) and vertical cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-04 21:23:34 +02:00
parent c212deb7b0
commit 3f1a5e9c41
8 changed files with 148 additions and 7 deletions

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { swipeDirection } from '$lib/swipe';
let {
onPrev,
@@ -27,6 +28,9 @@
let pressTimer: ReturnType<typeof setTimeout> | null = null;
let pressStart: { x: number; y: number } | null = null;
// Swipe origin — tracked for every touch pointerdown (independent of the
// long-press timer) so a horizontal drag can turn the page.
let swipeStart: { x: number; y: number } | null = null;
// Self-expiring suppression: cleared either by an actual click
// reaching `withSuppress` or by the SUPPRESS_EXPIRY_MS fallback
// timer below. The latter matters when the user lifts off the
@@ -54,8 +58,11 @@
}
function onPointerDown(e: PointerEvent) {
if (!onLongPress) return;
if (e.pointerType !== 'touch') return;
// Record the swipe origin for every touch, whether or not long-press
// is wired up.
swipeStart = { x: e.clientX, y: e.clientY };
if (!onLongPress) return;
clearPress();
pressStart = { x: e.clientX, y: e.clientY };
const startX = e.clientX;
@@ -79,17 +86,35 @@
if (Math.hypot(dx, dy) > MOVE_TOLERANCE) clearPress();
}
function onPointerUp() {
function onPointerUp(e: PointerEvent) {
if (swipeStart && e.pointerType === 'touch') {
const dir = swipeDirection(e.clientX - swipeStart.x, e.clientY - swipeStart.y);
if (dir) {
// A swipe consumed the gesture — suppress the click the
// browser may synthesize on the zone so it doesn't also fire
// that zone's tap action (reusing the long-press suppressor).
suppressNextClick = true;
if (suppressExpiryTimer != null) clearTimeout(suppressExpiryTimer);
suppressExpiryTimer = setTimeout(() => {
suppressNextClick = false;
suppressExpiryTimer = null;
}, SUPPRESS_EXPIRY_MS);
(dir === 'next' ? onNext : onPrev)();
}
}
swipeStart = null;
clearPress();
}
function onPointerCancel() {
swipeStart = null;
clearPress();
}
function onScroll() {
// Scrolling while pressed almost always means the user is
// panning, not deliberately holding. Cancel.
swipeStart = null;
clearPress();
}

View File

@@ -0,0 +1,20 @@
import { describe, it, expect } from 'vitest';
import { swipeDirection } from './swipe';
describe('swipeDirection', () => {
it('classifies a leftward horizontal swipe as next', () => {
expect(swipeDirection(-100, 8)).toBe('next');
});
it('classifies a rightward horizontal swipe as prev', () => {
expect(swipeDirection(120, -10)).toBe('prev');
});
it('ignores a swipe shorter than the threshold', () => {
expect(swipeDirection(-20, 2)).toBeNull();
});
it('ignores a mostly-vertical drag so panning does not turn the page', () => {
expect(swipeDirection(-50, 90)).toBeNull();
});
});

23
frontend/src/lib/swipe.ts Normal file
View File

@@ -0,0 +1,23 @@
// Pure gesture helper for the reader's single-page swipe navigation. Kept
// UI-free so the "is this a page-turn swipe, and which way" decision is
// unit-testable without dispatching pointer events.
export const SWIPE_THRESHOLD_PX = 45;
/**
* Classifies a pointer displacement as a page-turn swipe. Returns `'next'`
* for a leftward swipe, `'prev'` for a rightward one, or `null` when the
* gesture is too short or too vertical to be a deliberate horizontal swipe
* (so vertical panning never turns the page).
*/
export function swipeDirection(
dx: number,
dy: number,
threshold: number = SWIPE_THRESHOLD_PX
): 'next' | 'prev' | null {
if (Math.abs(dx) < threshold) return null;
// Must be predominantly horizontal — a diagonal or vertical drag is not
// a page turn.
if (Math.abs(dx) <= Math.abs(dy)) return null;
return dx < 0 ? 'next' : 'prev';
}