Files
readest/apps/readest-app/src/__tests__/utils/epubcfi-skip.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

140 lines
5.6 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import * as CFI from 'foliate-js/epubcfi.js';
const XHTML = (str: string) => new DOMParser().parseFromString(str, 'application/xhtml+xml');
// cfi-skip marks a layout-only wrapper as invisible to CFI: unlike cfi-inert (which
// removes the node AND its subtree), cfi-skip hoists the node's children into its
// parent, so the wrapped content keeps the exact CFI it had before being wrapped.
// This mirrors what applyScrollableStyle does when it wraps a wide <table>/<math>.
const TABLE = `<table id="tbl"><tbody><tr><td><p id="cell">xxx<em>yyy</em>0123456789</p></td></tr></tbody></table>`;
const body = (tableMarkup: string) =>
XHTML(`<html xmlns="http://www.w3.org/1999/xhtml">
<head><title>Test</title></head>
<body id="body01">
<p id="p1">First paragraph</p>
<p>Second paragraph</p>
${tableMarkup}
<p>Fourth paragraph</p>
<img id="svgimg" src="foo.svg" alt="an image"/>
</body>
</html>`);
// Baseline: a table sitting directly in body, no wrapper.
const basePage = () => body(TABLE);
// The same table wrapped in a cfi-skip layout div (as applyScrollableStyle does).
const pageWithSkipWrapper = () => body(`<div class="scroll-wrapper" cfi-skip="">${TABLE}</div>`);
// Wrapper nested two deep, to prove the hoisting recurses.
const pageWithNestedSkipWrappers = () =>
body(`<div cfi-skip=""><div cfi-skip="">${TABLE}</div></div>`);
// Build a representative set of ranges (inside the table and around it) in a doc,
// keyed by label so the same logical range can be built in every variant.
const ranges = (doc: Document): Record<string, Range> => {
const cell = doc.getElementById('cell')!;
const xxx = cell.firstChild!; // "xxx"
const em = cell.childNodes[1]!.firstChild!; // "yyy" inside <em>
const digits = cell.childNodes[2]!; // "0123456789"
const r = (build: (range: Range) => void) => {
const range = doc.createRange();
build(range);
return range;
};
const point = (node: Node, offset: number) =>
r((g) => {
g.setStart(node, offset);
g.collapse(true);
});
return {
beforeTable: point(doc.getElementById('p1')!.firstChild!, 0),
tableElement: point(doc.getElementById('tbl')!, 0), // collapsed at element → element CFI
cellStart: r((g) => {
g.setStart(xxx, 0);
g.setEnd(xxx, 3);
}),
cellEm: point(em, 0),
cellDigits: r((g) => {
g.setStart(digits, 1);
g.setEnd(digits, 5);
}),
spanningCell: r((g) => {
g.setStart(xxx, 1);
g.setEnd(digits, 4);
}),
afterTable: point(doc.getElementById('svgimg')!, 0),
};
};
const variants = () => [
['skip wrapper', pageWithSkipWrapper()] as const,
['nested skip wrappers', pageWithNestedSkipWrappers()] as const,
];
describe('epubcfi cfi-skip wrapper transparency', () => {
it('every base-page range round-trips unchanged (fromRange → toRange → fromRange)', () => {
const doc = basePage();
for (const [label, range] of Object.entries(ranges(doc))) {
const cfi = CFI.fromRange(range);
const resolved = CFI.toRange(doc, CFI.parse(cfi));
expect(resolved, label).not.toBeNull();
expect(CFI.fromRange(resolved!), label).toBe(cfi);
}
});
it('produces the SAME CFI for an equivalent range with and without the skip wrapper', () => {
const baseCFIs = Object.fromEntries(
Object.entries(ranges(basePage())).map(([k, range]) => [k, CFI.fromRange(range)]),
);
for (const [variantLabel, doc] of variants()) {
for (const [key, range] of Object.entries(ranges(doc))) {
expect(CFI.fromRange(range), `${variantLabel}: ${key}`).toBe(baseCFIs[key]);
}
}
});
it('resolves a base-page CFI to the same content inside the wrapped table', () => {
const base = basePage();
const baseCFIs = Object.entries(ranges(base)).map(
([key, range]) => [key, CFI.fromRange(range), range.toString()] as const,
);
for (const [variantLabel, doc] of variants()) {
for (const [key, cfi, text] of baseCFIs) {
const resolved = CFI.toRange(doc, CFI.parse(cfi));
expect(resolved, `${variantLabel}: ${key}`).not.toBeNull();
expect(resolved!.toString(), `${variantLabel}: ${key}`).toBe(text);
}
}
});
it('keeps the wrapper div out of the generated CFI (table stays at the same step)', () => {
const baseCFI = CFI.fromRange(ranges(basePage())['tableElement']);
for (const [variantLabel, doc] of variants()) {
const wrappedCFI = CFI.fromRange(ranges(doc)['tableElement']);
expect(wrappedCFI, variantLabel).toBe(baseCFI);
expect(wrappedCFI, variantLabel).toContain('[tbl]');
}
});
it('resolves the table element across the wrapper via toElement', () => {
const tblParts = CFI.parse(CFI.fromRange(ranges(basePage())['tableElement']));
for (const [variantLabel, doc] of variants()) {
const el = CFI.toElement(doc, tblParts[0]);
expect(el.id, variantLabel).toBe('tbl');
}
});
it('does NOT hoist a plain wrapper that lacks cfi-skip (it stays a CFI level)', () => {
const plain = body(`<div class="scroll-wrapper">${TABLE}</div>`);
const wrappedCFI = CFI.fromRange(ranges(plain)['tableElement']);
const baseCFI = CFI.fromRange(ranges(basePage())['tableElement']);
// The table now sits one element step deeper (inside the counted div), so the
// CFI differs from the unwrapped baseline — proving cfi-skip is what makes the
// wrapper transparent, not merely the presence of a wrapper div.
expect(wrappedCFI).not.toBe(baseCFI);
// It still resolves to the table, just at a deeper path.
expect(CFI.toElement(plain, CFI.parse(wrappedCFI)[0]).id).toBe('tbl');
});
});