fix(reader): render pages at a consistent width instead of thin stripes
Reader page sizing was height-driven — `max-height: 90vh` in single mode and unbounded natural width in continuous mode. A long vertical page hit the height cap first and, preserving aspect ratio, collapsed into a thin vertical stripe; narrow and wide scans rendered at different widths. Switch both modes to width-driven sizing via a capped `--reader-page-width` reading column (min(100%, 700px)): every page renders at the same width regardless of intrinsic dimensions, and tall pages extend downward and scroll instead of shrinking. Top-align the single-mode grid and make the prev/next chevrons sticky so they stay reachable on a long page. Add a Playwright spec asserting equal rendered width across differently- proportioned pages and that a tall page exceeds the viewport height. The page-context-menu fixture served a 1x1 image that now renders as a 700x700 square taller than the viewport, making Playwright scroll it into view before right-clicking — a synthetic scroll that trips the menu's by-design close-on-scroll. Give that fixture a realistic landscape aspect so the page fits the viewport, matching real user behaviour (no scroll on right-click). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "mangalord"
|
||||
version = "0.94.0"
|
||||
version = "0.94.1"
|
||||
edition = "2021"
|
||||
default-run = "mangalord"
|
||||
|
||||
|
||||
@@ -209,12 +209,15 @@ async function mockReader(
|
||||
}
|
||||
);
|
||||
|
||||
const png = Buffer.from(
|
||||
'89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c63000100000005000158a3b62a0000000049454e44ae426082',
|
||||
'hex'
|
||||
);
|
||||
// A landscape page image (wide aspect) so that, under the width-driven
|
||||
// reader sizing, the rendered page is SHORTER than the viewport. A tall
|
||||
// page would push its centre off-screen, and Playwright's `.click()`
|
||||
// would scroll it into view first — that synthetic scroll trips the
|
||||
// context menu's (by-design) close-on-scroll and flakes these tests.
|
||||
// Real users right-click at the visible cursor with no such scroll.
|
||||
const image = `<svg xmlns="http://www.w3.org/2000/svg" width="1400" height="400"><rect width="1400" height="400" fill="#888"/></svg>`;
|
||||
await page.route('**/api/v1/files/**', (route) =>
|
||||
route.fulfill({ status: 200, contentType: 'image/png', body: png })
|
||||
route.fulfill({ status: 200, contentType: 'image/svg+xml', body: image })
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
149
frontend/e2e/reader-page-sizing.spec.ts
Normal file
149
frontend/e2e/reader-page-sizing.spec.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { test, expect, type Page } from './fixtures';
|
||||
|
||||
// Regression coverage for the reader page-sizing fix: pages must render at a
|
||||
// consistent width regardless of their intrinsic dimensions, and a long
|
||||
// (tall) page must extend downward and scroll instead of collapsing into a
|
||||
// thin vertical stripe.
|
||||
//
|
||||
// The failure this guards against: sizing used to be height-driven
|
||||
// (`max-height: 90vh` in single mode; unbounded natural width in continuous
|
||||
// mode), so a tall webtoon strip shrank to a sliver and a narrow scan and a
|
||||
// wide scan rendered at different widths.
|
||||
|
||||
const MANGA_ID = 'm1';
|
||||
const CHAPTER_ID = 'c1';
|
||||
|
||||
// A capped reading column of ~700px on desktop; both modes should honour it.
|
||||
const READING_WIDTH = 700;
|
||||
|
||||
const manga = {
|
||||
id: MANGA_ID,
|
||||
title: 'Sizing Test',
|
||||
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
|
||||
};
|
||||
|
||||
const chapter = {
|
||||
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 1 is wide (1400×900), page 2 is a tall webtoon strip (800×4000).
|
||||
const pages = [
|
||||
{ id: 'p1', chapter_id: CHAPTER_ID, page_number: 1, storage_key: 'p/wide', content_type: 'image/svg+xml' },
|
||||
{ id: 'p2', chapter_id: CHAPTER_ID, page_number: 2, storage_key: 'p/tall', content_type: 'image/svg+xml' }
|
||||
];
|
||||
|
||||
// An SVG with an explicit width/height gives the <img> a real intrinsic
|
||||
// size, so the rendered aspect ratio matches a genuine page image.
|
||||
function svg(w: number, h: number): string {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><rect width="${w}" height="${h}" fill="#888"/></svg>`;
|
||||
}
|
||||
|
||||
async function mockReader(page: Page) {
|
||||
// Single dispatcher over the whole API surface — takes precedence over
|
||||
// the fixture's 503 fallback and avoids route-glob precedence puzzles.
|
||||
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) });
|
||||
|
||||
// Images.
|
||||
if (pathname.includes('/files/p/wide')) {
|
||||
return route.fulfill({ status: 200, contentType: 'image/svg+xml', body: svg(1400, 900) });
|
||||
}
|
||||
if (pathname.includes('/files/p/tall')) {
|
||||
return route.fulfill({ status: 200, contentType: 'image/svg+xml', body: svg(800, 4000) });
|
||||
}
|
||||
|
||||
// Auth: public, anonymous.
|
||||
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' } });
|
||||
}
|
||||
|
||||
// Reader data.
|
||||
if (pathname.endsWith(`/mangas/${MANGA_ID}/chapters/${CHAPTER_ID}/pages`)) {
|
||||
return json(200, { pages });
|
||||
}
|
||||
if (pathname.endsWith(`/mangas/${MANGA_ID}/chapters/${CHAPTER_ID}`)) return json(200, chapter);
|
||||
if (pathname.includes(`/mangas/${MANGA_ID}/chapters`)) {
|
||||
return json(200, { items: [chapter], page: { limit: 200, offset: 0, total: 1 } });
|
||||
}
|
||||
if (pathname.endsWith(`/mangas/${MANGA_ID}`)) return json(200, manga);
|
||||
if (pathname.includes(`/me/read-progress/${MANGA_ID}`)) {
|
||||
return json(401, { error: { code: 'unauthenticated', message: 'no' } });
|
||||
}
|
||||
|
||||
console.warn(`[e2e] reader spec unmocked: ${pathname}`);
|
||||
return json(503, { error: { code: 'e2e_unmocked', message: pathname } });
|
||||
});
|
||||
}
|
||||
|
||||
test('continuous mode renders all pages at a consistent capped width; tall pages scroll', async ({ page }) => {
|
||||
await page.addInitScript(() => localStorage.setItem('mangalord-reader-mode', 'continuous'));
|
||||
await mockReader(page);
|
||||
|
||||
await page.goto(`/manga/${MANGA_ID}/chapter/${CHAPTER_ID}`);
|
||||
|
||||
const wide = page.getByTestId('reader-page-1');
|
||||
const tall = page.getByTestId('reader-page-2');
|
||||
await expect(wide).toBeVisible();
|
||||
await expect(tall).toBeVisible();
|
||||
|
||||
const wideBox = await wide.boundingBox();
|
||||
const tallBox = await tall.boundingBox();
|
||||
const viewport = page.viewportSize();
|
||||
if (!wideBox || !tallBox || !viewport) throw new Error('missing layout metrics');
|
||||
|
||||
// 1. Consistent width regardless of intrinsic dimensions.
|
||||
expect(Math.abs(wideBox.width - tallBox.width)).toBeLessThanOrEqual(1);
|
||||
|
||||
// 2. Capped reading column on desktop (not full-bleed, not a stripe).
|
||||
expect(wideBox.width).toBeGreaterThan(READING_WIDTH - 40);
|
||||
expect(wideBox.width).toBeLessThanOrEqual(READING_WIDTH + 2);
|
||||
|
||||
// 3. The tall page extends past the viewport (scrolls, doesn't shrink).
|
||||
expect(tallBox.height).toBeGreaterThan(viewport.height);
|
||||
});
|
||||
|
||||
test('single mode renders a tall page at the capped width, not a thin stripe', async ({ page }) => {
|
||||
await mockReader(page); // default mode is single
|
||||
|
||||
await page.goto(`/manga/${MANGA_ID}/chapter/${CHAPTER_ID}`);
|
||||
|
||||
const img = page.getByTestId('reader-page');
|
||||
await expect(img).toBeVisible();
|
||||
|
||||
// Advance from the wide first page to the tall second page.
|
||||
await page.getByTestId('reader-next').click();
|
||||
await expect(img).toHaveAttribute('src', /tall/);
|
||||
|
||||
const box = await img.boundingBox();
|
||||
const viewport = page.viewportSize();
|
||||
if (!box || !viewport) throw new Error('missing layout metrics');
|
||||
|
||||
// Width is the capped reading column, not a sliver.
|
||||
expect(box.width).toBeGreaterThan(READING_WIDTH - 40);
|
||||
expect(box.width).toBeLessThanOrEqual(READING_WIDTH + 2);
|
||||
// And the page runs past the viewport so the reader scrolls it.
|
||||
expect(box.height).toBeGreaterThan(viewport.height);
|
||||
});
|
||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mangalord-frontend",
|
||||
"version": "0.94.0",
|
||||
"version": "0.94.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mangalord-frontend",
|
||||
"version": "0.94.0",
|
||||
"version": "0.94.1",
|
||||
"devDependencies": {
|
||||
"@lucide/svelte": "^1.16.0",
|
||||
"@playwright/test": "^1.48.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mangalord-frontend",
|
||||
"version": "0.94.0",
|
||||
"version": "0.94.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1783,11 +1783,23 @@
|
||||
written by the ResizeObserver in onMount so the reservation
|
||||
always matches actual rendered height. Focus mode collapses
|
||||
the reservation in lockstep with the bar's slide-out. */
|
||||
/* Sizing is width-driven: every page renders at this consistent width
|
||||
(a capped reading column on desktop, full width on narrow screens)
|
||||
regardless of its intrinsic dimensions. Tall pages therefore extend
|
||||
downward and scroll instead of shrinking to fit the viewport height —
|
||||
which is what used to turn long vertical pages into thin stripes. */
|
||||
.page-wrap,
|
||||
.continuous {
|
||||
--reader-page-width: min(100%, 700px);
|
||||
}
|
||||
|
||||
.page-wrap {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
/* start (not center): a tall page's top must sit under the reader
|
||||
nav so the reader begins at the top and scrolls down. */
|
||||
align-items: start;
|
||||
padding-top: var(--reader-nav-h);
|
||||
transition: padding-top 220ms ease-out;
|
||||
}
|
||||
@@ -1811,8 +1823,8 @@
|
||||
}
|
||||
|
||||
.page-image {
|
||||
width: var(--reader-page-width);
|
||||
max-width: 100%;
|
||||
max-height: 90vh;
|
||||
height: auto;
|
||||
margin: 0 auto;
|
||||
display: block;
|
||||
@@ -1829,12 +1841,10 @@
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.continuous .page-image {
|
||||
/* In continuous mode the user is scrolling — let each page take
|
||||
its natural height instead of capping at viewport height, so
|
||||
there are no scroll dead-zones inside a single page. */
|
||||
max-height: none;
|
||||
}
|
||||
/* Continuous mode inherits the width-driven sizing above: every page is
|
||||
the same width and takes its natural (uncapped) height, so there are
|
||||
no scroll dead-zones inside a single page and widths stay consistent
|
||||
between a narrow scan and a wide one. */
|
||||
|
||||
.nav {
|
||||
display: inline-flex;
|
||||
@@ -1851,6 +1861,12 @@
|
||||
transition:
|
||||
background var(--transition),
|
||||
border-color var(--transition);
|
||||
/* Now that the page-wrap grid is top-aligned, a tall page makes its
|
||||
row much taller than the viewport. Stick the prev/next chevrons to
|
||||
the vertical middle of the viewport so they stay reachable while
|
||||
the reader scrolls down a long page. */
|
||||
position: sticky;
|
||||
top: calc(50vh - 22px);
|
||||
}
|
||||
|
||||
/* ===== Continuous-mode chapter bar (sticky bottom) =====
|
||||
|
||||
Reference in New Issue
Block a user