Compare commits

...

29 Commits

Author SHA1 Message Date
Huang Xin 27fa9ab226 chore(i18n): translate new strings; commit pending agent memory notes (#4430)
Translate 4 new keys across all 33 locales:
- Send
- Book file is not available locally
- Failed to send book
- Highlight Current Sentence

Also commit pending .claude/memory bug-fix notes.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 19:01:47 +02:00
Huang Xin c2bbb6119a fix(reader): keep paginated page background inside its column (#4394) (#4429)
On a multi-column spread a coloured page's background bled into the outer margin gutter (the --_outer-min track) while an adjacent transparent/image page did not, shifting cover/title spreads off-centre. Bump foliate-js to clamp each background segment to its column instead of stretching into the gutter, keeping the symmetric margins intact. Update the computeBackgroundSegments regression tests.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 18:48:56 +02:00
Huang Xin 578b7ba14f fix(reader): restore annotation list auto-scroll to the nearest item (#4428)
Virtualizing BooknoteView (#4352) dropped the auto-scroll that centers the
note nearest the current reading position, leaving the list stranded at the
top. The single scrollToIndex that replaced the per-item useScrollToItem was
missing the machinery TOCView already uses for virtualized auto-scroll:

- Re-apply the scroll inside the OverlayScrollbars `initialized` callback
  (read via a ref): its deferred init resets the viewport scrollTop to 0, and
  the lastScrolledCfiRef guard otherwise blocked any retry (reload case).
- Mount Virtuoso natively centered via initialTopMostItemIndex with a
  skip-gate, so opening the panel while reading doesn't fire a scrollToIndex
  that races and wedges the freshly mounted, unmeasured list (tab-switch case).
- Jump instantly (behavior 'auto') for far moves and on eink, animating
  'smooth' only for short in-session updates — mirroring TOCView.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 18:39:45 +02:00
Huang Xin 9d8062ae27 fix(reader): keep table background matching the page in dark mode (#4419) (#4426)
The dark-mode `table *` color-mix tint in getColorStyles was applied
unconditionally since #4055, so plain tables — and the invisible spacer
cells some books use for vertical TOC layout — rendered a few shades off
the page background, and the spacing between words appeared to change.

Restore the `overrideColor` gate that #2377 originally added. Illegible
light/zebra table backgrounds (the #4028 case #4055 targeted) are now
handled separately by the dark-mode light-background rewriters from
#4392, so the blanket tint is no longer needed by default. The
standalone blockquote tint stays unconditional in dark mode.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 18:20:59 +02:00
Huang Xin 7128e8964e fix(reader): suppress Android image callout freezing the image viewer (#4425)
Long-pressing an image in the zoom viewer on Android triggers the
WebView's native image callout (context menu / drag / magnifier) at the
same time as the viewer's own pinch/pan/zoom touch handlers, locking up
the whole app until restart. Same root cause as the book-cover freeze
(PR #4345): `-webkit-touch-callout: none` doesn't inherit, so the class
must sit on an ancestor of the `<img>`.

Apply the existing `.no-context-menu` class to the viewer container so
the `.no-context-menu img` rule reaches the zoomed image and disables
the native callout. Harmless on desktop (the property is a no-op there
and right-click-save still works).

Closes #4420

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 17:04:52 +02:00
Huang Xin e4bb9fc4b7 refactor(share): make saveFile content nullable for path-based shares (#4424)
The book "Send" flow had to pass `new ArrayBuffer(0)` to saveFile purely to
satisfy the type-checker: the content arg is ignored on the native share
path when `options.filePath` points at an already-on-disk file. Widen
saveFile's content parameter to `string | ArrayBuffer | null` across the
AppService contract so callers can hand off a file by path without buffering
it into memory, and pass `null` from the Send flow instead of a throwaway
empty buffer.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 16:49:47 +02:00
Huang Xin df66c63a07 fix(txt): recover author for 【】-titled web-novel TXT imports, closes #4390 (#4423)
Chinese web-novel TXT files are commonly named 【书名】1-129 作者:起落.txt and
carry a noisy metadata block at the top of the file. Two author-recognition
failures resulted after importing them:

  1. Author missing — extractTxtFilenameMetadata only pulled an author from
     《》-wrapped names, so 【】-style names yielded no filename author, and when
     the file header had no clean "作者:X" line the author came out empty.

  2. Irrelevant content as author — the greedy file-header capture
     (/作者…(.+)\r?\n/) grabbed a publication blob like
     "2024/08/01发表于:是否首发:是 字数1023150字…" and surfaced it as the author.

Fix:
  - extractTxtFilenameMetadata now extracts the labeled "作者:X" form from any
    filename (title stays the full name; only the labeled form is safe so a
    leading 【title】 isn't mistaken for the author).
  - Validate the header-matched author (isPlausibleAuthorName) and fall back to
    the filename author when it looks like a metadata blob — embedded field
    separator, long digit run, or excessive length. Applied to both the small-
    and large-file conversion paths.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 16:44:07 +02:00
Huang Xin 963bab0f0f fix(library): stop bookshelf context menu shuffling its order (#4389) (#4421)
The bookshelf right-click menu built itself with un-awaited
`Menu.append()` calls. Each append is an async IPC round-trip to the
Tauri backend, so the concurrent fire-and-forget requests resolved in
non-deterministic order on the Rust side and the native menu items
landed shuffled on every open (only reproducible on the native app,
invisible in jsdom).

Build the items in order and create the menu in a single
`await Menu.new({ items })` call for both the book and group handlers.
Order and conditional inclusion are unchanged.

Extract the order/inclusion logic into a pure `getBookContextMenuItemIds`
helper in `libraryUtils.ts` so the deterministic ordering is unit-tested
without mounting the component or mocking Tauri.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 16:40:11 +02:00
Huang Xin f9ddddb6ac fix(library): use ghost cancel buttons in migrate-data dialog for e-ink (#4396) (#4422)
The Change Data Location dialog rendered its Cancel/Close (secondary)
buttons with `btn-outline`. Under `[data-eink='true']`, globals.css
inverts both `.btn-outline` and `.btn-primary` to the same base-content
fill + base-100 text, so Cancel and Start Migration collapsed into two
identical black buttons and became indistinguishable on e-ink screens.

Switch the secondary buttons to `btn-ghost`, matching the design-system
rule (DESIGN.md): the primary CTA keeps its solid fill while the ghost
cancel reads as borderless next to it, restoring the hierarchy.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 16:36:57 +02:00
loveheaven fe853554a9 feat(library): send book file from bookshelf selection popup (#4402)
* feat(library): send book file from bookshelf selection popup

Adds a Send button to the bottom popup that appears when one or more
books are selected on the bookshelf. Hands the actual book file
(epub/pdf/...) to the OS share sheet via tauri-plugin-sharekit
(UIActivityViewController on iOS, Intent.ACTION_SEND on Android,
NSSharingServicePicker on macOS), so users can fire the file off to
Mail / Messages / WeChat / AirDrop / etc.

This is intentionally distinct from the per-item context-menu "Share
Book", which uploads the book to the readest backend and generates a
public link. "Send" is offline file egress; "Share Book" is remote
collaboration. They share zero infra.

Resolution rules mirror bookContent.resolveBookContentSource: managed
copy under Books/<hash>/ first, then the device-local in-place import
path. Cloud-only books warn rather than silently no-op.

Path is handed to shareFile via options.filePath. Without that,
saveFile() falls back to writing a temp copy under BaseDirectory.Temp,
which on Android resolves to /data/local/tmp/ — the app sandbox has
no write permission there and the call fails with EACCES ("failed to
open file at path: /data/local/tmp/...epub Permission denied (os error
13)"). Passing the absolute path also avoids re-buffering the entire
epub/pdf into memory.

On macOS the NSSharingServicePicker is anchored to the selected
book's cover rather than to the Send button — the user's visual
focus is on the cover they just tapped, not on the bottom toolbar.
BookshelfItem stamps a data-book-hash attribute on its root div so
the Send handler can locate the cell via querySelector and pass its
rect through saveFile's sharePosition option. preferredEdge='bottom'
maps to NSMinYEdge, so the popover renders above the cover (and only
auto-flips below when there's no room above). iOS / Android share
sheets are modal and ignore sharePosition, so the same code is a
no-op there.

The button is hidden on Linux (no system share sheet), Windows
(WebView2 share UI deadlocks the main thread, see #4343), and web
browsers (no "send file to <app>" affordance for arbitrary downloads).

* fix(share): don't fall back to saveDialog when shareFile is cancelled

The native saveFile({ share: true }) path used to swallow any error
from sharekit's shareFile() and fall through to saveDialog. The plugin
treats user cancellation the same as a failure (it rejects with
'Share cancelled' on Android when the user dismisses the share sheet),
so cancelling a share popped up an unwanted 'Save As...' dialog right
after the user explicitly chose not to share.

Mirror what webAppService already does for the navigator.share()
AbortError path: once we entered the share branch, return true
regardless of whether the share completed or was cancelled. The
saveDialog path is now reserved for Linux/Windows desktop, which never
hit the share branch in the first place (wantShare gates them out).

If a future caller wants 'try share, fall back to save on hard
failure', that decision belongs to the caller — saveFile shouldn't
silently override an explicit share intent.
2026-06-02 16:09:29 +02:00
Huang Xin 4abbc0254c fix(reader): stop footer progress info painting a stray focus ring (#4397) (#4418)
The always-on page-info footer (`.progressinfo`) is a decorative
`role='presentation'` element, but it carried `tabIndex={-1}`, which made
it focusable. On Android, long-pressing the footer focused the div and the
WebView painted its default focus ring (`outline: auto`). Because the
element is pinned `absolute bottom-0` at book-view width, the ring rendered
as a content-column-wide line across the bottom of every page and persisted
until focus cleared.

Remove the `tabIndex` so the decorative element can no longer receive
focus. The `onClick` (tap-to-cycle progress mode / dismiss popup) still
fires regardless of tabindex, nothing focuses it programmatically, and the
translated `aria-label` keeps it exposed to screen readers unchanged.

Closes #4397

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 16:08:41 +02:00
loveheaven 7283a8ac21 fix(android): open book directly when launched via 'Open with' (#4407)
On Android, tapping an EPUB/MOBI/AZW3 in the system file browser and choosing Readest only opens the library — the book itself never opens in the reader. Now Readest can open the book now.
2026-06-02 16:07:23 +02:00
loveheaven 726f53a64b fix(reader): use Tauri clipboard plugin for copy on Android (#4409)
navigator.clipboard.writeText is unreliable inside the Tauri Android
WebView, so tapping Copy in the Reader's selection popup silently
no-ops. Route the write through @tauri-apps/plugin-clipboard-manager
on Tauri targets (Android, iOS, macOS, Windows, Linux), with a
graceful navigator.clipboard / execCommand fallback for the web
build and older WebViews.

- Add tauri-plugin-clipboard-manager (Rust + JS)
- Register the plugin in lib.rs
- Grant clipboard-manager:allow-write-text / allow-read-text
- New utils/clipboard.ts wrapper with platform-aware fallback chain
- Annotator handleCopy and handleConfirmExport use the wrapper
2026-06-02 16:02:52 +02:00
wfjack 5ff18b8f32 fix(readwise): use Tauri HTTP transport in desktop app (#4413)
Switch ReadwiseClient to @tauri-apps/plugin-http when running in
Tauri desktop mode, matching the pattern already used by WebDAV,
Hardcover, AI providers, translators, OPDS, and KOReader sync.

In a Tauri webview context the standard window.fetch is still subject
to CORS preflight rules and—on Android—the platform's cleartext-traffic
policy. @tauri-apps/plugin-http sends the request from the Rust side
via reqwest, bypassing the renderer entirely (no Origin header, no
preflight, no cleartext block). ReadwiseClient was one of the few
remaining services that had not yet adopted this transport, so users
on the desktop build could hit CORS or network errors when validating
tokens or pushing highlights.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 15:59:17 +02:00
Huang Xin 274afb0677 fix(sync): mint reincarnation token on re-import of custom fonts/textures (#4410) (#4416)
Custom fonts (and textures) disappeared a few seconds after opening a book
when logged into cloud sync. Under the replica layer's CRDT remove-wins
semantics, deleting a font writes a server-side tombstone that a plain
field upsert cannot revive — only a reincarnation token whose HLC beats
the tombstone does. Re-uploading the same file (same contentId) cleared
`deletedAt` locally but published the upsert with no token, so the tombstone
survived and the next pull (boot/periodic/book-open/visibility) re-applied
the soft-delete via softDeleteByContentId, making the font vanish. Logging
out stopped the pull, which is why it only reproduced while signed in.

addFont/addTexture now mint a reincarnation token when re-adding an existing
entry that is either soft-deleted or still-live with the same contentId (and
has no token yet), preserving any existing token. This mirrors the
dictionary's reincarnation handling (dictionaryService) and OPDS's token
style, fixing both the local re-import-after-delete case and the multi-device
stale-local race. The token is inert when there is no tombstone, so live
re-imports remain safe.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 15:56:38 +02:00
Huang Xin fe7fe25482 fix(reader): show background texture in paginated mode (#4399) (#4417)
The background texture (mounted on the reader container as
`.foliate-viewer::before`) showed in scrolled mode but was absent in
paginated mode: the paginator painted an opaque #background container
over it. Bump foliate-js to leave that container transparent under a
texture, and add a regression test for the shared textureAwareBackground
helper.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 15:54:59 +02:00
Huang Xin 3a81e09911 fix(reader): scroll oversized blocks in-place instead of turning the page (#4400) (#4415)
Wide or tall tables, code blocks and display equations overflowed the reading
column and a scroll gesture over them turned the page instead of scrolling the
content (#4400).

- Wrap tables and display equations in a horizontally/vertically scrollable
  container; route touch + wheel along the box's scrollable axis so it scrolls
  the box and never turns the page, even at the edge (both axes).
- A box that fits its column is marked fit (overflow:visible) so it never clips
  or captures gestures; the fit decision is measured once after layout via a
  self-disconnecting ResizeObserver, so it never relayerizes during a page turn.
- The scroll wrapper carries a new cfi-skip attribute that makes it transparent
  to CFI: epubcfi.js hoists a cfi-skip node's children into its parent (unlike
  cfi-inert which drops the subtree), and xcfi.ts mirrors this for CFI<->XPointer
  so existing highlights, bookmarks and KOSync positions inside a wrapped table
  or equation still resolve. The sanitizer whitelists cfi-skip.
- Bump foliate-js submodule (cfi-skip support + raf fallback for large sections).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 14:45:18 +02:00
Jesus Eduardo Medina Gallo 176b950c92 fix(reader): replace light callout backgrounds in dark mode (#4392)
Why:
- Dark mode sets theme foreground on html/body and rewrites black text,
  but EPUB callout boxes often keep white/light backgrounds from inline
  styles or publisher CSS unless override book color is enabled.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 11:16:52 +02:00
Jesus Eduardo Medina Gallo 458ad7510c fix(reader): scroll wide EPUB tables horizontally (#4391)
* fix(reader): scroll wide tables horizontally instead of scaling

Why:
- Wide EPUB tables (e.g. many columns without cell widths) overflowed the
  page because CSS scale only applied when widths were known.
- Paginated mode stole horizontal swipes for page turns over table content.

Refs:
- Replaces transform-based applyTableStyle scaling with a scroll wrapper
  and capture-phase touch routing (same pattern as gesture brightness).

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(reader): keep wheel/trackpad table scrolling from turning the page

Horizontal scrolling of a wide table in paginated mode also turned the page:

- applyTableTouchScroll only routed touch events. Trackpad/mouse wheel
  (readest forwards iframe wheel -> 'iframe-wheel' -> pagination) was
  never intercepted, so a horizontal wheel both scrolled the table and
  flipped the page.
- findWrapper used `instanceof Element`, which is always false for iframe
  event targets because this module runs in the top-window realm. The
  touch routing therefore never fired either.

Add a capture-phase wheel handler that consumes horizontal wheels over a
scrollable table -- including at the scroll edge, so the gesture (and
trackpad momentum) never chains into a page turn -- and make findWrapper
cross-realm safe via duck-typing on `closest`.

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

* fix(reader): don't show a spurious scrollbar on layout tables

Wrapping every table for horizontal scrolling made tables that fit the
column (e.g. character/glossary layout tables) show a spurious horizontal
scrollbar, because the wrapper was always scrollable.

Now the wrapper clips (no scrollbar) for a table that fits within a few px
of the column, and a ResizeObserver re-evaluates this as the column width
settles. A table genuinely wider than the column always scrolls and is
never clipped; one that wraps to fit shows no scrollbar. Touch/wheel
routing engages only scrollable wrappers.

Add a Chromium browser test over sample-table-layout.epub (layout tables
must not scroll) and sample-table-wide.epub (a too-wide table must scroll,
not clip).

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 10:42:53 +02:00
Huang Xin bc9fe67abf fix(desktop): sanitize invalid .window-state.json before restore (#4401)
A `.window-state.json` containing the Windows minimized sentinel
(x/y = -32000) or a 0×0 size makes WebView2 reject the restored bounds
with 0x80070057 ("The parameter is incorrect"), so the app fails to
launch until the file is deleted by hand.

Add a small `window-state-sanitizer` plugin, registered before
tauri-plugin-window-state, that strips window entries with invalid
geometry (non-positive size, or a position past the -16000 off-screen
cutoff) from the state file before the plugin loads it. Affected windows
fall back to default geometry instead of crashing.

Defense-in-depth: the bundled plugin (2.4.1) already guards against
writing these values, so a bad file is almost certainly stale from an
older build; this self-heals it on next launch.

Refs #4398

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 10:01:20 +02:00
Huang Xin 45ef5f7515 fix(metainfo): declare desktop and mobile device support (#4395)
The AppStream metainfo had no <requires>/<supports> relations, so software
stores such as Flathub listed Readest as "Desktop only". Readest is adaptive
and runs on desktop (keyboard/mouse) as well as phones and tablets (touch).

Declare a 360px display_length baseline plus keyboard, pointing and touch
controls so stores surface both Desktop and Mobile.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 05:13:45 +02:00
Huang Xin 97191a57c0 fix(reader): stop reading ruler creeping down on scroll (#4386) (#4388)
In scrolled mode the ruler's geometry-cache effect re-ran on every
relocate — including those fired continuously while scrolling — and
re-snapped the band to the next line forward from a screen-fixed
anchor, so the band crept down the page as the reader scrolled.

Place the band once on mount (and after a viewport-dimension change),
but never re-snap on a plain scroll relocate. Click-driven snapping
(the reading-ruler-move handler + pendingScrollAlign realign) is
unchanged, and paginated mode is unaffected.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 18:25:44 +02:00
Huang Xin a92fe0ce2e fix(koplugin): upload local book cover so synced books show a cover (#4374) (#4385)
uploadBook only attached a cover.png when one was already cached under
readest_covers/<hash>.png from a prior cloud download. Books that
originated locally in KOReader were never downloaded, so the cover step
was silently skipped and they synced to Readest with no cover.

Add extractLocalCover, which renders the book's embedded cover via
coverbrowser's FileManagerBookInfo:getCoverImage(nil, file_path) and
writes it as PNG. uploadBook now falls back to it when no cached cover
exists, caching the result under covers_dir so the Library view reuses
it like a downloaded cover.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 17:36:23 +02:00
loveheaven e8675fb7eb fix(reader): inline custom @font-face rules in iframe stylesheet (#4383)
* fix(reader): inline custom @font-face rules in iframe stylesheet

The reader iframe's first paint resolved with the default serif/sans
fallback and only swapped to the user's configured custom font a moment
later, producing a visible font flash when opening a book.

Custom font @font-face rules were registered via mountCustomFont on the
host document only, while paginator's setStyles writes its CSS into
the iframe synchronously before the first 'load' event. The iframe had
no knowledge of the user's custom fonts at that point, so font-family
declarations in the stylesheet matched nothing and fell back.

Inline the @font-face rules for every loaded custom font (blob URLs
already in memory, no network round-trip) at the front of getStyles
output, so paginator delivers them to the iframe atomically with the
rest of the stylesheet. Defensive try/catch around createFontCSS keeps
a single bad font from breaking the whole stylesheet.

* refactor(reader): pass custom fonts into getStyles instead of reading the store

getStyles lives in src/utils, where every other file is a pure function;
it was the only one importing a store (useCustomFontStore). Keep the
util pure: accept the loaded custom fonts as a parameter and let the
reader components — which already own the font store — supply them.

- style.ts drops the useCustomFontStore import and the SSR/store-error
  guards in getCustomFontFaces; the helper is now a pure CustomFont[] ->
  CSS transform.
- getStyles(viewSettings, themeCode?, customFonts = []) inlines the
  @font-face rules for the passed fonts.
- The first-paint call sites (FoliateViewer, FootnotePopup) pass
  getLoadedFonts(); settings-panel re-styles keep the default [] since
  custom fonts are already mounted as persistent <style> elements there.
- Add tests that exercise the font-face inlining path (the existing
  suite never did, since the store is empty under jsdom).

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

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 17:24:47 +02:00
Huang Xin 9b4db44490 fix(pdf): ship jbig2.wasm so scanned PDFs render in packaged builds (#4382)
pdfjs-dist 5.7.x (bumped 5.4.530 -> 5.7.284 in #4143) moved the JBIG2
image decoder -- the codec used by virtually every black-and-white
*scanned* PDF -- from pure JS into a WebAssembly module the worker
fetches at runtime from `wasmUrl` (/vendor/pdfjs/). The `copy-pdfjs-wasm`
script only copied an explicit allow-list ({openjpeg.wasm,qcms_bg.wasm})
and silently dropped jbig2.wasm. cpx does not error on an empty glob, so
the missing decoder went unnoticed: scanned PDFs rendered blank (pages
still turned) in CI builds, while local `tauri build` reused a stale
pre-bump public/vendor copy and worked. Regression between 0.11.1 and
0.11.2.

Copy the whole wasm/ dir (mirrors the {cmaps,standard_fonts}/* fonts
pattern) so future decoders moving to wasm can't be dropped again, and
ship the *_nowasm_fallback.js files for graceful degradation.

Adds a regression test asserting every .wasm the bundled pdf.js
references is covered by copy-pdfjs-wasm.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 08:52:10 +02:00
Huang Xin de3e4b6d3c fix(reader): show Duokan fullscreen cover in scrolled mode (#4379) (#4381)
A cover with `data-duokan-page-fullscreen` on <html> renders in
paginated mode but is blank in scrolled mode; only the first cover is
affected, other images are fine in both modes.

The paginator's fullscreen branch pins such images with
position:absolute and height:100% and forces their ancestors to
height:100%. That fills the fixed-height page when columnized, but in
scrolled mode the container height is `auto`, so height:100% resolves
to 0 and the cover collapses out of view.

Bump foliate-js to gate the fullscreen treatment on column mode and
reset any stale absolute pinning when a fullscreen-cover doc is laid
out scrolled (so toggling paginated -> scrolled also recovers). Add a
browser regression test + repro-4379.epub fixture.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 07:52:42 +02:00
Huang Xin 53fe8c2683 fix(release): don't clobber latest.json with a 404 "Not Found" body (#4376)
The Android and Windows-portable jobs fetched the existing updater
manifest with `curl -sL .../latest.json -o latest.json`. Without `-f`,
curl writes the server's 404 body ("Not Found") into the file and exits
0, after which `gh release upload --clobber` uploads that invalid JSON as
the release's latest.json.

tauri-action's updater step then downloads the existing latest.json and
runs `JSON.parse(...)` to merge new platforms in. Parsing "Not Found"
throws `Unexpected token 'N', "Not Found" is not valid JSON`, failing
every build-tauri matrix leg after its bundles were already uploaded.

Use `curl -fsSL` so an HTTP error fails the step instead of writing the
error body, and validate the download with `jq empty` before merging, so
a corrupt manifest can never be clobbered onto the release again.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 23:17:32 +02:00
Huang Xin f8d88cca50 chore: bump tauri plugins (#4373) 2026-05-30 22:18:41 +02:00
Huang Xin 99e551cbc6 chore: fix release workflow (#4372) 2026-05-30 21:48:48 +02:00
118 changed files with 4563 additions and 545 deletions
+24 -2
View File
@@ -273,7 +273,18 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
cd apps/readest-app/
curl -sL https://github.com/readest/readest/releases/latest/download/latest.json -o latest.json
# Use -f so curl fails on HTTP errors instead of writing the 404 body
# ("Not Found") into latest.json and clobbering the release asset with
# invalid JSON, which then breaks tauri-action's updater merge on every
# subsequent build.
if ! curl -fsSL https://github.com/readest/readest/releases/latest/download/latest.json -o latest.json; then
echo "::error::Failed to download existing latest.json; aborting to avoid clobbering the release asset."
exit 1
fi
if ! jq empty latest.json 2>/dev/null; then
echo "::error::Existing latest.json is not valid JSON; aborting."
exit 1
fi
version=${{ needs.get-release.outputs.release_version }}
universial_apk_url="https://github.com/readest/readest/releases/download/${{ needs.get-release.outputs.release_tag }}/Readest_${version}_universal.apk"
@@ -379,7 +390,18 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
curl -sL https://github.com/readest/readest/releases/latest/download/latest.json -o latest.json
# Use -f so curl fails on HTTP errors instead of writing the 404 body
# ("Not Found") into latest.json and clobbering the release asset with
# invalid JSON, which then breaks tauri-action's updater merge on every
# subsequent build.
if ! curl -fsSL https://github.com/readest/readest/releases/latest/download/latest.json -o latest.json; then
echo "::error::Failed to download existing latest.json; aborting to avoid clobbering the release asset."
exit 1
fi
if ! jq empty latest.json 2>/dev/null; then
echo "::error::Existing latest.json is not valid JSON; aborting."
exit 1
fi
version=${{ needs.get-release.outputs.release_version }}
arch=${{ matrix.config.arch }}
+2 -1
View File
@@ -14,7 +14,8 @@ on:
required: true
type: string
permissions: read-all
permissions:
contents: read
jobs:
upload-to-r2:
Generated
+340 -95
View File
@@ -33,6 +33,7 @@ dependencies = [
"tauri",
"tauri-build 2.6.2",
"tauri-plugin-cli",
"tauri-plugin-clipboard-manager",
"tauri-plugin-deep-link",
"tauri-plugin-device-info",
"tauri-plugin-dialog",
@@ -273,6 +274,27 @@ dependencies = [
"derive_arbitrary",
]
[[package]]
name = "arboard"
version = "3.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf"
dependencies = [
"clipboard-win",
"image",
"log",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation",
"parking_lot",
"percent-encoding",
"windows-sys 0.60.2",
"wl-clipboard-rs",
"x11rb",
]
[[package]]
name = "arc-swap"
version = "1.9.1"
@@ -581,7 +603,7 @@ dependencies = [
"quote",
"regex",
"rustc-hash 1.1.0",
"shlex",
"shlex 1.3.0",
"syn 2.0.117",
"which",
]
@@ -743,9 +765,9 @@ dependencies = [
[[package]]
name = "brotli"
version = "8.0.2"
version = "8.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560"
checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
@@ -754,9 +776,9 @@ dependencies = [
[[package]]
name = "brotli-decompressor"
version = "5.0.0"
version = "5.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03"
checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
@@ -930,14 +952,14 @@ dependencies = [
[[package]]
name = "cc"
version = "1.2.62"
version = "1.2.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98"
checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"shlex",
"shlex 2.0.1",
]
[[package]]
@@ -958,7 +980,7 @@ version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
dependencies = [
"nom",
"nom 7.1.3",
]
[[package]]
@@ -969,7 +991,7 @@ checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
dependencies = [
"byteorder",
"fnv",
"uuid 1.23.1",
"uuid 1.23.2",
]
[[package]]
@@ -984,12 +1006,12 @@ dependencies = [
[[package]]
name = "cfg-expr"
version = "0.20.7"
version = "0.20.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c6b04e07d8080154ed4ac03546d9a2b303cc2fe1901ba0b35b301516e289368"
checksum = "fb693542bcafa528e198be0ebd9d3632ca5b7c93dbe7237460e199910835997c"
dependencies = [
"smallvec",
"target-lexicon 0.13.3",
"target-lexicon 0.13.5",
]
[[package]]
@@ -1072,6 +1094,15 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "clipboard-win"
version = "5.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4"
dependencies = [
"error-code",
]
[[package]]
name = "cocoa"
version = "0.25.0"
@@ -1164,24 +1195,6 @@ dependencies = [
"version_check",
]
[[package]]
name = "cookie_store"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2eac901828f88a5241ee0600950ab981148a18f2f756900ffba1b125ca6a3ef9"
dependencies = [
"cookie",
"document-features",
"idna",
"log",
"publicsuffix",
"serde",
"serde_derive",
"serde_json",
"time",
"url",
]
[[package]]
name = "cookie_store"
version = "0.22.1"
@@ -1756,9 +1769,9 @@ dependencies = [
[[package]]
name = "displaydoc"
version = "0.2.5"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
dependencies = [
"proc-macro2",
"quote",
@@ -1841,6 +1854,12 @@ dependencies = [
"tendril 0.5.0",
]
[[package]]
name = "downcast-rs"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]]
name = "downcast-rs"
version = "2.0.2"
@@ -2031,6 +2050,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "error-code"
version = "3.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59"
[[package]]
name = "event-listener"
version = "5.4.1"
@@ -2081,6 +2106,12 @@ version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "fax"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a"
[[package]]
name = "fdeflate"
version = "0.3.7"
@@ -2134,6 +2165,12 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "fixedbitset"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]]
name = "flate2"
version = "1.1.9"
@@ -2838,6 +2875,17 @@ dependencies = [
"tracing",
]
[[package]]
name = "half"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
"zerocopy",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
@@ -3011,9 +3059,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
version = "1.9.0"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
dependencies = [
"atomic-waker",
"bytes",
@@ -3248,6 +3296,7 @@ dependencies = [
"moxcms",
"num-traits",
"png 0.18.1",
"tiff",
]
[[package]]
@@ -3692,9 +3741,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "libredox"
version = "0.1.16"
version = "0.1.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3"
dependencies = [
"libc",
]
@@ -3923,9 +3972,9 @@ checksum = "df39d232f5c40b0891c10216992c2f250c054105cb1e56f0fc9032db6203ecc1"
[[package]]
name = "memchr"
version = "2.8.0"
version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
[[package]]
name = "memmap2"
@@ -3997,9 +4046,9 @@ dependencies = [
[[package]]
name = "mio"
version = "1.2.0"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
dependencies = [
"libc",
"log",
@@ -4215,6 +4264,15 @@ dependencies = [
"minimal-lexical",
]
[[package]]
name = "nom"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
dependencies = [
"memchr",
]
[[package]]
name = "notify"
version = "8.2.0"
@@ -4992,6 +5050,17 @@ dependencies = [
"sha2",
]
[[package]]
name = "petgraph"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
dependencies = [
"fixedbitset",
"hashbrown 0.15.5",
"indexmap 2.14.0",
]
[[package]]
name = "phf"
version = "0.8.0"
@@ -5333,7 +5402,7 @@ version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
"toml_edit 0.25.11+spec-1.1.0",
"toml_edit 0.25.12+spec-1.1.0",
]
[[package]]
@@ -5487,6 +5556,12 @@ version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.39.4"
@@ -5877,7 +5952,7 @@ dependencies = [
"base64 0.22.1",
"bytes",
"cookie",
"cookie_store 0.22.1",
"cookie_store",
"encoding_rs",
"futures-core",
"futures-util",
@@ -6009,7 +6084,7 @@ dependencies = [
"rkyv_derive",
"seahash",
"tinyvec",
"uuid 1.23.1",
"uuid 1.23.2",
]
[[package]]
@@ -6214,7 +6289,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2"
dependencies = [
"fnv",
"quick-error",
"quick-error 1.2.3",
"tempfile",
"wait-timeout",
]
@@ -6264,7 +6339,7 @@ dependencies = [
"serde",
"serde_json",
"url",
"uuid 1.23.1",
"uuid 1.23.2",
]
[[package]]
@@ -6668,6 +6743,12 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "shuttle"
version = "0.8.1"
@@ -6785,9 +6866,9 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "socket2"
version = "0.6.3"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
dependencies = [
"libc",
"windows-sys 0.61.2",
@@ -7101,7 +7182,7 @@ version = "7.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7"
dependencies = [
"cfg-expr 0.20.7",
"cfg-expr 0.20.8",
"heck 0.5.0",
"pkg-config",
"toml 1.1.2+spec-1.1.0",
@@ -7124,7 +7205,7 @@ dependencies = [
"crc32fast",
"crossbeam-channel",
"datasketches",
"downcast-rs",
"downcast-rs 2.0.2",
"fastdivide",
"fnv",
"fs4",
@@ -7157,7 +7238,7 @@ dependencies = [
"thiserror 2.0.18",
"time",
"typetag",
"uuid 1.23.1",
"uuid 1.23.2",
"winapi",
]
@@ -7176,7 +7257,7 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c57166f5bcfd478f370ab8445afb4678dce44801fa5ce5c451aaf8595583c5dc"
dependencies = [
"downcast-rs",
"downcast-rs 2.0.2",
"fastdivide",
"itertools 0.14.0",
"serde",
@@ -7217,7 +7298,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfadb8526b6da90704feb293b0701a6aae62ea14983143344be2dc5ce30f1d82"
dependencies = [
"fnv",
"nom",
"nom 7.1.3",
"ordered-float",
"serde",
"serde_json",
@@ -7330,9 +7411,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]]
name = "target-lexicon"
version = "0.13.3"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c"
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
[[package]]
name = "tauri"
@@ -7388,7 +7469,7 @@ dependencies = [
"tracing",
"tray-icon",
"url",
"uuid 1.23.1",
"uuid 1.23.2",
"webkit2gtk",
"webview2-com",
"window-vibrancy",
@@ -7458,7 +7539,7 @@ dependencies = [
"thiserror 2.0.18",
"time",
"url",
"uuid 1.23.1",
"uuid 1.23.2",
"walkdir",
]
@@ -7505,6 +7586,21 @@ dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "tauri-plugin-clipboard-manager"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "206dc20af4ed210748ba945c2774e60fd0acd52b9a73a028402caf809e9b6ecf"
dependencies = [
"arboard",
"log",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
]
[[package]]
name = "tauri-plugin-deep-link"
version = "2.4.9"
@@ -7549,9 +7645,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-dialog"
version = "2.6.0"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9204b425d9be8d12aa60c2a83a289cf7d1caae40f57f336ed1155b3a5c0e359b"
checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884"
dependencies = [
"log",
"raw-window-handle",
@@ -7567,7 +7663,7 @@ dependencies = [
[[package]]
name = "tauri-plugin-fs"
version = "2.4.5"
version = "2.5.1"
dependencies = [
"anyhow",
"dunce",
@@ -7585,7 +7681,7 @@ dependencies = [
"tauri-plugin",
"tauri-utils 2.9.2",
"thiserror 2.0.18",
"toml 0.9.12+spec-1.1.0",
"toml 1.1.2+spec-1.1.0",
"url",
]
@@ -7605,12 +7701,12 @@ dependencies = [
[[package]]
name = "tauri-plugin-http"
version = "2.5.7"
version = "2.5.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8f069451c4e87e7e2636b7f065a4c52866c4ce5e60e2d53fa1038edb6d184dc"
checksum = "b5bd512048e1985b7ec78f96d99083e2ddaf7e0d906b2b63c44ce5bb8b894067"
dependencies = [
"bytes",
"cookie_store 0.21.1",
"cookie_store",
"data-url",
"http",
"regex",
@@ -7733,9 +7829,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-persisted-scope"
version = "2.3.5"
version = "2.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dcb33765b205abc72a1384d0a73489a00bae6ca1e151c5e824ac80115949d2e1"
checksum = "9b560a5962bf975d38fb4ec98a0e64e52929992ec708acb812add9c1ab8d186d"
dependencies = [
"aho-corasick",
"bincode",
@@ -7901,7 +7997,7 @@ dependencies = [
"thiserror 2.0.18",
"tokio",
"tracing",
"uuid 1.23.1",
"uuid 1.23.2",
"webkit2gtk",
"webview2-com",
"windows 0.61.3",
@@ -8037,7 +8133,7 @@ dependencies = [
"toml 1.1.2+spec-1.1.0",
"url",
"urlpattern",
"uuid 1.23.1",
"uuid 1.23.2",
"walkdir",
]
@@ -8053,11 +8149,9 @@ dependencies = [
"dom_query",
"dunce",
"glob",
"html5ever 0.29.1",
"http",
"infer",
"json-patch",
"kuchikiki",
"log",
"memchr",
"phf 0.13.1",
@@ -8076,7 +8170,7 @@ dependencies = [
"toml 1.1.2+spec-1.1.0",
"url",
"urlpattern",
"uuid 1.23.1",
"uuid 1.23.2",
"walkdir",
]
@@ -8174,6 +8268,20 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "tiff"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52"
dependencies = [
"fax",
"flate2",
"half",
"quick-error 2.0.1",
"weezl",
"zune-jpeg",
]
[[package]]
name = "time"
version = "0.3.47"
@@ -8413,9 +8521,9 @@ dependencies = [
[[package]]
name = "toml_edit"
version = "0.25.11+spec-1.1.0"
version = "0.25.12+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b"
checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7"
dependencies = [
"indexmap 2.14.0",
"toml_datetime 1.1.1+spec-1.1.0",
@@ -8581,6 +8689,17 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "tree_magic_mini"
version = "3.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
dependencies = [
"memchr",
"nom 8.0.0",
"petgraph",
]
[[package]]
name = "try-lock"
version = "0.2.5"
@@ -8682,7 +8801,7 @@ dependencies = [
"turso_parser",
"twox-hash",
"uncased",
"uuid 1.23.1",
"uuid 1.23.2",
"windows-sys 0.61.2",
]
@@ -8769,7 +8888,7 @@ dependencies = [
"tracing",
"turso_core",
"turso_parser",
"uuid 1.23.1",
"uuid 1.23.2",
]
[[package]]
@@ -8808,9 +8927,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
[[package]]
name = "typenum"
version = "1.20.0"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "typetag"
@@ -9025,9 +9144,9 @@ dependencies = [
[[package]]
name = "uuid"
version = "1.23.1"
version = "1.23.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7"
dependencies = [
"getrandom 0.4.2",
"js-sys",
@@ -9259,6 +9378,76 @@ dependencies = [
"semver",
]
[[package]]
name = "wayland-backend"
version = "0.3.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d"
dependencies = [
"cc",
"downcast-rs 1.2.1",
"rustix 1.1.4",
"smallvec",
"wayland-sys",
]
[[package]]
name = "wayland-client"
version = "0.31.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144"
dependencies = [
"bitflags 2.11.1",
"rustix 1.1.4",
"wayland-backend",
"wayland-scanner",
]
[[package]]
name = "wayland-protocols"
version = "0.32.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f"
dependencies = [
"bitflags 2.11.1",
"wayland-backend",
"wayland-client",
"wayland-scanner",
]
[[package]]
name = "wayland-protocols-wlr"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234"
dependencies = [
"bitflags 2.11.1",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-scanner",
]
[[package]]
name = "wayland-scanner"
version = "0.31.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a"
dependencies = [
"proc-macro2",
"quick-xml",
"quote",
]
[[package]]
name = "wayland-sys"
version = "0.31.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
dependencies = [
"pkg-config",
]
[[package]]
name = "web-sys"
version = "0.3.99"
@@ -9398,6 +9587,12 @@ dependencies = [
"windows-core 0.61.2",
]
[[package]]
name = "weezl"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]]
name = "which"
version = "4.4.2"
@@ -10199,6 +10394,24 @@ dependencies = [
"wasmparser",
]
[[package]]
name = "wl-clipboard-rs"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3"
dependencies = [
"libc",
"log",
"os_pipe",
"rustix 1.1.4",
"thiserror 2.0.18",
"tree_magic_mini",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-protocols-wlr",
]
[[package]]
name = "wmi"
version = "0.13.4"
@@ -10295,6 +10508,23 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "x11rb"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
dependencies = [
"gethostname",
"rustix 1.1.4",
"x11rb-protocol",
]
[[package]]
name = "x11rb-protocol"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
[[package]]
name = "xattr"
version = "1.6.1"
@@ -10341,9 +10571,9 @@ dependencies = [
[[package]]
name = "zbus"
version = "5.15.0"
version = "5.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3bcbf15c8708d7fc1be0c993622e0a5cbd5e8b52bfa40afa4c3e0cd8d724ac1"
checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285"
dependencies = [
"async-broadcast",
"async-executor",
@@ -10366,7 +10596,7 @@ dependencies = [
"serde_repr",
"tracing",
"uds_windows",
"uuid 1.23.1",
"uuid 1.23.2",
"windows-sys 0.61.2",
"winnow 1.0.3",
"zbus_macros",
@@ -10376,9 +10606,9 @@ dependencies = [
[[package]]
name = "zbus_macros"
version = "5.15.0"
version = "5.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51fa5406ad9175a8c825a931f8cf347116b531b3634fcb0b627c290f1f2516ff"
checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro2",
@@ -10402,18 +10632,18 @@ dependencies = [
[[package]]
name = "zerocopy"
version = "0.8.48"
version = "0.8.50"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.48"
version = "0.8.50"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639"
dependencies = [
"proc-macro2",
"quote",
@@ -10541,10 +10771,25 @@ dependencies = [
]
[[package]]
name = "zvariant"
version = "5.11.0"
name = "zune-core"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c1567a6ec68df868cbbfde844cfc6d81649fe5109a62b116b19fabd53e618ee"
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
[[package]]
name = "zune-jpeg"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
]
[[package]]
name = "zvariant"
version = "5.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0"
dependencies = [
"endi",
"enumflags2",
@@ -10556,9 +10801,9 @@ dependencies = [
[[package]]
name = "zvariant_derive"
version = "5.11.0"
version = "5.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7d5b780599bbde114e39d9a0799577fad1ced5105d38515745f7b3099d8ceda"
checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro2",
@@ -10569,9 +10814,9 @@ dependencies = [
[[package]]
name = "zvariant_utils"
version = "3.3.1"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d464f5733ffa07a3164d656f18533caace9d0638596721355d73256a410d691"
checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6"
dependencies = [
"proc-macro2",
"quote",
+25
View File
@@ -12,7 +12,11 @@
- [Issue #4112 scroll-anchoring](issue-4112-scroll-anchoring.md) — RESOLVED (PR #4349). Scroll-anchoring suppressed at scrollTop 0 when prepending a section in scrolled mode; fix patterns (prepend compensation, eager backward preload, no-blank nav) + test & dev-server gotchas
- [Reading ruler line/column-aware](reading-ruler-line-aware.md) — ruler snaps to real lines; multi-column band spans one column; Range.getClientRects() returns tall block boxes that must be dropped; iframe frame-offset mapping; synthetic-key throttling
- [TOC expand + auto-scroll](toc-expand-and-autoscroll.md) — #4059 collapse-by-default policy in `tocTree.ts`; pinned-sidebar mounts before progress → dynamic expansion breaks scroll-to-current via (1) spurious onScroll clearing pending and (2) Virtuoso scrollToIndex landing short after row growth (re-assert on rAF)
- [BooknoteView auto-scroll (#4352)](booknote-view-autoscroll-4352.md) — virtualizing the annotation/bookmark list dropped auto-scroll-to-nearest; two paths (reload: OverlayScrollbars resets scrollTop → re-apply in `initialized` via ref; tab-switch: synchronous scrollToIndex on fresh-mounted list wedges Virtuoso → use `initialTopMostItemIndex` + skip-gate). Mirrors TOCView. Includes dev-server/Fast-Refresh/screenshot-vs-DOM verification gotchas
- [Swipe page-turn bg flash](paginator-swipe-bg-flash.md) — white↔black flash on swipe+animation only; `#background` was static screen-space and didn't track content during drag/snap; fix = sliding per-view full-bleed segments (`computeBackgroundSegments`) rebuilt on scroll + per-rAF synced to the view transform during snap
- [Duokan fullscreen cover hidden in scroll mode](duokan-fullscreen-cover-scroll.md) — #4379 `data-duokan-page-fullscreen` cover pinned `position:absolute height:100%` collapses against auto-height scroll container; gate fullscreen on `this.#column` + reset stale absolute props on toggle (`setImageSize` in paginator.js)
- [Paginated texture occlusion](paginated-texture-occlusion-4399.md) — #4399 host `.foliate-viewer::before` texture absent in paginated (shown in scrolled); opaque `#background` container (`= fallbackBg`) from the swipe-flash fix occludes it; shared `textureAwareBackground` helper + `hasTexture ? '' : fallbackBg` container
- [Background overflows column (#4394, PR #4429)](paginator-gutter-bleed-asymmetry-4394.md) — paginated page bg stretched into the outer `--_outer-min` gutter → mixed cover/title 2-up spread shifted off-centre (~250px at 1920px). KEEP the grid (`--_outer-min` keeps margins symmetric); fix = clamp `computeBackgroundSegments` to `[containerStart,containerEnd]` (Math.max/Math.min) so bg stays in its column. 2 wrong tries first (bleed-gating, "page shouldn't be yellow"); foliate submodule needs dev-server RESTART to pick up edits
## Critical Files (Most Bug-Prone)
- `src/utils/style.ts` - Central EPUB CSS transformation hub (14+ bug fixes)
@@ -25,6 +29,16 @@
## Sync Notes
- [KOSync CFI spine resolution](kosync-cfi-spine-resolution.md) — convert via the CFI's own spine (`getXPointerFromCFI`/`getCFIFromXPointer`), never `new XCFI(primaryDoc, primaryIndex)`; primaryIndex lags during scroll → spine-mismatch throw
- [Empty-start CFI sync bug](empty-start-cfi-sync.md) — `epubcfi(/6/24!/4,,/20/1:58)` (empty-start range) from the cfi-inert skip-link transitional window; jumps to wrong section end; `isMalformedLocationCfi` → discard the synced value in `useProgressSync` (NOT the local open path); foliate fix doesn't repair already-synced values
- [Custom fonts disappear on cloud sync (#4410)](custom-fonts-reincarnation-4410.md) — CRDT remove-wins: re-import-after-delete needs a `reincarnation` token or the pull re-applies the tombstone; `addFont`/`addTexture` minted none; fix mirrors dictionary (both cases) + OPDS token style; coverage matrix per kind
## Testing
- [Tauri Rust↔JS parser parity tests](tauri-parser-parity-tests.md) — #4369 native Rust EPUB/MOBI parser; how to cross-check vs foliate-js in the `.tauri.test.ts` WebView suite (CWD disk path for Rust, Vite URL for JS, normalizer-based compare, cover presence-only, desc whitespace-collapse); the `dcterms:modified``published` divergence fix
## Build & Vendoring
- [pdfjs vendor wasm decoders](pdfjs-vendor-wasm-decoders.md) — scanned PDFs blank in CI build only (0.11.2 regression); pdfjs 5.7.x moved JBIG2 to `jbig2.wasm`, `copy-pdfjs-wasm` allow-list dropped it; `cpx` no-errors on empty glob; local stale `public/vendor` (gitignored, not refreshed by `tauri build`) masked it; fix = copy `wasm/*`
## Platform Compat
- [Window-state sanitizer (#4398)](window-state-sanitize-4398.md) — Windows launch crash (WebView2 0x80070057) from invalid `.window-state.json` (`-32000` minimized sentinel / `0×0`); our plugin already has upstream #253 fix so bad files are stale; defense-in-depth `window-state-sanitizer` plugin registered BEFORE window-state (plugin init = registration order); coord threshold `-16000` (~halfway to the -32000 sentinel; real desktops sit a few thousand px off origin) keeps multi-monitor negatives
## Feature Notes
- [Manage Cache + iOS container layout](manage-cache-ios-layout.md) — `'Cache'` base = `Library/Caches/<bundle>` only (not all of Caches); iOS `Documents/Inbox` cleared too; WebKit cache + tmp out of reach; never touch App Support
@@ -32,11 +46,21 @@
- [Cloudflare Workers WebSocket](cloudflare-workers-websocket.md) — use fetch() Upgrade pattern (not `ws` npm); CF delivers binary frames as Blob (must serialize async decodes)
- [Share-a-Book Feature (in progress)](share-feature.md) — locked decisions for the /s/{token} share-link feature; plan at ~/.claude/plans/ok-we-will-learn-cosmic-acorn.md
- [readest.koplugin i18n](koplugin-i18n.md) — gettext loader at `apps/readest.koplugin/i18n.lua`, `.po` catalog at `locales/<i18next-code>/translation.po`, extract/apply scripts in `scripts/`
- [koplugin cover upload](koplugin-cover-upload.md) — #4374 uploadBook only shipped cached cloud covers; local-origin books uploaded blank. Fix = `extractLocalCover` via `FileManagerBookInfo:getCoverImage(nil, file)``writeToFile(path,"png")`. KOReader checkout at `/Users/chrox/dev/koreader`
## Patterns
- [Virtuoso + OverlayScrollbars](virtuoso_overlayscrollbars.md) — useOverlayScrollbars hook integration for overlay scrollbars on mobile webviews
- [Design system → DESIGN.md](feedback_design_system_doc.md) — codify recurring UI/UX rules in `apps/readest-app/DESIGN.md`; never `pl/pr/ml/mr/text-left/text-right` (RTL); §5 boxed list anatomy has uniform `min-h-14` rows and chromeless controls
## Reader UI Fixes
- [Android image callout freeze](android-image-callout-freeze.md) — long-press `<img>` fires WebView native callout that collides with app touch handlers → whole-app freeze; `-webkit-touch-callout: none` doesn't inherit so put `.no-context-menu` on an ancestor of the image (`.no-context-menu img` rule in globals.css); seen on book covers (#4345) + image preview/zoom (#4420, `ImageViewer.tsx`)
- [ProgressBar focus-ring line (#4397)](progressbar-focus-ring-4397.md) — decorative `.progressinfo` footer was `tabIndex={-1}` → Android long-press focused it → stray content-width focus-ring line at the bottom every page; fix = drop tabIndex (role='presentation' must not be focusable); ffmpeg-the-video debugging + live-browser `:focus-visible` confirmation
- [Table dark-mode tint regression (#4419)](table-dark-mode-tint-4419.md) — `blockquote, table *` color-mix tint in `getColorStyles` must stay gated on `overrideColor` (gate added #2377, removed #4055, re-broke → #4419); safe now that #4392 light-bg rewriters handle #4028 zebra legibility; SAME rule paints vertical-TOC `.space`/▉ spacer cells (▉ U+2589 = blank glyph, contours=0) → "spacing changes" symptom; both fixed by the gate
## Library Fixes
- [Tauri menu append race (#4389)](tauri-menu-append-race-4389.md) — un-awaited `Menu.append()` (async IPC) in `BookshelfItem.tsx` → context-menu items shuffle order every open (native only, invisible in jsdom); fix = single `await Menu.new({ items })` of ordered `MenuItemOptions`; order/inclusion extracted to pure `getBookContextMenuItemIds` for unit testing
- [TXT author recognition (#4390)](txt-author-recognition-4390.md) — 【】-titled Chinese web-novels show author missing/garbage; they're TXT→EPUB (title==full filename is the tell, check `txt.ts` not foliate-js); `extractTxtFilenameMetadata` only handled 《》 + greedy header capture grabbed metadata blobs; fix = `parseLabeledAuthor` for any filename + `isPlausibleAuthorName` guard
## Architecture Notes
- foliate-js is a git submodule at `packages/foliate-js/`
- Multiview paginator: loads adjacent sections in background, multiple View/Overlayer instances per book
@@ -46,6 +70,7 @@
- Safe area insets flow: Native plugin -> useSafeAreaInsets hook -> component styles
- Dropdown menus use `DropdownContext` (not blur-based) for screen reader compat
- [Foliate touch-listener capture phase](foliate-touch-listener-capture-phase.md) — to suppress reader gestures from the app, use `{capture:true}`; the paginator registers bubble-phase doc listeners first (during `view.open()`)
- [iframe cross-realm instanceof](iframe-cross-realm-instanceof.md) — app-bundle code (style.ts, iframeEventHandlers.ts) runs in top realm; `iframeEl instanceof Element` is ALWAYS false → guards silently drop all iframe elements (passes jsdom, dead in app). Duck-type `'closest' in target` instead. Bit PR #4391's touch routing + applyTableStyle dedupe
## Workflow
- [Test file filter](feedback_test_file_filter.md) — use `pnpm test <path>` without `--` to run a single file
@@ -0,0 +1,31 @@
---
name: android-image-callout-freeze
description: "Android WebView native long-press image callout collides with app touch handlers and freezes the app; reusable `.no-context-menu` fix"
metadata:
node_type: memory
type: project
originSessionId: 50bec34f-7090-4bf4-a194-9bf4029527bf
---
Recurring Android-only freeze: long-pressing an `<img>` triggers the WebView's
native image callout (context menu / drag / magnifier) which collides with the
app's own touch handlers (long-press multi-select, or pinch/pan) and freezes the
whole app until restart.
**Root cause:** `-webkit-touch-callout: none` does NOT inherit, so a
`.no-context-menu` class on a *container* never reaches descendant images.
**Fix:** the `.no-context-menu img, .no-context-menu a` rule in
`src/styles/globals.css` (sets `-webkit-touch-callout: none; -webkit-user-drag:
none; user-select: none`). Apply the `no-context-menu` class to an *ancestor* of
the image so the descendant rule reaches it. Harmless on desktop
(`-webkit-touch-callout` is a no-op there; right-click-save still works).
Occurrences so far:
- Book covers on the bookshelf — PR #4345 (`BookshelfItem.tsx`, gated on
`appService?.isMobileApp`; added the `.no-context-menu img` rule).
- Image preview / zoom viewer — issue #4420, `ImageViewer.tsx` root container
(applied unconditionally — no selectable text there, so no need to gate).
**How to apply:** when a new "Android freezes on long-press of an image" report
comes in, find the `<img>` and put `no-context-menu` on a containing element.
@@ -0,0 +1,48 @@
---
name: booknote-view-autoscroll-4352
description: "Annotation/bookmark list (BooknoteView) auto-scroll-to-nearest regression after virtualization (#4352) and its TOCView-mirroring fix"
metadata:
node_type: memory
type: project
originSessionId: bda988b9-28ec-450f-874e-ee9c104f7603
---
After #4352 virtualized `BooknoteView` (sidebar annotations/bookmarks list,
`src/app/reader/components/sidebar/BooknoteView.tsx`), the list stopped
auto-scrolling to the nearest annotation for the current reading position (it
stranded at the top showing Chapter 1). #4352 replaced the per-item
`useScrollToItem` with a single `virtuosoRef.scrollToIndex`, but missed the
machinery `TOCView` already had. Two distinct failure paths:
1. **Reload (annotations tab active at load; progress arrives AFTER mount):** the
OverlayScrollbars `initialized` callback (deferred init) resets the wrapped
viewport `scrollTop` to 0, clobbering the scroll; the `lastScrolledCfiRef`
guard then blocks any retry. Fix = re-apply `scrollToIndex` to the *current*
nearest index inside the `initialized` callback, read via a **ref**
(`nearestIndexRef`) because that callback is the mount-time closure. Double
rAF (settle the reset, then re-assert once rows are measured).
2. **Tab-switch (open panel while reading; progress KNOWN at mount):** firing a
`scrollToIndex` synchronously on the freshly mounted, unmeasured list either
no-ops (`behavior:'smooth'`) or **wedges Virtuoso into rendering nothing**
(`behavior:'auto'`). Fix = mount Virtuoso *natively* centered via
`initialTopMostItemIndex` + an `initialScrollHandledRef` gate so the scroll
effect SKIPS that first jump. This is exactly TOCView's design.
The fix mirrors [[toc-expand-and-autoscroll]] (same OverlayScrollbars-resets-
scrollTop + Virtuoso-lands-short-on-unmeasured-rows pattern). Test:
`src/__tests__/components/BooknoteView.test.tsx` stubs Virtuoso (spy-able
`scrollToIndex`, captures `initialTopMostItemIndex`) and captures the mount-time
`initialized` callback — forcing a ref-based fix (modeled on `TOCView.test.tsx`).
**Dev-server verification gotchas (cost hours here):**
- The `localhost:3000` dev server was running from a *different worktree*
(`/Users/chrox/dev/readest-fix-4394-bg-gutter-bleed`), not the main checkout.
Edits weren't compiled until copied into that worktree's path. Check
`ps aux | grep next-server` for the serving cwd. Book data is per-origin
(OPFS/IndexedDB on localhost:3000) so you can't verify on another port.
- After ~10 rapid file syncs, **Fast Refresh corrupts the mounted tab's state**
— identical code that worked started rendering 0 items. A *brand-new tab*
(close the old one) renders correctly. Always verify in a fresh tab.
- Chrome MCP `javascript_tool` querying `.booknote-item` count catches Virtuoso
mid-render (returns 0 even when it later paints fine). Trust the *screenshot*
(the painted frame), not a synchronous DOM count, for virtualized lists.
@@ -0,0 +1,27 @@
---
name: custom-fonts-reincarnation-4410
description: Custom fonts/textures disappear when logged into cloud sync after re-import-after-delete; CRDT remove-wins needs a reincarnation token
metadata:
node_type: memory
type: project
originSessionId: 2b61b392-4d32-4516-84bd-f362bba22378
---
# #4410 — disappearing custom fonts when logged into cloud
**Symptom:** custom fonts vanish a few seconds after opening a book (or ~1 min idle) ONLY when logged into cloud sync; logging out fixes it; a brand-new never-deleted font is fine; problem starts after deleting a font / "Clear Custom Fonts" then re-uploading the same file.
**Root cause — CRDT remove-wins.** The replica sync (`src/libs/crdt.ts`, `src/libs/replicaInterpret.ts`) is remove-wins: once a row has a `deleted_at_ts` tombstone, a plain field upsert does NOT revive it. Only a `reincarnation` token whose effective HLC beats the tombstone revives it. `isReplicaRowAlive(row)` = `!deleted_at_ts || (reincarnation && updated_at_ts >= deleted_at_ts)`. In `mergeReplica` the reincarnation candidate's timestamp is the **row's `updated_at_ts`** (fresh on every upsert), NOT the token's mint time — so preserving an old token still revives, as long as the upsert carries it.
Flow that broke: import→delete writes a server tombstone (`publishReplicaDelete`). Re-upload same file → same `contentId``addFont` cleared `deletedAt` locally and called `publishFontUpsert` with `reincarnation = undefined` → server tombstone survives → next pull (boot 5s / periodic / book-open / visibility) sees `isReplicaRowAlive===false``softDeleteByContentId` → font disappears.
**Fix (PR for #4410):** in `addFont` (`src/store/customFontStore.ts`) and `addTexture` (`src/store/customTextureStore.ts`), when re-adding an existing entry, mint a reincarnation token (`Math.random().toString(36).slice(2)`, matching OPDS) when `!!contentId && !existing.reincarnation && (existing.deletedAt || existing.contentId === new.contentId)`; otherwise preserve `existing.reincarnation`. Covers both re-import-after-local-delete AND the stale-local race (local still live but another device tombstoned the row). Token is inert without a tombstone, so live re-imports are safe.
**Coverage matrix across collection kinds (all share the remove-wins replica):**
- Dictionary — handles BOTH cases (gold standard): `dictionaryService.ts importDictionaries` via `findTombstonedDictionaryMatches` + `shouldMintReincarnationForLiveReimport` (helpers in `dictionaries/dictionaryDedup.ts`), mints `uuidv4()`.
- OPDS — case 1 only: `customOPDSStore.addCatalog` (`existing?.deletedAt && !input.reincarnation`).
- Fonts / Textures — handled NEITHER → this bug. Now fixed to dictionary-parity.
Whole chain carries the token: returned font → `publishFontUpsert` upsert AND `queueReplicaBinaryUpload` → manifest publish (`replicaBinaryUpload.ts` uses `record.reincarnation`).
Note: `saveCustomFonts`/`saveCustomTextures` persist tombstoned (deletedAt) entries too, so the soft-deleted entry is still in the store at re-import time → the `existing.deletedAt` branch fires. (OPDS strips deleted at save; fonts/textures keep them.)
@@ -0,0 +1,21 @@
---
name: duokan-fullscreen-cover-scroll
description: "Duokan fullscreen cover image invisible in scrolled mode (#4379) — paginator pins it position:absolute height:100% which collapses against auto-height scroll container"
metadata:
node_type: memory
type: project
originSessionId: c45aabf0-e8a3-42b6-a5fd-c04d6eb2345c
---
Issue #4379: an EPUB cover with `data-duokan-page-fullscreen` on `<html>` (Duokan/DangDang convention) shows in paginated mode but is **blank in scrolled mode**; only the first cover image, other images fine in both modes.
**Root cause**`View.setImageSize()` in `packages/foliate-js/paginator.js` has a `pageFullscreen` branch that pins each img with `position:absolute; inset:0; width:100%; height:100%` and forces ancestors + `<html>` to `height:100%`/`position:relative`. This fills the page in paginated/columnized mode (html has a fixed pixel height). In scrolled mode `scrolled()` sets `html`/`body` height to `auto`, so the `height:100%` chain resolves to **0** and the absolutely-positioned cover collapses out of view (offsetHeight 0).
**Fix** — gate the fullscreen treatment on column mode: `const applyFullscreen = pageFullscreen && this.#column`. Use `applyFullscreen` for the max-height/max-width margin term and the `if` block. Add an `else if (pageFullscreen)` that `removeProperty`s the stale `position/inset/width/height/margin` on the img (and `width/height/margin/padding` on ancestors, `position` on html) so toggling paginated→scrolled doesn't leave the cover collapsed (same iframe/img is reused via `view.render(layout)` on `flow` change). In scrolled mode the cover then flows like a normal full-page image bounded by `max-height = availableHeight`.
**Key facts**
- `this.#column = layout.flow !== 'scrolled'` (set in `render()` before `setImageSize`), so it's reliable inside `setImageSize`.
- Foliate writes these styles as **inline `!important`** → cannot be overridden from `src/utils/style.ts`; the fix must live in the paginator.
- Regression test: `src/__tests__/document/paginator-duokan-cover.browser.test.ts` + fixture `repro-4379.epub` (cover xhtml with the duokan attr + dimensionless `<img>`). Asserts cover `img.offsetHeight > 0` in scrolled mode, paginated sanity, and paginated→scrolled toggle. Browser test (real layout) is required — jsdom can't compute the collapse.
Related: [[paginator-swipe-bg-flash]], [[css-style-fixes]].
@@ -0,0 +1,25 @@
---
name: iframe-cross-realm-instanceof
description: "App-bundle code handling foliate iframe DOM must not use `instanceof Element/HTMLElement` — cross-realm, always false"
metadata:
node_type: memory
type: project
originSessionId: 2e8d274e-a4da-4d63-8afb-d7a600d560b2
---
Reader content lives in foliate iframes, each with its **own JS realm**. App-bundle
code (e.g. `src/utils/style.ts`, `iframeEventHandlers.ts`) runs in the **top window
realm**, so its `Element`/`HTMLElement` constructors differ from the iframe's.
`someIframeEl instanceof Element` (top-realm `Element`) is **always `false`** — verified:
`Element === iframeWin.Element` is false. So any guard like `if (!(target instanceof Element)) return`
silently drops every real iframe element. Functions still pass jsdom unit tests (single realm)
yet are dead in the running app.
**Use duck-typing instead**: `if (!target || !('closest' in target)) return;` then
`(target as Element).closest(...)`. For node-type checks use `node.nodeType === Node.ELEMENT_NODE`
(numeric constant, realm-safe) or check `classList`/`closest` presence.
Seen in PR #4391 (wide-table horizontal scroll): the touch `findWrapper` and `applyTableStyle`'s
re-entrancy guard both used `instanceof`, so the touch routing never fired and the dedupe guard
never tripped. Related: [[foliate-touch-listener-capture-phase]].
@@ -0,0 +1,18 @@
---
name: koplugin-cover-upload
description: "koplugin cover system — where local/cloud covers come from, and the"
metadata:
node_type: memory
type: reference
originSessionId: 95cdff70-ce2c-4077-82a8-922f9578a22f
---
readest.koplugin cover handling (in `apps/readest.koplugin/library/`):
- **Local book covers** come from coverbrowser.koplugin's `BookInfoManager` (a hard dependency). `coverprovider.get_local_cover(file_path)` returns `BIM:getBookInfo(file_path, true).cover_bb` (a downscaled thumbnail bb) and kicks off background extraction on a miss.
- **Native-resolution cover** for a file (no open doc needed): `require("apps/filemanager/filemanagerbookinfo"):getCoverImage(nil, file_path)` — opens+closes the doc itself, honors KOReader custom covers, returns a blitbuffer. Same `(nil, file)` call form `calibre.koplugin` uses. Write it to PNG with `bb:writeToFile(path, "png")` (pcall-wrapped internally) then `bb:free()`.
- **Cloud covers** are downloaded to `DataStorage:getSettingsDir()/readest_covers/<hash>.png` (`cloud_covers.covers_dir()`); cloud storage key is `<user_id>/Readest/Books/<hash>/cover.png` (`build_cover_key`), which matches readest's `getCoverFilename` (`<hash>/cover.png`).
**Issue #4374 (fixed):** `syncbooks.uploadBook` only shipped a `cover.png` if one was *already cached* under `readest_covers/<hash>.png` from a prior cloud download — so books that originated locally in KOReader uploaded with no cover and showed blank in readest. Fix = `syncbooks.extractLocalCover(file_path, dst_png)` (extracts via `getCoverImage` → writeToFile), called from `uploadBook` when `has_cover` is false. Only file-upload path is `librarywidget.lua` "Upload to Cloud" → `uploadBook` (FileManager `addToReadest` only stages a local row).
Tests: network/live parts of `syncbooks.lua` aren't unit-tested (manual matrix in `docs/library-design.md`); `extractLocalCover` IS tested by injecting a fake `apps/filemanager/filemanagerbookinfo` via `package.loaded` in `spec/library/syncbooks_spec.lua`. A real KOReader checkout lives at `/Users/chrox/dev/koreader` for verifying KOReader APIs. See [[koplugin-i18n]].
@@ -0,0 +1,47 @@
---
name: paginated-texture-occlusion-4399
description: Background texture absent in paginated mode (shown in scrolled) — opaque
metadata:
node_type: memory
type: project
originSessionId: 1bd8a73e-f279-4ed8-9562-98e96d9723d5
---
RESOLVED — merged. foliate-js `142bf11` (on main) + readest pointer bump/test
(`fix(reader): show background texture in paginated mode (#4399)`).
#4399: a background texture (Settings → Color → texture, e.g. Leaves) shows in
**scrolled** mode but is **absent in paginated** mode. The texture is NOT in the
iframe — it's mounted on the HOST as `.foliate-viewer::before` (`src/styles/textures.ts`,
`mountBackgroundTexture`): `position:absolute; inset:0; z-index:0; mix-blend-mode:multiply;
opacity:.6` over the reader container. For it to show, the whole foliate view tree
(iframe page bg + the paginator `#background` layer) must be transparent so the host
`::before` composites over the white page fill that a **parent** element provides
(`oklch(1 0 0)` on the reader grid cell, NOT on `.foliate-viewer`, which is transparent).
**Root cause.** foliate-js `paginator.js` `#replaceBackground`. Scrolled mode already
left things transparent under a texture (`#background.style.background = hasTexture ? '' : fallbackBg`
+ blanking transparent view elements). The **paginated** branch hard-set
`this.#background.style.background = fallbackBg` (opaque theme color, e.g. white) on the
segment **container** — that opaque container, a descendant of `.foliate-viewer`, paints
over the host `::before` texture. The per-view *segments* were already transparent for
transparent pages (`rgba(0,0,0,0)`); only the container was the occluder. Verified live
via Chrome MCP: `#background` inline bg was `rgb(255,255,255)`; setting it `''` revealed
the leaves texture instantly.
**Regression** = commit `167757a` "Fix background flash when swiping between
differently-colored pages" (2026-05-31, see [[paginator-swipe-bg-flash]]). The OLD
paginated path was a CSS `grid` of per-column divs and never set the `#background`
container background (kept the `''` reset), so a transparent page let the texture through.
167757a swapped to `computeBackgroundSegments` and added the opaque container line.
**Fix.** Extract a shared exported pure helper `textureAwareBackground(resolved, hasTexture)`
→ returns `''` when `hasTexture && isTransparent(resolved)`, else `resolved`. Use it for
BOTH scrolled view elements and paginated segment bgs, and set the paginated container
`this.#background.style.background = hasTexture ? '' : fallbackBg` (mirroring scrolled).
No-texture path unchanged → swipe-flash fix for colored pages fully preserved; a
book-forced opaque page still paints its segment (texture correctly does not show there).
Test: `src/__tests__/document/paginator-background-segments.test.ts` (new `describe` for
the pure helper, alongside `computeBackgroundSegments`). foliate-js is a submodule —
commit there + bump the pointer. See [[paginator-swipe-bg-flash]].
@@ -0,0 +1,71 @@
---
name: paginator-gutter-bleed-asymmetry-4394
description: "Paginated page background overflowed its column into the outer --_outer-min gutter (asymmetric on mixed spreads); fix = clamp computeBackgroundSegments to the content area, keep the grid"
metadata:
node_type: memory
type: project
originSessionId: 108a70bd-5af9-4a31-9b93-9d6df7637579
---
#4394 "page viewport offset / content clipping." On a 2-up cover spread the
left page (cover `<img>`, transparent page bg) had a white outer gutter while
the right page (`<body class="c3">` body-colour yellow) bled its colour past its
column into the right outer gutter → spread looked shifted right. On a wide
screen (1920px, 720 max-inline) the gutters are ~250px so it reads as a "massive
white blank gap." 0.10.6 filled colour edge-to-edge; 0.11.2 regressed.
**Geometry.** Grid `#top` = `minmax(--_outer-min-left,1fr) margin-left
minmax(0,maxw) margin-right minmax(--_outer-min-right,1fr)` (foliate
`paginator.js`). `#container` = grid-column 2/5 (inset by the outer gutter
tracks); `#background` = grid-column 1/-1 (spans the gutters). `--_outer-min =
(col_count-1)*(margin/4 + gap/4)` — non-zero ONLY in multi-column (0 in
1-column), added in commit `0a0ceda` so the outer margin matches half the
inter-column gap (symmetric spacing). `inset = containerLeft - bgLeft =
--_outer-min-left`.
**Cause.** `computeBackgroundSegments` (the swipe-flash fix, commit `167757a`)
positioned each page's colour segment at `inset + offset - scrollPos` and then
STRETCHED any segment touching a container edge OUT to the bg edge (`start=0` /
`end=bgSize`) — i.e. bled into the outer gutter. A body-coloured page bled into
its gutter; a transparent/image page (cover) did not (its `#background` is
transparent; its `<img>` is clamped inside the inset `#container`). Mixed spread
→ one gutter coloured, one not → asymmetric.
**Fix (what the maintainer wanted).** Do NOT touch `--_outer-min` (it keeps the
left/right margins symmetric with the centre gap). Instead CLAMP each segment to
the content area so the background stays inside its column and never overflows
into the outer gutter:
```js
const start = Math.max(segStart, containerStart) // was: if(...) start = 0
const end = Math.min(segEnd, containerEnd) // was: if(...) end = bgSize
if (end <= start) continue
```
`containerStart=inset`, `containerEnd=inset+containerSize`. In single-column the
gutters are 0 (`--_outer-min`=0 → inset 0) so this still fills the viewport edge
to edge (matches 0.10.6); in multi-column each page stays in its column with
symmetric gutter margins. Image pages (cover/彩页) were already correct (`<img>`
sits in its column); only the body-colour `#background` overflow needed fixing.
**Dead ends (took 2 wrong tries before the maintainer steered me right).**
1. Thought it was the gutter-bleed and "gated" the stretch on both-edges-coloured
— maintainer: "nothing to do with the gutter" (meaning don't change the bleed
GATING, the real issue is the offset).
2. Thought the title page shouldn't be yellow at all — it genuinely is
`.c3{background:#ffe43f}` on `<body>`, captured as `docBackground` and painted
full-bleed (body is zeroed via `doc.body.style.background='none'`, the old
f087826 "dismiss iframe background, paint via root" design). Yellow is correct,
it was just overflowing.
3. Proposed dropping `--_outer-min` (revert `0a0ceda`) — maintainer rejected:
that desymmetrises the centre-vs-side gaps. Keep the grid; fix the background.
**Verifying live.** foliate-js is a submodule; `next dev` / turbopack did NOT
hot-reload an edit to `packages/foliate-js/paginator.js` even across full
navigations — had to RESTART the dev server (`pnpm dev-web`, port 3000) to pick
it up. Cheap interim check: clamp the live `#background` segment divs in the page
and screenshot. Segment colours are render-timing dependent (a probe right after
nav can show all-transparent before the body-colour is captured).
Tests: `apps/readest-app/src/__tests__/document/paginator-background-segments.test.ts`
(tests "centered page" + "two-up spread" flipped from full-bleed to confined; the
inset=0 swipe-flash tests are unchanged). Related: [[paginator-swipe-bg-flash]]
[[paginated-texture-occlusion-4399]].
@@ -0,0 +1,21 @@
---
name: pdfjs-vendor-wasm-decoders
description: Scanned PDFs blank in CI builds but fine locally — pdfjs wasm decoders (jbig2.wasm) not copied to public/vendor/pdfjs
metadata:
node_type: memory
type: project
originSessionId: 9066c0b0-71a6-4db0-92c1-c3ccacf1ff0d
---
CI-built DMG rendered scanned PDFs as blank pages (could still turn pages); text PDFs and EPUBs fine. Local build of the same commit worked. Regression between 0.11.1 (fine) and 0.11.2 (broken).
**Root cause:** `pdfjs-dist` was bumped 5.4.530 → 5.7.284 in #4143 (commit e8df651d5, between the 0.11.1 and 0.11.2 tags). pdfjs 5.7.x moved image decoders — notably **JBIG2** (the codec used by virtually every black-and-white *scanned* PDF) — from pure JS into WebAssembly modules the worker fetches at runtime from `wasmUrl` (`/vendor/pdfjs/`, set in `packages/foliate-js/pdf.js`: `wasmUrl: pdfjsPath('')`). The `copy-pdfjs-wasm` npm script only copied an allow-list `{openjpeg.wasm,qcms_bg.wasm}` and silently dropped `jbig2.wasm`. **`cpx` does not error when a glob matches nothing**, so the missing decoder was invisible: worker loads, pages turn, JBIG2 decode fails → blank.
**Why local masked it:** `pnpm build` / `tauri build` do NOT run `setup-vendors`. Local builds reuse whatever stale `public/vendor/pdfjs/` is already on disk (gitignored — `/public/vendor`). The dev's local copy was the old 5.4.530 worker (pure-JS JBIG2) → worked. CI runs `setup-vendors` fresh (release.yml:192) → ships the new 5.7.284 worker that needs jbig2.wasm → broke.
**Fix:** changed `copy-pdfjs-wasm` to copy the whole `wasm/*` dir instead of an allow-list (mirrors the `{cmaps,standard_fonts}/*` fonts pattern). Robust against future codecs moving to wasm; also ships the `*_nowasm_fallback.js` files for graceful degradation. Regression test: `src/__tests__/document/pdfjs-wasm-assets.test.ts` asserts every `.wasm` the bundled pdf.js references is covered by `copy-pdfjs-wasm`.
**Gotchas for future:**
- Vendor assets in `public/vendor/` are gitignored and only refreshed by `setup-vendors`. Local stale vendor can mask CI breakage — `git status` won't show it.
- `cpx` allow-lists are fragile: any upstream-added required file is dropped silently. Prefer copying whole dirs.
- Related: [[platform-compat-fixes]], [[bug-patterns]].
@@ -0,0 +1,18 @@
---
name: progressbar-focus-ring-4397
description: ProgressBar footer painted a stray focus-ring line on Android long-press; fix = remove tabIndex from the decorative div
metadata:
node_type: memory
type: project
originSessionId: 7d650c44-2d60-4a88-a9f5-6b5956bca59b
---
#4397 "Strange line on the bottom of the page when long-pressing the footer" (Android 13, 0.11.2).
**Symptom:** a thin horizontal line, content-column wide, across the last text line / bottom margin. Appears after long-pressing the footer + turning a page; persists across page turns; cleared by double-tapping the footer + turning a page. Maintainer couldn't repro on eink (device/WebView-specific long-press focus behavior).
**Root cause:** `ProgressBar.tsx` (the always-on page-info footer, `.progressinfo`) rendered as `<div role='presentation' tabIndex={-1} onClick=...>`. The `tabIndex={-1}` made the decorative div click/touch-focusable. Android WebView long-press focused it and painted the browser default focus ring (`outline: auto`, matched `:focus-visible`). It's a top-document element pinned `absolute bottom-0` at book-view width, so the ring shows as a content-width box at the bottom on *every* page until focus clears. NOT a range/slider input — those are `opacity-0`/`visibility:hidden` (red herring: globals.css excludes `input[type='range']` from its outline-suppression rule, but that's unrelated here).
**Fix:** remove `tabIndex={-1}`. A `role='presentation'` decorative element must not be focusable. `onClick` (tap-to-cycle progress mode / dismiss annotation popup) fires regardless of tabindex, and no ancestor has a real `tabindex` to grab focus instead (verified: focus falls through to `<body>`). Nothing programmatically focuses `.progressinfo`. Test asserts `.progressinfo` has no `tabindex` attribute (in `ProgressBar.test.tsx`).
**Debugging technique that worked:** download the issue video (`gh` attachment URL) → `ffmpeg` extract frames → zoom on the line; the **downward corner at the right end** revealed it was a rectangular *outline* (focus box), not a strikethrough/selection. Then in the live dev app (`mcp__claude-in-chrome__javascript_tool`): `el.focus(); el.matches(':focus-visible'); getComputedStyle(el).outlineStyle` → returned `true` / `auto`, confirming the element + mechanism. See [[annotator-reader-fixes]] [[layout-ui-fixes]].
@@ -0,0 +1,38 @@
---
name: table-dark-mode-tint-4419
description: "Dark-mode table tint must stay gated on overrideColor; blanket `table *` color-mix breaks plain tables AND vertical-TOC spacer cells"
metadata:
node_type: memory
type: project
originSessionId: 114eeb22-c9b6-480d-8305-3a3190855638
---
`getColorStyles` in `src/utils/style.ts` has a rule:
`blockquote, table * { background-color: color-mix(in srgb, ${bg} 80%, #000) }` in dark mode.
The `table *` part **must** be gated on `overrideColor``${isDarkMode && overrideColor ? ...}`.
**This gate has regressed twice.** #2377 (PR #2379) first added it ("avoid overriding
table background by default"). #4055 (commit `cead0f42e`, closes #4028/#4029) **removed**
the gate to darken illegible white zebra-stripe rows — which re-broke it and caused #4419:
*every* dark-mode table (and its cells) gets a tint a few shades off the page bg, even
tables with no background of their own.
Why the gate is safe now: #4392 (`176b950c9`) added light-background rewriters that map
only actually-light backgrounds → page `bg` in dark mode, regardless of overrideColor —
`getDarkModeLightBackgroundOverrides` (inline styles) + the `transformStylesheet` dark-mode
block (stylesheet rules, `isLightCssColor` luminance > 0.85). So #4028's white zebra rows
stay legible without the blanket tint. The blockquote-only tint (standalone `blockquote {}`
rule, from #2538) stays unconditional in dark mode — that's intended.
**Symptom #2 shares this root cause.** #4419 also reported "spacing between words changes"
on a vertical (writing-mode) 目录/TOC page. Those CJK web-novel books lay the TOC out as a
`<table>` with empty `<td>` spacer columns and `<span class="space">▉</span>` spacers
(custom "space" font; ▉ U+2589 is a **blank glyph, contours=0** — pure advance width, no
outline). The blanket `table *` rule paints a background on those spacer spans/cells,
making the invisible gaps show as tinted blocks → looks like the spacing changed. It's the
painted **background**, not text color, that reveals them — recoloring `.co0` (`color:#000`
→ fg) does nothing because the glyph has no outline. Gating the tint fixes both symptoms.
Tests live in `src/__tests__/utils/style-get-styles.test.ts` (assert the `blockquote,
table *` block has no `color-mix` when overrideColor false; keep the override-true and
blockquote-still-tinted cases). Related: [[css-style-fixes]].
@@ -0,0 +1,36 @@
---
name: tauri-menu-append-race-4389
description: "Un-awaited Tauri Menu.append() races on IPC → context menu items shuffle order randomly; fix = single Menu.new({ items })"
metadata:
node_type: memory
type: project
originSessionId: 8169b903-f66e-4c35-b90f-6b9110837588
---
#4389 — the bookshelf right-click context menu (Windows/Edge WebView2) showed the
same items but in a **randomly changing order** on every open.
**Root cause:** `Menu.append()` from `@tauri-apps/api/menu` returns `Promise<void>`
— each call is an async IPC round-trip to the Rust backend. `BookshelfItem.tsx`
fired ~11 `menu.append(item)` calls **without awaiting** (then `menu.popup()` also
un-awaited). The concurrent IPC requests resolve in non-deterministic order on the
Rust side, so items land shuffled. Synchronous JS call order is correct — the race
is purely in async resolution, which is why it's invisible in jsdom and only shows
on the native app.
**Fix:** build the items array in order and create the menu in ONE call:
`const menu = await Menu.new({ items })` then `await menu.popup()`.
`MenuOptions.items` / `append()` accept plain `MenuItemOptions` (`{ text, action }`)
arrays — no need to pre-create `MenuItem.new()` per item at all. Applied to both
book and group handlers.
**Testability:** extracted the order/inclusion logic into a pure
`getBookContextMenuItemIds(book): BookContextMenuItemId[]` in
`src/app/library/utils/libraryUtils.ts` (dep-light, already test-covered) so the
deterministic ordering is unit-testable without mounting the component or mocking
Tauri. Test: `src/__tests__/app/library/book-context-menu.test.ts`. The pure fn
guards the order contract; the `Menu.new({ items })` wiring is what kills the race.
**General lesson:** any un-awaited sequence of Tauri/IPC mutations that must stay
ordered (append, insert, etc.) is a latent race — batch into one call or await each.
See [[bug-patterns]].
@@ -0,0 +1,29 @@
---
name: tauri-parser-parity-tests
description: "How to test the native Rust EPUB/MOBI parser against foliate-js in the Tauri WebView suite, plus the dcterms:modified→published parity gotcha"
metadata:
node_type: memory
type: reference
originSessionId: 90d14df3-59ce-438f-ae91-b72e9d2b6f99
---
PR #4369 added native Rust EPUB/MOBI parsers (`src-tauri/src/epub_parser.rs`, `mobi_parser.rs`, shared `parser_common.rs`) with foliate-js fallback. Parity between the two parsers is the key risk.
**Cross-language parity test harness** (`src/__tests__/tauri/epub-parser-parity.tauri.test.ts`):
- The `.tauri.test.ts` suite (run by `scripts/test-tauri.sh` / `pnpm test:tauri`, included only by `vitest.tauri.config.mts`) is the ONLY env where both parsers coexist: Rust via `invoke()` (`./tauri-invoke.ts`), foliate-js via `DocumentLoader`. Default `pnpm test` excludes `**/*.tauri.test.ts`.
- Rust commands read an absolute on-disk path → build it from `process.env.CWD` (injected by the tauri config = readest-app dir): `${CWD}/src/__tests__/fixtures/data/<name>`. The JS side fetches the SAME file via a Vite URL: `new URL('../fixtures/data/<name>', import.meta.url).href` (same pattern as `paginator-stabilization.browser.test.ts`).
- Compare via the app's own normalizers so you compare user-visible values, not raw parser shapes: `formatTitle`, `formatAuthors(authors, lang)` (Rust gives `string[]`, foliate gives `string|Contributor|array` — both through formatAuthors), `getPrimaryLanguage`, `formatPublisher`, `formatDescription`.
- **Cover is presence-only**: Rust downscales/re-encodes the cover to a ~512px JPEG (`parser_common::maybe_resize_cover`), so bytes never match foliate's original — assert `(rust.coverBase64 != null) === ((await js.getCover()) != null)`.
- **Description needs whitespace-collapse**: foliate collapses an internal source newline to a space; Rust preserves the raw `\n`. Compare `formatDescription(x).replace(/\s+/g,' ').trim()`.
- Section `href` is undefined on foliate `SectionItem` — compare sections by `{id,size,linear}` only. `linear` can be `null`.
- Strongest test = open the same EPUB twice through `DocumentLoader` (with `{nativeFilePath}` → Rust prefetch, and without → pure zip.js) and assert identical `metadata`, sections, `toc`, and `computeBookNav` output (toc tree + per-section fragment CFIs/sizes). This validates `parse_epub_full` prefetch + the parallelized TOC pipeline are behavior-preserving in one shot. `isTauriAppPlatform()` is true in the webview (`.env.tauri` sets `NEXT_PUBLIC_APP_PLATFORM=tauri`) so the prefetch fires.
- No `.mobi`/`.azw3` fixture exists anywhere in the repo → MOBI parity is NOT covered by this harness; add a Kindle fixture to extend. Rust MOBI is covered by `mobi_parser`'s own unit tests.
- Offline verification trick (no chromedriver/tauri-driver locally): dump foliate `book.metadata` via a temp node vitest test, dump Rust `parse_epub_metadata_sync`/`compute_partial_md5` via a temp `#[cfg(test)]` mod using `env!("CARGO_MANIFEST_DIR")/../src/__tests__/fixtures/data`, diff. Lets you lock every expected value before CI runs the WebView suite.
**Running the `.tauri.test.ts` suite locally on macOS** (no chromedriver/tauri-driver needed — `tauri-plugin-webdriver` 0.2 embeds an axum WebDriver server with a `platform/macos.rs` WKWebView impl, default port 4445):
- `pnpm test:tauri` runs `scripts/test-tauri.sh`: starts `next dev` on :3000, `tauri dev --features webdriver --no-watch`, waits for :4445/status, then `vitest --config vitest.tauri.config.mts`. Its cleanup does `lsof -ti :3000 | xargs kill` — so it KILLS whatever is on :3000 (e.g. another worktree's dev server). To avoid that, copy the script and shift `DEV_PORT`/`next dev -p`/`--config devUrl` to a free port (e.g. 3100). Run via `pnpm exec bash <script>` so npm bins (`dotenv`, `next`, `tauri`, `vitest`) resolve, not the Ruby `dotenv` gem.
- **Single-instance blocker**: `tauri_plugin_single_instance` (lib.rs, keyed on bundle id `com.bilingify.readest`) is registered unconditionally for desktop and is NOT gated by the webdriver feature. If `/Applications/Readest.app` (or any Readest instance) is running, the test instance forwards to it and exits → "Tauri exited before WebDriver ready." Quit Readest.app before running. CI doesn't hit this (Linux, no Readest installed).
- **`vitest.tauri.config.mts` needs an `optimizeDeps` block** mirroring `vitest.browser.config.mts` (include the CJS deps `js-md5`/`@zip.js/zip.js`/`franc-min`/`iso-639-*`/etc.; exclude `@pdfjs/pdf.min.mjs` + the turso wasm). Without it, esbuild's dep-scan can't resolve foliate-js's `import '@pdfjs/pdf.min.mjs'`, skips pre-bundling, and any tauri test that imports `@/libs/document` fails to load with "Importing a module script failed" on a cold `.vite` cache. (A warm cache from a prior run masks it.)
- **Pre-existing failure, not parity-related**: `tauri-app-service.tauri.test.ts` has 13 failures on macOS — `forbidden path: …/src/__tests__/fixtures/data/sample-alice.epub … allow-open permission`. importBook's library-copy goes through the fs plugin (scoped), and `capabilities-extra/webdriver.json` doesn't grant the fixtures dir. Confirmed identical at base config (no regression from parity work). Parser parity tests avoid this: Rust `File::open` is unscoped, and the JS side fetches fixtures via a Vite URL. To fix separately, add the fixtures path to the webdriver capability scope.
**Parity gotcha found + fixed**: Rust `handle_meta` mapped `dc:date` AND `dcterms:modified``published`. foliate keeps `dcterms:modified` as a separate `modified` field and leaves `published` empty. EPUB3 mandates `dcterms:modified`, so native-imported EPUB3 books with no `<dc:date>` got a bogus publication date. Fix: map only `"date"` (not `"modified"`) to published. Regression tests: `epub_parser::tests::{dcterms_modified_does_not_populate_published, dc_date_populates_published}`.
@@ -0,0 +1,37 @@
---
name: txt-author-recognition-4390
metadata:
node_type: memory
type: project
originSessionId: f151827f-0bf2-4307-ac92-e6077df54d19
---
Issue #4390: imported Chinese web-novels showed author either **missing (未知)** or
as an **irrelevant metadata blob** (e.g. `2024/08/01发表于:是否首发:是字数1023150字116:01`).
**Key debugging tell:** the displayed *title* was the **entire filename** including
`作者:X` (e.g. `【月如无恨月长圆】(1-154)作者:陈西`). A real EPUB's `dc:title` would
be just the book name. Full-filename title ⇒ the file went through Readest's
**TXT→EPUB converter** (`bookService.ts` `/\.txt$/``TxtToEpubConverter`), which
sets `bookTitle = base filename` in the no-`《》` branch of `extractTxtFilenameMetadata`.
So "EPUB format" books can still be TXT-origin — check `src/utils/txt.ts`, not foliate-js,
when title looks like a filename. (Native Rust EPUB parser #4369 was NOT shipped in 0.11.2.)
Two root causes in `src/utils/txt.ts`:
1. `extractTxtFilenameMetadata` only pulled an author from `《》`-wrapped names; `【】`
names (the web-novel norm) got no filename author.
2. Greedy header capture `/[【\[]?作者[】\]]?[:\s]\s*(.+)\r?\n/` grabbed a whole noisy
metadata line as the author.
Fix:
- `parseLabeledAuthor(base)` extracts the labeled `作者:X` from ANY filename; title stays
the full name. Only the *labeled* form is safe on a full filename — a bracketed/bare
fallback would mistake a leading `【title】` for the author.
- `isPlausibleAuthorName()` rejects a header match that looks like a blob (contains `:`/``,
a `\d{4,}` run, or length > 20) → falls back to the filename author. Applied in BOTH
`convertSmallFile` and `extractAuthorAndLanguage` (large-file path).
Note: the `著` form is handled for file *content* headers but NOT for filenames (kept minimal).
Reported files were 455 kB / 1.25 MB → both under the 8 MB `LARGE_TXT_THRESHOLD_BYTES`
(`convertSmallFile` path). Tests live in `txt-converter.test.ts` (faithful repro of both
reported strings). Related: [[bug-patterns]].
@@ -0,0 +1,18 @@
---
name: window-state-sanitize-4398
description: Windows launch crash (WebView2 0x80070057) from invalid .window-state.json; defensive sanitizer plugin
metadata:
node_type: memory
type: project
originSessionId: 40a168fd-c90e-4f00-9fbc-7effe7c0c45f
---
Issue #4398 (PR #4401) — Windows app fails to launch ("WebView2 error 0x80070057 / The parameter is incorrect") when `%APPDATA%\com.bilingify.readest\.window-state.json` holds the minimized sentinel (`x/y = -32000`) or `0×0` size.
Non-obvious facts:
- Our `tauri-plugin-window-state` (2.4.1, and every shipped 2.2.1+) ALREADY contains the upstream fix for [tauri-apps/plugins-workspace#253](https://github.com/tauri-apps/plugins-workspace/issues/253): `update_state` + the Moved/Resized handlers skip saving while `is_minimized()`, and restore only re-applies position if it `intersects()` a monitor. So current builds shouldn't generate bad state — a bad file is almost certainly stale from an old build. The version-inquiry comment is on the issue.
- Fix = **defense-in-depth**, NOT a behavior change: `src-tauri/src/window_state.rs` — a tiny `window-state-sanitizer` Tauri plugin whose `setup` strips window entries with invalid geometry (`width<=0 || height<=0 || x<=-30000 || y<=-30000`) from the file before window-state reads it. Pure `sanitize_json(&str)->Option<String>` is unit-tested (`cargo test -p Readest window_state`).
- **Plugin init order matters**: Tauri's `PluginStore::initialize_all` iterates the store in registration order (Vec push), and window-state loads the file in its `setup` (runs during `initialize`). So the sanitizer plugin MUST be registered BEFORE `tauri_plugin_window_state` in `lib.rs` (it is). The app-level `.setup()` closure runs AFTER all plugin setups → too late to sanitize there.
- Coord threshold is `-16000` (sentinel is exactly `-32000`; ~halfway). Real desktops sit a few thousand px off origin (4K-left = `-3840`, 3×4K ≈ `-11520`), so `-16000` is well past normal use yet clearly the sentinel zone; a legit negative like `-1920` is kept. Do NOT justify by extreme N-monitor walls (user pushed back on that). The size check (`<=0`) is the real WebView2-crash guard; this coord check is secondary (the plugin already refuses to restore an off-screen position via its `intersects()` monitor check). Missing fields default to valid so a schema change never drops a good entry.
Repo Rust testing reality: zero pre-existing `#[cfg(test)]` modules; CI only runs `cargo fmt --check` + `cargo clippy -p Readest --no-deps -D warnings` (NOT `--all-targets`, so it won't compile test code). `generate_context!` tolerates a missing `frontendDist` (`../out`), so `cargo test`/clippy build without a frontend build. See [[tauri-parser-parity-tests]].
+2 -1
View File
@@ -45,7 +45,7 @@
"format:check": "pnpm -w format:check",
"prepare-public-vendor": "mkdirp ./public/vendor/pdfjs ./public/vendor/simplecc ./public/vendor/jieba",
"copy-pdfjs-js": "cpx \"../../packages/foliate-js/node_modules/pdfjs-dist/legacy/build/{pdf.worker.min.mjs,pdf.min.mjs,pdf.d.mts}\" ./public/vendor/pdfjs",
"copy-pdfjs-wasm": "cpx \"../../packages/foliate-js/node_modules/pdfjs-dist/wasm/{openjpeg.wasm,qcms_bg.wasm}\" ./public/vendor/pdfjs",
"copy-pdfjs-wasm": "cpx \"../../packages/foliate-js/node_modules/pdfjs-dist/wasm/*\" ./public/vendor/pdfjs",
"copy-pdfjs-fonts": "cpx \"../../packages/foliate-js/node_modules/pdfjs-dist/{cmaps,standard_fonts}/*\" ./public/vendor/pdfjs",
"copy-flatten-pdfjs-annotation-layer-css": "npx postcss \"../../packages/foliate-js/vendor/pdfjs/annotation_layer_builder.css\" --no-map -u postcss-nested > ./public/vendor/pdfjs/annotation_layer_builder.css",
"copy-flatten-pdfjs-text-layer-css": "npx postcss \"../../packages/foliate-js/vendor/pdfjs/text_layer_builder.css\" --no-map -u postcss-nested > ./public/vendor/pdfjs/text_layer_builder.css",
@@ -124,6 +124,7 @@
"@supabase/supabase-js": "^2.76.1",
"@tauri-apps/api": "2.10.1",
"@tauri-apps/plugin-cli": "^2.4.1",
"@tauri-apps/plugin-clipboard-manager": "^2.3.0",
"@tauri-apps/plugin-deep-link": "^2.4.7",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-fs": "^2.4.5",
@@ -1827,5 +1827,9 @@
"No Annotations": "لا توجد تعليقات توضيحية",
"No Bookmarks": "لا توجد إشارات مرجعية",
"Select some text to highlight": "حدّد نصًا لتمييزه",
"Bookmark This Page": "أضف إشارة مرجعية لهذه الصفحة"
"Bookmark This Page": "أضف إشارة مرجعية لهذه الصفحة",
"Book file is not available locally": "ملف الكتاب غير متوفر محليًا",
"Failed to send book": "فشل إرسال الكتاب",
"Send": "إرسال",
"Highlight Current Sentence": "تمييز الجملة الحالية"
}
@@ -1695,5 +1695,9 @@
"No Annotations": "কোনো টীকা নেই",
"No Bookmarks": "কোনো বুকমার্ক নেই",
"Select some text to highlight": "হাইলাইট করতে কিছু লেখা নির্বাচন করুন",
"Bookmark This Page": "এই পৃষ্ঠা বুকমার্ক করুন"
"Bookmark This Page": "এই পৃষ্ঠা বুকমার্ক করুন",
"Book file is not available locally": "বইয়ের ফাইলটি স্থানীয়ভাবে উপলব্ধ নয়",
"Failed to send book": "বই পাঠাতে ব্যর্থ হয়েছে",
"Send": "পাঠান",
"Highlight Current Sentence": "বর্তমান বাক্য হাইলাইট করুন"
}
@@ -1662,5 +1662,9 @@
"No Annotations": "མཆན་མེད།",
"No Bookmarks": "དཔེ་རྟགས་མེད།",
"Select some text to highlight": "འོག་ཐིག་གཏོང་བར་ཡི་གེ་འདེམས་རོགས།",
"Bookmark This Page": "ཤོག་ངོས་འདིར་དཔེ་རྟགས་འགོད།"
"Bookmark This Page": "ཤོག་ངོས་འདིར་དཔེ་རྟགས་འགོད།",
"Book file is not available locally": "དེབ་ཀྱི་ཡིག་ཆ་དེ་ས་གནས་སུ་མི་འདུག",
"Failed to send book": "དེབ་སྐུར་གཏོང་མ་ཐུབ།",
"Send": "སྐུར་གཏོང་།",
"Highlight Current Sentence": "ད་ལྟའི་ཚིག་གྲུབ་ལ་འོག་ཐིག་གཏོང་།"
}
@@ -1695,5 +1695,9 @@
"No Annotations": "Keine Anmerkungen",
"No Bookmarks": "Keine Lesezeichen",
"Select some text to highlight": "Wähle Text zum Hervorheben aus",
"Bookmark This Page": "Diese Seite mit Lesezeichen versehen"
"Bookmark This Page": "Diese Seite mit Lesezeichen versehen",
"Book file is not available locally": "Buchdatei ist nicht lokal verfügbar",
"Failed to send book": "Senden des Buchs fehlgeschlagen",
"Send": "Senden",
"Highlight Current Sentence": "Aktuellen Satz hervorheben"
}
@@ -1695,5 +1695,9 @@
"No Annotations": "Κανένας σχολιασμός",
"No Bookmarks": "Κανένας σελιδοδείκτης",
"Select some text to highlight": "Επιλέξτε κείμενο για επισήμανση",
"Bookmark This Page": "Προσθήκη σελιδοδείκτη σε αυτή τη σελίδα"
"Bookmark This Page": "Προσθήκη σελιδοδείκτη σε αυτή τη σελίδα",
"Book file is not available locally": "Το αρχείο του βιβλίου δεν είναι διαθέσιμο τοπικά",
"Failed to send book": "Αποτυχία αποστολής βιβλίου",
"Send": "Αποστολή",
"Highlight Current Sentence": "Επισήμανση τρέχουσας πρότασης"
}
@@ -1728,5 +1728,9 @@
"No Annotations": "Sin anotaciones",
"No Bookmarks": "Sin marcadores",
"Select some text to highlight": "Selecciona texto para resaltar",
"Bookmark This Page": "Añadir esta página a marcadores"
"Bookmark This Page": "Añadir esta página a marcadores",
"Book file is not available locally": "El archivo del libro no está disponible localmente",
"Failed to send book": "No se pudo enviar el libro",
"Send": "Enviar",
"Highlight Current Sentence": "Resaltar la oración actual"
}
@@ -1695,5 +1695,9 @@
"No Annotations": "هیچ حاشیه‌نویسی‌ای نیست",
"No Bookmarks": "هیچ نشانکی نیست",
"Select some text to highlight": "برای هایلایت کردن، متنی را انتخاب کنید",
"Bookmark This Page": "افزودن این صفحه به نشانک‌ها"
"Bookmark This Page": "افزودن این صفحه به نشانک‌ها",
"Book file is not available locally": "فایل کتاب به صورت محلی در دسترس نیست",
"Failed to send book": "ارسال کتاب ناموفق بود",
"Send": "ارسال",
"Highlight Current Sentence": "برجسته‌سازی جملهٔ فعلی"
}
@@ -1728,5 +1728,9 @@
"No Annotations": "Aucune annotation",
"No Bookmarks": "Aucun signet",
"Select some text to highlight": "Sélectionnez du texte à surligner",
"Bookmark This Page": "Ajouter cette page aux signets"
"Bookmark This Page": "Ajouter cette page aux signets",
"Book file is not available locally": "Le fichier du livre n'est pas disponible localement",
"Failed to send book": "Échec de l'envoi du livre",
"Send": "Envoyer",
"Highlight Current Sentence": "Surligner la phrase actuelle"
}
@@ -1728,5 +1728,9 @@
"No Annotations": "אין הדגשות",
"No Bookmarks": "אין סימניות",
"Select some text to highlight": "בחרו טקסט להדגשה",
"Bookmark This Page": "הוסיפו דף זה לסימניות"
"Bookmark This Page": "הוסיפו דף זה לסימניות",
"Book file is not available locally": "קובץ הספר אינו זמין באופן מקומי",
"Failed to send book": "שליחת הספר נכשלה",
"Send": "שליחה",
"Highlight Current Sentence": "הדגשת המשפט הנוכחי"
}
@@ -1695,5 +1695,9 @@
"No Annotations": "कोई टिप्पणी नहीं",
"No Bookmarks": "कोई बुकमार्क नहीं",
"Select some text to highlight": "हाइलाइट करने के लिए कुछ टेक्स्ट चुनें",
"Bookmark This Page": "इस पृष्ठ को बुकमार्क करें"
"Bookmark This Page": "इस पृष्ठ को बुकमार्क करें",
"Book file is not available locally": "पुस्तक फ़ाइल स्थानीय रूप से उपलब्ध नहीं है",
"Failed to send book": "पुस्तक भेजने में विफल",
"Send": "भेजें",
"Highlight Current Sentence": "वर्तमान वाक्य को हाइलाइट करें"
}
@@ -1695,5 +1695,9 @@
"No Annotations": "Nincsenek megjegyzések",
"No Bookmarks": "Nincsenek könyvjelzők",
"Select some text to highlight": "Jelölj ki szöveget a kiemeléshez",
"Bookmark This Page": "Oldal hozzáadása a könyvjelzőkhöz"
"Bookmark This Page": "Oldal hozzáadása a könyvjelzőkhöz",
"Book file is not available locally": "A könyvfájl nem érhető el helyileg",
"Failed to send book": "A könyv küldése sikertelen",
"Send": "Küldés",
"Highlight Current Sentence": "Aktuális mondat kiemelése"
}
@@ -1662,5 +1662,9 @@
"No Annotations": "Tidak ada anotasi",
"No Bookmarks": "Tidak ada penanda",
"Select some text to highlight": "Pilih teks untuk disorot",
"Bookmark This Page": "Tandai halaman ini"
"Bookmark This Page": "Tandai halaman ini",
"Book file is not available locally": "Berkas buku tidak tersedia secara lokal",
"Failed to send book": "Gagal mengirim buku",
"Send": "Kirim",
"Highlight Current Sentence": "Sorot kalimat saat ini"
}
@@ -1728,5 +1728,9 @@
"No Annotations": "Nessuna annotazione",
"No Bookmarks": "Nessun segnalibro",
"Select some text to highlight": "Seleziona del testo da evidenziare",
"Bookmark This Page": "Aggiungi questa pagina ai segnalibri"
"Bookmark This Page": "Aggiungi questa pagina ai segnalibri",
"Book file is not available locally": "Il file del libro non è disponibile localmente",
"Failed to send book": "Invio del libro non riuscito",
"Send": "Invia",
"Highlight Current Sentence": "Evidenzia la frase corrente"
}
@@ -1662,5 +1662,9 @@
"No Annotations": "注釈がありません",
"No Bookmarks": "ブックマークがありません",
"Select some text to highlight": "ハイライトするテキストを選択してください",
"Bookmark This Page": "このページをブックマーク"
"Bookmark This Page": "このページをブックマーク",
"Book file is not available locally": "書籍ファイルがローカルに存在しません",
"Failed to send book": "書籍の送信に失敗しました",
"Send": "送信",
"Highlight Current Sentence": "現在の文をハイライト"
}
@@ -1662,5 +1662,9 @@
"No Annotations": "주석 없음",
"No Bookmarks": "북마크 없음",
"Select some text to highlight": "하이라이트할 텍스트를 선택하세요",
"Bookmark This Page": "이 페이지 북마크"
"Bookmark This Page": "이 페이지 북마크",
"Book file is not available locally": "책 파일을 로컬에서 사용할 수 없습니다",
"Failed to send book": "책 보내기에 실패했습니다",
"Send": "보내기",
"Highlight Current Sentence": "현재 문장 강조"
}
@@ -1662,5 +1662,9 @@
"No Annotations": "Tiada anotasi",
"No Bookmarks": "Tiada penanda buku",
"Select some text to highlight": "Pilih teks untuk ditonjolkan",
"Bookmark This Page": "Tanda halaman ini"
"Bookmark This Page": "Tanda halaman ini",
"Book file is not available locally": "Fail buku tidak tersedia secara setempat",
"Failed to send book": "Gagal menghantar buku",
"Send": "Hantar",
"Highlight Current Sentence": "Serlahkan ayat semasa"
}
@@ -1695,5 +1695,9 @@
"No Annotations": "Geen aantekeningen",
"No Bookmarks": "Geen bladwijzers",
"Select some text to highlight": "Selecteer tekst om te markeren",
"Bookmark This Page": "Deze pagina als bladwijzer toevoegen"
"Bookmark This Page": "Deze pagina als bladwijzer toevoegen",
"Book file is not available locally": "Boekbestand is niet lokaal beschikbaar",
"Failed to send book": "Verzenden van boek mislukt",
"Send": "Verzenden",
"Highlight Current Sentence": "Huidige zin markeren"
}
@@ -1761,5 +1761,9 @@
"No Annotations": "Brak adnotacji",
"No Bookmarks": "Brak zakładek",
"Select some text to highlight": "Wybierz tekst do podświetlenia",
"Bookmark This Page": "Dodaj tę stronę do zakładek"
"Bookmark This Page": "Dodaj tę stronę do zakładek",
"Book file is not available locally": "Plik książki nie jest dostępny lokalnie",
"Failed to send book": "Nie udało się wysłać książki",
"Send": "Wyślij",
"Highlight Current Sentence": "Podświetl bieżące zdanie"
}
@@ -1728,5 +1728,9 @@
"No Annotations": "Nenhuma anotação",
"No Bookmarks": "Nenhum marcador",
"Select some text to highlight": "Selecione um texto para destacar",
"Bookmark This Page": "Adicionar esta página aos marcadores"
"Bookmark This Page": "Adicionar esta página aos marcadores",
"Book file is not available locally": "O arquivo do livro não está disponível localmente",
"Failed to send book": "Falha ao enviar o livro",
"Send": "Enviar",
"Highlight Current Sentence": "Destacar a frase atual"
}
@@ -1728,5 +1728,9 @@
"No Annotations": "Sem anotações",
"No Bookmarks": "Sem marcadores",
"Select some text to highlight": "Selecione texto para destacar",
"Bookmark This Page": "Adicionar esta página aos marcadores"
"Bookmark This Page": "Adicionar esta página aos marcadores",
"Book file is not available locally": "O ficheiro do livro não está disponível localmente",
"Failed to send book": "Falha ao enviar o livro",
"Send": "Enviar",
"Highlight Current Sentence": "Destacar a frase atual"
}
@@ -1728,5 +1728,9 @@
"No Annotations": "Nicio adnotare",
"No Bookmarks": "Niciun marcaj",
"Select some text to highlight": "Selectează text pentru evidențiere",
"Bookmark This Page": "Adaugă această pagină la marcaje"
"Bookmark This Page": "Adaugă această pagină la marcaje",
"Book file is not available locally": "Fișierul cărții nu este disponibil local",
"Failed to send book": "Trimiterea cărții a eșuat",
"Send": "Trimite",
"Highlight Current Sentence": "Evidențiază propoziția curentă"
}
@@ -1761,5 +1761,9 @@
"No Annotations": "Нет аннотаций",
"No Bookmarks": "Нет закладок",
"Select some text to highlight": "Выберите текст для выделения",
"Bookmark This Page": "Добавить эту страницу в закладки"
"Bookmark This Page": "Добавить эту страницу в закладки",
"Book file is not available locally": "Файл книги недоступен локально",
"Failed to send book": "Не удалось отправить книгу",
"Send": "Отправить",
"Highlight Current Sentence": "Выделять текущее предложение"
}
@@ -1695,5 +1695,9 @@
"No Annotations": "අනුසටහන් නැත",
"No Bookmarks": "පොත් සලකුණු නැත",
"Select some text to highlight": "ඉස්මතු කිරීමට පෙළක් තෝරන්න",
"Bookmark This Page": "මෙම පිටුව පොත් සලකුණක් කරන්න"
"Bookmark This Page": "මෙම පිටුව පොත් සලකුණක් කරන්න",
"Book file is not available locally": "පොත් ගොනුව දේශීයව ලබා ගත නොහැක",
"Failed to send book": "පොත යැවීමට අසමත් විය",
"Send": "යවන්න",
"Highlight Current Sentence": "වර්තමාන වාක්‍යය ඉස්මතු කරන්න"
}
@@ -1761,5 +1761,9 @@
"No Annotations": "Ni opomb",
"No Bookmarks": "Ni zaznamkov",
"Select some text to highlight": "Izberite besedilo za označevanje",
"Bookmark This Page": "Dodaj to stran med zaznamke"
"Bookmark This Page": "Dodaj to stran med zaznamke",
"Book file is not available locally": "Datoteka knjige ni na voljo lokalno",
"Failed to send book": "Pošiljanje knjige ni uspelo",
"Send": "Pošlji",
"Highlight Current Sentence": "Označi trenutni stavek"
}
@@ -1695,5 +1695,9 @@
"No Annotations": "Inga kommentarer",
"No Bookmarks": "Inga bokmärken",
"Select some text to highlight": "Markera text att framhäva",
"Bookmark This Page": "Bokmärk den här sidan"
"Bookmark This Page": "Bokmärk den här sidan",
"Book file is not available locally": "Bokfilen är inte tillgänglig lokalt",
"Failed to send book": "Det gick inte att skicka boken",
"Send": "Skicka",
"Highlight Current Sentence": "Markera aktuell mening"
}
@@ -1695,5 +1695,9 @@
"No Annotations": "சிறுகுறிப்புகள் இல்லை",
"No Bookmarks": "புக்மார்க்குகள் இல்லை",
"Select some text to highlight": "சிறப்பிக்க உரையைத் தேர்ந்தெடுக்கவும்",
"Bookmark This Page": "இந்தப் பக்கத்தைப் புக்மார்க் செய்யவும்"
"Bookmark This Page": "இந்தப் பக்கத்தைப் புக்மார்க் செய்யவும்",
"Book file is not available locally": "புத்தக கோப்பு உள்ளூரில் கிடைக்கவில்லை",
"Failed to send book": "புத்தகத்தை அனுப்ப முடியவில்லை",
"Send": "அனுப்பு",
"Highlight Current Sentence": "தற்போதைய வாக்கியத்தை தனிப்படுத்து"
}
@@ -1662,5 +1662,9 @@
"No Annotations": "ไม่มีคำอธิบายประกอบ",
"No Bookmarks": "ไม่มีบุ๊กมาร์ก",
"Select some text to highlight": "เลือกข้อความเพื่อไฮไลต์",
"Bookmark This Page": "บุ๊กมาร์กหน้านี้"
"Bookmark This Page": "บุ๊กมาร์กหน้านี้",
"Book file is not available locally": "ไม่มีไฟล์หนังสือในเครื่อง",
"Failed to send book": "ส่งหนังสือไม่สำเร็จ",
"Send": "ส่ง",
"Highlight Current Sentence": "ไฮไลต์ประโยคปัจจุบัน"
}
@@ -1695,5 +1695,9 @@
"No Annotations": "Açıklama yok",
"No Bookmarks": "Yer işareti yok",
"Select some text to highlight": "Vurgulamak için metin seçin",
"Bookmark This Page": "Bu Sayfayı Yer İşaretine Ekle"
"Bookmark This Page": "Bu Sayfayı Yer İşaretine Ekle",
"Book file is not available locally": "Kitap dosyası yerel olarak mevcut değil",
"Failed to send book": "Kitap gönderilemedi",
"Send": "Gönder",
"Highlight Current Sentence": "Geçerli cümleyi vurgula"
}
@@ -1761,5 +1761,9 @@
"No Annotations": "Немає анотацій",
"No Bookmarks": "Немає закладок",
"Select some text to highlight": "Виділіть текст для підсвічування",
"Bookmark This Page": "Додати цю сторінку до закладок"
"Bookmark This Page": "Додати цю сторінку до закладок",
"Book file is not available locally": "Файл книги недоступний локально",
"Failed to send book": "Не вдалося надіслати книгу",
"Send": "Надіслати",
"Highlight Current Sentence": "Виділяти поточне речення"
}
@@ -1695,5 +1695,9 @@
"No Annotations": "Izohlar yoʻq",
"No Bookmarks": "Xatchoʻplar yoʻq",
"Select some text to highlight": "Belgilash uchun matn tanlang",
"Bookmark This Page": "Bu sahifani xatchoʻpga qoʻshish"
"Bookmark This Page": "Bu sahifani xatchoʻpga qoʻshish",
"Book file is not available locally": "Kitob fayli mahalliy tarzda mavjud emas",
"Failed to send book": "Kitobni yuborib bo'lmadi",
"Send": "Yuborish",
"Highlight Current Sentence": "Joriy jumlani ajratib ko'rsatish"
}
@@ -1662,5 +1662,9 @@
"No Annotations": "Không có chú thích",
"No Bookmarks": "Không có dấu trang",
"Select some text to highlight": "Chọn một đoạn văn bản để tô sáng",
"Bookmark This Page": "Đánh dấu trang này"
"Bookmark This Page": "Đánh dấu trang này",
"Book file is not available locally": "Tệp sách không có sẵn trên thiết bị",
"Failed to send book": "Gửi sách thất bại",
"Send": "Gửi",
"Highlight Current Sentence": "Tô sáng câu hiện tại"
}
@@ -1662,5 +1662,9 @@
"No Annotations": "暂无注释",
"No Bookmarks": "暂无书签",
"Select some text to highlight": "选择文本以划线",
"Bookmark This Page": "为此页添加书签"
"Bookmark This Page": "为此页添加书签",
"Book file is not available locally": "本地没有该图书文件",
"Failed to send book": "发送图书失败",
"Send": "发送",
"Highlight Current Sentence": "高亮当前句子"
}
@@ -1662,5 +1662,9 @@
"No Annotations": "尚無註解",
"No Bookmarks": "尚無書籤",
"Select some text to highlight": "選取文字以劃線",
"Bookmark This Page": "將此頁加入書籤"
"Bookmark This Page": "將此頁加入書籤",
"Book file is not available locally": "本機沒有此書籍檔案",
"Failed to send book": "傳送書籍失敗",
"Send": "傳送",
"Highlight Current Sentence": "醒目標示目前句子"
}
+1
View File
@@ -59,6 +59,7 @@ tauri-plugin-native-tts = { path = "./plugins/tauri-plugin-native-tts" }
tauri-plugin-webview-upgrade = { path = "./plugins/tauri-plugin-webview-upgrade" }
tauri-plugin-websocket = "2"
tauri-plugin-sharekit = "0.3"
tauri-plugin-clipboard-manager = "2"
tauri-plugin-device-info = "1.0.1"
tauri-plugin-turso = { path = "./plugins/tauri-plugin-turso" }
tauri-plugin-webdriver = { version = "0.2", optional = true }
@@ -196,6 +196,8 @@
"haptics:allow-selection-feedback",
"deep-link:default",
"sharekit:default",
"clipboard-manager:allow-write-text",
"clipboard-manager:allow-read-text",
"device-info:default",
"turso:default",
"native-tts:default",
@@ -159,28 +159,98 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
}
private fun handleIntent(intent: Intent?) {
val uri = intent?.data ?: return
Log.e("NativeBridgePlugin", "Received intent: $uri")
when {
uri.scheme == "readest" && uri.host == "auth-callback" -> {
val result = JSObject().apply {
put("redirectUrl", uri.toString())
}
pendingInvoke?.resolve(result)
pendingInvoke = null
}
if (intent == null) return
Log.d("NativeBridgePlugin", "Received intent: action=${intent.action} data=${intent.data}")
intent.action == Intent.ACTION_VIEW -> {
try {
activity.contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
} catch (e: SecurityException) {
Log.e("NativeBridgePlugin", "Failed to take persistable URI permission: ${e.message}")
}
}
// OAuth callback uses a custom scheme on intent.data and is handled
// separately from any user-shared content.
intent.data?.let { uri ->
if (uri.scheme == "readest" && uri.host == "auth-callback") {
val result = JSObject().apply {
put("redirectUrl", uri.toString())
}
pendingInvoke?.resolve(result)
pendingInvoke = null
return
}
}
when (intent.action) {
Intent.ACTION_VIEW -> {
// "Open with Readest": the OS hands us a single content://
// (or file://) URI on `intent.data`. Take the persistable
// permission so we can read it through any subsequent app
// launch, then forward it to the JS side via the existing
// shared-intent channel — without this trigger, the URI
// silently dies in Kotlin and the user just sees the
// library splash with nothing happening.
val uri = intent.data ?: return
tryTakePersistableReadPermission(uri)
emitSharedIntent("VIEW", listOf(uri))
}
Intent.ACTION_SEND -> {
// System share-sheet → "Send to Readest" (single file).
// The URI lives on EXTRA_STREAM, not on intent.data, which
// is why the previous data-only handler never saw share
// captures at all.
val uri = getExtraStream(intent) ?: return
tryTakePersistableReadPermission(uri)
emitSharedIntent("SEND", listOf(uri))
}
Intent.ACTION_SEND_MULTIPLE -> {
val uris = getExtraStreamList(intent)
if (uris.isEmpty()) return
uris.forEach { tryTakePersistableReadPermission(it) }
emitSharedIntent("SEND", uris)
}
}
}
private fun tryTakePersistableReadPermission(uri: Uri) {
// Only content:// URIs support persistable permissions; file://
// URIs are accessible directly and would throw SecurityException
// here. Skip the call rather than swallow noisy logs.
if (uri.scheme != "content") return
try {
activity.contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
} catch (e: SecurityException) {
Log.w("NativeBridgePlugin", "takePersistableUriPermission failed for $uri: ${e.message}")
}
}
@Suppress("DEPRECATION")
private fun getExtraStream(intent: Intent): Uri? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
} else {
intent.getParcelableExtra(Intent.EXTRA_STREAM) as? Uri
}
}
@Suppress("DEPRECATION")
private fun getExtraStreamList(intent: Intent): List<Uri> {
val list: ArrayList<Uri>? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)
} else {
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)
}
return list ?: emptyList()
}
private fun emitSharedIntent(action: String, uris: List<Uri>) {
val payload = JSObject().apply {
put("action", action)
val arr = JSArray()
uris.forEach { arr.put(it.toString()) }
put("urls", arr)
}
triggerEvent("shared-intent", payload)
}
@Command
+10
View File
@@ -29,6 +29,8 @@ mod discord_rpc;
#[cfg(target_os = "macos")]
mod macos;
mod transfer_file;
#[cfg(desktop)]
mod window_state;
#[cfg(target_os = "windows")]
use tauri::webview::ScrollBarStyle;
use tauri::{command, Emitter, WebviewUrl, WebviewWindowBuilder, Window};
@@ -288,6 +290,7 @@ pub fn run() {
.plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_sharekit::init())
.plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_device_info::init())
.plugin(tauri_plugin_turso::init())
.plugin(tauri_plugin_native_bridge::init())
@@ -317,6 +320,13 @@ pub fn run() {
#[cfg(desktop)]
let builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
// Strip invalid geometry from the saved window state before the
// window-state plugin loads it, so a bad `.window-state.json` (e.g. the
// Windows minimized `-32000` sentinel) can't crash WebView2 on launch.
// See https://github.com/readest/readest/issues/4398.
#[cfg(desktop)]
let builder = builder.plugin(window_state::init());
#[cfg(desktop)]
let builder = builder.plugin(tauri_plugin_window_state::Builder::default().build());
@@ -0,0 +1,143 @@
//! Defensive sanitizer for the `.window-state.json` file written by
//! `tauri-plugin-window-state`.
//!
//! On Windows a minimized window reports its position as `(-32000, -32000)`
//! and its size as `0x0`. The plugin already guards against persisting those
//! values, but a state file written by an older build (or a future
//! regression) can still contain them, and WebView2 then rejects the restored
//! bounds with `0x80070057` ("The parameter is incorrect"), leaving the app
//! unable to launch. See https://github.com/readest/readest/issues/4398.
//!
//! This module strips any window entry with invalid geometry from the state
//! file *before* the window-state plugin loads it, so the affected window
//! falls back to its default position and size instead of crashing.
use std::path::Path;
use tauri::{
plugin::{Builder, TauriPlugin},
Manager, Runtime,
};
/// Default filename used by `tauri-plugin-window-state`.
const STATE_FILENAME: &str = ".window-state.json";
/// Windows parks a minimized window at exactly `(-32000, -32000)`. Real
/// monitors sit only a few thousand pixels off the origin even in multi-display
/// setups (a 4K display left of the primary is `-3840`), so a saved coordinate
/// at or below `-16000` — roughly halfway to the sentinel and well past any
/// normal desktop — is the minimize marker rather than a real position. A
/// normal negative like `-1920` stays well above the cutoff and is kept.
const MIN_VALID_COORD: i64 = -16000;
/// Returns a sanitized copy of the window-state JSON when one or more window
/// entries have invalid geometry, or `None` when nothing needs to change
/// (already valid, empty, or unparseable).
fn sanitize_json(content: &str) -> Option<String> {
let mut windows: serde_json::Map<String, serde_json::Value> =
serde_json::from_str(content).ok()?;
let before = windows.len();
windows.retain(|_, state| has_valid_geometry(state));
if windows.len() == before {
return None;
}
serde_json::to_string_pretty(&windows).ok()
}
/// A window entry is only usable if it has a positive size and an on-screen
/// position. Missing fields are treated as valid so a schema change never
/// drops an otherwise-good entry.
fn has_valid_geometry(state: &serde_json::Value) -> bool {
let int = |key: &str, default: i64| {
state
.get(key)
.and_then(serde_json::Value::as_i64)
.unwrap_or(default)
};
int("width", 1) > 0
&& int("height", 1) > 0
&& int("x", 0) > MIN_VALID_COORD
&& int("y", 0) > MIN_VALID_COORD
}
/// Reads, sanitizes, and rewrites the window-state file at `path`. Removes the
/// file entirely when sanitizing leaves no valid entries.
fn sanitize_file(path: &Path) {
let Ok(content) = std::fs::read_to_string(path) else {
return;
};
let Some(sanitized) = sanitize_json(&content) else {
return;
};
log::warn!("Removing invalid window geometry from {}", path.display());
if sanitized.trim() == "{}" {
let _ = std::fs::remove_file(path);
} else {
let _ = std::fs::write(path, sanitized);
}
}
/// Tauri plugin that sanitizes the saved window state during setup. Register it
/// immediately **before** `tauri-plugin-window-state` so the bad geometry is
/// gone before that plugin loads the file.
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("window-state-sanitizer")
.setup(|app, _api| {
if let Ok(dir) = app.path().app_config_dir() {
sanitize_file(&dir.join(STATE_FILENAME));
}
Ok(())
})
.build()
}
#[cfg(test)]
mod tests {
use super::sanitize_json;
const VALID: &str = r#"{"main":{"width":1280,"height":800,"x":100,"y":100,"prev_x":0,"prev_y":0,"maximized":false,"visible":true,"decorated":true,"fullscreen":false}}"#;
#[test]
fn keeps_valid_state() {
assert!(sanitize_json(VALID).is_none());
}
#[test]
fn keeps_negative_multi_monitor_position() {
// A monitor to the left yields a legitimately negative x (e.g. -1920).
let json = r#"{"main":{"width":1280,"height":800,"x":-1920,"y":0}}"#;
assert!(sanitize_json(json).is_none());
}
#[test]
fn keeps_deep_multi_monitor_position() {
// Even a few stacked displays left of the primary stay well above the
// cutoff (three 4K monitors reach only ~ -11520).
let json = r#"{"main":{"width":1280,"height":800,"x":-11520,"y":0}}"#;
assert!(sanitize_json(json).is_none());
}
#[test]
fn drops_minimized_sentinel_position() {
let json = r#"{"main":{"width":800,"height":600,"x":-32000,"y":-32000}}"#;
assert_eq!(sanitize_json(json).as_deref().map(str::trim), Some("{}"));
}
#[test]
fn drops_zero_size() {
let json = r#"{"main":{"width":0,"height":0,"x":100,"y":100}}"#;
assert!(sanitize_json(json).is_some());
}
#[test]
fn keeps_good_entry_drops_bad_entry() {
let json = r#"{"good":{"width":1280,"height":800,"x":0,"y":0},"bad":{"width":0,"height":0,"x":-32000,"y":-32000}}"#;
let out = sanitize_json(json).expect("file changed");
assert!(out.contains("good"));
assert!(!out.contains("bad"));
}
#[test]
fn ignores_unparseable_content() {
assert!(sanitize_json("not json").is_none());
}
}
@@ -0,0 +1,94 @@
import { describe, expect, it } from 'vitest';
import { getBookContextMenuItemIds } from '@/app/library/utils/libraryUtils';
import { Book } from '@/types/book';
const createBook = (overrides: Partial<Book> = {}): Book => ({
hash: 'hash-1',
format: 'EPUB',
title: 'Test Book',
author: 'Test Author',
createdAt: 0,
updatedAt: 0,
...overrides,
});
describe('getBookContextMenuItemIds', () => {
it('returns a deterministic order for a local downloaded book', () => {
const book = createBook({ downloadedAt: 1 });
expect(getBookContextMenuItemIds(book)).toEqual([
'select',
'group',
'markFinished',
'showDetails',
'showInFinder',
'upload',
'share',
'delete',
]);
});
it('shows "Mark as Unread" + "Clear Status" for a finished book', () => {
const book = createBook({ downloadedAt: 1, readingStatus: 'finished' });
expect(getBookContextMenuItemIds(book)).toEqual([
'select',
'group',
'markUnread',
'clearStatus',
'showDetails',
'showInFinder',
'upload',
'share',
'delete',
]);
});
it('shows "Mark as Finished" + "Clear Status" for an unread book', () => {
const book = createBook({ downloadedAt: 1, readingStatus: 'unread' });
expect(getBookContextMenuItemIds(book)).toEqual([
'select',
'group',
'markFinished',
'clearStatus',
'showDetails',
'showInFinder',
'upload',
'share',
'delete',
]);
});
it('offers Download (not Upload) for a cloud-only book', () => {
const book = createBook({ uploadedAt: 1 });
expect(getBookContextMenuItemIds(book)).toEqual([
'select',
'group',
'markFinished',
'showDetails',
'showInFinder',
'download',
'share',
'delete',
]);
});
it('omits download/upload/share for a book that is neither downloaded nor uploaded', () => {
const book = createBook({ filePath: '/some/external/file.epub' });
expect(getBookContextMenuItemIds(book)).toEqual([
'select',
'group',
'markFinished',
'showDetails',
'showInFinder',
'delete',
]);
});
it('produces the same order on repeated calls and never duplicates an item (issue #4389)', () => {
const book = createBook({ downloadedAt: 1, uploadedAt: 1, readingStatus: 'finished' });
const first = getBookContextMenuItemIds(book);
const second = getBookContextMenuItemIds(book);
expect(second).toEqual(first);
expect(new Set(first).size).toBe(first.length);
});
});
@@ -0,0 +1,84 @@
import { act, cleanup, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
MigrateDataWindow,
setMigrateDataDirDialogVisible,
} from '@/app/library/components/MigrateDataWindow';
vi.mock('@/hooks/useTranslation', () => ({
useTranslation: () => (key: string, options?: Record<string, string | number>) => {
if (!options) return key;
return key.replace(/{{(\w+)}}/g, (_match, name) => String(options[name] ?? ''));
},
}));
const appService = {
isAndroidApp: false,
isDesktopApp: true,
distChannel: 'github',
resolveFilePath: vi.fn(async () => '/current/data/dir'),
readDirectory: vi.fn(async () => []),
};
vi.mock('@/context/EnvContext', () => ({
useEnv: () => ({ appService, envConfig: {} }),
}));
vi.mock('@/store/settingsStore', () => ({
useSettingsStore: () => ({ settings: {}, setSettings: vi.fn(), saveSettings: vi.fn() }),
}));
vi.mock('@tauri-apps/api/path', () => ({
documentDir: vi.fn(async () => '/docs'),
join: vi.fn(async (...parts: string[]) => parts.join('/')),
}));
vi.mock('@tauri-apps/plugin-process', () => ({ relaunch: vi.fn() }));
vi.mock('@tauri-apps/plugin-opener', () => ({ revealItemInDir: vi.fn() }));
vi.mock('@/utils/bridge', () => ({ getExternalSDCardPath: vi.fn(async () => ({ path: '' })) }));
vi.mock('@/utils/permission', () => ({ requestStoragePermission: vi.fn(async () => true) }));
// Preserve the dialog id so the component's getElementById event wiring works.
vi.mock('@/components/Dialog', () => ({
__esModule: true,
default: ({
id,
title,
children,
}: {
id?: string;
title?: string;
children: React.ReactNode;
}) => (
<div id={id} role='dialog' aria-label={title}>
{children}
</div>
),
}));
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe('MigrateDataWindow e-ink button hierarchy', () => {
it('uses btn-ghost (not btn-outline) for the Cancel button so it stays distinct from the primary CTA in e-ink', async () => {
render(<MigrateDataWindow />);
await act(async () => {
setMigrateDataDirDialogVisible(true);
});
const cancelButton = await screen.findByRole('button', { name: 'Cancel' });
const startButton = screen.getByRole('button', { name: 'Start Migration' });
// The primary CTA keeps btn-primary, which e-ink inverts to a solid black fill.
expect(startButton.className).toContain('btn-primary');
// The Cancel button must NOT use btn-outline: in e-ink, btn-outline inverts
// to the SAME solid black fill as btn-primary, leaving the two buttons
// indistinguishable (see issue #4396). It must be a borderless ghost instead.
expect(cancelButton.className).not.toContain('btn-outline');
expect(cancelButton.className).toContain('btn-ghost');
});
});
@@ -0,0 +1,223 @@
import { render, act, cleanup } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
import type { BookNote } from '@/types/book';
// ---------- Shared mutable test state (captured by the mock factories) ----------
let scrollToIndexSpy: Mock<(arg: unknown) => void>;
// Only the FIRST (mount-time) `initialized` callback is captured, mirroring how
// OverlayScrollbars binds its event handlers when it initializes the viewport.
// A fix that relied on a fresher render closure would pass against the latest
// callback but still break in the real app — so the test forces a ref-based fix.
let capturedInitialized:
| ((instance: { elements: () => { viewport: HTMLElement } }) => void)
| undefined;
// Latest props Virtuoso was rendered with, so tests can assert the mount-time
// position (initialTopMostItemIndex) the panel hands it.
let capturedVirtuosoProps: Record<string, unknown> | undefined;
let mockProgress: { location: string } | null;
let mockBooknotes: BookNote[];
// ---------- Mocks ----------
vi.mock('@/store/bookDataStore', () => ({
useBookDataStore: () => ({ getConfig: () => ({ booknotes: mockBooknotes }) }),
}));
vi.mock('@/store/readerStore', () => ({
useReaderStore: () => ({ getProgress: () => mockProgress }),
}));
vi.mock('@/store/sidebarStore', () => ({
useSidebarStore: () => ({
setActiveBooknoteType: vi.fn(),
setBooknoteResults: vi.fn(),
}),
}));
// Derive a per-chapter TOC group from the spine step of each note's CFI so the
// flattened list is [header, note, header, note, ...] sorted by chapter.
vi.mock('@/services/nav', () => ({
findTocItemBS: (_toc: unknown, cfi: string) => {
const match = cfi.match(/\/6\/(\d+)!/);
const n = match ? Number(match[1]) : 0;
return { id: n, href: `ch${n}.html`, label: `Chapter ${n}`, index: n };
},
}));
vi.mock('@/hooks/useTranslation', () => ({
useTranslation: () => (s: string) => s,
}));
vi.mock('@/utils/event', () => ({
eventDispatcher: { dispatch: vi.fn(), on: vi.fn(), off: vi.fn() },
}));
vi.mock('@/app/reader/components/sidebar/BooknoteItem', () => ({
default: () => null,
}));
vi.mock('@/app/reader/components/EmptyState', () => ({
default: () => null,
}));
// Virtuoso is replaced with a stub that exposes a spy-able `scrollToIndex`
// through the imperative handle and hands BooknoteView a scroller element.
vi.mock('react-virtuoso', async () => {
const ReactMod = await import('react');
return {
Virtuoso: ReactMod.forwardRef(
(
props: { scrollerRef?: (el: HTMLElement | Window | null) => void },
ref: React.Ref<unknown>,
) => {
capturedVirtuosoProps = props as Record<string, unknown>;
ReactMod.useImperativeHandle(ref, () => ({
scrollToIndex: (arg: unknown) => scrollToIndexSpy(arg),
}));
ReactMod.useEffect(() => {
props.scrollerRef?.(document.createElement('div'));
}, []);
return null;
},
),
};
});
// Capture the OverlayScrollbars `initialized` callback so the test can fire it
// on demand (the real hook fires it after a deferred, timing-dependent init).
vi.mock('overlayscrollbars-react', () => ({
useOverlayScrollbars: (opts: {
events?: { initialized?: (i: { elements: () => { viewport: HTMLElement } }) => void };
}) => {
if (!capturedInitialized) capturedInitialized = opts.events?.initialized;
return [vi.fn(), () => undefined];
},
}));
// eslint-disable-next-line import/first
import BooknoteView from '@/app/reader/components/sidebar/BooknoteView';
const makeNote = (cfi: string): BookNote =>
({
id: cfi,
type: 'annotation',
cfi,
text: cfi,
note: '',
createdAt: 0,
updatedAt: 0,
}) as BookNote;
const fireOverlayScrollbarsInitialized = () => {
act(() => {
capturedInitialized?.({ elements: () => ({ viewport: document.createElement('div') }) });
});
};
beforeEach(() => {
scrollToIndexSpy = vi.fn<(arg: unknown) => void>();
capturedInitialized = undefined;
capturedVirtuosoProps = undefined;
mockProgress = null;
mockBooknotes = [
makeNote('epubcfi(/6/4!/4/2:0)'),
makeNote('epubcfi(/6/6!/4/4:0)'),
makeNote('epubcfi(/6/8!/4/2:0)'),
makeNote('epubcfi(/6/10!/4/6:0)'),
makeNote('epubcfi(/6/26!/4/2:0)'),
];
// Run rAF synchronously so the callback's scroll happens inside act().
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
cb(0);
return 0;
});
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe('BooknoteView — OverlayScrollbars init does not rewind the list to the top', () => {
it('re-applies the auto-scroll to the nearest note when OverlayScrollbars initializes after the reading position arrives', () => {
// Fresh open: BooknoteView mounts before the first relocate, so `progress`
// (and thus the nearest cfi) has no reading position yet.
mockProgress = null;
const { rerender } = render(<BooknoteView type='annotation' bookKey='book1' toc={[]} />);
// The relocate arrives → the normal auto-scroll effect centers the nearest
// note. The list is [h4, n4, h6, n6, h8, n8, h10, n10, h26, n26]; the
// reading position is in the last chapter, so the nearest note is index 9.
mockProgress = { location: 'epubcfi(/6/26!/4/10:0)' };
act(() => {
rerender(<BooknoteView type='annotation' bookKey='book1' toc={[]} />);
});
// Ignore that first scroll; we only care whether the OverlayScrollbars init
// (which clobbers scrollTop) re-applies it instead of stranding the top.
scrollToIndexSpy.mockClear();
fireOverlayScrollbarsInitialized();
expect(scrollToIndexSpy).toHaveBeenCalledWith(expect.objectContaining({ index: 9 }));
});
it('does not force a scroll on OverlayScrollbars init when there is no reading position', () => {
mockProgress = null;
render(<BooknoteView type='annotation' bookKey='book1' toc={[]} />);
scrollToIndexSpy.mockClear();
fireOverlayScrollbarsInitialized();
expect(scrollToIndexSpy).not.toHaveBeenCalled();
});
it('positions Virtuoso natively on mount (without a racing scrollToIndex) when the reading position is already known', () => {
// Switching to the panel while reading: the reading position is available at
// mount. A scrollToIndex against the freshly mounted, unmeasured list no-ops
// or wedges it into rendering nothing — so the panel must mount Virtuoso
// already centered via initialTopMostItemIndex and the scroll effect must
// skip its first jump.
mockProgress = { location: 'epubcfi(/6/26!/4/10:0)' };
act(() => {
render(<BooknoteView type='annotation' bookKey='book1' toc={[]} />);
});
expect(capturedVirtuosoProps?.['initialTopMostItemIndex']).toEqual({
index: 9,
align: 'center',
});
expect(scrollToIndexSpy).not.toHaveBeenCalled();
// The deferred OverlayScrollbars init resets scrollTop; the re-apply then
// restores the centered position (now that the rows have been measured).
fireOverlayScrollbarsInitialized();
expect(scrollToIndexSpy).toHaveBeenCalledWith(expect.objectContaining({ index: 9 }));
});
it('jumps instantly (behavior auto) for a far scroll instead of animating it, like TOCView', () => {
// 12 notes in distinct chapters → flat list [h, n, h, n, ...] of 24 rows;
// the nearest note (last chapter) sits at index 23, far from the top.
mockBooknotes = Array.from({ length: 12 }, (_, i) =>
makeNote(`epubcfi(/6/${4 + i * 2}!/4/2:0)`),
);
// Reload-style: no reading position at mount, so initialTopMostItemIndex
// does not handle it and the scroll effect performs the jump.
mockProgress = null;
const { rerender } = render(<BooknoteView type='annotation' bookKey='book1' toc={[]} />);
mockProgress = { location: 'epubcfi(/6/26!/4/10:0)' };
act(() => {
rerender(<BooknoteView type='annotation' bookKey='book1' toc={[]} />);
});
// distance (23 - 0) > 16 → instant jump, not a smooth animation.
expect(scrollToIndexSpy).toHaveBeenCalledWith(
expect.objectContaining({ index: 23, behavior: 'auto' }),
);
expect(scrollToIndexSpy).not.toHaveBeenCalledWith(
expect.objectContaining({ behavior: 'smooth' }),
);
});
});
@@ -0,0 +1,39 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, cleanup } from '@testing-library/react';
import ImageViewer from '@/app/reader/components/ImageViewer';
vi.mock('@/hooks/useTranslation', () => ({
useTranslation: () => (s: string) => s,
}));
// useKeyDownActions pulls in EnvContext + device store; not under test here.
vi.mock('@/hooks/useKeyDownActions', () => ({
useKeyDownActions: () => {},
}));
// ZoomControls reaches into the theme store and Tauri window APIs; stub it out.
vi.mock('@/app/reader/components/ZoomControls', () => ({
__esModule: true,
default: () => null,
}));
afterEach(cleanup);
const gridInsets = { top: 0, right: 0, bottom: 0, left: 0 };
describe('ImageViewer', () => {
it('suppresses the native image callout on the zoomed image', () => {
// The WebView's native long-press image callout collides with the
// viewer's own pinch/pan handlers on Android and freezes the app. The
// zoomed <img> must live under a `.no-context-menu` ancestor so the
// global `.no-context-menu img { -webkit-touch-callout: none }` rule
// disables that callout. Mirrors the book-cover fix (PR #4345).
const { container } = render(
<ImageViewer src='blob:test-image' onClose={vi.fn()} gridInsets={gridInsets} />,
);
const calloutSafeImage = container.querySelector('.no-context-menu img');
expect(calloutSafeImage).toBeTruthy();
});
});
@@ -178,3 +178,26 @@ describe('ProgressBar — fixed-layout remaining pages', () => {
);
});
});
describe('ProgressBar — decorative footer is not focusable', () => {
it('does not make the progress info container focusable (no stray focus ring)', () => {
// The footer info is a decorative role="presentation" element. A negative
// tabindex made it focusable, so long-pressing it on Android focused the
// div and the WebView painted its default focus ring as a persistent line
// across the bottom of the page (issue #4397). A decorative element must
// not be focusable so it can never receive a focus ring.
currentViewSettings = {
...baseSettings,
progressInfoMode: 'all',
} as ViewSettings;
currentProgress = makeProgress(2, 5);
currentBookData = { isFixedLayout: false };
currentRenderer = { page: 1, pages: 4 };
const { container } = renderProgressBar();
const progressInfo = container.querySelector('.progressinfo');
expect(progressInfo).not.toBeNull();
expect(progressInfo!.hasAttribute('tabindex')).toBe(false);
});
});
@@ -1,21 +1,89 @@
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import ReadingRuler from '@/app/reader/components/ReadingRuler';
import { ViewSettings } from '@/types/book';
import { BookFormat, ViewSettings } from '@/types/book';
import { eventDispatcher } from '@/utils/event';
const saveViewSettings = vi.fn();
vi.mock('@/context/EnvContext', () => ({
useEnv: () => ({ envConfig: {} }),
}));
type RulerTestRect = {
top: number;
bottom: number;
left: number;
right: number;
width: number;
height: number;
};
vi.mock('@/store/readerStore', () => ({
useReaderStore: () => ({
getProgress: () => null,
getView: () => ({ renderer: { columnCount: 1 } }),
}),
}));
// Mutable doubles read lazily by the store mock so individual tests can drive
// the progress range and the scrolled-mode visible contents.
let mockProgress: { range: unknown; pageinfo: { current: number } } | null = null;
let mockContents: Array<{ doc: unknown }> = [];
vi.mock('@/context/EnvContext', () => {
// Stable envConfig ref; an unstable one would churn the throttled save → ruler
// setter → applyBlock callbacks and make the cache effect re-run every render.
const env = { envConfig: {} };
return { useEnv: () => env };
});
vi.mock('@/store/readerStore', () => {
// Stable store-method references (zustand returns the same selectors across
// renders); the ReadingRuler cache effect lists getView as a dependency, so an
// unstable ref would make it re-run on every render.
const getProgress = () => mockProgress;
const getView = () => ({ renderer: { columnCount: 1, getContents: () => mockContents } });
const store = { getProgress, getView };
return { useReaderStore: () => store };
});
// Evenly spaced single-column text lines in iframe-content coordinates.
const makeLineRects = (count: number, pitch: number, height: number): RulerTestRect[] =>
Array.from({ length: count }, (_, i) => ({
top: i * pitch,
bottom: i * pitch + height,
left: 50,
right: 750,
width: 700,
height,
}));
// A single visible section whose iframe is offset by `frameTop` along the scroll
// axis (negative = scrolled down). `buildScrolledLineBoxes` walks these contents.
const makeScrolledContents = (
frameTop: number,
lineRects: RulerTestRect[],
): Array<{ doc: unknown }> => {
const doc: {
body: object;
createRange: () => unknown;
defaultView: { frameElement: { getBoundingClientRect: () => DOMRect } };
} = {
body: {},
createRange: () => ({
startContainer: { ownerDocument: doc },
selectNodeContents: () => {},
getClientRects: () => lineRects,
}),
defaultView: {
frameElement: {
getBoundingClientRect: () =>
({
x: 0,
y: frameTop,
top: frameTop,
left: 0,
right: 800,
bottom: frameTop + 5000,
width: 800,
height: 5000,
toJSON: () => ({}),
}) as DOMRect,
},
},
};
return [{ doc }];
};
vi.mock('@/helpers/settings', () => ({
saveViewSettings: (...args: unknown[]) => saveViewSettings(...args),
@@ -38,6 +106,8 @@ describe('ReadingRuler', () => {
beforeEach(() => {
vi.clearAllMocks();
mockProgress = null;
mockContents = [];
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
@@ -188,4 +258,79 @@ describe('ReadingRuler', () => {
expect(consumed).toBe(false);
expect(saveViewSettings).not.toHaveBeenCalled();
});
// Regression: issue #4386 — in scrolled mode the ruler used to re-snap on every
// relocate fired while scrolling, so its position crept down the page. It must
// stay fixed on screen while scrolling; snapping only happens on click.
it('keeps the ruler fixed on screen while scrolling in scrolled mode', () => {
const lineRects = makeLineRects(50, 100, 40);
mockProgress = { range: {}, pageinfo: { current: 0 } };
mockContents = makeScrolledContents(0, lineRects);
const scrolledSettings = { ...viewSettings, scrolled: true } as ViewSettings;
const props = {
bookKey: 'book-1',
isVertical: false,
rtl: false,
lines: 2,
position: 50,
opacity: 0.5,
color: 'transparent' as const,
bookFormat: 'EPUB' as BookFormat,
viewSettings: scrolledSettings,
gridInsets: { top: 0, right: 0, bottom: 0, left: 0 },
};
const { container, rerender } = render(<ReadingRuler {...props} />);
const rulerTop = () =>
parseFloat((container.querySelector('.ruler') as HTMLDivElement).style.top);
// Initial mount snaps the band to a real line, moving it off the 50% prop.
const initialTop = rulerTop();
expect(initialTop).toBeGreaterThan(50);
// Simulate scrolling: a new relocate range arrives with the content shifted
// up, but the section/page is unchanged.
mockProgress = { range: {}, pageinfo: { current: 0 } };
mockContents = makeScrolledContents(-130, lineRects);
rerender(<ReadingRuler {...props} />);
// The band must not move on its own as the reader scrolls.
expect(rulerTop()).toBeCloseTo(initialTop, 5);
});
it('still snaps the ruler to lines when advancing by click in scrolled mode', async () => {
const lineRects = makeLineRects(50, 100, 40);
mockProgress = { range: {}, pageinfo: { current: 0 } };
mockContents = makeScrolledContents(0, lineRects);
const scrolledSettings = { ...viewSettings, scrolled: true } as ViewSettings;
const { container } = render(
<ReadingRuler
bookKey='book-1'
isVertical={false}
rtl={false}
lines={2}
position={50}
opacity={0.5}
color='transparent'
bookFormat='EPUB'
viewSettings={scrolledSettings}
gridInsets={{ top: 0, right: 0, bottom: 0, left: 0 }}
/>,
);
const rulerTop = () =>
parseFloat((container.querySelector('.ruler') as HTMLDivElement).style.top);
const before = rulerTop();
const consumed = eventDispatcher.dispatchSync('reading-ruler-move', {
bookKey: 'book-1',
direction: 'forward',
});
expect(consumed).toBe(true);
await waitFor(() => {
expect(rulerTop()).toBeGreaterThan(before);
});
});
});
@@ -13,7 +13,7 @@
// See readest/readest swipe background flash.
import { describe, expect, it } from 'vitest';
import { computeBackgroundSegments } from 'foliate-js/paginator.js';
import { computeBackgroundSegments, textureAwareBackground } from 'foliate-js/paginator.js';
describe('computeBackgroundSegments', () => {
it('splits the background at the content seam mid-swipe (incoming page keeps its own colour)', () => {
@@ -43,24 +43,27 @@ describe('computeBackgroundSegments', () => {
expect(segments).toEqual([{ start: 0, size: 430, bg: 'rgb(0, 0, 0)' }]);
});
it('stretches a centered page into the full-bleed gutters (inset > 0)', () => {
// Desktop-style: container is inset 50px inside a 430px background.
it('keeps a centered page inside its content area (inset > 0)', () => {
// Desktop-style: container is inset 50px inside a 430px background. The page
// background stays inside its column [50, 380] — it must NOT bleed into the
// outer margin gutters, so the page stays centered (readest#4394).
const views = [{ size: 330, bg: 'rgb(0, 0, 0)' }];
const segments = computeBackgroundSegments(views, 0, 430, 50, 330);
// Extends out to both background edges so the page is full-bleed.
expect(segments).toEqual([{ start: 0, size: 430, bg: 'rgb(0, 0, 0)' }]);
expect(segments).toEqual([{ start: 50, size: 330, bg: 'rgb(0, 0, 0)' }]);
});
it('gives each section its own full-bleed half in a two-up spread', () => {
it('keeps each section inside its own column in a two-up spread', () => {
const views = [
{ size: 215, bg: 'rgb(255, 0, 0)' },
{ size: 215, bg: 'rgb(0, 0, 255)' },
];
// bg 500 wide, container 430 inset 35 → seam at 35 + 215 = 250.
const segments = computeBackgroundSegments(views, 0, 500, 35, 430);
// Each page fills its column up to the seam; neither bleeds into its outer
// gutter ([0, 35] and [465, 500] stay as symmetric margins).
expect(segments).toEqual([
{ start: 0, size: 250, bg: 'rgb(255, 0, 0)' }, // left page bleeds into left gutter
{ start: 250, size: 250, bg: 'rgb(0, 0, 255)' }, // right page bleeds into right gutter
{ start: 35, size: 215, bg: 'rgb(255, 0, 0)' },
{ start: 250, size: 215, bg: 'rgb(0, 0, 255)' },
]);
});
@@ -74,4 +77,65 @@ describe('computeBackgroundSegments', () => {
const segments = computeBackgroundSegments(views, 430, 430, 0, 430);
expect(segments).toEqual([{ start: 0, size: 430, bg: 'rgb(1, 1, 1)' }]);
});
// Regression test for readest/readest#4394. Backgrounds used to bleed into the
// outer margin gutter on the side they touched, so a mixed two-up spread — a
// transparent cover (no segment) beside a body-coloured title page — filled
// the right gutter with colour while the left gutter stayed the plain margin.
// The spread then looked shifted off-centre (badly so on wide screens, where
// each gutter is ~250px). Each page now stays inside its own column.
it('keeps symmetric margins when only the right page of a two-up spread is colored', () => {
const views = [
{ size: 215, bg: '' }, // left page — transparent cover image, no segment
{ size: 215, bg: 'rgb(255, 228, 63)' }, // right page — yellow title
];
// bg 500 wide, container 430 inset 35 → seam at 35 + 215 = 250.
const segments = computeBackgroundSegments(views, 0, 500, 35, 430);
// The yellow page stops at the content edge (465); it must NOT bleed into
// the right gutter [465, 500] while the transparent left gutter shows white.
expect(segments).toEqual([{ start: 250, size: 215, bg: 'rgb(255, 228, 63)' }]);
});
it('keeps symmetric margins when only the left page of a two-up spread is colored', () => {
const views = [
{ size: 215, bg: 'rgb(255, 228, 63)' }, // left page — yellow
{ size: 215, bg: '' }, // right page — transparent, no segment
];
const segments = computeBackgroundSegments(views, 0, 500, 35, 430);
// The yellow page starts at the content edge (35); it must NOT bleed into
// the left gutter [0, 35] while the transparent right gutter shows white.
expect(segments).toEqual([{ start: 35, size: 215, bg: 'rgb(255, 228, 63)' }]);
});
});
// Regression test for readest/readest#4399: a background texture mounted on the
// reader container (`.foliate-viewer::before`) shows in scrolled mode but is
// absent in paginated mode. The texture is occluded when the paginator paints an
// opaque fill over a page whose own background is transparent. Both modes must
// drop the fill for transparent pages while a texture is active so the texture
// shows through; pages that force their own opaque colour keep painting.
describe('textureAwareBackground', () => {
it('drops a transparent page background when a texture is active (texture shows through)', () => {
expect(textureAwareBackground('rgba(0, 0, 0, 0)', true)).toBe('');
expect(textureAwareBackground('transparent', true)).toBe('');
expect(textureAwareBackground('', true)).toBe('');
// The real value captured from the iframe html computed `background`.
expect(
textureAwareBackground(
'rgba(0, 0, 0, 0) none repeat scroll 0% 0% / auto padding-box border-box',
true,
),
).toBe('');
});
it('keeps an opaque page background even when a texture is active', () => {
expect(textureAwareBackground('rgb(0, 0, 0)', true)).toBe('rgb(0, 0, 0)');
expect(textureAwareBackground('rgb(255, 255, 255)', true)).toBe('rgb(255, 255, 255)');
});
it('never drops a background when no texture is active (no regression)', () => {
expect(textureAwareBackground('rgba(0, 0, 0, 0)', false)).toBe('rgba(0, 0, 0, 0)');
expect(textureAwareBackground('rgb(0, 0, 0)', false)).toBe('rgb(0, 0, 0)');
expect(textureAwareBackground('', false)).toBe('');
});
});
@@ -0,0 +1,150 @@
import { describe, it, expect, beforeAll, afterEach } from 'vitest';
import { DocumentLoader } from '@/libs/document';
import type { BookDoc } from '@/libs/document';
import type { Renderer } from '@/types/view';
// repro-4379: a Duokan/DangDang full-page cover — `data-duokan-page-fullscreen`
// on <html> with a dimensionless <img> inside a `.cover` wrapper. The paginator
// pins such covers with `position:absolute; inset:0; height:100%`, which fills
// the fixed-height page in paginated mode but collapses to zero against the
// auto-height scroll container in scrolled mode (the cover disappears).
const EPUB_URL = new URL('../fixtures/data/repro-4379.epub', import.meta.url).href;
let book: BookDoc;
const loadEPUB = async () => {
const resp = await fetch(EPUB_URL);
const buffer = await resp.arrayBuffer();
const file = new File([buffer], 'repro-4379.epub', { type: 'application/epub+zip' });
const loader = new DocumentLoader(file);
const { book } = await loader.open();
return book;
};
const waitForStabilized = (el: HTMLElement, timeout = 10000) =>
new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('stabilized timeout')), timeout);
el.addEventListener(
'stabilized',
() => {
clearTimeout(timer);
resolve();
},
{ once: true },
);
});
const getCoverImg = (paginator: Renderer): HTMLImageElement | null => {
const cover = paginator.getContents().find((c) => c.index === 0);
return (cover?.doc.body.querySelector('img') as HTMLImageElement | undefined) ?? null;
};
/** Wait for the cover <img> resource to decode so layout has a natural size. */
const waitForImgLoaded = async (img: HTMLImageElement, timeout = 5000) => {
const start = Date.now();
while (Date.now() - start < timeout) {
if (img.complete && img.naturalHeight > 0) return;
await new Promise((r) => setTimeout(r, 50));
}
};
/** Poll until the element has a non-zero rendered height (or time out). */
const waitForVisibleHeight = async (img: HTMLImageElement, timeout = 3000) => {
const start = Date.now();
while (Date.now() - start < timeout) {
if (img.offsetHeight > 0) return;
await new Promise((r) => setTimeout(r, 50));
}
};
describe('Paginator Duokan fullscreen cover (#4379)', () => {
let paginator: Renderer;
beforeAll(async () => {
book = await loadEPUB();
await import('foliate-js/paginator.js');
}, 30000);
const createPaginator = () => {
const el = document.createElement('foliate-paginator') as Renderer;
Object.assign(el.style, {
width: '800px',
height: '600px',
position: 'absolute',
left: '0',
top: '0',
});
document.body.appendChild(el);
return el;
};
afterEach(() => {
if (paginator) {
try {
paginator.destroy();
} catch {
/* iframe body may already be torn down */
}
paginator.remove();
}
});
it('shows the cover image in paginated mode (sanity)', async () => {
paginator = createPaginator();
paginator.open(book);
const stabilized = waitForStabilized(paginator);
await paginator.goTo({ index: 0 });
await stabilized;
const img = getCoverImg(paginator);
expect(img).toBeTruthy();
await waitForImgLoaded(img!);
await waitForVisibleHeight(img!);
expect(img!.offsetHeight).toBeGreaterThan(0);
});
it('shows the cover image in scrolled mode', async () => {
paginator = createPaginator();
paginator.open(book);
paginator.setAttribute('flow', 'scrolled');
const stabilized = waitForStabilized(paginator);
await paginator.goTo({ index: 0 });
await stabilized;
const img = getCoverImg(paginator);
expect(img).toBeTruthy();
await waitForImgLoaded(img!);
await waitForVisibleHeight(img!);
// Before the fix the cover is absolutely positioned at height:100% inside
// an auto-height (zero) container, so it collapses and never renders.
expect(img!.offsetHeight).toBeGreaterThan(0);
// It must also stay bounded by the viewport rather than overflowing it.
expect(img!.offsetHeight).toBeLessThanOrEqual(600);
});
it('shows the cover image after toggling paginated -> scrolled', async () => {
paginator = createPaginator();
paginator.open(book);
const stabilized = waitForStabilized(paginator);
await paginator.goTo({ index: 0 });
await stabilized;
const img = getCoverImg(paginator);
expect(img).toBeTruthy();
await waitForImgLoaded(img!);
await waitForVisibleHeight(img!);
const stabilized2 = waitForStabilized(paginator);
paginator.setAttribute('flow', 'scrolled');
await stabilized2;
// The same <img> element is re-rendered; stale absolute positioning from
// the paginated render must not leave it collapsed in scrolled mode.
await waitForVisibleHeight(img!);
expect(img!.offsetHeight).toBeGreaterThan(0);
});
});
@@ -0,0 +1,191 @@
import { describe, it, expect, beforeAll, afterEach } from 'vitest';
import { DocumentLoader } from '@/libs/document';
import type { BookDoc } from '@/libs/document';
import type { ViewSettings } from '@/types/book';
import type { Renderer } from '@/types/view';
import { getStyles } from '@/utils/style';
import { applyScrollableStyle, SCROLL_WRAPPER_CLASS } from '@/utils/scrollable';
import {
DEFAULT_BOOK_FONT,
DEFAULT_BOOK_LAYOUT,
DEFAULT_BOOK_STYLE,
DEFAULT_BOOK_LANGUAGE,
DEFAULT_VIEW_CONFIG,
DEFAULT_TTS_CONFIG,
DEFAULT_TRANSLATOR_CONFIG,
DEFAULT_ANNOTATOR_CONFIG,
DEFAULT_SCREEN_CONFIG,
} from '@/services/constants';
// sample-table-layout.epub: CSS layout tables (fixed em widths, images +
// wrapping prose) for character/glossary pages. They are designed to fit the
// reading column, so they must NOT get a horizontal scrollbar. Before the fix,
// `.readest-table-scroll > table { width: max-content }` forced every table to
// its unwrapped width, which overflowed the column and showed a scrollbar.
const LAYOUT_EPUB_URL = new URL('../fixtures/data/sample-table-layout.epub', import.meta.url).href;
// sample-table-wide.epub: a single section with a genuinely wide (many-column,
// non-wrapping) data table — it cannot fit the column, so it must SCROLL rather
// than be clipped.
const WIDE_EPUB_URL = new URL('../fixtures/data/sample-table-wide.epub', import.meta.url).href;
// Spine indices of sample-table-layout.epub whose sections contain <table>.
const TABLE_SECTION_INDICES = [2, 3, 4, 9, 10];
// Ignore sub-pixel / border slop when deciding whether a table really overflows.
const TOLERANCE_PX = 4;
const loadEPUB = async (url: string, name: string) => {
const resp = await fetch(url);
const buffer = await resp.arrayBuffer();
const file = new File([buffer], name, { type: 'application/epub+zip' });
const { book } = await new DocumentLoader(file).open();
return book;
};
const makeViewSettings = (): ViewSettings =>
({
...DEFAULT_BOOK_FONT,
...DEFAULT_BOOK_LAYOUT,
...DEFAULT_BOOK_STYLE,
...DEFAULT_BOOK_LANGUAGE,
...DEFAULT_VIEW_CONFIG,
...DEFAULT_TTS_CONFIG,
...DEFAULT_TRANSLATOR_CONFIG,
...DEFAULT_ANNOTATOR_CONFIG,
...DEFAULT_SCREEN_CONFIG,
}) as ViewSettings;
const waitForStabilized = (el: HTMLElement, timeout = 10000) =>
new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('stabilized timeout')), timeout);
el.addEventListener(
'stabilized',
() => {
clearTimeout(timer);
resolve();
},
{ once: true },
);
});
const poll = async (fn: () => boolean, timeout = 2000) => {
const start = Date.now();
while (Date.now() - start < timeout) {
if (fn()) return true;
await new Promise((r) => setTimeout(r, 30));
}
return fn();
};
const getSectionDoc = (paginator: Renderer, index: number): Document | null =>
paginator.getContents().find((c) => c.index === index)?.doc ?? null;
const overflow = (w: HTMLElement) => w.scrollWidth - w.clientWidth;
const overflowX = (w: HTMLElement) => w.ownerDocument.defaultView!.getComputedStyle(w).overflowX;
const showsScrollbar = (w: HTMLElement) =>
['auto', 'scroll'].includes(overflowX(w)) && overflow(w) > TOLERANCE_PX;
const isClipped = (w: HTMLElement) =>
!['auto', 'scroll'].includes(overflowX(w)) && overflow(w) > TOLERANCE_PX;
let layoutBook: BookDoc;
let wideBook: BookDoc;
describe('Paginator table layout', () => {
let paginator: Renderer;
beforeAll(async () => {
layoutBook = await loadEPUB(LAYOUT_EPUB_URL, 'sample-table-layout.epub');
wideBook = await loadEPUB(WIDE_EPUB_URL, 'sample-table-wide.epub');
await import('foliate-js/paginator.js');
}, 30000);
const createPaginator = (width: number) => {
const el = document.createElement('foliate-paginator') as Renderer;
Object.assign(el.style, {
width: `${width}px`,
height: '800px',
position: 'absolute',
left: '0',
top: '0',
});
document.body.appendChild(el);
return el;
};
afterEach(() => {
if (paginator) {
try {
paginator.destroy();
} catch {
/* iframe body may already be torn down */
}
paginator.remove();
}
});
it('renders layout tables without a horizontal scrollbar', async () => {
paginator = createPaginator(320);
paginator.open(layoutBook);
paginator.setStyles?.(getStyles(makeViewSettings(), undefined, []));
let checked = 0;
// Measure each section while it is still loaded (the paginator unloads
// sections as we navigate, so earlier docs would be detached afterwards).
for (const index of TABLE_SECTION_INDICES) {
const stabilized = waitForStabilized(paginator);
await paginator.goTo({ index });
await stabilized;
const doc = getSectionDoc(paginator, index);
expect(doc, `section ${index} doc`).toBeTruthy();
paginator.setStyles?.(getStyles(makeViewSettings(), undefined, []));
applyScrollableStyle(doc!);
for (const wrapper of doc!.querySelectorAll<HTMLElement>(`.${SCROLL_WRAPPER_CLASS}`)) {
expect(showsScrollbar(wrapper), `section ${index}: layout table shows a scrollbar`).toBe(
false,
);
expect(isClipped(wrapper), `section ${index}: layout table is clipped`).toBe(false);
checked += 1;
}
}
expect(checked).toBeGreaterThan(0);
}, 60000);
it('scrolls a table too wide for its column instead of clipping it', async () => {
paginator = createPaginator(600);
paginator.open(wideBook);
paginator.setStyles?.(getStyles(makeViewSettings(), undefined, []));
const stabilized = waitForStabilized(paginator);
await paginator.goTo({ index: 0 });
await stabilized;
const doc = getSectionDoc(paginator, 0);
expect(doc, 'wide section doc').toBeTruthy();
paginator.setStyles?.(getStyles(makeViewSettings(), undefined, []));
applyScrollableStyle(doc!);
const wrapper = doc!.querySelector<HTMLElement>(`.${SCROLL_WRAPPER_CLASS}`)!;
expect(wrapper, 'wide-table wrapper').toBeTruthy();
const table = wrapper.querySelector(':scope > table') as HTMLElement;
// Confirm this really is a wide table whose non-wrapping cells can't shrink
// to fit a reading column.
table.style.width = 'min-content';
const minContent = table.getBoundingClientRect().width;
table.style.removeProperty('width');
expect(minContent, 'wide table min-content width').toBeGreaterThan(500);
// Constrain the wrapper well below the table — the real-app situation where a
// wide table can't fit its column. (The bare paginator otherwise widens the
// column to fit content, so we reproduce the column constraint explicitly.)
wrapper.style.width = '250px';
wrapper.style.maxWidth = '250px';
// The wrapper is statically overflow-x:auto, so an over-wide table scrolls.
await poll(() => overflow(wrapper) > TOLERANCE_PX);
expect(overflow(wrapper), 'wide table should overflow its constrained column').toBeGreaterThan(
TOLERANCE_PX,
);
expect(['auto', 'scroll'], 'wide table overflow-x').toContain(overflowX(wrapper));
expect(isClipped(wrapper), 'wide table is clipped instead of scrolling').toBe(false);
}, 60000);
});
@@ -0,0 +1,90 @@
import { describe, it, expect } from 'vitest';
import { existsSync, readdirSync, readFileSync } from 'fs';
import { isAbsolute, resolve } from 'path';
/**
* Regression guard for a build-packaging bug (scanned PDFs rendered blank in
* CI builds of 0.11.2, but fine locally).
*
* pdfjs-dist 5.7.x moved several image decoders (notably JBIG2 the codec used
* by virtually every black-and-white *scanned* PDF) from pure JS into
* WebAssembly modules that the worker fetches at runtime from `wasmUrl`
* (`/vendor/pdfjs/` see packages/foliate-js/pdf.js). The `copy-pdfjs-wasm`
* npm script only copied an explicit allow-list (`openjpeg.wasm`, `qcms_bg.wasm`)
* and silently dropped `jbig2.wasm`. `cpx` does not error when a glob matches
* nothing, so the missing decoder went unnoticed: the worker loaded, pages
* turned, but JBIG2 image decoding failed blank pages.
*
* Invariant: every `.wasm` file the *bundled* pdf.js (main + worker) references
* and that actually exists in pdfjs-dist's `wasm/` directory must be copied into
* the public vendor folder by `copy-pdfjs-wasm`.
*/
const appRoot = process.cwd();
/** Extract the first double-quoted argument from a cpx-based npm script. */
const sourceGlobOf = (script: string): string => {
const match = script.match(/"([^"]+)"/);
if (!match) throw new Error(`No quoted source glob in script: ${script}`);
return match[1]!;
};
/**
* Resolve which files a cpx source glob of the form `<dir>/<token>` copies,
* where `<token>` is `*`, a `{a,b,c}` brace list, or a single filename.
* Returns the absolute directory and the concrete set of copied basenames.
*/
const resolveCpxGlob = (glob: string): { dir: string; files: Set<string> } => {
const slash = glob.lastIndexOf('/');
const dirPart = glob.slice(0, slash);
const token = glob.slice(slash + 1);
const dir = isAbsolute(dirPart) ? dirPart : resolve(appRoot, dirPart);
let names: string[];
if (token.includes('*')) {
names = readdirSync(dir);
} else if (token.startsWith('{') && token.endsWith('}')) {
names = token.slice(1, -1).split(',');
} else {
names = [token];
}
return { dir, files: new Set(names) };
};
describe('pdfjs vendor wasm assets', () => {
const pkg = JSON.parse(readFileSync(resolve(appRoot, 'package.json'), 'utf8')) as {
scripts: Record<string, string>;
};
it('copies every wasm decoder the bundled pdf.js references', () => {
// Files the worker/main bundle are copied from (source of truth for CI).
const jsGlob = sourceGlobOf(pkg.scripts['copy-pdfjs-js']!);
const { dir: jsDir, files: jsFiles } = resolveCpxGlob(jsGlob);
// The wasm modules available in pdfjs-dist and what the copy script ships.
const wasmGlob = sourceGlobOf(pkg.scripts['copy-pdfjs-wasm']!);
const { dir: wasmDir, files: copiedWasm } = resolveCpxGlob(wasmGlob);
const availableWasm = new Set(readdirSync(wasmDir).filter((f) => f.endsWith('.wasm')));
// Scan the bundled JS for `*.wasm` references, keeping only ones that map to
// a real file (drops minified false positives like `e.wasm`/`t.wasm`).
const referenced = new Set<string>();
for (const file of jsFiles) {
const full = resolve(jsDir, file);
if (!existsSync(full)) continue;
const text = readFileSync(full, 'utf8');
for (const m of text.matchAll(/[A-Za-z0-9_-]+\.wasm/g)) {
if (availableWasm.has(m[0])) referenced.add(m[0]);
}
}
// Sanity: the bump that caused the bug must still ship jbig2 as wasm.
expect(referenced.has('jbig2.wasm'), 'expected pdf.js to reference jbig2.wasm').toBe(true);
const missing = [...referenced].filter((w) => !copiedWasm.has(w));
expect(missing, `copy-pdfjs-wasm must copy these referenced wasm files: ${missing}`).toEqual(
[],
);
});
});
@@ -107,4 +107,25 @@ describe('NativeAppService.saveFile share gating', () => {
expect(shareFileMock).not.toHaveBeenCalled();
expect(saveDialogMock).toHaveBeenCalledTimes(1);
});
// The book "Send" flow hands an already-on-disk file straight to the share
// sheet via `filePath` and passes `null` content so nothing gets re-buffered
// into memory. The file at `filePath` must be shared verbatim without any
// write happening first.
test('shares the file at filePath without buffering when content is null', async () => {
const service = await loadServiceWithOS('macos');
await service.saveFile('book.epub', null, {
share: true,
mimeType: 'application/epub+zip',
filePath: '/abs/path/book.epub',
});
expect(shareFileMock).toHaveBeenCalledTimes(1);
expect(shareFileMock).toHaveBeenCalledWith(
'/abs/path/book.epub',
expect.objectContaining({ mimeType: 'application/epub+zip' }),
);
expect(writeFileMock).not.toHaveBeenCalled();
expect(writeTextFileMock).not.toHaveBeenCalled();
expect(saveDialogMock).not.toHaveBeenCalled();
});
});
@@ -1,9 +1,19 @@
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import { fetch as tauriFetch } from '@tauri-apps/plugin-http';
import { ReadwiseClient } from '@/services/readwise/ReadwiseClient';
import { READWISE_API_BASE_URL } from '@/services/constants';
import { isTauriAppPlatform } from '@/services/environment';
import type { ReadwiseSettings } from '@/types/settings';
import type { Book, BookNote } from '@/types/book';
vi.mock('@tauri-apps/plugin-http', () => ({
fetch: vi.fn(),
}));
vi.mock('@/services/environment', () => ({
isTauriAppPlatform: vi.fn(() => false),
}));
const makeSettings = (overrides: Partial<ReadwiseSettings> = {}): ReadwiseSettings => ({
enabled: true,
accessToken: 'test-token',
@@ -37,6 +47,10 @@ describe('ReadwiseClient base URL', () => {
beforeEach(() => {
fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
vi.stubGlobal('fetch', fetchMock);
vi.mocked(tauriFetch)
.mockReset()
.mockResolvedValue(new Response(null, { status: 204 }));
vi.mocked(isTauriAppPlatform).mockReturnValue(false);
});
afterEach(() => {
@@ -79,4 +93,20 @@ describe('ReadwiseClient base URL', () => {
expect(init.method).toBe('POST');
expect((init.headers as Record<string, string>)['Authorization']).toBe('Token test-token');
});
test('validateToken uses the Tauri HTTP transport in the desktop app', async () => {
vi.mocked(isTauriAppPlatform).mockReturnValue(true);
const client = new ReadwiseClient(makeSettings({ baseUrl: 'https://example.com/api/v2' }));
await client.validateToken();
expect(tauriFetch).toHaveBeenCalledWith('https://example.com/api/v2/auth/', {
method: 'GET',
headers: {
Authorization: 'Token test-token',
},
body: undefined,
});
expect(fetchMock).not.toHaveBeenCalled();
});
});
@@ -118,6 +118,66 @@ describe('customFontStore', () => {
const restored = useCustomFontStore.getState().fonts[0]!;
expect(restored.deletedAt).toBeUndefined();
});
// ── reincarnation on re-import (issue #4410) ───────────────────
// Deleting a font writes a server-side tombstone. Under CRDT
// remove-wins a plain re-upload can't revive it, so the next pull
// re-applies the delete and the font silently disappears while
// logged into cloud sync. Re-import must mint a reincarnation token.
test('re-import after a local delete mints + publishes a reincarnation token', () => {
useCustomFontStore.getState().addFont('/fonts/MyFont.ttf', { contentId: 'cid-1' });
useCustomFontStore.getState().removeFont(useCustomFontStore.getState().fonts[0]!.id);
mockPublishReplicaUpsert.mockClear();
const revived = useCustomFontStore.getState().addFont('/fonts/MyFont.ttf', {
contentId: 'cid-1',
});
expect(revived.deletedAt).toBeUndefined();
expect(revived.reincarnation).toBeTruthy();
expect(mockPublishReplicaUpsert).toHaveBeenCalledTimes(1);
const call = mockPublishReplicaUpsert.mock.calls[0]!;
expect(call[0]).toBe('font');
expect(call[2]).toBe('cid-1');
// 4th arg is the reincarnation token handed to publishReplicaUpsert.
expect(call[3]).toBe(revived.reincarnation);
});
test('re-import of a still-live font with the same contentId mints a token (stale-local race)', () => {
// Another device may have tombstoned the row while this device still
// has the font live. Minting on live re-import lets the upsert win
// remove-wins on every device's next pull. Mirrors dictionaryService.
useCustomFontStore.getState().addFont('/fonts/MyFont.ttf', { contentId: 'cid-1' });
expect(useCustomFontStore.getState().fonts[0]!.reincarnation).toBeUndefined();
const reimported = useCustomFontStore.getState().addFont('/fonts/MyFont.ttf', {
contentId: 'cid-1',
});
expect(reimported.deletedAt).toBeUndefined();
expect(reimported.reincarnation).toBeTruthy();
});
test('re-import preserves an existing reincarnation token instead of churning a new one', () => {
useCustomFontStore.getState().addFont('/fonts/MyFont.ttf', { contentId: 'cid-1' });
useCustomFontStore.getState().removeFont(useCustomFontStore.getState().fonts[0]!.id);
const firstToken = useCustomFontStore
.getState()
.addFont('/fonts/MyFont.ttf', { contentId: 'cid-1' }).reincarnation;
expect(firstToken).toBeTruthy();
const secondToken = useCustomFontStore
.getState()
.addFont('/fonts/MyFont.ttf', { contentId: 'cid-1' }).reincarnation;
expect(secondToken).toBe(firstToken);
});
test('brand-new import does not mint a reincarnation token', () => {
const fresh = useCustomFontStore.getState().addFont('/fonts/Brand-New.ttf', {
contentId: 'cid-new',
});
expect(fresh.reincarnation).toBeUndefined();
});
});
// ── removeFont ─────────────────────────────────────────────────
@@ -4,6 +4,7 @@ import { useSettingsStore } from '@/store/settingsStore';
import { CustomTexture } from '@/styles/textures';
import { SystemSettings } from '@/types/settings';
import { EnvConfigType } from '@/services/environment';
import { publishReplicaUpsert } from '@/services/sync/replicaPublish';
// Mock textures module - we need createCustomTexture, and the mount/unmount functions
vi.mock('@/styles/textures', async (importOriginal) => {
@@ -15,6 +16,15 @@ vi.mock('@/styles/textures', async (importOriginal) => {
};
});
// Replica-publish helpers fan out to the network — stub them so tests stay
// hermetic. We assert the reincarnation token reaches the upsert via a spy.
vi.mock('@/services/sync/replicaPublish', () => ({
publishReplicaUpsert: vi.fn(),
publishReplicaDelete: vi.fn(),
}));
const mockPublishReplicaUpsert = vi.mocked(publishReplicaUpsert);
function makeTexture(
overrides: Partial<CustomTexture> & { id: string; name: string },
): CustomTexture {
@@ -100,6 +110,65 @@ describe('customTextureStore', () => {
const updated = useCustomTextureStore.getState().textures[0]!;
expect(updated.deletedAt).toBeUndefined();
});
// ── reincarnation on re-import (same latent bug as issue #4410) ──
// A deleted texture writes a server-side tombstone; under CRDT
// remove-wins a plain re-import can't revive it, so the next pull
// re-applies the delete. Re-import must mint a reincarnation token.
test('re-import after a local delete mints + publishes a reincarnation token', () => {
useCustomTextureStore.getState().addTexture('/images/wood.png', { contentId: 'cid-1' });
useCustomTextureStore
.getState()
.removeTexture(useCustomTextureStore.getState().textures[0]!.id);
mockPublishReplicaUpsert.mockClear();
const revived = useCustomTextureStore.getState().addTexture('/images/wood.png', {
contentId: 'cid-1',
});
expect(revived.deletedAt).toBeUndefined();
expect(revived.reincarnation).toBeTruthy();
expect(mockPublishReplicaUpsert).toHaveBeenCalledTimes(1);
const call = mockPublishReplicaUpsert.mock.calls[0]!;
expect(call[0]).toBe('texture');
expect(call[2]).toBe('cid-1');
expect(call[3]).toBe(revived.reincarnation);
});
test('re-import of a still-live texture with the same contentId mints a token (stale-local race)', () => {
useCustomTextureStore.getState().addTexture('/images/wood.png', { contentId: 'cid-1' });
expect(useCustomTextureStore.getState().textures[0]!.reincarnation).toBeUndefined();
const reimported = useCustomTextureStore.getState().addTexture('/images/wood.png', {
contentId: 'cid-1',
});
expect(reimported.deletedAt).toBeUndefined();
expect(reimported.reincarnation).toBeTruthy();
});
test('re-import preserves an existing reincarnation token instead of churning a new one', () => {
useCustomTextureStore.getState().addTexture('/images/wood.png', { contentId: 'cid-1' });
useCustomTextureStore
.getState()
.removeTexture(useCustomTextureStore.getState().textures[0]!.id);
const firstToken = useCustomTextureStore
.getState()
.addTexture('/images/wood.png', { contentId: 'cid-1' }).reincarnation;
expect(firstToken).toBeTruthy();
const secondToken = useCustomTextureStore
.getState()
.addTexture('/images/wood.png', { contentId: 'cid-1' }).reincarnation;
expect(secondToken).toBe(firstToken);
});
test('brand-new import does not mint a reincarnation token', () => {
const fresh = useCustomTextureStore.getState().addTexture('/images/brand-new.png', {
contentId: 'cid-new',
});
expect(fresh.reincarnation).toBeUndefined();
});
});
// ── removeTexture ──────────────────────────────────────────────
@@ -0,0 +1,139 @@
import { describe, it, expect } from 'vitest';
import * as CFI from 'foliate-js/epubcfi.js';
const XHTML = (str: string) => new DOMParser().parseFromString(str, 'application/xhtml+xml');
// cfi-skip marks a layout-only wrapper as invisible to CFI: unlike cfi-inert (which
// removes the node AND its subtree), cfi-skip hoists the node's children into its
// parent, so the wrapped content keeps the exact CFI it had before being wrapped.
// This mirrors what applyScrollableStyle does when it wraps a wide <table>/<math>.
const TABLE = `<table id="tbl"><tbody><tr><td><p id="cell">xxx<em>yyy</em>0123456789</p></td></tr></tbody></table>`;
const body = (tableMarkup: string) =>
XHTML(`<html xmlns="http://www.w3.org/1999/xhtml">
<head><title>Test</title></head>
<body id="body01">
<p id="p1">First paragraph</p>
<p>Second paragraph</p>
${tableMarkup}
<p>Fourth paragraph</p>
<img id="svgimg" src="foo.svg" alt="an image"/>
</body>
</html>`);
// Baseline: a table sitting directly in body, no wrapper.
const basePage = () => body(TABLE);
// The same table wrapped in a cfi-skip layout div (as applyScrollableStyle does).
const pageWithSkipWrapper = () => body(`<div class="scroll-wrapper" cfi-skip="">${TABLE}</div>`);
// Wrapper nested two deep, to prove the hoisting recurses.
const pageWithNestedSkipWrappers = () =>
body(`<div cfi-skip=""><div cfi-skip="">${TABLE}</div></div>`);
// Build a representative set of ranges (inside the table and around it) in a doc,
// keyed by label so the same logical range can be built in every variant.
const ranges = (doc: Document): Record<string, Range> => {
const cell = doc.getElementById('cell')!;
const xxx = cell.firstChild!; // "xxx"
const em = cell.childNodes[1]!.firstChild!; // "yyy" inside <em>
const digits = cell.childNodes[2]!; // "0123456789"
const r = (build: (range: Range) => void) => {
const range = doc.createRange();
build(range);
return range;
};
const point = (node: Node, offset: number) =>
r((g) => {
g.setStart(node, offset);
g.collapse(true);
});
return {
beforeTable: point(doc.getElementById('p1')!.firstChild!, 0),
tableElement: point(doc.getElementById('tbl')!, 0), // collapsed at element → element CFI
cellStart: r((g) => {
g.setStart(xxx, 0);
g.setEnd(xxx, 3);
}),
cellEm: point(em, 0),
cellDigits: r((g) => {
g.setStart(digits, 1);
g.setEnd(digits, 5);
}),
spanningCell: r((g) => {
g.setStart(xxx, 1);
g.setEnd(digits, 4);
}),
afterTable: point(doc.getElementById('svgimg')!, 0),
};
};
const variants = () => [
['skip wrapper', pageWithSkipWrapper()] as const,
['nested skip wrappers', pageWithNestedSkipWrappers()] as const,
];
describe('epubcfi cfi-skip wrapper transparency', () => {
it('every base-page range round-trips unchanged (fromRange → toRange → fromRange)', () => {
const doc = basePage();
for (const [label, range] of Object.entries(ranges(doc))) {
const cfi = CFI.fromRange(range);
const resolved = CFI.toRange(doc, CFI.parse(cfi));
expect(resolved, label).not.toBeNull();
expect(CFI.fromRange(resolved!), label).toBe(cfi);
}
});
it('produces the SAME CFI for an equivalent range with and without the skip wrapper', () => {
const baseCFIs = Object.fromEntries(
Object.entries(ranges(basePage())).map(([k, range]) => [k, CFI.fromRange(range)]),
);
for (const [variantLabel, doc] of variants()) {
for (const [key, range] of Object.entries(ranges(doc))) {
expect(CFI.fromRange(range), `${variantLabel}: ${key}`).toBe(baseCFIs[key]);
}
}
});
it('resolves a base-page CFI to the same content inside the wrapped table', () => {
const base = basePage();
const baseCFIs = Object.entries(ranges(base)).map(
([key, range]) => [key, CFI.fromRange(range), range.toString()] as const,
);
for (const [variantLabel, doc] of variants()) {
for (const [key, cfi, text] of baseCFIs) {
const resolved = CFI.toRange(doc, CFI.parse(cfi));
expect(resolved, `${variantLabel}: ${key}`).not.toBeNull();
expect(resolved!.toString(), `${variantLabel}: ${key}`).toBe(text);
}
}
});
it('keeps the wrapper div out of the generated CFI (table stays at the same step)', () => {
const baseCFI = CFI.fromRange(ranges(basePage())['tableElement']);
for (const [variantLabel, doc] of variants()) {
const wrappedCFI = CFI.fromRange(ranges(doc)['tableElement']);
expect(wrappedCFI, variantLabel).toBe(baseCFI);
expect(wrappedCFI, variantLabel).toContain('[tbl]');
}
});
it('resolves the table element across the wrapper via toElement', () => {
const tblParts = CFI.parse(CFI.fromRange(ranges(basePage())['tableElement']));
for (const [variantLabel, doc] of variants()) {
const el = CFI.toElement(doc, tblParts[0]);
expect(el.id, variantLabel).toBe('tbl');
}
});
it('does NOT hoist a plain wrapper that lacks cfi-skip (it stays a CFI level)', () => {
const plain = body(`<div class="scroll-wrapper">${TABLE}</div>`);
const wrappedCFI = CFI.fromRange(ranges(plain)['tableElement']);
const baseCFI = CFI.fromRange(ranges(basePage())['tableElement']);
// The table now sits one element step deeper (inside the counted div), so the
// CFI differs from the unwrapped baseline — proving cfi-skip is what makes the
// wrapper transparent, not merely the presence of a wrapper div.
expect(wrappedCFI).not.toBe(baseCFI);
// It still resolves to the table, just at a deeper path.
expect(CFI.toElement(plain, CFI.parse(wrappedCFI)[0]).id).toBe('tbl');
});
});
@@ -0,0 +1,308 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
applyScrollableStyle,
applyTableTouchScroll,
findScrollableBox,
updateTableFit,
shouldTableScrollConsumeTouch,
shouldTableScrollConsumeWheel,
SCROLL_WRAPPER_CLASS,
SCROLL_WRAPPER_FIT_CLASS,
} from '@/utils/scrollable';
describe('applyScrollableStyle', () => {
beforeEach(() => {
document.body.innerHTML = '';
});
it('wraps a table in a horizontal scroll container', () => {
document.body.innerHTML = `
<div>
<table>
<tr>
<td width="100">Cell 1</td>
<td width="200">Cell 2</td>
</tr>
</table>
</div>
`;
applyScrollableStyle(document);
const table = document.querySelector('table')!;
const wrapper = table.parentElement;
expect(wrapper?.classList.contains(SCROLL_WRAPPER_CLASS)).toBe(true);
expect(table.style.transform).toBe('');
});
it('wraps multiple tables independently', () => {
document.body.innerHTML = `
<div>
<table id="t1"><tr><td>A</td></tr></table>
<table id="t2"><tr><td>B</td></tr></table>
</div>
`;
applyScrollableStyle(document);
const wrappers = document.querySelectorAll(`.${SCROLL_WRAPPER_CLASS}`);
expect(wrappers).toHaveLength(2);
});
it('does not double-wrap when applyScrollableStyle runs twice', () => {
document.body.innerHTML = `
<div>
<table><tr><td>Cell</td></tr></table>
</div>
`;
applyScrollableStyle(document);
applyScrollableStyle(document);
expect(document.querySelectorAll(`.${SCROLL_WRAPPER_CLASS}`)).toHaveLength(1);
});
it('does not crash on a table whose parent is body', () => {
document.body.innerHTML = `
<table>
<tr><td width="100">A</td></tr>
</table>
`;
applyScrollableStyle(document);
const table = document.querySelector('table')!;
expect(table.parentElement?.classList.contains(SCROLL_WRAPPER_CLASS)).toBe(true);
});
it('wraps a display equation (math that is the sole content of its container)', () => {
document.body.innerHTML = `
<div data-type="equation">
<math><mrow><mi>x</mi></mrow></math>
</div>
`;
applyScrollableStyle(document);
const math = document.querySelector('math')!;
expect(math.parentElement?.classList.contains(SCROLL_WRAPPER_CLASS)).toBe(true);
});
it('does not wrap inline math that flows with surrounding text', () => {
document.body.innerHTML = `<p>tokens <math><mi>x</mi></math>, then more text</p>`;
applyScrollableStyle(document);
const math = document.querySelector('math')!;
expect(math.parentElement?.classList.contains(SCROLL_WRAPPER_CLASS)).toBe(false);
expect(math.parentElement?.tagName.toLowerCase()).toBe('p');
});
it('wraps math[display="block"] even when it has a sibling (e.g. an equation number)', () => {
document.body.innerHTML = `<div><math display="block"><mi>x</mi></math><span>(1)</span></div>`;
applyScrollableStyle(document);
const math = document.querySelector('math')!;
expect(math.parentElement?.classList.contains(SCROLL_WRAPPER_CLASS)).toBe(true);
});
});
describe('updateTableFit', () => {
const makeWrapper = (scrollWidth: number, clientWidth: number) => {
const wrapper = document.createElement('div');
wrapper.className = SCROLL_WRAPPER_CLASS;
Object.defineProperty(wrapper, 'scrollWidth', { value: scrollWidth, configurable: true });
Object.defineProperty(wrapper, 'clientWidth', { value: clientWidth, configurable: true });
return wrapper;
};
it('marks a table that fits its column as not-a-scroll-container', () => {
const wrapper = makeWrapper(200, 200);
updateTableFit(wrapper);
expect(wrapper.classList.contains(SCROLL_WRAPPER_FIT_CLASS)).toBe(true);
});
it('leaves a table wider than its column scrollable', () => {
const wrapper = makeWrapper(400, 200);
updateTableFit(wrapper);
expect(wrapper.classList.contains(SCROLL_WRAPPER_FIT_CLASS)).toBe(false);
});
it('treats overflow within tolerance as fitting', () => {
const wrapper = makeWrapper(203, 200); // 3px slop ≤ tolerance
updateTableFit(wrapper);
expect(wrapper.classList.contains(SCROLL_WRAPPER_FIT_CLASS)).toBe(true);
});
});
describe('shouldTableScrollConsumeTouch', () => {
it('returns false when the table is not wider than its container', () => {
const wrapper = document.createElement('div');
Object.defineProperty(wrapper, 'scrollWidth', { value: 100, configurable: true });
Object.defineProperty(wrapper, 'clientWidth', { value: 100, configurable: true });
expect(shouldTableScrollConsumeTouch(wrapper, -40, 0)).toBe(false);
});
it('consumes a horizontal swipe when more content is available to the right', () => {
const wrapper = document.createElement('div');
Object.defineProperty(wrapper, 'scrollWidth', { value: 400, configurable: true });
Object.defineProperty(wrapper, 'clientWidth', { value: 200, configurable: true });
Object.defineProperty(wrapper, 'scrollLeft', { value: 0, configurable: true });
expect(shouldTableScrollConsumeTouch(wrapper, -40, 0)).toBe(true);
});
it('still consumes at the right edge so it never chains to a page turn', () => {
const wrapper = document.createElement('div');
Object.defineProperty(wrapper, 'scrollWidth', { value: 400, configurable: true });
Object.defineProperty(wrapper, 'clientWidth', { value: 200, configurable: true });
Object.defineProperty(wrapper, 'scrollLeft', { value: 200, configurable: true });
expect(shouldTableScrollConsumeTouch(wrapper, -40, 0)).toBe(true);
});
it('ignores a vertical swipe on a box that only scrolls horizontally', () => {
const wrapper = document.createElement('div');
Object.defineProperty(wrapper, 'scrollWidth', { value: 400, configurable: true });
Object.defineProperty(wrapper, 'clientWidth', { value: 200, configurable: true });
Object.defineProperty(wrapper, 'scrollLeft', { value: 0, configurable: true });
expect(shouldTableScrollConsumeTouch(wrapper, -5, -40)).toBe(false);
});
it('consumes a vertical swipe when more content is available below (tall code block)', () => {
const wrapper = document.createElement('div');
Object.defineProperty(wrapper, 'scrollHeight', { value: 400, configurable: true });
Object.defineProperty(wrapper, 'clientHeight', { value: 200, configurable: true });
Object.defineProperty(wrapper, 'scrollTop', { value: 0, configurable: true });
// Finger up (dy < 0) scrolls down; content remains below the viewport.
expect(shouldTableScrollConsumeTouch(wrapper, 0, -40)).toBe(true);
});
it('still consumes a vertical swipe at the bottom edge (never turns the page)', () => {
const wrapper = document.createElement('div');
Object.defineProperty(wrapper, 'scrollHeight', { value: 400, configurable: true });
Object.defineProperty(wrapper, 'clientHeight', { value: 200, configurable: true });
Object.defineProperty(wrapper, 'scrollTop', { value: 200, configurable: true });
expect(shouldTableScrollConsumeTouch(wrapper, 0, -40)).toBe(true);
});
});
describe('shouldTableScrollConsumeWheel', () => {
it('returns false when the table is not wider than its container', () => {
const wrapper = document.createElement('div');
Object.defineProperty(wrapper, 'scrollWidth', { value: 100, configurable: true });
Object.defineProperty(wrapper, 'clientWidth', { value: 100, configurable: true });
expect(shouldTableScrollConsumeWheel(wrapper, 40, 0)).toBe(false);
});
it('consumes a horizontal wheel when more content is available to the right', () => {
const wrapper = document.createElement('div');
Object.defineProperty(wrapper, 'scrollWidth', { value: 400, configurable: true });
Object.defineProperty(wrapper, 'clientWidth', { value: 200, configurable: true });
Object.defineProperty(wrapper, 'scrollLeft', { value: 0, configurable: true });
expect(shouldTableScrollConsumeWheel(wrapper, 40, 0)).toBe(true);
});
it('still consumes at the right edge so it never chains to a page turn', () => {
const wrapper = document.createElement('div');
Object.defineProperty(wrapper, 'scrollWidth', { value: 400, configurable: true });
Object.defineProperty(wrapper, 'clientWidth', { value: 200, configurable: true });
// scrolled fully to the right edge
Object.defineProperty(wrapper, 'scrollLeft', { value: 200, configurable: true });
expect(shouldTableScrollConsumeWheel(wrapper, 40, 0)).toBe(true);
});
it('still consumes at the left edge so it never chains to a page turn', () => {
const wrapper = document.createElement('div');
Object.defineProperty(wrapper, 'scrollWidth', { value: 400, configurable: true });
Object.defineProperty(wrapper, 'clientWidth', { value: 200, configurable: true });
Object.defineProperty(wrapper, 'scrollLeft', { value: 0, configurable: true });
expect(shouldTableScrollConsumeWheel(wrapper, -40, 0)).toBe(true);
});
it('ignores a vertical wheel on a box that only scrolls horizontally', () => {
const wrapper = document.createElement('div');
Object.defineProperty(wrapper, 'scrollWidth', { value: 400, configurable: true });
Object.defineProperty(wrapper, 'clientWidth', { value: 200, configurable: true });
Object.defineProperty(wrapper, 'scrollLeft', { value: 0, configurable: true });
expect(shouldTableScrollConsumeWheel(wrapper, 5, 40)).toBe(false);
});
it('consumes a vertical wheel down while a tall box can still scroll down', () => {
const wrapper = document.createElement('div');
Object.defineProperty(wrapper, 'scrollHeight', { value: 400, configurable: true });
Object.defineProperty(wrapper, 'clientHeight', { value: 200, configurable: true });
Object.defineProperty(wrapper, 'scrollTop', { value: 0, configurable: true });
expect(shouldTableScrollConsumeWheel(wrapper, 0, 40)).toBe(true);
});
it('still consumes a vertical wheel at the bottom edge (never turns the page)', () => {
const wrapper = document.createElement('div');
Object.defineProperty(wrapper, 'scrollHeight', { value: 400, configurable: true });
Object.defineProperty(wrapper, 'clientHeight', { value: 200, configurable: true });
Object.defineProperty(wrapper, 'scrollTop', { value: 200, configurable: true });
expect(shouldTableScrollConsumeWheel(wrapper, 0, 40)).toBe(true);
});
});
describe('findScrollableBox', () => {
const setScroll = (el: HTMLElement, scrollWidth: number, clientWidth: number) => {
Object.defineProperty(el, 'scrollWidth', { value: scrollWidth, configurable: true });
Object.defineProperty(el, 'clientWidth', { value: clientWidth, configurable: true });
};
const makeWrapper = () => {
const wrapper = document.createElement('div');
wrapper.className = SCROLL_WRAPPER_CLASS;
const inner = document.createElement('span');
wrapper.appendChild(inner);
document.body.appendChild(wrapper);
return { wrapper, inner };
};
it('routes a scrollable wrapper (table / equation) from a target inside it', () => {
const { wrapper, inner } = makeWrapper();
setScroll(wrapper, 400, 200);
expect(findScrollableBox(inner)).toBe(wrapper);
wrapper.remove();
});
it('only routes the scroll-wrapper — bare pre / code / math do not capture gestures', () => {
// pre/code/math rely on native overflow scrolling; only the wrapper that
// applyScrollableStyle adds (tables, display equations) is routed.
for (const tag of ['pre', 'code', 'math'] as const) {
const box = document.createElement(tag);
const inner = document.createElement('span');
box.appendChild(inner);
document.body.appendChild(box);
setScroll(box, 400, 200);
expect(findScrollableBox(inner), `${tag} should NOT route`).toBeNull();
box.remove();
}
});
it('routes a wrapper that only overflows vertically (tall block)', () => {
const { wrapper, inner } = makeWrapper();
setScroll(wrapper, 200, 200); // fits horizontally
Object.defineProperty(wrapper, 'scrollHeight', { value: 400, configurable: true });
Object.defineProperty(wrapper, 'clientHeight', { value: 200, configurable: true });
expect(findScrollableBox(inner)).toBe(wrapper);
wrapper.remove();
});
it('ignores a wrapper that fits (overflow within tolerance)', () => {
const { wrapper, inner } = makeWrapper();
setScroll(wrapper, 202, 200); // 2px slop ≤ tolerance
expect(findScrollableBox(inner)).toBeNull();
wrapper.remove();
});
it('returns null for targets outside any scroll box', () => {
const p = document.createElement('p');
document.body.appendChild(p);
expect(findScrollableBox(p)).toBeNull();
expect(findScrollableBox(null)).toBeNull();
p.remove();
});
});
describe('applyTableTouchScroll', () => {
it('attaches capture-phase touch and wheel listeners once per document', () => {
document.documentElement.removeAttribute('data-readest-table-touch-scroll');
const addSpy = vi.spyOn(document, 'addEventListener');
applyTableTouchScroll(document);
applyTableTouchScroll(document);
const touchMoves = addSpy.mock.calls.filter(([type]) => type === 'touchmove');
expect(touchMoves).toHaveLength(1);
expect(touchMoves[0]?.[2]).toEqual({ capture: true, passive: false });
const wheels = addSpy.mock.calls.filter(([type]) => type === 'wheel');
expect(wheels).toHaveLength(1);
expect(wheels[0]?.[2]).toEqual({ capture: true, passive: true });
addSpy.mockRestore();
});
});
@@ -26,7 +26,6 @@ import {
getStyles,
applyImageStyle,
keepTextAlignment,
applyTableStyle,
} from '@/utils/style';
import {
DEFAULT_BOOK_FONT,
@@ -540,120 +539,3 @@ describe('keepTextAlignment', () => {
expect(document.querySelector('blockquote')!.classList.contains('aligned-justify')).toBe(true);
});
});
// ---------------------------------------------------------------------------
// applyTableStyle
// ---------------------------------------------------------------------------
describe('applyTableStyle', () => {
beforeEach(() => {
document.body.innerHTML = '';
});
it('applies scale transform to a table with td width attributes', () => {
document.body.innerHTML = `
<div>
<table>
<tr>
<td width="100">Cell 1</td>
<td width="200">Cell 2</td>
</tr>
</table>
</div>
`;
applyTableStyle(document);
const table = document.querySelector('table')!;
// totalTableWidth = 100 + 200 = 300
expect(table.style.transform).toContain('scale(');
expect(table.style.transform).toContain('300');
expect(table.style.transformOrigin).toBe('left top');
});
it('applies scale transform using px width from td elements', () => {
document.body.innerHTML = `
<div>
<table>
<tr>
<td width="150px">Cell 1</td>
<td width="250px">Cell 2</td>
</tr>
</table>
</div>
`;
applyTableStyle(document);
const table = document.querySelector('table')!;
// totalTableWidth = 150 + 250 = 400
expect(table.style.transform).toContain('400');
});
it('uses the widest row when multiple rows exist', () => {
document.body.innerHTML = `
<div>
<table>
<tr>
<td width="100">A</td>
<td width="100">B</td>
</tr>
<tr>
<td width="200">C</td>
<td width="300">D</td>
</tr>
</table>
</div>
`;
applyTableStyle(document);
const table = document.querySelector('table')!;
// Second row: 200 + 300 = 500
expect(table.style.transform).toContain('500');
});
it('applies center-top origin when no cell widths are specified', () => {
document.body.innerHTML = `
<div style="width: 600px;">
<table>
<tr>
<td>Cell 1</td>
<td>Cell 2</td>
</tr>
</table>
</div>
`;
applyTableStyle(document);
const table = document.querySelector('table')!;
// No cell widths => totalTableWidth = 0
// jsdom getComputedStyle may return parentWidth as "" or "0px" so transform may not be set
// But if parentContainerWidth > 0 it would use center top
// In jsdom getComputedStyle returns "" for inline styles on div so parentContainerWidth = 0
// Neither branch applies; verify no crash
expect(table).toBeTruthy();
});
it('handles tables with inline style width on td', () => {
document.body.innerHTML = `
<div>
<table>
<tr>
<td style="width: 120px;">Cell 1</td>
<td style="width: 180px;">Cell 2</td>
</tr>
</table>
</div>
`;
applyTableStyle(document);
const table = document.querySelector('table')!;
// totalTableWidth = 120 + 180 = 300
expect(table.style.transform).toContain('300');
});
it('does not crash on a table without parent element', () => {
// Table at root level inside body
document.body.innerHTML = `
<table>
<tr><td width="100">A</td></tr>
</table>
`;
// body is the parent, which is an Element, so it proceeds
applyTableStyle(document);
const table = document.querySelector('table')!;
expect(table.style.transform).toContain('100');
});
});
@@ -9,6 +9,7 @@ vi.mock('@/utils/misc', async (importOriginal) => {
});
import { getStyles, ThemeCode } from '@/utils/style';
import { CustomFont } from '@/styles/fonts';
import { ViewSettings } from '@/types/book';
import {
DEFAULT_BOOK_FONT,
@@ -570,6 +571,22 @@ describe('getColorStyles branches (via getStyles)', () => {
expect(css).not.toMatch(/body\.pbg\s*\{[^}]*background-color:[^}]*!important/);
});
it('overrides inline white backgrounds in dark mode when overrideColor is false', () => {
const vs = makeViewSettings({ overrideColor: false });
const theme = makeThemeCode({ isDarkMode: true, bg: '#1a1a1a', fg: '#e0e0e0' });
const css = getStyles(vs, theme);
expect(css).toContain('background-color: #1a1a1a !important');
expect(css).toContain('background-color: #fff"]');
expect(css).toContain('body.theme-dark');
});
it('does not add inline white background overrides in light mode', () => {
const vs = makeViewSettings({ overrideColor: false });
const theme = makeThemeCode({ isDarkMode: false });
const css = getStyles(vs, theme);
expect(css).not.toContain('background-color: #fff"]');
});
it('applies dark mode link color lightblue when overrideColor is false', () => {
const vs = makeViewSettings({ overrideColor: false });
const theme = makeThemeCode({ isDarkMode: true, bg: '#1a1a1a', fg: '#e0e0e0' });
@@ -622,6 +639,33 @@ describe('getColorStyles branches (via getStyles)', () => {
);
});
it('does not tint table descendants in dark mode when overrideColor is false (#4419)', () => {
const vs = makeViewSettings({ overrideColor: false });
const theme = makeThemeCode({ isDarkMode: true, bg: '#1a1a1a', fg: '#e0e0e0' });
const css = getStyles(vs, theme);
// When the user has NOT enabled color override, the `blockquote, table *`
// rule must not paint a tinted background on table descendants. Otherwise
// plain tables — and the invisible spacer cells some books use for vertical
// layout — render with a color different from the page background, and the
// spacing between words appears to change in dark mode. Regression from
// #4055; the #4028 zebra-row legibility case is now handled by the
// light-background rewriters from #4392. See issue #4419.
const match = css.match(/blockquote,\s*table\s*\*\s*\{([^}]*)\}/);
expect(match).not.toBeNull();
expect(match![1]).not.toContain('color-mix');
});
it('still tints blockquotes in dark mode when overrideColor is false', () => {
const vs = makeViewSettings({ overrideColor: false });
const theme = makeThemeCode({ isDarkMode: true, bg: '#1a1a1a', fg: '#e0e0e0' });
const css = getStyles(vs, theme);
// The standalone `blockquote` rule keeps its dark-mode tint regardless of
// overrideColor — only the shared `table *` part is gated.
expect(css).toMatch(
/blockquote\s*\{[^}]*background:\s*color-mix\(in srgb,\s*#1a1a1a\s*80%,\s*#000\)/,
);
});
it('makes svg/img backgrounds transparent when overrideColor is true', () => {
const vs = makeViewSettings({ overrideColor: true });
const theme = makeThemeCode();
@@ -719,3 +763,63 @@ describe('getStyles integration', () => {
expect(css).toContain('--theme-bg-color');
});
});
// ---------------------------------------------------------------------------
// custom @font-face inlining
// ---------------------------------------------------------------------------
/** Build a loaded CustomFont (blob URL in memory) for testing. */
function makeCustomFont(overrides: Partial<CustomFont> = {}): CustomFont {
return {
id: 'my-test-font',
name: 'My Test Font',
path: '/fonts/my-test-font.ttf',
blobUrl: 'blob:http://localhost/my-test-font',
...overrides,
};
}
describe('custom @font-face inlining (via getStyles)', () => {
const theme = makeThemeCode();
it('inlines an @font-face rule for each loaded custom font', () => {
const vs = makeViewSettings();
const css = getStyles(vs, theme, [makeCustomFont()]);
expect(css).toContain('@font-face');
expect(css).toContain('font-family: "My Test Font"');
expect(css).toContain('blob:http://localhost/my-test-font');
});
it('inlines the @font-face rules ahead of the rest of the stylesheet', () => {
const vs = makeViewSettings();
const css = getStyles(vs, theme, [makeCustomFont()]);
// Paginator writes this CSS into the iframe before its first paint,
// so the custom @font-face must precede the font-family declarations
// that reference it (layout styles begin with `@namespace epub`).
expect(css.indexOf('@font-face')).toBeLessThan(css.indexOf('@namespace epub'));
});
it('emits one @font-face per loaded font', () => {
const vs = makeViewSettings();
const fonts = [
makeCustomFont({ id: 'font-a', name: 'Font A', blobUrl: 'blob:http://localhost/a' }),
makeCustomFont({ id: 'font-b', name: 'Font B', blobUrl: 'blob:http://localhost/b' }),
];
const css = getStyles(vs, theme, fonts);
expect(css.split('@font-face').length - 1).toBe(2);
expect(css).toContain('font-family: "Font A"');
expect(css).toContain('font-family: "Font B"');
});
it('skips fonts that have no blob URL', () => {
const vs = makeViewSettings();
const css = getStyles(vs, theme, [makeCustomFont({ blobUrl: undefined })]);
expect(css).not.toContain('font-family: "My Test Font"');
});
it('emits no custom @font-face when no fonts are passed', () => {
const vs = makeViewSettings();
const css = getStyles(vs, theme);
expect(css).not.toContain('font-family: "My Test Font"');
});
});
@@ -313,6 +313,27 @@ describe('transformStylesheet', () => {
expect(result).toBe(css);
});
});
describe('dark mode light backgrounds', () => {
it('rewrites white backgrounds in EPUB CSS to theme bg', () => {
localStorage.setItem('themeMode', 'dark');
localStorage.setItem('themeColor', 'default');
localStorage.setItem('systemIsDarkMode', 'false');
const css = '.callout { background-color: #ffffff; padding: 1em; }';
const result = transformStylesheet(css, VW, VH, VERTICAL);
expect(result).toMatch(/background-color:\s*#[0-9a-f]{6}/i);
expect(result).not.toContain('background-color: #ffffff');
localStorage.removeItem('themeMode');
});
it('leaves dark backgrounds unchanged in dark mode', () => {
localStorage.setItem('themeMode', 'dark');
const css = '.panel { background-color: #222; }';
const result = transformStylesheet(css, VW, VH, VERTICAL);
expect(result).toContain('background-color: #222');
localStorage.removeItem('themeMode');
});
});
});
describe('getFootnoteStyles', () => {
@@ -492,4 +492,65 @@ describe('extractTxtFilenameMetadata', () => {
it('returns empty object for empty input', () => {
expect(extractTxtFilenameMetadata('')).toEqual({ title: '' });
});
// Chinese web-novel TXT files are commonly named with a 【】 title and a
// labeled author tacked on, e.g. 【书名】1-129 作者:起落.txt. There are no 《》,
// so the whole name stays the title, but the labeled author must still be
// extracted. See issue #4390.
it('extracts a labeled author from a 【】-titled filename without 《》 (issue #4390)', () => {
expect(extractTxtFilenameMetadata('【细雨飘香】1-129 作者:起落.txt')).toEqual({
title: '【细雨飘香】1-129 作者:起落',
author: '起落',
});
expect(extractTxtFilenameMetadata('【月如无恨月长圆】(1-154)作者:陈西.txt')).toEqual({
title: '【月如无恨月长圆】(1-154)作者:陈西',
author: '陈西',
});
});
it('does not mistake a leading 【tag】 for the author when no 作者 label is present', () => {
expect(extractTxtFilenameMetadata('【完结】斗破苍穹.txt')).toEqual({
title: '【完结】斗破苍穹',
});
});
});
describe('author resolution during conversion (issue #4390)', () => {
const convertAndCaptureMetadata = async (name: string, content: string) => {
const converter = new TxtToEpubConverter() as unknown as TxtConverterFlowPrivateAPI;
let captured: TestMetadata | undefined;
converter.detectEncoding = () => 'utf-8';
converter.createEpub = async (_chapters, metadata) => {
captured = metadata;
return new Blob();
};
converter.extractChapters = () => [{ title: '第一章', content: '正文', isVolume: false }];
const file = new File([content], name);
await converter.convert({ file });
return captured;
};
it('falls back to the filename author when the header has none (missing author)', async () => {
const metadata = await convertAndCaptureMetadata(
'【细雨飘香】1-129 作者:起落.txt',
'第一章 初见\n正文内容……\n',
);
expect(metadata?.author).toBe('起落');
});
it('rejects a metadata-blob header author and uses the filename author (irrelevant content)', async () => {
const metadata = await convertAndCaptureMetadata(
'【月如无恨月长圆】(1-154)作者:陈西.txt',
'作者:2024/08/01发表于:是否首发:是字数1023150字116:01\n第一章 初见\n正文内容……\n',
);
expect(metadata?.author).toBe('陈西');
});
it('keeps a clean labeled author parsed from the file header', async () => {
const metadata = await convertAndCaptureMetadata(
'【幻灵幽火】1-23未完结 作者:月夜银狐.txt',
'作者:月夜银狐\n第一章 初见\n正文内容……\n',
);
expect(metadata?.author).toBe('月夜银狐');
});
});
@@ -530,4 +530,70 @@ describe('CFIToXPointerConverter', () => {
expect(cfi).toMatch(/^epubcfi\(/);
});
});
// applyScrollableStyle wraps wide tables/equations in a cfi-skip <div>. KOReader's
// CREngine DOM has no such wrapper, so CFI ↔ XPointer must treat the wrapper as
// transparent: the same KOReader XPointer must map to the same CFI (and back) with
// or without the wrapper — otherwise KOSync positions would drift.
describe('cfi-skip wrapper is invisible to CFI <-> XPointer', () => {
// identical content; only difference is the wrapping cfi-skip div around the table
const inner = `<table><tbody><tr><td><p>Hello scrollable world</p></td></tr></tbody></table>`;
const html = (table: string) =>
`<html><body><p>Intro paragraph</p>${table}<p>Outro paragraph</p></body></html>`;
const baseDoc = () => new DOMParser().parseFromString(html(inner), 'text/html');
const wrappedDoc = () =>
new DOMParser().parseFromString(
html(`<div class="scroll-wrapper" cfi-skip="">${inner}</div>`),
'text/html',
);
// KOReader-style XPointers (no wrapper) — the wrapper-less DOM CREngine produces.
const cellText = '/body/DocFragment[1]/body/table/tbody/tr/td/p/text().5';
const tableElement = '/body/DocFragment[1]/body/table';
const afterTable = '/body/DocFragment[1]/body/p[2]/text().3';
it('maps a KOReader XPointer to the same CFI with and without the wrapper', () => {
const base = new XCFI(baseDoc(), 0);
const wrapped = new XCFI(wrappedDoc(), 0);
for (const xp of [cellText, tableElement, afterTable]) {
// The wrapper must not change the CFI the XPointer resolves to.
expect(wrapped.xPointerToCFI(xp), xp).toBe(base.xPointerToCFI(xp));
}
});
it('round-trips the table element XPointer back to itself through the wrapped doc', () => {
const wrapped = new XCFI(wrappedDoc(), 0);
const cfi = wrapped.xPointerToCFI(tableElement);
const built = wrapped.cfiToXPointer(cfi);
expect(built.xpointer).toBe(tableElement);
// The wrapper is a <div>; a transparent wrapper must not appear in the path.
expect(built.xpointer).not.toContain('/div');
});
it('round-trips a KOReader range (highlight) inside the table unchanged', () => {
// A highlight is a range; xPointerToCFI(pos0,pos1) -> cfiToXPointer pos0/pos1
// exercises rangePointToXPointer, the path real highlights use.
const pos0 = '/body/DocFragment[1]/body/table/tbody/tr/td/p/text().0';
const pos1 = '/body/DocFragment[1]/body/table/tbody/tr/td/p/text().5';
const base = new XCFI(baseDoc(), 0);
const wrapped = new XCFI(wrappedDoc(), 0);
const baseCfi = base.xPointerToCFI(pos0, pos1);
const wrappedCfi = wrapped.xPointerToCFI(pos0, pos1);
expect(wrappedCfi).toBe(baseCfi); // wrapper doesn't change the highlight CFI
const back = wrapped.cfiToXPointer(wrappedCfi);
expect(back.pos0).toBe(pos0);
expect(back.pos1).toBe(pos1);
expect(back.pos0).not.toContain('/div');
expect(back.pos1).not.toContain('/div');
});
it('builds the same CFI->XPointer for the table element with and without the wrapper', () => {
const base = new XCFI(baseDoc(), 0);
const wrapped = new XCFI(wrappedDoc(), 0);
const cfi = base.xPointerToCFI(tableElement);
expect(wrapped.cfiToXPointer(cfi).xpointer).toBe(base.cfiToXPointer(cfi).xpointer);
});
});
});
@@ -45,6 +45,9 @@ import {
resolveEffectiveSecondarySort,
} from '../utils/libraryUtils';
import { eventDispatcher } from '@/utils/event';
import { getLocalBookFilename } from '@/utils/book';
import { MIMETYPES, EXTS } from '@/libs/document';
import { makeSafeFilename } from '@/utils/misc';
import { useSpatialNavigation } from '../hooks/useSpatialNavigation';
import Alert from '@/components/Alert';
@@ -425,6 +428,113 @@ const Bookshelf: React.FC<BookshelfProps> = ({
setShowStatusAlert(true);
};
const sendSelectedBook = async () => {
// "Send" hands the actual book file (epub/pdf/...) to the OS share
// sheet (UIActivityViewController on iOS, Intent.ACTION_SEND on
// Android, NSSharingServicePicker on macOS) so the user can fire it
// off to Mail / Messages / WeChat / AirDrop / etc. Backed by
// tauri-plugin-sharekit via appService.saveFile({ share: true }).
//
// This is intentionally distinct from the per-item "Share Book"
// context menu, which uploads the book to the readest backend and
// generates a public link. "Send" is offline file egress; "Share
// Book" is remote collaboration. They share zero infra.
//
// Linux has no system share sheet, and Windows is intentionally
// disabled (issue #4343 — WebView2's native share UI blocks the main
// thread waiting on cancel/complete callbacks that may never fire).
// We hide the button entirely on those platforms (see sendEnabled
// in the JSX) so users don't see an action that can't be honoured.
const ids = getSelectedBooks();
if (ids.length !== 1) return;
const book = filteredBooks.find((b) => b.hash === ids[0]);
if (!book || !appService) return;
// Anchor the macOS share popover to the selected book's cover, not
// to the Send button — the user just tapped/clicked the book, so
// their visual focus is on the cover. We look the cover up via the
// `data-book-hash` attribute that BookshelfItem stamps on its root
// div. The rect must be captured *before* setShowSelectModeActions
// tears the popup down (the bookshelf itself stays mounted, but we
// still want to grab it up front to keep the share-call site
// simple). preferredEdge='bottom' maps to NSMinYEdge, which in
// WKWebView's flipped coord space is the rect's top edge, so the
// popover renders above the cover (and only auto-flips below when
// there's no room above). On iOS / Android the share sheet is modal
// and ignores sharePosition, so this work is harmless there.
const coverEl = document.querySelector<HTMLElement>(`[data-book-hash="${book.hash}"]`);
const anchorRect = coverEl?.getBoundingClientRect();
const sharePosition = anchorRect
? {
x: anchorRect.left + anchorRect.width / 2,
y: anchorRect.top + anchorRect.height / 2,
preferredEdge: 'bottom' as const,
}
: undefined;
setShowSelectModeActions(false);
handleSetSelectMode(false);
try {
// Resolve the file the same way bookContent.resolveBookContentSource
// does, but via the public AppService surface (the underlying `fs`
// is protected): managed copy under Books/<hash>/ first, then the
// device-local in-place import path. Cloud-only books or remote
// URL books can't be shared without first downloading them.
const managedPath = getLocalBookFilename(book);
let path: string;
let base: 'Books' | 'None';
if (await appService.exists(managedPath, 'Books')) {
path = managedPath;
base = 'Books';
} else if (book.filePath && (await appService.exists(book.filePath, 'None'))) {
path = book.filePath;
base = 'None';
} else {
eventDispatcher.dispatch('toast', {
type: 'warning',
message: _('Book file is not available locally'),
timeout: 2500,
});
return;
}
const ext = EXTS[book.format] ?? 'bin';
const mimeType = MIMETYPES[book.format]?.[0] ?? 'application/octet-stream';
const baseName = makeSafeFilename(book.sourceTitle || book.title || book.hash);
const shareFilename = `${baseName}.${ext}`;
// Native (Tauri) only — the Share button is hidden on web because
// browsers can't surface a real "share to <app>" sheet for an
// arbitrary local file. Hand the already-on-disk file straight to
// the OS share sheet via `options.filePath`. Without it,
// saveFile() falls back to writing a temp copy under
// BaseDirectory.Temp, which on Android resolves to
// /data/local/tmp/ — the app sandbox has no write permission
// there and the call fails with EACCES ("failed to open file at
// path: /data/local/tmp/...epub Permission denied (os error
// 13)"). Passing the absolute path also avoids re-buffering the
// whole epub/pdf into memory just to have saveFile write it back
// to disk.
const absoluteFilePath = await appService.resolveFilePath(path, base);
// `null` content: there's nothing to write — the file already lives at
// `filePath`, which the native share path reads directly.
await appService.saveFile(shareFilename, null, {
share: true,
mimeType,
filePath: absoluteFilePath,
sharePosition,
});
} catch (err) {
console.error('Failed to send book file:', err);
eventDispatcher.dispatch('toast', {
type: 'error',
message: _('Failed to send book'),
timeout: 2500,
});
}
};
const updateBooksStatus = async (status: ReadingStatus | undefined) => {
const selectedIds = getSelectedBooks();
const booksToUpdate: Book[] = [];
@@ -688,10 +798,22 @@ const Bookshelf: React.FC<BookshelfProps> = ({
<SelectModeActions
selectedBooks={selectedBooks}
safeAreaBottom={safeAreaInsets?.bottom || 0}
// Native send targets: iOS, Android, macOS — route through
// tauri-plugin-sharekit (UIActivityViewController /
// Intent.ACTION_SEND / NSSharingServicePicker). Linux has no
// system share sheet, Windows WebView2 share UI is disabled
// upstream (issue #4343 — deadlocks the main thread), and web
// browsers don't expose a real "send file to <app>" sheet, so
// the button is hidden on those platforms.
sendEnabled={
!!appService &&
(appService.isIOSApp || appService.isAndroidApp || appService.isMacOSApp)
}
onOpen={openSelectedBooks}
onGroup={groupSelectedBooks}
onDetails={openBookDetails}
onStatus={showStatusSelection}
onSend={sendSelectedBook}
onDelete={deleteSelectedBooks}
onCancel={() => handleSetSelectMode(false)}
/>
@@ -6,7 +6,7 @@ import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useAppRouter } from '@/hooks/useAppRouter';
import { useLongPress } from '@/hooks/useLongPress';
import { Menu, MenuItem } from '@tauri-apps/api/menu';
import { Menu, type MenuItemOptions } from '@tauri-apps/api/menu';
import { revealItemInDir } from '@tauri-apps/plugin-opener';
import { eventDispatcher } from '@/utils/event';
import { getOSPlatform } from '@/utils/misc';
@@ -16,6 +16,10 @@ import { LibraryCoverFitType, LibraryViewModeType } from '@/types/settings';
import { BOOK_UNGROUPED_ID, BOOK_UNGROUPED_NAME } from '@/services/constants';
import { FILE_REVEAL_LABELS, FILE_REVEAL_PLATFORMS } from '@/utils/os';
import { Book, BooksGroup, ReadingStatus } from '@/types/book';
import {
getBookContextMenuItemIds,
type BookContextMenuItemId,
} from '@/app/library/utils/libraryUtils';
import { md5Fingerprint } from '@/utils/md5';
import BookItem from './BookItem';
import GroupItem from './GroupItem';
@@ -214,144 +218,127 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
const osPlatform = getOSPlatform();
const fileRevealLabel =
FILE_REVEAL_LABELS[osPlatform as FILE_REVEAL_PLATFORMS] || FILE_REVEAL_LABELS.default;
const selectBookMenuItem = await MenuItem.new({
text: itemSelected ? _('Deselect Book') : _('Select Book'),
action: async () => {
if (!isSelectMode) handleSetSelectMode(true);
toggleSelection(book.hash);
},
});
const groupBooksMenuItem = await MenuItem.new({
text: _('Group Books'),
action: async () => {
if (!isSelectMode) handleSetSelectMode(true);
if (!itemSelected) {
// Build every item up front, then create the menu from the ordered subset
// in a single Menu.new({ items }) call. Appending items one-by-one with
// un-awaited Menu.append() promises races on the Tauri IPC boundary and
// shuffles the order on every open (issue #4389).
const itemOptions: Record<BookContextMenuItemId, MenuItemOptions> = {
select: {
text: itemSelected ? _('Deselect Book') : _('Select Book'),
action: async () => {
if (!isSelectMode) handleSetSelectMode(true);
toggleSelection(book.hash);
}
handleGroupBooks();
},
},
});
const markAsFinishedMenuItem = await MenuItem.new({
text: _('Mark as Finished'),
action: async () => {
handleUpdateReadingStatus(book, 'finished');
group: {
text: _('Group Books'),
action: async () => {
if (!isSelectMode) handleSetSelectMode(true);
if (!itemSelected) {
toggleSelection(book.hash);
}
handleGroupBooks();
},
},
});
const markAsUnreadMenuItem = await MenuItem.new({
text: _('Mark as Unread'),
action: async () => {
handleUpdateReadingStatus(book, 'unread');
markFinished: {
text: _('Mark as Finished'),
action: async () => {
handleUpdateReadingStatus(book, 'finished');
},
},
});
const clearStatusMenuItem = await MenuItem.new({
text: _('Clear Status'),
action: async () => {
handleUpdateReadingStatus(book, undefined);
markUnread: {
text: _('Mark as Unread'),
action: async () => {
handleUpdateReadingStatus(book, 'unread');
},
},
});
const showBookInFinderMenuItem = await MenuItem.new({
text: _(fileRevealLabel),
action: async () => {
const folder = `${settings.localBooksDir}/${book.hash}`;
revealItemInDir(folder);
clearStatus: {
text: _('Clear Status'),
action: async () => {
handleUpdateReadingStatus(book, undefined);
},
},
});
const showBookDetailsMenuItem = await MenuItem.new({
text: _('Show Book Details'),
action: async () => {
showBookDetailsModal(book);
showDetails: {
text: _('Show Book Details'),
action: async () => {
showBookDetailsModal(book);
},
},
});
const downloadBookMenuItem = await MenuItem.new({
text: _('Download Book'),
action: async () => {
handleBookDownload(book, { queued: true });
showInFinder: {
text: _(fileRevealLabel),
action: async () => {
const folder = `${settings.localBooksDir}/${book.hash}`;
revealItemInDir(folder);
},
},
});
const uploadBookMenuItem = await MenuItem.new({
text: _('Upload Book'),
action: async () => {
handleBookUpload(book);
download: {
text: _('Download Book'),
action: async () => {
handleBookDownload(book, { queued: true });
},
},
});
const shareBookMenuItem = await MenuItem.new({
text: _('Share Book'),
action: async () => {
// Bookshelf.tsx hosts the dialog; we dispatch and let it route
// unauthenticated users into the login flow first.
eventDispatcher.dispatch('show-share-dialog', { book });
upload: {
text: _('Upload Book'),
action: async () => {
handleBookUpload(book);
},
},
});
const deleteBookMenuItem = await MenuItem.new({
text: _('Delete'),
action: async () => {
eventDispatcher.dispatch('delete-books', { ids: [book.hash] });
share: {
text: _('Share Book'),
action: async () => {
// Bookshelf.tsx hosts the dialog; we dispatch and let it route
// unauthenticated users into the login flow first.
eventDispatcher.dispatch('show-share-dialog', { book });
},
},
});
const menu = await Menu.new();
menu.append(selectBookMenuItem);
menu.append(groupBooksMenuItem);
if (book.readingStatus === 'finished') {
menu.append(markAsUnreadMenuItem);
} else {
menu.append(markAsFinishedMenuItem);
}
// show "Clear Status" option when book has an explicit status set
if (book.readingStatus === 'finished' || book.readingStatus === 'unread') {
menu.append(clearStatusMenuItem);
}
menu.append(showBookDetailsMenuItem);
menu.append(showBookInFinderMenuItem);
if (book.uploadedAt && !book.downloadedAt) {
menu.append(downloadBookMenuItem);
}
if (!book.uploadedAt && book.downloadedAt) {
menu.append(uploadBookMenuItem);
}
// Share is offered for any local-or-uploaded book; the dialog will trigger
// an upload first if the book hasn't been pushed yet.
if (book.downloadedAt || book.uploadedAt) {
menu.append(shareBookMenuItem);
}
menu.append(deleteBookMenuItem);
menu.popup();
delete: {
text: _('Delete'),
action: async () => {
eventDispatcher.dispatch('delete-books', { ids: [book.hash] });
},
},
};
const items = getBookContextMenuItemIds(book).map((id) => itemOptions[id]);
const menu = await Menu.new({ items });
await menu.popup();
};
const groupContextMenuHandler = async (group: BooksGroup) => {
if (!appService?.hasContextMenu) return;
const selectGroupMenuItem = await MenuItem.new({
text: itemSelected ? _('Deselect Group') : _('Select Group'),
action: async () => {
if (!isSelectMode) handleSetSelectMode(true);
toggleSelection(group.id);
},
});
const groupBooksMenuItem = await MenuItem.new({
text: _('Group Books'),
action: async () => {
if (!isSelectMode) handleSetSelectMode(true);
if (!itemSelected) {
// Single Menu.new({ items }) call keeps the order deterministic — see the
// note in bookContextMenuHandler about the Menu.append() IPC race (#4389).
const items: MenuItemOptions[] = [
{
text: itemSelected ? _('Deselect Group') : _('Select Group'),
action: async () => {
if (!isSelectMode) handleSetSelectMode(true);
toggleSelection(group.id);
}
handleGroupBooks();
},
},
});
const deleteGroupMenuItem = await MenuItem.new({
text: _('Delete'),
action: async () => {
// Dispatch the constituent book hashes — `group.books` is the
// rendered rollup and already includes books from nested sub-
// folders, so the deletion path doesn't need to re-derive what
// belongs to the group from the id alone.
const ids = group.books.filter((book) => !book.deletedAt).map((book) => book.hash);
eventDispatcher.dispatch('delete-books', { ids });
{
text: _('Group Books'),
action: async () => {
if (!isSelectMode) handleSetSelectMode(true);
if (!itemSelected) {
toggleSelection(group.id);
}
handleGroupBooks();
},
},
});
const menu = await Menu.new();
menu.append(selectGroupMenuItem);
menu.append(groupBooksMenuItem);
menu.append(deleteGroupMenuItem);
menu.popup();
{
text: _('Delete'),
action: async () => {
// Dispatch the constituent book hashes — `group.books` is the
// rendered rollup and already includes books from nested sub-
// folders, so the deletion path doesn't need to re-derive what
// belongs to the group from the id alone.
const ids = group.books.filter((book) => !book.deletedAt).map((book) => book.hash);
eventDispatcher.dispatch('delete-books', { ids });
},
},
];
const menu = await Menu.new({ items });
await menu.popup();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -427,6 +414,14 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
}
};
// Tag the rendered DOM with the book/group identity so feature code
// (e.g. the Send action's macOS share-popover anchor) can locate the
// exact bookshelf cell the user is acting on without threading refs
// through every parent. Books carry their content-hash; groups carry
// their full group name.
const itemDataAttrs =
'format' in item ? { 'data-book-hash': item.hash } : { 'data-group-name': item.name };
return (
<div className={clsx(mode === 'grid' ? 'h-full' : 'sm:hover:bg-base-300/50 px-4 sm:px-6')}>
<div
@@ -445,6 +440,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
transition: 'transform 0.2s',
}}
onKeyDown={handleKeyDown}
{...itemDataAttrs}
{...handlers}
>
<div className='flex h-full flex-col justify-end'>
@@ -448,7 +448,7 @@ export const MigrateDataWindow = () => {
<div className='flex gap-3 pt-2'>
{migrationStatus === 'completed' ? (
<>
<button className='btn btn-outline flex-1' onClick={handleClose}>
<button className='btn btn-ghost flex-1' onClick={handleClose}>
{_('Close')}
</button>
<button className='btn btn-primary flex-1' onClick={handleRestartApp}>
@@ -458,7 +458,7 @@ export const MigrateDataWindow = () => {
) : (
<>
<button
className='btn btn-outline flex-1'
className='btn btn-ghost flex-1'
onClick={handleClose}
disabled={migrationStatus === 'migrating'}
>
@@ -6,6 +6,7 @@ import {
MdInfoOutline,
MdCheckCircleOutline,
} from 'react-icons/md';
import { IoShareSocialOutline } from 'react-icons/io5';
import { LuFolderPlus } from 'react-icons/lu';
import { useKeyDownActions } from '@/hooks/useKeyDownActions';
import { useTranslation } from '@/hooks/useTranslation';
@@ -14,10 +15,21 @@ import { isMd5 } from '@/utils/md5';
interface SelectModeActionsProps {
selectedBooks: string[];
safeAreaBottom: number;
// When false (Linux desktop, Windows desktop, web) the Send button is
// hidden entirely — those platforms can't surface a system share sheet
// so the affordance would be misleading. Note: this is *file send* (hands
// the book file to the OS share sheet), distinct from "Share Book" in
// the per-item context menu, which generates a remote share link.
sendEnabled?: boolean;
onOpen: () => void;
onGroup: () => void;
onDetails: () => void;
onStatus: () => void;
// The macOS / iPad share popover is anchored to the selected book's
// cover (located via its data-book-hash attribute), not to this
// button — the user's visual focus is on the cover they just tapped.
// On iOS / Android the share sheet is modal and ignores position.
onSend: () => void;
onDelete: () => void;
onCancel: () => void;
}
@@ -25,10 +37,12 @@ interface SelectModeActionsProps {
const SelectModeActions: React.FC<SelectModeActionsProps> = ({
selectedBooks,
safeAreaBottom,
sendEnabled = true,
onOpen,
onGroup,
onDetails,
onStatus,
onSend,
onDelete,
onCancel,
}) => {
@@ -96,11 +110,30 @@ const SelectModeActions: React.FC<SelectModeActionsProps> = ({
<MdInfoOutline />
<div>{_('Details')}</div>
</button>
{sendEnabled && (
<button
onClick={onSend}
className={clsx(
'flex flex-col items-center justify-center gap-1',
// Wraps to the start of the second row on narrow viewports.
'max-[500px]:col-start-1',
(!hasSingleSelection || !hasValidBooks) && 'btn-disabled opacity-50',
)}
>
<IoShareSocialOutline />
<div>{_('Send')}</div>
</button>
)}
<button
onClick={onDelete}
className={clsx(
'flex flex-col items-center justify-center gap-1',
'max-[500px]:col-start-2',
// Without Send (Linux/Windows/web), Delete needs an explicit
// col-start-2 so the wrapped row {Delete, Cancel} stays centred
// under the 4-col grid. With Send present, the layout is
// {Send, Delete, Cancel} starting at col-start-1, so Delete
// naturally lands in col-start-2 without an override.
!sendEnabled && 'max-[500px]:col-start-2',
!hasSelection && 'btn-disabled opacity-50',
)}
>
@@ -583,3 +583,41 @@ export const createGroupSorter =
return 0;
};
export type BookContextMenuItemId =
| 'select'
| 'group'
| 'markFinished'
| 'markUnread'
| 'clearStatus'
| 'showDetails'
| 'showInFinder'
| 'download'
| 'upload'
| 'share'
| 'delete';
/**
* Resolve the ordered list of context-menu item ids for a book from its state.
*
* The native menu MUST be built from this list in a single `Menu.new({ items })`
* call. Appending items one at a time with un-awaited `Menu.append()` promises
* races on the Tauri IPC boundary, so the items land in a non-deterministic
* order and the menu appears to shuffle on every open (issue #4389).
*/
export const getBookContextMenuItemIds = (book: Book): BookContextMenuItemId[] => {
const ids: BookContextMenuItemId[] = ['select', 'group'];
ids.push(book.readingStatus === 'finished' ? 'markUnread' : 'markFinished');
// "Clear Status" is offered only when the book has an explicit status set.
if (book.readingStatus === 'finished' || book.readingStatus === 'unread') {
ids.push('clearStatus');
}
ids.push('showDetails', 'showInFinder');
if (book.uploadedAt && !book.downloadedAt) ids.push('download');
if (!book.uploadedAt && book.downloadedAt) ids.push('upload');
// Share is offered for any local-or-uploaded book; the dialog uploads first
// if the book hasn't been pushed yet.
if (book.downloadedAt || book.uploadedAt) ids.push('share');
ids.push('delete');
return ids;
};
@@ -31,7 +31,6 @@ import {
applyImageStyle,
applyScrollbarStyle,
applyScrollModeClass,
applyTableStyle,
applyThemeModeClass,
applyTranslationStyle,
getStyles,
@@ -39,6 +38,7 @@ import {
keepTextAlignment,
transformStylesheet,
} from '@/utils/style';
import { applyScrollableStyle, applyTableTouchScroll } from '@/utils/scrollable';
import { mountAdditionalFonts, mountCustomFont } from '@/styles/fonts';
import { layoutWarichu, relayoutWarichu } from '@/utils/warichu';
import { getBookDirFromLanguage, getBookDirFromWritingMode } from '@/utils/book';
@@ -262,7 +262,8 @@ const FoliateViewer: React.FC<{
}
applyImageStyle(detail.doc);
applyTableStyle(detail.doc);
applyScrollableStyle(detail.doc);
applyTableTouchScroll(detail.doc);
applyThemeModeClass(detail.doc, isDarkMode);
applyScrollModeClass(detail.doc, viewSettings.scrolled || false);
applyScrollbarStyle(document, viewSettings.hideScrollbar || false);
@@ -558,7 +559,7 @@ const FoliateViewer: React.FC<{
const width = viewWidth - insets.left - insets.right;
const height = viewHeight - insets.top - insets.bottom;
book.transformTarget?.addEventListener('data', getDocTransformHandler({ width, height }));
view.renderer.setStyles?.(getStyles(viewSettings));
view.renderer.setStyles?.(getStyles(viewSettings, undefined, getLoadedFonts()));
applyTranslationStyle(viewSettings);
doubleClickDisabled.current = viewSettings.disableDoubleClick!;
@@ -685,7 +686,7 @@ const FoliateViewer: React.FC<{
if (viewRef.current && viewRef.current.renderer) {
const renderer = viewRef.current.renderer;
const viewSettings = getViewSettings(bookKey)!;
viewRef.current.renderer.setStyles?.(getStyles(viewSettings));
viewRef.current.renderer.setStyles?.(getStyles(viewSettings, undefined, getLoadedFonts()));
const docs = viewRef.current.renderer.getContents();
docs.forEach(({ doc }) => {
if (bookDoc.rendition?.layout === 'pre-paginated') {
@@ -150,7 +150,7 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
const backgroundColor = getComputedStyle(popupContainer).backgroundColor;
popupTheme.bg = backgroundColor;
}
const mainStyles = getStyles(viewSettings, popupTheme);
const mainStyles = getStyles(viewSettings, popupTheme, getLoadedFonts());
const footnoteStyles = getFootnoteStyles();
renderer.setStyles?.(`${mainStyles}\n${footnoteStyles}`);
};
@@ -367,12 +367,15 @@ const ImageViewer: React.FC<ImageViewerProps> = ({
if (!src) return null;
return (
// `no-context-menu` suppresses the WebView's native long-press image
// callout (via the `.no-context-menu img` rule). On Android it otherwise
// collides with the pinch/pan handlers below and freezes the app.
<div
ref={containerRef}
tabIndex={-1}
role='button'
aria-label={_('Image viewer')}
className='fixed inset-0 z-50 flex items-center justify-center outline-none'
className='no-context-menu fixed inset-0 z-50 flex items-center justify-center outline-none'
onKeyDown={handleKeyDown}
onWheel={handleWheel}
onTouchMove={onTouchMove}
@@ -188,7 +188,6 @@ const ProgressBar: React.FC<ProgressBarProps> = ({
return (
<div
role='presentation'
tabIndex={-1}
className={clsx(
'progressinfo absolute bottom-0 flex items-center justify-between font-sans',
isEink ? 'text-sm font-normal' : 'text-neutral-content text-xs font-extralight',
@@ -176,6 +176,12 @@ const ReadingRuler: React.FC<ReadingRulerProps> = ({
// In scrolled mode, set when a tap advances past the view edge and scrolls the
// view; the next relocate realigns the band to the start/end of the new view.
const pendingScrollAlignRef = useRef<'forward' | 'backward' | null>(null);
// In scrolled mode the band is fixed to the screen: it is snapped into place on
// the initial mount (and after a viewport-dimension change), but never re-snapped
// on a plain scroll relocate — that made it creep down the page (issue #4386).
// Snapping while scrolled is driven by clicks (the reading-ruler-move handler).
const scrolledPlacedRef = useRef(false);
const scrolledPlacedDimensionRef = useRef(0);
const supportsLineSnap = !FIXED_LAYOUT_FORMATS.has(bookFormat);
const columnCount = getView(bookKey)?.renderer?.columnCount ?? 1;
@@ -333,12 +339,32 @@ const ReadingRuler: React.FC<ReadingRulerProps> = ({
pending === 'forward'
? snapReadingRulerToLines(-Infinity, -Infinity, lines, 'forward', derivBoxes)
: snapReadingRulerToLines(Infinity, Infinity, lines, 'backward', derivBoxes);
if (block) applyBlock(block.start, block.end, dimension, true);
if (block) {
applyBlock(block.start, block.end, dimension, true);
scrolledPlacedRef.current = true;
scrolledPlacedDimensionRef.current = dimension;
}
} else if (!pageChanged) {
const block =
snapReadingRulerToLines(anchor, anchor, lines, 'forward', derivBoxes) ??
snapReadingRulerToLines(Infinity, Infinity, lines, 'backward', derivBoxes);
if (block) applyBlock(block.start, block.end, dimension, false);
// In scrolled mode, only snap the band on the initial mount or after the
// viewport dimension changes (resize/relayout). A plain scroll fires a
// relocate without changing the dimension; re-snapping then would walk
// the band down the page as the reader scrolls (issue #4386).
const alreadyPlaced =
scrolled &&
scrolledPlacedRef.current &&
scrolledPlacedDimensionRef.current === dimension;
if (!alreadyPlaced) {
const block =
snapReadingRulerToLines(anchor, anchor, lines, 'forward', derivBoxes) ??
snapReadingRulerToLines(Infinity, Infinity, lines, 'backward', derivBoxes);
if (block) {
applyBlock(block.start, block.end, dimension, false);
if (scrolled) {
scrolledPlacedRef.current = true;
scrolledPlacedDimensionRef.current = dimension;
}
}
}
}
}
} catch {
@@ -45,6 +45,7 @@ import {
import { runSimpleCC } from '@/utils/simplecc';
import { getWordCount } from '@/utils/word';
import { getIndexFromCfi, isCfiInLocation } from '@/utils/cfi';
import { writeTextToClipboard } from '@/utils/clipboard';
import { TransformContext } from '@/services/transformers/types';
import { transformContent } from '@/services/transformService';
import {
@@ -729,9 +730,10 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const handleCopy = (dismissPopup = true) => {
if (!selection || !selection.text) return;
const textToCopy = selection.text;
setTimeout(() => {
// Delay to ensure it won't be overridden by system clipboard actions
navigator.clipboard?.writeText(selection.text);
void writeTextToClipboard(textToCopy);
}, 100);
if (dismissPopup) {
handleDismissPopupAndSelection();
@@ -1208,7 +1210,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
setTimeout(() => {
// Delay to ensure it won't be overridden by system clipboard actions
navigator.clipboard?.writeText(content);
void writeTextToClipboard(content);
}, 100);
const ext = isPlainText ? 'txt' : 'md';
@@ -96,6 +96,17 @@ const BooknoteView: React.FC<{
return findNearestCfi(allSorted, progress?.location);
}, [progress?.location, sortedGroups]);
// Index of the nearest note in the flattened list (-1 when none). Memoized so
// the scroll effect and the OverlayScrollbars `initialized` callback share a
// single source of truth.
const nearestIndex = useMemo(
() =>
nearestCfi
? flatItems.findIndex((row) => row.kind === 'note' && row.item.cfi === nearestCfi)
: -1,
[nearestCfi, flatItems],
);
const handleBrowseBookNotes = useCallback(() => {
if (filteredNotes.length === 0) return;
const sorted = [...filteredNotes].sort((a, b) => CFI.compare(a.cfi, b.cfi));
@@ -111,6 +122,27 @@ const BooknoteView: React.FC<{
const [containerHeight, setContainerHeight] = useState(400);
const lastScrolledCfiRef = useRef<string | null>(null);
// Mirror the nearest index so the OverlayScrollbars `initialized` callback —
// created at mount but fired after a deferred, timing-dependent init — reads
// the current target instead of its stale mount-time closure.
const nearestIndexRef = useRef(nearestIndex);
nearestIndexRef.current = nearestIndex;
// Center index of the currently visible window, kept fresh by Virtuoso's
// rangeChanged. Lets the scroll effect jump instantly for far moves and
// animate only short ones (mirrors TOCView).
const visibleCenterRef = useRef(0);
// When the reading position is already known at open time (the common case of
// switching to the panel while reading), mount Virtuoso *natively* centered on
// the nearest note via initialTopMostItemIndex. A scrollToIndex against a
// freshly mounted, unmeasured list no-ops (smooth) or wedges it, so the scroll
// effect skips that first jump and lets initialTopMostItemIndex handle it; the
// OverlayScrollbars `initialized` re-apply restores it after the deferred init
// resets scrollTop (mirrors TOCView).
const [initialTopIndex] = useState(() => nearestIndex);
const initialScrollHandledRef = useRef(initialTopIndex > 0);
const [initialize, osInstance] = useOverlayScrollbars({
defer: true,
options: { scrollbars: { autoHide: 'scroll' } },
@@ -119,6 +151,22 @@ const BooknoteView: React.FC<{
const { viewport } = instance.elements();
viewport.style.overflowX = 'var(--os-viewport-overflow-x)';
viewport.style.overflowY = 'var(--os-viewport-overflow-y)';
// OverlayScrollbars resets the wrapped viewport's scrollTop to 0 as it
// initializes (deferred), clobbering the mount-time auto-scroll and
// stranding the list at the top. Re-apply it to the *current* nearest
// note — read via ref since this is the mount-time closure. The first
// rAF lets the reset settle; the second re-asserts once the freshly
// mounted rows are measured (a lone scrollToIndex to a far, unmeasured
// row otherwise lands short).
const reapply = () => {
const index = nearestIndexRef.current;
if (index < 0) return;
virtuosoRef.current?.scrollToIndex({ index, align: 'center', behavior: 'auto' });
};
requestAnimationFrame(() => {
reapply();
requestAnimationFrame(reapply);
});
},
},
});
@@ -169,18 +217,35 @@ const BooknoteView: React.FC<{
// per-item useScrollToItem (which forced 1000 layout reads) with a single
// virtuosoRef.scrollToIndex call.
useEffect(() => {
if (!nearestCfi || !flatItems.length) return;
if (nearestIndex < 0) return;
if (nearestCfi === lastScrolledCfiRef.current) return;
const idx = flatItems.findIndex((row) => row.kind === 'note' && row.item.cfi === nearestCfi);
if (idx < 0) return;
const isEink = document.documentElement.getAttribute('data-eink') === 'true';
virtuosoRef.current?.scrollToIndex({
index: idx,
align: 'center',
behavior: isEink ? 'auto' : 'smooth',
});
lastScrolledCfiRef.current = nearestCfi;
}, [nearestCfi, flatItems]);
// initialTopMostItemIndex already centered the mount position; a
// scrollToIndex that races Virtuoso's first render no-ops or wedges it, so
// skip this one and let the `initialized` re-apply restore it if needed.
if (initialScrollHandledRef.current) {
initialScrollHandledRef.current = false;
return;
}
const isEink = document.documentElement.getAttribute('data-eink') === 'true';
// Jump instantly for far moves (and on eink, which ghosts during a smooth
// animation) to avoid blanking the virtualized list mid-animation; keep
// smooth only for short, in-session progress updates (mirrors TOCView). A
// far instant jump can land short until the target rows are measured, so
// re-assert once on the next frame.
const distance = Math.abs(nearestIndex - visibleCenterRef.current);
const behavior = isEink || distance > 16 ? 'auto' : 'smooth';
virtuosoRef.current?.scrollToIndex({ index: nearestIndex, align: 'center', behavior });
if (behavior === 'auto') {
requestAnimationFrame(() => {
virtuosoRef.current?.scrollToIndex({
index: nearestIndex,
align: 'center',
behavior: 'auto',
});
});
}
}, [nearestCfi, nearestIndex]);
const renderItem = useCallback(
(index: number) => {
@@ -250,6 +315,12 @@ const BooknoteView: React.FC<{
<Virtuoso
ref={virtuosoRef}
scrollerRef={handleScrollerRef}
initialTopMostItemIndex={
initialTopIndex > 0 ? { index: initialTopIndex, align: 'center' } : 0
}
rangeChanged={({ startIndex, endIndex }) => {
visibleCenterRef.current = Math.floor((startIndex + endIndex) / 2);
}}
style={{ height: containerHeight }}
totalCount={flatItems.length}
computeItemKey={(index) => flatItems[index]?.key ?? index}
+13 -4
View File
@@ -17,6 +17,15 @@ interface OpenFilesPayload {
interface SharedIntentPayload {
urls: string[];
/**
* Android-only. Distinguishes "Open with Readest" (`VIEW` the user
* tapped a file in their file browser and chose Readest) from "Send to
* Readest" (`SEND` / `SEND_MULTIPLE` share-sheet capture). We forward
* it on the `app-incoming-url` event so consumers can pick the right
* import strategy: VIEW should open the file directly without writing
* it to the library, SEND should ingest it like a sync capture.
*/
action?: 'VIEW' | 'SEND';
}
/**
@@ -53,10 +62,10 @@ export function useAppUrlIngress() {
if (listened.current) return;
listened.current = true;
const dispatch = (urls: string[]) => {
const dispatch = (urls: string[], action?: 'VIEW' | 'SEND') => {
if (!urls.length) return;
console.log('App incoming URL:', urls);
eventDispatcher.dispatch('app-incoming-url', { urls });
console.log('App incoming URL:', urls, 'action:', action);
eventDispatcher.dispatch('app-incoming-url', { urls, action });
};
const unlistenSingleInstance = getCurrentWindow().listen<SingleInstancePayload>(
@@ -83,7 +92,7 @@ export function useAppUrlIngress() {
'native-bridge',
'shared-intent',
(payload) => {
if (payload.urls?.length) dispatch(payload.urls);
if (payload.urls?.length) dispatch(payload.urls, payload.action);
},
);
}
+119 -4
View File
@@ -5,8 +5,9 @@ import { useEnv } from '@/context/EnvContext';
import { useLibraryStore } from '@/store/libraryStore';
import { useSettingsStore } from '@/store/settingsStore';
import { isTauriAppPlatform } from '@/services/environment';
import { navigateToLibrary, showLibraryWindow } from '@/utils/nav';
import { navigateToLibrary, navigateToReader, showLibraryWindow } from '@/utils/nav';
import { eventDispatcher } from '@/utils/event';
import { partialMD5 } from '@/utils/md5';
/**
* Handle "Open with Readest" file imports. Consumes the `app-incoming-url`
@@ -18,6 +19,26 @@ import { eventDispatcher } from '@/utils/event';
*
* Mount this hook alongside `useAppUrlIngress` so the ingress dispatcher is
* actually running when URLs arrive.
*
* Two routing modes by `action`:
*
* `'VIEW'` (Android only user picked Readest from the system "Open with"
* chooser for an epub/pdf): we MUST NOT silently import the file into the
* library. We first hash the file ourselves and check the in-memory library:
* - if a non-deleted entry with the same hash already exists, jump
* straight into the reader on that entry no `importBook` call, so the
* managed `Books/<hash>/` filePath, createdAt and reading progress are
* all preserved untouched;
* - otherwise call `appService.importBook` with `transient: true`, which
* creates an ephemeral `Book` (with `deletedAt` set and `filePath`
* pointing at the original URI) without writing to `Books/<hash>/` or
* uploading to the cloud, then navigate to the reader on that hash.
*
* `'SEND'` / undefined (iOS / macOS / desktop / Android share-sheet
* capture): keep the existing flow that pushes the URLs through
* `window.OPEN_WITH_FILES` so `library/page.tsx::processOpenWithFiles`
* does a full ingest + cloud upload that's the contract a "Send to
* Readest" share is meant to honour.
*/
export function useOpenWithBooks() {
const router = useRouter();
@@ -34,7 +55,7 @@ export function useOpenWithBooks() {
return sortedWindows[0]?.label === currentWindow.label;
};
const handle = async (urls: string[]) => {
const normalizeUrls = (urls: string[]): string[] => {
const filePaths: string[] = [];
for (let url of urls) {
if (url.startsWith('file://')) {
@@ -44,8 +65,102 @@ export function useOpenWithBooks() {
filePaths.push(url);
}
}
return filePaths;
};
const openTransient = async (filePaths: string[]) => {
// For each file, first try to recognise it as a book the user has
// already imported (hash match in the live library). If yes, we route
// straight to the reader without ever calling importBook — that avoids
// mutating the managed entry's filePath / createdAt (importBook in
// transient mode unconditionally rewrites filePath to the incoming
// content:// URI and bumps createdAt, both of which we want to keep
// untouched for already-imported books).
//
// Only files that are NOT in the library go through importBook in
// transient mode, which creates an ephemeral Book (deletedAt set,
// filePath pointing at the original URI) without writing Books/<hash>/
// or uploading to the cloud. We also setLibrary so the reader's
// initViewState (which looks the book up via getBookByHash) can find
// the ephemeral entry.
const { library, setLibrary, getBookByHash } = useLibraryStore.getState();
const bookIds: string[] = [];
let libraryMutated = false;
for (const file of filePaths) {
try {
// Lightweight pre-check: hash the file and look it up. A miss here
// means we still have to fall back to importBook because we need
// its full metadata-extraction + ephemeral entry creation.
let existingHash: string | undefined;
try {
const fileobj = await appService.openFile(file, 'None');
try {
existingHash = await partialMD5(fileobj);
} finally {
const closable = fileobj as File & { close?: () => Promise<void> };
if (closable.close) await closable.close();
}
} catch (e) {
// Path/permission issue — let importBook surface the real error.
console.warn('Pre-hash failed, falling back to transient import:', file, e);
}
if (existingHash) {
const existing = getBookByHash(existingHash);
if (existing && !existing.deletedAt) {
bookIds.push(existing.hash);
continue;
}
}
const book = await appService.importBook(file, library, { transient: true });
if (book) {
bookIds.push(book.hash);
// importBook may have mutated `library` (added the ephemeral
// entry, or — for a hash hit on a previously-deleted entry —
// refreshed an existing one); either way we want store sync.
libraryMutated = true;
}
} catch (e) {
console.warn('Failed to open file transiently:', file, e);
}
}
if (bookIds.length === 0) return;
if (libraryMutated) {
setLibrary(library);
}
// Defensive: only navigate if the reader will actually be able to
// resolve the book via getBookByHash (managed entries are already in
// the store; ephemeral entries are after the setLibrary above).
const reachable = bookIds.filter((h) => !!getBookByHash(h));
if (reachable.length > 0) {
navigateToReader(router, reachable);
}
};
const handle = async (urls: string[], action?: 'VIEW' | 'SEND') => {
const filePaths = normalizeUrls(urls);
if (filePaths.length === 0) return;
// Android "Open with" → straight to reader, no library write.
if (action === 'VIEW') {
// If a reader is already mounted, ignore the second tap rather
// than try to swap books underneath it. The in-place URL swap
// would otherwise leave ReaderContent's init effect (gated by
// an isInitiating ref + empty deps) stuck on the previous book
// and the user would see the spinner hang. The user can close
// the current book to return to the library, then re-open the
// new one — same UX as most OS image viewers.
if (typeof window !== 'undefined' && window.location.pathname.startsWith('/reader')) {
console.log('Ignoring Open-with VIEW intent: reader already active');
return;
}
await openTransient(filePaths);
return;
}
const settings = useSettingsStore.getState().settings;
if (appService?.hasWindow && settings.openBookInNewWindow) {
if (await isFirstWindow()) {
@@ -59,8 +174,8 @@ export function useOpenWithBooks() {
};
const onIncoming = (event: CustomEvent) => {
const { urls } = event.detail as { urls: string[] };
handle(urls);
const { urls, action } = event.detail as { urls: string[]; action?: 'VIEW' | 'SEND' };
handle(urls, action);
};
eventDispatcher.on('app-incoming-url', onIncoming);
+1 -1
View File
@@ -77,7 +77,7 @@ export abstract class BaseAppService implements AppService {
abstract selectFiles(name: string, extensions: string[]): Promise<string[]>;
abstract saveFile(
filename: string,
content: string | ArrayBuffer,
content: string | ArrayBuffer | null,
options?: {
filePath?: string;
mimeType?: string;
+15 -3
View File
@@ -2,7 +2,7 @@ import type { Book } from '@/types/book';
import type { FileSystem } from '@/types/system';
import { EXTS } from '@/libs/document';
import { getDir, getLocalBookFilename } from '@/utils/book';
import { isValidURL } from '@/utils/misc';
import { isContentURI, isValidURL } from '@/utils/misc';
import { isPseStreamFileName } from './opds/pseStream';
export type BookContentSource =
@@ -49,8 +49,20 @@ export async function resolveBookContentSource(
return { kind: 'managed', path: managedPath, base: 'Books' };
}
if (book.filePath && (await fs.exists(book.filePath, 'None'))) {
return { kind: 'external', path: book.filePath, base: 'None' };
if (book.filePath) {
// Android "Open with Readest" hands us a content:// URI as the
// book.filePath (e.g. content://media/external/file/1322). Tauri's
// fs.exists() doesn't understand content URIs and returns false,
// which would route us to `missing` here even though the URI is
// perfectly readable through appService.openFile (which copies the
// content to Cache on demand). Skip the existence probe for URIs
// we know openFile knows how to resolve.
if (isContentURI(book.filePath)) {
return { kind: 'external', path: book.filePath, base: 'None' };
}
if (await fs.exists(book.filePath, 'None')) {
return { kind: 'external', path: book.filePath, base: 'None' };
}
}
if (book.url) {
@@ -633,7 +633,7 @@ export class NativeAppService extends BaseAppService {
async saveFile(
filename: string,
content: string | ArrayBuffer,
content: string | ArrayBuffer | null,
options?: {
filePath?: string;
mimeType?: string;
@@ -655,7 +655,7 @@ export class NativeAppService extends BaseAppService {
shareablePath = await this.resolveFilePath(filename, 'Temp');
if (typeof content === 'string') {
await writeTextFile(shareablePath, content);
} else {
} else if (content) {
await writeFile(shareablePath, new Uint8Array(content));
}
}
@@ -667,10 +667,17 @@ export class NativeAppService extends BaseAppService {
// WebView's top-left corner.
...(options?.sharePosition ? { position: options.sharePosition } : {}),
});
return true;
} catch (error) {
console.error('shareFile failed; falling back to saveDialog:', error);
// The plugin throws on user cancellation (e.g. dismissing the
// Android share sheet returns "Share cancelled"). That's not a
// failure — the user explicitly chose not to share, so we must
// NOT fall back to saveDialog and pop a "Save As..." prompt.
// Same goes for any other share error: the caller asked for a
// share sheet, fulfilled or not, the saveDialog flow is a
// completely different user intent.
console.warn('shareFile did not complete:', error);
}
return true;
}
const filePath = await saveDialog({
@@ -681,7 +688,7 @@ export class NativeAppService extends BaseAppService {
if (typeof content === 'string') {
await writeTextFile(filePath, content);
} else {
} else if (content) {
await writeFile(filePath, new Uint8Array(content));
}
return true;
@@ -386,7 +386,7 @@ export class NodeAppService extends BaseAppService {
async saveFile(
_filename: string,
content: string | ArrayBuffer,
content: string | ArrayBuffer | null,
options?: {
filePath?: string;
mimeType?: string;
@@ -399,7 +399,7 @@ export class NodeAppService extends BaseAppService {
await fsp.mkdir(nodePath.dirname(filepath), { recursive: true });
if (typeof content === 'string') {
await fsp.writeFile(filepath, content, 'utf-8');
} else {
} else if (content) {
await fsp.writeFile(filepath, Buffer.from(content));
}
return true;

Some files were not shown because too many files have changed in this diff Show More