forked from akai/readest
942095bcd634e498aeb66035c6a3e8899d82c527
609 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
942095bcd6 |
fix(reader): make Shift+P toggle, exit, and resume paragraph mode reliably (#4717) (#4725)
Three paragraph-mode problems, all fixed:
- Shift+P inside paragraph mode flashed and re-entered (and pressing it
repeatedly did nothing). A single keypress toggled twice: eventDispatcher
.dispatch() iterated the live listener Set while awaiting each listener, and
the exit's awaited dispatch('paragraph-mode-disabled') let React re-run
useParagraphMode's subscription effect mid-loop, adding a handler the same
dispatch then called. Snapshot the listeners before iterating (dispatchSync
already did), so a listener added during a dispatch can't fire for the
current event.
- Shift+P / Escape only worked when focus sat on the overlay. Handle the
overlay's keys the way a dialog/alert does: focus the dialog element on open
and handle Escape / the toggle shortcut / paragraph navigation in its own
onKeyDown (stopping propagation so the global handler can't double-fire),
instead of a global window listener. Suppress the focus ring on the
programmatically-focused, non-tab-stop container.
- Resume jumped to the chapter start, and repeated enter/exit walked further
back. Two causes: (a) entering/exiting scrolled the underlying view to the
focused paragraph's start, which rewinds a page when that paragraph began on
the previous page — don't scroll on resume/exit (the paragraph is already on
screen); navigation still scrolls. (b) resume preferred the rAF-debounced
store progress and a stored last-paragraph CFI that can come out malformed
and resolve to an empty range, shadowing the correct candidate and sending
findByRange to the first block. Resume from the view's live, foliate-
generated lastLocation CFI first (set synchronously on every relocate,
resolved against the current document so it survives iframe recreation).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
a6d28ffcdf |
fix(reader): add Alt+P proofread shortcut and let Shift+P exit paragraph mode (#4717) (#4723)
On Windows/Linux, Ctrl+P opens the proofread/replace rules but also triggers the browser print dialog, since the selection shortcut handlers return undefined and never preventDefault. Add a print-free `alt+p` binding for Proofread Selection alongside ctrl+p/cmd+p. Also fix Shift+P being unable to exit paragraph mode: the paragraph overlay attaches a capture-phase keydown listener that calls stopImmediatePropagation() on every key while visible, so the global toggle shortcut never reached useShortcuts. Honor the configured "Toggle Paragraph Mode" shortcut directly in the overlay so the same shortcut that enters paragraph mode also exits it. Extract the shared shortcut event-matching into matchesShortcut() in utils/shortcutKeys.ts and reuse it from useShortcuts instead of its private duplicate. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
c781aeddaa |
feat(reader): add sticky progress bar with chapter ticks (#4707)
Add an always-visible, opt-in progress bar with chapter tick marks in the persistent footer, so reading progress no longer disappears like the hover footer slider does. - New StickyProgressBar: a 1px rounded-border capsule with a fill and chapter tick marks; display-only, e-ink aware, and RTL safe. Ticks render inside the clipped track so the rounded ends crop them and they never exceed the border. - Chapter ticks come from the TOC, mapped to spine-section start fractions (getChapterTickFractions); the first and last ticks are trimmed so they do not crowd the rounded ends. - Thread the overall size-domain reading fraction through setProgress so the bar fill aligns with the tick domain. - Footer layout: when enabled the bar grows on the left and the info widgets group to the right with even spacing; otherwise the existing layout is unchanged. - Horizontal writing mode only; vertical keeps the current footer. - Add the showStickyProgressBar view setting, a LayoutPanel toggle, and i18n. Closes #1616. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
7185dca1a2 |
feat(reader): add save/share button to image gallery toolbar (#4680)
* feat(reader): add save/share button to image gallery toolbar Add a button to the top-right toolbar of the fullscreen image viewer that saves the currently viewed image to the device. It uses the native or web Share flow where available (iOS/Android/macOS, navigator.share) and falls back to a save dialog or browser download otherwise, reusing the existing export path via appService.saveFile. The button icon and label reflect the active flow (share vs save). Adds dataUrlToBytes/imageExtensionFromMime helpers, unit and component tests, and translations for the new strings across all locales. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(share): write shareable file to a Temp subdirectory to avoid 0-byte share On Android, Tauri's Temp dir is the app cache dir, and the sharekit plugin copies the shared file to <cacheDir>/<name> before firing the share intent. When saveFile wrote the shareable file to the Temp root, that copy became a copy onto itself whose output stream truncated the source to 0 bytes, so the shared image (and any shared export) arrived as a 0 KB file. Write the file to a Temp subdirectory instead so the plugin's copy has a distinct source. Verified on a Xiaomi device: sharing a file in the Temp root truncated it to 0 bytes, while sharing from the subdirectory produced a real, non-empty copy. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(reader): save image to system gallery on Android The Android share sheet cannot save an image to a file (no file manager registers as an ACTION_SEND target), so the Save Image button now writes the image straight into the system photo gallery via MediaStore. It lands in Pictures/Readest, visible in Gallery and the Files app, with no picker and no storage permission on Android 10+. Adds a save_image_to_gallery command to the native-bridge plugin (Rust + Kotlin MediaStore insert) and an appService.saveImageToGallery method. On Android the Save button uses it; iOS/macOS/desktop/web keep the existing share/export flow, and the button label/icon reflect the actual action. Also includes local agent memory notes that were staged alongside. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
6caa376f82 |
feat(reader): Webtoon Mode seamless continuous scroll for image books (#3647) (#4662)
* feat(reader): make fixed-layout scroll gap configurable (foliate-js bump) (#3647) * feat(reader): add webtoonMode view setting + scroll-gap helper (#3647) * feat(reader): Webtoon Mode toggle in the fixed-layout view menu (#3647) * feat(reader): apply Webtoon Mode gap on fixed-layout book open (#3647) * fix(reader): clear Webtoon Mode + reset gap when Shift+J leaves scrolled (#3647) * chore(i18n): translate Webtoon Mode string across locales (#3647) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore(deps): bump foliate-js to merged readest/foliate-js#30 (#3647) --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
1faa931a0e |
fix(txt): stop detecting measure-word prose as chapters in TXT import (#4658) (#4660)
The Chinese chapter-detection regex treated certain measure words (the classifiers for "letter" and "book") as chapter units and let a title attach directly after the unit. As a result, ordinary prose such as "the first letter" or "the fourth book records..." was split out as bogus TOC entries when importing Chinese TXT novels. Split the unit characters into two explicit tiers that share the same number prefix and stay in one alternation (a single split pass, so a segment mixing chapter and volume headings is handled together): - Chapter units may carry a title attached directly, unchanged. - Volume/measure-word units only start a heading when the title is introduced by a separator (colon, comma, space, or parentheses) or the line ends, never a bare noun directly after the unit. Real volume and chapter headings still match. Adds regex-level and end-to-end tests covering both reported cases. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
86f5502724 |
fix: bot-review robustness fixes (TTS sync, updater, nightly, a11y) (#4659)
Cherry-picked and re-verified the applicable subset of julianshen/readest@fa1b74a0 (its "address PR #15 bot reviews" commit). The fork-only AI-annotation-tool change was dropped — readest has no 'ai' toolbar tool. Each logic fix is covered by a failing-first test. - TTS position sequence is now an app-wide monotonic counter, so a fresh TTSController (constructed per `tts-speak`) isn't dropped by consumers holding `lastSequenceSeen` from a prior session. - share.ts only swallows AbortError (user cancel); other failures — e.g. NotAllowedError when a quick action fires without a user gesture — fall back to the clipboard so the text still reaches the user. - document.isTxt tolerates MIME params (text/plain;charset=utf-8), uppercase extensions (BOOK.TXT), and a nameless Blob, so a TXT can't slip onto the non-text path and yield a null book. - updater getNightlyPlatformKey matches x86_64/aarch64 explicitly; a 32-bit or otherwise unknown arch yields no nightly instead of mis-routing to aarch64. - UpdaterWindow downloadWithProgress resolves on tauriDownload completion even when Content-Length is absent (no more hang on portable/AppImage/Android). - nightly_update.rs uses async tokio::fs::read in the async command. - nightly.yml: serialize runs via a concurrency group (no cancel) and persist-credentials:false on checkouts. - edge TTS route only emits the word-boundary header when it fits under ~8KB; oversized values get dropped by proxies, and the client falls back to []. - RSVPOverlay drops the contradictory aria-disabled on the functional rate button (it opens the pace picker). - nightly verify harness handles artifact stream errors instead of crashing. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
72233e1c6a |
feat: sync reading status across devices and with KOReader (#4634) (#4656)
* docs(sync): design spec for syncing reading status (#4634) Field-level LWW for reading_status (dedicated reading_status_updated_at), a new 'abandoned' status in the Readest UI, and a koplugin bridge to KOReader's native summary.status (whole-library apply + capture). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(sync): implementation plan for syncing reading status (#4634) Bite-sized TDD tasks across 3 parts: A) cloud field-level LWW (reading_status_updated_at on server upsert + client pull-merge), B) 'abandoned'/On-hold status in the Readest UI, C) koplugin bridge to KOReader summary.status (mapping + reconcile + whole-library apply/capture). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sync): add reading_status_updated_at for field-level status LWW (#4634) * feat(sync): stamp readingStatusUpdatedAt on status change in updateBookProgress Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sync): stamp status timestamp on explicit library status edits * feat(sync): resolve reading status by its own timestamp in client pull-merge * feat(sync): resolve reading status by its own timestamp in server upsert (#4634) * fix(sync): tighten reading-status merge typing + strengthen test (A5 review) Replace as-unknown-as double-casts at read sites with typed locals (clientBook/serverBook); retain a single as-unknown-as only at the server-wins construction site where the static type is too narrow. Strengthen test 3 to assert both fields with toEqual. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(library): render the 'On hold' (abandoned) status badge * feat(library): add 'Mark as On hold' actions + i18n for abandoned status Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * i18n: translate 'On hold' and 'Mark as On hold' for the abandoned status (#4634) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(koplugin): add reading-status mapping + reconcile between Readest and KOReader * feat(koplugin): persist + sync reading_status_updated_at in LibraryStore Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(koplugin): bridge reading status to KOReader summary.status on library sync (#4634) * test(sync): cover koplugin v1->v2 migration + tighten status-sync tests (final review) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(sync): redesign KOReader first-sync (decisive-only + bootstrap) (#4634) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(koplugin): safe first-sync of reading status (decisive-only + bootstrap) (#4634) KOReader auto-sets summary.status='reading' on first open, and legacy Readest statuses have reading_status_updated_at=0, so pure timestamp LWW let opening a finished book downgrade it. Restrict sync to deliberate statuses (finished/ complete, abandoned/on-hold, unread->clear); never capture KO 'reading'/'New'. On the unsynced baseline (Readest ts=0) conflicts resolve Readest-authoritative, then stamp now_ms to exit bootstrap into steady-state LWW. reconcile now returns write_ko/write_store flags; statussync captures now_ms once and equalizes both sides (convergent, idempotent, resumable). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(koplugin): cover remaining first-sync graph cells + document sort effect (review) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
ff96c6d3f7 |
feat(annotations): unify highlights and annotations into one record (#3870, #4511) (#4647)
A highlight and its note are now a single BookNote. Adding a note attaches it to the highlight at that CFI (or creates one with the current global style) instead of creating a second record, and a unified record renders as both a highlight overlay and a note bubble. - onDrawAnnotation chooses the draw kind from the overlay value prefix (cfi -> highlight, NOTE_PREFIX -> bubble) instead of annotation.note, so a record with both a style and a note draws both. Fixes notes synced from KOReader losing their highlight (#4511). - handleSaveNote updates the existing annotation at the CFI rather than pushing a new record (#3870); re-styling preserves the note. - unifyAnnotations migration (book config schema v1 -> v2, run in deserializeConfig) collapses existing split highlight+note records into one survivor and tombstones the redundant record (deletedAt) so the merge syncs to the cloud and KOReader. - Sidebar: a note's quoted highlight text uses the theme foreground so it stays legible on the highlight background. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
6626db967c |
fix(reader): keep last paragraph's line spacing by making the section skip link a <span> (#4642)
The next-section accessibility skip link is injected nested inside each section's last content element (findSectionEndHost in a11y.ts, added for #4126). The paragraph-layout rule in getParagraphLayoutStyles() targets `div:not(:has(*:not(b,a,em,i,strong,u,span)))`, so nesting a <div> made the enclosing paragraph fail the :has() test and silently lose its line-spacing, word/letter-spacing, text-indent, and hyphenation overrides — but only for the last paragraph of every section, and only in <div>-based EPUBs (common in Chinese-source books). <p>-based books were unaffected because the bare `p` clause matches regardless of children. Create the next-section skip link as a <span> instead. <span> is in the selector's allow-list, so the enclosing paragraph keeps matching. The link is still position:absolute (an out-of-flow 1x1px box) and focusable, so layout and NVDA focus behavior are unchanged. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
495783d045 |
fix(security): harden OPDS proxy SSRF, storage key validation, Stripe check (#4638)
Server-side hardening for three reported web advisories: - OPDS proxy (/api/opds/proxy): add http(s) scheme allowlist, internal/loopback/ link-local host blocklist, and manual per-hop redirect re-validation so a public URL can't redirect into an internal address. Move isBlockedHost into the shared src/utils/network.ts as the canonical blocklist and reimplement isLanAddress to delegate to it (also tightens the /api/kosync LAN check); fetch-url.ts re-exports it. (GHSA-c7mm-g2j2-98cx, GHSA-5g3f-mq2c-j65v) - Storage upload (/api/storage/upload): validate the client-supplied fileName with a new isSafeObjectKeyName helper before building the object key, so a name can't escape the caller's own prefix. (GHSA-mfmj-2frf-vhgw) - Stripe (/api/stripe/check): bind the entitlement to the session owner — reject a Checkout Session whose metadata.userId differs from the authenticated caller. (GHSA-pv88-3727-j7v8) Unit tests added for each path; full suite + lint green. The Tauri-native advisory (GHSA-55vr-pvq5-6fmg) is handled in a separate change. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
8bcb9f9b2a |
feat(wordlens): trim hints to first sense + suppress known derivations (#4635)
* refactor(wordlens): rename ww-gloss CSS class to wl-gloss Completes the Word Wise → Word Lens rename (#4633) for the gloss ruby class — the 'ww' shorthand was missed. Renamed consistently across the apply site (GLOSS_CLASS), the CSS rules in style.ts, the tap hit-test in iframeEventHandlers, and the browser tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(wordlens): trim hints to first sense + suppress known derivations Runtime, best-effort gloss-quality pass over the shipped en-zh pack (no regeneration): - cleanGloss: strip leading POS tags (now incl. 6-letter `interj.`) and keep only the first sense, so hints stay short — "Ahem" shows 呃哼, not "interj. 呃哼"; multi-sense entries collapse to their first sense. - Derivational reduction (English source only): a would-be-glossed word inherits a known base form's lower rank when the base exists in the pack AND their glosses share meaning, so lazily/shyly/sorrowful/downwards/inwards stop being hinted once lazy/shy/sorrow/… are known. Drifted forms keep their own rank because their gloss doesn't overlap the base (hardly≠hard, lately≠late). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(wordlens): note hint-quality layer + wl-gloss in agent memory Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
c2ac207945 |
refactor(wordlens): rename "Word Wise" to "Word Lens" (#4633)
"Word Wise" is a Kindle trademark, so rename the inline-gloss feature to
"Word Lens" throughout the product.
- User-facing strings → "Word Lens" across all 34 locales; brand translated
for Chinese (zh-CN 单词透镜, zh-TW 單詞透鏡) and German (Word-Lens-Daten).
- Code identifiers: WordWise→WordLens, wordWise→wordLens, WORD_WISE→WORD_LENS.
- Files/dirs: src/services/wordwise→wordlens, WordWisePanel→WordLensPanel,
wordwise{Ruby,Section}.ts, build/sync scripts, test dirs/fixtures,
data/wordwise→data/wordlens.
- Storage paths: CDN base, R2 key, on-device cache dir, WORDLENS_R2_BUCKET env,
pnpm wordlens:{manifest,sync}. manifest.json is path-agnostic so its
sha256/bytes stay valid (verified).
- biome.json: point the formatter-ignore at data/wordlens so the generated
one-line gloss packs aren't pretty-printed on commit.
Migration notes:
- Re-run `pnpm wordlens:sync` to upload packs to cdn.readest.com/wordlens/.
- Persisted view-settings keys renamed (wordWiseEnabled/Level/HintLang and
wordWiseAutoDownload) — saved values reset to defaults once on upgrade.
- Cached packs under the old Data/wordwise/ orphan (harmless re-download).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
757ed8066b |
feat(library): show series and number in list view (#4593) (#4612)
In the library list view, surface each book's series and series number on
their own line, in addition to the description. Previously series info was
only visible by grouping by series or opening a book's details.
- Add `formatSeries(series, seriesIndex)` helper ("Series #N", trims the
name, omits a zero/NaN/negative index) with unit tests.
- In list mode, render a dedicated single-line "Series #N" line above the
description when the book has series metadata.
- Clamp every list line (incl. title) to one line and tighten the row gap
to `gap-1` so the extra line fits the fixed-height row without clipping.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
4908245042 |
feat(reader): Word Wise inline vocabulary hints (#4589)
* feat(reader): Word Wise — inline native-language vocabulary hints Kindle-style Word Wise: a short native-language gloss renders above difficult words as you read (always-on ruby), gated by a CEFR vocabulary-level slider (A1–C2); tapping a glossed word opens the existing dictionary. - Pipeline: CEFR→frequency-rank difficulty, inflection-aware gloss index, pure offset-aware planner (EN regex + jieba for CJK). - Rendering: <ruby cfi-skip>…<rt cfi-inert> injected per occurrence — CFI-transparent (verified), so highlights/bookmarks/progress are unaffected; kept out of TTS word offsets and find-in-book. - Delivery: gloss packs are version-controlled in data/wordwise/, mirrored to R2, and downloaded on demand into local storage (sha-verified, single-flight) when enabled. - Settings: a Word Wise sub-page under Settings → Language (enable, level, hint language, per-pack download/manage, auto-download toggle). - Build tooling: scripts/build-wordwise-data.mjs (ECDICT / CC-CEDICT+HSK / WikDict + FrequencyWords, with lemmatization) and scripts/sync-wordwise-r2.mjs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * data(wordwise): bundled gloss packs + manifest + attribution 13 frequency-trimmed gloss packs (en↔中文 + es/fr/de/pt/it/ru↔en, ~19 MB) generated by build-wordwise-data.mjs from ECDICT (MIT), CC-CEDICT + HSK, and WikDict + FrequencyWords (CC-BY-SA). Source of truth, mirrored to the CDN via `pnpm wordwise:sync`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
57501cc520 | feat(updater): nightly update channel (Android/Windows/macOS/Linux) (#4577) | ||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
1ce79d9abf |
perf(reader): reduce open-book TBT by batching layout-thrashing reads/writes and deferring annotation page back-fill (#4554)
* perf(reader): batch keepTextAlignment reads/writes to avoid layout thrashing
keepTextAlignment iterates every <div>, <p>, <blockquote>, <dd> in a
freshly-loaded section and tags each with an aligned-{center,left,
right,justify} class based on its computed text-align. The previous
implementation read getComputedStyle and wrote classList.add inside
the SAME forEach pass, which is the textbook layout-thrashing
anti-pattern: classList.add invalidates the document's style cache
(class-based selectors can affect descendants), so the next
getComputedStyle call forces the browser to recompute style for the
whole document.
For a long chapter (~hundreds of p/div/blockquote/dd elements — a
typical Harry Potter section), that turned the loop into N x layout
recalcs. On a release Android build it surfaced as:
- Browser console violation: 'Forced reflow while executing
JavaScript took 1210ms'
- The dominant chunk of the open-book Bottom-Up profile's
Layout = 32.8% / Recalculate Style = 17.5% of TBT (2503ms total)
- The 'load' handler also tripped a 1249ms violation, dominated by
keepTextAlignment running inside it
Fix: split into a read pass (O(N) getComputedStyle into an array) +
a write pass (O(N) classList.add). The browser computes style once
for the document at the start of the read pass and reuses that
result for every subsequent getComputedStyle call; the write pass
then batches all class mutations together so style invalidation
happens at most once at the end.
* perf(reader): back-fill annotation pages off the open-book hot window
Each call to view.getCFIProgress(cfi) synchronously decompresses the matching section's XHTML from the EPUB zip and walks its text nodes (foliate-js progress.js #getCache), costing 100-300ms per cold section on a release Android build. For users with annotations spread across many chapters that's seconds of zip-IPC + main-thread work that was happening inside the open-book TBT window.
First attempt scheduled the back-fill via requestIdleCallback. On Android Tauri the WebView fires rIC aggressively while the main thread is still doing layout/style work for the freshly-opened book — the Bottom-Up profile after that change still showed 1.5s+ of sendIpcMessage -> readData -> loadDocument -> getCFIProgress chains nested under "Fire Idle Callback" inside the same hot window.
New strategy:
- Hard gate on the renderer's first 'stabilized' event so the back-fill can't possibly start before the open-book paint settles.
- Add a 5s grace timer after stabilized so the user's first page-turns and paginator's adjacent-section preload can finish without contention.
- Process annotations one at a time with a 250ms setTimeout gap between each, instead of chained idle callbacks. Each getCFIProgress shows up as its own short task with input-handling slots in between.
- 10s safety-net fallback if 'stabilized' never arrives, plus full cleanup on unmount.
- Batch the saveConfig write at the end (one IPC instead of N).
- Skip entirely when there are no annotations missing a page.
The page field still only feeds the secondary 'p NN ·' label in the sidebar BooknoteItem, so the on-screen highlight rendering paths (progress-driven addAnnotation in the [progress] effect, plus onCreateOverlay on section load) are completely independent and unaffected by this change.
|
||
|
|
5cab1fa94b | feat(css): override document layout also apply to hyphenation, closes #4529 (#4546) | ||
|
|
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> |
||
|
|
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> |
||
|
|
cfe2bb9116 | fix(reader): Android text selection breaks on the first word of hyphenated paragraphs (#4545) | ||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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.
|
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>`, `"`, `'`) 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 <code>` 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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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 |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
e8675fb7eb |
fix(reader): inline custom @font-face rules in iframe stylesheet (#4383)
* fix(reader): inline custom @font-face rules in iframe stylesheet The reader iframe's first paint resolved with the default serif/sans fallback and only swapped to the user's configured custom font a moment later, producing a visible font flash when opening a book. Custom font @font-face rules were registered via mountCustomFont on the host document only, while paginator's setStyles writes its CSS into the iframe synchronously before the first 'load' event. The iframe had no knowledge of the user's custom fonts at that point, so font-family declarations in the stylesheet matched nothing and fell back. Inline the @font-face rules for every loaded custom font (blob URLs already in memory, no network round-trip) at the front of getStyles output, so paginator delivers them to the iframe atomically with the rest of the stylesheet. Defensive try/catch around createFontCSS keeps a single bad font from breaking the whole stylesheet. * refactor(reader): pass custom fonts into getStyles instead of reading the store getStyles lives in src/utils, where every other file is a pure function; it was the only one importing a store (useCustomFontStore). Keep the util pure: accept the loaded custom fonts as a parameter and let the reader components — which already own the font store — supply them. - style.ts drops the useCustomFontStore import and the SSR/store-error guards in getCustomFontFaces; the helper is now a pure CustomFont[] -> CSS transform. - getStyles(viewSettings, themeCode?, customFonts = []) inlines the @font-face rules for the passed fonts. - The first-paint call sites (FoliateViewer, FootnotePopup) pass getLoadedFonts(); settings-panel re-styles keep the default [] since custom fonts are already mounted as persistent <style> elements there. - Add tests that exercise the font-face inlining path (the existing suite never did, since the store is empty under jsdom). 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> |
||
|
|
d0071a6bcb |
fix(sync,reader): discard malformed sync CFIs; fix swipe background flash (#4370)
sync: empty-start/end range CFIs left by the cfi-inert skip-link bug (e.g. epubcfi(/6/24!/4,,/20/1:58)) resolve to a section-spanning range and navigate to the wrong end of the section. Add isMalformedLocationCfi and discard such locations on the cloud-sync receive path (useProgressSync) and the kosync push path (useKOSync) so they can't move the reader or propagate to other devices. foliate 569cc06 stops generating them but does not repair already-synced values. reader: bump foliate-js to 167757a to fix the white<->black background flash when swiping between differently-colored pages; add a regression test for the sliding per-view background segments. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
f1ae050768 |
fix(ui): refine reader side panels and their empty states (#4361)
* docs(agent): add agent notes for cache, reading-ruler, foliate touch Add project-memory notes and index entries: - manage-cache-ios-layout: iOS container layout and what Manage Cache clears - reading-ruler-line-aware: line/column-aware reading ruler internals - foliate-touch-listener-capture-phase: capture-phase gesture suppression Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(reader): pad sidebar and notebook for the device status bar (#4089) Top-anchored slide-in panels (sidebar, notebook) only applied status-bar top padding when isFullHeightInMobile was true. On a tablet/desktop (isMobile === false) that gate collapsed the padding to 0, so a visible system status bar overlapped the panel's top toolbar and made its icons inaccessible. Extract the inset math into getPanelTopInset() and gate it on (!isMobile || isFullHeightInMobile) so non-mobile panels clear the status bar like the reader header, while a partial-height mobile bottom sheet (which doesn't reach the top of the screen) stays flush. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(reader): keep footer bar clear of the pinned sidebar On a mobile tablet in portrait, forceMobileLayout renders the footer bar with position: fixed, anchored to the viewport, so left-0 w-full spans the whole window and slides under a pinned sidebar — the progress / font / TTS controls end up obscured. Anchor the footer inside the book's grid cell (position: absolute) when the sidebar is pinned, mirroring the header bar. The flex layout already offsets the grid cell by the sidebar's real rendered width, which honors the sidebar's min-w-60 floor and 45% cap that a stored-width offset would miss. The slide-up panels are absolute within the footer container, so they shift and narrow with it and their animation is unchanged. The switch only happens when the sidebar is pinned, so phone (< 640px) and unpinned tablet-portrait class names stay identical. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(ui): refine reader side panels and their empty states Closes #4089 Add a shared EmptyState component (large muted icon, title, and an optional hint or action) and use it for the empty annotations, bookmarks, and notes panels in the sidebar and notebook, replacing the ad-hoc "No … yet" placeholders. Polish the surrounding chrome: switch the bookmark toggler to the Ri icon set with responsive sizing, crop the HighlighterIcon viewBox to its artwork to remove the asymmetric bottom padding, and tune mobile sizing and spacing across the panel headers, tab navigation, and footer nav bar. Translate the new empty-state strings (No Notes, No Annotations, No Bookmarks, and their hints/action) across all 33 locales and drop the obsolete "No … yet" keys. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
bed31e8181 |
feat(library): add Manage Cache to advanced settings (#4359)
Add a "Manage Cache" item to the library Advanced Settings menu (native mobile apps only) that opens a modern dialog showing the combined size and file count of the app's reclaimable storage, with a confirm-gated clear that reports per-file progress. - iOS clears Cache + Temp + Documents/Inbox; Android clears Cache + Temp. - Multi-source helper (getCacheEntries/getCacheStats/clearCacheEntries) with unit tests; per-file failures are counted, never abort the run. - Dialog uses the centered-hero + btn-contrast design language, theme-neutral progress, and is e-ink correct. - i18n: new strings translated across all locales (+ en plural forms). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
2f5e583653 |
feat(annotations): configurable export link type + dedicated Import Annotations modal (#4350)
* feat(export): make annotation export link type configurable Add an Annotation Link selector (App / Web) to the Export Annotations dialog. Defaults to the app deeplink in the native app and the universal web link on the web, so web exports no longer emit readest:// links that only the desktop/mobile app can open. The default markdown template now uses the configurable annotation.link variable. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(annotations): move Moon+ Reader import into a dedicated Import Annotations modal Replace the single 'Import from Moon+ Reader' menu item with an 'Import Annotations' entry (below 'Export Annotations') that opens a dedicated modal listing import sources. Currently lists Moon+ Reader; the boxed-list layout makes adding future providers a one-row change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
48d52ea898 |
feat(telemetry): opt-out by default for new users; consent prompt for 10% (#4340)
Fixes #4339. PostHog telemetry was previously enabled by default for every install, which surprised privacy-conscious users on self-hosted setups. Behavior for new users only (existing users are migrated to a decision that preserves their current `telemetryEnabled` setting): - 90% are silently opted out on first launch. - 10% see a one-time consent prompt; accepting opts in, declining opts out. Decision is persisted via a new `readest-telemetry-decision` localStorage key so subsequent boots don't re-roll. PostHog now inits with `opt_out_capturing_by_default` so brand-new users never ping before the decision is finalized. New `TelemetryConsentDialog` uses the project's `btn-contrast` (theme-neutral) CTA and `eink-bordered` chassis so it renders correctly under `[data-eink]` without color-mode-only assumptions. Strings translated across all 33 locales. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
93abca8960 |
feat(dict): faster MDict/StarDict import + lazy lookup; raw .dict; UX (#4334)
Make the dictionary import path usable on large bundles and bring the multi-device flow up to par. Import perf - Skip `MDX.create()` at import time. The factory triggers full init — decompresses every key block and sorts millions of keys with localeCompare just to expose the header. Replace with a tiny `readMdxHeader()` that only reads the small XML header for Title / Encoding / Encrypted (saves ~17 s on a 250 MB MDX on web). - `partialMD5`: read the 9 sample slices in parallel rather than sequentially. Each freshly-picked-File slice round-trip on Chrome costs ~100 ms cold; parallelisation collapses 9 of them into one. - Native fast-path in `nativeAppService.writeFile`: when the source is a `NativeFile`, delegate to Tauri's `copyFile` rather than streaming the file through `NativeFile.stream()`. Streaming a 250 MB body through 1 MB IPC chunks on Android took ~100 s; native copy is bound by disk throughput instead. Exposes `NativeFile.getNativeLocation()` for the FS layer to use the underlying path + baseDir directly. Lazy lookup - Pass `lazy: true` to `MDX.create` / `MDD.create`. The js-mdict change in this PR skips the upfront decompress-every-block + sort during init (~80 s on the same 250 MB bundle) and decodes only the relevant key block on demand per lookup. First-lookup main-thread block drops from ~81 s to ~230 ms. (Closes #4228.) Raw .dict - Drop the import-time gate that flagged non-gzip dict bodies as `unsupported`. The runtime body loader (`loadDictBody`) already probes the gzip header and falls through to a passthrough buffer for raw files, so the gate was the only thing preventing raw `.dict` bundles from importing on devices that received them via cloud sync. (Closes #4179, partially addresses #4248.) Import-flow UX - `handleImport` now always surfaces a toast for every non-cancelled attempt: picker errors, missing app service, no-op imports, and unsupported-but-imported bundles each get their own message instead of failing silently. - Call `markAvailableByContentId(newDict.contentId)` after add/replace so the "Bundle is missing on this device" warning clears immediately — no need to close-and-reopen the panel. System Dictionary - Drop the cascading toggle behavior in `setEnabled`. Each provider's enabled flag persists independently; exclusivity is enforced at lookup time. Toggling System on/off no longer wipes the user's preferred set of in-app providers. - Render non-system rows as read-only when System is on (toggle still shows what's queued to restore; tooltip explains the lock). - `isSystemDictionaryEnabled` short-circuits to `false` on platforms where the handoff isn't implemented. `providerEnabled` is whole- field synced across devices, so a flag set on macOS would otherwise leak to a Windows device with no way to look up a word. js-mdict - Submodule bump to e6dbc99 which adds the opt-in `lazy: true` `MDictOptions` flag (skip `_readKeyBlocks` + post-init sort; new `lookupKeyBlockByWordLazy` path on `MDX` and `MDD`). Eager mode is unchanged and every existing js-mdict test still passes. i18n - 208 new translations across 33 locales for the new UX strings. Closes #4228 Closes #4248 Closes #4179 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
cf44e85180 |
fix(reader): fit duokan-page-fullscreen cover image without cropping (#4328)
Closes #3914. Switch the page-fullscreen image to object-fit: contain (and SVG preserveAspectRatio meet) so cover images fit the page and center without cropping. Also adds a duokan-image-gallery-cell layout helper and drops the mobile marginBottomPx override. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
7bd3386c20 |
fix(perf): avoid Layerize storm caused by huge <pre> blocks on Android (#4295)
|
||
|
|
5e366018df |
fix(cbz): ComicInfo metadata + CBZ page count + WebDAV i18n (#4282)
* fix(cbz,i18n): ComicInfo metadata + CBZ page count + WebDAV i18n Closes #4253 (ComicInfo.xml not read) and #4255 (CBZ shows "1 page left"). CBZ / ComicInfo (foliate-js submodule + Readest derivation): - comic-book.js: find ComicInfo.xml in subdirectories too, parse description / subject / identifier / published / series fields beyond the prior name+position pair. Series Count populates the canonical `belongsTo.series.total`; no top-level duplication. - bookService.ts / readerStore.ts: derive `metadata.seriesTotal` from `belongsTo.series.total` in parallel to the existing series / seriesIndex derivation. - ProgressBar / FooterBar / DesktopFooterBar: drop the hard-coded `pagesLeft = 1` for fixed-layout books and compute it from `section.total - section.current`. FooterBar uses `FIXED_LAYOUT_FORMATS.has(bookFormat)` so CBZ picks `section` (correct image count) instead of `pageinfo` (locations). - ProgressBar: switch the remaining-pages text to "in book" for fixed-layout titles (no chapter structure) and keep "in chapter" for reflowable books. WebDAV refactor for translation coverage: - WebDAVBrowsePane / SyncHistoryPanel called `t(...)` (passed as a prop) instead of `_(...)`. The i18next-scanner only looks for `_`, so ~53 strings were unreachable and shipped in English to every locale. Switched both components to call `useTranslation()` themselves; helpers that aren't React FCs take `_: TranslationFunc` so the scanner sees the literal calls. - WebDAVClient.checkConnection now returns a `code` discriminator (`SERVER_URL_REQUIRED` / `AUTH_FAILED` / `ROOT_NOT_FOUND` / `UNEXPECTED_STATUS` / `NETWORK`); raw English `message` is reserved for the dev console. New `formatConnectError` and `formatSyncError` helpers in WebDAVForm translate via a switch where each branch is a literal `_('...')`. Same treatment for the sync-failure path that previously surfaced raw e.message. - "Syncing 0 / {{total}}" is now parameterized as "Syncing {{n}} / {{total}}" with n=0 at startup so the digit formats naturally and the template can be reused mid-sync. - "Cleanup · {{count}} book(s)" hard-coded options used unsupported ternary; rewrote as plural-aware key. i18n scanner fix (i18next-scanner.config.cjs): - vinyl-fs walked into directories whose names end in source-file extensions (Next.js route folder `runtime-config.js/`, Playwright screenshot folder `*.test.tsx/`) and crashed with EISDIR. Resolved by expanding globs via `fs.globSync` and filtering to files only before handing to the scanner. TypeScript-syntax sites that broke esprima during extraction: - WebDAVBrowsePane / WebDAVForm: `(e as Error).message` and `failed[0]!.title` inside `_(..., options)` arguments. Replaced with `e instanceof Error ? e.message : String(e)` and `failed[0]?.title ?? ''` — also runtime-safer. User-facing em-dash cleanup: - Removed em-dashes from translation keys across SyncHistoryPanel / WebDAVForm / WebDAVBrowsePane / SyncPassphraseSection / send/page / replicaCryptoMiddleware / AIPanel. Tagline in `layout.tsx` kept. Locale translations: - ~2400 translations applied across all 33 locales for the keys that were either newly extractable, freshly worded, or pre-existing but untranslated. Zero `__STRING_NOT_TRANSLATED__` remain after the run. Misc: - next.config.mjs: drop `eslint.ignoreDuringBuilds: true` so build runs the same lint as CI. - Collection type: add `total?: string` for ComicInfo series count. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci(test): fix vitest invocation, run with 4 workers `pnpm test:pr:web` was chaining `pnpm test -- --watch=false`, which pnpm expanded into: dotenv -e .env -e .env.test.local -- vitest -- --watch=false The second `--` made vitest treat `--watch=false` as a positional file pattern, not a flag. Vitest then fell back to defaults (in CI's non-TTY env that still meant a one-shot run, so the suite passed), but the worker pool was effectively serialized for big chunks of the 243-file run — wall ~90 s on a 4-vCPU runner where the parallel-sum of phases was ~236 s (≈2.6× effective parallelism). Replace the chained pnpm invocation with a direct call to `vitest run --maxWorkers=4`, matching the 4 vCPUs the GH Actions ubuntu-latest runner provides. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
b78daed562 |
feat(send): gate email-in to Plus, Pro, and Lifetime plans (#4280)
Email-in (`<user>@readest.com`) is now a paid feature. The other
Send channels — in-app /send page, mobile share-sheet, browser
extension — stay open to free users.
Three enforcement layers:
- `pages/api/send/address.ts` and `pages/api/send/senders.ts` return
403 with `{ code: 'plan_required', plan, requiredPlans }` for free
users. No `send_addresses` row is allocated on the blocked path.
`pages/api/send/inbox.ts` and `pages/api/send/inbox/file.ts` are
deliberately left open — they're shared with the file-upload and
extension channels.
- `workers/send-email` looks up `plans.plan` after resolving the
recipient and bounces (not silently drops) inbound mail for free
users with a one-sentence message pointing to upgrade plus the free
clip channels. Bounce rather than drop so a downgraded user
understands why their mail stops landing.
- `components/settings/integrations/SendToReadestForm.tsx` reads the
user's plan from the JWT before any API call. Free users see one
friendly card — headline, value prop, "View plans" CTA → /user, and
a softer line about the free alternatives — instead of address /
senders / activity sections of disabled controls. The
IntegrationsPanel NavigationRow stays visible so users can discover
the feature.
Single source of truth for the entitled tier set: `EMAIL_IN_PLANS` +
`isEmailInPlan(plan)` in `src/utils/access.ts`. Mirror copies live in
the Worker (no shared import surface) — keep them in sync.
Edge cases:
- Downgraded user: existing `send_addresses` row stays. All three
layers block; re-upgrading silently restores the same address.
- Loading flicker: `userPlan` starts as `null` so the loading skeleton
stays up rather than briefly flashing the upgrade card for a paid
user on a slow client.
12 new unit tests cover the gate on `/api/send/address` and
`/api/send/senders` (GET + POST blocked for free users, no Supabase
access on the blocked path, allowed for plus / pro / purchase).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|