fix(reader): harden swipe/long-press coexistence and next-chapter prefetch
- 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:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||
|
||||
[[package]]
|
||||
name = "mangalord"
|
||||
version = "0.104.0"
|
||||
version = "0.105.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "mangalord"
|
||||
version = "0.104.0"
|
||||
version = "0.105.0"
|
||||
edition = "2021"
|
||||
default-run = "mangalord"
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mangalord-frontend",
|
||||
"version": "0.104.0",
|
||||
"version": "0.105.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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.
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user