fix(reader): show Duokan fullscreen cover in scrolled mode (#4379) (#4381)

A cover with `data-duokan-page-fullscreen` on <html> renders in
paginated mode but is blank in scrolled mode; only the first cover is
affected, other images are fine in both modes.

The paginator's fullscreen branch pins such images with
position:absolute and height:100% and forces their ancestors to
height:100%. That fills the fixed-height page when columnized, but in
scrolled mode the container height is `auto`, so height:100% resolves
to 0 and the cover collapses out of view.

Bump foliate-js to gate the fullscreen treatment on column mode and
reset any stale absolute pinning when a fullscreen-cover doc is laid
out scrolled (so toggling paginated -> scrolled also recovers). Add a
browser regression test + repro-4379.epub fixture.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-05-31 13:52:42 +08:00
committed by GitHub
parent 53fe8c2683
commit de3e4b6d3c
5 changed files with 173 additions and 1 deletions
@@ -13,6 +13,7 @@
- [Reading ruler line/column-aware](reading-ruler-line-aware.md) — ruler snaps to real lines; multi-column band spans one column; Range.getClientRects() returns tall block boxes that must be dropped; iframe frame-offset mapping; synthetic-key throttling
- [TOC expand + auto-scroll](toc-expand-and-autoscroll.md) — #4059 collapse-by-default policy in `tocTree.ts`; pinned-sidebar mounts before progress → dynamic expansion breaks scroll-to-current via (1) spurious onScroll clearing pending and (2) Virtuoso scrollToIndex landing short after row growth (re-assert on rAF)
- [Swipe page-turn bg flash](paginator-swipe-bg-flash.md) — white↔black flash on swipe+animation only; `#background` was static screen-space and didn't track content during drag/snap; fix = sliding per-view full-bleed segments (`computeBackgroundSegments`) rebuilt on scroll + per-rAF synced to the view transform during snap
- [Duokan fullscreen cover hidden in scroll mode](duokan-fullscreen-cover-scroll.md) — #4379 `data-duokan-page-fullscreen` cover pinned `position:absolute height:100%` collapses against auto-height scroll container; gate fullscreen on `this.#column` + reset stale absolute props on toggle (`setImageSize` in paginator.js)
## Critical Files (Most Bug-Prone)
- `src/utils/style.ts` - Central EPUB CSS transformation hub (14+ bug fixes)
@@ -0,0 +1,21 @@
---
name: duokan-fullscreen-cover-scroll
description: "Duokan fullscreen cover image invisible in scrolled mode (#4379) — paginator pins it position:absolute height:100% which collapses against auto-height scroll container"
metadata:
node_type: memory
type: project
originSessionId: c45aabf0-e8a3-42b6-a5fd-c04d6eb2345c
---
Issue #4379: an EPUB cover with `data-duokan-page-fullscreen` on `<html>` (Duokan/DangDang convention) shows in paginated mode but is **blank in scrolled mode**; only the first cover image, other images fine in both modes.
**Root cause**`View.setImageSize()` in `packages/foliate-js/paginator.js` has a `pageFullscreen` branch that pins each img with `position:absolute; inset:0; width:100%; height:100%` and forces ancestors + `<html>` to `height:100%`/`position:relative`. This fills the page in paginated/columnized mode (html has a fixed pixel height). In scrolled mode `scrolled()` sets `html`/`body` height to `auto`, so the `height:100%` chain resolves to **0** and the absolutely-positioned cover collapses out of view (offsetHeight 0).
**Fix** — gate the fullscreen treatment on column mode: `const applyFullscreen = pageFullscreen && this.#column`. Use `applyFullscreen` for the max-height/max-width margin term and the `if` block. Add an `else if (pageFullscreen)` that `removeProperty`s the stale `position/inset/width/height/margin` on the img (and `width/height/margin/padding` on ancestors, `position` on html) so toggling paginated→scrolled doesn't leave the cover collapsed (same iframe/img is reused via `view.render(layout)` on `flow` change). In scrolled mode the cover then flows like a normal full-page image bounded by `max-height = availableHeight`.
**Key facts**
- `this.#column = layout.flow !== 'scrolled'` (set in `render()` before `setImageSize`), so it's reliable inside `setImageSize`.
- Foliate writes these styles as **inline `!important`** → cannot be overridden from `src/utils/style.ts`; the fix must live in the paginator.
- Regression test: `src/__tests__/document/paginator-duokan-cover.browser.test.ts` + fixture `repro-4379.epub` (cover xhtml with the duokan attr + dimensionless `<img>`). Asserts cover `img.offsetHeight > 0` in scrolled mode, paginated sanity, and paginated→scrolled toggle. Browser test (real layout) is required — jsdom can't compute the collapse.
Related: [[paginator-swipe-bg-flash]], [[css-style-fixes]].
@@ -0,0 +1,150 @@
import { describe, it, expect, beforeAll, afterEach } from 'vitest';
import { DocumentLoader } from '@/libs/document';
import type { BookDoc } from '@/libs/document';
import type { Renderer } from '@/types/view';
// repro-4379: a Duokan/DangDang full-page cover — `data-duokan-page-fullscreen`
// on <html> with a dimensionless <img> inside a `.cover` wrapper. The paginator
// pins such covers with `position:absolute; inset:0; height:100%`, which fills
// the fixed-height page in paginated mode but collapses to zero against the
// auto-height scroll container in scrolled mode (the cover disappears).
const EPUB_URL = new URL('../fixtures/data/repro-4379.epub', import.meta.url).href;
let book: BookDoc;
const loadEPUB = async () => {
const resp = await fetch(EPUB_URL);
const buffer = await resp.arrayBuffer();
const file = new File([buffer], 'repro-4379.epub', { type: 'application/epub+zip' });
const loader = new DocumentLoader(file);
const { book } = await loader.open();
return book;
};
const waitForStabilized = (el: HTMLElement, timeout = 10000) =>
new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('stabilized timeout')), timeout);
el.addEventListener(
'stabilized',
() => {
clearTimeout(timer);
resolve();
},
{ once: true },
);
});
const getCoverImg = (paginator: Renderer): HTMLImageElement | null => {
const cover = paginator.getContents().find((c) => c.index === 0);
return (cover?.doc.body.querySelector('img') as HTMLImageElement | undefined) ?? null;
};
/** Wait for the cover <img> resource to decode so layout has a natural size. */
const waitForImgLoaded = async (img: HTMLImageElement, timeout = 5000) => {
const start = Date.now();
while (Date.now() - start < timeout) {
if (img.complete && img.naturalHeight > 0) return;
await new Promise((r) => setTimeout(r, 50));
}
};
/** Poll until the element has a non-zero rendered height (or time out). */
const waitForVisibleHeight = async (img: HTMLImageElement, timeout = 3000) => {
const start = Date.now();
while (Date.now() - start < timeout) {
if (img.offsetHeight > 0) return;
await new Promise((r) => setTimeout(r, 50));
}
};
describe('Paginator Duokan fullscreen cover (#4379)', () => {
let paginator: Renderer;
beforeAll(async () => {
book = await loadEPUB();
await import('foliate-js/paginator.js');
}, 30000);
const createPaginator = () => {
const el = document.createElement('foliate-paginator') as Renderer;
Object.assign(el.style, {
width: '800px',
height: '600px',
position: 'absolute',
left: '0',
top: '0',
});
document.body.appendChild(el);
return el;
};
afterEach(() => {
if (paginator) {
try {
paginator.destroy();
} catch {
/* iframe body may already be torn down */
}
paginator.remove();
}
});
it('shows the cover image in paginated mode (sanity)', async () => {
paginator = createPaginator();
paginator.open(book);
const stabilized = waitForStabilized(paginator);
await paginator.goTo({ index: 0 });
await stabilized;
const img = getCoverImg(paginator);
expect(img).toBeTruthy();
await waitForImgLoaded(img!);
await waitForVisibleHeight(img!);
expect(img!.offsetHeight).toBeGreaterThan(0);
});
it('shows the cover image in scrolled mode', async () => {
paginator = createPaginator();
paginator.open(book);
paginator.setAttribute('flow', 'scrolled');
const stabilized = waitForStabilized(paginator);
await paginator.goTo({ index: 0 });
await stabilized;
const img = getCoverImg(paginator);
expect(img).toBeTruthy();
await waitForImgLoaded(img!);
await waitForVisibleHeight(img!);
// Before the fix the cover is absolutely positioned at height:100% inside
// an auto-height (zero) container, so it collapses and never renders.
expect(img!.offsetHeight).toBeGreaterThan(0);
// It must also stay bounded by the viewport rather than overflowing it.
expect(img!.offsetHeight).toBeLessThanOrEqual(600);
});
it('shows the cover image after toggling paginated -> scrolled', async () => {
paginator = createPaginator();
paginator.open(book);
const stabilized = waitForStabilized(paginator);
await paginator.goTo({ index: 0 });
await stabilized;
const img = getCoverImg(paginator);
expect(img).toBeTruthy();
await waitForImgLoaded(img!);
await waitForVisibleHeight(img!);
const stabilized2 = waitForStabilized(paginator);
paginator.setAttribute('flow', 'scrolled');
await stabilized2;
// The same <img> element is re-rendered; stale absolute positioning from
// the paginated render must not leave it collapsed in scrolled mode.
await waitForVisibleHeight(img!);
expect(img!.offsetHeight).toBeGreaterThan(0);
});
});