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 00000000..5592f758 Binary files /dev/null and b/apps/readest-app/src/__tests__/fixtures/data/repro-4379.epub differ 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