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>
This commit is contained in:
Huang Xin
2026-06-02 20:45:18 +08:00
committed by GitHub
parent 176b950c92
commit 3a81e09911
17 changed files with 897 additions and 362 deletions
@@ -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) ||
+204
View File
@@ -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);
};
+19 -182
View File
@@ -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);
+49 -7
View File
@@ -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;
+2
View File
@@ -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 ."
},