Commit Graph

2278 Commits

Author SHA1 Message Date
Huang Xin 6dc42222e0 fix(reader): keep double-click-and-drag from turning the page (#4524) (#4536)
On the web, double-clicking a word and then dragging to extend the
native selection also turned the page. The first click's deferred
single-click timer fires 250ms later while the second click's button is
still held during the drag, so it posts iframe-single-click and flips
the page. A plain double-click escapes this because its fast second
click updates lastClickTime in time.

Track the mouse-button state in iframeEventHandlers and suppress the
deferred single click while the button is held (a drag is in progress).
A normal single click is unaffected: its button is already released by
the time the deferred timer fires.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 18:39:01 +02:00
justin-jiajia d165e8df2c fix(reader): turn automatically when highlighting across pages (#4487)
* fix(reader): turn automatically when highlighting across pages (closes #1354)

* refact: Refactor time retrieval to use Date.now()

* fix(reader): rework auto page-turn as a corner-dwell gesture

Rework the initial #1354 implementation into a deliberate corner-dwell
gesture that works across platforms, and fix popup positioning for
cross-page selections.

Trigger:
- While a text selection is active, hold any engagement signal — the
  pointer (web/desktop/iOS), the Android native touchmove, or the
  selection caret — inside a screen corner for 500ms to turn one page:
  bottom-right goes to the next page, top-left to the previous.
- One turn per engagement: a signal must leave the corner and return to
  turn another page, so the user controls it one page at a time.
- The corner is a quarter-ellipse of radius 15% of each axis, measured
  against the reading frame (the <foliate-view> rect) inset by the page
  content margins, so the zone lands on the text — not the margin/footer
  or a sidebar — and the pointer can actually reach it.

Per-platform signals:
- web/desktop/iOS: the iframe pointermove, mapped to window coordinates
  via the iframe element's on-screen rect.
- Android: the selection caret (the only signal during a native handle
  drag, where the handles live in a separate window so their touches
  never reach the Activity) plus a throttled (~10/s) native touchmove
  added in MainActivity.dispatchTouchEvent for content drags.

Android scroll-pin (#873): an active selection pins the container scroll,
which reverted the turn; suspend the pin during the turn and re-anchor it
to the page we land on.

Popup positioning: getPosition decided which selection end was on-screen
using window bounds, so a cross-page selection's off-screen start (which
maps behind the sidebar but inside the window) read "in view" and pinned
the popup off the visible page. Test visibility against the reading frame
instead, and for a multi-page selection anchor to the last on-screen line.

Also: logical view.prev()/next() (RTL-correct); skip in scrolled mode;
pass contentInsets down to the annotator.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 18:11:25 +02:00
Huang Xin 1a85f251c0 fix(sync): flush pending Readest cloud push when the reader closes (#4535)
Closing a book within the SYNC_PROGRESS_INTERVAL_SEC (3s) auto-sync
debounce window saved progress locally but dropped the pending Readest
cloud push on teardown. The `sync-book-progress` close handler only
reset the pull gate and re-pulled — it never pushed — so other devices
stayed on the previous cloud-synced position until the book was
reopened (issue #4532).

Flush the debounced push at the start of `handleSyncBookProgress`,
before the pull gate is reset, so the latest local position reaches the
cloud before the view tears down. `syncConfig` reads `configPulled`
synchronously, so flushing while the gate is still open takes the push
branch. Mirrors the existing KOSync close-time `pushProgress.flush()`.
The manual Sync button shares the event and now becomes a true two-way
sync (push local, then pull remote).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 17:32:14 +02:00
loveheaven 1e26c5d765 fix(nav): bound section-scan concurrency to keep zip.js writers from ERRORED-ing (#4528)
On Tauri, section.loadText() drives a plugin-fs open/read/close round trip per call. The unbounded Promise.all in computeBookNav and enrichTocFromNavElements can fire 200+ concurrent IPC chains against a long-spine EPUB, saturate the JS↔Rust bridge or the fd pool, and cause individual reads to reject. The zip.js TextWriter then transitions to ERRORED, surfacing as 'Cannot close a ERRORED writable stream' and silently dropping TOC fragments for the affected sections. In the worst case the rejection propagates through Promise.all and prevents the reader from opening the book.

Hoist the OPDS module's runWithConcurrency to utils/concurrency.ts (zero behaviour change for OPDS) and reuse it in computeBookNav and enrichTocFromNavElements, capped at 128. The cap was binary-searched against the worst-case repro (Android emulator + dev mode + 250-section EPUB): 30/64/128 pass, 200 fails. Section-internal loadText/createDocument dedupe is unchanged.

The worker pool also isolates per-section failures: the outcome shape ({item,result}|{item,error}) lets us log and skip the offending section instead of aborting the entire build as Promise.all did. Even if a future workload pushes past the cap, the reader still opens.
2026-06-11 07:42:39 +02:00
dependabot[bot] 715967dbe7 chore(deps): bump github/codeql-action in the github-actions group (#4533)
Bumps the github-actions group with 1 update: [github/codeql-action](https://github.com/github/codeql-action).


Updates `github/codeql-action` from 4.36.1 to 4.36.2
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/87557b9c84dde89fdd9b10e88954ac2f4248e463...8aad20d150bbac5944a9f9d289da16a4b0d87c1e)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.36.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-11 07:38:21 +02:00
Huang Xin 82bd90afc5 feat(reader): random-access file reads on Android via rangefile scheme (#4534)
* feat(reader): random-access file reads on Android via rangefile scheme

NativeFile's per-chunk Tauri IPC (open+seek+read+close) is slow on Android, and RemoteFile can't replace it because the WebView mishandles Range requests on intercepted custom-protocol responses — it re-applies the offset to the already-sliced body, so any non-zero-start range returns corrupt data or net::ERR_FAILED (Chromium 40739128, tauri-apps/tauri#12019/#3725).

Add a `rangefile` custom URI scheme that carries the byte range in the URL query (?path=&start=&end=) instead of a Range header. With no Range header the WebView delivers the 200 body verbatim, while bytes still stream through the network stack rather than the IPC bridge. The handler is scope-gated by asset_protocol_scope (same boundary as the asset protocol) plus an explicit traversal/NUL/relative guard.

RemoteFile.fromNativePath() drives the scheme on Android (query-carried range, X-Total-Size for size); nativeAppService.openFile routes Android reads through it with a NativeFile fallback. Verified on-device (Android 16 / WebView 147) via CDP: byte-equal reads at every offset, ~1.8x faster small scattered reads, real book opens/renders; all out-of-scope/traversal/NUL paths rejected 403.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci(rust): run cargo unit tests in rust_lint

The rust_lint job ran only fmt + clippy, so the crate's ~40 Rust unit tests (parsers, parser_common, and the new range_file tests) never executed in CI. Add `cargo test -p Readest --lib` to rust_lint — the frontend dist is absent there, but generate_context! already compiles without it (clippy proves this) and the unit tests run headless.

Also add a `test:rust` pnpm script and document it as verification done-condition #6.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 07:37:46 +02:00
loveheaven 9180767ba4 fix(android): deliver Open-with intents reliably on cold start and re-mount (#4527)
Tapping an EPUB in the system file browser and choosing Readest could
silently fail to open the book in two distinct scenarios on Android:

1. Cold launch — the system delivers the ACTION_VIEW intent to
   onCreate / onNewIntent before the JS layer has finished hydrating
   and called addPluginListener('native-bridge', 'shared-intent', ...).
   The upstream Tauri Plugin.trigger() drops events when the per-event
   listener list is empty, so the intent vanishes. Fix this in
   NativeBridgePlugin by queueing emits whose event has no listener,
   then overriding registerListener so the queue is drained whenever
   a listener becomes available.

2. React strict-mode re-mount — useAppUrlIngress had a one-shot
   listened.current ref guard meant to avoid double registration. In
   strict mode (and any subsequent effect re-run) the cleanup
   unregister()'d the underlying native plugin listener but the next
   mount short-circuited on the ref and never re-registered. The
   shared-intent listener list ended up empty for the rest of the
   session, so any subsequent Open-with intent went into the queue and
   never came out. Drop the guard and let the effect register on every
   mount; cleanup balances each registration.
2026-06-10 19:27:56 +02:00
loveheaven 31176e5d47 fix(paginator): bump foliate-js submodule for scrollBounds guard (#4526)
Pulls in the foliate-js fix that guards Paginator#scrollBy and
Paginator#snap against an uninitialized #scrollBounds. Without the
guard, a swipe that lands before the first #scrollToPage seeds the
bounds (e.g. a fast swipe right after the reader mounts, or while a
section is still loading) crashes with

  TypeError: undefined is not iterable (cannot read property
  Symbol(Symbol.iterator)) at Paginator.snap

The submodule fix bails out of both entry points when the bounds aren't
ready yet, so the swipe is dropped rather than fatal; subsequent
settled scrolls reseed the bounds and swipe handling resumes.
2026-06-10 19:23:07 +02:00
Huang Xin 2ade769956 feat(toc): show current reading page under the active item (#4513) (#4525)
Insert a "Current position" row in the TOC sidebar directly under the
highlighted section, indented one level deeper, with an open-book icon
and the live reading page number. Clicking it navigates to the exact
current reading location (progress.location) — distinct from the section
header, which jumps to the section start.

Implemented via a pure buildTOCDisplayItems() helper that injects the
synthetic row after the active item, keeping the active item's index
stable so the existing TOC auto-scroll logic stays untouched. The page
number uses the same muted color as the other rows.

Also fills in the missing "File Path" i18n translations across all
locales (surfaced by i18n:extract) and records project memory notes.

Closes #4513.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 18:40:59 +02:00
Huang Xin 607e646bc6 chore(deps): bump shell-quote to 1.8.4 and qs to 6.15.2 for security (#4523)
Raise the pnpm overrides so the patched transitive versions are pulled:

- shell-quote >=1.8.4 fixes GHSA-w7jw-789q-3m8p / CVE-2026-9277 (critical):
  quote() failed to escape newlines in object .op values, allowing shell
  command injection. Pulled in via cpx2.
- qs >=6.15.2 fixes GHSA-q8mj-m7cp-5q26 / CVE-2026-8723 (medium):
  qs.stringify DoS on null/undefined entries in comma-format arrays with
  encodeValuesOnly. The prior >=6.14.2 pin still allowed vulnerable 6.15.1.
  Pulled in via express, body-parser, googleapis-common.

Resolves Dependabot alerts:
- https://github.com/readest/readest/security/dependabot/237 (shell-quote)
- https://github.com/readest/readest/security/dependabot/235 (qs)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 17:19:25 +02:00
loveheaven 11d796361e perf(import+open): native Rust EPUB/MOBI parser, OPF prefetch, parallel TOC enrichment (#4369)
* perf(epub): add native EPUB parser in Rust

Introduce a Rust-side EPUB pre-parser exposing three Tauri commands:

  * parse_epub_metadata     - title/author/cover + partialMD5 in one
                              shot, for the import hot path
  * parse_epub_full         - OPF + nav.xhtml + toc.ncx bytes plus a
                              manifest size table, for the reader open
                              hot path
  * extract_epub_cover_full - full-resolution cover bytes, for the
                              lock-screen wallpaper writer

All three avoid ferrying multi-MB blobs across the JS<->Rust IPC
boundary. Cover bytes returned by parse_epub_metadata are downscaled
to a webview-friendly JPEG when the long edge exceeds the library
thumbnail size.

No JS callers yet -- wired up in the following commits.

* perf(import): use native EPUB parser and downscale covers on Tauri targets

On Tauri (desktop/iOS/Android), importBook now forwards EPUB
metadata + cover extraction to the Rust parse_epub_metadata
command and reuses the partialMD5 it returns, skipping the
foliate-js full archive parse and the second pass over the file
for hashing.

As a side effect, the cover written to cover.png is downscaled
to a webview-friendly JPEG (long edge <= 512px), shrinking the
on-disk thumbnail from multi-MB to ~30-60KB per book. To keep
the lock-screen wallpaper feature unchanged, useAutoSaveBookCover
now pulls the original full-resolution cover via the Rust
extract_epub_cover_full command instead of copying the (now
downscaled) cover.png; falls back to the thumbnail when the
native path is unavailable.

Web targets and non-EPUB formats keep the existing path.

* perf(reader): prefetch EPUB OPF/nav from Rust on book open

When opening an EPUB on Tauri targets, DocumentLoader now calls the
Rust parse_epub_full command up-front to pull the OPF, EPUB3 nav,
NCX and the central-directory size map in a single IPC. The
foliate-js zip loader is wrapped so that loadText() of these
entries (and a synthetic META-INF/container.xml) is served from
that in-memory cache without inflating through zip.js, while
all other assets keep flowing through the original loader.

A small in-flight dedupe is added to the spine-text loader so the
nav pipeline (loadText + createDocument back-to-back on the same
href) doesn't pay for two zip.js inflate calls per chapter on
first open.

Reader store / app service plumbing: readerStore.openBook now
resolves an absolute on-disk path via the new
appService.resolveNativeBookFilePath / bookService.resolveNativeBookFilePath
helper and threads it into DocumentLoader as nativeFilePath so
the prefetch can fire. Web targets, non-EPUB formats and books
without a managed/external on-disk path skip the prefetch and
take the original code path.

* perf(nav): parallelize section scans and memoize fragment lookups

computeBookNav now processes sections via Promise.all instead of
a sequential for-loop, and within each section issues loadText()
and createDocument() concurrently. Combined with the in-flight
loadText dedupe added to the zip loader, each chapter pays for a
single zip inflate per nav build, and the inflates of different
chapters overlap.

enrichTocFromNavElements is restructured into two concurrent
phases: a cheap '<nav' substring filter on the inflated text, and
a parsed-document walk for the survivors. Most chapters fall out
in phase 1 without ever being parsed.

In fragments.ts, calculateFragmentSize now consults a
per-section position cache (makeFragmentPositionCache) so the
N-fragment loop is O(N) over the chapter HTML instead of O(N²).
A small isCfiAddressable guard is added to skip elements that
foliate-js's CFI generator can't address (documentElement, body
itself, detached nodes, nodes outside <body>) — these previously
threw and spammed console.warn for every fragment, now they
silently fall back to the section CFI.

* perf(import): use native MOBI/AZW/AZW3 parser on Tauri targets

On Tauri (desktop/iOS/Android), importBook now forwards
MOBI/AZW/AZW3/PRC metadata + cover extraction to the Rust
parse_mobi_metadata command and reuses the partialMD5 it returns,
skipping the foliate-js full-buffer parse and the second pass over
the file for hashing. Mirrors the existing EPUB native fast-path
added in e3fc4767 — bookService tries EPUB first, then MOBI; both
bridges fall back to the foliate-js DocumentLoader when the native
path is unavailable (web target, parse error, format mismatch).

The new mobi_parser is built on the mobi crate (KF7+KF8 reader,
zero JS-side touch). It reads title, author, publisher, ISBN, ASIN,
publish date, language, subjects and description from the MobiHeader
+ EXTH records, resolves the EXTH 201 cover offset against the PDB
image-record table (with ThumbOffset / first-image fallbacks), and
strips KindleGen's HTML wrapping in EXTH 103 so the description goes
into the library DB as plain text. The parsed cover is funneled
through the same maybe_resize_cover path as EPUB, so MOBI library
thumbnails are also clamped to a 512px-long-edge JPEG.

Cover-resize / partialMD5 / RawCoverImage are extracted into a new
parser_common module shared between epub_parser and mobi_parser, so
a single tweak (e.g. raising the thumbnail target) applies to every
native importer and the partialMD5 implementation can't drift between
the two paths (a divergent algorithm would silently re-import every
existing book under a new hash on the first run).

Web targets and non-Kindle formats keep the existing path.

* test(tauri): verify native Rust EPUB parser parity with foliate-js

Add a Tauri WebView parity suite (epub-parser-parity.tauri.test.ts) that
cross-checks the native Rust parser against foliate-js on the same fixtures:
parse_epub_metadata / parse_epub_full (title, author, language, identifier,
publisher, published, subjects, partialMD5, OPF + per-entry size table), and
that opening with the native prefetch produces the same BookDoc and
computeBookNav (TOC) output as the pure-JS path.

Fix a parity divergence the suite caught: the Rust OPF parser mapped
dcterms:modified onto `published`, but foliate-js keeps them separate and
leaves `published` empty -- so EPUB3 books carrying only the mandatory
dcterms:modified got a bogus publication date on the native import path. Map
only dc:date now; add regression tests.

Test infra:
- vitest.tauri.config.mts: add optimizeDeps (mirroring vitest.browser.config)
  so foliate-js-importing tauri tests load -- otherwise esbuild's dep scan
  can't resolve '@pdfjs/pdf.min.mjs', pre-bundling is skipped, and the CJS
  deps fail to import ("Importing a module script failed").
- capabilities-extra/webdriver.json: fix __test__ -> __tests__ fs scope typo
  so import tests can open fixtures under src/__tests__/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(import): foliate-js owns EPUB/MOBI metadata via standalone extractors

Rust contributes only the mechanical work that's expensive on a
WebView — partialMD5, the downscaled cover, and (for EPUB) the raw
OPF bytes Rust already had to read for cover resolution. Metadata
extraction is delegated to foliate-js's two new standalone entry
points (`parseEpubMetadataFromXML`, `readMobiMetadata`) so the
import-path BookDoc and the reader-path BookDoc share a single
parser implementation.

EPUB
- `parse_epub_metadata` returns
  `{ partialMd5, cover, coverMime, opfPath, opfBytes }`. OPF bytes
  are a free byproduct of the cover-resolution scan.
- `tryNativeParseEpub` runs `parseEpubMetadataFromXML` on the OPF
  bytes and assembles a lightweight BookDoc stub (metadata +
  getCover). The importer doesn't drive `DocumentLoader.open()`, so
  no zip central-directory scan, no nav/ncx inflate, no spine walk.
- `coverMime` is preserved so `bookService.importBook`'s
  `cover.type === 'image/svg+xml'` branch still routes SVG covers
  through svg2png.

MOBI / AZW / AZW3 / PRC
- `parse_mobi_metadata` returns `{ partialMd5, cover, coverMime }`.
  `tryNativeParseMobi` runs foliate's `readMobiMetadata` on the
  same File, which uses `MOBI.open(file, { metadataOnly: true })`
  to parse PalmDB + MobiHeader + EXTH and short-circuit before the
  MOBI6 / KF8 init() that walks every text record.
- `Book.metadata.identifier` is foliate's `mobi.uid.toString()`
  (PalmDB UID), the canonical MOBI identifier the reader path uses.

bookService.importBook
- EPUB and MOBI native branches consume the bridge's BookDoc stub
  directly. The stub's `getCover()` returns the Rust-downscaled
  blob, falling back to foliate's own `getCover` thunk when Rust
  didn't extract a cover.

Other
- Drop the unused `base64` Rust dependency: cover bytes go over IPC
  as `Vec<u8>` (Tauri 2 transports them natively, like opfBytes /
  navBytes / ncxBytes).
- Drop the `nativePrefetch` option on `DocumentLoaderOptions`; no
  caller passes it. `nativeFilePath` keeps driving `parse_epub_full`
  on the open hot path.

Tests
- vitest.tauri parity test asserts byte-equal partialMD5, cover
  presence parity, OPF bytes that decode to a real `<package>`
  document, and that `parseEpubMetadataFromXML` on those bytes
  produces the same user-visible metadata fields (title / author /
  language / identifier / published) as `DocumentLoader.open()`.

* test(tauri): add War and Peace MOBI fixture for native parser parity

The .tauri parser-parity suite previously had no .mobi/.azw3 asset, so the native MOBI parser (metadata + EXTH cover resolution) was uncovered. Adds a real KF8 MOBI ("War and Peace") to enable MOBI parity coverage against foliate-js.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(foliate-js): bump submodule to readest/foliate-js main (91191ca)

Replaces the ad-hoc 02f435a with the merged main commit 91191ca, which lands the standalone OPF/MOBI metadata extractors (parseEpubMetadataFromXML, readMobiMetadata) the import fast-path depends on (foliate#19), plus the RTL multi-view rect-mapper fix (foliate#20). The extractor code is byte-identical to 02f435a, so the bridges are unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 16:58:25 +02:00
Huang Xin 7e5c74f5ef chore(memory): record OPDS HTML description and JSON search notes (#4516)
Persist pending agent memory notes for the recently merged OPDS fixes:
- OPDS HTML description rendering (#4503 / PR #4510), including the
  sanitizeHtml consolidation into @/utils/sanitize.
- OPDS 2.0 JSON catalog search (#4502 / PR #4509).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 07:17:11 +02:00
Huang Xin 31ebf4b586 feat(opds): make subject and author links clickable in the book detail view (#4515)
* feat(opds): add getOPDSNavLink helper + subject/author link types (#4504)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(opds): make subject/author links clickable in detail view (#4504)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 07:15:03 +02:00
Wanten 553c2b6398 fix(linux): update tauri submodule for resize cursor fix (#4512)
Update tauri submodule to include input_shape fix that lets edge pointer
events fall through to the GtkWindow for native resize cursor on Linux.
2026-06-10 06:57:55 +02:00
loveheaven 88d8aa285f feat(metadata): show file path for in-place imported books (#4508)
In-place imports point at a file the user keeps under one of their
external library folders (book.filePath set), as opposed to hash-copy
imports that live anonymously under Books/<hash>/. The book details
view didn't surface where an entry actually lives on disk, so users had
no way to tell the two storage modes apart or locate the source file.

Add a 'File Path' row to the metadata grid that renders only when
book.filePath is set, breaks long paths across lines, and exposes the
full string via a hover title for paths that overflow the row.
2026-06-10 06:54:53 +02:00
Huang Xin d12e1ad087 fix(opds): enable search for OPDS 2.0 JSON catalogs, closes #4502 (#4509)
OPDS 2.0 JSON feeds advertise search as a templated link with
type `application/opds+json`, `templated: true`, and an RFC 6570 URI
template href (e.g. `/search{?query}`). `isSearchLink` only recognized
OpenSearch/Atom types, so `hasSearch` was false and the navbar search
input stayed disabled (greyed out). Even when enabled, `handleSearch`
only handled OpenSearch/Atom, so a query would not reach the server.

- Recognize templated `application/opds+json` search links.
- Add `expandOPDSSearchTemplate` to expand the URI template (reusing
  foliate-js/uri-template.js) with the typed term placed in the primary
  text variable (query/searchTerms/q), then resolve and navigate.
  Expansion happens before resolveURL, which would otherwise mangle the
  `{?query}` braces.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 06:53:49 +02:00
Huang Xin 8425d0b91f fix(opds): render HTML in publication descriptions (#4510)
* fix(opds): render HTML in publication descriptions, closes #4503

OPDS publication descriptions showed raw HTML tags (literal `<p>`,
`&quot;`, `&#x27;`) instead of rendering them. Some aggregator feeds
serve the description as an Atom `type="text"` summary whose HTML has
been escaped twice; foliate's getContent only un-escapes `type="html"`/
`"xhtml"`, so the markup survives parsing as entity text and the detail
view dumped it straight into an unsanitized `dangerouslySetInnerHTML`
(also an XSS sink for untrusted feed content).

Add `getOPDSDescriptionHtml`: decode one extra entity level only when the
value is entirely escaped markup (mixed content like `<p>see &lt;code&gt;`
is left literal), then sanitize with the shared DOMPurify sanitizer.
Wire it into PublicationView and render the sanitized HTML.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor: consolidate HTML sanitizers into @/utils/sanitize

sanitizeHtml/sanitizeForParsing are generic DOMPurify wrappers, not
specific to Send-to-Readest. Now that OPDS description rendering also
needs sanitizeHtml, move them out of services/send/conversion into the
shared @/utils/sanitize module (alongside sanitizeString) so neither
consumer reaches across the other's feature boundary.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 06:52:34 +02:00
Huang Xin 75dc2e4e81 fix(updater): disable in-app updater inside Flatpak sandbox, closes #4440 (#4507)
Flatpak mounts the app directory read-only, so the bundled Tauri updater
can download a new version but never apply it, leaving the user stuck on
the old build with no working install path. Update management belongs to
the Flatpak runtime / system package manager.

Detect the sandbox via FLATPAK_ID or /.flatpak-info and fold it into the
existing `updater_disabled` flag, which propagates to `hasUpdater` and
suppresses the in-app updater window. Release notes still surface as an
informational-only path.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 17:46:09 +02:00
Wanten ad23fbba9f fix(reader): dismiss annotation popup when selection clears (#4483) 2026-06-09 17:44:10 +02:00
loveheaven c15e850252 fix(reader): shrink hidden page-nav buttons on Android so they don't eat long-press (#4501)
On Android, the four 80x80 page-navigation buttons stay mounted on top of the foliate viewer even when hidden (opacity-0). pointer-events:none can't be used on Android (it breaks touch propagation to the iframe), so the prev/next-section buttons already have an h-4 w-4 fallback for the hidden state. The prev-page / next-page buttons were missing this fallback and therefore kept covering ~80x80 hot zones in the lower-left and lower-right of the page, swallowing long-press touches on the first/last words of the bottom two lines so they could neither be highlighted nor open the toolbar. Apply the same h-4 w-4 fallback to those two buttons.
2026-06-09 17:42:32 +02:00
loveheaven 676e14234b fix(reader): correct RTL reading position restore on book reopen (#4505)
Bumps foliate-js to pick up the RTL multi-view rect mapper fix
(readest/foliate-js#20).

Reproduced with an Arabic EPUB: closing the book at e.g. chapter 2
page 14 and reopening it briefly landed near the saved position, then
jumped several chapters forward as foliate-js's #fillVisibleArea
pre-loaded adjacent sections; the wrong location was then auto-saved,
overwriting the user's actual reading progress on disk.
2026-06-09 17:39:46 +02:00
Huang Xin 1eaf16ffc2 fix(opds): tolerate junk after document element in feeds (#4479) (#4506)
The Hungarian MEK catalog (a PHP backend) returns a valid Atom feed
followed by trailing junk after </feed> — a stray PHP warning, an extra
tag, or text. Chrome's DOMParser ignores it, but Firefox's strict parser
fails with "junk after document element" and replaces the whole document
with a <parsererror>. The reader then sees a non-feed root, treats the
response as HTML, finds no OPDS link, and silently navigates back, so
browsing the catalog on Firefox web is broken on nearly every subpage.

Add parseOPDSXML(): on a parser error, re-parse the slice from the root
element's start tag to its last matching end tag, dropping any leading
prolog and trailing junk. If recovery still fails the original error
document is returned, so callers fall through to their existing
HTML/non-OPDS handling. Wire it into the three OPDS XML parse sites:
the reader (page.tsx), validateOPDSURL (adding a catalog), and the
subscription/auto-download feed checker. feedChecker also switches its
text.startsWith('<') detection to looksLikeXMLContent so the MEK feed's
leading newlines (no <?xml?> declaration, #4181) are recognized.

jsdom mirrors Firefox's strict behavior (same parsererror namespace), so
the regression tests run in the normal unit suite.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 17:26:47 +02:00
Huang Xin 4d1205fdf5 fix(reader): stop zoomed image pan from flickering on desktop, closes #4451 (#4465)
The desktop mouse-drag handlers were bound to the moving <img>, so the
cursor crossing the (transition-lagged) image boundary fired onMouseLeave
and repeatedly aborted/restarted the drag — the flicker. Touch was fine
because it tracks on the full-screen container.

Track the drag on `window` while dragging (mirroring the touch path),
disable the transform transition during the drag so the pan is 1:1, and
set will-change: transform (the transform-gpu class is overridden by the
inline transform, so its GPU hint was lost).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 18:29:21 +02:00
Huang Xin b07c9eb631 fix(eink): make Custom Fonts panel readable in e-ink mode (#4454) (#4464)
The selected custom-font card used `bg-primary/50`, whose opacity suffix
dodges the e-ink `.bg-primary` normalizer — leaving a dark primary fill
under force-black `text-base-content` text, i.e. black-on-black (#4454).
Add `eink-bordered` so the selected card gets the same white-bg /
black-border / black-text treatment every other selected surface gets,
while staying distinct from the faint-bordered unselected cards.

The Import Font "+" badge had the same class of bug: e-ink's substring
matchers catch its `group-hover:bg-base-content` and `text-base-content/60`
utilities and paint a black glyph on a black circle. Pin the badge to an
intentional base-content circle with a base-100 glyph so the "+" stays
legible.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 18:28:30 +02:00
loveheaven 4eeed74cdd fix(webdav): always sync book covers, not just when syncBooks is on (#4445)
Cover uploads were nested inside the `syncBooks` toggle in both the
batch path (`syncLibrary`) and the per-book reader path
(`pushBookFileNow`). With the default `syncBooks: false`, covers
silently never reached the WebDAV server even though `config.json`
did, so receiving devices ended up with progress + notes synced but
no shelf art.

Covers are conceptually metadata, not bytes:
  - they're tiny (~30–60 KB after the import-time downscale);
  - they cannot be regenerated on a fresh device that doesn't hold
    the book bytes (custom covers from metadata services in
    particular are completely unrecoverable without sync);
  - cloudService already treats them as metadata-grade in its
    download path (`downloadBookCovers`, `downloadBook(onlyCover)`).

Two changes:

1. `WebDAVSync.ts::syncLibrary` — moved `pushBookCover` out of the
   `if (options.syncBooks)` block; it now runs alongside
   `pushBookConfig`, before `pushBookFile`. Step ordering in the
   header doc-comment was updated to match.

2. `useWebDAVSync.ts` — extracted a standalone `pushBookCoverNow`
   callback (gated only on `allowPush`, with its own
   `coverSyncedRef` for per-instance dedupe), and dropped the cover
   ride-along that lived at the tail of `pushBookFileNow`. The
   open-book effect now fires `pushBookCoverNow` and
   `pushBookFileNow` in parallel via `Promise.all` (different remote
   paths, no reason to serialize), and the manual-push event handler
   triggers both independently.

The WebDAV pull path was already independent of `syncBooks`, so no
changes are needed there — receiving devices will pick up the newly
mirrored covers automatically.
2026-06-04 18:23:53 +02:00
Huang Xin d8fbf5fe08 fix(reader): show KOReader progress-synced as a top-right hint (#4461) (#4463)
KOReader sync displayed the "Reading Progress Synced" notification as a
centered info toast that blocks the text for fast readers, while Readest's
own cloud sync uses an unobtrusive top-right hint.

Route the notification through the same 'hint' event (HintInfo, top-right,
~2s auto-dismiss) that useProgressSync uses, instead of the centered 'toast'.
This covers both KOSync paths that apply remote progress (the auto-apply
receive/silent flow and the conflict-resolved "use remote" flow); the
interactive conflict-resolution dialog is unchanged.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 18:03:19 +02:00
dependabot[bot] 93b3c4373a chore(deps): bump the github-actions group with 2 updates (#4450) 2026-06-04 14:46:48 +08:00
Huang Xin 719e9c7542 fix(eink): keep dropdown-toggle label legible under e-ink (#4435) (#4441) 2026-06-03 14:48:10 +08:00
Huang Xin 35eb7f2e14 release: version 0.11.4 (#4431) 2026-06-02 19:15:32 +02:00
Huang Xin 27fa9ab226 chore(i18n): translate new strings; commit pending agent memory notes (#4430)
Translate 4 new keys across all 33 locales:
- Send
- Book file is not available locally
- Failed to send book
- Highlight Current Sentence

Also commit pending .claude/memory bug-fix notes.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 19:01:47 +02:00
Huang Xin c2bbb6119a fix(reader): keep paginated page background inside its column (#4394) (#4429)
On a multi-column spread a coloured page's background bled into the outer margin gutter (the --_outer-min track) while an adjacent transparent/image page did not, shifting cover/title spreads off-centre. Bump foliate-js to clamp each background segment to its column instead of stretching into the gutter, keeping the symmetric margins intact. Update the computeBackgroundSegments regression tests.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 18:48:56 +02:00
Huang Xin 578b7ba14f fix(reader): restore annotation list auto-scroll to the nearest item (#4428)
Virtualizing BooknoteView (#4352) dropped the auto-scroll that centers the
note nearest the current reading position, leaving the list stranded at the
top. The single scrollToIndex that replaced the per-item useScrollToItem was
missing the machinery TOCView already uses for virtualized auto-scroll:

- Re-apply the scroll inside the OverlayScrollbars `initialized` callback
  (read via a ref): its deferred init resets the viewport scrollTop to 0, and
  the lastScrolledCfiRef guard otherwise blocked any retry (reload case).
- Mount Virtuoso natively centered via initialTopMostItemIndex with a
  skip-gate, so opening the panel while reading doesn't fire a scrollToIndex
  that races and wedges the freshly mounted, unmeasured list (tab-switch case).
- Jump instantly (behavior 'auto') for far moves and on eink, animating
  'smooth' only for short in-session updates — mirroring TOCView.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 18:39:45 +02:00
Huang Xin 9d8062ae27 fix(reader): keep table background matching the page in dark mode (#4419) (#4426)
The dark-mode `table *` color-mix tint in getColorStyles was applied
unconditionally since #4055, so plain tables — and the invisible spacer
cells some books use for vertical TOC layout — rendered a few shades off
the page background, and the spacing between words appeared to change.

Restore the `overrideColor` gate that #2377 originally added. Illegible
light/zebra table backgrounds (the #4028 case #4055 targeted) are now
handled separately by the dark-mode light-background rewriters from
#4392, so the blanket tint is no longer needed by default. The
standalone blockquote tint stays unconditional in dark mode.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 18:20:59 +02:00
Huang Xin 7128e8964e fix(reader): suppress Android image callout freezing the image viewer (#4425)
Long-pressing an image in the zoom viewer on Android triggers the
WebView's native image callout (context menu / drag / magnifier) at the
same time as the viewer's own pinch/pan/zoom touch handlers, locking up
the whole app until restart. Same root cause as the book-cover freeze
(PR #4345): `-webkit-touch-callout: none` doesn't inherit, so the class
must sit on an ancestor of the `<img>`.

Apply the existing `.no-context-menu` class to the viewer container so
the `.no-context-menu img` rule reaches the zoomed image and disables
the native callout. Harmless on desktop (the property is a no-op there
and right-click-save still works).

Closes #4420

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 17:04:52 +02:00
Huang Xin e4bb9fc4b7 refactor(share): make saveFile content nullable for path-based shares (#4424)
The book "Send" flow had to pass `new ArrayBuffer(0)` to saveFile purely to
satisfy the type-checker: the content arg is ignored on the native share
path when `options.filePath` points at an already-on-disk file. Widen
saveFile's content parameter to `string | ArrayBuffer | null` across the
AppService contract so callers can hand off a file by path without buffering
it into memory, and pass `null` from the Send flow instead of a throwaway
empty buffer.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 16:49:47 +02:00
Huang Xin df66c63a07 fix(txt): recover author for 【】-titled web-novel TXT imports, closes #4390 (#4423)
Chinese web-novel TXT files are commonly named 【书名】1-129 作者:起落.txt and
carry a noisy metadata block at the top of the file. Two author-recognition
failures resulted after importing them:

  1. Author missing — extractTxtFilenameMetadata only pulled an author from
     《》-wrapped names, so 【】-style names yielded no filename author, and when
     the file header had no clean "作者:X" line the author came out empty.

  2. Irrelevant content as author — the greedy file-header capture
     (/作者…(.+)\r?\n/) grabbed a publication blob like
     "2024/08/01发表于:是否首发:是 字数1023150字…" and surfaced it as the author.

Fix:
  - extractTxtFilenameMetadata now extracts the labeled "作者:X" form from any
    filename (title stays the full name; only the labeled form is safe so a
    leading 【title】 isn't mistaken for the author).
  - Validate the header-matched author (isPlausibleAuthorName) and fall back to
    the filename author when it looks like a metadata blob — embedded field
    separator, long digit run, or excessive length. Applied to both the small-
    and large-file conversion paths.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 16:44:07 +02:00
Huang Xin 963bab0f0f fix(library): stop bookshelf context menu shuffling its order (#4389) (#4421)
The bookshelf right-click menu built itself with un-awaited
`Menu.append()` calls. Each append is an async IPC round-trip to the
Tauri backend, so the concurrent fire-and-forget requests resolved in
non-deterministic order on the Rust side and the native menu items
landed shuffled on every open (only reproducible on the native app,
invisible in jsdom).

Build the items in order and create the menu in a single
`await Menu.new({ items })` call for both the book and group handlers.
Order and conditional inclusion are unchanged.

Extract the order/inclusion logic into a pure `getBookContextMenuItemIds`
helper in `libraryUtils.ts` so the deterministic ordering is unit-tested
without mounting the component or mocking Tauri.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 16:40:11 +02:00
Huang Xin f9ddddb6ac fix(library): use ghost cancel buttons in migrate-data dialog for e-ink (#4396) (#4422)
The Change Data Location dialog rendered its Cancel/Close (secondary)
buttons with `btn-outline`. Under `[data-eink='true']`, globals.css
inverts both `.btn-outline` and `.btn-primary` to the same base-content
fill + base-100 text, so Cancel and Start Migration collapsed into two
identical black buttons and became indistinguishable on e-ink screens.

Switch the secondary buttons to `btn-ghost`, matching the design-system
rule (DESIGN.md): the primary CTA keeps its solid fill while the ghost
cancel reads as borderless next to it, restoring the hierarchy.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 16:36:57 +02:00
loveheaven fe853554a9 feat(library): send book file from bookshelf selection popup (#4402)
* feat(library): send book file from bookshelf selection popup

Adds a Send button to the bottom popup that appears when one or more
books are selected on the bookshelf. Hands the actual book file
(epub/pdf/...) to the OS share sheet via tauri-plugin-sharekit
(UIActivityViewController on iOS, Intent.ACTION_SEND on Android,
NSSharingServicePicker on macOS), so users can fire the file off to
Mail / Messages / WeChat / AirDrop / etc.

This is intentionally distinct from the per-item context-menu "Share
Book", which uploads the book to the readest backend and generates a
public link. "Send" is offline file egress; "Share Book" is remote
collaboration. They share zero infra.

Resolution rules mirror bookContent.resolveBookContentSource: managed
copy under Books/<hash>/ first, then the device-local in-place import
path. Cloud-only books warn rather than silently no-op.

Path is handed to shareFile via options.filePath. Without that,
saveFile() falls back to writing a temp copy under BaseDirectory.Temp,
which on Android resolves to /data/local/tmp/ — the app sandbox has
no write permission there and the call fails with EACCES ("failed to
open file at path: /data/local/tmp/...epub Permission denied (os error
13)"). Passing the absolute path also avoids re-buffering the entire
epub/pdf into memory.

On macOS the NSSharingServicePicker is anchored to the selected
book's cover rather than to the Send button — the user's visual
focus is on the cover they just tapped, not on the bottom toolbar.
BookshelfItem stamps a data-book-hash attribute on its root div so
the Send handler can locate the cell via querySelector and pass its
rect through saveFile's sharePosition option. preferredEdge='bottom'
maps to NSMinYEdge, so the popover renders above the cover (and only
auto-flips below when there's no room above). iOS / Android share
sheets are modal and ignore sharePosition, so the same code is a
no-op there.

The button is hidden on Linux (no system share sheet), Windows
(WebView2 share UI deadlocks the main thread, see #4343), and web
browsers (no "send file to <app>" affordance for arbitrary downloads).

* fix(share): don't fall back to saveDialog when shareFile is cancelled

The native saveFile({ share: true }) path used to swallow any error
from sharekit's shareFile() and fall through to saveDialog. The plugin
treats user cancellation the same as a failure (it rejects with
'Share cancelled' on Android when the user dismisses the share sheet),
so cancelling a share popped up an unwanted 'Save As...' dialog right
after the user explicitly chose not to share.

Mirror what webAppService already does for the navigator.share()
AbortError path: once we entered the share branch, return true
regardless of whether the share completed or was cancelled. The
saveDialog path is now reserved for Linux/Windows desktop, which never
hit the share branch in the first place (wantShare gates them out).

If a future caller wants 'try share, fall back to save on hard
failure', that decision belongs to the caller — saveFile shouldn't
silently override an explicit share intent.
2026-06-02 16:09:29 +02:00
Huang Xin 4abbc0254c fix(reader): stop footer progress info painting a stray focus ring (#4397) (#4418)
The always-on page-info footer (`.progressinfo`) is a decorative
`role='presentation'` element, but it carried `tabIndex={-1}`, which made
it focusable. On Android, long-pressing the footer focused the div and the
WebView painted its default focus ring (`outline: auto`). Because the
element is pinned `absolute bottom-0` at book-view width, the ring rendered
as a content-column-wide line across the bottom of every page and persisted
until focus cleared.

Remove the `tabIndex` so the decorative element can no longer receive
focus. The `onClick` (tap-to-cycle progress mode / dismiss popup) still
fires regardless of tabindex, nothing focuses it programmatically, and the
translated `aria-label` keeps it exposed to screen readers unchanged.

Closes #4397

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 16:08:41 +02:00
loveheaven 7283a8ac21 fix(android): open book directly when launched via 'Open with' (#4407)
On Android, tapping an EPUB/MOBI/AZW3 in the system file browser and choosing Readest only opens the library — the book itself never opens in the reader. Now Readest can open the book now.
2026-06-02 16:07:23 +02:00
loveheaven 726f53a64b fix(reader): use Tauri clipboard plugin for copy on Android (#4409)
navigator.clipboard.writeText is unreliable inside the Tauri Android
WebView, so tapping Copy in the Reader's selection popup silently
no-ops. Route the write through @tauri-apps/plugin-clipboard-manager
on Tauri targets (Android, iOS, macOS, Windows, Linux), with a
graceful navigator.clipboard / execCommand fallback for the web
build and older WebViews.

- Add tauri-plugin-clipboard-manager (Rust + JS)
- Register the plugin in lib.rs
- Grant clipboard-manager:allow-write-text / allow-read-text
- New utils/clipboard.ts wrapper with platform-aware fallback chain
- Annotator handleCopy and handleConfirmExport use the wrapper
2026-06-02 16:02:52 +02:00
wfjack 5ff18b8f32 fix(readwise): use Tauri HTTP transport in desktop app (#4413)
Switch ReadwiseClient to @tauri-apps/plugin-http when running in
Tauri desktop mode, matching the pattern already used by WebDAV,
Hardcover, AI providers, translators, OPDS, and KOReader sync.

In a Tauri webview context the standard window.fetch is still subject
to CORS preflight rules and—on Android—the platform's cleartext-traffic
policy. @tauri-apps/plugin-http sends the request from the Rust side
via reqwest, bypassing the renderer entirely (no Origin header, no
preflight, no cleartext block). ReadwiseClient was one of the few
remaining services that had not yet adopted this transport, so users
on the desktop build could hit CORS or network errors when validating
tokens or pushing highlights.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 15:59:17 +02:00
Huang Xin 274afb0677 fix(sync): mint reincarnation token on re-import of custom fonts/textures (#4410) (#4416)
Custom fonts (and textures) disappeared a few seconds after opening a book
when logged into cloud sync. Under the replica layer's CRDT remove-wins
semantics, deleting a font writes a server-side tombstone that a plain
field upsert cannot revive — only a reincarnation token whose HLC beats
the tombstone does. Re-uploading the same file (same contentId) cleared
`deletedAt` locally but published the upsert with no token, so the tombstone
survived and the next pull (boot/periodic/book-open/visibility) re-applied
the soft-delete via softDeleteByContentId, making the font vanish. Logging
out stopped the pull, which is why it only reproduced while signed in.

addFont/addTexture now mint a reincarnation token when re-adding an existing
entry that is either soft-deleted or still-live with the same contentId (and
has no token yet), preserving any existing token. This mirrors the
dictionary's reincarnation handling (dictionaryService) and OPDS's token
style, fixing both the local re-import-after-delete case and the multi-device
stale-local race. The token is inert when there is no tombstone, so live
re-imports remain safe.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 15:56:38 +02:00
Huang Xin fe7fe25482 fix(reader): show background texture in paginated mode (#4399) (#4417)
The background texture (mounted on the reader container as
`.foliate-viewer::before`) showed in scrolled mode but was absent in
paginated mode: the paginator painted an opaque #background container
over it. Bump foliate-js to leave that container transparent under a
texture, and add a regression test for the shared textureAwareBackground
helper.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 15:54:59 +02:00
Huang Xin 3a81e09911 fix(reader): scroll oversized blocks in-place instead of turning the page (#4400) (#4415)
Wide or tall tables, code blocks and display equations overflowed the reading
column and a scroll gesture over them turned the page instead of scrolling the
content (#4400).

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

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 14:45:18 +02:00
Jesus Eduardo Medina Gallo 176b950c92 fix(reader): replace light callout backgrounds in dark mode (#4392)
Why:
- Dark mode sets theme foreground on html/body and rewrites black text,
  but EPUB callout boxes often keep white/light backgrounds from inline
  styles or publisher CSS unless override book color is enabled.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 11:16:52 +02:00
Jesus Eduardo Medina Gallo 458ad7510c fix(reader): scroll wide EPUB tables horizontally (#4391)
* fix(reader): scroll wide tables horizontally instead of scaling

Why:
- Wide EPUB tables (e.g. many columns without cell widths) overflowed the
  page because CSS scale only applied when widths were known.
- Paginated mode stole horizontal swipes for page turns over table content.

Refs:
- Replaces transform-based applyTableStyle scaling with a scroll wrapper
  and capture-phase touch routing (same pattern as gesture brightness).

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(reader): keep wheel/trackpad table scrolling from turning the page

Horizontal scrolling of a wide table in paginated mode also turned the page:

- applyTableTouchScroll only routed touch events. Trackpad/mouse wheel
  (readest forwards iframe wheel -> 'iframe-wheel' -> pagination) was
  never intercepted, so a horizontal wheel both scrolled the table and
  flipped the page.
- findWrapper used `instanceof Element`, which is always false for iframe
  event targets because this module runs in the top-window realm. The
  touch routing therefore never fired either.

Add a capture-phase wheel handler that consumes horizontal wheels over a
scrollable table -- including at the scroll edge, so the gesture (and
trackpad momentum) never chains into a page turn -- and make findWrapper
cross-realm safe via duck-typing on `closest`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(reader): don't show a spurious scrollbar on layout tables

Wrapping every table for horizontal scrolling made tables that fit the
column (e.g. character/glossary layout tables) show a spurious horizontal
scrollbar, because the wrapper was always scrollable.

Now the wrapper clips (no scrollbar) for a table that fits within a few px
of the column, and a ResizeObserver re-evaluates this as the column width
settles. A table genuinely wider than the column always scrolls and is
never clipped; one that wraps to fit shows no scrollbar. Touch/wheel
routing engages only scrollable wrappers.

Add a Chromium browser test over sample-table-layout.epub (layout tables
must not scroll) and sample-table-wide.epub (a too-wide table must scroll,
not clip).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 10:42:53 +02:00
Huang Xin bc9fe67abf fix(desktop): sanitize invalid .window-state.json before restore (#4401)
A `.window-state.json` containing the Windows minimized sentinel
(x/y = -32000) or a 0×0 size makes WebView2 reject the restored bounds
with 0x80070057 ("The parameter is incorrect"), so the app fails to
launch until the file is deleted by hand.

Add a small `window-state-sanitizer` plugin, registered before
tauri-plugin-window-state, that strips window entries with invalid
geometry (non-positive size, or a position past the -16000 off-screen
cutoff) from the state file before the plugin loads it. Affected windows
fall back to default geometry instead of crashing.

Defense-in-depth: the bundled plugin (2.4.1) already guards against
writing these values, so a bad file is almost certainly stale from an
older build; this self-heals it on next launch.

Refs #4398

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 10:01:20 +02:00
Huang Xin 45ef5f7515 fix(metainfo): declare desktop and mobile device support (#4395)
The AppStream metainfo had no <requires>/<supports> relations, so software
stores such as Flathub listed Readest as "Desktop only". Readest is adaptive
and runs on desktop (keyboard/mouse) as well as phones and tablets (touch).

Declare a 360px display_length baseline plus keyboard, pointing and touch
controls so stores surface both Desktop and Mobile.

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