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>
This commit is contained in:
@@ -27,15 +27,22 @@
|
||||
- [KOSync CFI spine resolution](kosync-cfi-spine-resolution.md) — convert via the CFI's own spine (`getXPointerFromCFI`/`getCFIFromXPointer`), never `new XCFI(primaryDoc, primaryIndex)`; primaryIndex lags during scroll → spine-mismatch throw
|
||||
- [Empty-start CFI sync bug](empty-start-cfi-sync.md) — `epubcfi(/6/24!/4,,/20/1:58)` (empty-start range) from the cfi-inert skip-link transitional window; jumps to wrong section end; `isMalformedLocationCfi` → discard the synced value in `useProgressSync` (NOT the local open path); foliate fix doesn't repair already-synced values
|
||||
|
||||
## Testing
|
||||
- [Tauri Rust↔JS parser parity tests](tauri-parser-parity-tests.md) — #4369 native Rust EPUB/MOBI parser; how to cross-check vs foliate-js in the `.tauri.test.ts` WebView suite (CWD disk path for Rust, Vite URL for JS, normalizer-based compare, cover presence-only, desc whitespace-collapse); the `dcterms:modified`→`published` divergence fix
|
||||
|
||||
## Build & Vendoring
|
||||
- [pdfjs vendor wasm decoders](pdfjs-vendor-wasm-decoders.md) — scanned PDFs blank in CI build only (0.11.2 regression); pdfjs 5.7.x moved JBIG2 to `jbig2.wasm`, `copy-pdfjs-wasm` allow-list dropped it; `cpx` no-errors on empty glob; local stale `public/vendor` (gitignored, not refreshed by `tauri build`) masked it; fix = copy `wasm/*`
|
||||
|
||||
## Platform Compat
|
||||
- [Window-state sanitizer (#4398)](window-state-sanitize-4398.md) — Windows launch crash (WebView2 0x80070057) from invalid `.window-state.json` (`-32000` minimized sentinel / `0×0`); our plugin already has upstream #253 fix so bad files are stale; defense-in-depth `window-state-sanitizer` plugin registered BEFORE window-state (plugin init = registration order); coord threshold `-16000` (~halfway to the -32000 sentinel; real desktops sit a few thousand px off origin) keeps multi-monitor negatives
|
||||
|
||||
## Feature Notes
|
||||
- [Manage Cache + iOS container layout](manage-cache-ios-layout.md) — `'Cache'` base = `Library/Caches/<bundle>` only (not all of Caches); iOS `Documents/Inbox` cleared too; WebKit cache + tmp out of reach; never touch App Support
|
||||
- [D-pad Navigation](dpad-navigation.md) — Android TV remote / keyboard arrow navigation design, key files, and pitfalls
|
||||
- [Cloudflare Workers WebSocket](cloudflare-workers-websocket.md) — use fetch() Upgrade pattern (not `ws` npm); CF delivers binary frames as Blob (must serialize async decodes)
|
||||
- [Share-a-Book Feature (in progress)](share-feature.md) — locked decisions for the /s/{token} share-link feature; plan at ~/.claude/plans/ok-we-will-learn-cosmic-acorn.md
|
||||
- [readest.koplugin i18n](koplugin-i18n.md) — gettext loader at `apps/readest.koplugin/i18n.lua`, `.po` catalog at `locales/<i18next-code>/translation.po`, extract/apply scripts in `scripts/`
|
||||
- [koplugin cover upload](koplugin-cover-upload.md) — #4374 uploadBook only shipped cached cloud covers; local-origin books uploaded blank. Fix = `extractLocalCover` via `FileManagerBookInfo:getCoverImage(nil, file)` → `writeToFile(path,"png")`. KOReader checkout at `/Users/chrox/dev/koreader`
|
||||
|
||||
## Patterns
|
||||
- [Virtuoso + OverlayScrollbars](virtuoso_overlayscrollbars.md) — useOverlayScrollbars hook integration for overlay scrollbars on mobile webviews
|
||||
@@ -50,6 +57,7 @@
|
||||
- Safe area insets flow: Native plugin -> useSafeAreaInsets hook -> component styles
|
||||
- Dropdown menus use `DropdownContext` (not blur-based) for screen reader compat
|
||||
- [Foliate touch-listener capture phase](foliate-touch-listener-capture-phase.md) — to suppress reader gestures from the app, use `{capture:true}`; the paginator registers bubble-phase doc listeners first (during `view.open()`)
|
||||
- [iframe cross-realm instanceof](iframe-cross-realm-instanceof.md) — app-bundle code (style.ts, iframeEventHandlers.ts) runs in top realm; `iframeEl instanceof Element` is ALWAYS false → guards silently drop all iframe elements (passes jsdom, dead in app). Duck-type `'closest' in target` instead. Bit PR #4391's touch routing + applyTableStyle dedupe
|
||||
|
||||
## Workflow
|
||||
- [Test file filter](feedback_test_file_filter.md) — use `pnpm test <path>` without `--` to run a single file
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
name: iframe-cross-realm-instanceof
|
||||
description: "App-bundle code handling foliate iframe DOM must not use `instanceof Element/HTMLElement` — cross-realm, always false"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 2e8d274e-a4da-4d63-8afb-d7a600d560b2
|
||||
---
|
||||
|
||||
Reader content lives in foliate iframes, each with its **own JS realm**. App-bundle
|
||||
code (e.g. `src/utils/style.ts`, `iframeEventHandlers.ts`) runs in the **top window
|
||||
realm**, so its `Element`/`HTMLElement` constructors differ from the iframe's.
|
||||
|
||||
`someIframeEl instanceof Element` (top-realm `Element`) is **always `false`** — verified:
|
||||
`Element === iframeWin.Element` is false. So any guard like `if (!(target instanceof Element)) return`
|
||||
silently drops every real iframe element. Functions still pass jsdom unit tests (single realm)
|
||||
yet are dead in the running app.
|
||||
|
||||
**Use duck-typing instead**: `if (!target || !('closest' in target)) return;` then
|
||||
`(target as Element).closest(...)`. For node-type checks use `node.nodeType === Node.ELEMENT_NODE`
|
||||
(numeric constant, realm-safe) or check `classList`/`closest` presence.
|
||||
|
||||
Seen in PR #4391 (wide-table horizontal scroll): the touch `findWrapper` and `applyTableStyle`'s
|
||||
re-entrancy guard both used `instanceof`, so the touch routing never fired and the dedupe guard
|
||||
never tripped. Related: [[foliate-touch-listener-capture-phase]].
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: koplugin-cover-upload
|
||||
description: "koplugin cover system — where local/cloud covers come from, and the"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: reference
|
||||
originSessionId: 95cdff70-ce2c-4077-82a8-922f9578a22f
|
||||
---
|
||||
|
||||
readest.koplugin cover handling (in `apps/readest.koplugin/library/`):
|
||||
|
||||
- **Local book covers** come from coverbrowser.koplugin's `BookInfoManager` (a hard dependency). `coverprovider.get_local_cover(file_path)` returns `BIM:getBookInfo(file_path, true).cover_bb` (a downscaled thumbnail bb) and kicks off background extraction on a miss.
|
||||
- **Native-resolution cover** for a file (no open doc needed): `require("apps/filemanager/filemanagerbookinfo"):getCoverImage(nil, file_path)` — opens+closes the doc itself, honors KOReader custom covers, returns a blitbuffer. Same `(nil, file)` call form `calibre.koplugin` uses. Write it to PNG with `bb:writeToFile(path, "png")` (pcall-wrapped internally) then `bb:free()`.
|
||||
- **Cloud covers** are downloaded to `DataStorage:getSettingsDir()/readest_covers/<hash>.png` (`cloud_covers.covers_dir()`); cloud storage key is `<user_id>/Readest/Books/<hash>/cover.png` (`build_cover_key`), which matches readest's `getCoverFilename` (`<hash>/cover.png`).
|
||||
|
||||
**Issue #4374 (fixed):** `syncbooks.uploadBook` only shipped a `cover.png` if one was *already cached* under `readest_covers/<hash>.png` from a prior cloud download — so books that originated locally in KOReader uploaded with no cover and showed blank in readest. Fix = `syncbooks.extractLocalCover(file_path, dst_png)` (extracts via `getCoverImage` → writeToFile), called from `uploadBook` when `has_cover` is false. Only file-upload path is `librarywidget.lua` "Upload to Cloud" → `uploadBook` (FileManager `addToReadest` only stages a local row).
|
||||
|
||||
Tests: network/live parts of `syncbooks.lua` aren't unit-tested (manual matrix in `docs/library-design.md`); `extractLocalCover` IS tested by injecting a fake `apps/filemanager/filemanagerbookinfo` via `package.loaded` in `spec/library/syncbooks_spec.lua`. A real KOReader checkout lives at `/Users/chrox/dev/koreader` for verifying KOReader APIs. See [[koplugin-i18n]].
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
name: tauri-parser-parity-tests
|
||||
description: "How to test the native Rust EPUB/MOBI parser against foliate-js in the Tauri WebView suite, plus the dcterms:modified→published parity gotcha"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: reference
|
||||
originSessionId: 90d14df3-59ce-438f-ae91-b72e9d2b6f99
|
||||
---
|
||||
|
||||
PR #4369 added native Rust EPUB/MOBI parsers (`src-tauri/src/epub_parser.rs`, `mobi_parser.rs`, shared `parser_common.rs`) with foliate-js fallback. Parity between the two parsers is the key risk.
|
||||
|
||||
**Cross-language parity test harness** (`src/__tests__/tauri/epub-parser-parity.tauri.test.ts`):
|
||||
- The `.tauri.test.ts` suite (run by `scripts/test-tauri.sh` / `pnpm test:tauri`, included only by `vitest.tauri.config.mts`) is the ONLY env where both parsers coexist: Rust via `invoke()` (`./tauri-invoke.ts`), foliate-js via `DocumentLoader`. Default `pnpm test` excludes `**/*.tauri.test.ts`.
|
||||
- Rust commands read an absolute on-disk path → build it from `process.env.CWD` (injected by the tauri config = readest-app dir): `${CWD}/src/__tests__/fixtures/data/<name>`. The JS side fetches the SAME file via a Vite URL: `new URL('../fixtures/data/<name>', import.meta.url).href` (same pattern as `paginator-stabilization.browser.test.ts`).
|
||||
- Compare via the app's own normalizers so you compare user-visible values, not raw parser shapes: `formatTitle`, `formatAuthors(authors, lang)` (Rust gives `string[]`, foliate gives `string|Contributor|array` — both through formatAuthors), `getPrimaryLanguage`, `formatPublisher`, `formatDescription`.
|
||||
- **Cover is presence-only**: Rust downscales/re-encodes the cover to a ~512px JPEG (`parser_common::maybe_resize_cover`), so bytes never match foliate's original — assert `(rust.coverBase64 != null) === ((await js.getCover()) != null)`.
|
||||
- **Description needs whitespace-collapse**: foliate collapses an internal source newline to a space; Rust preserves the raw `\n`. Compare `formatDescription(x).replace(/\s+/g,' ').trim()`.
|
||||
- Section `href` is undefined on foliate `SectionItem` — compare sections by `{id,size,linear}` only. `linear` can be `null`.
|
||||
- Strongest test = open the same EPUB twice through `DocumentLoader` (with `{nativeFilePath}` → Rust prefetch, and without → pure zip.js) and assert identical `metadata`, sections, `toc`, and `computeBookNav` output (toc tree + per-section fragment CFIs/sizes). This validates `parse_epub_full` prefetch + the parallelized TOC pipeline are behavior-preserving in one shot. `isTauriAppPlatform()` is true in the webview (`.env.tauri` sets `NEXT_PUBLIC_APP_PLATFORM=tauri`) so the prefetch fires.
|
||||
- No `.mobi`/`.azw3` fixture exists anywhere in the repo → MOBI parity is NOT covered by this harness; add a Kindle fixture to extend. Rust MOBI is covered by `mobi_parser`'s own unit tests.
|
||||
- Offline verification trick (no chromedriver/tauri-driver locally): dump foliate `book.metadata` via a temp node vitest test, dump Rust `parse_epub_metadata_sync`/`compute_partial_md5` via a temp `#[cfg(test)]` mod using `env!("CARGO_MANIFEST_DIR")/../src/__tests__/fixtures/data`, diff. Lets you lock every expected value before CI runs the WebView suite.
|
||||
|
||||
**Running the `.tauri.test.ts` suite locally on macOS** (no chromedriver/tauri-driver needed — `tauri-plugin-webdriver` 0.2 embeds an axum WebDriver server with a `platform/macos.rs` WKWebView impl, default port 4445):
|
||||
- `pnpm test:tauri` runs `scripts/test-tauri.sh`: starts `next dev` on :3000, `tauri dev --features webdriver --no-watch`, waits for :4445/status, then `vitest --config vitest.tauri.config.mts`. Its cleanup does `lsof -ti :3000 | xargs kill` — so it KILLS whatever is on :3000 (e.g. another worktree's dev server). To avoid that, copy the script and shift `DEV_PORT`/`next dev -p`/`--config devUrl` to a free port (e.g. 3100). Run via `pnpm exec bash <script>` so npm bins (`dotenv`, `next`, `tauri`, `vitest`) resolve, not the Ruby `dotenv` gem.
|
||||
- **Single-instance blocker**: `tauri_plugin_single_instance` (lib.rs, keyed on bundle id `com.bilingify.readest`) is registered unconditionally for desktop and is NOT gated by the webdriver feature. If `/Applications/Readest.app` (or any Readest instance) is running, the test instance forwards to it and exits → "Tauri exited before WebDriver ready." Quit Readest.app before running. CI doesn't hit this (Linux, no Readest installed).
|
||||
- **`vitest.tauri.config.mts` needs an `optimizeDeps` block** mirroring `vitest.browser.config.mts` (include the CJS deps `js-md5`/`@zip.js/zip.js`/`franc-min`/`iso-639-*`/etc.; exclude `@pdfjs/pdf.min.mjs` + the turso wasm). Without it, esbuild's dep-scan can't resolve foliate-js's `import '@pdfjs/pdf.min.mjs'`, skips pre-bundling, and any tauri test that imports `@/libs/document` fails to load with "Importing a module script failed" on a cold `.vite` cache. (A warm cache from a prior run masks it.)
|
||||
- **Pre-existing failure, not parity-related**: `tauri-app-service.tauri.test.ts` has 13 failures on macOS — `forbidden path: …/src/__tests__/fixtures/data/sample-alice.epub … allow-open permission`. importBook's library-copy goes through the fs plugin (scoped), and `capabilities-extra/webdriver.json` doesn't grant the fixtures dir. Confirmed identical at base config (no regression from parity work). Parser parity tests avoid this: Rust `File::open` is unscoped, and the JS side fetches fixtures via a Vite URL. To fix separately, add the fixtures path to the webdriver capability scope.
|
||||
|
||||
**Parity gotcha found + fixed**: Rust `handle_meta` mapped `dc:date` AND `dcterms:modified` → `published`. foliate keeps `dcterms:modified` as a separate `modified` field and leaves `published` empty. EPUB3 mandates `dcterms:modified`, so native-imported EPUB3 books with no `<dc:date>` got a bogus publication date. Fix: map only `"date"` (not `"modified"`) to published. Regression tests: `epub_parser::tests::{dcterms_modified_does_not_populate_published, dc_date_populates_published}`.
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: window-state-sanitize-4398
|
||||
description: Windows launch crash (WebView2 0x80070057) from invalid .window-state.json; defensive sanitizer plugin
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 40a168fd-c90e-4f00-9fbc-7effe7c0c45f
|
||||
---
|
||||
|
||||
Issue #4398 (PR #4401) — Windows app fails to launch ("WebView2 error 0x80070057 / The parameter is incorrect") when `%APPDATA%\com.bilingify.readest\.window-state.json` holds the minimized sentinel (`x/y = -32000`) or `0×0` size.
|
||||
|
||||
Non-obvious facts:
|
||||
- Our `tauri-plugin-window-state` (2.4.1, and every shipped 2.2.1+) ALREADY contains the upstream fix for [tauri-apps/plugins-workspace#253](https://github.com/tauri-apps/plugins-workspace/issues/253): `update_state` + the Moved/Resized handlers skip saving while `is_minimized()`, and restore only re-applies position if it `intersects()` a monitor. So current builds shouldn't generate bad state — a bad file is almost certainly stale from an old build. The version-inquiry comment is on the issue.
|
||||
- Fix = **defense-in-depth**, NOT a behavior change: `src-tauri/src/window_state.rs` — a tiny `window-state-sanitizer` Tauri plugin whose `setup` strips window entries with invalid geometry (`width<=0 || height<=0 || x<=-30000 || y<=-30000`) from the file before window-state reads it. Pure `sanitize_json(&str)->Option<String>` is unit-tested (`cargo test -p Readest window_state`).
|
||||
- **Plugin init order matters**: Tauri's `PluginStore::initialize_all` iterates the store in registration order (Vec push), and window-state loads the file in its `setup` (runs during `initialize`). So the sanitizer plugin MUST be registered BEFORE `tauri_plugin_window_state` in `lib.rs` (it is). The app-level `.setup()` closure runs AFTER all plugin setups → too late to sanitize there.
|
||||
- Coord threshold is `-16000` (sentinel is exactly `-32000`; ~halfway). Real desktops sit a few thousand px off origin (4K-left = `-3840`, 3×4K ≈ `-11520`), so `-16000` is well past normal use yet clearly the sentinel zone; a legit negative like `-1920` is kept. Do NOT justify by extreme N-monitor walls (user pushed back on that). The size check (`<=0`) is the real WebView2-crash guard; this coord check is secondary (the plugin already refuses to restore an off-screen position via its `intersects()` monitor check). Missing fields default to valid so a schema change never drops a good entry.
|
||||
|
||||
Repo Rust testing reality: zero pre-existing `#[cfg(test)]` modules; CI only runs `cargo fmt --check` + `cargo clippy -p Readest --no-deps -D warnings` (NOT `--all-targets`, so it won't compile test code). `generate_context!` tolerates a missing `frontendDist` (`../out`), so `cargo test`/clippy build without a frontend build. See [[tauri-parser-parity-tests]].
|
||||
@@ -3,7 +3,8 @@ import { DocumentLoader } from '@/libs/document';
|
||||
import type { BookDoc } from '@/libs/document';
|
||||
import type { ViewSettings } from '@/types/book';
|
||||
import type { Renderer } from '@/types/view';
|
||||
import { applyTableStyle, getStyles, TABLE_SCROLL_CLASS } from '@/utils/style';
|
||||
import { getStyles } from '@/utils/style';
|
||||
import { applyScrollableStyle, SCROLL_WRAPPER_CLASS } from '@/utils/scrollable';
|
||||
import {
|
||||
DEFAULT_BOOK_FONT,
|
||||
DEFAULT_BOOK_LAYOUT,
|
||||
@@ -29,8 +30,7 @@ const WIDE_EPUB_URL = new URL('../fixtures/data/sample-table-wide.epub', import.
|
||||
|
||||
// Spine indices of sample-table-layout.epub whose sections contain <table>.
|
||||
const TABLE_SECTION_INDICES = [2, 3, 4, 9, 10];
|
||||
const TABLE_SCROLL_FIT_CLASS = 'readest-table-scroll-fit';
|
||||
// Same tolerance applyTableStyle uses to ignore sub-pixel / border slop.
|
||||
// Ignore sub-pixel / border slop when deciding whether a table really overflows.
|
||||
const TOLERANCE_PX = 4;
|
||||
|
||||
const loadEPUB = async (url: string, name: string) => {
|
||||
@@ -137,9 +137,9 @@ describe('Paginator table layout', () => {
|
||||
const doc = getSectionDoc(paginator, index);
|
||||
expect(doc, `section ${index} doc`).toBeTruthy();
|
||||
paginator.setStyles?.(getStyles(makeViewSettings(), undefined, []));
|
||||
applyTableStyle(doc!);
|
||||
applyScrollableStyle(doc!);
|
||||
|
||||
for (const wrapper of doc!.querySelectorAll<HTMLElement>(`.${TABLE_SCROLL_CLASS}`)) {
|
||||
for (const wrapper of doc!.querySelectorAll<HTMLElement>(`.${SCROLL_WRAPPER_CLASS}`)) {
|
||||
expect(showsScrollbar(wrapper), `section ${index}: layout table shows a scrollbar`).toBe(
|
||||
false,
|
||||
);
|
||||
@@ -161,9 +161,9 @@ describe('Paginator table layout', () => {
|
||||
const doc = getSectionDoc(paginator, 0);
|
||||
expect(doc, 'wide section doc').toBeTruthy();
|
||||
paginator.setStyles?.(getStyles(makeViewSettings(), undefined, []));
|
||||
applyTableStyle(doc!);
|
||||
applyScrollableStyle(doc!);
|
||||
|
||||
const wrapper = doc!.querySelector<HTMLElement>(`.${TABLE_SCROLL_CLASS}`)!;
|
||||
const wrapper = doc!.querySelector<HTMLElement>(`.${SCROLL_WRAPPER_CLASS}`)!;
|
||||
expect(wrapper, 'wide-table wrapper').toBeTruthy();
|
||||
const table = wrapper.querySelector(':scope > table') as HTMLElement;
|
||||
|
||||
@@ -179,16 +179,12 @@ describe('Paginator table layout', () => {
|
||||
// column to fit content, so we reproduce the column constraint explicitly.)
|
||||
wrapper.style.width = '250px';
|
||||
wrapper.style.maxWidth = '250px';
|
||||
// The ResizeObserver re-evaluates fit on the resize.
|
||||
// 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(
|
||||
wrapper.classList.contains(TABLE_SCROLL_FIT_CLASS),
|
||||
'wide table must not be marked fit',
|
||||
).toBe(false);
|
||||
expect(['auto', 'scroll'], 'wide table overflow-x').toContain(overflowX(wrapper));
|
||||
expect(isClipped(wrapper), 'wide table is clipped instead of scrolling').toBe(false);
|
||||
}, 60000);
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,308 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
applyScrollableStyle,
|
||||
applyTableTouchScroll,
|
||||
findScrollableBox,
|
||||
updateTableFit,
|
||||
shouldTableScrollConsumeTouch,
|
||||
shouldTableScrollConsumeWheel,
|
||||
SCROLL_WRAPPER_CLASS,
|
||||
SCROLL_WRAPPER_FIT_CLASS,
|
||||
} from '@/utils/scrollable';
|
||||
|
||||
describe('applyScrollableStyle', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('wraps a table in a horizontal scroll container', () => {
|
||||
document.body.innerHTML = `
|
||||
<div>
|
||||
<table>
|
||||
<tr>
|
||||
<td width="100">Cell 1</td>
|
||||
<td width="200">Cell 2</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
applyScrollableStyle(document);
|
||||
const table = document.querySelector('table')!;
|
||||
const wrapper = table.parentElement;
|
||||
expect(wrapper?.classList.contains(SCROLL_WRAPPER_CLASS)).toBe(true);
|
||||
expect(table.style.transform).toBe('');
|
||||
});
|
||||
|
||||
it('wraps multiple tables independently', () => {
|
||||
document.body.innerHTML = `
|
||||
<div>
|
||||
<table id="t1"><tr><td>A</td></tr></table>
|
||||
<table id="t2"><tr><td>B</td></tr></table>
|
||||
</div>
|
||||
`;
|
||||
applyScrollableStyle(document);
|
||||
const wrappers = document.querySelectorAll(`.${SCROLL_WRAPPER_CLASS}`);
|
||||
expect(wrappers).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('does not double-wrap when applyScrollableStyle runs twice', () => {
|
||||
document.body.innerHTML = `
|
||||
<div>
|
||||
<table><tr><td>Cell</td></tr></table>
|
||||
</div>
|
||||
`;
|
||||
applyScrollableStyle(document);
|
||||
applyScrollableStyle(document);
|
||||
expect(document.querySelectorAll(`.${SCROLL_WRAPPER_CLASS}`)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not crash on a table whose parent is body', () => {
|
||||
document.body.innerHTML = `
|
||||
<table>
|
||||
<tr><td width="100">A</td></tr>
|
||||
</table>
|
||||
`;
|
||||
applyScrollableStyle(document);
|
||||
const table = document.querySelector('table')!;
|
||||
expect(table.parentElement?.classList.contains(SCROLL_WRAPPER_CLASS)).toBe(true);
|
||||
});
|
||||
|
||||
it('wraps a display equation (math that is the sole content of its container)', () => {
|
||||
document.body.innerHTML = `
|
||||
<div data-type="equation">
|
||||
<math><mrow><mi>x</mi></mrow></math>
|
||||
</div>
|
||||
`;
|
||||
applyScrollableStyle(document);
|
||||
const math = document.querySelector('math')!;
|
||||
expect(math.parentElement?.classList.contains(SCROLL_WRAPPER_CLASS)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not wrap inline math that flows with surrounding text', () => {
|
||||
document.body.innerHTML = `<p>tokens <math><mi>x</mi></math>, then more text</p>`;
|
||||
applyScrollableStyle(document);
|
||||
const math = document.querySelector('math')!;
|
||||
expect(math.parentElement?.classList.contains(SCROLL_WRAPPER_CLASS)).toBe(false);
|
||||
expect(math.parentElement?.tagName.toLowerCase()).toBe('p');
|
||||
});
|
||||
|
||||
it('wraps math[display="block"] even when it has a sibling (e.g. an equation number)', () => {
|
||||
document.body.innerHTML = `<div><math display="block"><mi>x</mi></math><span>(1)</span></div>`;
|
||||
applyScrollableStyle(document);
|
||||
const math = document.querySelector('math')!;
|
||||
expect(math.parentElement?.classList.contains(SCROLL_WRAPPER_CLASS)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateTableFit', () => {
|
||||
const makeWrapper = (scrollWidth: number, clientWidth: number) => {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = SCROLL_WRAPPER_CLASS;
|
||||
Object.defineProperty(wrapper, 'scrollWidth', { value: scrollWidth, configurable: true });
|
||||
Object.defineProperty(wrapper, 'clientWidth', { value: clientWidth, configurable: true });
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
it('marks a table that fits its column as not-a-scroll-container', () => {
|
||||
const wrapper = makeWrapper(200, 200);
|
||||
updateTableFit(wrapper);
|
||||
expect(wrapper.classList.contains(SCROLL_WRAPPER_FIT_CLASS)).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves a table wider than its column scrollable', () => {
|
||||
const wrapper = makeWrapper(400, 200);
|
||||
updateTableFit(wrapper);
|
||||
expect(wrapper.classList.contains(SCROLL_WRAPPER_FIT_CLASS)).toBe(false);
|
||||
});
|
||||
|
||||
it('treats overflow within tolerance as fitting', () => {
|
||||
const wrapper = makeWrapper(203, 200); // 3px slop ≤ tolerance
|
||||
updateTableFit(wrapper);
|
||||
expect(wrapper.classList.contains(SCROLL_WRAPPER_FIT_CLASS)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldTableScrollConsumeTouch', () => {
|
||||
it('returns false when the table is not wider than its container', () => {
|
||||
const wrapper = document.createElement('div');
|
||||
Object.defineProperty(wrapper, 'scrollWidth', { value: 100, configurable: true });
|
||||
Object.defineProperty(wrapper, 'clientWidth', { value: 100, configurable: true });
|
||||
expect(shouldTableScrollConsumeTouch(wrapper, -40, 0)).toBe(false);
|
||||
});
|
||||
|
||||
it('consumes a horizontal swipe when more content is available to the right', () => {
|
||||
const wrapper = document.createElement('div');
|
||||
Object.defineProperty(wrapper, 'scrollWidth', { value: 400, configurable: true });
|
||||
Object.defineProperty(wrapper, 'clientWidth', { value: 200, configurable: true });
|
||||
Object.defineProperty(wrapper, 'scrollLeft', { value: 0, configurable: true });
|
||||
expect(shouldTableScrollConsumeTouch(wrapper, -40, 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('still consumes at the right edge so it never chains to a page turn', () => {
|
||||
const wrapper = document.createElement('div');
|
||||
Object.defineProperty(wrapper, 'scrollWidth', { value: 400, configurable: true });
|
||||
Object.defineProperty(wrapper, 'clientWidth', { value: 200, configurable: true });
|
||||
Object.defineProperty(wrapper, 'scrollLeft', { value: 200, configurable: true });
|
||||
expect(shouldTableScrollConsumeTouch(wrapper, -40, 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores a vertical swipe on a box that only scrolls horizontally', () => {
|
||||
const wrapper = document.createElement('div');
|
||||
Object.defineProperty(wrapper, 'scrollWidth', { value: 400, configurable: true });
|
||||
Object.defineProperty(wrapper, 'clientWidth', { value: 200, configurable: true });
|
||||
Object.defineProperty(wrapper, 'scrollLeft', { value: 0, configurable: true });
|
||||
expect(shouldTableScrollConsumeTouch(wrapper, -5, -40)).toBe(false);
|
||||
});
|
||||
|
||||
it('consumes a vertical swipe when more content is available below (tall code block)', () => {
|
||||
const wrapper = document.createElement('div');
|
||||
Object.defineProperty(wrapper, 'scrollHeight', { value: 400, configurable: true });
|
||||
Object.defineProperty(wrapper, 'clientHeight', { value: 200, configurable: true });
|
||||
Object.defineProperty(wrapper, 'scrollTop', { value: 0, configurable: true });
|
||||
// Finger up (dy < 0) scrolls down; content remains below the viewport.
|
||||
expect(shouldTableScrollConsumeTouch(wrapper, 0, -40)).toBe(true);
|
||||
});
|
||||
|
||||
it('still consumes a vertical swipe at the bottom edge (never turns the page)', () => {
|
||||
const wrapper = document.createElement('div');
|
||||
Object.defineProperty(wrapper, 'scrollHeight', { value: 400, configurable: true });
|
||||
Object.defineProperty(wrapper, 'clientHeight', { value: 200, configurable: true });
|
||||
Object.defineProperty(wrapper, 'scrollTop', { value: 200, configurable: true });
|
||||
expect(shouldTableScrollConsumeTouch(wrapper, 0, -40)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldTableScrollConsumeWheel', () => {
|
||||
it('returns false when the table is not wider than its container', () => {
|
||||
const wrapper = document.createElement('div');
|
||||
Object.defineProperty(wrapper, 'scrollWidth', { value: 100, configurable: true });
|
||||
Object.defineProperty(wrapper, 'clientWidth', { value: 100, configurable: true });
|
||||
expect(shouldTableScrollConsumeWheel(wrapper, 40, 0)).toBe(false);
|
||||
});
|
||||
|
||||
it('consumes a horizontal wheel when more content is available to the right', () => {
|
||||
const wrapper = document.createElement('div');
|
||||
Object.defineProperty(wrapper, 'scrollWidth', { value: 400, configurable: true });
|
||||
Object.defineProperty(wrapper, 'clientWidth', { value: 200, configurable: true });
|
||||
Object.defineProperty(wrapper, 'scrollLeft', { value: 0, configurable: true });
|
||||
expect(shouldTableScrollConsumeWheel(wrapper, 40, 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('still consumes at the right edge so it never chains to a page turn', () => {
|
||||
const wrapper = document.createElement('div');
|
||||
Object.defineProperty(wrapper, 'scrollWidth', { value: 400, configurable: true });
|
||||
Object.defineProperty(wrapper, 'clientWidth', { value: 200, configurable: true });
|
||||
// scrolled fully to the right edge
|
||||
Object.defineProperty(wrapper, 'scrollLeft', { value: 200, configurable: true });
|
||||
expect(shouldTableScrollConsumeWheel(wrapper, 40, 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('still consumes at the left edge so it never chains to a page turn', () => {
|
||||
const wrapper = document.createElement('div');
|
||||
Object.defineProperty(wrapper, 'scrollWidth', { value: 400, configurable: true });
|
||||
Object.defineProperty(wrapper, 'clientWidth', { value: 200, configurable: true });
|
||||
Object.defineProperty(wrapper, 'scrollLeft', { value: 0, configurable: true });
|
||||
expect(shouldTableScrollConsumeWheel(wrapper, -40, 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores a vertical wheel on a box that only scrolls horizontally', () => {
|
||||
const wrapper = document.createElement('div');
|
||||
Object.defineProperty(wrapper, 'scrollWidth', { value: 400, configurable: true });
|
||||
Object.defineProperty(wrapper, 'clientWidth', { value: 200, configurable: true });
|
||||
Object.defineProperty(wrapper, 'scrollLeft', { value: 0, configurable: true });
|
||||
expect(shouldTableScrollConsumeWheel(wrapper, 5, 40)).toBe(false);
|
||||
});
|
||||
|
||||
it('consumes a vertical wheel down while a tall box can still scroll down', () => {
|
||||
const wrapper = document.createElement('div');
|
||||
Object.defineProperty(wrapper, 'scrollHeight', { value: 400, configurable: true });
|
||||
Object.defineProperty(wrapper, 'clientHeight', { value: 200, configurable: true });
|
||||
Object.defineProperty(wrapper, 'scrollTop', { value: 0, configurable: true });
|
||||
expect(shouldTableScrollConsumeWheel(wrapper, 0, 40)).toBe(true);
|
||||
});
|
||||
|
||||
it('still consumes a vertical wheel at the bottom edge (never turns the page)', () => {
|
||||
const wrapper = document.createElement('div');
|
||||
Object.defineProperty(wrapper, 'scrollHeight', { value: 400, configurable: true });
|
||||
Object.defineProperty(wrapper, 'clientHeight', { value: 200, configurable: true });
|
||||
Object.defineProperty(wrapper, 'scrollTop', { value: 200, configurable: true });
|
||||
expect(shouldTableScrollConsumeWheel(wrapper, 0, 40)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findScrollableBox', () => {
|
||||
const setScroll = (el: HTMLElement, scrollWidth: number, clientWidth: number) => {
|
||||
Object.defineProperty(el, 'scrollWidth', { value: scrollWidth, configurable: true });
|
||||
Object.defineProperty(el, 'clientWidth', { value: clientWidth, configurable: true });
|
||||
};
|
||||
|
||||
const makeWrapper = () => {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = SCROLL_WRAPPER_CLASS;
|
||||
const inner = document.createElement('span');
|
||||
wrapper.appendChild(inner);
|
||||
document.body.appendChild(wrapper);
|
||||
return { wrapper, inner };
|
||||
};
|
||||
|
||||
it('routes a scrollable wrapper (table / equation) from a target inside it', () => {
|
||||
const { wrapper, inner } = makeWrapper();
|
||||
setScroll(wrapper, 400, 200);
|
||||
expect(findScrollableBox(inner)).toBe(wrapper);
|
||||
wrapper.remove();
|
||||
});
|
||||
|
||||
it('only routes the scroll-wrapper — bare pre / code / math do not capture gestures', () => {
|
||||
// pre/code/math rely on native overflow scrolling; only the wrapper that
|
||||
// applyScrollableStyle adds (tables, display equations) is routed.
|
||||
for (const tag of ['pre', 'code', 'math'] as const) {
|
||||
const box = document.createElement(tag);
|
||||
const inner = document.createElement('span');
|
||||
box.appendChild(inner);
|
||||
document.body.appendChild(box);
|
||||
setScroll(box, 400, 200);
|
||||
expect(findScrollableBox(inner), `${tag} should NOT route`).toBeNull();
|
||||
box.remove();
|
||||
}
|
||||
});
|
||||
|
||||
it('routes a wrapper that only overflows vertically (tall block)', () => {
|
||||
const { wrapper, inner } = makeWrapper();
|
||||
setScroll(wrapper, 200, 200); // fits horizontally
|
||||
Object.defineProperty(wrapper, 'scrollHeight', { value: 400, configurable: true });
|
||||
Object.defineProperty(wrapper, 'clientHeight', { value: 200, configurable: true });
|
||||
expect(findScrollableBox(inner)).toBe(wrapper);
|
||||
wrapper.remove();
|
||||
});
|
||||
|
||||
it('ignores a wrapper that fits (overflow within tolerance)', () => {
|
||||
const { wrapper, inner } = makeWrapper();
|
||||
setScroll(wrapper, 202, 200); // 2px slop ≤ tolerance
|
||||
expect(findScrollableBox(inner)).toBeNull();
|
||||
wrapper.remove();
|
||||
});
|
||||
|
||||
it('returns null for targets outside any scroll box', () => {
|
||||
const p = document.createElement('p');
|
||||
document.body.appendChild(p);
|
||||
expect(findScrollableBox(p)).toBeNull();
|
||||
expect(findScrollableBox(null)).toBeNull();
|
||||
p.remove();
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyTableTouchScroll', () => {
|
||||
it('attaches capture-phase touch and wheel listeners once per document', () => {
|
||||
document.documentElement.removeAttribute('data-readest-table-touch-scroll');
|
||||
const addSpy = vi.spyOn(document, 'addEventListener');
|
||||
applyTableTouchScroll(document);
|
||||
applyTableTouchScroll(document);
|
||||
const touchMoves = addSpy.mock.calls.filter(([type]) => type === 'touchmove');
|
||||
expect(touchMoves).toHaveLength(1);
|
||||
expect(touchMoves[0]?.[2]).toEqual({ capture: true, passive: false });
|
||||
const wheels = addSpy.mock.calls.filter(([type]) => type === 'wheel');
|
||||
expect(wheels).toHaveLength(1);
|
||||
expect(wheels[0]?.[2]).toEqual({ capture: true, passive: true });
|
||||
addSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -26,11 +26,6 @@ import {
|
||||
getStyles,
|
||||
applyImageStyle,
|
||||
keepTextAlignment,
|
||||
applyTableStyle,
|
||||
applyTableTouchScroll,
|
||||
shouldTableScrollConsumeTouch,
|
||||
shouldTableScrollConsumeWheel,
|
||||
TABLE_SCROLL_CLASS,
|
||||
} from '@/utils/style';
|
||||
import {
|
||||
DEFAULT_BOOK_FONT,
|
||||
@@ -544,155 +539,3 @@ describe('keepTextAlignment', () => {
|
||||
expect(document.querySelector('blockquote')!.classList.contains('aligned-justify')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// applyTableStyle
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('applyTableStyle', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('wraps a table in a horizontal scroll container', () => {
|
||||
document.body.innerHTML = `
|
||||
<div>
|
||||
<table>
|
||||
<tr>
|
||||
<td width="100">Cell 1</td>
|
||||
<td width="200">Cell 2</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
applyTableStyle(document);
|
||||
const table = document.querySelector('table')!;
|
||||
const wrapper = table.parentElement;
|
||||
expect(wrapper?.classList.contains(TABLE_SCROLL_CLASS)).toBe(true);
|
||||
expect(table.style.transform).toBe('');
|
||||
});
|
||||
|
||||
it('wraps multiple tables independently', () => {
|
||||
document.body.innerHTML = `
|
||||
<div>
|
||||
<table id="t1"><tr><td>A</td></tr></table>
|
||||
<table id="t2"><tr><td>B</td></tr></table>
|
||||
</div>
|
||||
`;
|
||||
applyTableStyle(document);
|
||||
const wrappers = document.querySelectorAll(`.${TABLE_SCROLL_CLASS}`);
|
||||
expect(wrappers).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('does not double-wrap when applyTableStyle runs twice', () => {
|
||||
document.body.innerHTML = `
|
||||
<div>
|
||||
<table><tr><td>Cell</td></tr></table>
|
||||
</div>
|
||||
`;
|
||||
applyTableStyle(document);
|
||||
applyTableStyle(document);
|
||||
expect(document.querySelectorAll(`.${TABLE_SCROLL_CLASS}`)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not crash on a table whose parent is body', () => {
|
||||
document.body.innerHTML = `
|
||||
<table>
|
||||
<tr><td width="100">A</td></tr>
|
||||
</table>
|
||||
`;
|
||||
applyTableStyle(document);
|
||||
const table = document.querySelector('table')!;
|
||||
expect(table.parentElement?.classList.contains(TABLE_SCROLL_CLASS)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldTableScrollConsumeTouch', () => {
|
||||
it('returns false when the table is not wider than its container', () => {
|
||||
const wrapper = document.createElement('div');
|
||||
Object.defineProperty(wrapper, 'scrollWidth', { value: 100, configurable: true });
|
||||
Object.defineProperty(wrapper, 'clientWidth', { value: 100, configurable: true });
|
||||
expect(shouldTableScrollConsumeTouch(wrapper, -40, 0)).toBe(false);
|
||||
});
|
||||
|
||||
it('consumes a horizontal swipe when more content is available to the right', () => {
|
||||
const wrapper = document.createElement('div');
|
||||
Object.defineProperty(wrapper, 'scrollWidth', { value: 400, configurable: true });
|
||||
Object.defineProperty(wrapper, 'clientWidth', { value: 200, configurable: true });
|
||||
Object.defineProperty(wrapper, 'scrollLeft', { value: 0, configurable: true });
|
||||
expect(shouldTableScrollConsumeTouch(wrapper, -40, 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not consume when at the right edge and swiping left', () => {
|
||||
const wrapper = document.createElement('div');
|
||||
Object.defineProperty(wrapper, 'scrollWidth', { value: 400, configurable: true });
|
||||
Object.defineProperty(wrapper, 'clientWidth', { value: 200, configurable: true });
|
||||
Object.defineProperty(wrapper, 'scrollLeft', { value: 200, configurable: true });
|
||||
expect(shouldTableScrollConsumeTouch(wrapper, -40, 0)).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores mostly vertical movement', () => {
|
||||
const wrapper = document.createElement('div');
|
||||
Object.defineProperty(wrapper, 'scrollWidth', { value: 400, configurable: true });
|
||||
Object.defineProperty(wrapper, 'clientWidth', { value: 200, configurable: true });
|
||||
Object.defineProperty(wrapper, 'scrollLeft', { value: 0, configurable: true });
|
||||
expect(shouldTableScrollConsumeTouch(wrapper, -5, -40)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldTableScrollConsumeWheel', () => {
|
||||
it('returns false when the table is not wider than its container', () => {
|
||||
const wrapper = document.createElement('div');
|
||||
Object.defineProperty(wrapper, 'scrollWidth', { value: 100, configurable: true });
|
||||
Object.defineProperty(wrapper, 'clientWidth', { value: 100, configurable: true });
|
||||
expect(shouldTableScrollConsumeWheel(wrapper, 40, 0)).toBe(false);
|
||||
});
|
||||
|
||||
it('consumes a horizontal wheel when more content is available to the right', () => {
|
||||
const wrapper = document.createElement('div');
|
||||
Object.defineProperty(wrapper, 'scrollWidth', { value: 400, configurable: true });
|
||||
Object.defineProperty(wrapper, 'clientWidth', { value: 200, configurable: true });
|
||||
Object.defineProperty(wrapper, 'scrollLeft', { value: 0, configurable: true });
|
||||
expect(shouldTableScrollConsumeWheel(wrapper, 40, 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('still consumes at the right edge so it never chains to a page turn', () => {
|
||||
const wrapper = document.createElement('div');
|
||||
Object.defineProperty(wrapper, 'scrollWidth', { value: 400, configurable: true });
|
||||
Object.defineProperty(wrapper, 'clientWidth', { value: 200, configurable: true });
|
||||
// scrolled fully to the right edge
|
||||
Object.defineProperty(wrapper, 'scrollLeft', { value: 200, configurable: true });
|
||||
expect(shouldTableScrollConsumeWheel(wrapper, 40, 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('still consumes at the left edge so it never chains to a page turn', () => {
|
||||
const wrapper = document.createElement('div');
|
||||
Object.defineProperty(wrapper, 'scrollWidth', { value: 400, configurable: true });
|
||||
Object.defineProperty(wrapper, 'clientWidth', { value: 200, configurable: true });
|
||||
Object.defineProperty(wrapper, 'scrollLeft', { value: 0, configurable: true });
|
||||
expect(shouldTableScrollConsumeWheel(wrapper, -40, 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores mostly vertical wheel movement', () => {
|
||||
const wrapper = document.createElement('div');
|
||||
Object.defineProperty(wrapper, 'scrollWidth', { value: 400, configurable: true });
|
||||
Object.defineProperty(wrapper, 'clientWidth', { value: 200, configurable: true });
|
||||
Object.defineProperty(wrapper, 'scrollLeft', { value: 0, configurable: true });
|
||||
expect(shouldTableScrollConsumeWheel(wrapper, 5, 40)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyTableTouchScroll', () => {
|
||||
it('attaches capture-phase touch and wheel listeners once per document', () => {
|
||||
document.documentElement.removeAttribute('data-readest-table-touch-scroll');
|
||||
const addSpy = vi.spyOn(document, 'addEventListener');
|
||||
applyTableTouchScroll(document);
|
||||
applyTableTouchScroll(document);
|
||||
const touchMoves = addSpy.mock.calls.filter(([type]) => type === 'touchmove');
|
||||
expect(touchMoves).toHaveLength(1);
|
||||
expect(touchMoves[0]?.[2]).toEqual({ capture: true, passive: false });
|
||||
const wheels = addSpy.mock.calls.filter(([type]) => type === 'wheel');
|
||||
expect(wheels).toHaveLength(1);
|
||||
expect(wheels[0]?.[2]).toEqual({ capture: true, passive: true });
|
||||
addSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -530,4 +530,70 @@ describe('CFIToXPointerConverter', () => {
|
||||
expect(cfi).toMatch(/^epubcfi\(/);
|
||||
});
|
||||
});
|
||||
|
||||
// applyScrollableStyle wraps wide tables/equations in a cfi-skip <div>. KOReader's
|
||||
// CREngine DOM has no such wrapper, so CFI ↔ XPointer must treat the wrapper as
|
||||
// transparent: the same KOReader XPointer must map to the same CFI (and back) with
|
||||
// or without the wrapper — otherwise KOSync positions would drift.
|
||||
describe('cfi-skip wrapper is invisible to CFI <-> XPointer', () => {
|
||||
// identical content; only difference is the wrapping cfi-skip div around the table
|
||||
const inner = `<table><tbody><tr><td><p>Hello scrollable world</p></td></tr></tbody></table>`;
|
||||
const html = (table: string) =>
|
||||
`<html><body><p>Intro paragraph</p>${table}<p>Outro paragraph</p></body></html>`;
|
||||
const baseDoc = () => new DOMParser().parseFromString(html(inner), 'text/html');
|
||||
const wrappedDoc = () =>
|
||||
new DOMParser().parseFromString(
|
||||
html(`<div class="scroll-wrapper" cfi-skip="">${inner}</div>`),
|
||||
'text/html',
|
||||
);
|
||||
|
||||
// KOReader-style XPointers (no wrapper) — the wrapper-less DOM CREngine produces.
|
||||
const cellText = '/body/DocFragment[1]/body/table/tbody/tr/td/p/text().5';
|
||||
const tableElement = '/body/DocFragment[1]/body/table';
|
||||
const afterTable = '/body/DocFragment[1]/body/p[2]/text().3';
|
||||
|
||||
it('maps a KOReader XPointer to the same CFI with and without the wrapper', () => {
|
||||
const base = new XCFI(baseDoc(), 0);
|
||||
const wrapped = new XCFI(wrappedDoc(), 0);
|
||||
for (const xp of [cellText, tableElement, afterTable]) {
|
||||
// The wrapper must not change the CFI the XPointer resolves to.
|
||||
expect(wrapped.xPointerToCFI(xp), xp).toBe(base.xPointerToCFI(xp));
|
||||
}
|
||||
});
|
||||
|
||||
it('round-trips the table element XPointer back to itself through the wrapped doc', () => {
|
||||
const wrapped = new XCFI(wrappedDoc(), 0);
|
||||
const cfi = wrapped.xPointerToCFI(tableElement);
|
||||
const built = wrapped.cfiToXPointer(cfi);
|
||||
expect(built.xpointer).toBe(tableElement);
|
||||
// The wrapper is a <div>; a transparent wrapper must not appear in the path.
|
||||
expect(built.xpointer).not.toContain('/div');
|
||||
});
|
||||
|
||||
it('round-trips a KOReader range (highlight) inside the table unchanged', () => {
|
||||
// A highlight is a range; xPointerToCFI(pos0,pos1) -> cfiToXPointer pos0/pos1
|
||||
// exercises rangePointToXPointer, the path real highlights use.
|
||||
const pos0 = '/body/DocFragment[1]/body/table/tbody/tr/td/p/text().0';
|
||||
const pos1 = '/body/DocFragment[1]/body/table/tbody/tr/td/p/text().5';
|
||||
const base = new XCFI(baseDoc(), 0);
|
||||
const wrapped = new XCFI(wrappedDoc(), 0);
|
||||
|
||||
const baseCfi = base.xPointerToCFI(pos0, pos1);
|
||||
const wrappedCfi = wrapped.xPointerToCFI(pos0, pos1);
|
||||
expect(wrappedCfi).toBe(baseCfi); // wrapper doesn't change the highlight CFI
|
||||
|
||||
const back = wrapped.cfiToXPointer(wrappedCfi);
|
||||
expect(back.pos0).toBe(pos0);
|
||||
expect(back.pos1).toBe(pos1);
|
||||
expect(back.pos0).not.toContain('/div');
|
||||
expect(back.pos1).not.toContain('/div');
|
||||
});
|
||||
|
||||
it('builds the same CFI->XPointer for the table element with and without the wrapper', () => {
|
||||
const base = new XCFI(baseDoc(), 0);
|
||||
const wrapped = new XCFI(wrappedDoc(), 0);
|
||||
const cfi = base.xPointerToCFI(tableElement);
|
||||
expect(wrapped.cfiToXPointer(cfi).xpointer).toBe(base.cfiToXPointer(cfi).xpointer);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,8 +31,6 @@ import {
|
||||
applyImageStyle,
|
||||
applyScrollbarStyle,
|
||||
applyScrollModeClass,
|
||||
applyTableStyle,
|
||||
applyTableTouchScroll,
|
||||
applyThemeModeClass,
|
||||
applyTranslationStyle,
|
||||
getStyles,
|
||||
@@ -40,6 +38,7 @@ import {
|
||||
keepTextAlignment,
|
||||
transformStylesheet,
|
||||
} from '@/utils/style';
|
||||
import { applyScrollableStyle, applyTableTouchScroll } from '@/utils/scrollable';
|
||||
import { mountAdditionalFonts, mountCustomFont } from '@/styles/fonts';
|
||||
import { layoutWarichu, relayoutWarichu } from '@/utils/warichu';
|
||||
import { getBookDirFromLanguage, getBookDirFromWritingMode } from '@/utils/book';
|
||||
@@ -263,7 +262,7 @@ const FoliateViewer: React.FC<{
|
||||
}
|
||||
|
||||
applyImageStyle(detail.doc);
|
||||
applyTableStyle(detail.doc);
|
||||
applyScrollableStyle(detail.doc);
|
||||
applyTableTouchScroll(detail.doc);
|
||||
applyThemeModeClass(detail.doc, isDarkMode);
|
||||
applyScrollModeClass(detail.doc, viewSettings.scrolled || false);
|
||||
|
||||
@@ -33,6 +33,7 @@ export const sanitizerTransformer: Transformer = {
|
||||
'data-wr-footernote', // custom attribute for weread footnotes
|
||||
'zy-footnote', // custom attribute for zhangyue footnotes
|
||||
'cfi-inert', // custom attribute to mark nodes as inert for CFI processing
|
||||
'cfi-skip', // custom attribute to mark nodes to be skipped in CFI processing
|
||||
];
|
||||
return (
|
||||
attrWhitelist.includes(attributeName) ||
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
// Makes wide/tall block content (tables, code blocks, equations) scroll inside the
|
||||
// reading column instead of overflowing the page, and routes touch/wheel gestures so
|
||||
// scrolling a box never turns the page. The matching CSS lives in getLayoutStyles()
|
||||
// in ./style.ts and keys off these class names.
|
||||
|
||||
export const SCROLL_WRAPPER_CLASS = 'scroll-wrapper';
|
||||
// Marks a wrapped table that fits its column: the wrapper drops to overflow:visible
|
||||
// (not a scroll container — never clips, never captures gestures). A wider table
|
||||
// keeps the default overflow:auto and scrolls. Toggled by updateTableFit.
|
||||
export const SCROLL_WRAPPER_FIT_CLASS = 'scroll-wrapper-fit';
|
||||
// Only the wrapper that applyScrollableStyle injects (around tables and display
|
||||
// equations) captures swipe/wheel gestures so scrolling it doesn't turn the page.
|
||||
// pre/code rely on native overflow scrolling and aren't routed; a bare <math> can't
|
||||
// scroll itself (its box reports no overflow) — it's wrapped instead.
|
||||
const SCROLLABLE_SELECTOR = `.${SCROLL_WRAPPER_CLASS}`;
|
||||
const SCROLL_WRAPPER_TOLERANCE_PX = 4;
|
||||
const SCROLL_WRAPPER_TOUCH_SCROLL_FLAG = 'data-readest-scroll-wrapper-touch-scroll';
|
||||
|
||||
const canScrollX = (el: Element) => el.scrollWidth - el.clientWidth > SCROLL_WRAPPER_TOLERANCE_PX;
|
||||
const canScrollY = (el: Element) => el.scrollHeight - el.clientHeight > SCROLL_WRAPPER_TOLERANCE_PX;
|
||||
|
||||
/**
|
||||
* Nearest scroll box (table/equation wrapper, pre, or code) that can actually scroll
|
||||
* the gesture, or null. Walks up so a non-scrollable inner match (inline <code>, a
|
||||
* <pre><code> whose <pre> is the real scroller) doesn't shadow a scrollable
|
||||
* ancestor (e.g. a wide table wrapping that code).
|
||||
*
|
||||
* This module runs in the top-window realm but `target` comes from the iframe's
|
||||
* realm, so `target instanceof Element` is always false here — duck-type on
|
||||
* `closest` instead so the lookup works across realms.
|
||||
*/
|
||||
export const findScrollableBox = (target: EventTarget | null): HTMLElement | null => {
|
||||
if (!target || !('closest' in target)) return null;
|
||||
let el: Element | null = (target as Element).closest(SCROLLABLE_SELECTOR);
|
||||
while (el) {
|
||||
// Either axis: a tall code block scrolls vertically though it fits horizontally.
|
||||
if (canScrollX(el) || canScrollY(el)) return el as HTMLElement;
|
||||
el = el.parentElement?.closest(SCROLLABLE_SELECTOR) ?? null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* A swipe along a box's scrollable axis scrolls the box and never turns the page —
|
||||
* the box owns that axis, even when already scrolled to the edge (no chaining into
|
||||
* a page turn). A swipe along a non-scrollable axis is left alone, so the reader
|
||||
* can still turn pages by swiping the other way over the box.
|
||||
*/
|
||||
export const shouldTableScrollConsumeTouch = (
|
||||
wrapper: HTMLElement,
|
||||
dx: number,
|
||||
dy: number,
|
||||
): boolean => {
|
||||
if (Math.abs(dx) > Math.abs(dy)) return Math.abs(dx) >= 8 && canScrollX(wrapper);
|
||||
return Math.abs(dy) >= 8 && canScrollY(wrapper);
|
||||
};
|
||||
|
||||
/**
|
||||
* A wheel/trackpad along a box's scrollable axis scrolls the box and never turns
|
||||
* the page, even at the edge — so trackpad momentum (or wheeling past the end of a
|
||||
* code block) can't chain into a page turn. The box owns that axis.
|
||||
*/
|
||||
export const shouldTableScrollConsumeWheel = (
|
||||
wrapper: HTMLElement,
|
||||
deltaX: number,
|
||||
deltaY: number,
|
||||
): boolean => {
|
||||
if (Math.abs(deltaX) > Math.abs(deltaY)) return canScrollX(wrapper);
|
||||
return canScrollY(wrapper);
|
||||
};
|
||||
|
||||
/**
|
||||
* Capture-phase touch + wheel routing so foliate's paginator and readest's wheel
|
||||
* pagination do not steal scrolls over a scroll box. Attached once per iframe document.
|
||||
*/
|
||||
export const applyTableTouchScroll = (document: Document) => {
|
||||
const root = document.documentElement;
|
||||
if (root.getAttribute(SCROLL_WRAPPER_TOUCH_SCROLL_FLAG) === 'true') return;
|
||||
root.setAttribute(SCROLL_WRAPPER_TOUCH_SCROLL_FLAG, 'true');
|
||||
|
||||
let touchStartX = 0;
|
||||
let touchStartY = 0;
|
||||
let activeWrapper: HTMLElement | null = null;
|
||||
|
||||
const onTouchStart = (e: TouchEvent) => {
|
||||
activeWrapper = findScrollableBox(e.target);
|
||||
if (!activeWrapper) return;
|
||||
const touch = e.changedTouches[0];
|
||||
if (!touch) return;
|
||||
touchStartX = touch.screenX;
|
||||
touchStartY = touch.screenY;
|
||||
};
|
||||
|
||||
const onTouchMove = (e: TouchEvent) => {
|
||||
if (!activeWrapper) return;
|
||||
if (!activeWrapper.contains(e.target as Node)) return;
|
||||
|
||||
const touch = e.changedTouches[0];
|
||||
if (!touch) return;
|
||||
|
||||
const dx = touch.screenX - touchStartX;
|
||||
const dy = touch.screenY - touchStartY;
|
||||
if (!shouldTableScrollConsumeTouch(activeWrapper, dx, dy)) return;
|
||||
|
||||
e.stopImmediatePropagation();
|
||||
};
|
||||
|
||||
const onTouchEnd = () => {
|
||||
activeWrapper = null;
|
||||
};
|
||||
|
||||
// Trackpad / mouse wheel over a scroll box generates wheel events (not touch).
|
||||
// foliate has no wheel handler, but readest forwards iframe wheel events to
|
||||
// pagination (see handleWheel -> 'iframe-wheel'), so a wheel over a scrollable box
|
||||
// would both scroll the box and turn the page.
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
const wrapper = findScrollableBox(e.target);
|
||||
if (!wrapper) return;
|
||||
if (!shouldTableScrollConsumeWheel(wrapper, e.deltaX, e.deltaY)) return;
|
||||
// Native overflow scrolling of the box still happens (no preventDefault);
|
||||
// we only stop pagination from also acting on this wheel.
|
||||
e.stopImmediatePropagation();
|
||||
};
|
||||
|
||||
const opts = { capture: true, passive: false } as const;
|
||||
document.addEventListener('touchstart', onTouchStart, opts);
|
||||
document.addEventListener('touchmove', onTouchMove, opts);
|
||||
document.addEventListener('touchend', onTouchEnd, opts);
|
||||
document.addEventListener('touchcancel', onTouchEnd, opts);
|
||||
document.addEventListener('wheel', onWheel, { capture: true, passive: true });
|
||||
};
|
||||
|
||||
/**
|
||||
* A display equation: a <math> that is the sole content of its container (or is
|
||||
* explicitly display="block"). Those need a scroll wrapper; an inline <math> sitting
|
||||
* among text in a paragraph must be left alone so it keeps flowing with the text.
|
||||
*/
|
||||
const isDisplayMath = (math: Element): boolean => {
|
||||
if (math.getAttribute('display') === 'block') return true;
|
||||
const parent = math.parentElement;
|
||||
if (!parent) return false;
|
||||
for (const node of parent.childNodes) {
|
||||
if (node === math) continue;
|
||||
if (node.nodeType === Node.ELEMENT_NODE) return false;
|
||||
if (node.nodeType === Node.TEXT_NODE && node.textContent?.trim()) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrap each table — and each display equation — in a horizontally-scrollable
|
||||
* container so content wider than the column scrolls instead of overflowing the
|
||||
* page. A box that fits is marked fit (overflow:visible — not a scroll container).
|
||||
*
|
||||
* Math needs the wrapper rather than overflow on the <math> itself: a <math> box
|
||||
* reports scrollWidth === clientWidth even when its content overflows, so it can't
|
||||
* scroll on its own — but the wrapping <div>'s scrollWidth does reflect the overflow.
|
||||
*/
|
||||
export const applyScrollableStyle = (document: Document) => {
|
||||
const win = document.defaultView;
|
||||
const wrap = (el: Element) => {
|
||||
const parent = el.parentElement;
|
||||
if (!parent || parent.classList.contains(SCROLL_WRAPPER_CLASS)) return;
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = SCROLL_WRAPPER_CLASS;
|
||||
// cfi-skip keeps this layout-only wrapper out of CFIs: the wrapped element and
|
||||
// its descendants keep the same CFI they had before wrapping, so existing
|
||||
// highlights/bookmarks inside a table or equation still resolve.
|
||||
wrapper.setAttribute('cfi-skip', '');
|
||||
parent.insertBefore(wrapper, el);
|
||||
wrapper.appendChild(el);
|
||||
decideTableFit(wrapper, win);
|
||||
};
|
||||
document.querySelectorAll('table').forEach(wrap);
|
||||
document.querySelectorAll('math').forEach((math) => {
|
||||
if (isDisplayMath(math)) wrap(math);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle SCROLL_WRAPPER_FIT_CLASS: a table within tolerance of its column fits
|
||||
* (overflow:visible — not a scroll container), a wider one stays overflow:auto.
|
||||
*/
|
||||
export const updateTableFit = (wrapper: HTMLElement) => {
|
||||
const fits = wrapper.scrollWidth - wrapper.clientWidth <= SCROLL_WRAPPER_TOLERANCE_PX;
|
||||
wrapper.classList.toggle(SCROLL_WRAPPER_FIT_CLASS, fits);
|
||||
};
|
||||
|
||||
/**
|
||||
* Decide fit ONCE, after layout. applyScrollableStyle runs while the iframe is still
|
||||
* display:none (every width is 0), so the measurement must wait for layout — via a
|
||||
* ResizeObserver that disconnects on its first real measurement. It measures exactly
|
||||
* once and then never observes again, so it never fires during a page turn (a
|
||||
* *persistent* observer is what caused the #4391 relayerize storm).
|
||||
*/
|
||||
const decideTableFit = (wrapper: HTMLElement, win: (Window & typeof globalThis) | null) => {
|
||||
if (!win?.ResizeObserver) return; // no layout (jsdom): leave default (scrollable)
|
||||
const observer = new win.ResizeObserver(() => {
|
||||
if (wrapper.clientWidth <= 0) return; // not laid out yet
|
||||
updateTableFit(wrapper);
|
||||
observer.disconnect();
|
||||
});
|
||||
observer.observe(wrapper);
|
||||
};
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from '@/styles/themes';
|
||||
import { createFontCSS, CustomFont } from '@/styles/fonts';
|
||||
import { getOSPlatform } from './misc';
|
||||
import { SCROLL_WRAPPER_CLASS, SCROLL_WRAPPER_FIT_CLASS } from './scrollable';
|
||||
|
||||
const getFontStyles = (
|
||||
serif: string,
|
||||
@@ -338,39 +339,31 @@ const getPageLayoutStyles = (
|
||||
inset: -10px;
|
||||
}
|
||||
|
||||
pre, code {
|
||||
white-space: pre-wrap !important;
|
||||
}
|
||||
.readest-table-scroll {
|
||||
.${SCROLL_WRAPPER_CLASS} {
|
||||
display: block;
|
||||
overflow: auto;
|
||||
max-width: 100%;
|
||||
/* Scrolling is the default so a table wider than the column is never clipped.
|
||||
A table that can wrap to fit doesn't overflow, so no scrollbar shows.
|
||||
applyTableStyle adds .readest-table-scroll-fit (via a ResizeObserver) to
|
||||
clip the few px of min-content slop some layout tables have, suppressing a
|
||||
spurious scrollbar once layout has settled. */
|
||||
overflow-x: auto;
|
||||
overflow-y: visible;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
/* Let the browser handle horizontal pans on the table; paginated swipe uses capture-phase JS when needed. */
|
||||
touch-action: pan-x pan-y;
|
||||
scrollbar-width: thin;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.readest-table-scroll-fit {
|
||||
overflow-x: clip;
|
||||
touch-action: auto;
|
||||
.${SCROLL_WRAPPER_FIT_CLASS} {
|
||||
overflow: visible;
|
||||
}
|
||||
.readest-table-scroll > table {
|
||||
.${SCROLL_WRAPPER_CLASS} > table {
|
||||
display: table !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
pre {
|
||||
max-width: calc(var(--available-width) * 1px);
|
||||
max-height: calc(var(--available-height) * 1px);
|
||||
pre, code, math {
|
||||
white-space: pre-wrap !important;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
math {
|
||||
overflow: auto;
|
||||
}
|
||||
pre::-webkit-scrollbar {
|
||||
display: none;
|
||||
table, math {
|
||||
max-width: calc(var(--available-width) * 1px);
|
||||
max-height: calc(var(--available-height) * 1px);
|
||||
}
|
||||
|
||||
.epubtype-footnote,
|
||||
@@ -407,6 +400,10 @@ const getPageLayoutStyles = (
|
||||
max-height: calc(var(--available-height) * 0.8 * 1px);
|
||||
}
|
||||
|
||||
figure.code {
|
||||
overflow: unset !important;
|
||||
}
|
||||
|
||||
/* some epubs set insane inline-block for p */
|
||||
p {
|
||||
display: block;
|
||||
@@ -1114,166 +1111,6 @@ export const applyImageStyle = (document: Document) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const TABLE_SCROLL_CLASS = 'readest-table-scroll';
|
||||
// Added to a wrapper whose table fits the column within tolerance, so the wrapper
|
||||
// clips instead of showing a scrollbar. Wide tables stay scrollable (no class).
|
||||
const TABLE_SCROLL_FIT_CLASS = 'readest-table-scroll-fit';
|
||||
// Ignore tiny overflows (borders, image rounding) so they don't show a scrollbar.
|
||||
const TABLE_SCROLL_TOLERANCE_PX = 4;
|
||||
|
||||
const TABLE_TOUCH_SCROLL_FLAG = 'data-readest-table-touch-scroll';
|
||||
|
||||
/** Horizontal swipe on a wide table should scroll the table, not turn the page (paginated mode). */
|
||||
export const shouldTableScrollConsumeTouch = (
|
||||
wrapper: HTMLElement,
|
||||
dx: number,
|
||||
dy: number,
|
||||
): boolean => {
|
||||
if (wrapper.scrollWidth <= wrapper.clientWidth) return false;
|
||||
if (Math.abs(dx) < 8 || Math.abs(dx) <= Math.abs(dy)) return false;
|
||||
|
||||
const atStart = wrapper.scrollLeft <= 1;
|
||||
const atEnd = wrapper.scrollLeft + wrapper.clientWidth >= wrapper.scrollWidth - 1;
|
||||
|
||||
// Finger moves left (dx < 0) reveals content to the right; finger moves right reveals left.
|
||||
if (dx < 0 && !atEnd) return true;
|
||||
if (dx > 0 && !atStart) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
/** Horizontal wheel/trackpad over a wide table should scroll the table, not turn the page. */
|
||||
export const shouldTableScrollConsumeWheel = (
|
||||
wrapper: HTMLElement,
|
||||
deltaX: number,
|
||||
deltaY: number,
|
||||
): boolean => {
|
||||
if (wrapper.scrollWidth <= wrapper.clientWidth) return false;
|
||||
// A horizontal wheel belongs to the table. Consume it regardless of scroll
|
||||
// position: at the edge a wheel gesture (incl. trackpad momentum) must not
|
||||
// chain into a page turn — the table owns the whole horizontal gesture.
|
||||
return Math.abs(deltaX) > Math.abs(deltaY);
|
||||
};
|
||||
|
||||
/**
|
||||
* Capture-phase touch + wheel routing so foliate's paginator and readest's wheel
|
||||
* pagination do not steal horizontal scrolls over wide tables. Attached once per
|
||||
* iframe document.
|
||||
*/
|
||||
export const applyTableTouchScroll = (document: Document) => {
|
||||
const root = document.documentElement;
|
||||
if (root.getAttribute(TABLE_TOUCH_SCROLL_FLAG) === 'true') return;
|
||||
root.setAttribute(TABLE_TOUCH_SCROLL_FLAG, 'true');
|
||||
|
||||
let touchStartX = 0;
|
||||
let touchStartY = 0;
|
||||
let activeWrapper: HTMLElement | null = null;
|
||||
|
||||
const findWrapper = (target: EventTarget | null): HTMLElement | null => {
|
||||
// This module runs in the top-window realm, but `target` originates from
|
||||
// the iframe's realm, so `target instanceof Element` is always false here.
|
||||
// Duck-type on `closest` instead so the lookup works across realms. Skip
|
||||
// wrappers that fit (they clip, not scroll) so only scrollable tables route.
|
||||
if (!target || !('closest' in target)) return null;
|
||||
const wrapper = (target as Element).closest(`.${TABLE_SCROLL_CLASS}`) as HTMLElement | null;
|
||||
return wrapper && !wrapper.classList.contains(TABLE_SCROLL_FIT_CLASS) ? wrapper : null;
|
||||
};
|
||||
|
||||
const onTouchStart = (e: TouchEvent) => {
|
||||
activeWrapper = findWrapper(e.target);
|
||||
if (!activeWrapper) return;
|
||||
const touch = e.changedTouches[0];
|
||||
if (!touch) return;
|
||||
touchStartX = touch.screenX;
|
||||
touchStartY = touch.screenY;
|
||||
};
|
||||
|
||||
const onTouchMove = (e: TouchEvent) => {
|
||||
if (!activeWrapper) return;
|
||||
if (!activeWrapper.contains(e.target as Node)) return;
|
||||
|
||||
const touch = e.changedTouches[0];
|
||||
if (!touch) return;
|
||||
|
||||
const dx = touch.screenX - touchStartX;
|
||||
const dy = touch.screenY - touchStartY;
|
||||
if (!shouldTableScrollConsumeTouch(activeWrapper, dx, dy)) return;
|
||||
|
||||
e.stopImmediatePropagation();
|
||||
};
|
||||
|
||||
const onTouchEnd = () => {
|
||||
activeWrapper = null;
|
||||
};
|
||||
|
||||
// Trackpad / mouse wheel over a wide table generates wheel events (not touch).
|
||||
// foliate has no wheel handler, but readest forwards iframe wheel events to
|
||||
// pagination (see handleWheel -> 'iframe-wheel'), so a horizontal wheel over
|
||||
// a scrollable table would both scroll the table and turn the page.
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
const wrapper = findWrapper(e.target);
|
||||
if (!wrapper) return;
|
||||
if (!shouldTableScrollConsumeWheel(wrapper, e.deltaX, e.deltaY)) return;
|
||||
// Native overflow scrolling of the table still happens (no preventDefault);
|
||||
// we only stop pagination from also acting on this wheel.
|
||||
e.stopImmediatePropagation();
|
||||
};
|
||||
|
||||
const opts = { capture: true, passive: false } as const;
|
||||
document.addEventListener('touchstart', onTouchStart, opts);
|
||||
document.addEventListener('touchmove', onTouchMove, opts);
|
||||
document.addEventListener('touchend', onTouchEnd, opts);
|
||||
document.addEventListener('touchcancel', onTouchEnd, opts);
|
||||
document.addEventListener('wheel', onWheel, { capture: true, passive: true });
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle the fit class: a wrapper whose table overflows by no more than the
|
||||
* tolerance is treated as fitting (clip the slop, no scrollbar); a genuinely
|
||||
* wider table keeps scrolling. Re-runs on resize so the decision is correct once
|
||||
* layout has settled (the column width isn't reliable at section-load time).
|
||||
*/
|
||||
const updateTableFit = (wrapper: HTMLElement) => {
|
||||
const fits = wrapper.scrollWidth - wrapper.clientWidth <= TABLE_SCROLL_TOLERANCE_PX;
|
||||
wrapper.classList.toggle(TABLE_SCROLL_FIT_CLASS, fits);
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrap each table so a table wider than its column scrolls horizontally instead
|
||||
* of overflowing the page. Tables that wrap to fit show no scrollbar; a
|
||||
* ResizeObserver suppresses the scrollbar for tables that overflow only within
|
||||
* tolerance, re-evaluating as the column width settles.
|
||||
*/
|
||||
export const applyTableStyle = (document: Document) => {
|
||||
document.querySelectorAll('table').forEach((table) => {
|
||||
const parent = table.parentNode;
|
||||
if (!parent || parent.nodeType !== Node.ELEMENT_NODE) return;
|
||||
|
||||
if (
|
||||
parent instanceof HTMLElement &&
|
||||
parent.classList.contains(TABLE_SCROLL_CLASS) &&
|
||||
parent.querySelector(':scope > table') === table
|
||||
) {
|
||||
table.style.removeProperty('transform');
|
||||
table.style.removeProperty('transform-origin');
|
||||
return;
|
||||
}
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = TABLE_SCROLL_CLASS;
|
||||
parent.insertBefore(wrapper, table);
|
||||
wrapper.appendChild(table);
|
||||
table.style.removeProperty('transform');
|
||||
table.style.removeProperty('transform-origin');
|
||||
|
||||
updateTableFit(wrapper);
|
||||
const win = document.defaultView;
|
||||
if (win?.ResizeObserver) {
|
||||
const observer = new win.ResizeObserver(() => updateTableFit(wrapper));
|
||||
observer.observe(wrapper);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const keepTextAlignment = (document: Document) => {
|
||||
document.querySelectorAll('div, p, blockquote, dd').forEach((el) => {
|
||||
const computedStyle = window.getComputedStyle(el);
|
||||
|
||||
@@ -293,10 +293,11 @@ export class XCFI {
|
||||
throw new Error(`Invalid XPointer segment: ${segment}`);
|
||||
}
|
||||
|
||||
// Find child elements with matching tag name, skipping cfi-inert elements
|
||||
const children = Array.from(current.children).filter(
|
||||
(child) =>
|
||||
!XCFI.isCfiInert(child) && child.tagName.toLowerCase() === tagName?.toLowerCase(),
|
||||
// Find child elements with matching tag name. effectiveChildren drops
|
||||
// cfi-inert nodes and hoists cfi-skip wrappers, so a layout-only wrapper
|
||||
// doesn't shift indices relative to KOReader's wrapper-less DOM.
|
||||
const children = this.effectiveChildren(current).filter(
|
||||
(child) => child.tagName.toLowerCase() === tagName?.toLowerCase(),
|
||||
);
|
||||
|
||||
if (index >= children.length) {
|
||||
@@ -414,6 +415,39 @@ export class XCFI {
|
||||
return element.hasAttribute('cfi-inert');
|
||||
}
|
||||
|
||||
/**
|
||||
* A cfi-skip element (e.g. the layout-only scroll wrapper applyScrollableStyle
|
||||
* adds around a table/equation) must be transparent to XPointer paths: KOReader's
|
||||
* CREngine DOM has no such wrapper, so its children must keep the indices they'd
|
||||
* have without it. Unlike cfi-inert (drops the node AND its subtree), cfi-skip
|
||||
* hoists the node's children into its parent. Must match epubcfi.js's cfi-skip
|
||||
* handling so CFI ↔ XPointer round-trips through the same logical structure.
|
||||
*/
|
||||
private static isCfiSkip(element: Element): boolean {
|
||||
return element.hasAttribute('cfi-skip');
|
||||
}
|
||||
|
||||
/** Nearest ancestor-or-self element that is not a cfi-skip wrapper. */
|
||||
private static skipTransparentParent(element: Element): Element {
|
||||
let el: Element = element;
|
||||
while (XCFI.isCfiSkip(el) && el.parentElement) el = el.parentElement;
|
||||
return el;
|
||||
}
|
||||
|
||||
/**
|
||||
* Element children of `parent` as XPointer sees them: cfi-inert nodes removed and
|
||||
* cfi-skip wrappers spliced out (their own children hoisted in place, recursively).
|
||||
*/
|
||||
private effectiveChildren(parent: Element): Element[] {
|
||||
const result: Element[] = [];
|
||||
for (const child of Array.from(parent.children)) {
|
||||
if (XCFI.isCfiInert(child)) continue;
|
||||
if (XCFI.isCfiSkip(child)) result.push(...this.effectiveChildren(child));
|
||||
else result.push(child);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build XPointer path from DOM element
|
||||
*/
|
||||
@@ -423,15 +457,23 @@ export class XCFI {
|
||||
|
||||
// Build path from target back to root
|
||||
while (current && current !== this.document.documentElement) {
|
||||
// A cfi-skip wrapper contributes no path segment: it is hoisted away, so
|
||||
// just continue from its parent (matches KOReader's wrapper-less DOM).
|
||||
if (XCFI.isCfiSkip(current)) {
|
||||
current = current.parentElement;
|
||||
continue;
|
||||
}
|
||||
const parent: Element | null = current.parentElement;
|
||||
if (!parent) break;
|
||||
|
||||
const tagName = current.tagName.toLowerCase();
|
||||
// Count preceding siblings with same tag name, skipping cfi-inert elements
|
||||
// Count preceding siblings with the same tag name among the effective
|
||||
// (cfi-inert removed, cfi-skip hoisted) children of the nearest non-skip
|
||||
// ancestor, so a layout-only wrapper doesn't shift the index.
|
||||
const siblings = this.effectiveChildren(XCFI.skipTransparentParent(parent));
|
||||
let siblingIndex = 0;
|
||||
let totalSameTagSiblings = 0;
|
||||
for (const sibling of Array.from(parent.children)) {
|
||||
if (XCFI.isCfiInert(sibling)) continue;
|
||||
for (const sibling of siblings) {
|
||||
if (sibling.tagName.toLowerCase() === tagName) {
|
||||
if (sibling === current) {
|
||||
siblingIndex = totalSameTagSiblings;
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
"prepare": "husky",
|
||||
"fmt:check": "pnpm --filter @readest/readest-app fmt:check",
|
||||
"clippy:check": "pnpm --filter @readest/readest-app clippy:check",
|
||||
"worktree:new": "pnpm --filter @readest/readest-app worktree:new",
|
||||
"worktree:rm": "pnpm --filter @readest/readest-app worktree:rm",
|
||||
"format": "biome format --write .",
|
||||
"format:check": "biome format ."
|
||||
},
|
||||
|
||||
+1
-1
Submodule packages/foliate-js updated: 1fc763b8e6...2fab7ecc25
Reference in New Issue
Block a user