From 3f1a5e9c41e929a09ffd66acc3a56d59f0128edc Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sat, 4 Jul 2026 21:23:34 +0200 Subject: [PATCH] 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) --- backend/Cargo.lock | 2 +- backend/Cargo.toml | 2 +- frontend/e2e/reader-swipe.spec.ts | 73 ++++++++++++++++++++++ frontend/package-lock.json | 4 +- frontend/package.json | 2 +- frontend/src/lib/components/TapZone.svelte | 29 ++++++++- frontend/src/lib/swipe.test.ts | 20 ++++++ frontend/src/lib/swipe.ts | 23 +++++++ 8 files changed, 148 insertions(+), 7 deletions(-) create mode 100644 frontend/e2e/reader-swipe.spec.ts create mode 100644 frontend/src/lib/swipe.test.ts create mode 100644 frontend/src/lib/swipe.ts diff --git a/backend/Cargo.lock b/backend/Cargo.lock index c8bd883..17c3a96 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.100.0" +version = "0.101.0" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 0432e04..05bfcf7 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.100.0" +version = "0.101.0" edition = "2021" default-run = "mangalord" diff --git a/frontend/e2e/reader-swipe.spec.ts b/frontend/e2e/reader-swipe.spec.ts new file mode 100644 index 0000000..efb0586 --- /dev/null +++ b/frontend/e2e/reader-swipe.spec.ts @@ -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: '' }); + } + 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/); +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c78181e..4e0da67 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mangalord-frontend", - "version": "0.100.0", + "version": "0.101.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mangalord-frontend", - "version": "0.100.0", + "version": "0.101.0", "devDependencies": { "@lucide/svelte": "^1.16.0", "@playwright/test": "^1.48.0", diff --git a/frontend/package.json b/frontend/package.json index 60f903f..ef6b757 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.100.0", + "version": "0.101.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/components/TapZone.svelte b/frontend/src/lib/components/TapZone.svelte index e188354..310a00b 100644 --- a/frontend/src/lib/components/TapZone.svelte +++ b/frontend/src/lib/components/TapZone.svelte @@ -1,5 +1,6 @@