forked from akai/readest
99b9adfe85a04db4c774fc33ed695656ef65da96
994 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
99b9adfe85 |
refactor(sync): provider-agnostic file-sync engine with incremental WebDAV sync (#4784)
* refactor(sync): extract provider-agnostic layout paths Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(sync): extract wire envelope module Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(sync): extract pure merge module with law tests Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(sync): add FileSyncProvider and LocalStore interfaces Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sync): FileSyncEngine orchestration over a provider Port WebDAVSync's per-book + library-wide sync onto FileSyncProvider + LocalStore. Behavior preserved; the #4756 metadata-reconciliation test is retargeted to drive the engine through a fake provider + store. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(sync): move WebDAV client + connect settings under providers/webdav Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sync): WebDAVProvider implementing FileSyncProvider Wraps the WebDAV transport client, maps WebDAVRequestError to the neutral FileSyncError, and owns Tauri streaming upload/download. Adds a provider-conformance suite future backends can run against. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sync): shared appService-backed LocalStore bridge Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(reader): drive WebDAV sync through FileSyncEngine Construct a WebDAVProvider + shared LocalStore + engine once per hook; the inline buffered/streaming book-file loader collapses into the provider + store, so the hook no longer imports tauriUpload or the file path helpers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(settings): drive WebDAV library sync + browse through the provider WebDAVForm now builds a WebDAVProvider + shared LocalStore + engine and calls engine.syncLibrary; the ~170-line inline callback block (buffered/streaming loaders, URL+auth construction) is gone. WebDAVBrowsePane builds a provider for the engine-level deleteRemoteBookDir cleanup helper. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(sync): remove WebDAV-specific sync module, WebDAV is now a provider Delete src/services/webdav (WebDAVSync/WebDAVPaths + the transitional client and connect-settings shims). The superseded webdav-metadata-sync test is replaced by engine-metadata-sync; webdav-delete now drives deleteRemoteBookDir through a WebDAVProvider and asserts FileSyncError. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(sync): hydrate library before WebDAV Sync now to prevent clobber Sync now while the library store was unloaded (app launched into reader/ settings without mounting the Library view) merged the engine's addBookToLibrary / updateBookMetadata against an empty in-memory library, persisting a downloaded book or a metadata update as the entire library and wiping what was on disk. Pre-existing bug surfaced during the file-sync review. Hydrate the store in handleSyncNow and harden the store bridge with a load-if-unloaded guard (mirrors useLibraryStore.updateBooks). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(sync): make listDirectory honor the FileSyncError contract listDirectory threw a plain Error (and let raw fetch failures escape), so WebDAVProvider flattened every list() failure to FileSyncError(UNKNOWN). Throw the same WebDAVRequestError taxonomy as the file-level helpers (AUTH_FAILED / NOT_FOUND / NETWORK) so the provider maps them correctly. Add list() cases to the provider-conformance suite. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(sync): cover streaming upload, discovery/download, and receive paths The metadata-sync gate only exercised the buffered metadata + config-merge paths. Add engine tests for streaming uploadStream (+ HEAD short-circuit + one-shot retry), remote-only discovery -> streaming download -> addBook, and the receive strategy (pull-only, no config or index writes). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sync): incremental WebDAV Sync now + bounded concurrency Sync now was a full walk of every book each run (675 round-trips even when nothing changed). Default to incremental: diff the local library against the shared library.json index per hash and only process books whose local copy is newer (or absent). book.updatedAt bumps on every progress/notes/metadata save (bookDataStore.saveConfig), so the index is a reliable per-book change marker. Remote-newer books pull their config in the reconcile pass so peer progress still propagates. A new 'Full Sync' toggle (default off) re-checks everything. Also run the reconcile / download / push phases over a bounded worker pool (default concurrency 4) instead of one book at a time. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(sync): simplify Sync now toast to a single book count The completion toast built a multi-line success bullet list (downloaded / pulled / pushed / uploaded). Replace it with the same single-line info toast the native cloud sync uses: '{{count}} book(s) synced'. Add a booksSynced counter to the engine result (a Set of distinct hashes touched in any direction, since the per-action counters overlap under Full Sync). Failures still surface as a warning. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(ui): raise toasts above modals so they aren't hidden by open dialogs Toasts rendered at z-50, below the Settings dialog (z-110) and ModalPortal (z-120), so a toast dispatched from an open dialog (e.g. WebDAV 'Sync now') was buried. The documented overlay scale already places toast at 130; the component just hadn't followed it. Move the toast to z-[130] and extend the zIndexScale invariant test to guard TOAST > MODAL/SETTINGS and APP_LOCK > TOAST. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
79ae8a48ba |
feat(reader): sync per-book proofread rules across devices (#4781)
Per-book and selection-scope proofread (find/replace) rules were pushed in the synced book config but dropped on pull (applyRemoteProgress only applied location), so they never propagated across devices. Merge them by id on the config pull, mirroring the booknote CRDT path. Library-scope rules keep syncing via the settings replica. - Add updatedAt/deletedAt to ProofreadRule. Delete is now a tombstone for book/selection scope so a removal is not resurrected by a peer's live copy; library-scope deletion keeps the hard splice (settings-replica whole-field LWW already handles it). - Add mergeProofreadRules (by id, updatedAt/deletedAt last-write-wins) and merge into applyRemoteProgress; refresh the live view only when the merged rules actually changed. - Backfill a content-derived id for id-less rules (legacy/foreign/hand-edited) via ensureRuleId, and seed book/library ids from content so the same rule created on two devices dedupes instead of duplicating. Selection rules keep a per-instance unique id. Without this, id-less rules collide on one Map key and clobber each other. - Filter tombstoned rules from the transformer and the manager dialog list. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
0589cb4f4a |
fix(reader): stop a quick-deleted highlight from being re-drawn (#4773) (#4779)
The per-relocate re-apply effect reads a memoized annotation index. A highlight deleted in place after the index was built still sits in its bucket, and selectLocationAnnotations trusted the build-time deletedAt filter, so the effect re-drew the just-deleted overlay and left it orphaned on the page until the book was reopened. Re-check deletedAt at the read site: in selectLocationAnnotations and in the sibling globals re-apply loop in Annotator. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
e80ab1762b |
refactor(settings): polish sync and integration panels (#4774)
- Background Image: move the Library/Reader scope into the section title
("Background Image (Library)" and "Background Image (Reader)") instead
of a separate "Applies to ..." sublabel line.
- Send to Readest: render approved-sender emails monospace to match the
inbound address, and wrap long addresses to at most two lines instead
of truncating on one line.
- WebDAV: split the "Uploading X / Y" progress into a status line plus a
one-line book title.
- WebDAV: reword the "Upload Book Files" description to "Uploads book
files to your other devices."
- WebDAV: rename the "Always use latest" strategy to "Send and receive".
KOSync keeps "Always use latest" since it must contrast with its
"Ask on conflict" option.
- WebDAV: remove the Sync History section and its persisted log model;
the sync engine still reports per-book failures in its result.
Updated i18n across all 33 locales.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
0c7ffa9799 |
fix(reader): stop iOS page-turn animation stutter (#4768) (#4772)
* fix(reader): stop iOS page-turn animation stutter (#4768) iOS users saw occasional page-turn animation stutter that was not present on earlier 0.11.x builds. It traces to foliate-js commit c1c7315 (first shipped in 0.11.4): the large-section rafAnimateScroll fallback and the removal of persistent compositor-layer hints, both added to fix a ~1s Blink freeze on Android Chromium at high DPR. Apple WebKit composites those layers fine, so on iOS (notably 120Hz ProMotion devices) the changes only cost smoothness: large-section turns animate scroll on the main thread, and every turn promotes a layer on-demand instead of using a persistent one. Opt the iOS renderer into foliate-js's new gpu-composite path, which restores persistent compositor layers and skips the main-thread rafAnimateScroll fallback. Other platforms keep the Android freeze fix. Bumps the foliate-js submodule. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(deps): repin foliate-js to merged gpu-composite commit (#4768) readest/foliate-js#39 squash-merged to a new commit on main. Move the submodule pin off the now-orphaned PR branch commit to the merged main commit. No content 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> |
||
|
|
44a6900da0 |
feat(reader): extend selections and highlights across pages (#4741) (#4767)
* docs(plan): design for cross-page corner auto-turn (#4741) Extract useAutoPageTurn so the corner-dwell page turn works for instant highlight drags and for range-editor handle drags, not just native text selection. Decouple the dwell liveness from the DOM selection and anchor each range's non-dragged end to a DOM position so it survives the scroll. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(plan): add keyboard turn-on-cross to cross-page design (#4741) Shift+Arrow selection adjust extends into the off-screen next column without turning the page. Fold it into the feature with an immediate turn-on-cross (no dwell) in the keyboard path, reusing the page-edge geometry from useAutoPageTurn. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(reader): extend selections and highlights across pages (#4741) Extract the corner-dwell auto page-turn (#1354) into useAutoPageTurn, decoupled from the DOM selection, so every selection gesture can drive it in paginated mode, not just native text selection: - Instant Highlight drag: feed the finger corner into the dwell machine and DOM-anchor the highlight start so it survives the page scroll. - SelectionRangeEditor and AnnotationRangeEditor handle drags: feed the dragged-handle corner; anchor the non-dragged end to a DOM position so the edited range spans pages (the annotation editor previously resolved both ends from window coordinates and lost the previous page). - Shift+Arrow keyboard selection adjust: turn the page immediately when the extended focus leaves the visible page, so the growing selection stays in view. An after-turn re-emit rebuilds each gesture's range from the held position so the selection extends onto the new page without waiting for the next move. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
d963b911c8 |
fix(reader): zoom linked images on single tap (#4757) (#4766)
A single tap on an image wrapped in an <a> element followed the link instead of opening the image viewer, because postSingleClick returned early for any element inside an anchor before reaching media detection. Compute the media target up front and let it bypass the anchor guard, so a tapped image/table/svg-image opens the viewer just like long-press already does. Footnotes are excluded so footnote anchors keep their popup and navigation behavior. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
163487b5e3 |
feat(reader): add regex and nearby-words search modes (#4560) (#4764)
Add Calibre-parity search modes to the reader's full-text search. The "Match Whole Words" toggle becomes a single-select mode group: Contains, Whole Words, Regular Expression, Nearby Words. - Regex and nearby-words matching live in the foliate-js submodule (bumped here); the sidebar threads `mode` and `nearbyWords` through. - Nearby distance is chosen with a "within N words" control (5/10/20/50, default 10), not parsed from the query, so trailing numbers stay literal search words. - Per-mode modifiers: Match Diacritics is greyed out for regex (no-op). - Calm inline error for invalid regex / too-few nearby words, a no-results state, and a results-count footer. - Nearby matches render a segmented excerpt emphasizing each matched word and highlight every word in the book. - BookConfig schema v2 -> v3 migrates the deprecated `matchWholeWords` boolean to `mode` (still written for sync back-compat). Also fix two search interactions: - option changes (e.g. within-N-words) now take effect immediately by reading the latest config at search time instead of a stale closure. - closing search from the results nav bar now exits the sidebar search mode, not just the results (search-bar visibility lifted to the store). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
8810aa6db0 |
fix(reader): stop trackpad pinch-zoom flicker on image viewer (#4742) (#4748)
On macOS a trackpad pinch-to-zoom is delivered as a rapid stream of ctrl+wheel events. The zoomed image kept its 0.05s transform transition during that stream, so each event restarted the in-flight transition from its interpolated mid-point and the image lagged and flickered. This is the same root cause as the #4451 pan flicker, which was fixed by dropping the transition during the gesture for the pan and touch-pinch paths; the wheel-zoom path was the only continuous gesture left with the transition on. Suppress the transition while a wheel-zoom gesture is streaming, cleared on a short debounce since wheel has no explicit gesture-end event. Discrete zoom (buttons, double-click, keyboard) keeps its smoothing. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
acd4a67dcf |
fix(reader): require a still-hold before instant-highlight on touch (#4745)
* fix(reader): require a still-hold before instant-highlight on touch Instant Highlight (the highlighter quick action) engaged on every pointer-down over text, calling preventDefault, which swallowed the single tap / swipe that turns the page on Android. Tapping the side margins still worked only because they are not selectable text; the synthetic-click fallback was also dead on Android (native touchend calls handlePointerUp with no event). Gate engagement behind a 300ms still hold for touch/pen: a tap releases first and a swipe moves first, so both fall through to pagination, and only a deliberate still hold starts drag-to-highlight. Mouse input keeps engaging immediately (click vs. press-drag is already unambiguous). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(agent): update agent memories Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
b1346bf16d |
feat(wordlens): en-en glosses, styling, derivation lemmas, display-time cap (#4744)
* feat(wordlens): support en-en monolingual glosses Gloss difficult English words with a short English definition for learners reading English with English hints. - build: buildEnEn + shortDefGloss read ECDICT's English `definition` column (first/primary WordNet sense, POS-stripped, drop ;-example, <=24 word-boundary with trailing-connector trim). New `en-en` CLI branch; buildEnZh/buildEnEn now share a buildEnPack core. - gating: drop the hardcoded `hint === source` rejections (wordlensSection, WordLensPanel) so same-language packs are allowed; availability is decided by the manifest (resolvePack returns null when no pack exists). - data: data/wordlens/en-en.json (26,578 entries) + regenerated manifest.json. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(wordlens): gloss styling, derivation lemmas, display-time cap Builds on the en-en monolingual gloss support with refinements and regenerated packs. - settings: per-book gloss <rt> font size (em) and color in Settings > Language > Word Lens (getRubyStyles reads viewSettings). - en-en hints: WordNet hybrid (a simpler synonym, else a category hypernym, else the ECDICT definition) instead of raw verbose definitions. - lemmatization: gate difficulty by the lemma rank for every English source pair. enBaseFormCandidates now also covers -able/-ible suffixes and negative prefixes (un/in/im/ir/il), so insufferable resolves to suffer. A candidate is accepted when the English definition names the base OR the Chinese translations share a content character, which keeps true derivations (insufferable -> suffer) and rejects coincidental stems (capable -> cap). en-X packs inherit the en-en lemma table. - display cap: the max gloss length is applied at render time in cleanGloss (MAX_GLOSS_LEN), so the packs store the full hint and the cap can change without regenerating data. - tooling: pnpm wordlens:preview to sample pack entries; cache build corpora under data/wordlens/.sources (gitignored). - data: regenerate en-en, en-zh and en-de/es/fr/pt/ru plus the manifest. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
e982af1725 |
feat(reader): adjust text selection with Shift/Ctrl/Opt+Arrow keys (#4728) (#4738)
Support standard desktop shortcuts for refining an active text selection: Shift+Left/Right by character, Ctrl/Option+Shift+Left/Right by word. Only active while text is selected; otherwise the keys fall through to page navigation as before. Root cause: after a selection the reader container (not the book iframe) holds focus, so Shift+Left/Right keystrokes reach the parent shortcut handler and matched the page-turn shortcuts, turning the page instead of refining the selection. The new onAdjustTextSelection action runs before the navigation actions: when a selection is active it extends the iframe selection via Selection.modify() and suppresses the page turn; an iframe-forwarded key (already extended natively) just suppresses navigation. handleSelectionchange now refreshes the popup/range for keyboard-driven changes (no pointer drag) so the selection toolbar follows the refined selection. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
bc9b8b23e6 |
fix(reader): stop per-chapter listener leak that degrades paragraph mode (#4735)
The annotator's foliate `load` handler (onLoad) attached a renderer `scroll` listener and, on Android, a global `native-touch` dispatcher listener on every section load. Both the renderer and the eventDispatcher outlive individual sections — and foliate fires `load` for preloaded neighbour sections too — so these listeners accumulated without bound, one set per chapter. Each renderer `scroll` (fired on every paragraph-mode `goTo`) then ran all of them, and on Android the scroll/native-touch handlers do real work. Reading a long book (e.g. a 3000-chapter web novel) in paragraph mode slowed down steadily after a few chapters and only an app restart cleared it. Register these listeners once per view via a new `useRendererInputListeners` hook with cleanup, instead of once per section load. The native-touch handler now resolves the CURRENT primary section's doc/index at fire time rather than capturing a (possibly off-screen, preloaded) section's. The redundant `scroll` → `repositionPopups` listener is dropped — a dedicated effect already repositions popups on scroll. Doc-scoped listeners stay in onLoad, since they die with the section's iframe. Add useRendererInputListeners unit tests covering register-once-per-view, no-accumulation-across-re-renders, latest-handler routing, Android gating, and unmount cleanup. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
140b71ee30 |
feat(dictionary): add adjustable dictionary popup font size (#4443) (#4734)
Expose `::part(dict-content)` on the MDict shadow content and add a dictionary popup font-size setting (Settings → Language → Dictionaries), independent of the main reading view. - mdictProvider: tag the in-shadow body with `part="dict-content"` and a stable `dict-shadow-host` class so the popup's `::part()` rule can reach across the shadow boundary — MDict is the only provider that renders into a shadow root, so ordinary popup CSS can't touch it. - DictionarySettings.fontScale (default 1) with setFontScale + load-merge; synced cross-device via the `dictionarySettings.fontScale` whitelist entry. - DictionaryResultsView drives `--dict-font-scale` + `data-dict-content` on each per-tab container. globals.css re-bases the light-DOM Tailwind text utilities to `em` within that scope and sizes the MDict shadow body via `::part(dict-content)`, so every provider scales from one lever. - SettingsSelect control (85–175%) in the Dictionaries panel. Tests: jsdom unit tests (part attribute, store fontScale, sync whitelist) plus a real-Chromium browser test for the em-rebasing + `::part` + custom- property-inheritance CSS contract jsdom cannot model. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
30727d353a |
fix(reader): release volume-key page-flip while TTS is playing (#4691) (#4710)
When "page turn with volume buttons" is enabled, the volume keys were intercepted for the whole reading session, so switching from reading to TTS left them flipping pages instead of adjusting playback volume. Gate the volume-key interception on this book's TTS playback state (via the existing `tts-playback-state` bus): release interception while TTS is playing so the OS handles volume, and re-acquire it when TTS is paused or stopped. The acquire/release pair is keyed on the playback state so the deviceStore reference count stays balanced. 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> |
||
|
|
9735f497db |
feat(reader): proofread rule sync, regex, reorder, and dialog refresh (#4700) (#4708)
- Sync library-scope replacement rules across devices (settings whitelist). Book and selection rules already ride along the book config. - Add regex support: a Regex toggle on the selection popup plus a full add-rule form (find / replace / scope / regex / case-sensitive) in the Proofread Rules manager. - Reuse Ctrl/Cmd+P to open the rules manager when nothing is selected (handleProofread); opens the create-from-selection popup otherwise. - Translate the whole-word warning, which was hardcoded English. - Drag-to-reorder rules within each category (dnd-kit), persisted via the rule order field across both the book config and global settings. - Modernize the manager dialog with the settings primitives and a btn-contrast CTA; fix mobile height clipping and the inset scrollbar. - Translate all pending i18n strings across 33 locales (includes the delete-confirm strings surfaced by the extractor). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
96d65d9960 |
feat(tts): add native local iOS TTS (AVSpeechSynthesizer) (#4697)
Implement on-device iOS text-to-speech using AVSpeechSynthesizer, mirroring the Android native TextToSpeech plugin so the shared NativeTTSClient drives both platforms through the same command and tts_events contract. - Swift NativeTTSPlugin: speak/stop/pause/resume/rate/pitch/voice and voice enumeration, with region-disambiguated duplicate voice names and a small preUtteranceDelay to avoid first-word clipping. - Enable the native TTS client on iOS in TTSController. - Make TTS teardown resilient: reset UI state up front and tear down the controller, media session, and background audio in parallel so a slow native shutdown can never leave the TTS icon or lock-screen session stuck on. - Keep iOS on navigator.mediaSession for the lock screen (Android uses the native foreground service), which restores the Edge TTS cover and current-sentence metadata. 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> |
||
|
|
5f561504e3 | fix(sync): keep view settings device-local and exclude them from sync (#4672) | ||
|
|
23d1ef6f13 |
fix(rsvp): restore in-flow control bar layout reverted by #4589 (#4671)
* fix(rsvp): restore in-flow control bar layout reverted by #4589 PR #4585 fixed the mobile RSVP control bar overlap by laying the audio toggle and settings gear in a single in-flow flex row flanking the centered transport. PR #4589 branched from main about five minutes before #4585 merged and merged about ten hours later without rebasing, so its squash carried the stale pre-#4585 file and reverted the entire fix, including the regression test #4585 had added. On narrow phones (360px) the audio and settings icons again overlapped the right end of the transport, hiding the "skip forward 15" control. Restore the #4585 layout and re-add a structural guard test asserting the audio toggle and settings share the transport row and live in no absolutely positioned cluster. Verified on a Xiaomi 13 (360px) via on-device CDP: no overlap, play button stays centered. Also stage the project-memory note for this regression. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(rsvp): hide Faster/Slower buttons at 350px or below On very narrow phones (width 350px or less) the control row has no room for every control. Collapse the Faster/Slower speed buttons via a max-[350px]:hidden variant (matching the existing 350px tightening tier) so the transport, audio toggle and settings never overflow. Speed stays adjustable from the WPM dropdown. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
d5c640996d |
fix(opds): show Add Catalog dialog above Settings on mobile (#4669)
The "Add OPDS Catalog" dialog (a ModalPortal opened from inside Settings > Integrations > OPDS Catalogs) rendered behind the Settings sheet on mobile, so the form could not be reached or filled in. Root cause: PR #3235 raised the Settings dialog to z-[10050] to clear the full-screen RSVP overlay (z-[10000]) for in-overlay dictionary management. That also jumped Settings above the ModalPortal layer (z-[100]), so any modal opened from inside Settings was buried. The bug is mobile-only because on desktop the rounded-window frame (.window-border, z-99) traps the inline-rendered Settings dialog in its own stacking context, while ModalPortal escapes to document.body and wins there. Redesign the overlay z-index into a compact scale (no four-digit values), each layer clearing the z-99 page frame: 100 RSVP overlay 101 RSVP controls (start dialog, lookup chip) 110 Settings dialog 120 modal / command palette 130 toast / alert 200 app lock Lock the ordering with a static test that reads the values from source and would have caught the #3235 regression. Documented in DESIGN.md. Verified on a Xiaomi device via CDP: elementFromPoint at the dialog center now resolves inside the Add Catalog form instead of Settings. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
dd53e52453 | chore: only show the current position item in TOC and update agent memories (#4665) | ||
|
|
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> |
||
|
|
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> |
||
|
|
e327d0c992 |
feat(tts): reuse the speaking session across paragraph & RSVP modes (#4657)
Switching into Paragraph or RSVP mode while TTS is already playing now syncs to the live session instead of forcing a stop + restart inside the mode. Bundles several related TTS fixes uncovered along the way. Session reuse (enter from normal mode): - TTSController.redispatchPosition() re-emits the current position on the canonical tts-position signal with a fresh sequence. - useTTSControl answers a new tts-sync-request by replaying the current position then playback state (position-first so RSVP's paused handler can't discard it). - Paragraph & RSVP engage following on entry and dispatch the request; no-op when no session exists. RSVP refinements: - Reusing a session skips the start dialog and the get-ready countdown (starts externally driven); gated on a live tts-playback-state signal so the countdown can't flash. - Stopping TTS now pauses RSVP instead of resuming its own pacing. Word-sync fixes: - rangeTextExcludingInert honours the range offsets inside a single text node, fixing word-highlight drift on middle sentences of single-<span> paragraphs (Edge word highlighting). - foliate-js TTS.from() starts at the sentence containing the selection, not the next one (submodule bump). - Selecting a word and starting TTS now clears the selection. - Dev-only [TTS] word-sync trace (stripped from production builds). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
be17654fc0 |
fix(rsvp): render RTL words whole so Arabic shapes correctly (#4630) (#4648)
The RSVP word window split each word into before/orp/after spans at the ORP index and laid them out left-to-right. Slicing an Arabic/Hebrew word by character index breaks letter shaping (letters stop connecting, some slices render as notdef boxes) and the LTR layout reverses the visual order, so e.g. علم showed as disconnected, out-of-order letters. Detect RTL text and render the word as a single centered span — reusing the existing CJK Highlight Word path — with dir="rtl" so the browser shapes and orders it correctly, matching the context panel. ORP anchoring is meaningless for unsplittable shaped scripts, so RTL always renders whole; no new toggle. 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> |
||
|
|
38a6d3d9ba |
fix(dict): stop iOS instant system dictionary popping multiple times (#4644)
On iOS a single long-press emits several selectionchange events, so the instant quick action fired the system dictionary 2-3 times, stacking UIReferenceLibraryViewController sheets. Add a once-per-gesture latch to deferredAction (re-armed by beginGesture on touchstart/pointerdown) so the action runs at most once per gesture, mirroring the Android defer-to-touchend coalescing. Also fix two related cases: - Tapping outside to deselect after dismissing the dictionary occasionally re-opened it (~1/3): the deselect tap re-armed the latch and a racy lingering selectionchange re-fired. Gate the instant action on a long-press hold (isLongPressHold, 300ms, touch only) so a quick tap can't trigger it. - A Word Lens gloss tap ignored the system-dictionary setting (always opened the in-app popup); route it through handleDictionary so it honors the system dictionary like the toolbar and instant-quick-action paths. Verified on Android (Xiaomi) that the instant dictionary still fires once per long-press and re-arms for the next gesture; iOS double-popup and tap-to-deselect re-fire confirmed fixed. 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>
|
||
|
|
480ab5b71e |
feat(hardcover): automatically sync progress and notes (#4614)
Hardcover sync previously only ran when the user opened the reader menu and tapped "Push Progress" / "Push Notes". Add an opt-in Auto Sync toggle (default OFF) to the Hardcover settings so progress and notes are pushed automatically while reading. - useHardcoverSync: silent debounced (10s) auto-push of progress on page turns and of notes on annotation/excerpt changes, gated on enabled && autoSync === true; pending pushes flush on the existing sync-book-progress close event and cancel on unmount. Manual menu actions are unchanged (still loud). - HardcoverSettings.autoSync flag (default false); existing connected users stay manual until they opt in. - HardcoverForm: new "Auto Sync" toggle row. Also backfills two untranslated strings surfaced by i18n:extract from the reading-stats feature (#4606) across all locales, plus the new "Auto Sync" key. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
35b02c4efc |
feat(statistics): KOReader-compatible reading stats with cross-device sync (#4606)
Supersedes #3156. Adds a reading-statistics system whose canonical data model is KOReader's own (book + page_stat_data), so stats round-trip losslessly between Readest and KOReader. - Storage: a cross-platform Turso statistics.db in KOReader's exact schema (web/Workers, desktop, iOS, Android) — replacing #3156's Node-only better-sqlite3 + statistics.json. - Tracking: per-page reading events (time-on-page, idle-capped) flushed on page-change/idle/hide/close — the KOReader model — not session aggregates. - Sync: legacy /api/sync extended with a stats type backed by self-contained Supabase tables (stat_books, stat_pages); union/longer-duration-wins merge keyed on book_hash. apps/readest.koplugin syncs through the same endpoint. - Scale & robustness: per-tab singleton connection (avoids OPFS lock conflicts) + explicit WAL checkpoint; transactional bulk apply; chunked resumable push; client-driven paged pull with trailing-ms completion; paginated/scoped server merge. Verified: 5668 unit tests, 155 koplugin busted tests, biome+tsgo + luacheck all green; web OPFS DB verified live. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
e145eb835a |
feat(reader): open image gallery & table zoom on single tap (#4600)
* feat(reader): open image gallery & table zoom on single tap In reflowable EPUBs, a single tap on an image or table now opens the same viewer a long-press opens, so the image gallery / table zoom is reachable by both gestures. Fixed-layout books (PDF/comics/manga) keep tap-to-turn, and long-press is unchanged everywhere. Reuses the existing iframe-long-press -> handleImagePress/handleTablePress flow via a new shared detectMediaTarget() helper (also adopted by the long-press path so the two entry points can't drift). handleClick now takes an isFixedLayout flag; the tap branch sits after the link/footnote/drag/ long-hold/Word-Wise guards so linked images still follow links and a long-hold or double-tap won't double-trigger. Context: #4584 (single taps stop registering after picture zoom on some WebView builds) - this adds a second, independent way into the viewer rather than fixing that root cause. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(reader): rename iframe-long-press message to iframe-open-media The message is now posted for both a long-press (any book) and a single tap on an image/table (reflowable books), so the long-press-specific name was misleading. Rename the message type to `iframe-open-media` and the consumer hook `useLongPressEvent` -> `useOpenMediaEvent`. The long-press detector (`addLongPressListeners`/`handleLongPress`) keeps its name since it still detects a long-press specifically. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- 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> |
||
|
|
51fede1a0d |
fix(rsvp): keep the audio toggle from overlapping transport on mobile (#4585)
The read-along audio toggle + settings gear sat in an `absolute end-0` cluster overlaid on the centered transport row. After the #3235 read-along feature grew that cluster from a single gear (~36px) to ~81px (audio + divider + gear), it covered the right end of the transport on narrow phones, hiding the audio button behind the "skip forward 15" control. Lay the playback controls out as a single full-width flex row: the audio toggle moves to the far left and the settings gear stays far right, symmetrically flanking the centered play button (justify-between on mobile, justify-center on md+). Tighten the secondary buttons on mobile (h-8, px-1.5) and add shrink-0 so the row fits without overlap; the symmetry keeps the play button centered. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
aab721b219 |
feat(dictionary): lemmatize inflected words before lookup (#4574) (#4582)
Dictionaries that store only base headwords (e.g. Oxford Dictionary of English) miss inflected selections like `ran`, `mice`, `children`, or `analyses` even though the lemma (`run`, `mouse`, `child`, `analysis`) is present. Add a language-aware lemmatizer whose base-form candidates are appended to the existing lookup candidate chain, after the exact/case variants, so an exact/case match always wins and the lemma is only tried once those miss. - New pluggable `lemmatize/` registry keyed by primary language subtag; add a language by registering one lemmatizer, no caller changes. - English lemmatizer: irregular-form table (suppletive verbs, irregular plurals/comparatives) + regular suffix rules (plural/past/gerund/ comparative/possessive). Over-generates on purpose — the dictionary lookup is the validator, so bogus stems simply miss. - Unknown/missing book language defaults to English (no-op on non-ASCII); an explicit non-English language with no registered lemmatizer is a no-op. - Applies centrally to all definition providers (mdict/stardict/dict/slob and the online builtins) via `buildLookupCandidates`. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
0f0b4279a7 |
perf(reader): memoize global-annotation fan-out per section (#4575) (#4579)
Highlighting recurring text (e.g. main-character names) as global annotations made page turning very laggy. The `progress` effect re-fans-out every global annotation across every rendered section on EVERY page turn, and each pass re-walks the section DOM, recomputes `view.getCFI()` for every occurrence, and tears down + recreates an SVG overlay per match. The overlays already exist after the first pass, so this is pure wasted work — profiled at ~25–45ms of synchronous main-thread time per page turn for 6 names / 226 occurrences across 2 rendered chapters, multiplied on slower mobile hardware. Memoize, per live section `Document`, which global notes have been expanded (signature embeds `updatedAt`/style/color/text). Subsequent page turns short-circuit to ~0ms. Keying on the `Document` makes invalidation automatic: a re-rendered section gets a fresh document (and fresh overlayer) so its overlays are rebuilt, while edits/recolors bump `updatedAt` and toggling global off clears the memo. 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> |
||
|
|
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> |
||
|
|
a56cc6c61a |
feat(tts): word-by-word highlighting for Edge TTS, closes #4017 (#4566)
Highlight each word as it is spoken (Edge TTS only) instead of keeping the whole sentence highlighted, and keep the view tracking the spoken word across page boundaries. Word boundaries - Capture Edge's audio.metadata WordBoundary frames (offset/duration in 100ns ticks plus the verbatim input-text span) in the Tauri, browser, and Cloudflare-Workers WebSocket transports. - Carry boundaries through the authenticated HTTPS proxy route via an X-TTS-Word-Boundaries response header (percent-encoded JSON, ASCII-safe), so word highlighting works on the web where the browser cannot open the wss connection directly. Cache them alongside the audio blob URL. Highlighting - Sync a requestAnimationFrame loop to audio.currentTime against the boundary table and highlight the word sub-range within the spoken sentence. Synthesis stays sentence-level (natural prosody); only the visual highlight is word-level. - Suppress the sentence highlight when the active client reports word boundaries and draw the first word immediately, so the whole sentence never flashes before the first word. Fall back to the sentence highlight when a chunk has no boundaries (other engines, empty metadata). - Re-apply the current word (not the sentence) when the view relocates. Page following - Turn the page as soon as the spoken word crosses a page boundary (a tts-highlight-word event scrolls only when the word is outside the visible range), instead of waiting for the next sentence. - Check the word's position for the "back to TTS location" badge so it no longer appears while the view follows the word onto the next page. Also fixes a pre-existing bug where the browser WebSocket was constructed with an options object (valid only for the Node ws package), which threw in browsers and made the wss path unusable on the web. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
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>
|
||
|
|
7cba22ab31 |
perf(reader): coalesce relocate events and memoize BookCell to stop per-swipe storm (#4562)
* perf(reader): coalesce relocate events and memoize BookCell to stop per-swipe storm
FoliateViewer
-------------
foliate fires `relocate` multiple times during a swipe burst (snap
steps + intermediate stabilize). Each one ended up in setProgress,
which writes to readerProgressStore + bookDataStore. Coalesce them to
a single commit per animation frame so only the final viewport state
is persisted.
Earlier this used requestIdleCallback, but profiling on Android showed
"Fire Idle Callback" ballooning to 2.0+ s of total time per ~28 s
session: rIC backed up under sustained pressure and dumped the whole
queue into the post-swipe pause, producing exactly the "feels sluggish
right after I let go" jank we were trying to fix. rAF runs once per
frame, gets scheduled by the browser's normal vsync loop, and doesn't
accumulate when the page is busy.
BooksGrid -> BookCell
---------------------
Previously BooksGrid subscribed to the entire progresses map and
rendered every book inline. The map changes on every page turn, so the
whole bookKeys.map(...) body re-ran for every swipe. On top of that
inset-related objects (gridInsets, contentInsets) were rebuilt every
render and threaded as fresh references into 7+ children, so even
unchanged children couldn't bail out. That accounted for ~27% of
main-thread time in the Bottom-Up profile ("Animation Frame Fired"
2.6s / 27%).
Extract BookCell as its own React.memo'd component:
- Each cell subscribes only to its own book's progress via
useBookProgress(bookKey). A page turn re-renders one BookCell, not
the grid.
- viewInsets / contentInsets are memoized off their numeric inputs so
children get stable prop references across renders.
- BookCell uses per-field selectors internally for the same reason
spelled out in store/readerProgressStore.ts header.
- Dropdown handlers are wrapped in useCallback so HeaderBar's props
object stays stable.
* fix(reader): subscribe BookCell to its own viewState so settings/ribbon toggles apply live
BookCell subscribed reactively only to useBookProgress and read
viewState/viewSettings imperatively. Settings that save with
applyStyles=false (Show Header/Footer, Double Border, Border Color) and
the bookmark ribbon toggle write no progress, so the cell didn't
re-render and the chrome it gates (SectionInfo, ProgressBar, DoubleBorder,
Ribbon) only updated on the next page turn.
Subscribe to the per-book viewStates[key] slice. This is safe now that
progress lives in its own store — viewStates[key] only bumps on
low-frequency events (settings toggles, ribbon, init, sync), never on
the per-swipe relocate path — so it does not reintroduce the commit
storm the progress-store split removed.
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>
|
||
|
|
ee01fcd123 |
fix(reader): texture the scrolled-mode top inset mask, closes #4486 (#4563)
In scrolled mode the notch-area masks the top safe-area inset with opaque bg-base-100 so content scrolling under the status bar is hidden, but it painted over the background texture (.foliate-viewer::before at the z-0 layer), leaving a flat untextured strip across the unsafe header area. Give the mask its own texture ::before (.notch-masked in textures.ts) and make the element span the grid cell, clipped down to the inset strip with clip-path — background-size cover/contain resolves against the element box, so the full-cell box is what keeps the mask's tiles aligned with the viewer's at the seam. clip-path also clips hit-testing, so the click target stays the inset strip only. Verified on a Xiaomi 13: the strip now renders the texture with a pixel-continuous seam (row-to-row MAE at the boundary dropped from 11913 to 230, the level of ordinary texture rows), and elementsFromPoint confirms the notch is hit-testable only inside the strip. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
59d4f0aa33 |
perf(reader): split progress into its own store to cut React commit storm (#4557)
setProgress was called multiple times per swipe burst, each call writing into readerStore.viewStates[key].progress. ~65 places in the reader subtree subscribed to useReaderStore() without a selector, so every setProgress fan-out re-rendered all of them -- even the 51 that didn't care about progress. On Android release builds this showed up as Layout = 9.8% and Function Call = 9.6% of main-thread self time in Chrome DevTools' Bottom-Up profile during a reading session. Fix: - New tiny store store/readerProgressStore.ts holds the per-book BookProgress map. setBookProgress only fires its own subscribers. - readerStore.setProgress now writes progress to the new store and only touches bookDataStore for the primary view (secondary parallel views shouldn't overwrite the shared config). - readerStore.getProgress is kept as a delegating facade so existing imperative call sites don't break. - Components / hooks that genuinely need to react to progress changes subscribe via the new useBookProgress(bookKey) hook. The handful of call sites that just want a one-shot read use getBookProgress(key) so they don't subscribe at all. - readerStore.clearViewState calls clearBookProgress so the map doesn't grow unbounded across book opens/closes. See store/readerProgressStore.ts header for the full rationale. |
||
|
|
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.
|
||
|
|
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> |