From de3e4b6d3c95f461970612e4608fbb509ebdd20a Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Sun, 31 May 2026 13:52:42 +0800 Subject: [PATCH] fix(reader): show Duokan fullscreen cover in scrolled mode (#4379) (#4381) A cover with `data-duokan-page-fullscreen` on 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) --- apps/readest-app/.claude/memory/MEMORY.md | 1 + .../memory/duokan-fullscreen-cover-scroll.md | 21 +++ .../paginator-duokan-cover.browser.test.ts | 150 ++++++++++++++++++ .../__tests__/fixtures/data/repro-4379.epub | Bin 0 -> 2238 bytes packages/foliate-js | 2 +- 5 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 apps/readest-app/.claude/memory/duokan-fullscreen-cover-scroll.md create mode 100644 apps/readest-app/src/__tests__/document/paginator-duokan-cover.browser.test.ts create mode 100644 apps/readest-app/src/__tests__/fixtures/data/repro-4379.epub diff --git a/apps/readest-app/.claude/memory/MEMORY.md b/apps/readest-app/.claude/memory/MEMORY.md index a8b3bdbc..8f1a6bb7 100644 --- a/apps/readest-app/.claude/memory/MEMORY.md +++ b/apps/readest-app/.claude/memory/MEMORY.md @@ -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) diff --git a/apps/readest-app/.claude/memory/duokan-fullscreen-cover-scroll.md b/apps/readest-app/.claude/memory/duokan-fullscreen-cover-scroll.md new file mode 100644 index 00000000..cfe68395 --- /dev/null +++ b/apps/readest-app/.claude/memory/duokan-fullscreen-cover-scroll.md @@ -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 `` (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 + `` 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 ``). 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]]. diff --git a/apps/readest-app/src/__tests__/document/paginator-duokan-cover.browser.test.ts b/apps/readest-app/src/__tests__/document/paginator-duokan-cover.browser.test.ts new file mode 100644 index 00000000..05938081 --- /dev/null +++ b/apps/readest-app/src/__tests__/document/paginator-duokan-cover.browser.test.ts @@ -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 with a dimensionless 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((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 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 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); + }); +}); diff --git a/apps/readest-app/src/__tests__/fixtures/data/repro-4379.epub b/apps/readest-app/src/__tests__/fixtures/data/repro-4379.epub new file mode 100644 index 0000000000000000000000000000000000000000..5592f7580536c406bc470717ee9b28d46d8b6b21 GIT binary patch literal 2238 zcmWIWW@h1H00FD){W1B8t93+xY!K!E;@r&K)RM}A)Wm{1%4*?-0uo`1Q=nQn|-7>NaJtzdxOD$@y^Me(SkUIiAf_Z5MNC?=?>S#d0OV>BG5M zQ}{iPMR!PU_Afr}YSuKzmA)p*TBM`&Y z&u73FY_2U;d^}G|$4sr3WW}96sCHTEI|NC?^ zz3?~fi3g%h3l;=kdjE5NN%1b5H^tZYCi!S<YSW5&7pBC7Mp#Y@c=!K=NT1dWExt7$vAoh9nW46xkw^OSROkP#tp_CIW;n8cOP=-1{GYPal7;IcpS%<_PUb?01t{*DK1E!WgKk3~K(7e}h?8>7Vz?iIOW?&FRcn_GMQ}asn@(a@DPClEr#DM4c`_CeQ>qJ?WT`lWw zxc7}Q?5=lyVA6psp-U4=Zf%eMYtn%PUnlXIl1Engdj?NNnmaFd32RJ++b+9_b-FuLYc^h&-ptdJRmt&j^}{Gvjyo$TZB}&HZdvsHM55#3 zGmoaXw=G_J?#LUl`$~D2?b|cX>N-^A2b}b{-w<)A{7cpTMqX9*<80EWjJy8wpE=nt z>vnAafpeFXeY8DtR>g5f`pe12gf4XosStm(Z0TzS-<==LTy(eXUupY?6*ZO4>U#6+ z6fpYafKiDYjb)%R36w2$`yGo8EAY5}w`+B)_A$PBMb<}b=1b05%mD$M!8~#bg;l@1 zj(GRp`|lsL{J79Vzol(+(rkM2Ii}>4DgKnykDvcY*1sy?`Z0;cE6-Kr=Kh^fbbDK& zb>Gg4${EW%?W~#weEox*3YJMe%=>v=Wuuh;qvvADTU$GPZ+M@ySR0iw@r&hWL9cym zs`FPn)~0`7&d%`kkZg|0)UVqnZ}{G}R6Km2na~W08YOj|rEaX%6BZ|a4Cubw{CJj) z(amp?PdCSJYN-9BnqvQdQF}eA$92=Xt8#!I_W-6}r0kgw@_0dB`fHvK#m}EkdX$uq z@Zjs00#2q&>JkzXr4~0D)@+Nf|7K`l@VR=IMO(<3%Ie6#366~q8xAWT{8`7yyE)R{ z?pO7*8*7rDeLB9Br~aG<#|Hyh8CAE=L_eF989$%vEsplL(O7R#vFq2)HN{>TObh|u zj7)OOxa%XJE5Tq%BZz|4PUyNIY9J)2E`k67hPRGZKqgYn1hNcw-2~DGObZN48WWMV zq106PtOk|-5CF2;8E6!&Epf!Lc4*8{H`AQmDVjb64QG>0+4EQFV|=$g?> zE`(-NU|N8<7UW%MIfkwsJ;xxl|6xIBhvy%3?da(aq#Xp7G=618)ecOB0p6_O@Z@CR MWRL>-tPjis0BOxGy#N3J literal 0 HcmV?d00001 diff --git a/packages/foliate-js b/packages/foliate-js index 167757a1..e8995dae 160000 --- a/packages/foliate-js +++ b/packages/foliate-js @@ -1 +1 @@ -Subproject commit 167757a1020a77ef0cbd8f7b291a47e5ccc83186 +Subproject commit e8995daeb6f7743aae4ac617529a3ce2f076dfab