Files
readest/apps/readest-app/src/__tests__/document/paginator-table-layout.browser.test.ts
T
Huang Xin 3a81e09911 fix(reader): scroll oversized blocks in-place instead of turning the page (#4400) (#4415)
Wide or tall tables, code blocks and display equations overflowed the reading
column and a scroll gesture over them turned the page instead of scrolling the
content (#4400).

- Wrap tables and display equations in a horizontally/vertically scrollable
  container; route touch + wheel along the box's scrollable axis so it scrolls
  the box and never turns the page, even at the edge (both axes).
- A box that fits its column is marked fit (overflow:visible) so it never clips
  or captures gestures; the fit decision is measured once after layout via a
  self-disconnecting ResizeObserver, so it never relayerizes during a page turn.
- The scroll wrapper carries a new cfi-skip attribute that makes it transparent
  to CFI: epubcfi.js hoists a cfi-skip node's children into its parent (unlike
  cfi-inert which drops the subtree), and xcfi.ts mirrors this for CFI<->XPointer
  so existing highlights, bookmarks and KOSync positions inside a wrapped table
  or equation still resolve. The sanitizer whitelists cfi-skip.
- Bump foliate-js submodule (cfi-skip support + raf fallback for large sections).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 14:45:18 +02:00

192 lines
7.1 KiB
TypeScript

import { describe, it, expect, beforeAll, afterEach } from 'vitest';
import { DocumentLoader } from '@/libs/document';
import type { BookDoc } from '@/libs/document';
import type { ViewSettings } from '@/types/book';
import type { Renderer } from '@/types/view';
import { getStyles } from '@/utils/style';
import { applyScrollableStyle, SCROLL_WRAPPER_CLASS } from '@/utils/scrollable';
import {
DEFAULT_BOOK_FONT,
DEFAULT_BOOK_LAYOUT,
DEFAULT_BOOK_STYLE,
DEFAULT_BOOK_LANGUAGE,
DEFAULT_VIEW_CONFIG,
DEFAULT_TTS_CONFIG,
DEFAULT_TRANSLATOR_CONFIG,
DEFAULT_ANNOTATOR_CONFIG,
DEFAULT_SCREEN_CONFIG,
} from '@/services/constants';
// sample-table-layout.epub: CSS layout tables (fixed em widths, images +
// wrapping prose) for character/glossary pages. They are designed to fit the
// reading column, so they must NOT get a horizontal scrollbar. Before the fix,
// `.readest-table-scroll > table { width: max-content }` forced every table to
// its unwrapped width, which overflowed the column and showed a scrollbar.
const LAYOUT_EPUB_URL = new URL('../fixtures/data/sample-table-layout.epub', import.meta.url).href;
// sample-table-wide.epub: a single section with a genuinely wide (many-column,
// non-wrapping) data table — it cannot fit the column, so it must SCROLL rather
// than be clipped.
const WIDE_EPUB_URL = new URL('../fixtures/data/sample-table-wide.epub', import.meta.url).href;
// Spine indices of sample-table-layout.epub whose sections contain <table>.
const TABLE_SECTION_INDICES = [2, 3, 4, 9, 10];
// Ignore sub-pixel / border slop when deciding whether a table really overflows.
const TOLERANCE_PX = 4;
const loadEPUB = async (url: string, name: string) => {
const resp = await fetch(url);
const buffer = await resp.arrayBuffer();
const file = new File([buffer], name, { type: 'application/epub+zip' });
const { book } = await new DocumentLoader(file).open();
return book;
};
const makeViewSettings = (): ViewSettings =>
({
...DEFAULT_BOOK_FONT,
...DEFAULT_BOOK_LAYOUT,
...DEFAULT_BOOK_STYLE,
...DEFAULT_BOOK_LANGUAGE,
...DEFAULT_VIEW_CONFIG,
...DEFAULT_TTS_CONFIG,
...DEFAULT_TRANSLATOR_CONFIG,
...DEFAULT_ANNOTATOR_CONFIG,
...DEFAULT_SCREEN_CONFIG,
}) as ViewSettings;
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 poll = async (fn: () => boolean, timeout = 2000) => {
const start = Date.now();
while (Date.now() - start < timeout) {
if (fn()) return true;
await new Promise((r) => setTimeout(r, 30));
}
return fn();
};
const getSectionDoc = (paginator: Renderer, index: number): Document | null =>
paginator.getContents().find((c) => c.index === index)?.doc ?? null;
const overflow = (w: HTMLElement) => w.scrollWidth - w.clientWidth;
const overflowX = (w: HTMLElement) => w.ownerDocument.defaultView!.getComputedStyle(w).overflowX;
const showsScrollbar = (w: HTMLElement) =>
['auto', 'scroll'].includes(overflowX(w)) && overflow(w) > TOLERANCE_PX;
const isClipped = (w: HTMLElement) =>
!['auto', 'scroll'].includes(overflowX(w)) && overflow(w) > TOLERANCE_PX;
let layoutBook: BookDoc;
let wideBook: BookDoc;
describe('Paginator table layout', () => {
let paginator: Renderer;
beforeAll(async () => {
layoutBook = await loadEPUB(LAYOUT_EPUB_URL, 'sample-table-layout.epub');
wideBook = await loadEPUB(WIDE_EPUB_URL, 'sample-table-wide.epub');
await import('foliate-js/paginator.js');
}, 30000);
const createPaginator = (width: number) => {
const el = document.createElement('foliate-paginator') as Renderer;
Object.assign(el.style, {
width: `${width}px`,
height: '800px',
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('renders layout tables without a horizontal scrollbar', async () => {
paginator = createPaginator(320);
paginator.open(layoutBook);
paginator.setStyles?.(getStyles(makeViewSettings(), undefined, []));
let checked = 0;
// Measure each section while it is still loaded (the paginator unloads
// sections as we navigate, so earlier docs would be detached afterwards).
for (const index of TABLE_SECTION_INDICES) {
const stabilized = waitForStabilized(paginator);
await paginator.goTo({ index });
await stabilized;
const doc = getSectionDoc(paginator, index);
expect(doc, `section ${index} doc`).toBeTruthy();
paginator.setStyles?.(getStyles(makeViewSettings(), undefined, []));
applyScrollableStyle(doc!);
for (const wrapper of doc!.querySelectorAll<HTMLElement>(`.${SCROLL_WRAPPER_CLASS}`)) {
expect(showsScrollbar(wrapper), `section ${index}: layout table shows a scrollbar`).toBe(
false,
);
expect(isClipped(wrapper), `section ${index}: layout table is clipped`).toBe(false);
checked += 1;
}
}
expect(checked).toBeGreaterThan(0);
}, 60000);
it('scrolls a table too wide for its column instead of clipping it', async () => {
paginator = createPaginator(600);
paginator.open(wideBook);
paginator.setStyles?.(getStyles(makeViewSettings(), undefined, []));
const stabilized = waitForStabilized(paginator);
await paginator.goTo({ index: 0 });
await stabilized;
const doc = getSectionDoc(paginator, 0);
expect(doc, 'wide section doc').toBeTruthy();
paginator.setStyles?.(getStyles(makeViewSettings(), undefined, []));
applyScrollableStyle(doc!);
const wrapper = doc!.querySelector<HTMLElement>(`.${SCROLL_WRAPPER_CLASS}`)!;
expect(wrapper, 'wide-table wrapper').toBeTruthy();
const table = wrapper.querySelector(':scope > table') as HTMLElement;
// Confirm this really is a wide table whose non-wrapping cells can't shrink
// to fit a reading column.
table.style.width = 'min-content';
const minContent = table.getBoundingClientRect().width;
table.style.removeProperty('width');
expect(minContent, 'wide table min-content width').toBeGreaterThan(500);
// Constrain the wrapper well below the table — the real-app situation where a
// wide table can't fit its column. (The bare paginator otherwise widens the
// column to fit content, so we reproduce the column constraint explicitly.)
wrapper.style.width = '250px';
wrapper.style.maxWidth = '250px';
// The wrapper is statically overflow-x:auto, so an over-wide table scrolls.
await poll(() => overflow(wrapper) > TOLERANCE_PX);
expect(overflow(wrapper), 'wide table should overflow its constrained column').toBeGreaterThan(
TOLERANCE_PX,
);
expect(['auto', 'scroll'], 'wide table overflow-x').toContain(overflowX(wrapper));
expect(isClipped(wrapper), 'wide table is clipped instead of scrolling').toBe(false);
}, 60000);
});