fix(reader): harden swipe/long-press coexistence and next-chapter prefetch
All checks were successful
deploy / test-backend (push) Successful in 32m21s
deploy / test-frontend (push) Successful in 10m33s
deploy / build-and-push (push) Successful in 11m40s
deploy / deploy (push) Successful in 18s

- A long-press now cancels any pending swipe, so a hold-then-drag gesture
  opens the action sheet without also turning the page (regression-tested).
- The next-chapter prefetch no longer retries on every page flip when the
  endpoint fails (guard clears only when the next chapter changes), and it
  clears a previous chapter's warmed images on navigation so stale hidden
  <img>s don't linger.
- Tap zones set touch-action: pan-y so vertical panning stays native while
  horizontal swipes reach our handler.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-04 21:57:36 +02:00
parent bfaa166e0a
commit 9c4a93c058
8 changed files with 72 additions and 9 deletions

2
backend/Cargo.lock generated
View File

@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mangalord"
version = "0.104.0"
version = "0.105.0"
dependencies = [
"anyhow",
"argon2",

View File

@@ -1,6 +1,6 @@
[package]
name = "mangalord"
version = "0.104.0"
version = "0.105.0"
edition = "2021"
default-run = "mangalord"

View File

@@ -7,6 +7,7 @@ import { test, expect, type Page } from './fixtures';
const MANGA_ID = 'm1';
const CH1 = 'c1';
const CH2 = 'c2';
const CH3 = 'c3';
function pages(prefix: string, n: number) {
return Array.from({ length: n }, (_, i) => ({
@@ -36,11 +37,13 @@ async function mockReader(page: Page) {
if (pathname.endsWith('/auth/me/preferences')) return json(401, { error: { code: 'unauthenticated', message: 'no' } });
if (pathname.endsWith(`/chapters/${CH1}/pages`)) return json(200, { pages: pages(CH1, 5) });
if (pathname.endsWith(`/chapters/${CH2}/pages`)) return json(200, { pages: pages(CH2, 4) });
if (pathname.endsWith(`/chapters/${CH3}/pages`)) return json(200, { pages: pages(CH3, 3) });
if (pathname.endsWith(`/chapters/${CH1}`)) return json(200, chapter(CH1, 1, 5));
if (pathname.endsWith(`/chapters/${CH2}`)) return json(200, chapter(CH2, 2, 4));
if (pathname.endsWith(`/chapters/${CH3}`)) return json(200, chapter(CH3, 3, 3));
if (pathname.includes(`/mangas/${MANGA_ID}/chapters`)) {
// Oldest-first, as the reader expects (next = index + 1).
return json(200, { items: [chapter(CH1, 1, 5), chapter(CH2, 2, 4)], page: { limit: 200, offset: 0, total: 2 } });
return json(200, { items: [chapter(CH1, 1, 5), chapter(CH2, 2, 4), chapter(CH3, 3, 3)], page: { limit: 200, offset: 0, total: 3 } });
}
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' } });
@@ -65,3 +68,19 @@ test('prefetches the next chapter first pages only when near the end', async ({
await expect(preloads).toHaveCount(3);
await expect(preloads.first()).toHaveAttribute('src', /c2\/1/);
});
test('clears stale prefetch when navigating to the next chapter', async ({ page }) => {
await mockReader(page);
await page.goto(`/manga/${MANGA_ID}/chapter/${CH1}`);
await expect(page.getByTestId('reader-page')).toBeVisible();
// Warm chapter 2's pages near the end of chapter 1.
await page.keyboard.press('End');
await expect(page.getByTestId('reader-next-preload')).toHaveCount(3);
// Navigate to chapter 2. At its start (not near the end) the previous
// prefetch must be cleared, even though chapter 2 has its own next (3).
await page.goto(`/manga/${MANGA_ID}/chapter/${CH2}`);
await expect(page.getByTestId('reader-page')).toBeVisible();
await expect(page.getByTestId('reader-next-preload')).toHaveCount(0);
});

View File

@@ -51,6 +51,36 @@ async function swipe(page: Page, dx: number, dy: number) {
);
}
// Dispatch pointerdown, hold (so long-press fires), then swipe+lift.
async function holdThenSwipe(page: Page, holdMs: number, dx: number) {
await page.evaluate(() => {
const el = document.querySelector('[data-testid="reader-tap-center"]');
if (!el) throw new Error('tap zone not found');
el.dispatchEvent(new PointerEvent('pointerdown', { clientX: 300, clientY: 500, pointerType: 'touch', bubbles: true, cancelable: true }));
});
await page.waitForTimeout(holdMs);
await page.evaluate(({ dx }) => {
const el = document.querySelector('[data-testid="reader-tap-center"]');
if (!el) throw new Error('tap zone not found');
el.dispatchEvent(new PointerEvent('pointermove', { clientX: 300 + dx, clientY: 505, pointerType: 'touch', bubbles: true, cancelable: true }));
el.dispatchEvent(new PointerEvent('pointerup', { clientX: 300 + dx, clientY: 505, pointerType: 'touch', bubbles: true, cancelable: true }));
}, { dx });
}
test('a long-press then swipe does not also turn the page', async ({ page }) => {
await mockReader(page);
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(`/manga/${MANGA_ID}/chapter/${CHAPTER_ID}`);
const img = page.getByTestId('reader-page');
await expect(img).toHaveAttribute('src', /k\/1/);
// Hold past the 450ms long-press threshold, then swipe left before lifting.
await holdThenSwipe(page, 550, -200);
// The long-press consumed the gesture — the page must not have turned.
await expect(img).toHaveAttribute('src', /k\/1/);
});
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

View File

@@ -1,12 +1,12 @@
{
"name": "mangalord-frontend",
"version": "0.104.0",
"version": "0.105.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mangalord-frontend",
"version": "0.104.0",
"version": "0.105.0",
"devDependencies": {
"@lucide/svelte": "^1.16.0",
"@playwright/test": "^1.48.0",

View File

@@ -1,6 +1,6 @@
{
"name": "mangalord-frontend",
"version": "0.104.0",
"version": "0.105.0",
"private": true,
"type": "module",
"scripts": {

View File

@@ -69,6 +69,10 @@
const startY = e.clientY;
pressTimer = setTimeout(() => {
pressTimer = null;
// A long-press consumed the gesture — cancel any swipe so a
// subsequent drag-then-lift (finger held, then moved) doesn't
// also turn the page on top of opening the action sheet.
swipeStart = null;
suppressNextClick = true;
if (suppressExpiryTimer != null) clearTimeout(suppressExpiryTimer);
suppressExpiryTimer = setTimeout(() => {
@@ -200,6 +204,9 @@
padding: 0;
margin: 0;
cursor: pointer;
/* Let vertical panning through to the page but keep horizontal
gestures for our swipe handler rather than browser scroll. */
touch-action: pan-y;
/* No visible style — these are interaction surfaces, not
controls. Focus-visible is honored so keyboard users get
a ring if they tab into one. */

View File

@@ -973,11 +973,18 @@
$effect(() => {
const nc = nextChapter;
// Drop a previous chapter's warmed images once the next chapter
// changes (e.g. after navigating), so stale <img>s don't linger.
if (preloadedForChapterId !== null && (!nc || preloadedForChapterId !== nc.id)) {
preloadedForChapterId = null;
nextPreloadUrls = [];
}
if (!nc || pages.length === 0) return;
if (pages.length - furthestPage > PRELOAD_WITHIN_PAGES) return;
if (preloadedForChapterId === nc.id) return;
// Guard set before the await so overlapping effect runs don't
// double-fetch; cleared on failure so a later pass can retry.
// Marked before the await and NOT reset on failure, so a broken
// endpoint can't re-fetch on every page flip near the end — the
// guard clears only when the next chapter itself changes (above).
preloadedForChapterId = nc.id;
getChapterPages(manga.id, nc.id)
.then((ps) => {
@@ -987,7 +994,7 @@
.map((p) => fileUrl(p.storage_key));
})
.catch(() => {
if (preloadedForChapterId === nc.id) preloadedForChapterId = null;
// Best-effort prefetch — swallow and don't retry this chapter.
});
});