Commit Graph

314 Commits

Author SHA1 Message Date
Huang Xin bfb85c2f68 feat(reader): sync paragraph mode & speed reader with TTS read-along (#3235) (#4576)
* docs(reader): TTS-sync design spec for paragraph mode + RSVP (#3235)

Hardened via brainstorming + /autoplan (CEO/Design/Eng dual-voice review).

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

* feat(tts): emit canonical tts-position event from TTSController (#3235)

Controller emits { cfi, kind, sectionIndex, sequence } alongside the existing
tts-highlight-mark/-word events. Monotonic sequence lets downstream consumers
(paragraph mode, RSVP — later slices) drop out-of-order positions. Additive;
existing events untouched.

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

* feat(reader): containment+cursor CFI->index mappers for TTS sync (#3235)

RSVPController.syncToCfi + setExternallyDriven: containment match (fixes
mid-token skip), monotonic cursor + binary search (avoids O(N)-per-word jank,
no per-word getCFI), -1/no-op on no match (no silent jump to word 0), timer
suspension while externally driven.

ParagraphIterator.findIndexByRange: hinted + binary-search containment mapper
returning -1 on no match (never first()).

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

* feat(tts): forward tts-position + tts-playback-state onto the app bus (#3235)

useTTSControl republishes the controller's canonical tts-position (tagged with
bookKey) via a dedicated listener — NOT inside the suppression-gated highlight
handlers, so page-follow suppression can't silently desync the modes. Adds
tts-playback-state (playing/paused/stopped) so RSVP can track playback without
the hook-local isPlaying. Verified by extending the real-foliate-view browser
harness.

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

* feat(reader): paragraph mode follows TTS playback (#3235)

When paragraph mode + TTS are both active, the focused paragraph follows the
spoken position (sentence granularity, all engines). Section-generation contract
(stash cross-section position, apply after the iterator re-inits); sync-focus
path that does NOT arm isFocusingRef (avoids the relocate-eaten wrong-section
paragraph-0 bug); stale-sequence drop; decouple on manual nav, re-engage on next
playing. Start-alignment + visible indicator deferred to later slices.

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

* feat(rsvp): speed reader follows TTS playback (#3235)

Edge word-boundary voices: RSVP shows the spoken word via syncToCfi. Non-Edge
(sentence-only) voices: sentence-paced estimator (clamp 60..600 wpm from voice
rate, hold at +60 words cap, snap to first word on each new sentence mark).
RSVP auto-advance suspended while TTS-driven. Decouple on manual nav via a
rsvp-manual-nav signal; re-engage on next playing. Cross-section positions
re-extract then apply. Pure decideRsvpTtsPosition helper unit-tested.

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

* feat(reader): fixed-layout gate + ttsSyncStatus for TTS sync (#3235)

Gate sync to reflowable books (D7): fixed-layout reports 'unsupported' and
never engages. Both modes expose ttsSyncStatus (idle/following/syncing/
decoupled/unsupported) as the data source for the upcoming indicator. RSVPControl
now forwardRef-exposes the status via an imperative handle. Cross-bookKey events
ignored (regression-tested).

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

* feat(reader): 'following audio' indicator for TTS sync (#3235)

5-state pill (following/syncing/decoupled, idle+unsupported render null) shown
top-center in the paragraph overlay and as a status row in the RSVP overlay.
Decoupled state is the tap-to-resume control; first decouple fires a one-time
toast. eink-bordered, glyph+text (no color-only), RTL logical props, touch
targets, safe-area top inset. RSVP 'plain' variant matches its themed surface;
non-Edge shows '· estimated'. New i18n keys need extraction before merge.

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

* feat(rsvp): in-overlay TTS toggle + audio-paced speed control (#3235)

Voice-glyph audio toggle in the RSVP control row (trailing, by the gear) starts/
stops read-along from inside the full-screen overlay, start-aligned to the current
word (range validated against the live doc). While TTS-driven, the WPM control
shows a locked 'Audio pace' affordance that opens a compact rate picker; rate
changes go through a new tts-set-rate bus event reusing the existing throttled
setRate path. Pure buildRsvpTtsSpeakDetail helper unit-tested.

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

* test(reader): e2e paragraph mode follows TTS across a section boundary (#3235)

Real <foliate-view> browser e2e: with paragraph mode active, the focused
paragraph follows the TTS walk and re-targets to the new section after a Ch4->Ch5
boundary (proves no stuck wrong-section paragraph-0 / isFocusingRef trap).
Asserts on the owning section of the current range. Test-only.

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

* i18n(reader): translate TTS-sync strings across 33 locales (#3235)

Following audio / · estimated / Resume audio / Stopped following audio /
Play audio / Pause audio / Audio pace / Speed follows audio.

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

* fix(reader): resolve TTS CFI anchors across iframe realms (#3235)

RSVP and paragraph follow silently failed to track the spoken word: the CFI
anchor from view.resolveCFI(...).anchor(doc) is a Range created in the book
iframe's realm, so 'anchor instanceof Range' (top realm) was always false
(cross-realm instanceof) -> resolveCfiToRange/applySyncCfi returned null ->
syncToCfi never advanced. Add isRangeLike() duck-type (cloneRange is unique to
Range) and use it at all 4 CFI-resolution sites. Confirmed live via CDP: before
= syncToCfi false (frozen); after = exact word map + RSVP follows Edge TTS at
~171 wpm (audio pace). Unit tests reproduce the cross-realm anchor (jsdom is
single-realm so the old code passed there but died in the app).

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

* fix(rsvp): stop estimator/word fight + map transport to TTS play/pause (#3235)

Two read-along refinements (verified live via CDP with Edge TTS):

1. No more jump-ahead-then-snap-back flashing. Word-boundary engines (Edge) emit
   BOTH sentence marks and word boundaries; RSVP was routing sentence -> the
   estimator (self-paces ~190xrate, up to +60 words ahead) while word positions
   snapped it back. Now once a word position is seen, sentence positions are
   ignored and any running estimator is stopped, so words alone drive RSVP.

2. The RSVP transport (center play/pause, Space, center-tap) maps to TTS
   play/pause while read-along is engaged (tts-toggle-play), instead of RSVP's
   own suspended timer. Pausing TTS keeps RSVP suspended (no runaway); a full
   stop releases it.

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

* fix(rsvp): keep indicator on pause + reach dict management from RSVP (#3235)

- Pausing read-along no longer dismisses the 'following audio' indicator / 'Audio
  pace' lock (layout shift). New 'paused' sync status keeps the indicator row and
  WPM lock present while TTS is engaged-but-paused; only a full stop clears them.
  Verified live via CDP: pause keeps the layout, no shift.
- Dict management is reachable from RSVP: the settings dialog is z-50, far below
  the full-screen RSVP overlay (z-[10000]), so it opened invisibly behind it.
  handleManageDictionary now exits RSVP first (position saved/resumable) so
  management shows over the reader.

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

* fix(rsvp): show dict management over RSVP instead of exiting it (#3235)

Per feedback: opening dictionary management from the RSVP lookup popup no longer
closes the speed reader. The settings dialog is raised above the full-screen RSVP
overlay (z-[10000] -> SettingsDialog !z-[10050]) so it shows on top, and RSVP's
capture-phase keyboard handler bails while the settings dialog is open so its
inputs accept Space and Escape closes settings (not RSVP). Verified live via CDP:
management opens over RSVP, RSVP stays active behind it, Escape returns to it.

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

* fix(dict): only apply drag-handle margin compensation when the handle shows (#3235)

The dictionary sheet header used -mt-4 to compensate for Dialog's drag handle,
but that handle is sm:hidden (shown only below sm). On sm+ the handle is
display:none, so -mt-4 pulled the header up into the top edge (broken layout
when the lookup renders as a sheet on a short/wide window). Mirror the handle's
breakpoint: -mt-4 sm:mt-0. Verified live via CDP.

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

* feat(reader): in-mode TTS audio toggle for paragraph mode (#3235)

Paragraph mode already follows TTS, but there was no way to start read-along
from inside it. Add an audio toggle to the ParagraphBar (mirroring RSVP's): it
starts TTS start-aligned to the focused paragraph (range validated live, +
section index) and stops it. Track session-active vs playing so a pause keeps
the indicator ('paused' status) instead of collapsing to idle. Pure
buildParagraphTtsSpeakDetail helper unit-tested. Verified live via CDP: tapping
the icon starts audio from the focused paragraph and the focus follows speech.

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

* feat(reader): highlight current TTS word/sentence in paragraph mode (#3235)

Paragraph mode follows TTS by advancing the focused paragraph, but the spoken
word wasn't highlighted within it like normal mode. The overlay renders a CLONE
of the paragraph, so the iframe's TTS highlight isn't visible there — reproduce
it on the clone with the CSS Custom Highlight API (no DOM mutation, spans inline
boundaries natively, leaves the fade-in animation untouched).

- TTSController already tags tts-position with kind word|sentence. The hook
  decides granularity: word boundaries (Edge) drive a per-word highlight; once
  seen, the coarse sentence event is skipped so the whole sentence doesn't
  flicker over the current word. Engines without word boundaries
  (WebSpeech/Native) fall back to the sentence highlight.
- Offsets are computed relative to the paragraph start (so they map 1:1 onto the
  clone's text) and tagged with the paragraph index so a stale highlight never
  paints the wrong paragraph. Cleared on stop / section change / disabled.
- The ::highlight() style mirrors the user's ttsHighlightOptions color+style.

Pure helpers (offset math, word/sentence decision, css builder) unit-tested.
Verified live via CDP: word highlight tracks Edge word-by-word and follows
across paragraph boundaries (news -> ... -> ladies), matching the TTS color.

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-14 09:46:18 +02:00
Huang Xin cc618b8739 test(tts): add browser e2e for auto-advance across a chapter boundary (#4573)
Mounts the real foliate <foliate-view> with sample-alice.epub, renders
the real useTTSControl hook with the real stores, and mocks only the
speech client. Starts TTS at the last paragraph of chapter 4 and
verifies the reading auto-advances into chapter 5, the page turns, and
the "Back to TTS Location" badge never appears (the TTS location stays
in view).

The mock client's speak() only needs to yield `end` — the real
TTSController drives forward() and the real view.tts walks the document
across the section boundary, so the cross-chapter navigation and badge
suppression are genuinely exercised rather than re-implemented in the
test.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:46:26 +02:00
Huang Xin 5a8f0873fa fix(library): refresh book cover after editing metadata (#4572)
* fix(library): refresh book cover after editing metadata

Editing a book's cover in Book Details and saving showed the old cover
until a full reload, in two render paths:

- Library grid: handleUpdateMetadata mutated the book object in place,
  so the memoized <BookCover> compared fields off the same (mutated)
  reference and skipped re-rendering. Build a new book object via the
  new getBookWithUpdatedMetadata helper instead of mutating.
- Book Details view: BookDetailView renders cover/title/author from the
  modal's `book` prop, which the parent never re-passed after save.
  BookDetailModal now tracks the saved book locally (displayBook) and
  renders the view from it.

Adds a unit test for the immutable helper, a BookDetailModal regression
test (edit cover -> save -> view reflects it), and a sample-alice.txt
fixture for TXT import testing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(agent): add cover-refresh stale-render memory

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 19:56:25 +02:00
Huang Xin 4b0bbc77b0 fix(reader): open TXT files shared via "Open with" (#4571)
* fix(reader): open TXT files shared via "Open with" by converting to EPUB

The Android "Open with Readest" (VIEW intent) transient path hands the
reader the original .txt file (its filePath points at the content:// URI),
unlike the managed library which stores the already-converted EPUB. The
DocumentLoader had no branch for a raw .txt, so open() returned
{ book: null } and initViewState crashed with
"TypeError: Cannot read properties of null (reading 'metadata')",
leaving the user stuck on the library splash.

Add an isTxt() check that converts the raw .txt to EPUB in-memory (the
same TxtToEpubConverter the import path runs) and parses that. The
converter emits a .epub-named file, so the importer's own
DocumentLoader.open() on the converted file is unaffected.

Verified on-device (emulator, warm + cold start): the TXT now opens and
renders in the reader instead of crashing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(settings): allow adjusting highlight opacity in e-ink mode

Drop the isEink prop that disabled the highlight Opacity slider under
e-ink. Opacity is still meaningful on e-ink, so let users change it.
Removes the prop from HighlightColorsEditor, its ColorPanel call site,
and the test render helper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(send): mock fetch to fix flaky article conversion test

The article/page conversion paths fetch a favicon + author image for the
synthetic cover via globalThis.fetch. In jsdom that hit the real network:
a live fetch to the sample URL can hang up to faviconFetcher's 6s timeout,
exceeding the 5s test timeout and intermittently failing the suite. Stub
fetch so the cover falls back to its initial-letter tile (the pattern other
tests in this suite already use). Article test: ~5003ms hang -> ~80ms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(agent): update annotation-share-toolbar memory

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 18:07:28 +02:00
Huang Xin 67c22c770b feat(reader): Share intent + customizable annotation toolbar (#4014) (#4570)
* docs(spec): annotation Share tool + customizable toolbar (#4014)

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

* docs(plan): implementation plan for Share tool + customizable toolbar (#4014)

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

* feat(annotator): add 'share' annotation tool type and button (#4014)

* feat(annotator): add pure toolbar order/visibility helpers (#4014)

* feat(annotator): add annotationToolbarItems view setting (#4014)

* feat(annotator): add shareSelectedText ladder helper (#4014)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(annotator): render Share tool and honor toolbar order in selection popup (#4014)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(settings): add drag-and-drop annotation toolbar customizer (#4014)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(settings): open the toolbar customizer from the Behavior panel (#4014)

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

* chore(i18n): extract and translate annotation share/toolbar strings (#4014)

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

* refactor(annotator): extract canShareText helper, preserve hidden Share on cross-platform edit (#4014)

Addresses final-review findings: de-duplicate the triplicated canShare
definition into share.ts::canShareText, trim ShareCapableService to the
fields actually read, and stop the toolbar customizer from dropping a
synced 'share' tool when edited on a non-share-capable device.

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

* feat(settings): WYSIWYG drag-and-drop toolbar customizer (#4014)

Rework the customizer per live testing:
- Render 'In toolbar' as a faithful, content-width, start-aligned preview of
  the real selection popup (gray bar, icon-only buttons); 'Available' tools
  show as labeled chips.
- Multi-container dnd-kit pattern: in-place dragging (no DragOverlay, which a
  transformed modal offsets), pointerWithin collision so empty zones accept
  drops, live onDragOver reparent, itemsRef to dodge dnd-kit's drag-start
  handler-capture stale closure.
- Add 'Add all' (canonical predefined order) and 'Clear all' shortcuts.
- Align zone labels with the SubPageHeader breadcrumb.
- Empty toolbar now suppresses the selection popup entirely (no empty bar),
  while still allowing highlight-edit/notes popups.

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

* chore(i18n): translate Add all / Clear all toolbar shortcuts (#4014)

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

* fix(annotator): size selection popup to visible tool count (#4014)

With the customizable toolbar a fixed-width popup looked sparse for a 2-3
tool toolbar (buttons spread to the corners). Size the popup to the number
of visible tools (responsive) capped at the previous max; annotated
selections keep the max width since they show highlight options / notes.

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

* fix(settings): reword empty-toolbar hint to 'No tools, drag one here' (#4014)

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

* test(annotator): render default tools (not Share) in popup layout screenshot (#4014)

The visual regression test rendered every annotationToolButtons entry, so
adding the Share tool shifted the toolbar to 9 buttons and broke the
baselines. Share is hidden by default (added via Customize Toolbar), so the
popup screenshot should mirror the default-enabled set — filter to
DEFAULT_ANNOTATION_TOOLBAR_ITEMS, keeping the existing baselines valid.

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-13 14:11:59 +02:00
Huang Xin a56cc6c61a feat(tts): word-by-word highlighting for Edge TTS, closes #4017 (#4566)
Highlight each word as it is spoken (Edge TTS only) instead of keeping the
whole sentence highlighted, and keep the view tracking the spoken word
across page boundaries.

Word boundaries
- Capture Edge's audio.metadata WordBoundary frames (offset/duration in
  100ns ticks plus the verbatim input-text span) in the Tauri, browser, and
  Cloudflare-Workers WebSocket transports.
- Carry boundaries through the authenticated HTTPS proxy route via an
  X-TTS-Word-Boundaries response header (percent-encoded JSON, ASCII-safe),
  so word highlighting works on the web where the browser cannot open the
  wss connection directly. Cache them alongside the audio blob URL.

Highlighting
- Sync a requestAnimationFrame loop to audio.currentTime against the
  boundary table and highlight the word sub-range within the spoken
  sentence. Synthesis stays sentence-level (natural prosody); only the
  visual highlight is word-level.
- Suppress the sentence highlight when the active client reports word
  boundaries and draw the first word immediately, so the whole sentence
  never flashes before the first word. Fall back to the sentence highlight
  when a chunk has no boundaries (other engines, empty metadata).
- Re-apply the current word (not the sentence) when the view relocates.

Page following
- Turn the page as soon as the spoken word crosses a page boundary (a
  tts-highlight-word event scrolls only when the word is outside the visible
  range), instead of waiting for the next sentence.
- Check the word's position for the "back to TTS location" badge so it no
  longer appears while the view follows the word onto the next page.

Also fixes a pre-existing bug where the browser WebSocket was constructed
with an options object (valid only for the Node ws package), which threw in
browsers and made the wss path unusable on the web.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 08:29:34 +02:00
Huang Xin 763b579c8f fix(android): launch installed dictionary for system lookup, closes #4559 (#4568)
On targetSdk 36, ACTION_PROCESS_TEXT handlers were hidden by Android 11+
package-visibility filtering — only auto-visible web browsers resolved the
intent, so system-dictionary lookups landed in the OEM browser (VIVO/iQOO)
even with a dictionary like Eudic installed. Add a <queries> declaration so
dictionary apps are visible, and filter web browsers out of the handler set
so an OEM browser that registers PROCESS_TEXT can't swallow the lookup:

- no browser among handlers → unchanged implicit dispatch (keeps native Always)
- browser + one dictionary → launch it directly (explicit component)
- browser + several dictionaries → chooser excluding browsers, remembering the
  pick via EXTRA_CHOSEN_COMPONENT so later lookups go straight through
- only a browser installed → report unavailable instead of opening it

Routing is a pure, JUnit-tested decideLookupDispatch(). Adds get/clear
lookup-dictionary commands + an Android-only reset row in the dictionary
settings to switch the remembered app.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 04:36:31 +02:00
Huang Xin c72afe269a fix(tts): keep voice list stable across region variants of a language, closes #4033 (#4565)
The voice panel filtered voices by the full locale of the currently
speaking text (v.lang.startsWith(locale)), so a book mixing region
variants of one language flip-flopped its voice list: Standard Ebooks
tag their boilerplate front matter en-US (17 Edge voices) while the
body text is en-GB (5 Edge voices).

Filter by primary language instead (isSameLang) in all three TTS
clients so every English variant yields the same voice set, and sort
voices matching the requested locale first
(TTSUtils.sortVoicesPreferLocaleFunc) so default-voice resolution via
getVoiceIdFromLang still picks an exact-locale voice. This also fixes
languages whose tags never matched a voice locale prefix at all (e.g.
zh-Hans books previously got an empty Edge voice list).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 04:32:27 +02:00
Huang Xin 852d0ae3e9 fix(reader): keep dark-mode page body transparent so the bg texture shows, closes #4446 (#4564)
The body.theme-dark catch-all from #4392 painted every section iframe's
body with the opaque theme bg in dark mode, occluding the host
background texture and poisoning foliate's docBackground capture (so
paginated segments and scrolled view backgrounds resolved opaque too).
Force transparent instead: the dark page fill already comes from the
paginator container / reader grid cell, and book-forced light page
backgrounds stay neutralized since the theme-dark fill shows through.
Unconditional rather than texture-gated because docBackground is
captured once per section load and a gated rule would go stale on live
texture toggling.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 19:37:23 +02:00
loveheaven 7f57af8f90 perf(cfi): bucket booknotes per chapter and batch-collapse location matcher (#4561)
* perf(cfi): bucket booknotes per chapter and batch-collapse location matcher

When iterating a list of CFIs against the same currentLocation (Annotator
on every page turn, useSearchNav, useBooknotesNav), the standalone
isCfiInLocation collapses the location twice per CFI. With 1000+
booknotes -- which a heavy user reported -- that's 2000 CFI parses
per page turn. The foliate epubcfi.js chunk showed up as ~15% of
self time in Bottom-Up profiles of the release Android build.

Fix:
- createCfiLocationMatcher(location) collapses once and returns a
  matches(cfi) predicate that reuses the cached bounds. O(N) calls
  become 1 collapse + N compares.
- getCfiSpinePrefix(cfi) extracts the spine path via pure string ops
  (no CFI.parse round-trip) for use as a chapter bucket key.
- Annotator builds annotationIndex = { bySection, globals } via
  useMemo([config.booknotes]) once when booknotes change, not per
  page turn. The progress-driven effect then only scans the current
  chapter's bucket -- ~50 CFIs in a typical book instead of all 1000.
  globals are pre-filtered too.
- useSearchNav / useBooknotesNav switch to the batched matcher for
  the same reason.

Includes parity tests covering empty/malformed inputs, equality
shortcut, prefix shortcut, in-range, and out-of-range cases.

* fix(annotator): keep note-only annotations in the per-chapter bucket

The booknote bucketing gated entries on `item.style`, which dropped
note-only annotations (a `note` with no highlight style/color, created
via the Notebook flow) from the per-relocate re-apply path. Their note
bubble was no longer redrawn on relocate or when booknotes changed while
a section stayed rendered.

Restore the original two-list semantics: bucket on style OR note, then
classify per location (annotations need a style, notes need a note).

Extract the logic into a dedicated, unit-tested `annotationIndex` module
(buildAnnotationIndex + selectLocationAnnotations) instead of inlining it
in Annotator, matching the reader/utils domain-named convention.

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-12 18:06:13 +02:00
Huang Xin ee01fcd123 fix(reader): texture the scrolled-mode top inset mask, closes #4486 (#4563)
In scrolled mode the notch-area masks the top safe-area inset with
opaque bg-base-100 so content scrolling under the status bar is hidden,
but it painted over the background texture (.foliate-viewer::before at
the z-0 layer), leaving a flat untextured strip across the unsafe
header area.

Give the mask its own texture ::before (.notch-masked in textures.ts)
and make the element span the grid cell, clipped down to the inset
strip with clip-path — background-size cover/contain resolves against
the element box, so the full-cell box is what keeps the mask's tiles
aligned with the viewer's at the seam. clip-path also clips
hit-testing, so the click target stays the inset strip only.

Verified on a Xiaomi 13: the strip now renders the texture with a
pixel-continuous seam (row-to-row MAE at the boundary dropped from
11913 to 230, the level of ordinary texture rows), and
elementsFromPoint confirms the notch is hit-testable only inside the
strip.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 17:39:17 +02:00
loveheaven 59d4f0aa33 perf(reader): split progress into its own store to cut React commit storm (#4557)
setProgress was called multiple times per swipe burst, each call writing
into readerStore.viewStates[key].progress. ~65 places in the reader
subtree subscribed to useReaderStore() without a selector, so every
setProgress fan-out re-rendered all of them -- even the 51 that didn't
care about progress. On Android release builds this showed up as
Layout = 9.8% and Function Call = 9.6% of main-thread self time in
Chrome DevTools' Bottom-Up profile during a reading session.

Fix:
- New tiny store store/readerProgressStore.ts holds the per-book
  BookProgress map. setBookProgress only fires its own subscribers.
- readerStore.setProgress now writes progress to the new store and only
  touches bookDataStore for the primary view (secondary parallel views
  shouldn't overwrite the shared config).
- readerStore.getProgress is kept as a delegating facade so existing
  imperative call sites don't break.
- Components / hooks that genuinely need to react to progress changes
  subscribe via the new useBookProgress(bookKey) hook. The handful of
  call sites that just want a one-shot read use getBookProgress(key) so
  they don't subscribe at all.
- readerStore.clearViewState calls clearBookProgress so the map doesn't
  grow unbounded across book opens/closes.

See store/readerProgressStore.ts header for the full rationale.
2026-06-12 17:14:49 +02:00
loveheaven 1c392de0fa perf(reader): throttle library.json writes and cache known dirs to cut IPC (#4556)
useProgressAutoSave fires saveConfig ~once per second of reading. Two
write-time sources were doubling its IPC cost:

1. saveConfig wrote the WHOLE library.json (+ backup) on every call, so a
   user with N books paid 2*JSON.stringify(N) per save. Chrome DevTools'
   Bottom-Up profile on a release Android build showed processIpcMessage
   chewing ~25% of main-thread time during a reading session.
2. nativeFileSystem.writeFile / copyFile defensively called plugin:fs|exists
   before every write to ensure the parent dir existed. Same dir gets
   probed once per save -- ~50% of IPC time per save was just exists()
   round-trips against directories that have been there since the book
   was opened.

Fix:
- LIBRARY_SAVE_THROTTLE_MS=30s coalesces a swipe burst into a single
  library.json write. Per-book config.json is still written eagerly --
  it's the sync source-of-truth and is small. flushPendingLibrarySave()
  is called on hook unmount + window blur so closing the book always
  flushes.
- In-process knownExistingDirs Set caches verified directories per app
  session. createDir adds, removeDir (incl. recursive) clears. Cold
  start still does the original exists+createDir dance once per dir.
2026-06-12 16:32:21 +02:00
Huang Xin 61d804a54f fix(dict): resolve Android content-URI filenames via native basename (#4553)
On some Android devices the SAF picker returns an opaque, extension-less
content:// document URI (e.g. .../downloads.documents/document/msf%3A20).
Dictionary bundle grouping derived each filename from getFilename() — a pure
string-parse of the URI — so no .ifo/.idx/.dict marker was found, every file
was orphaned, and the user saw "Skipped incomplete bundles" even though the
bundle was complete. Devices whose URI happens to embed the name (e.g.
primary%3ADictionaries%3A21cen.dict.dz) worked, which is why it reproduced
only on some Android devices. The same string-parse also wrote the bundle
files (and synced metadata / contentId) under the mangled URI-segment names,
so a re-import elsewhere did not dedupe.

tauri's Android path.file_name (basename) special-cases content:// / file://
URIs and queries the content resolver for the real DISPLAY_NAME — the same
call AppService.openFile already relies on. Resolve the display name once at
selection time, store it on SelectedFile.name, and have bundle grouping
classify by that name instead of re-parsing the URI. The old extension filter
already used basename but discarded the resolved name; threading it through
removes that divergence.

Also fix the Settings -> Dictionaries "+" badges (Import Dictionary / Add Web
Search) collapsing to a black spot in e-ink mode by adding eink-inverted,
mirroring the font import button (#4454).

Fixes #4489
Fixes #4472

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 12:01:29 +02:00
Huang Xin 12ac7ae6c0 fix(reader): draw annotation highlights over bullet lists (#4552)
Highlights spanning paragraphs and a bullet list painted the
paragraphs but not the list items: the overlayer split ranges with a
hard-coded 'p, h1, h2, h3, h4' selector before collecting client
rects, so li/blockquote/td text fell into no sub-range and produced
no SVG rects. Bump foliate-js to split by text nodes (plus img/svg)
instead, which covers every block type while still excluding the
block border boxes that over-highlight blank space.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 10:34:34 +02:00
Huang Xin 9dc41e7adf feat(reader): reference page numbers from EPUB page-list with manual page count fallback (#4549)
Add a 'Reference Pages' reading progress style that shows physical book
page numbers in the footer progress info:

- When the book carries a page list (EPUB3 nav page-list or EPUB2 NCX
  pageList — foliate-js already parses both and resolves the current
  pageItem on relocate; it was just never consumed), display the current
  page label and use the highest numeric label as the total, so a
  trailing roman-numeral index page can't corrupt the total (#672).
- When the book has none, a per-book 'Reference Page Count' input
  appears; the reading fraction is mapped linearly onto the entered
  count (#4542). The count is saved per book only and never propagates
  to global view settings.
- Falls back to percentage display when neither source is available.

Verified with the sample books from #672: Caleb's Crossing (EPUB3
page-list, 419 pages) and Count Zero (EPUB2 NCX pageList/page-map,
346 pages — chapter 2 lands exactly on page 22 per its page-map), plus
a stripped no-pagelist copy for the manual-count path (175/350 at 50%).

Closes #672
Closes #4542

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 08:44:18 +02:00
Huang Xin ceddee3793 feat(library): search a book on Goodreads from the library and reader (#4543) (#4548)
Adds a quick "Search on Goodreads" action so readers can jump straight to
Goodreads to track a book instead of retyping the title there.

- Library: a Goodreads button in the Book Details view (works on web,
  desktop and mobile) searching the book's title + author, plus a
  "Search on Goodreads" item in the desktop right-click context menu.
- Reader: Goodreads is added as a built-in web-search provider so
  highlighted text (e.g. a short-story title inside a magazine) can be
  looked up on Goodreads. Disabled by default like the other built-ins;
  enable it in Settings -> Dictionaries.

Both surfaces are used because the native context menu is desktop-only;
the Book Details button covers web and mobile. Adds a shared
openExternalUrl() helper and translates "Search on Goodreads" across all
locales (the Goodreads brand name is kept verbatim).

Closes #4543

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 08:30:17 +02:00
Huang Xin cfe2bb9116 fix(reader): Android text selection breaks on the first word of hyphenated paragraphs (#4545) 2026-06-12 12:30:56 +08:00
Huang Xin 390c711070 feat(rsvp): configurable start delay, word stepping, context dictionary lookup, and keyboard shortcut (#4541)
- #4478: add a configurable pre-start countdown (Off / 1s / 2s / 3s, default
  3s) that now ticks at honest one-second intervals and applies to start,
  resume, and page loads; Off starts instantly.
- #4476: add manual next/previous word controls (buttons flanking Play plus the
  "," / "." keys) that pause playback and step exactly one word.
- #4475: allow selecting text in the context panel to look it up in the
  dictionary (anchored popup on desktop, bottom sheet on small screens),
  reusing the reader's dictionary view; auto-scroll/seek are suppressed during
  selection and an outside click dismisses the popup.
- #4473: add a Speed Reading keyboard shortcut (Shift+V), shown on the View
  menu item; ignore repeat triggers while a session is active.

Adds i18n strings for the new UI across all locales.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 20:52:27 +02:00
Huang Xin d6e981e568 fix(reader): hide footnote aside border again when custom fonts are loaded (#4438) (#4540)
PR #4383 inlined custom `@font-face` rules at the very front of the iframe
stylesheet, ahead of the `@namespace epub` declaration that lived inside
`getPageLayoutStyles`. Per the CSS spec a `@namespace` rule is only honored
when it precedes every style and `@font-face` rule; a misplaced one is
silently ignored. That dropped the namespaced
`aside[epub|type~="footnote"]` hide rule, so EPUBs whose footnote `<aside>`
carries a `border: 3px #333 double` rendered a stray horizontal line below
the annotation marker — but only for users who had custom fonts loaded
(otherwise `customFontFaces` is empty and `@namespace` stayed first).

Hoist the `@namespace` declaration to the very start of the assembled
stylesheet, before the inlined custom `@font-face` rules, and drop it from
`getPageLayoutStyles`. Custom faces still precede the `--serif`/`--sans-serif`
font lists that reference them, preserving #4383's first-paint behavior.

Verified in Chromium against the reported book's CSS: the aside goes from
`display: block` (3px double border visible) back to `display: none`.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 20:32:39 +02:00
Huang Xin 755bee1ee6 fix(reader): prevent accidental paragraph-mode exit and center its bar (#4474) (#4539)
Paragraph mode could be exited by accident just by tapping a bit too high
or low on the screen: a tap on the empty area around the centered
paragraph hit the overlay backdrop, which closed the mode. Tapping the
neutral center of the paragraph did nothing, and once the control bar
auto-hid there was no touch gesture to bring it back — so removing the
stray-tap exits alone would have stranded touch users with no way out.

- Backdrop and center-zone taps now dispatch `paragraph-show-controls`
  instead of exiting; the bar re-appears so the explicit exit button
  stays reachable on touch.
- ParagraphBar listens for that event (scoped by bookKey) and re-shows.
- Exit now only happens via the bar's exit button, Escape/Backspace, or a
  deliberate double-tap on the paragraph (kept as a power-user shortcut).
- Center the bar with `fixed` instead of `absolute`: it was centered on
  the gridcell, which a pinned sidebar pushes off-center, while the
  paragraph centers on the viewport via the `fixed inset-0` overlay.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 20:30:41 +02:00
Huang Xin cf41e7d50d feat(rsvp): apply reader font face/family settings to the RSVP word (#4519) (#4537)
RSVP displayed the focal word in a hardcoded monospace font, ignoring the
reader's configured font. Resolve the reader's body font-family (serif or
sans-serif chain, per the "Default Font" setting, including the chosen
typeface, CJK font, and any user-imported custom font) and apply it to the
RSVP word display.

Custom and additional fonts are already mounted in the top document where
the overlay renders, so the resolved family resolves the same typeface. The
monospace fallback is kept only when no font setting is available.

Extracts the font-family list building from getFontStyles into a shared
buildFontFamilyLists helper and exposes getBaseFontFamily for top-level UI.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 19:24:46 +02:00
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
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
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
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 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
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 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 719e9c7542 fix(eink): keep dropdown-toggle label legible under e-ink (#4435) (#4441) 2026-06-03 14:48:10 +08: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
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
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