Commit Graph

904 Commits

Author SHA1 Message Date
akai da756eed47 feat: secure inline review sidecar 2026-07-10 12:37:13 +08:00
akai 48383b3db6 fix: harden inline review web workflow 2026-07-10 11:56:25 +08:00
akai 75293bd1a4 refactor: harden inline review panel layout
Android E2E (CDP) / android-e2e (pull_request) Has been cancelled
CodeQL Advanced / Analyze (actions) (pull_request) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (pull_request) Has been cancelled
CodeQL Advanced / Analyze (rust) (pull_request) Has been cancelled
PR checks / rust_lint (pull_request) Has been cancelled
PR checks / build_web_app (pull_request) Has been cancelled
PR checks / test_web_app (1) (pull_request) Has been cancelled
PR checks / test_web_app (2) (pull_request) Has been cancelled
PR checks / test_extensions (pull_request) Has been cancelled
PR checks / build_tauri_app (pull_request) Has been cancelled
2026-07-09 19:16:52 +08:00
akai 3b6d49c1ee fix: streamline inline review editing controls
CodeQL Advanced / Analyze (actions) (pull_request) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (pull_request) Has been cancelled
CodeQL Advanced / Analyze (rust) (pull_request) Has been cancelled
PR checks / rust_lint (pull_request) Has been cancelled
PR checks / build_web_app (pull_request) Has been cancelled
PR checks / test_web_app (1) (pull_request) Has been cancelled
PR checks / test_web_app (2) (pull_request) Has been cancelled
PR checks / test_extensions (pull_request) Has been cancelled
PR checks / build_tauri_app (pull_request) Has been cancelled
2026-07-09 17:32:43 +08:00
akai ebae9103d2 fix: enable floating review panel height resize 2026-07-09 16:30:52 +08:00
akai 0d8b2f9167 fix: restore review panel pin behavior 2026-07-09 16:04:08 +08:00
akai 987067414c fix: refine inline review panel layout 2026-07-09 15:13:14 +08:00
akai afd48837f9 feat: add inline review mode to reader 2026-07-09 14:15:36 +08:00
Huang Xin 0c24aad606 fix(reader): let page margins shrink into the safe-area inset (#4761) (#5001) 2026-07-08 02:31:01 +09:00
Huang Xin f8ad47a418 feat(reader): Auto Scroll reading mode for scrolled flow (#4998) (#4999)
Teleprompter-style continuous scrolling toggled from the View menu
(Shift+A), available only in scrolled mode. A PacedScroller drives
whole-pixel forward steps at a constant, user-adjustable velocity;
the speed (25-500 percent, persisted as autoScrollSpeed) is tuned
from a floating control pill that also offers pause/resume and exit,
and fades away while scrolling to keep the mode immersive.

Tapping the page pauses and resumes instead of turning pages or
toggling the bars; manual wheel or drag input simply composes with
the paced scrolling. Escape or leaving scrolled mode ends the
session. When forward progress stalls the session hops to the next
section (single-section scroll mode) or stops with a toast at the
end of the book. Vertical-writing books scroll along the horizontal
axis with the sign convention foliate uses for scrolled offsets.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 17:45:02 +02:00
Huang Xin 17de9357dd feat(reader): redesign the TTS control as a mini player with an expandable player sheet (#4996)
* feat(reader): add formatCountdown helper for TTS timer chips

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

* feat(reader): extract shared TTS playback info hook

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

* feat(reader): add TTS scrubber with buffer-ahead fill

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

* feat(reader): add TTS speed preset chips

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

* feat(reader): add persistent TTS mini player

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

* feat(reader): add full TTS player sheet with voice and timer sub-views

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

* test(reader): cover player sheet view reset on reopen

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

* feat(reader): replace TTS icon and popup with mini player and player sheet

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

* feat(reader): reserve mini player clearance and retire showTTSBar setting

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

* chore: retire shipped TTS follow-ups from TODOS

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

* chore(i18n): translate the TTS player strings

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

* fix(reader): pin mini player transport LTR and unmount the closed player sheet

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

* feat(reader): collapse sheet speed, voice, and timer controls into one row

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

* feat(tts): stabilize timeline estimates with cumulative voice calibration

Replace the per-sentence EMA with the cumulative ratio of all measured chars to all measured seconds per voice, so the estimated remainder converges instead of re-pricing on every quirky sentence. Legacy stored calibrations migrate as a small prior.

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

* fix(reader): hide the mini player while the player sheet is open

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

* fix(tts): clear stale highlights across sections and stop sentence flash in word mode

Entering a section now scrubs the TTS highlight from every live view, not just the primary, so the outgoing section's last word no longer stays lit in the preloaded neighbor. reapplyCurrentHighlight no longer redraws the whole sentence during word-mode playback while awaiting the first word boundary.

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

* style(reader): move the mini player progress line to the bottom edge

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

* fix(reader): make the TTS progress bar and scrubber legible in eink mode

Grey tints wash out on e-ink: the mini player track gets a 1px hairline with a solid base-content fill (buffer fill hidden), and the sheet scrubber gets a crisp 1px border marking the track extent.

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

* style(reader): drop the player sheet header label on the main view

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 16:37:59 +02:00
Huang Xin 4527aa277a feat(reader): add TTS speak button to dictionary popup (#4876) (#4957)
Add a speaker button to the dictionary popup/sheet header that pronounces
the current headword. Tapping it speaks via Edge TTS, falling back to the
platform speech engine (Web Speech on desktop/web, native on the mobile
app) when Edge is unavailable.

To speak as soon as possible, a dedicated wordPronouncer bypasses the
reader's TTSController entirely: it never runs EdgeTTSClient.init() (which
wastes a round trip synthesizing "test"), calls EdgeSpeechTTS directly
(whose static MP3 cache makes repeat words instant), and schedules one
chunk on a dedicated Web Audio context isolated from any active read-aloud
session. The context is warmed synchronously inside the click gesture so
playback is not blocked by autoplay policy after the network await.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 06:56:51 +02:00
Huang Xin 6f3b401c24 feat(reader): middle mouse button autoscroll in scrolled mode (#4955)
* feat(reader): add middle mouse button autoscroll in scrolled mode

Middle-clicking a book in scrolled mode on desktop apps plants an anchor
indicator and scrolls with a velocity proportional to the pointer's
distance from it, like browser autoscroll (#4951). A quick click sticks
until the next click, wheel, or Escape; press-move-release scrolls only
while held. Vertical-writing books autoscroll along the horizontal axis.

The middle button's default is suppressed while the feature is armed so
WebView2's native autoscroll cannot double-drive on Windows. A new
Middle-Click Autoscroll toggle in the Scroll settings section (desktop
only, default on) turns it off.

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

* refactor(reader): drop the middle-click autoscroll toggle

Middle-click autoscroll is a common desktop convention and middle click
has no other use in the reader, so it is always enabled on desktop apps
in scrolled mode instead of being a setting.

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

* chore(i18n): translate missing strings across all locales

Fill in translations for 69 keys that landed on main without an i18n
pass (search modes, cloud sync and Google Drive settings, page turn
animation styles, TTS states, Word Lens hints, file browser sorting,
watched-folder auto-import) in all 33 locales, plus the English plural
variants for the search result count.

Keys the scanner would prune (strings it cannot see statically on this
branch) are left untouched.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 05:01:30 +02:00
Huang Xin 75f1fafe9f feat(reader): slide and page curl turn animations (#555) (#4940)
* feat(reader): slide and page curl turn animations (#555)

Add an Animation Style setting (Push, Slide, Page Curl) next to the
Paging Animation switch. Slide moves the turning page over the still
previous or next page like the Apple Books slide; Page Curl folds it
open in 3D so the page underneath is partially visible as it turns.
Both styles track the finger: the page follows a horizontal drag and
commits past halfway or on a flick, or settles back. The page header
and footer stay in place while the page turns.

The styles layer a View Transitions snapshot of the outgoing page over
the live, stationary incoming page, since the pages of one section live
in a single iframe and can never be on screen twice. They work for all
writing modes including vertical-rl, and on engines without the View
Transitions API (older WebViews) the paginator falls back to the
existing push animation, so all platforms keep working page turns.

The paginator changes live in the foliate-js submodule; this bumps the
pointer, wires viewSettings.pageTurnStyle to the renderer turn-style
attribute, and adds browser tests covering slide layering, curl,
vertical-rl, finger tracking with commit and revert, and the push
fallback.

Fixes #555

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

* feat(reader): add WebGL page curl renderer for mesh turn animations (#555)

Grid mesh deformed around a cylinder: content past the fold wraps over
and lands mirrored on top with a whitened page back, transparent where
the page has curled away. Corner grabs start as a steep diagonal pinch
that straightens as the turn completes so the whole page clears by the
end. Groundwork for the Tauri mesh curl; capture and orchestration land
separately.

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

* feat(native-bridge): capture webview region as PNG on macOS and iOS (#555)

New capture_webview_region plugin command returns a binary PNG snapshot
of the calling webview (tauri::ipc::Response, no JSON overhead) for the
mesh page-curl texture. macOS goes through WKWebView
takeSnapshotWithConfiguration via with_webview on the main thread with
a 500ms timeout; iOS snapshots in Swift and hands the PNG across the
JSON-only plugin boundary base64-encoded, decoded back to bytes in
mobile.rs. Windows, Linux, and Android reject for now so the JS side
falls back to the CSS curl.

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

* feat(reader): drive the mesh page curl on Tauri platforms (#555)

Wire the WebGL curl renderer and the native webview capture into page
turns. A MeshCurlTurn controller runs the pipeline per turn: snapshot
the content box, overlay the captured page drawn flat, turn the live
view instantly underneath (the paginator's animated paths all gate on
the animated attribute), then curl the capture away. Backward turns
mirror the fold to the spine edge, matching the layered VT curl's
old-page-recedes choreography.

useMeshPageCurl wraps the view's prev/next so taps, keys, and wheel
turns all curl, and registers a touch interceptor (between the reading
ruler and the fixed-layout swipe) that scrubs the curl from the finger,
committing past halfway or on a flick and otherwise un-curling and
turning back under the overlay. The paginator stays out of the way via
no-swipe while the mesh is active; if the native capture ever fails the
session falls back to the paginator's CSS arc-fold curl and the shared
applyPageTurnAttributes helper restores turn-style.

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

* fix(build): restore iOS builds on Xcode 26.2 with a vendored swift-rs

Swift 6.2's driver no longer honors swift-rs 1.0.7's cross-compilation
style (swift build --arch <host> with per-swiftc -target overrides and
an inherited SDKROOT): plugin sources compile against the wrong
platform's Swift overlays and fail with baffling errors like type
'Bundle' has no member 'main' and extra argument 'privacy' in call.
Upstream swift-rs is unmaintained, so vendor it under packages/swift-rs
via a crates-io patch and build with SPM's first-class --triple/--sdk
flags instead, dropping the leaked SDKROOT so the host-targeted
manifest compile stays clean. Artifacts land in the unversioned-triple
directory now, so the link search path follows.

With --triple, SPM enforces the deployment floor declared in
Package.swift (the old override bypassed it): bump native-bridge to
iOS 15.0, matching the app's deployment target, since StoreKit's
Storefront is used unguarded.

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

* feat(native-bridge): capture webview region on Android via PixelCopy (#555)

Implements the Android side of capture_webview_region so the mesh page
curl works there too. The Kotlin command scales the CSS-pixel rect by
the display density, offsets it by the webview's window position, and
reads the pixels back from the window surface with PixelCopy (API 26+,
the app's minSdk), which includes the hardware-accelerated WebView that
View.draw would miss. PNG encoding runs off the main thread and the
result crosses the JSON plugin boundary base64-encoded, decoded back to
bytes in mobile.rs like iOS.

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

* fix(reader): right the upside-down page curl on iOS (#555)

The renderer oriented its texture with UNPACK_FLIP_Y_WEBGL, which WebKit
ignores for ImageBitmap uploads: on iOS the captured page rendered
upside down, and the mirrored page back read as rotated 180 degrees
instead of the ink-through-paper horizontal mirror Apple Books shows.
Upload unflipped and sample page coordinates directly so no pixel-store
flag is involved.

The page texture in the browser test was only horizontally asymmetric,
which is how the flip slipped through; it now uses four quadrants fed
through the production PNG-blob-to-ImageBitmap path and pins the
vertical orientation. Verified red/green by running the suite on
Playwright WebKit, which reproduces the iOS behavior.

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

* fix(reader): curl the whole page including header, footer, and margins (#555)

The mesh curl captured only the margin-inset content box, leaving the
running header, footer, and page margins static while just the text
column turned. A physical page turn takes the whole sheet with it, as
Apple Books does, so the capture and overlay now span the full reader
cell. The overlay mounts above the in-cell header (z-10) and footer, so
the static copies never show through the turning page.

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

* fix(reader): gate layered View Transition turns and slide from a capture instead (#555)

iOS 18 WebKit ships document.startViewTransition but crashes the WebContent
process when a page-turn transition snapshots the reader, so the mere
presence of the API is not enough for the layered slide/curl turns. Require
nested view-transition groups (Chrome/WebView 140+) as the marker of a
mature engine before setting turn-style on the renderer.

Engines that fail the check no longer lose the slide on Tauri: the mesh
curl's capture pipeline generalizes to CapturedPageTurn and now also drives
a flat slide overlay (capture the outgoing page, turn instantly underneath,
translate the captured page out toward the spine, mirrored for backward
turns), clipped to the content box with an edge shadow like the VT slide.
On the web, engines without full support fall back to push and the
Slide/Page Curl options are hidden from the Animation Style select; a
synced slide/curl setting from another device reads as Push there.

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

* fix(reader): make the Android page curl start instantly (#555)

The Android capture encoded a full-density PNG: 1080x2400 on a 3x
Xiaomi 13 took ~1.5s per turn, so the page sat frozen long enough to
read as the curl not working at all. Encode JPEG instead (the page is
opaque) and cap the destination bitmap at 2x CSS pixels - PixelCopy
scales into a smaller bitmap for free and the moving page stays sharp.
Measured on device over CDP: the capture invoke drops from 1550ms to
34ms and the curl overlay mounts 132ms after the tap.

The JS side stops hardcoding an image/png blob type and lets the
decoder sniff the platform's actual format.

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

* perf(reader): encode iOS page-curl captures as capped JPEG (#555)

Apply the Android speedup to iOS: encode the snapshot as JPEG (the
page is opaque) off the main thread, and cap it at 2x CSS pixels via
WKSnapshotConfiguration.snapshotWidth on 3x screens, cutting both the
encode time and the base64 payload crossing the JSON plugin boundary.
The JS side already sniffs the image format from the bytes.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 20:49:57 +02:00
Huang Xin 843ab3448b feat(tts): keep TTS playing when the book is closed (#4941)
* refactor(tts): controller owns its foliate TTS instance and emits lifecycle events

view.close() nulls view.tts, so the controller keeps its own handle
(mirrored to view.tts while attached; reads prefer the public mirror).
state becomes an accessor that dispatches tts-state-change on a
microtask, and terminal conditions (end of content, error exhaustion)
fire an explicit tts-session-ended: 'stopped' is a transit value that
occurs on every paragraph advance and must never be read as death.

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

* feat(tts): make TTSController detachable from the reader view

detachView enters headless mode: layout-dependent work is guarded, the
dead hook's preprocess/section-change closures are severed, and text
supply continues through created documents while position events keep
flowing. attachView adopts a new view without touching in-flight audio,
re-seeding the fresh text instance from the old cursor AT the
synchronous swap point (auto-advance during async prep would otherwise
replay a paragraph) and aborting via an attach epoch when a detach
supersedes it.

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

* feat(tts): move media session ownership to a session-scoped bridge

ttsMediaBridge binds directly to the controller (metadata per mark,
clamped position state, transport handlers, the silent keep-alive
element) so the lock screen keeps working when the reader hook is
unmounted. The hook's last-writer-wins handler effect and its
per-render re-registration are gone; the panel now derives isPlaying
from the controller's state channel, so lock-screen transport keeps
the in-reader UI truthful. useTTSMediaSession had no consumers left
and is removed.

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

* feat(tts): add hash-keyed TTS session manager with sleep timer and headless persistence

Sessions key by book hash (bookKey is regenerated per open), the
playback-state relay dedupes transit stopped values so paragraph
advances never flicker followers, and terminal handling rides the
explicit tts-session-ended event. The sleep timer survives reader
unmount, and headless positions persist through the book config on
disk (view/progress stores are cleared on close and reopen loads
from disk).

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

* feat(tts): keep TTS playing across book close and reattach on reopen

Back-to-library and Android back dispatch tts-close-book (detach when
the session is not terminated: transit stopped states during chapter
transitions must not kill it); quit and window-destroying closes keep
the hard tts-stop so the foreground service tears down with the
webview. The unmount cleanup transfers ownership to the manager
instead of shutting down, covering deep-link book switches and
split-view pane closes. Mounting a book adopts a matching background
session once the view is ready (primary pane only) and stops a
different book's session unless it is still mounted elsewhere. The
sleep timer moves to the manager and a one-time toast announces the
first background continuation.

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

* feat(tts): now-playing bar in the library for background sessions

Floating pill above the shelf while a TTS session outlives its
reader: cover, title, sleep-timer countdown, play/pause following the
manager-relayed playback channel, and a hard stop. Tapping the body
reopens the book in the SAME window regardless of the new-window
preference, since the session is a per-webview singleton. Deleting
the playing book stops the session before its data is cleared. The
bookshelf reserves scroll clearance via a --now-playing-inset var the
bar sets while visible.

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

* fix(tts): make the header close button background-eligible

The header X routes through handleCloseBook (onCloseBook), not
handleCloseBooksToLibrary, so the sticky eligibility ref never got
set and closing a book from the header hard-stopped a live TTS
session. Replace the ref with an explicit keepTTSAlive parameter on
saveConfigAndCloseBook/handleCloseBooks: back-to-library, Android
back, and pane closes pass true; beforeunload, quit-app, and window
close invoke handleCloseBooks with an event object, which coerces to
a hard stop. This also removes the stickiness where one background
close would have made a later quit detach instead of stop.

Verified live in Chrome dev-web: close from the header keeps audio
playing with the now-playing bar shown; reopening reattaches the
same session (generation numbering continues); opening a different
book stops it.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 17:38:57 +02:00
Huang Xin 42f9b8fe3c feat(tts): gapless Web Audio playback engine for Edge TTS with chapter timeline and seek (#4931)
* feat(tts): add PCM speech-bounds detection for sentence audio trimming

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

* feat(tts): add WSOLA time-stretch for pitch-preserved playback rate

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

* feat(tts): add sentence duration store with per-voice speaking-rate calibration

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

* refactor(tts): serve edge audio as ArrayBuffer with in-flight fetch dedup

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

* feat(tts): add WebAudioPlayer with gapless chunk scheduling and backpressure

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

* feat(tts): play edge TTS through gapless Web Audio pipeline

Replaces the per-sentence audio element with trimmed, time-stretched
buffers scheduled on the shared AudioContext. Marks dispatch at audible
time so schedule-ahead cannot run foliate's cursor past the voice; a
decode failure or missing audio skips the chunk instead of wedging the
session; pause and resume ride context suspend and resume with no iOS
rewind hack; the object-URL cache is gone.

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

* feat(tts): add section timeline with measured and estimated sentence durations

Includes the foliate-js submodule bump for the getSentences export
(fork branch feat/tts-get-sentences; fork PR must merge before this
lands so the pinned SHA resolves).

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

* feat(tts): expose section playback position and sentence-snapped seeking

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

* feat(tts): surface playback position and seek in the media session

Position state is clamped, never skipped, so the lock-screen scrubber
stays live when estimates overshoot; seekto units map per backend
(native ms, web seconds). The AudioContext warms up in the tts-speak
gesture path before any await, and the silent keep-alive element now
runs on all platforms so desktop hardware media keys survive the
removal of the per-sentence audio element.

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

* feat(tts): add seekable chapter progress bar to the TTS panel

The scrubber joins the transport cluster with a thin range-xs track and
flanking tabular time labels so it cannot be misgrabbed for the chunky
rate slider (which persists a global setting). States: reserved
disabled slot until the lazy timeline lands, persists across chapter
transitions, optimistic thumb with failure toast, monotonic position,
tilde-prefixed estimated totals, sentence-event updates under e-ink.
The popup grows only when a timeline-capable client is active.

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

* docs: record deferred TTS listening-engine follow-ups

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

* docs: record background TTS decoupling design decisions

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

* feat(tts): slim the panel scrubber to a native track with remaining time

Match the footer Jump to Location slider (plain native range: thin
track, small thumb) instead of the chunky daisyUI pill, show remaining
time with a minus prefix on the right, and drop the This chapter
caption. Popup height shrinks accordingly. Verified live in Chrome.

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

* chore: pin foliate-js to merged main with getSentences export

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

* fix(tts): catch autoplay rejection from the keep-alive element

Running the silent keep-alive on all platforms exposed an un-awaited
play() that headless Chromium rejects without a user gesture, failing
CI on unhandled rejections while every test passed. The keep-alive is
best-effort; the production path is gesture-qualified.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 10:50:22 +02:00
Huang Xin 4dbe9cc9f1 fix: Sentry production hardening (release/OS tags, unhandled-rejection & render-loop guards) (#4929) 2026-07-05 08:50:36 +09:00
Huang Xin 2b524439bf fix(reader): keep running header/footer readable over light PDFs in dark mode (#4901) (#4911)
The running section title and page-number footer used text-neutral-content,
which is a light color in dark mode. A light-mode PDF stays white under a dark
theme (invertImgColorInDark defaults to false), so the light text sat on the
white page and became unreadable.

Blend the header/footer text against whatever is behind it using
mix-blend-mode: difference with a fixed white/75 anchor, so it inverts to dark
on a light page and stays light on a dark margin. white/75 matches the former
neutral-content brightness over the dark theme, so reflowable books look
unchanged. E-ink keeps its plain base-content text; StatusInfo and the sticky
progress bar manage their own colors and are left untouched.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:14:02 +02:00
Huang Xin 4b2c5f93ab fix(window): keep Linux window opaque so it can't turn invisible (#3682) (#4904)
On Linux the window was created fully transparent to draw rounded corners
(#1982), but on WebKitGTK a transparent window composites as transparent
whenever its web process is too busy to repaint damaged regions (for example
during a library backup). Interacting with the app then makes it appear to
turn invisible, showing the desktop through the window.

Make the window opaque everywhere: the main window in lib.rs and the
reader/extra windows in nav.ts. Drop the rounded-window treatment
(hasRoundedWindow=false) so no rounded 1px border floats on the now square
opaque window, and give the Linux loading placeholders a solid background.
An opaque window retains its last painted frame instead of going invisible;
the tradeoff is square corners on Linux.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 21:41:26 +02:00
Huang Xin fd8fbb178c fix(reader): apply page margin changes live on all platforms (#4898) (#4900)
Adjusting the top, bottom, left, or right page margin had no visible
effect until an unrelated setting (e.g. Show Header) was toggled.

The BooksGrid perf refactor (#4562) memoized the derived view/content
insets on the ViewSettings object identity. saveViewSettings mutates
ViewSettings in place (same reference), so the memo never recomputed on
a margin edit and the new margin never reached the paginator. Left and
right margins were always stale; top and bottom only refreshed when the
header/footer visibility (an effect dependency) changed, which is why
toggling the header appeared to apply a pending change.

Extract the inset derivation into useContentInsets and memoize by the
resolved numeric values instead of the object reference: identical
numbers across a page turn keep a stable reference (no re-render storm),
while a changed margin yields a new one that propagates to the renderer.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 20:24:34 +02:00
Huang Xin 849f151166 fix(ios): release screen brightness on background so auto-brightness resumes (#4885) (#4896)
On iOS `UIScreen.main.brightness` is a global device setting, not a
per-window one like Android. Once Readest overrode it (brightness slider
or left-edge swipe gesture) the override survived backgrounding, so
swiping to the home screen left the system stuck at an extreme level and
ambient auto-brightness appeared locked. The only cleanup lived in the
reader's unmount effect, which never runs when the app is merely sent to
the background, and the native `brightness < 0` "release" branch was a
no-op stub.

Native (NativeBridgePlugin.swift): capture the system brightness before
the first override, restore it on `appDidEnterBackground` so iOS resumes
auto-brightness, and re-apply the app's value on `appWillEnterForeground`.
Implement the negative-value release path (restore + forget state),
mirroring Android's BRIGHTNESS_OVERRIDE_NONE.

JS (useScreenBrightness hook, replacing the racy inline Reader effect):
apply the manual brightness while reading, release via
setScreenBrightness(-1) on unmount and when "System Screen Brightness" is
toggled back on. Excludes screenBrightness from deps so live slider/gesture
drags don't flash release-then-reapply.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 18:23:26 +02:00
Huang Xin 5bc8eda50b feat(proofread): editable Find pattern and per-rule enable/disable toggle (#4859) (#4888)
* fix(proofread): keep disabled book rules visible in the manager list

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

* feat(proofread): add per-rule enable/disable toggle in the manager

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

* feat(proofread): allow editing Find pattern, regex, and case on existing rules

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

* i18n: add proofread edit and toggle strings

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 15:38:53 +02:00
Huang Xin 70bad93ebf feat(reader): select word on double-click and run instant action or toolbar (#4846)
Double-click (mouse) or touch double-tap on a word now selects that word,
like a long-press selection, then runs the configured instant quick action
or raises the annotation toolbar when none is set.

The iframe posted iframe-double-click but nothing consumed it, so a touch
double-tap did nothing (Android has no native double-tap word-select; on
desktop the browser already selects the word natively via the pointerup
path).

- sel.ts: getWordRangeAt expands a caret to its word-like segment via
  Intl.Segmenter (CJK and Latin); getWordRangeFromPoint resolves the caret
  at a point and delegates.
- useTextSelector: handleDoubleClick selects the word and routes through the
  existing makeSelection flow (guarded so the programmatic selectionchange
  echo is ignored). It no-ops when a native selection already exists, so the
  desktop double-click path is not double-fired.
- Annotator: consume iframe-double-click, resolve the visible section
  doc/index, and set pointerDownTimeRef to 0 so the deliberate double-tap
  bypasses the touch long-press hold gate before the instant action fires.

Tests: unit coverage for the word-range helpers and the selection routing
(plus the desktop guard), and an Android CDP e2e for the double-tap gesture
on a real device.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 19:47:09 +02:00
Huang Xin 7da41a65ad feat(widget): add mobile home-screen reading widgets (#1602) (#4842)
Add a resizable home-screen widget on iOS and Android showing recent
in-progress books with cover, reading progress, and tap-to-open.

- One responsive widget: Android resizable 1x1 to 4x3 (one book per
  column, up to 3); iOS Small/Medium/Large families. Covers are cropped,
  rounded, with a percent badge and a progress bar (baked into the bitmap
  on Android, SwiftUI overlays on iOS).
- TTS controls (previous, play-pause, next) appear in 2+ row sizes when
  TTS is active, wired to the existing media session. Reading progress
  stays live during background TTS via a fraction computed from the baked
  offline locations.
- Publishes a snapshot plus downsized cover thumbnails to the iOS App
  Group and Android SharedPreferences through a new update_reading_widget
  native-bridge command; refresh is debounced and driven by library and
  progress changes, TTS, and app backgrounding.
- Tapping a cover opens readest://book/{hash}, switching the reader in
  place when one is already open.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 18:03:16 +02:00
Huang Xin 7e78f80e14 feat(sync): Google Drive cloud sync + premium Third-party Cloud Sync section (desktop) (#4821)
* feat(sync): add Google Drive file-sync provider core

Second FileSyncProvider for the merged provider-agnostic file-sync engine,
behind the provider seam. This is the CI-testable core only: no settings UI
and no platform OAuth runners yet (those land in later phases).

- GoogleDriveProvider over the Drive v3 REST API: id-addressed path
  resolution with a per-instance id cache, create-then-name uploads, real
  idempotent ensureDir, files.list pagination, Retry-After-aware 429/5xx
  backoff, per-path folder-creation locks with deterministic duplicate
  collapse, stale-id eviction, and FileSyncError mapping (403 split into
  rate-limit vs permission).
- DI OAuth layer: pkce, parseRedirect (redirect-target + CSRF state),
  reverseDnsRedirect, tokenStore (iOS client, no secret), oauthFlow.
- PersistedDriveAuth with single-flight token refresh; keychain-backed
  token store with no ephemeral fallback for the refresh token; account
  label via about.get.
- providerRegistry (backend kind to provider) and buildGoogleDriveProvider
  assembly.
- Shared transport-agnostic provider semantic contract, run against both
  WebDAV and Drive.
- Keyed secure-KV bridge contract (set/get/clear_secure_item); the native
  keychain implementation lands with the desktop OAuth slice that first
  exercises it.

Adapted from ratatabananana-bit/Readest-google-drive-mod-patcher (AGPL-3.0)
with the author's explicit permission.

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

* feat(sync): multi-provider file-sync settings + sync-state foundation

PR2 foundation for a second file-sync backend (Google Drive). The
behaviour-sensitive reader-hook and Sync-now form generalization land in
PR3 alongside OAuth, where Drive actually connects and the multi-provider
paths can be exercised and live-verified (and the extracted form gets its
second consumer, avoiding a single-use abstraction).

- GoogleDriveSettings type (mirrors WebDAVSettings minus URL/credentials/
  rootPath, plus accountLabel) wired into SystemSettings, with
  DEFAULT_GOOGLE_DRIVE_SETTINGS in the defaults.
- googleDrive.deviceId + googleDrive.lastSyncedAt added to the backup
  blacklist so device-local sync identity / cursors never restore onto
  another device. Covered by the existing backup-settings test.
- Generalize webdavSyncStore into fileSyncStore: per-backend progress keyed
  by provider kind, plus a global library-sync mutex (beginSync returns
  false when another backend already holds the lock) since every backend's
  syncLibrary mutates the same local library. Migrate WebDAVForm and
  IntegrationsPanel to the keyed API; WebDAV behaviour is unchanged.

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

* feat(native-bridge): add keyed secure key-value store commands

A generic, keyed secret store over the same OS keychain backends as the
sync passphrase (set/get/clear_secure_item), so secrets that aren't the
single sync passphrase get the same XSS-free cross-launch persistence
without each needing its own native command. The Google Drive OAuth token
store (PR1's KeychainTokenPersistence) is the first consumer; a future
cloud provider's refresh token reuses it.

- Desktop (macOS/Windows/Linux): keyring-core, keyed by the item key as
  the entry account under the existing "Readest Safe Storage" service.
- Android: EncryptedSharedPreferences (a dedicated readest_secure_items_v1
  file, the item key as the pref key).
- iOS: Security framework Keychain (kSecClassGenericPassword, dedicated
  service, the item key as kSecAttrAccount).

Registered in the plugin invoke handler + build COMMANDS + default
permission set (autogenerated permission files regenerated; the passphrase
entries are preserved). The TS bridge wrappers shipped in PR1.

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

* feat(sync): desktop Google Drive OAuth runner + connect flow

The desktop half of Drive sign-in: open consent in the system browser, capture
the reverse-DNS redirect the OS routes back, and exchange the code for tokens.

- oauthDesktop.ts: runDesktopDeepLinkOAuth wires the DI OAuth flow to the
  desktop mechanics (open default browser, capture via single-instance /
  onOpenUrl, cold-browser fallback after a grace period, hard deadline). Fully
  headless-unit-tested via injected deps.
- spawn_fresh_browser.rs (+ registration, Windows-only winreg dep): the cold
  browser the runner falls back to when the user's already-running browser
  snapshotted protocol associations before the scheme was registered (a
  Windows-specific failure). Resolves the default browser from the registry and
  spawns it cold with an isolated --user-data-dir; a no-op on macOS/Linux where
  the default-browser open already routes the redirect. Pure helpers unit-tested.
- connectGoogleDrive.ts: run the platform OAuth runner, persist the token
  (fail-loud — Drive is not reported connected if the refresh token does not
  save), and resolve the account label via about.get (best-effort).

OAuth runner adapted from ratatabananana-bit/Readest-google-drive-mod-patcher
(AGPL-3.0) with the author's permission. Scheme registration + the ingress
redirect filter + the Drive connect UI land in the following commits; live
desktop verification follows once the official Google client id is provisioned.

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

* feat(sync): filter Google OAuth redirects out of the deep-link ingress

The reverse-DNS OAuth redirect (com.googleusercontent.apps.<id>:/oauthredirect)
is delivered through the same single-instance / onOpenUrl channels as book-file
deep links. Without a filter the book-import consumer would treat the redirect
URL as a file path to open. Drop it at the ingress source (useAppUrlIngress)
before the app-incoming-url broadcast, so no consumer ever sees it; the Drive
sign-in runner still captures it via its own listeners.

isGoogleOAuthRedirectUrl matches the scheme prefix (not a specific client id),
so it stays correct regardless of which client is baked into the build.

Note: registering the scheme in tauri.conf.json (so the OS routes it back to the
app) needs the official Google client id, which is a provisioning prerequisite.

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

* feat(sync): bake the official Google Drive OAuth client id + redirect scheme

Provisioned the Readest Google Cloud OAuth client (iOS application type, no
secret, drive.file scope). Bake the client id as the default in
getGoogleClientId (overridable via NEXT_PUBLIC_GOOGLE_CLIENT_ID for forkers,
who must also regenerate the manifest schemes) and register the derived
reverse-DNS redirect scheme com.googleusercontent.apps.<id> in tauri.conf.json
(desktop + mobile deep-link) so the OS routes the OAuth redirect back to the
app. The client id is a public client identifier, not a secret.

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

* feat(sync): Google Drive connect UI + shared FileSyncForm

Make Drive usable from Settings, and extract the now-two-consumer sync controls.

- FileSyncForm: the provider-agnostic sync controls (sub-toggles, conflict
  strategy, manual "Sync now" with progress + result toast), parameterised by
  backend kind and building the provider through the registry. Extracted from
  WebDAVForm now that a second consumer exists. WebDAVForm keeps its
  URL/credentials connect panel + browse pane and renders FileSyncForm for the
  sync section; behaviour is unchanged (WebDAV "Sync now" goes through the same
  provider via the registry).
- GoogleDriveForm: an OAuth connect panel (Connect -> runGoogleDriveConnect ->
  store token in keychain -> "Connected as <email>"; Disconnect) + FileSyncForm.
- googleDriveConnect.ts: assemble the env client id + keychain + desktop runner
  into connectGoogleDrive/disconnectGoogleDrive for the UI.
- IntegrationsPanel: a "Google Drive" row + sub-page, shown only on desktop
  (mobile OAuth runners land in later phases).

Reader-side auto-sync (generalizing useWebDAVSync) is a follow-up; manual
"Sync now" already exercises the full Drive stack. Full suite 6412 green.

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

* feat(settings): unified Third-party Cloud Sync section (exclusive provider)

Group WebDAV + Google Drive into a new "Third-party Cloud Sync" section and make
them mutually exclusive — only one cloud provider syncs the library at a time.

- New unified "Cloud Sync" sub-page (CloudSyncForm): a provider picker (radio,
  the AIPanel mutually-exclusive pattern) on top, the shared FileSyncForm sync
  options below for whichever provider is active. Google Drive is offered only on
  desktop; on mobile the page is WebDAV only and the picker is hidden.
- withActiveCloudProvider helper: enabling one provider disables the other in one
  save. Both panels' connect/activate paths use it. Unit-tested.
- WebDAVForm / GoogleDriveForm refactored into embeddable panels (the unified
  page owns the header). Drive gains a "configured but inactive" state so
  switching back re-activates it without a fresh sign-in; explicit Disconnect
  clears the keychain token.
- IntegrationsPanel: remove the two separate WebDAV / Google Drive rows from
  "Reading Sync" (now KOReader Sync / Readwise / Hardcover only); add the
  Third-party Cloud Sync section with one Cloud Sync row (status = active
  provider). Old webdav/gdrive deep-links route to the unified page.

Also removes the temporary Drive concurrency probe (the upload already runs at
the intended concurrency 4; the probe confirmed it).

Full suite 6416 green; lint + format clean.

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

* feat(reader): auto-sync the active cloud provider while reading

Generalize the reader sync hook from useWebDAVSync to useFileSync so the active
third-party cloud provider (WebDAV OR Google Drive) syncs per-book while reading
— pull-on-open, debounced push on progress/booknote changes, cover/file upload —
not just via the manual "Sync now" in settings.

Since the providers are mutually exclusive, the hook drives exactly the one
enabled backend, built through the provider registry. The build is async (the
Google Drive provider probes the OS keychain), so the engine lives in state and
the pull-on-open waits for it; switching providers mid-session resets the
per-book locks. The engine is keyed on connection-relevant settings so a
lastSyncedAt write doesn't re-probe the keychain. deviceId / lastSyncedAt now
write the active provider's settings slice; the auth-failed toast is
provider-neutral; the per-book events are renamed *-file-sync.

WebDAV reader-sync behaviour is unchanged. Full suite 6416 green.

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

* feat(settings): surface cloud providers in the section with inline switch

Show WebDAV + Google Drive as separate rows in the Third-party Cloud Sync
section (instead of one "Cloud Sync" row), so both providers are visible and the
active one can be switched right there.

- CloudProviderRow: a trailing radio makes a provider the single active sync
  target inline (enabled only when it's already configured — WebDAV creds / a
  Drive token); the row body / chevron opens its config sub-page (connect, sync
  options, disconnect). Status reads Active / Configured / Not connected, with a
  Syncing… indicator.
- Each provider drills into its own sub-page again (WebDAV / Google Drive),
  rendering the embeddable panel under a SubPageHeader; the brief unified
  CloudSyncForm picker page is removed (its old deep-link maps to Google Drive).
- Switching stays exclusive via withActiveCloudProvider; an inline switch trusts
  the stored credentials/token (no re-validate / re-OAuth).

Full suite 6416 green; lint + format clean.

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

* feat(sync): gate third-party cloud sync behind a premium plan

WebDAV + Google Drive sync is now a premium feature: available on any paid plan
(Plus, Pro, or Lifetime), not on free.

- isCloudSyncInPlan(plan) helper (mirrors isEmailInPlan; plus/pro/purchase).
- IntegrationsPanel: free users see the Third-party Cloud Sync section with an
  upgrade row ("Available on Plus, Pro, or Lifetime") that opens the plans page
  instead of the provider rows; the cloud-sync deep-links are gated too (waiting
  for the plan to load before deciding).
- useFileSync: the reader's auto-sync only runs on a paid plan, so a downgraded
  user's sync stops even if a provider's enabled flag lingers.

Full suite 6418 green; lint + format clean.

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

* fix(sync): escape backslashes in Drive query literals (CodeQL)

escapeDriveLiteral escaped single quotes but not the backslash escape
character, so a file name containing a backslash (or ending in one) could
break out of the single-quoted Drive `files.list` query literal and malform
the query. Escape backslashes first, then single quotes, so the backslashes
added for the quotes are not doubled.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 10:28:08 +02:00
Huang Xin dced42912f feat(reader): filter exported annotations by color and style (#4801) (#4806)
Add a Filter section to the annotation export dialog so users who color-code
highlights (e.g. red for important, yellow for difficult words) can export only
selected colors and styles.

The selection is stored as exclusions in NoteExportConfig, so an empty filter
exports everything and any color or style added later is included by default.
A new pure helper filterExportGroups applies the filter to both the default
formatter and the custom-template paths, and only filters a dimension when at
least two distinct values are present so a hidden row never silently drops notes.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 11:44:34 +02:00
Huang Xin 01a54238ae fix(annotator): clean up empty highlight on annotation cancel (#4791) (#4804)
Clicking "Annotate" on a selection eagerly creates a highlight (with an
empty note) as the anchor for the note being typed, so the selection stays
visible while the NoteEditor is open. Cancelling the note instead of saving
left that empty highlight behind: it leaked into the config DB, showed as a
stale card in the Booknotes list, and left a phantom yellow highlight.

handleHighlight now returns the created BookNote only when it pushes a new
record (null when it restyles an existing highlight, which predates the flow
and must survive a cancel). handleAnnotate tracks that id via the new
notebookNewHighlightId store field; cleanup is keyed on the id, not the cfi,
so a fresh selection that collides with an existing highlight's cfi can't
wrongly delete it.

removeEmptyAnnotationPlaceholder tombstones the tracked placeholder only when
it still has no note text, and the Notebook tears its overlay down. Cleanup is
presentation-driven: an effect removes the placeholder whenever the creation
editor stops being shown (Cancel, Escape, overlay, close, swipe, navigate),
plus a second effect for book-switch and reader-close. Save survives the guard
and clears the tracked id.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 09:51:36 +02:00
Huang Xin 0b4993407c feat(reader): add contrast option to PDF/CBZ view menu (#4800)
Add a Contrast stepper to the reader View menu for fixed-layout
(PDF/CBZ) documents. It increases and decreases page contrast via a
CSS filter on the rendered page images, applies to the whole book,
and is stored per-book (local to the current document).

The filter is built in applyFixedlayoutStyles by combining any
dark-mode invert with the contrast amount into a single filter
declaration. Persisted with skipGlobal so it never touches global
view settings, and added to FoliateViewer's effect dependencies so
the change re-applies across all rendered pages.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 05:16:36 +02:00
Huang Xin 370a516620 feat(reader): glue non-breaking spaces after short Russian words (#4769) (#4798)
Russian typography requires short function words (prepositions,
conjunctions, particles) to never hang at the end of a line. Add an
`nbsp` content transformer that inserts U+00A0 after such words so they
stick to the following word. The source file is never modified.

The transformer is language-driven via an NBSP_LANGUAGES registry keyed
by language code (only `ru` is configured today), so adding another
language is a single entry. It runs only for matching books and rewrites
text between tags with a regex, leaving tags, attributes, and the XML
declaration intact. Runs after whitespace normalization so the inserted
spaces are not stripped under the override-layout setting.

The space-to-NBSP swap is length-preserving (both are single UTF-16 code
units), so DOM character offsets and CFIs stay valid for every word
before and after the transform; tests enforce this invariant.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 04:52:32 +02:00
Huang Xin 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>
2026-06-25 09:49:30 +02:00
Huang Xin 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>
2026-06-25 09:33:55 +02:00
Huang Xin 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>
2026-06-25 04:46:16 +02:00
Huang Xin 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>
2026-06-24 19:23:11 +02:00
Huang Xin 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>
2026-06-24 17:23:09 +02:00
Huang Xin 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>
2026-06-23 18:28:42 +02:00
Huang Xin 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>
2026-06-22 18:12:39 +02:00
Huang Xin 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>
2026-06-22 17:51:23 +02:00
Huang Xin 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>
2026-06-22 08:22:00 +02:00
Huang Xin 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>
2026-06-22 04:57:04 +02:00
Huang Xin 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>
2026-06-21 19:03:24 +02:00
Huang Xin 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>
2026-06-21 19:01:04 +02:00
Huang Xin 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>
2026-06-20 06:28:08 +02:00
Huang Xin 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>
2026-06-20 02:58:47 +02:00
Huang Xin 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>
2026-06-20 01:41:16 +02:00
Huang Xin dd53e52453 chore: only show the current position item in TOC and update agent memories (#4665) 2026-06-19 13:17:09 +02:00
Huang Xin 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>
2026-06-19 12:37:37 +02:00
Huang Xin 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>
2026-06-19 10:04:34 +02:00
Huang Xin 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>
2026-06-19 07:47:29 +02:00
Huang Xin 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>
2026-06-18 16:03:43 +02:00
Huang Xin 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>
2026-06-18 15:37:46 +02:00