* fix(macos): place traffic lights via Tauri trafficLightPosition
Replaces the cocoa private-API positioning that drove traffic light placement through IPC with Tauri's supported trafficLightPosition window option, which routes through wry's macOS API and stays correct across versions including macOS 26 (Tahoe).
Position is now declared once at window creation: WebviewWindowBuilder.traffic_light_position in src-tauri/src/lib.rs for the initial main window, and trafficLightPosition on new WebviewWindow(...) in utils/nav.ts for reader windows and the recreated main window. The reader path mattered — those windows used to rely on the cocoa hack to place buttons after the on_window_ready hook fired, so any path that bypassed it left the buttons in AppKit's overlay default position (off-screen on macOS 26 until a resize).
The IPC surface narrows accordingly. set_traffic_lights now takes only visible: position is no longer a parameter and the WINDOW_CONTROL_PAD_X/Y static muts go away; setTrafficLightVisibility drops its position arg in trafficLightStore; useTrafficLight and HeaderBar drop their hard-coded { x: 10, y: 20 } magic numbers. position_traffic_lights stops touching the per-button NSWindowButton frames entirely and only collapses or restores the title-bar container view to hide / show buttons during reader chrome auto-hide. A short-circuit on the no-op transition keeps the cocoa setFrame from racing AppKit's own traffic-light tracking on every IPC call.
useTrafficLight stays — it still owns full-screen visibility synchronisation, the auto-hide visibility toggle, and feeds isTrafficLightVisible to the self-drawn <WindowButtons /> in the auth, library, OPDS, reader-sidebar, and user headers. None of those have an equivalent in the new declarative API. Only its 'where do the buttons sit' responsibility was moved out.
A single named constant TRAFFIC_LIGHT_RESTORE_Y_INSET is left behind in traffic_light.rs, used solely by the visible: false → true restore path to recompute the title-bar container height. It must agree with the y component of the two declarative trafficLightPosition values; a doc comment makes that contract explicit. Caching each window's natural title-bar height before the first collapse would let us delete the constant entirely, but the per-window state machine that requires is not worth the win for a single number.
y is tuned by eye to 24 to vertically center the buttons inside readest's ~48px header bar on macOS 26.1.
* fix(macos): center traffic lights from live AppKit offset, no version check
Restores the pre-PR cocoa-driven positioning that worked on macOS 15
while keeping the macOS 26 fix this PR was originally about: the
plugin owns `position_traffic_lights`, which now sizes the title-bar
container *and* sets each window button's frame.origin on every
on_window_ready / resize / theme-change / full-screen-exit event. Tao's
runtime `inset_traffic_lights` never fires (we never declare
`trafficLightPosition` or call `set_traffic_light_position`), so there
is no second code path fighting us on drawRect.
The y inset that visually centers the close button is computed at
runtime as
y = (header_height - button_height) / 2 + button_origin_y
where `button_origin_y` is the close button's natural rest position
inside the title-bar container. Apple shifted that rest position by
~2pt on macOS Tahoe (26), so the same formula yields y=22 on macOS 15.6
and y=24 on macOS 26.1 with a 48px header — no `NSProcessInfo` lookup
and no hardcoded per-OS offset. The natural origin.y is read once and
cached via `OnceLock` so any post-resize autoresize that AppKit might
apply doesn't feed back into the centering math.
Frontend plumbing: `set_traffic_lights` IPC now carries `headerHeight`;
the zustand store remembers it across visibility toggles; the
`useTrafficLight` hook accepts a header ref, mirrors `ref.current`
into local state (so the effect re-runs when LibraryHeader's
conditional render flips the ref from null to the live node), measures
the border-box height on mount, and observes via ResizeObserver to
re-push on responsive breakpoint / safe-area changes. LibraryHeader,
sidebar Header, OPDS Navigation, and the reader HeaderBar each pass
their own ref so y is computed against the chrome each page actually
renders.
Library header is normalised to h-[44px] desktop to match the reader's
h-11 and drops the `-2px` macOS marginTop workaround, since the runtime
centering removes the need for it.
---------
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
On iOS the system text-selection menu (Copy / Look Up / Translate /
Share) appeared on top of Readest's annotation toolbar. The previous
workaround removed and re-added the selection range on a timer
(makeSelectionOnIOS) to shake the menu off — flaky on iOS 16 and on the
first long-press of a word.
Suppress the menu natively instead, in the native-bridge iOS plugin.
ContextMenuSuppressor swizzles WKContentView so non-editable web
selections produce an empty menu that is never presented:
* editMenuInteraction(_:menuForConfiguration:suggestedActions:) — the
UIEditMenuInteraction delegate WebKit uses to build the menu on
iOS 16+ (the menu users actually see on modern iOS).
* presentEditMenu(with:) — a present-time backstop.
* canPerformAction(_:withSender:) — the legacy UIMenuController gate
for iOS 15 and earlier.
Editable HTML fields keep their native menu (Paste / Select All still
work) via a cut:/paste: probe. Text selection and drag handles are
unaffected, so the annotation toolbar still triggers.
With suppression handled natively, makeSelectionOnIOS is removed and iOS
selections take the same path as desktop.
Closes#4218
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hand selected words off to the platform's native dictionary surface
when the user opts into the new "System Dictionary" entry under
Settings → Languages → Dictionaries. The setting is exclusive: enabling
it disables all other providers (and vice versa) so the in-app lookup
button either always opens the popup or always invokes the OS — no
mixed states.
Per platform:
- macOS: AppKit's -[NSView showDefinitionForAttributedString:atPoint:]
via a top-level Tauri command in src-tauri/src/macos/system_dictionary.rs.
Anchored at the selection's bottom-center (CSS pixels mapped into
NSView coords), so the inline Lookup HUD appears just below the
highlighted text without raising Dictionary.app to the foreground.
- iOS: UIReferenceLibraryViewController presented as a half-detent
pageSheet on iPhone (medium → large drag-to-expand) and as a
formSheet on iPad. Implemented in the native-bridge plugin.
- Android: ACTION_PROCESS_TEXT intent with EXTRA_PROCESS_TEXT_READONLY,
dispatched without createChooser so users get the standard system
disambiguation dialog with "Just once / Always" buttons. Reports
unavailable=true when no app handles the intent so the TS layer can
silently skip rather than open an empty chooser.
Web/Linux/Windows hide the row entirely. The provider is a sentinel —
the registry filters it out of the popup tab list (it has no in-popup
UI) and the annotator's handleDictionary checks isSystemDictionaryEnabled
to dispatch directly to the native bridge before opening the in-app
DictionaryPopup.
The library sync lane (useBooksSync) only runs while the library page is
mounted. While a reader stays open on one device, in-reader auto-sync
pushes `configs` but never re-pushes the `books` row, so other devices'
library pull-to-refresh keeps showing stale reading progress until the
source reader is closed.
useProgressSync.pushConfig now also forwards the in-memory library Book
through the books lane after pushing the config. useProgressAutoSave has
already merged config.progress into that Book via saveConfig, so the
books push carries the up-to-date progress.
Fixes#4198
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The sync-conflict dialog had two issues with servers other than KOReader
(e.g. Kavita's KOReader-compatible sync endpoint):
- "This device" preview rendered a bare "undefined" because reflowable
books built the string from `sectionLabel`, which is empty for spine
items with no matching TOC entry. It now falls back to the page count.
- Choosing "use remote" closed the dialog but never moved the reader:
`applyRemoteProgress` only knew how to navigate via CREngine XPointers,
so non-XPointer progress strings were silently ignored. It now falls
back to `view.goToFraction` using the reported percentage.
Also fixes the section-title indentation in the dialog (SectionTitle
bakes in `ps-4`, which misaligned the labels against their values).
Closes#4200
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(reader): add RSVP CJK character mode and whole-word highlight, closes#4131
Add two CJK-only options to the RSVP overlay settings row:
- Character Mode: split CJK text per-character instead of by jieba/Intl
word segmentation, restoring one-character-per-flash reading.
- Highlight Word: render a CJK word as a single centered, fully-colored
span, fixing the focus-only highlight and even-length left-shift.
The focus point now skips trailing CJK punctuation so tokens like "是。"
highlight the character, not the punctuation. Both toggles appear only
for sections that contain CJK text.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* i18n: extract RSVP CJK character mode and highlight word strings
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(reader): import annotations from Moon+ Reader (.mrexpt)
Add a new menu entry under the reader sidebar 'More' menu that lets users import highlights and notes exported from the Moon+ Reader Android app.
Implementation:
- utils/mrexpt.ts: parser for the .mrexpt plaintext format (entry id, NCX navPoint index b4, character offset b6, type marker, word and note).
- services/annotation/providers/mrexpt.ts: convert mrexpt entries to BookNote[] using bookDoc. Locate the chapter via b4 -> toc -> spine, then TreeWalker-search the section DOM for the highlighted word with English suffix tolerance (ing/ed/s/...). Falls back to a section-level CFI when the exact word can't be located. Re-imports are deduplicated by a stable id derived from entryId.
- BookMenu: add 'Import from Moon+ Reader' menu item dispatching the 'import-mrexpt' event.
- Annotator: handle 'import-mrexpt' — pick the file (Web File / Tauri path), parse, convert against the live bookDoc, merge into booknotes (latest updatedAt wins), persist via saveConfig, and apply to all live views so highlights appear immediately. User feedback via toasts (importing / imported N / N unmatched / nothing new).
* refactor(reader): simplify Moon+ Reader import notifications
Reworks the .mrexpt import UX so it shows exactly one toast per run
instead of up to two, and removes redundant intermediate notices.
- Drop the intermediate "Importing N annotations…" toast. The toast
system shows one toast at a time, so it merely flashed and was
replaced by the result toast.
- Drop the duplicate "Failed to read the selected file." toast in the
read catch block; it falls through to the existing empty-content
check which surfaces the same message.
- Collapse the three-way result toast (already imported / N unmatched /
N imported) into one: "Imported {{count}} annotations" or
"No new annotations to import".
- Fix a result-message bug: when every converted note was already
imported and nothing was unmatched, the toast read "Imported 0
annotations." It now reports "No new annotations to import".
- Pluralize the success message via i18n `count` (the previous `{{n}}`
placeholder never pluralized, e.g. "Imported 1 annotations").
- Extract the dedupe/merge logic into a pure, unit-tested
`mergeImportedBookNotes` helper.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(i18n): translate Moon+ Reader import strings
Run i18next extraction and translate the new .mrexpt import strings
across all 33 locales (340 keys). The import feature added in this PR
introduced translatable strings that had not yet been extracted.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A touch-surface mouse like the Magic Mouse emits a flood of tiny, low-
magnitude wheel events — plus an inertial momentum tail — for a single
physical gesture, and even a light brush of the surface produces spurious
deltas. The previous 100ms trailing debounce collapsed bursts but did not
filter by magnitude, so isolated micro-touches and the momentum tail each
turned a page, cascading into continuous accidental page turns in
paginated mode.
Add a wheel gesture detector that accumulates normalized wheel travel and
only flips once it crosses a deliberate-intent threshold, then swallows the
rest of the stream (the momentum tail) until the wheel goes idle — so one
physical gesture flips exactly one page, mirroring native readers.
Closes#4117
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A double-click selection can carry trailing whitespace and most imported
dictionaries store headwords lowercased, so an exact match on the raw
selection often misses (e.g. `Hello` or `world ` fail to resolve
`hello`/`world`). Case-sensitive formats like mdict are hit hardest since
their reader compares the raw word.
Seed the lookup history with a trimmed word and try ordered query
variants (trimmed, lowercase, title-case, uppercase) per provider,
keeping the first hit. Closes#4176.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a "Clear Annotations" item to the book menu. Picking it opens a confirm dialog and, on confirm, soft-deletes every type='annotation' booknote on the active book by stamping deletedAt, removes overlays from live views, persists via saveConfig, and resets sidebar browse state. Bookmarks and excerpts are untouched. The dialog lives in Annotator (per-book, long-lived) and is wired up via a new 'clear-annotations' event so it survives the dropdown menu unmounting.
* feat(reader): add custom hardware-button page turning (#4139)
Lets users bind hardware remote keys (media keys, D-pad/arrow keys) to
previous/next page via a learn-mode capture UI in reader settings — an
accessibility feature for page-turner remotes.
- New global hardwarePageTurner system setting (enabled + key bindings).
- hardwareKeys.ts: key normalization, matching, and page-turn resolution.
- deviceStore: reference-counted media-key interception + learn mode.
- usePagination: flips pages from bound media keys (native bridge) and
D-pad/keyboard keys (DOM keydown), scoped to the active book and
suppressed while the toolbar is visible.
- Page Turner settings section on all platforms; web/desktop bind keys
via DOM keydown only, native media-key interception stays mobile-only.
- Android: intercept media + learn-mode keys in dispatchKeyEvent.
- iOS: forward media keys via MPRemoteCommandCenter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(i18n): add and translate hardware page turner strings (#4139)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(reader): refine hardware page turner (#4139)
- Handle book-iframe key events (iframe-keydown messages) so custom
bindings work as soon as a book is open, not only after the settings
panel has been shown.
- Add Previous/Next Section bindings alongside the page bindings.
- Rename the hardwareKeys util to keybinding.
- Wire the Page Turner section into the settings Reset action.
- Drop the focus ring on the capture buttons; BoxedList gains an
optional description.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(i18n): translate page turner section and key strings (#4139)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The smooth-wheel feature (#3974, closing #3966) intercepts mouse-wheel
events in scroll mode: it makes the wheel listener non-passive,
preventDefault()s the native scroll, and replays the delta through a
main-thread rAF animation against the renderer container.
That regressed normal mouse scrolling on Windows (#4130): fast wheel
bursts were discarded entirely, and the JS replay is structurally worse
than native scrolling -- a non-passive wheel listener forces every wheel
event (mouse and trackpad) off the compositor thread, and the
postMessage hop plus main-thread animation add latency and jank that
native compositor scrolling does not have.
High-resolution scrolling (e.g. Logitech MX Master, the mouse in #3966)
needs no special API: the OS/driver just delivers regular wheel events
with smaller, more frequent deltas, and the browser scrolls them
natively. #3966's own report ("smooth scrolling works with all
applications apart from yours") points at the interception, not a
missing capability. Restore native wheel scrolling in scroll mode.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Remove the redundant "Apply also in Scrolled Mode" options for bars and
margins so scrolled mode renders the header/footer consistently with
paginated mode: transparent, fixed in position, and not obscuring content.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
closes#4140
The bare-numeric-text heuristic added in #3894 to detect non-superscript
footnotes (`/^.{0,2}\d+$/` over `anchor.textContent`) was too permissive:
in-book TOCs that list chapter/verse links such as `<a>1</a>, <a>2</a>, ...`
all match the regex, so clicking them sets `check=true` and the footnote
handler renders the destination as a popup instead of letting the link
navigate. The OSB v2 verse-index and OSB v4 chapter-index from the bug
report both hit this.
Reject the `check` heuristic when the clicked link sits inside a numeric
link list (2+ sibling links with the same short-numeric pattern within
three ancestor levels). A real body paragraph with a couple of footnote
markers still passes; a flat TOC of numeric links does not.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When TTS playback crosses a section boundary, the page would stay on
the last page of the previous chapter while audio continued reading
the next chapter — leaving the user stuck behind the "back-to-TTS"
button.
Two compounding issues since the paginator adjacent-section preloading
landed:
1. `handleSectionChange` called `view.renderer.goTo(resolved)` without
awaiting. `TTSController.#initTTSForSection` does
`await this.onSectionChange?.(sectionIndex)` precisely so the view
can finish navigating before audio of the new section starts, but
the missing await defeated that contract.
2. `handleHighlightMark` returned silently on a cross-section
mismatch (`viewSectionIndex !== ttsSectionIndex`), so when the
renderer.goTo above completed only partially — which can happen on
the new paginator when the target section is already loaded as an
adjacent view and the post-goTo state appears reused without a
visible page flip — there was no second chance to drag the view to
the TTS cfi.
Fix:
- Await `view.renderer.goTo` in `handleSectionChange`.
- In `handleHighlightMark`, run the cross-section branch *before* the
`followingTTSLocationRef` check and call `view.goTo(cfi)` directly,
stamping `sectionChangingTimestampRef` so the back-to-TTS button
stays suppressed while progress.location catches up. Skip only when
the user is actively selecting text.
Adds unit tests covering both the cross-section navigation path and
the in-section scrollToAnchor path.
* feat: add default ruby rt styles with user-select: none
* fix: prevent furigana text from being copied via ruby transformer
* fix: register ruby transformer in FoliateViewer pipeline; use span wrapper for reliable ::before rendering
* refactor(reader): simplify furigana copy exclusion
Drop the ruby transformer and the .rt-text::before pseudo-element
wrapping. Instead, pass ['rt'] to getTextFromRange unconditionally so
furigana is excluded from annotator/translation/copy text extraction,
and let `rt { user-select: none }` handle the native selection cursor.
Avoids DOM rewriting and HTML-entity round-tripping in the data-text
attribute, and keeps <rt> text in the DOM for TTS, in-page find, and
screen readers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Popup: hide the inner triangle when its anchor point lands inside
the popup body. Extracted as a generic `isPointInRect` helper in
`sel.ts` (with a default 1px padding so edge cases stay visible).
- style.ts: handle `<p[width][height]><img></p>` (common in some
MOBI conversions) — clear hardcoded width/height and apply
multiply blend for dark themes so the image doesn't sit on a
colored box.
- Annotator: shrink dict popup height from 480 to 360 to fit
smaller screens.
- foliate-js: submodule bump.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ui/ux: codify design system and migrate settings to shared primitives
Document Readest's design language in DESIGN.md (Adwaita-aligned, e-ink-first,
RTL-correct) and migrate every settings panel onto a small set of primitives
(BoxedList, SettingsRow, SettingsSwitchRow, SettingsSelect, SettingsInput,
NavigationRow, Tips, SubPageHeader). AGENTS.md links to DESIGN.md so contributors
land there before inventing new chassis classes.
Replace the standalone KOReader/Readwise/Hardcover Config dialogs with a single
Integrations panel (Reading Sync + Content Sources sub-pages). The reader's
BookMenu now hides each provider until it's configured, and Hardcover's per-book
"Enable for This Book" toggle is dropped — there's no auto-sync to gate, so the
flag was just extra clicks.
Refresh highlight colors (two-trigger swatch + label, translatable default
names), background texture / theme color selectors (border-current keeps
selection legible on any backdrop), CustomFonts/CustomDictionaries (quiet
list-extension style + shared Tips primitive), the OPDS catalog manager
(debounced auto-download, right-aligned Browse), Set PIN, and the KOSync
conflict resolver. Translate the ~30 new strings across all 33 locales.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ui/ux: responsive typography, OPDS card polish, deep-link return paths
Restore the .settings-content responsive cascade (14px desktop / 16px
mobile) the legacy panels relied on by dropping hardcoded `text-sm`/`text-xs`
from the new primitives. Secondary text moves to em-relative `text-[0.85em]`
so it scales with the parent. Form controls (`<input>`, `<select>`) re-apply
the cascade explicitly via the `settings-content` class since browsers don't
inherit font-size onto form elements.
Extract `<SectionTitle>` primitive (caseless-language aware via
`isCaselessUILang`/`isCaselessLang`) and route every uppercase tag-style
header through it: BoxedList groups, Reading Sync, Content Sources, Theme
Color, Background Image, integration form labels, KOSyncResolver device
labels, and the OPDS My Catalogs / Popular Catalogs sections. CJK / Arabic
/ Hebrew / Indic / Thai / Tibetan locales bump to `1em` since `uppercase`
is a no-op on those scripts.
Redesign the OPDS My Catalogs cards: whole card becomes the browse trigger
(role='button'), edit/delete collapse into a 3-dot dropdown menu, and the
sync-status moves to a sub-line under Auto-download so the card height stays
constant whether the toggle is on/off or sync data has arrived.
Plumb a `from=settings-integrations` URL marker through the OPDS browser so
both manual close and auto-close-on-failure (preserved as `router.back()`
for transient failures, paired with a new `stashOPDSReturnTarget` helper)
return the user to Settings -> Integrations -> OPDS Catalogs sub-page
rather than the dialog's top level. Backed by new `requestedSubPage`
deep-link store field.
Skip the OPDS catalog passphrase prompt when credentials sync is disabled
-- `replicaPublish` already drops encrypted fields at the wire, so prompting
was both pointless and confusing.
Fix `SettingsDialog` calling `setRequestedPanel(null)` inside a `useState`
lazy initializer (zustand setter during render -> React warning); move the
clear into a one-shot `useEffect`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ui/ux: opt Settings into OverlayScrollbars + caseless typography polish
Add an opt-in `useOverlayScroll` prop to `<Dialog>` that swaps the body's
native `overflow-y-auto` for `<OverlayScrollbarsComponent>` (autohide,
click-scroll, no native overlaid bars). SettingsDialog flips it on so the
long Layout / Color panels keep a visible, theme-aware scroll track on
Android / iOS webviews where native scrollbars auto-hide entirely. Other
short-modal callers stay on the native scrollbar.
Drop the `uppercase tracking-wider` SectionTitle styling for caseless
scripts and pair it with body-weight `font-medium` instead — those
typographic effects are no-ops on Han / Hangul / Devanagari / Thai etc.,
so a plain medium-weight body-size title reads more correctly than a
shrunken pseudo-uppercase one. SettingsRow / NavigationRow primary labels
follow the same rule (drop `font-medium` in caseless locales since the
inherited body weight already carries; CJK fonts bold poorly at body
size).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ui/ux: SettingLabel primitive + KOSyncForm select polish + Tips alignment
Add `<SettingLabel>` primitive — caseless-aware row/field label that pairs
with `<SectionTitle>` (groups) for per-item labels. Cased scripts get
`font-medium`; caseless scripts (CJK / Arabic / Hebrew / Indic / Thai /
Tibetan) drop the weight since Han / Hangul / Devanagari etc. bold poorly
at body size. No font-size class so it inherits the `.settings-content`
14/16 cascade. Routed through `SettingsRow`, `NavigationRow`, and the
~12 ad-hoc inline `text-sm font-medium` callsites in AIPanel / FontPanel
/ ColorPanel / IntegrationsPanel / KOSync / Readwise / Hardcover forms.
Refactor KOSyncForm's Sync Strategy + Checksum Method rows onto the
shared `<SettingsSelect>` primitive — the inline 17-line div/select/
MdArrowDropDown chassis becomes a single SettingsSelect call with an
options array. Drops the unused MdArrowDropDown import and ~25 lines.
Fix Tips list-item alignment: callers traditionally pass `<li>` elements
(semantic) but the primitive was double-wrapping into `<li><span><li>...</li></span></li>` — invalid HTML, and the inner `<li>`'s
`display: list-item` broke line-wrap alignment on multi-line items.
Unwrap caller `<li>` to its content; add `flex-1` on the text span so
wrapped lines align under the first line instead of falling back to the
bullet column. Bullet container switches to `h-[1.4em]` so it tracks the
text line-height and pins to the first line's optical center via
`items-center` regardless of how much the content wraps.
DESIGN.md §5 typography updated to point primary-label callers at
`<SettingLabel>`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a `share` flag and `sharePosition` to `saveFile` across the app
services. On iOS/Android/macOS/Windows the annotation export now calls
the sharekit `shareFile` (writing the markdown/txt to `$TEMP` first when
no `filePath` is provided), so users get the system "Share via…" sheet
that drops the export into Mail, Notes, Messages, etc. Linux desktop
keeps the existing save dialog, since sharekit has no Linux backend.
On the web, `saveFile` now prefers `navigator.share({ files })` when the
browser advertises support via `canShare`. AbortError (user dismissed)
is treated as a deliberate "don't share" choice; any other rejection
(e.g., Chrome desktop's `NotAllowedError` despite a positive `canShare`)
falls through to the `<a download>` fallback so a save still happens.
Also fixes the macOS share popover anchoring: `preferredEdge: 'top'`
maps to `NSMaxYEdge`, which is the rect's bottom edge in WKWebView's
flipped coords, so the picker rendered below the trigger button. The
annotations export only got away with it because its dialog has no room
below — macOS auto-flipped above. Switching to `preferredEdge: 'bottom'`
(`NSMinYEdge` → top edge in flipped coords) anchors the popover above
the button consistently. Adds `$TEMP/**/*` to the Tauri fs capabilities
so the writable temp share file is permitted.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Plug the texture replica adapter into the kind-agnostic primitives
shipped in #4077. Textures imported on one device download and
become available on every signed-in device, with the same shape as
the font sync stack (single-file binary, contentId from
partialMD5+size+filename, bundleDir layout, replica-publish on
import, full activation on auto-download).
Includes legacy flat-path migration so pre-existing textures sync
without re-import, ColorPanel import flow now publishes the row
and queues the binary upload, and createCustomTexture preserves
contentId/bundleDir/byteSize through addTexture (mirrors the
font-import fix). Server allowlist gains 'texture' with a
single-image Zod schema; useBackgroundTexture passes replica
metadata through addTexture so the boot-time "ensure selected
texture is in store" path doesn't silently un-publish a remote
record.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(sync): kind-agnostic replica primitives
Extract dict-only sync into shared primitives (registry, pull/apply
orchestrator, persist env, schema allowlist) so other kinds can plug
in. Companion changes: per-replica Storage Manager grouping,
useReplicaPull boot-race recovery, manifest=null reconciliation on
every boot pull, copyFile takes explicit srcBase + dstBase, settled-
event helpers, lenient webDownload Content-Length (R2/S3 signed URLs
commonly omit it), and generic "File" transfer toast copy any replica
kind can share.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(sync): cross-device custom font sync
Plug the font replica adapter into the kind-agnostic primitives:
font store gains replica wiring, custom font import publishes the
replica row + queues a binary upload, and bootstrap registers the
font adapter and download-complete handler.
Includes legacy flat-path migration so pre-existing fonts sync
without re-import, full @font-face activation on auto-download
(load + mount the rule, mirroring manual import), and a fix to
createCustomFont so contentId / bundleDir / byteSize survive the
trip through addFont — otherwise import-time publish silently
no-oped on missing contentId.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(sync): cross-device dictionary sync
Custom MDict / StarDict / DICT / SLOB dictionaries now sync across
signed-in devices via the replica layer.
- Store mutations publish replica rows with field-level LWW + tombstones.
- Re-importing the same content (renamed or after delete) preserves the
user's label and reincarnates the server row instead of duplicating.
- Manifest commits after binary upload so other devices never see a row
whose binaries aren't on cloud storage yet.
- Pull-side orchestrator creates a placeholder dict, queues the binaries
via TransferManager, and clears the unavailable flag on completion.
- Toast copy branches by transfer kind so dict uploads don't read
"Book uploaded".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(sync): boot pull and binary download path
- Defer the boot pull until TransferManager is initialized so download
enqueues aren't dropped.
- Auto-persist the local dict store after applyRemoteDictionary; otherwise
the next loadCustomDictionaries wipes the in-memory rows.
- Boot pull passes since=null so a device whose cursor advanced past
unpersisted rows can still recover.
- Skip pulling when not authenticated instead of logging
"SyncError: Not authenticated" on every boot of a signed-out device.
- downloadReplicaFile resolves the destination against the kind's base
dir; binaries previously landed at the literal lfp and openFile then
failed with "File not found".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(sync): per-page useReplicaPull hook
Lifts the boot-time pull out of EnvContext into a hook each page mounts
for the kinds it needs: useReplicaPull({ kinds: ['dictionary'] }).
Library page and the shared Reader component opt in. The hook fires 10s
after page load (so feature mounts hydrate first), dedups per-kind
across navigation, and releases the slot on failure so a later mount
can retry. Future kinds plug into the hook's per-kind switch.
Also closes two refresh-loop bugs:
- Hydrate the dict store from settings BEFORE the apply loop, so the
auto-persist doesn't clobber persisted rows that the in-memory store
hadn't yet read. Library-page refresh was the visible victim.
- Skip the download queue when every manifest file is already on disk
under the resolved bundle dir. Refreshing is a no-op; partial-
download recovery still queues because some files would be missing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(reader): play sound:// links in MDict definitions via MDD lookup
MDX entries reference audio resources with `<a href="sound://name.ext">`.
Until now those anchors fell through to the browser, which tried to
navigate to an invalid scheme and did nothing useful.
Wire each `sound://` anchor inside the rendered MDX body to:
- preventDefault + stopPropagation (so the parent card's tap-to-expand
doesn't fire),
- look up the path in every companion `.mdd` until one returns bytes
(js-mdict's `MDD.locateBytes` auto-normalizes the leading separator),
- wrap the bytes in a Blob and play via `new Audio(URL.createObjectURL)`,
- cache the resolved URL on the anchor so subsequent clicks reuse it,
with the URL tracked for revocation in `dispose()`.
Note: many MW-style dictionaries use `.spx` (Speex) which Chromium and
Safari don't natively decode — the lookup will succeed but playback may
fail silently. Other formats (mp3, wav, ogg vorbis) play fine.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(dict): improve MDict rendering and dictionary management
Builds on the sound:// fix to round out MDict rendering and tighten
the dictionary settings panel.
MDict provider:
- Follow MDict-specific URL schemes inside the rendered HTML:
`sound://path` plays via Audio (with a deprecation toast for `.spx`
whose codec no major browser decodes), and `entry://word` /
`bword://word` forward to ctx.onNavigate so the popup re-looks-up
the target. Cycle-bounded (5 hops) `@@@LINK=<word>` content-level
redirects are followed transparently, so entries that are pure
redirect strings (e.g. "questions" → "question") render the
canonical entry instead of the literal redirect text.
- Render the body inside a shadow root so each dict's CSS stays
scoped — `<link rel="stylesheet">` references are resolved against
the companion .mdd, loose .css files imported alongside the bundle
are read at init, and `url(...)` refs inside both are rewritten to
blob URLs sourced from the MDD (covers sound icons, background
images, @font-face sources). The body is tagged `data-dict-kind="mdict"`
for downstream targeting.
- A baseline app-level stylesheet (`getDictStyles`) is injected into
every shadow root with theme-adaptive `mix-blend-mode` for `<a>`
background icons / `<a> img` (multiply on light, screen on dark);
isDarkMode is forwarded via the lookup context.
- `<img src="/path">` is now treated as MDD-relative (the tightened
IMG_SRC_PROTOCOL_RX skips schemes / protocol-relative only); a
fallback retry strips the leading slash for bundles that store the
resource without it.
- The auto-prepended light-DOM headword `<h1>` is hidden when the
dict body either leads with a same-text element (any tag — covers
`<h3 class="entry_name">`, etc.) or contains an `<h1>` with the same
trimmed text anywhere (covers wrapper-div-then-h1 layouts).
Dictionary management:
- Importing a dict whose name matches an existing one now replaces
it in place, preserving the slot in providerOrder and inheriting
the previous enabled flag. The .css extension is added to the file
picker, and loose .css files imported alongside .mdx/.mdd are
bundled with the dictionary regardless of stem-match.
- The settings panel gains an Edit mode (parity with Delete mode):
trailing pencil button on imported dicts and custom web searches
opens a rename modal. Edit and Delete are mutually exclusive.
Below 400px, the Edit/Delete labels collapse to icons only.
Card UX:
- The card's tap-to-expand handler now walks `composedPath()` so
clicks on anchors / buttons / images inside the shadow root no
longer fold the card.
i18n:
- Translations added for new strings across 33 locales.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Redesign the dictionary lookup UI as a single scrolling list of
expandable cards — one per provider that has a result — instead of
a tab strip with one active provider at a time.
Behavior:
- All enabled dictionaries are queried in parallel; cards render in
user-defined order. Cards whose provider returns no result, an
unsupported format, or an error are removed entirely.
- Cards default to expanded when 3 or fewer providers have results,
collapsed (4-line preview) otherwise. Manual taps are sticky
across re-renders; the auto-decision is reset only when a new
word is looked up.
- Web-search providers (Google, Urban, Merriam-Webster, custom
templates) appear in a separate "Search the web" section as
tappable rows. On the web build they use native target="_blank"
anchors; on Tauri the click is routed through openUrl since
target="_blank" doesn't open externally there.
- The header carries a back arrow (when in-content link navigation
has pushed onto the history stack), the looked-up word, and a
gear that deep-links to Settings → Language → Dictionaries.
Mobile / narrow viewports (<sm) get the same UX as a bottom sheet
(Dialog with snapHeight 0.75); sm+ viewports keep the anchored
popup with triangle pointer. Both share useDictionaryResults +
DictionaryResultsHeader/Body.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Expose `annotation.appLink` (readest://) and `annotation.webLink`
(https://web.readest.com) as template variables for custom export
templates. The shipped default template now emits the readest:// app
deeplink for the page link so exported notes open the native app.
The non-template export mode keeps the universal https link.
Preview links also gain target="_blank" so they open in a new tab
instead of replacing the dialog.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tapping the footer with `tapToToggleFooter` on cycles `progressInfoMode`
through values including 'none', which persists to view settings. When
the user later disabled the toggle in settings, nothing reverted the
saved mode — so the footer stayed hidden with no UI path back to a
visible state, only re-enabling the toggle and tap-cycling forward.
ProgressBar now self-heals: when `tapToToggleFooter` is off and the
current mode isn't already 'all', it resets to 'all'. Fires both at
mount (book opened with stuck 'none') and on the toggle transition.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- i18n loader at apps/readest.koplugin/i18n.lua: isolated callable,
falls back to KOReader's gettext when a string is untranslated
- Translation catalog at locales/<i18next-code>/translation.po for 31
languages, mirroring apps/readest-app/public/locales/
- scripts/extract-i18n.js (scan _("...") and _([[...]]); preserve
existing, drop obsolete, add new) and scripts/apply-translations.js
(bulk import from /tmp/koplugin-translations/<lang>.json)
- Mirror apps/readest-app SyncInfoDialog: rename showMetaHashInfo to
showSyncInfo, dialog title "Sync Info", new Last Synced row computed
as max(last_synced_at_config, last_synced_at_notes) from doc_settings
- syncconfig.lua / syncannotations.lua mark per-book sync timestamps
on push/pull success
- Rename "Meta Hash" -> "Book Fingerprint" in koplugin and
apps/readest-app SyncInfoDialog.tsx; translations propagated to
all readest-app locales
- "book config" -> "reading progress" wording across user-facing
strings (matches QiuYukang fork terminology)
- Replace "Log out as " / "Login failed: " concat prefixes with
T(_("...%1..."), arg) placeholder pattern (RTL / verb-final friendly)
- pnpm lint:lua: luajit -b syntax check across koplugin .lua files;
soft-skips when luajit is missing locally; CI installs luajit and
runs the check unconditionally
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extends the dictionary system beyond StarDict/MDict with two more open
formats and a pluggable Web Search tier so users can fall back to online
sources when their offline bundles miss a word.
Formats:
- DICT (dictd, RFC 2229): .index + .dict.dz bundles. Shared DictZip
parsing with StarDict via new dictZip.ts helper.
- Slob (Aard 2): self-contained .slob containers, zlib-compressed
utf-8 entries; non-zlib/non-utf-8 bundles flagged unsupported at
import.
Web Search:
- Built-in templates for Google, Urban Dictionary, Merriam-Webster
(seeded into providerOrder, disabled by default).
- Custom URL templates via %WORD% placeholder, URL-encoded at
substitution; entries persist in settings.webSearches.
- V1 renders an "Open in {{name}}" external link (iframe embedding is
blocked by every major target site's X-Frame-Options).
UI:
- CustomDictionaries panel: flat outline-primary buttons for Import /
Add Web Search, end-aligned type badges for a uniform column,
hover states, compact tips block.
- Dictionary popup: bottom-right Manage icon (tooltip-only) deep-links
into Settings → Language → Dictionaries; rounded-corner clipping fix
on the tab strip.
File picker accepts .index and .slob; importer recognizes DICT and
Slob bundles and reads bundle metadata for friendly names.
Tests cover DICT/Slob readers and providers with real freedict-eng-nld
fixtures, web search substitution + provider rendering, and the new
store CRUD for web searches.
Closes#4038
Tapping the section info or progress bar overlays did not dismiss an
open annotation popup because the dismiss flow listens for
iframe-single-click, which only fires from inside the foliate iframe.
These overlays sit above the iframe and intercept taps before the
iframe sees them, so the popup stayed open.
Dispatch iframe-single-click first in their click handlers; if a popup
consumes it (existing useTextSelector handler dismisses popup/selection
and returns true), skip the original action.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- ensureSharedBookLocal helper makes sure the local library has both the
Book entry and the bytes on disk after /import succeeds; navigating
into the reader before this lands on "Book not found"
- ShareLanding navigates via navigateToReader (path form on web) so the
reader actually renders instead of hitting the App Router stub and
going blank
- Loading + progress UI on the landing page while bytes stream in;
Open-in-app disabled mid-import to avoid races
- UserInfo header: vertically center avatar with name/email, tighter
mobile gap, and a fillContainer prop on UserAvatar so a parent can
size the box via classes without the inline style fighting back
- Rename "Share current page" -> "Share reading progress" (and matching
post-generation hint) and shrink the dialog from 480 to 460px
- Drop the unused Reload Page menu item from SettingsMenu
- Translate the two new i18n keys across 31 locales
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Direct file download from the public share landing carries rights /
abuse risk. Replace the Download button with the Open-in-app deep link
in both the logged-in flow (now: Add to library + Open in app) and the
anonymous flow (now: Open in app + Get Readest footnote).
The /api/share/[token]/download route is left intact so re-enabling
the button later is a one-file UI change.
Add a Share Book feature that generates an expiring HTTPS share URL plus a
parallel readest://share/{token} deep link. Recipients land on /s/{token},
where logged-in users can one-tap "Add to my library" (R2 server-side
byte-copy) and anonymous users download the book or open it in the app.
Sharers manage active links from a dedicated "Manage Shared Links" panel
under user settings.
Highlights:
- 9 new App Router endpoints under /api/share (create, [token], cover,
og.png, download, download/confirm, import, revoke, list).
- /s landing page with branded next/og chat unfurl image and SSR auth-cookie
detection so logged-in recipients see "Add to my library" as the primary
action without layout shift.
- Server-side R2 byte-copy for /import preserves the project's existing
invariant that every files.file_key is prefixed with its row's user_id;
stats / purge / delete / download routes work unchanged. URL-encodes the
copy source so titles with spaces or '&' don't break the copy.
- Universal 7-day expiry cap, no tier differentiation, no "never". DMCA-risk
reduction. Picker defaults to 3 days.
- Position-aware shares: "Share current page" toggle (off by default for
privacy) attaches the sharer's CFI; recipient lands at the same paragraph.
- Per-user 50-share cap, rate limiting via Cache-Control: no-store on
token-bearing responses, atomic SQL increment for download_count via a
SECURITY DEFINER function so the public confirm beacon stays safe under
concurrent fire.
- Soft revocation: presigned download URLs (5-min TTL) cannot be cancelled
before TTL; documented as accepted v1 behavior.
- token + token_hash hybrid storage: public endpoints look up by hash and
never select the raw token, so accidental SELECT-* leakage on a public
route can't expose the bearer credential.
- Mobile / desktop Tauri share via tauri-plugin-sharekit; web falls back to
navigator.share with a clipboard fallback when no native share method
exists. Share-sheet dismissal no longer silently copies.
UI:
- New Dialog with a settings-card group: iOS-style segmented duration picker
+ toggle slider for "Share current page", on a single row each.
- Reader top-bar Share button, library context-menu Share entry, manage-
shares list with cover thumbnails and overflow menu.
- New <SegmentedControl> primitive in src/components for reuse.
Coverage:
- Unit tests for token utils + URL parser (20 new tests, full suite at 3445).
- 31 locales translated for all new strings; en plurals hand-added per the
project's hand-curated en convention.
DB migration in docker/volumes/db/migrations/002_add_book_shares.sql adds
the book_shares table, RLS policies, and the increment_book_share_download
RPC. Migration is idempotent.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The footer NavigationBar and slide-up panels (Color, Progress, Font &
Layout) used `bg-base-200`, which is computed as a slightly-darker shade
of the theme's background. In eink mode this rendered as a visible grey
strip even when the user picked a pure white/black theme.
Switch to `bg-base-100` in eink mode and add a `border-base-content` top
border so the bar stays visually separated from the page area without
relying on a tinted background, matching the existing eink treatment
elsewhere (e.g. PageNavigationButtons, ImageViewer).
* fix(rsvp): seed local position from synced BookConfig on resume
* refactor(rsvp): simplify seedPosition
Consolidate the matched/mismatched write paths into one localStorage
write, extract stripCfiPath() to a module-level helper, and trim the
comments around it.
* fix(rsvp): make progress bar draggable on mobile
Three coordinated fixes so the RSVP overlay's seek bar works reliably
under touch:
- Add `touch-action: none` on the slider so the mobile browser stops
claiming the gesture for scroll/pan and firing pointercancel mid-drag.
- Hoist the `.rsvp-controls`/`.rsvp-header` exclusion to the top of the
overlay's touchend handler so a successful drag isn't immediately
re-interpreted as a speed-change swipe.
- Guard `releasePointerCapture` with `hasPointerCapture` so pointercancel
arriving after the browser has already released capture (multitouch,
app backgrounding) no longer throws NotFoundError.
* feat(annotations): preview mode for deep-link landings
When the reader opens at a deep-link CFI (e.g. clicking an exported
highlight from Obsidian), the position should not be persisted as the
user's reading progress until they actually start reading. Otherwise
the deep-link visit overwrites their last-read position and propagates
that across all sync targets.
Adds a per-book `previewMode` flag in the reader store that:
- Is set to true in FoliateViewer when the URL's `?cfi=` overrides the
saved last-position.
- Is cleared on the first user-initiated relocate (page turn / scroll),
reusing the existing reason filter in `docRelocateHandler`.
- Gates the auto progress writers:
- useProgressAutoSave — skip local config persist
- useProgressSync — skip auto-push and skip the remote-progress
view.goTo (so cloud pull doesn't yank the
user away from the previewed annotation)
- useKOSync — skip auto-push (manual pushes still respected)
Hardcover sync and Discord presence are unaffected: hardcover only
fires on explicit user button press, and Discord presence carries no
position information.
Also picks up the regenerated AndroidManifest.xml change from the
existing tauri.conf.json deep-link config (registers readest:// scheme
on Android so the smart landing page's intent:// launch resolves).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(annotations): jump in place when target book is already open
When an annotation deep link arrives while the user is already in the
reader (most common case on mobile App Links), navigateToReader was
pushing the same /reader path with a different cfi query param. The
reader's init useEffect has [] deps, so it doesn't re-run, and
FoliateViewer doesn't re-read the cfi — the view stayed put.
Detect a mounted view for the target book hash by walking
viewStates and matching the hash prefix on the bookKey. If found,
call view.goTo(cfi) directly and set previewMode so the existing
gates fire. Falls back to navigateToReader when no view is open.
Also adds a console.log on each parsed deep link to make this path
easier to debug from device logs in the future.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(annotations): deep links for highlight exports
Embed an HTTPS deep link in markdown export so clicking a highlight in
Obsidian / Notion / Mail launches Readest at the exact CFI position.
Mobile App Links / Universal Links open the native app silently when
installed; desktop attempts the readest:// scheme automatically with a
manual fallback.
- Markdown export wraps the page-number text in a per-annotation link:
https://web.readest.com/o/book/{hash}/annotation/{id}?cfi=...
- New /o/... smart landing page handles platform routing (intent:// on
Android Chrome, scheme + visibility-cancel on other Android, auto
scheme + 1 s fallback on desktop, manual button on iOS).
- Reader honors a ?cfi= query param on initial load (overrides the
saved last-position for the primary book only).
- New useOpenAnnotationLink hook handles incoming readest:// and
https://web.readest.com/o/... URLs, including cold-start (getCurrent)
and library-load deferral; supports the legacy flat shape
readest://annotation/{hash}/{id} from previous Readwise syncs.
- ReadwiseClient now emits the HTTPS deep link instead of the legacy
custom scheme.
- AASA extended with /o/* matcher; Android intent-filter for the host
has no pathPrefix so it already covers it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* i18n: translate annotation deep-link strings across all locales
Translates the 13 new keys introduced for the annotation deep-link
feature into all 31 supported locales. Replaces all 403
__STRING_NOT_TRANSLATED__ placeholders.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(reader): custom dictionaries (StarDict + MDict)
Adds a pluggable dictionary provider system. Built-in Wiktionary +
Wikipedia (extracted from the legacy single-popup model into a tabbed
shell) plus user-importable StarDict (.ifo/.idx/.dict.dz/.syn) and MDict
(.mdx/.mdd) bundles.
Settings → Language → Dictionaries: import / enable / drag-reorder /
delete (delete-mode toggle mirrors CustomFonts). Drag uses @dnd-kit with
pointer/touch/keyboard sensors. Reader popup: tabbed UI, per-tab lookup
history, scroll-aware back button, last-active tab persists. Tabs grow
to natural width up to a cap, truncate with ellipsis when crowded; phantom
bold layer prevents layout shift on focus.
StarDict reader is self-contained (replaces unused foliate-js/dict.js),
with lazy random-access binary search on .idx + .syn (~420 KB Int32Array
of byte offsets vs ~10 MB of parsed JS objects), lazy DictZip chunk
decompression via fflate streaming Inflate (cmudict/eng-nld both
chunked), and an optional .idx.offsets sidecar generated at import to
skip the init scan. Cmudict 105K-entry init drops from ~10 MB heap and
2 MB IO to ~1.7 MB heap and ~500 KB IO.
MDict uses the readest/js-mdict fork (added as a submodule, consumed via
tsconfig paths so deps stay out of readest's pnpm-lock) which adds a
browser-friendly BlobScanner reading via blob.slice(...).arrayBuffer()
— slices are lazy when the Blob is Readest's NativeFile / RemoteFile.
encrypt=2 (key-info-only) MDX is fully supported via ripemd128-based
mdxDecrypt; encrypt=1 (record-block, needs user passcode) surfaces as
unsupported.
Wikipedia annotation tool removed (Wikipedia is now a tab inside the
unified popup); legacy WiktionaryPopup / WikipediaPopup deleted. Stale
annotationQuickAction === 'wikipedia' coerced to 'dictionary' on settings
load. iOS-friendly external links: skip target="_blank" on Tauri to
avoid the WebView's "open externally" path triggering the shell scope
error; the popup's container click handler routes through openUrl.
i18n: 939 strings translated across 31 locales (30 base keys + CLDR
plural forms for ar/he/sl/pl/ru/uk/ro/it/pt/fr/es).
Test fixtures bundled: cmudict (StarDict, 105K entries), eng-nld
(StarDict, smaller), and a Longman Phrasal Verbs MDX (encrypt=2).
3396 unit tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(stardict): resolve fflate from js-mdict source for vitest + Next
js-mdict is consumed as TypeScript source via tsconfig paths from
packages/js-mdict/src/. Its sources `import 'fflate'` directly, but
fflate is only installed under apps/readest-app/node_modules — so
vite's import-analysis (and Next/Turbopack's resolver) can't find
fflate when it walks up from the redirected js-mdict source location.
CI's fresh checkout exposes this; locally a leftover
packages/js-mdict/node_modules/fflate from the old workspace setup
masked it.
Pin fflate resolution to apps/readest-app/node_modules/fflate in:
- vitest.config.mts (Vite alias)
- next.config.mjs (webpack alias + Turbopack resolveAlias — Turbopack
rejects absolute paths so use a project-relative form)
- tsconfig.json (paths entry so tsgo / Biome see it)
Verified by deleting packages/js-mdict/node_modules locally and
re-running pnpm test (3396 pass), pnpm lint (clean), and both
pnpm build-web and a tauri-platform Next build (clean).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The paginator's scrolled-mode scroll handler is debounced 250 ms, so
#anchor and #primaryIndex can lag behind the user's actual viewport.
Toggling out of scrolled mode within that window made
render() → scrollToAnchor(#anchor) restore the stale anchor, reverting
the position to a previously visible section.
Update foliate-js to flush the pending scroll state before flow leaves
'scrolled', and add regression coverage for the multi-section toggle path.
When a book's underlying file is missing, opening it in a dedicated
reader window showed an error toast then navigated that window to
/library, leaving a leftover library-in-reader-window the user had
to close manually. Route the recovery through a new
closeReaderWindowOrGoToLibrary() that closes the dedicated reader
window (after ensuring the main library window is visible) and only
falls back to /library navigation in the main window or on web.
Also fix a related macOS issue: the reader's CloseRequested handler
was running handleCloseBooks and calling currentWindow.destroy() on
the main window, which tore down the active book and bypassed the
Rust close-to-hide handler — making Cmd+W / traffic-light close
quit the app from the reader page (vs. correctly hiding from the
library page) and lose the active book even when the window did
hide. Skip both the cleanup and destroy on macOS for the main
window so the Rust handler hides it with the book intact, matching
the macOS minimize-to-dock convention.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
On Android, long-press selects text via selectionchange while the finger
is still on the screen. The quick action handler was gated by
androidTouchEndRef and silently returned, so no popup ever opened. After
the user lifted, nothing re-ran the gated action.
Track the gated action in a small DeferredActionState ref and flush it
from the native touchend handler, so instant copy/dictionary/wikipedia/
search/translate/tts now fire on the first long-press release.
The browser delivers one large quantised delta per wheel notch, which
Chromium scrolls without interpolation — producing the jerky one-step
motion reported on Windows. Detect mouse-wheel-shaped events inside
the iframe (line-mode, or single-axis with |deltaY| ≥ 50), suppress
the native scroll, and replay the delta as an rAF exponential lerp on
the renderer's container. Trackpad / high-resolution input is left to
native scrolling so its momentum and 2-axis behaviour are preserved.
When the main window has been destroyed (Windows/Linux default close), the
reader's "go to library" button only closed the reader, leaving no library
visible. Add ensureMainLibraryWindow() that shows an existing main window
or recreates one with the 'main' label so the existing close-reader-window
wiring keeps working. Also grant the cross-window show/unminimize permissions
the call now needs.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>