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:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.100.0"
|
version = "0.101.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.100.0"
|
version = "0.101.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
default-run = "mangalord"
|
default-run = "mangalord"
|
||||||
|
|
||||||
|
|||||||
73
frontend/e2e/reader-swipe.spec.ts
Normal file
73
frontend/e2e/reader-swipe.spec.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { test, expect, type Page } from './fixtures';
|
||||||
|
|
||||||
|
// Single-page reader: a horizontal swipe turns the page; a mostly-vertical
|
||||||
|
// drag does not (so panning a tall page never turns it). Swipe is wired into
|
||||||
|
// the mobile tap-zone overlay, which only exists in single mode.
|
||||||
|
|
||||||
|
const MANGA_ID = 'm1';
|
||||||
|
const CHAPTER_ID = 'c1';
|
||||||
|
|
||||||
|
async function mockReader(page: Page) {
|
||||||
|
await page.route('**/api/v1/**', async (route) => {
|
||||||
|
const { pathname } = new URL(route.request().url());
|
||||||
|
const json = (status: number, body: unknown) =>
|
||||||
|
route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) });
|
||||||
|
|
||||||
|
if (pathname.includes('/files/')) {
|
||||||
|
return route.fulfill({ status: 200, contentType: 'image/svg+xml', body: '<svg xmlns="http://www.w3.org/2000/svg" width="800" height="1000"/>' });
|
||||||
|
}
|
||||||
|
if (pathname.endsWith('/auth/config')) return json(200, { self_register_enabled: true, private_mode: false });
|
||||||
|
if (pathname.endsWith('/auth/me')) return json(401, { error: { code: 'unauthenticated', message: 'no' } });
|
||||||
|
if (pathname.endsWith('/auth/me/preferences')) return json(401, { error: { code: 'unauthenticated', message: 'no' } });
|
||||||
|
if (pathname.endsWith(`/mangas/${MANGA_ID}/chapters/${CHAPTER_ID}/pages`)) {
|
||||||
|
return json(200, { pages: [
|
||||||
|
{ id: 'p1', chapter_id: CHAPTER_ID, page_number: 1, storage_key: 'k/1', content_type: 'image/svg+xml' },
|
||||||
|
{ id: 'p2', chapter_id: CHAPTER_ID, page_number: 2, storage_key: 'k/2', content_type: 'image/svg+xml' }
|
||||||
|
] });
|
||||||
|
}
|
||||||
|
if (pathname.endsWith(`/mangas/${MANGA_ID}/chapters/${CHAPTER_ID}`)) return json(200, { id: CHAPTER_ID, manga_id: MANGA_ID, number: 1, title: null, page_count: 2, created_at: '2026-01-01T00:00:00Z', size_bytes: 0 });
|
||||||
|
if (pathname.includes(`/mangas/${MANGA_ID}/chapters`)) return json(200, { items: [{ id: CHAPTER_ID, manga_id: MANGA_ID, number: 1, title: null, page_count: 2, created_at: '2026-01-01T00:00:00Z', size_bytes: 0 }], page: { limit: 200, offset: 0, total: 1 } });
|
||||||
|
if (pathname.endsWith(`/mangas/${MANGA_ID}`)) return json(200, { id: MANGA_ID, title: 'Berserk', status: 'ongoing', alt_titles: [], description: null, cover_image_path: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', authors: [], genres: [], tags: [], content_warnings: [], chapter_storage_bytes: 0 });
|
||||||
|
if (pathname.includes('/me/read-progress')) return json(401, { error: { code: 'unauthenticated', message: 'no' } });
|
||||||
|
return json(503, { error: { code: 'e2e_unmocked', message: pathname } });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch a touch pointer swipe on the tap-zone overlay.
|
||||||
|
async function swipe(page: Page, dx: number, dy: number) {
|
||||||
|
await page.evaluate(
|
||||||
|
({ dx, dy }) => {
|
||||||
|
const el = document.querySelector('[data-testid="reader-tap-center"]');
|
||||||
|
if (!el) throw new Error('tap zone not found');
|
||||||
|
const sx = 300;
|
||||||
|
const sy = 500;
|
||||||
|
const ev = (type: string, x: number, y: number) =>
|
||||||
|
new PointerEvent(type, { clientX: x, clientY: y, pointerType: 'touch', bubbles: true, cancelable: true });
|
||||||
|
el.dispatchEvent(ev('pointerdown', sx, sy));
|
||||||
|
el.dispatchEvent(ev('pointermove', sx + dx / 2, sy + dy / 2));
|
||||||
|
el.dispatchEvent(ev('pointerup', sx + dx, sy + dy));
|
||||||
|
},
|
||||||
|
{ dx, dy }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('horizontal swipe turns the page; vertical drag does not', async ({ page }) => {
|
||||||
|
await mockReader(page);
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 }); // mobile → tap zones render
|
||||||
|
await page.goto(`/manga/${MANGA_ID}/chapter/${CHAPTER_ID}`);
|
||||||
|
|
||||||
|
const img = page.getByTestId('reader-page');
|
||||||
|
await expect(img).toHaveAttribute('src', /k\/1/);
|
||||||
|
|
||||||
|
// A near-vertical drag must NOT turn the page.
|
||||||
|
await swipe(page, 6, -220);
|
||||||
|
await expect(img).toHaveAttribute('src', /k\/1/);
|
||||||
|
|
||||||
|
// A leftward horizontal swipe advances to the next page.
|
||||||
|
await swipe(page, -200, 8);
|
||||||
|
await expect(img).toHaveAttribute('src', /k\/2/);
|
||||||
|
|
||||||
|
// A rightward horizontal swipe goes back.
|
||||||
|
await swipe(page, 200, 8);
|
||||||
|
await expect(img).toHaveAttribute('src', /k\/1/);
|
||||||
|
});
|
||||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.100.0",
|
"version": "0.101.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.100.0",
|
"version": "0.101.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@lucide/svelte": "^1.16.0",
|
"@lucide/svelte": "^1.16.0",
|
||||||
"@playwright/test": "^1.48.0",
|
"@playwright/test": "^1.48.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.100.0",
|
"version": "0.101.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { swipeDirection } from '$lib/swipe';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
onPrev,
|
onPrev,
|
||||||
@@ -27,6 +28,9 @@
|
|||||||
|
|
||||||
let pressTimer: ReturnType<typeof setTimeout> | null = null;
|
let pressTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let pressStart: { x: number; y: number } | 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
|
// Self-expiring suppression: cleared either by an actual click
|
||||||
// reaching `withSuppress` or by the SUPPRESS_EXPIRY_MS fallback
|
// reaching `withSuppress` or by the SUPPRESS_EXPIRY_MS fallback
|
||||||
// timer below. The latter matters when the user lifts off the
|
// timer below. The latter matters when the user lifts off the
|
||||||
@@ -54,8 +58,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onPointerDown(e: PointerEvent) {
|
function onPointerDown(e: PointerEvent) {
|
||||||
if (!onLongPress) return;
|
|
||||||
if (e.pointerType !== 'touch') 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();
|
clearPress();
|
||||||
pressStart = { x: e.clientX, y: e.clientY };
|
pressStart = { x: e.clientX, y: e.clientY };
|
||||||
const startX = e.clientX;
|
const startX = e.clientX;
|
||||||
@@ -79,17 +86,35 @@
|
|||||||
if (Math.hypot(dx, dy) > MOVE_TOLERANCE) clearPress();
|
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();
|
clearPress();
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPointerCancel() {
|
function onPointerCancel() {
|
||||||
|
swipeStart = null;
|
||||||
clearPress();
|
clearPress();
|
||||||
}
|
}
|
||||||
|
|
||||||
function onScroll() {
|
function onScroll() {
|
||||||
// Scrolling while pressed almost always means the user is
|
// Scrolling while pressed almost always means the user is
|
||||||
// panning, not deliberately holding. Cancel.
|
// panning, not deliberately holding. Cancel.
|
||||||
|
swipeStart = null;
|
||||||
clearPress();
|
clearPress();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
20
frontend/src/lib/swipe.test.ts
Normal file
20
frontend/src/lib/swipe.test.ts
Normal 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
23
frontend/src/lib/swipe.ts
Normal 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';
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user