fix(reader): require a still-hold before instant-highlight on touch (#4745)

* fix(reader): require a still-hold before instant-highlight on touch

Instant Highlight (the highlighter quick action) engaged on every
pointer-down over text, calling preventDefault, which swallowed the
single tap / swipe that turns the page on Android. Tapping the side
margins still worked only because they are not selectable text; the
synthetic-click fallback was also dead on Android (native touchend
calls handlePointerUp with no event).

Gate engagement behind a 300ms still hold for touch/pen: a tap
releases first and a swipe moves first, so both fall through to
pagination, and only a deliberate still hold starts drag-to-highlight.
Mouse input keeps engaging immediately (click vs. press-drag is
already unambiguous).

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

* chore(agent): update agent memories

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-23 17:32:41 +08:00
committed by GitHub
parent b1346bf16d
commit acd4a67dcf
7 changed files with 538 additions and 163 deletions
+126 -151
View File
@@ -1,168 +1,143 @@
# Readest Project Memory
## Key Reference Documents
- [Bug Fixing Patterns](bug-patterns.md) - Common bug categories, root causes, and fix strategies
- [CSS & Style Fixes](css-style-fixes.md) - EPUB CSS override patterns and the style.ts pipeline
- [TTS Fixes](tts-fixes.md) - Text-to-Speech architecture and bug patterns
- [Layout & UI Fixes](layout-ui-fixes.md) - Safe insets, z-index, platform-specific UI issues
- [Platform Compat Fixes](platform-compat-fixes.md) - Android, iOS, Linux, macOS platform-specific bugs
- [Annotator & Reader Fixes](annotator-reader-fixes.md) - Highlight, selection, accessibility bugs
- [Bug Fixing Patterns](bug-patterns.md) bug categories, root causes, fix strategies
- [CSS & Style Fixes](css-style-fixes.md) EPUB CSS overrides + style.ts pipeline
- [TTS Fixes](tts-fixes.md) TTS architecture and bug patterns
- [Layout & UI Fixes](layout-ui-fixes.md) — safe insets, z-index, platform UI
- [Platform Compat Fixes](platform-compat-fixes.md) Android/iOS/Linux/macOS bugs
- [Annotator & Reader Fixes](annotator-reader-fixes.md) — highlight, selection, a11y
## Data Safety
- [In-place delete wiped user originals](in-place-delete-wiped-originals.md) — deleting a "Read books in place" book ran `fs.removeFile` on the user's own source file (`cloudService.deleteBook`, `external` source). Was deliberately coded+tested; reversed. Fix = never remove `external` sources, only managed copy + sidecars
- [Backup zip Windows paths (#4703)](backup-windows-zip-paths-4703.md) — Windows-exported backup unrestorable everywhere: `readDirectory` host-separator → `file.path` = `hash\cover.png` → zip entry name with `\` → restore's `startsWith(`${hash}/`)` never matches → files silently skipped. Fix = `.replace(/\\/g,'/')` on entry name in `addBackupEntriesToZip`. Normalize separators at any cross-platform boundary
## Safety & Security
- [In-place delete wiped originals](in-place-delete-wiped-originals.md) — never `fs.removeFile` an `external` source; only managed copy + sidecars
- [Backup zip Windows paths (#4703)](backup-windows-zip-paths-4703.md) — `\` entry names broke restore; normalize separators at cross-platform boundaries
- [download_file scope Android (#4639)](download-file-scope-android-regression.md) — strict `is_allowed` broke Android downloads; fix = `app.path()` base-dir membership
- [Security advisories 2026-06](security-advisories-web-2026-06.md) — 4 GHSA in #4638 (OPDS SSRF, storage key, Stripe userId) + #4639 transfer_file.rs
## Security
- [download_file scope Android regression](download-file-scope-android-regression.md) — #4639 strict `is_allowed` broke ALL Android downloads to app data dir (covers/dicts/books); `app.fs_scope()` lacks command-scoped capability globs; fix = `app.path()` base-dir membership. On-device CDP verify recipe + raw-invoke Channel trick
- [Security advisories 2026-06](security-advisories-web-2026-06.md) — all 4 GHSA fixed in PR #4638 (web: A OPDS-proxy SSRF + canonical `isBlockedHost` in network.ts + `isLanAddress` merge, B storage `isSafeObjectKeyName`, D Stripe `metadata.userId` ownership) + PR #4639 (native C: `transfer_file.rs` fs_scope guard). OPDS proxy can't require auth (`<img>` usage); strict `is_allowed` for C; shared-target worktree build-cache pollution gotcha
## Paginator Scroll Knowledge
- [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
- [TOC current-position row](toc-current-position-row.md) — synthetic "Current position" row (open-book icon + live `progress.page`) injected one level deeper under the active TOC item via `buildTOCDisplayItems` in `TOCItem.tsx`. INVARIANT: insert AFTER the active item so its `flatItems` index stays valid for the auto-scroll effects
- [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
- [Dark-mode texture occluded by body bg (#4446)](dark-mode-texture-body-bg-4446.md) — RESOLVED (PR #4564): `body.theme-dark{bg !important}` from #4392 (v0.11.4, NOT foliate-js) painted iframe bodies opaque dark → occluded host texture + poisoned `docBackground` capture → opaque segments/view bgs; fix = `transparent !important` UNCONDITIONALLY (texture-gating would go stale: capture is once-per-section-load); CDP gotchas = patch ALL multiview iframes, stale preload views survive navigation ±2, load-listener sees exact capture-time state
- [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
- [Inline-block column overflow](inline-block-column-overflow.md) — chapter skips to "Reference materials", clipping a large middle; EPUB wraps body in `display:inline-block` div → atomic-inline box can't fragment across columns → vertical overflow clipped (scrollHeight≫clientHeight). Fix = paginator `#demoteUnfragmentableBoxes` in `columnize` (col-mode, over-tall atomic-inline→fragmentable block). Renders via goTo/next but pages unreachable; scrolled mode unaffected
- [Fixed-layout fit-width page-turn scroll reset (#4683)](fixed-layout-paginated-scroll-reset-4683.md) — tall fit-width PDF/FXL page opens scrolled-to-END on next-page (WebKit ONLY; Blink resets on content-swap). `#render` recentred scrollLeft but never reset scrollTop; fix = `computePaginatedScroll` helper + `pageTurn` flag set true only in `#showSpread`/`#goLeft`/`#goRight`. Proved on real Safari 605.1.15 (==reporter), NOT Android-reproducible (CDP showed Blink already 0)
- [PDF spread 1px white seam (#4587)](pdf-spread-canvas-seam-4587.md) — spine seam at fractional dpr (Win 150%); `canvas.width=viewport.width` truncates fractional bitmap → canvas renders ~1 device-px narrow under the `1/dpr` documentElement scale → left page stops short of spine. Fix = pin `canvas.style.width/height = viewport.{width,height}` so bitmap fills the box. dpr=2 can't repro; CDP `--force-device-scale-factor=1.5` repro recipe inside
- [PDF scrolled-mode wheel double-scroll (#4727)](pdf-scroll-mode-wheel-double-4727.md) — fixed-layout/PDF scrolled mode scrolls 2× (instant lurch) over the page vs smooth over the margin; `#loadScrollPage` iframe wheel listener did a manual `scrollBy('instant')` ON TOP OF native scroll-chaining from the `scrolling="no"` iframe. Fix = drop the manual scrollBy, keep `#setScrollIframeInteraction(false)`. Browser-lane test mounts real `<foliate-fxl>` + synthetic wheel; needs real layout (jsdom can't)
## Paginator & Scroll
- [Reading ruler line-aware](reading-ruler-line-aware.md) — snaps to real lines; drop tall block boxes; iframe frame-offset map
- [TOC expand + auto-scroll](toc-expand-and-autoscroll.md) — #4059 collapse-default; dynamic expansion breaks scroll-to-current
- [BooknoteView auto-scroll (#4352)](booknote-view-autoscroll-4352.md) — reload via `initialized` ref, tab via `initialTopMostItemIndex`
- [TOC current-position row](toc-current-position-row.md) — synthetic row under active item; insert AFTER so flatItems index stays valid
- [Swipe page-turn bg flash](paginator-swipe-bg-flash.md) — static `#background`; sliding per-view `computeBackgroundSegments` synced per-rAF
- [Paginated texture occlusion (#4399)](paginated-texture-occlusion-4399.md) — opaque `#background` occludes texture; `hasTexture ? '' : fallbackBg`
- [Background overflows column (#4394)](paginator-gutter-bleed-asymmetry-4394.md) — bg bled into gutter; clamp segments to `[containerStart,containerEnd]`
- [Inline-block column overflow](inline-block-column-overflow.md) — inline-block body can't fragment → clipped; `#demoteUnfragmentableBoxes`
- [FXL fit-width scroll reset (#4683)](fixed-layout-paginated-scroll-reset-4683.md) — WebKit scrollTop not reset on turn; `computePaginatedScroll` + `pageTurn`
- [PDF spread 1px seam (#4587)](pdf-spread-canvas-seam-4587.md) — fractional dpr truncates bitmap; pin `canvas.style.{w,h} = viewport.{w,h}`
- [PDF scrolled wheel double (#4727)](pdf-scroll-mode-wheel-double-4727.md) — manual `scrollBy` on top of native chaining; drop it
- [Scrolled header title center (#4436)](scrolled-header-title-center-4436.md) — return view covering `renderedStart + size/2`, not topmost sliver
- [Duokan fullscreen cover scroll](duokan-fullscreen-cover-scroll.md) — #4379 absolute cover collapses in scroll; gate on `this.#column`
## Critical Files (Most Bug-Prone)
- `src/utils/style.ts` - Central EPUB CSS transformation hub (14+ bug fixes)
- `packages/foliate-js/paginator.js` - Page layout, image sizing, backgrounds
- `src/services/tts/TTSController.ts` - TTS state machine, section tracking
- `src/hooks/useSafeAreaInsets.ts` - Safe area inset management
- `src/app/reader/components/FoliateViewer.tsx` - Reader view orchestration
- `src/app/reader/components/annotator/Annotator.tsx` - Annotation lifecycle
- `src/utils/style.ts` — central EPUB CSS transformation hub
- `packages/foliate-js/paginator.js` — page layout, image sizing, backgrounds
- `src/services/tts/TTSController.ts` TTS state machine, section tracking
- `src/hooks/useSafeAreaInsets.ts` — safe area inset management
- `src/app/reader/components/FoliateViewer.tsx` — reader view orchestration
- `src/app/reader/components/annotator/Annotator.tsx` — annotation lifecycle
## 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
- [koplugin note deletion sync](koplugin-note-deletion-sync.md) — koplugin push only walked LIVE annotations so deletions never reached the server; fix = `recordDeletion` persists a `deletedAt` tombstone to `doc_settings.readest_sync.deleted_notes`, `push` folds+clears them; deletion signal in `onAnnotationsModified` is `items.index_modified < 0`
- [koplugin stats sync (#4666)](koplugin-stats-sync.md) — reading-stats sync (pull on open / push on close, whole statistics.sqlite3 delta, cursor-based); 3-bug chain: plain-table-not-LuaSettings `settings:readSetting` crash; missing required books/notes/configs; statBooks/statPages need `optional_params` (Spore expected=requiredoptional, `payload`≠accepted); large-backlog UI-stall + silent-retry risk unfixed
- [Statusless books re-pinned to top (#4677)](sync-statusless-book-rebump-4677.md) — never-statused (locally-imported, never-pulled) books send `reading_status:undefined` vs server `null`; server POST `statusChanged` (`undefined!==null`) rewrites `updated_at=now()` every push → batch-identical ts pins them top of date-sort; 1-day re-sync window amplifies; fix = `(a??null)!==(b??null)`. On-device CDP PUSH_SENT-vs-RETURNED proof recipe
- [Decouple pull cursor via server synced_at (#4678)](sync-synced-at-cursor-4678.md) — books-only `synced_at` timestamptz + `BEFORE INS/UPD` trigger = server-stamped pull cursor; GET filters `synced_at>since` (drop deleted_at), POST status-merge drops `updated_at=now()` → propagates w/o reordering date-read. Backfill BEFORE trigger; `synced_at≥updated_at` ⇒ superset ⇒ old web+koplugin compatible (koplugin untouched, shared pull/push cursor)
- [KOSync connect() false-positive (#4692)](kosync-connect-false-positive-4692.md) — connect() accepted any 2xx (HTML web-UI page) as login → misconfigured Server URL silently "connects", pulls 0%/pushes fail no-error; server tell = PUT hits Spring `ResourceHttpRequestHandler` (SPA fallback); fix = validate koreader JSON resp; per-device settings = #1 suspect when one platform syncs
- [Grimmory native sync](grimmory-native-sync.md) — Booklore-fork sync research; built then REVERTED ("not ready"); official koplugin maps id by ISBN/ASIN + progress via koreader-hash endpoint; OPDS link carries bookId+fileId
- [KOSync CFI spine resolution](kosync-cfi-spine-resolution.md) — convert via the CFI's own spine, never `new XCFI(primaryDoc, primaryIndex)`
- [Empty-start CFI sync](empty-start-cfi-sync.md) — skip-link malformed CFI; `isMalformedLocationCfi` → discard synced value
- [Custom fonts vanish on sync (#4410)](custom-fonts-reincarnation-4410.md) — CRDT remove-wins; re-import needs `reincarnation` token
- [koplugin note deletion sync](koplugin-note-deletion-sync.md) — `recordDeletion` tombstone; signal = `index_modified < 0`
- [koplugin stats sync (#4666)](koplugin-stats-sync.md) — statistics.sqlite3 delta; 3-bug chain (LuaSettings, required, optional_params)
- [Statusless books re-pin top (#4677)](sync-statusless-book-rebump-4677.md) — `undefined`≠server `null` rewrites updated_at; fix `(a??null)!==(b??null)`
- [Pull cursor via synced_at (#4678)](sync-synced-at-cursor-4678.md) — books `synced_at` + BEFORE trigger; superset ⇒ old clients OK
- [KOSync connect() false-positive (#4692)](kosync-connect-false-positive-4692.md) — any 2xx HTML accepted as login; validate koreader JSON
## Testing
- [format:check is a separate gate](verify-format-check-gate.md) — `pnpm lint` (tsgo + biome lint) does NOT run the Biome formatter; `pnpm format:check` is its own CI (build_web_app) + pre-push gate. Run it before pushing. `--no-verify` when pre-push trips on unrelated working-tree WIP; biome stdin-format recipe; zsh no-word-split gotcha
- [Nightly updater Android E2E](nightly-updater-android-e2e.md) — real Xiaomi/HyperOS test of #4577 self-updater; `pnpm dev-android` (--features devtools) for CDP, raw-socket CDP discovery, nightly>stable comparator, MIUI 单次安装授权 install gates
- [Android CDP e2e lane](android-cdp-e2e-lane.md) — `pnpm test:android`: adb+CDP drives the installed app on device/emulator; discover-don't-assume targeting, injected hyphenation, MediaStore VIEW transient open (canonical `_data` path gotcha), per-section frame restore; CI workflow with KVM emulator + debug x86_64 APK (no signing secrets)
- [CDP Android WebView profiling](cdp-android-webview-profiling.md) — drive the on-device Readest WebView via adb+CDP to run JS probes/benchmarks inside the live app (no rebuild); gotchas: locked device freezes fetch (not invoke), visible:false throttles setTimeout, `__TAURI_INTERNALS__.convertFileSrc/invoke` always present, books in internal `/data/user/0/...`, fs `read{rid,len}` last-8-bytes=nread, `fs|close` not ACL-allowed, curl mishandles WebView HTTP framing
- [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
- [TTS browser e2e harness](tts-browser-e2e-harness.md) — faithful auto-advance test (real `<foliate-view>` + real `useTTSControl` + mock ONLY the 3 client modules; mock `speak()` yields `end` to drive the real `forward()` walk); seed readerStore/bookDataStore + `settings.globalViewSettings` (else `getMergedRules` crash stops TTS); reproduce FoliateViewer relocate→setProgress; sample-alice Ch4=section 6/Ch5=section 7; assert badge `false` BEFORE tts-stop
- [TTS sync chrome verification](tts-sync-chrome-verification.md) — Edge TTS WORKS in claude-in-chrome (WebSpeech errors there with `InvalidStateError`); use an Edge voice to verify TTS-driven features live (RSVP followed at ~171 wpm). Synthetic-CFI debug recipe (expose controller, `syncToCfi(view.getCFI(docIndex, word.range))`). Exposed the #3235 cross-realm `instanceof Range` bug (frozen RSVP/paragraph follow) → `isRangeLike()` duck-type fix
- [TTS sync paragraph+RSVP (#3235, PR #4576)](tts-sync-paragraph-rsvp-3235.md) — TTS-is-clock follow: canonical `tts-position{cfi,kind:word|sentence,sectionIndex,sequence}`; in-mode 🔊 audio toggle (`build{Paragraph,Rsvp}TtsSpeakDetail`, live-range gate); **current word/sentence highlight painted on the overlay CLONE via CSS Custom Highlight API** (no DOM mutation, spans inline; offsets relative to para-start map 1:1 to clone, `getTextSubRange` reuse, index-tagged vs stale); kind-gating `decideParagraphTtsHighlight` (Edge word wins, skip coarse sentence); `::highlight()` from `ttsHighlightOptions`
## Build & Vendoring
- [fastlane Apple App Store submission](fastlane-apple-appstore-submission.md) — `release_ios`/`release_macos` lanes (App Store review + TestFlight on the altool-uploaded build); gotchas = Tauri auto-notarizes if `APPLE_API_KEY_PATH` is in the macOS build env (keep it OUT, derive from key id), fastlane runs lanes from `./fastlane` so anchor paths via `repo_path`, npm-dotenv-cli vs Ruby-gem shadowing + `cd ../..`
- [Turbopack build-cache OOM + gated Docker standalone (#4619)](turbopack-build-cache-oom-docker-standalone.md) — interrupted-build partial `turbopackFileSystemCacheForBuild` cache → 42 workers/18GB-swap freeze (clean build=~6.5GB); disabled the flag; `output:'standalone'` gated on `BUILD_STANDALONE` (Docker-only); tauri CI uses `next dev` (config-independent)
- [Deps/security override workflow](deps-security-overrides-workflow.md) — fix transitive npm Dependabot alerts: main monorepo overrides live in `pnpm-workspace.yaml` (NOT root package.json); `packages/tauri-plugins` is a SEPARATE submodule project w/ own lockfile + `minimumReleaseAge` (main workspace has no age gate); bound 0.x overrides like `vite`; verify via test+lint+build-web. PR #4618 (esbuild 0.28.1, vitest 4.1.9)
- [R2 rclone CreateBucket 403 (#4588)](r2-rclone-createbucket-403.md) — single-file `rclone copyto`/`moveto` probes CreateBucket → 403 on object-scoped R2 token; use a directory `rclone copy` (or `no_check_bucket=true`); broke nightly assemble, not the release flow
- [Deploy workers.dev SNI-block + proxy](deploy-workers-dev-sni-proxy.md) — pnpm deploy crash (CN): workers.dev SNI-blocked (DoH useless), R2 populate WS hangs even via proxy; shipped fix = `dangerous.disableIncrementalCache:true` in open-next.config (stock deploy skips populate; readest has no ISR so runtime no-op)
- [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/*`
## Build, Testing & CI
- [format:check separate gate](verify-format-check-gate.md) — `pnpm lint` ≠ formatter; `pnpm format:check` own gate, run before push
- [Android CDP e2e lane](android-cdp-e2e-lane.md) — `pnpm test:android` adb+CDP; MediaStore VIEW transient open; CI KVM emulator
- [CDP Android WebView profiling](cdp-android-webview-profiling.md) — adb+CDP JS probes in live app; locked-device freezes fetch
- [Tauri Rust↔JS parser parity](tauri-parser-parity-tests.md) — #4369 native parser cross-check; `dcterms:modified``published`
- [TTS browser e2e harness](tts-browser-e2e-harness.md) — real foliate-view; seed `settings.globalViewSettings` or getMergedRules crashes
- [TTS paragraph+RSVP sync (#3235)](tts-sync-paragraph-rsvp-3235.md) — TTS-is-clock; highlight on overlay CLONE via CSS Custom Highlight API
- [fastlane App Store](fastlane-apple-appstore-submission.md) — keep `APPLE_API_KEY_PATH` OUT of macOS build env or Tauri double-notarizes
- [Turbopack cache OOM (#4619)](turbopack-build-cache-oom-docker-standalone.md) — partial cache → freeze; standalone gated on `BUILD_STANDALONE`
- [Deps override workflow](deps-security-overrides-workflow.md) — overrides in `pnpm-workspace.yaml`; tauri-plugins separate submodule w/ age gate
- [pdfjs vendor wasm](pdfjs-vendor-wasm-decoders.md) — scanned PDFs blank in CI; pdfjs 5.7 moved JBIG2 to `jbig2.wasm`; copy `wasm/*`
- [CI/PR delivery + push keepalive](ci-pr-delivery-and-push.md) — temp-index plumbing; SSH `ServerAliveInterval`; `--no-verify` once hook passed
## Platform Compat
- [Android hyphen selection bounds (#1553)](android-hyphen-selection-bounds-1553.md) — Blink paints the start handle on the paragraph's LAST hyphen when a touch selection starts at the first word of a hyphenated paragraph (`ComputePaintingSelectionStateForCursor` lacks the generated-text offset remap, hyphen offsets {0,1}); drag-extend re-anchors base there. Fix = repair jumped anchor + suppress handles (empty-commit needs one painted frame) + `SelectionRangeEditor` custom handles; multicol NOT required; desktop/iOS unaffected
- [Android NativeFile vs RemoteFile I/O](android-nativefile-remotefile-io.md) — why NativeFile is slow (4-IPC/chunk + bridge serialization, tauri#9190); RemoteFile CANNOT replace it on Android (asset-protocol Range broken: start>0 → "Failed to fetch", start-0 capped at 1,024,000; plain no-Range fetch returns full file at 281 MB/s); measured 44/100/281 MB/s; speedups = handle-reuse (2.3×), whole-file asset loader (6.3×), or fix wry upstream. Verified live via CDP.
- [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
- [Android Open-with intent flow (#4521)](android-open-with-intent-flow.md) — "Open with"/"Send to" pipeline: `NativeBridgePlugin.kt::handleIntent``shared-intent``useAppUrlIngress``useOpenWithBooks` (VIEW=transient→reader, SEND=library+upload). Telegram fails where file-manager works on TWO axes: cold-start delivery (fixed by #4527, on dev NOT released v0.11.4) + foreign-private-file read (Telegram FileProvider non-persistable grant vs shared-storage FUSE real-path). adb MediaStore VIEW repro tests pipeline but CANNOT reproduce the read axis (MANAGE_EXTERNAL_STORAGE bypasses grant)
- [Dict lookup → OEM browser hijack (#4559)](dict-lookup-browser-hijack-4559.md) — VIVO system-dict lookup opened the browser not Eudic. PRIMARY: no `<queries>` for `ACTION_PROCESS_TEXT` under targetSdk36 → dictionary apps invisible, only auto-visible browser returned (fix = add `<queries>` to plugin manifest). SECONDARY: browser registers PROCESS_TEXT + is default → filter browsers in pure `decideLookupDispatch` (explicit/chooser/unavailable). Remember-the-pick via `IntentSender`+`EXTRA_CHOSEN_COMPONENT``LookupChoiceReceiver`→SharedPreferences (`ACTION_CHOOSER` has no native Always); reset row in `CustomDictionaries.tsx`
- [Android sideload same versionCode](android-sideload-same-versioncode.md) — sideloaded APK reinstall allows EQUAL versionCode (only strictly-lower blocked); Play Store's increment rule does NOT apply to sideload. Nightly APKs share base versionCode and still install. Corrects a plausible-but-wrong review claim
- [Large-PDF OOM range flood (#3470)](pdf-oom-range-flood-3470.md) — 50MB+ PDF import/open crash = foliate makePDF firing ALL pdf.js range reads un-awaited (753 concurrent fetch→shouldInterceptRequest→Java byte[] → 512MB heap OOM), NOT whole-file load; official viewer survives via browser ~6-conn/host cap; fix = MAX_CONCURRENT_RANGES=6 queue in makePDF; on-device CDP recipe; Xiaomi13/WV147 won't OOM but flood 753→6 verified
- [Android themed (monochrome) icon (#4733)](android-themed-icon-4733.md) — Material-You themed icon; #2353 dropped the `<monochrome>` layer when it added 22% inset. `gen/android` gitignored but customized res files force-added; CI `tauri android init`+`tauri icon`+`git checkout .` → committed gen = build truth; `tauri icon` emits NO monochrome so PNGs/vector MUST be force-committed. Tint = SRC_IN (alpha-only) → opaque art blobs; add character via negative space (narrow center-spine gap, ImageMagick alpha-mask). Emulator verify: Wallpaper&style→Home screen→Themed icons; home/dock themed, app drawer stays color; gradle-standalone panics tauri-cli, use `tauri android build --target aarch64`
- [Android hyphen selection (#1553)](android-hyphen-selection-bounds-1553.md) — Blink paints start handle on last hyphen; repair anchor + custom handles
- [NativeFile vs RemoteFile I/O](android-nativefile-remotefile-io.md) — NativeFile slow; RemoteFile can't replace (asset Range broken); handle-reuse 2.3×
- [Window-state sanitizer (#4398)](window-state-sanitize-4398.md) — invalid `.window-state.json` crashes WebView2; sanitizer plugin before window-state
- [Android Open-with intent (#4521)](android-open-with-intent-flow.md) — VIEW=transient→reader, SEND=library; Telegram fails cold-start + foreign-file read
- [Dict lookup browser hijack (#4559)](dict-lookup-browser-hijack-4559.md) — missing `<queries>` ACTION_PROCESS_TEXT sdk36; filter browsers in `decideLookupDispatch`
- [Large-PDF OOM range flood (#3470)](pdf-oom-range-flood-3470.md) — makePDF fires all ranges un-awaited → OOM; MAX_CONCURRENT_RANGES=6
- [Android themed icon (#4733)](android-themed-icon-4733.md) — `tauri icon` emits no monochrome → force-commit; tint=SRC_IN, negative-space gap
## Feature Notes
- [Dict popup font size (#4443)](dict-popup-font-size-4443.md) — adjustable dictionary popup text size; `DictionarySettings.fontScale` (synced via whitelist) → `--dict-font-scale` on `[data-dict-content]`. MDict is shadow-DOM so `::part(dict-content)` (host class `dict-shadow-host`) is the only cross-boundary hook; light-DOM Tailwind `text-*` re-based to `em` within scope. CSS contract needs the browser lane
- [Paragraph mode toggle/resume (#4717, PR #4725)](paragraph-mode-toggle-resume-4717.md) — Shift+P double-toggle = `eventDispatcher.dispatch` iterates LIVE Set while awaiting → re-subscribing effect double-fires (snapshot `[...listeners]`); overlay keys = dialog pattern (focus + own onKeyDown, NOT global onEscape); resume rewind = don't scroll view on enter/exit + resume from fresh `view.lastLocation.cfi` (rAF-debounced store + malformed stored paragraph CFI sent it to chapter start). claude-in-chrome keystrokes buffer/drop on the reader; menu clicks reliable
- [Annotator onLoad listener leak → paragraph mode degrades (#4735)](annotator-onload-listener-leak-paragraph-mode.md) — `onLoad` (per foliate `load`, incl. preloaded sections) attached renderer-`scroll` + Android `native-touch` listeners to long-lived `view.renderer`/global `eventDispatcher` w/o cleanup → unbounded; paragraph `goTo` scrolls every step → runs all; Android-gated cost; restart-fixed. Fix = `useRendererInputListeners` once-per-view + resolve primary doc/index at fire time. Pattern: listeners on session-lived targets inside per-section handlers leak
- [Stripe highest-active plan (#4694)](stripe-plan-highest-active-4694.md) — `plans.plan` must be MAX over active subs not last webhook (Plus→Pro overlap race); `getHighestActivePlan` in create+cancel paths; IAP unaffected (subscription groups); expand depth=4 cap; opt-in live test = `it.skipIf`+dynamic-import (supabase atob crash), `npx dotenv -e .env -- vitest run <file>`, pre-push tsgo whole-tree → `--no-verify`
- [Save image to gallery (Android, #4680)](save-image-to-gallery-android.md) — image-viewer Save button → MediaStore on Android (share sheet can't save-to-file: ACTION_SEND has no file-manager target); sharekit 0-byte self-copy bug (Temp==cacheDir); tsgo misses abstract-class conformance (real tsc catches); on-device CDP verify recipe
- [Webtoon Mode (#3647)](webtoon-mode-3647.md) — seamless no-gap scrolled reading for image books (PRs #4662 + foliate-js#30); fixed-layout scroll mode is fit-width by construction (ignores `zoom`, only `scale-factor`); `scroll-gap` attr→`--scroll-page-gap` var; clear-on-leave in BOTH ViewMenu effect AND Shift+J; worktree submodule has local-path origin (push SHA direct to fork)
- [Biometric app-lock (#4645)](biometric-app-lock-4645.md) — fingerprint/Face ID startup unlock layered over PIN (mobile); gate must read flag from `appLockStore` not un-seeded `settingsStore` (race); `tauri-plugin-biometric` is `#![cfg(mobile)]` (desktop clippy skips it; pin in root Cargo.lock); scope i18n manually (en unscanned, full extract churns drift)
- [Tap to open image/table (#4600)](tap-to-open-image-table-4600.md) — single-tap opens gallery/table-zoom in **reflowable** EPUBs (long-press unchanged); `iframe-long-press` message renamed to `iframe-open-media`, hook `useLongPressEvent``useOpenMediaEvent`; shared `detectMediaTarget`; `handleClick` got `isFixedLayout`
- [#4584 tap-death investigation](issue-4584-tap-death-investigation.md) — UNFIXED; `isPopuped` self-heals (RED HERRING, don't "fix" it); likely WebView-148-specific (emulator=133 can't repro); Android emulator/CDP gesture-verification gotchas (swiftshader ANR=artifact, CDP can't native-select, screenX=0)
- [Dictionary lemmatization (#4574)](dict-lemmatization-4574.md) — inflected selections (`ran`/`mice`/`analyses`) resolve to base headwords (`run`/`mouse`/`analysis`) in dicts that store only lemmas (ODE). Pluggable `lemmatize/` registry (default English, explicit non-English no-op), English rules+irregulars, appended to tail of `buildLookupCandidates` so exact match wins; over-generate + dict-validates; `-ses→-sis` ordered before `-es`
- [Word Lens inline gloss (feat/word-wise)](wordlens-feature.md) — Kindle-style native-language hint above hard words; CFI-safe via `<ruby cfi-skip>…<rt cfi-inert>` (epubcfi hoist+merge, NOT just tree-walk); TTS/search isolation (tags:['rt'] + rangeTextExcludingInert + search attributes:['cfi-inert']); gloss data = curated starters, full asset built by `build-wordlens-data.mjs` (ECDICT/CC-CEDICT+HSK)
- [iOS instant-dict double popup](ios-instant-dict-double-popup.md) — iOS emits multiple `selectionchange`/long-press → instant sys-dict fired 2-3×; deferredAction `fired` once-per-gesture latch + `beginGesture`; tap-to-deselect re-fire fixed by `isLongPressHold` 300ms gate (!isAndroid); Word Lens `wantWordLensDict` now routes via `handleDictionary` to honor system dict
- [Native local iOS TTS (#4676)](native-ios-tts-4676.md) — AVSpeechSynthesizer Swift plugin mirroring Android `TextToSpeech`; shared TS `NativeTTSClient`+Rust layer already platform-agnostic, only Swift stub + 2 gates missing. `init`=reserved→`@objc(init:)`; pause==stop + NEVER emit `end` on `didCancel`; AVAudioSession owned by native-bridge `use_background_audio`; `getMediaSession()` reorder (native before `navigator.mediaSession`); MPRemoteCommandCenter shared w/ native-bridge media-keys (token removal); rate `pow^(1/2.5)` invert→AV 0..1
- [Native TTS offline auto-advance halt (#4613, #4408, PR #4716)](native-tts-offline-autoadvance-4613.md) — System TTS offline stops at chapter end/random; `#speak` only advances on `'end'`, native terminal `'error'` (unsynthesizable char) dead-ends + wedges `'playing'`; fix = native-gated SKIP-on-error (`forward()`, not retry) + consecutive-error cap→stop; Google local engine can't repro; `__TAURI_INTERNALS__.invoke` reverts on Next.js nav
- [Edge TTS word highlighting (#4017, PR #4566)](edge-tts-word-highlighting-4017.md) — keep sentence marks, add word highlight via `audio.metadata` WordBoundary (verbatim input span, 100-ns ticks) synced to `audio.currentTime` by rAF; readaloud endpoint gates on UA (Edg, non-headless) NOT Origin; fixed browser `new WebSocket(url,{headers})` SyntaxError (wss never worked on web); overlay = `<path>` in FOLIATE-PAGINATOR shadow root; dev-web verify recipe (browse --proxy + UA spoof, never Origin header)
- [Reference Pages (#672+#4542, PR #4549)](reference-pages-672-4542.md) — 'reference' progressStyle from foliate `pageItem`/`book.pageList` (numeric-max total rule, roman-tail safe); per-book `referencePageCount` via skipGlobal save; verification EPUBs + dev-web synthetic drag-drop import trick; locale-tail rebase-conflict recipe (checkout --ours → re-extract → re-translate)
- [OPDS Firefox strict-XML parse (#4479)](opds-firefox-strict-xml-4479.md) — MEK feed has junk after `</feed>`; Firefox DOMParser → `<parsererror>` (silent back-nav), Chrome lenient; `parseOPDSXML` slices root start→last close tag; jsdom mirrors Firefox; wired into page.tsx + validateOPDSURL + feedChecker (latter also #4181 `looksLikeXMLContent` swap)
- [OPDS 2.0 JSON search greyed out (#4502)](opds2-json-search-4502.md) — `isSearchLink` ignored templated `application/opds+json` links → `hasSearch` false → disabled navbar input; add `MIME.OPDS2`+`templated`, `expandOPDSSearchTemplate` (foliate `uri-template.js`), handleSearch OPDS2 branch. Gotcha: `resolveURL` mangles `{?query}` braces — expand template BEFORE resolving
- [OPDS HTML description (#4503)](opds-html-description-4503.md) — detail-view descriptions showed raw `<p>`/`&quot;` tags; aggregator double-escapes `type="text"` summary + `PublicationView` dumped it into unsanitized `dangerouslySetInnerHTML`; fix = `getOPDSDescriptionHtml` (decode-one-level-iff-fully-escaped, then `sanitizeHtml`)
- [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
- [D-pad Navigation](dpad-navigation.md) — Android TV remote / keyboard arrow navigation design, key files, and pitfalls
- [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`
- [Proofread enhancements (#4700)](proofread-enhancements-4700.md) — sync (only global rules needed whitelisting; book/selection ride book-config JSON), regex UI (engine already supported isRegex), Ctrl+P reuse (no-selection→open rules manager via handleProofread), i18n'd whole-word warning; `wholeWord` field is a transformer no-op; macOS opt+letter dead-key
## Feedback
- [Commit messages English-only](feedback-commit-message-english-only.md) — commit messages + PR titles must be English only (no CJK glyphs, no em/en dashes); keep CJK examples/screenshots in the PR body, code, and tests. From PR #4660
## 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
- [RSVP control bar overlap = REVERT](rsvp-control-bar-overlap-revert.md) — mobile RSVP TTS+settings overlapping transport was a REGRESSION: #4585 fixed it (in-flow `justify-between md:justify-center`), stale-branch #4589 (Word Wise) squash-reverted the whole fix incl. its guard test; re-fixed + on-device CDP recipe (Shift+V to enter RSVP, live-DOM preview)
- [Overlay z-index scale](zindex-overlay-scale.md) — compact global scale (RSVP 100/101, Settings 110, ModalPortal/CmdPalette 120, toast 130, app-lock 200) replacing insane 10000s; Add-Catalog-behind-Settings was MOBILE-ONLY (desktop `.window-border` z-99 traps inline Settings; ModalPortal portals to body→wins); static invariant test `zIndexScale.test.ts`; on-device CDP verify via `pnpm dev-android` devtools build
- [Search excerpt no context for styled words (#4594)](search-excerpt-context-4594.md) — RESOLVED (foliate-js#25 + readest#4631). italic/`<i>` word = own `strs[]` text node; `makeExcerpt` read context only WITHIN the node → empty pre/post; fix = `collectBefore/After` walk neighbour nodes (+2 latent multi-node match bugs: string-index `slice`, `start===end`)
- [Global annotation page-turn lag (#4575)](global-annotation-pageturn-perf-4575.md) — highlighting recurring names = `global` highlights re-fanned-out (TreeWalker + getCFI/occurrence + SVG churn) EVERY page turn (~25-45ms desktop, ×mobile); fix = `WeakMap<Document,...>` memo in `globalAnnotations.ts` skips already-expanded sections; live-profiled via dev-web foliate-view; GBK-TXT synthetic-drop import recipe
- [Overlayer splitRange by text nodes](overlayer-splitrange-textnodes.md) — highlight SVG missed bullet-list text when range also touched a `<p>`: `#splitRangeByParagraph`'s `'p,h1-h4'` selector dropped `li` (3rd whack-a-mole after f087826/920676b); fix = walk text nodes + `img,svg` in overlayer.js, never block-tag selectors; jsdom test stubs `Range.prototype.getClientRects`
- [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
- [Double-click-drag turns page (#4524)](dblclick-drag-pageturn-4524.md) — web double-click+drag selection also turned the page; 1st click's deferred single-click (250ms) fires mid-drag while 2nd-click button held; fix = `isMouseDown` flag in `iframeEventHandlers.ts` gates the deferred `postSingleClick`; synthetic-repro gotchas (shadow-DOM iframe walk, chained-repro timing pollution, reload to re-bind listeners)
- [RSVP font face/family (#4519)](rsvp-font-settings-4519.md) — RSVP word was hardcoded `font-mono`; now mirrors the reader font via `getBaseFontFamily(viewSettings)` (new export in `style.ts`, shares `buildFontFamilyLists` with `getFontStyles`). Overlay renders in the TOP document (portal to body) where custom + basic Google fonts are mounted; known gap = built-in CJK web fonts only in top doc when `isCJKEnv()`
- [RSVP RTL word display (#4630)](rsvp-rtl-word-display-4630.md) — Arabic/RTL word window showed separated, reversed letters: ORP focus-letter split slices words by char index (breaks shaping/order); fix = `isRTLText` → render RTL whole via the CJK `.rsvp-word-whole` branch with `dir=rtl`. Literal-RTL-char Edit pitfall → write regex with `\u` escapes
- [Edge TTS word-highlight drift on middle sentences](tts-word-highlight-singletextnode-drift.md) — `rangeTextExcludingInert` TEXT_NODE fast path ignored range offsets → returned whole paragraph → word offsets drift (spoken "Those"→hl "if th"); only middle sentences of single-`<span>` paras (cac=TEXT_NODE); Edge-only; fix=slice [startOffset,endOffset]; added dev-only `[TTS] word-sync` log; select-word→popup-headphone repro
- [TTS start-from-selection bugs](tts-start-from-selection.md) — foliate `from()` picked first mark at/after selection → started NEXT sentence for non-first words (fix=last mark at/before); + Annotator now `cloneRange()`+`view.deselect()` on TTS start so the word doesn't stay selected; jsdom needs `CSS.escape` polyfill (vitest.setup) since `from()` uses it
- [Reuse TTS session on Paragraph/RSVP entry](tts-reuse-session-mode-entry.md) — modes only engaged following on a fresh `playing` event → entering with TTS already playing didn't sync. Fix = `TTSController.redispatchPosition()` + `useTTSControl` `tts-sync-request` replay (position-before-state) + per-mode engage-on-entry effect (following=true, reset lastSequenceSeen, dispatch request); RSVP paused branch also `setExternallyDriven(true)`. Paragraph live-verified ("Following audio" on entry)
- [Footnote aside border line (#4438)](footnote-aside-namespace-order-4438.md) — v0.11.4 regression: stray horizontal line below footnote marker. #4383 inlined custom `@font-face` BEFORE the `@namespace epub` (which lived in `getPageLayoutStyles`), invalidating it per CSS spec → namespaced `aside[epub|type~="footnote"]{display:none}` dropped → book's `aside{border:3px double}` showed. Only with custom fonts loaded. Fix = hoist `@namespace` to front of `getStyles`. Repro needs XHTML (`epub:type` namespaced only in XML); Playwright `setContent` parses HTML and won't reproduce
- [Scrolled-mode notch mask vs texture (#4486)](notch-mask-texture-4486.md) — top inset mask occluded the bg texture; full-cell + clip-path paint-box-matching for tile alignment; CDP-inject + MAE seam verification on device; adb taps in status-bar region eaten by SystemUI
- [Paragraph-mode accidental exit + off-center bar (#4474)](paragraph-mode-accidental-exit-4474.md) — backdrop/center taps exited focus mode (stray "too high/low" taps); `ParagraphBar` only reshows on mousemove (no touch reshow) so can't just delete tap-exits → new `paragraph-show-controls` event reveals the bar instead. Also bar `absolute``fixed`: it centered on the gridcell which a pinned sidebar shifts right, while the paragraph centers on the `fixed inset-0` overlay/viewport
- [Share intent + customizable toolbar (#4014)](annotation-share-toolbar-4014.md) — Share tool in the selection toolbar (sharekit gated mobile+macOS only re: #4343 Windows freeze; `canShareText`/`shareSelectedText` in dual-purpose `share.ts`) + drag-and-drop customizer sub-page; `annotationToolbarItems` view setting (Share hidden by default); pure helpers in `annotationToolbar.ts`
## Reader Features & UI
- [Instant Highlight ate tap/swipe (Android)](instant-highlight-tap-paginate.md) — `handlePointerDown` preventDefault killed tap-paginate; touch still-hold gate `INSTANT_HOLD_MS=300`
- [Keyboard selection adjust (#4728)](keyboard-selection-adjust-4728.md) — Shift+←/→ char, Ctrl/Alt+Shift word; `onAdjustTextSelection` in useBookShortcuts
- [Annotator onLoad listener leak (#4735)](annotator-onload-listener-leak-paragraph-mode.md) — per-section onLoad leaked listeners; `useRendererInputListeners` once-per-view
- [Paragraph mode toggle/resume (#4717)](paragraph-mode-toggle-resume-4717.md) — dispatch iterates live Set → snapshot; resume from fresh `view.lastLocation.cfi`
- [Paragraph-mode accidental exit (#4474)](paragraph-mode-accidental-exit-4474.md) — backdrop taps exited; `paragraph-show-controls` event; bar `absolute``fixed`
- [#4584 tap-death](issue-4584-tap-death-investigation.md) — UNFIXED; `isPopuped` self-heal RED HERRING; likely WebView-148 (emulator 133 can't repro)
- [Dblclick-drag turns page (#4524)](dblclick-drag-pageturn-4524.md) — deferred single-click fires mid-drag; `isMouseDown` gates `postSingleClick`
- [Tap to open image/table (#4600)](tap-to-open-image-table-4600.md) — single-tap opens gallery in reflowable; `iframe-open-media` + `detectMediaTarget`
- [iOS instant-dict double popup](ios-instant-dict-double-popup.md) — multi selectionchange → once-per-gesture latch; `isLongPressHold` 300ms gate
- [Dict popup font size (#4443)](dict-popup-font-size-4443.md) — `fontScale``--dict-font-scale`; MDict shadow-DOM needs `::part(dict-content)`
- [Dictionary lemmatization (#4574)](dict-lemmatization-4574.md) — inflected→lemma; `lemmatize/` registry; `-ses→-sis` before `-es`
- [Word Lens inline gloss](wordlens-feature.md) — native hint above hard words; CFI-safe `<ruby cfi-skip>…<rt cfi-inert>`; TTS/search isolation
- [Word Lens en-en](wordlens-en-en.md) — gloss = simplest WordNet synonym; same-lang unblock manifest-driven; rt font/color settings
- [Stripe highest-active plan (#4694)](stripe-plan-highest-active-4694.md) — `plans.plan` = MAX over active subs; `getHighestActivePlan` create+cancel
- [Save image to gallery (#4680)](save-image-to-gallery-android.md) — Save → MediaStore; sharekit 0-byte self-copy bug (Temp==cacheDir)
- [Webtoon Mode (#3647)](webtoon-mode-3647.md) — no-gap scrolled image reading; FXL scroll = fit-width; `scroll-gap``--scroll-page-gap`
- [Biometric app-lock (#4645)](biometric-app-lock-4645.md) — read flag from `appLockStore` not settingsStore; plugin `cfg(mobile)`
- [Reference Pages (#4542)](reference-pages-672-4542.md) — 'reference' progressStyle from foliate pageList; per-book `referencePageCount`
- [Share intent + toolbar (#4014)](annotation-share-toolbar-4014.md) — Share tool gated mobile+macOS; drag-drop customizer; `annotationToolbarItems`
- [Native iOS TTS (#4676)](native-ios-tts-4676.md) — AVSpeechSynthesizer plugin; pause==stop, never `end` on didCancel; rate `pow^(1/2.5)`
- [Native TTS offline halt (#4613)](native-tts-offline-autoadvance-4613.md) — `#speak` advances only on `end`; native SKIP-on-error via `forward()` + cap
- [Edge TTS word highlight (#4017)](edge-tts-word-highlighting-4017.md) — `audio.metadata` WordBoundary synced by rAF; gates on UA not Origin
- [Edge TTS word-highlight drift](tts-word-highlight-singletextnode-drift.md) — TEXT_NODE fast path ignored offsets → whole-para; slice `[start,end]`
- [TTS start-from-selection](tts-start-from-selection.md) — `from()` picked first mark after sel (use last at/before); cloneRange+deselect
- [Reuse TTS session on mode entry](tts-reuse-session-mode-entry.md) — `redispatchPosition()` + `tts-sync-request` replay + engage-on-entry
- [RSVP control bar overlap = REVERT](rsvp-control-bar-overlap-revert.md) — #4585 fixed; stale #4589 reverted it incl. guard test
- [RSVP font face/family (#4519)](rsvp-font-settings-4519.md) — was font-mono; `getBaseFontFamily`; overlay renders in top doc
- [RSVP RTL word display (#4630)](rsvp-rtl-word-display-4630.md) — ORP char-split breaks Arabic; `isRTLText` → render whole `dir=rtl`
- [Overlay z-index scale](zindex-overlay-scale.md) — RSVP 100 / Settings 110 / ModalPortal 120 / toast 130 / app-lock 200; invariant test
- [Global annotation page-turn lag (#4575)](global-annotation-pageturn-perf-4575.md) — `global` highlights re-fanned every turn; `WeakMap<Document>` memo
- [Overlayer splitRange text nodes](overlayer-splitrange-textnodes.md) — `'p,h1-h4'` selector dropped `li`; walk text nodes + img/svg
- [Android image callout freeze](android-image-callout-freeze.md) — long-press img callout → freeze; `.no-context-menu` on ANCESTOR
- [Table dark-mode tint (#4419)](table-dark-mode-tint-4419.md) — `blockquote, table *` tint must stay gated on `overrideColor`; paints TOC spacer
- [Footnote aside border line (#4438)](footnote-aside-namespace-order-4438.md) — @font-face before @namespace invalidated it; hoist @namespace
- [Proofread enhancements (#4700)](proofread-enhancements-4700.md) — only global rules sync; regex UI; Ctrl+P reuse; `wholeWord` no-op
- [OPDS Firefox strict-XML (#4479)](opds-firefox-strict-xml-4479.md) — junk after `</feed>` → parsererror; `parseOPDSXML` slices to last close tag
- [OPDS2 JSON search greyed (#4502)](opds2-json-search-4502.md) — `isSearchLink` ignored templated opds+json; expand `{?query}` BEFORE resolveURL
- [OPDS HTML description (#4503)](opds-html-description-4503.md) — double-escaped into unsanitized HTML; decode-once + sanitize
- [D-pad Navigation](dpad-navigation.md) — Android TV remote / arrow-key nav design + pitfalls
- [koplugin cover upload (#4374)](koplugin-cover-upload.md) — local covers uploaded blank; `extractLocalCover` via `getCoverImage`
## 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
- [TXT chapter measure-word false positives (#4658)](txt-chapter-measure-word-4658.md) — `第一封信`/`第四本书…` (量词 prose) detected as chapters; `createChapterRegexps('zh')` unit class split into strong `[章节回讲篇话]` (attached title OK) vs weak/量词 `[卷本册部封]` (title needs a separator or line end, never a bare noun)
- [Cover stale until refresh (in-place mutation vs React.memo)](cover-stale-inplace-mutation-memo.md) — editing a book cover in details + Save left the library cover stale until reload; `handleUpdateMetadata` mutated `book` IN PLACE so memoized `<BookCover>`'s prev snapshot pointed at the same object → comparator saw no change → skip; fix = pure `getBookWithUpdatedMetadata` returns a NEW book object. Cloning in `updateBook` wouldn't help (original already mutated). Verified live on emulator via CDP fiber-store extraction (A: mutate→stale, B: new obj→updates)
- [Series/author folder back no-op (#4437)](series-folder-back-noop-4437.md) — back arrow dead inside Series/Author folder after cold start; Next.js 16.2 static-export empty-search `router.replace` no-op (same as #3782/#3832); `GroupHeader.handleBack` missed the `group=''` workaround. CDP-verify gotcha: synthetic `el.click()` won't fire React onClick — use trusted `Input.dispatchMouseEvent`
- [Book action platform surfaces](book-actions-platform-surfaces.md) — context menu Tauri-desktop-only; cross-platform actions in `BookDetailView`
- [Tauri menu append race (#4389)](tauri-menu-append-race-4389.md) — un-awaited `Menu.append()` shuffles order; single `await Menu.new({ items })`
- [TXT author recognition (#4390)](txt-author-recognition-4390.md) — 【】 web-novels garbage author; `parseLabeledAuthor` + `isPlausibleAuthorName`
- [TXT chapter measure-word FP (#4658)](txt-chapter-measure-word-4658.md) — strong `[章节回讲篇话]` vs weak `[卷本册部封]` needs separator
- [Cover stale (in-place mutation)](cover-stale-inplace-mutation-memo.md) — mutated book in place → memo skip; pure `getBookWithUpdatedMetadata`
- [Series/author back no-op (#4437)](series-folder-back-noop-4437.md) — Next 16.2 empty-search `router.replace` no-op; `handleBack` `group=''` workaround
## Library Architecture
- [Book action platform surfaces](book-actions-platform-surfaces.md) — library context menu is **Tauri-desktop-only** (`hasContextMenu` false on web + iOS/Android); cross-platform book actions go in `BookDetailView`'s icon row. #4543 Goodreads search added both surfaces + a built-in web-search provider for highlighted-text lookup
## Architecture & Patterns
- foliate-js is a git submodule at `packages/foliate-js/`; multiview paginator preloads adjacent sections (multiple View/Overlayer per book)
- Style: `getLayoutStyles()` always, `getColorStyles()` when overriding color; `transformStylesheet()` regex-rewrites EPUB CSS at load
- TTS independent section tracking (`#ttsSectionIndex`); safe insets: native plugin → useSafeAreaInsets → styles; Dropdowns use `DropdownContext`
- [Foliate touch-listener capture phase](foliate-touch-listener-capture-phase.md) — suppress reader gestures via `{capture:true}`
- [iframe cross-realm instanceof](iframe-cross-realm-instanceof.md) — top realm: `iframeEl instanceof Element` always false; duck-type `'closest' in target`
- [Virtuoso + OverlayScrollbars](virtuoso_overlayscrollbars.md) — useOverlayScrollbars hook for mobile webviews
- [Design system → DESIGN.md](feedback_design_system_doc.md) — codify UI rules; never `pl/pr/ml/mr/text-left/right` (RTL)
## 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
- Style overrides: `getLayoutStyles()` (always), `getColorStyles()` (when overriding color)
- `transformStylesheet()` does regex-based EPUB CSS rewriting at load time
- TTS uses independent section tracking (`#ttsSectionIndex`) decoupled from view
- 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
- [Always rebase before PR](feedback_pr_rebase.md) — rebase onto origin/main before creating PRs
- [New branch per PR](feedback_pr_new_branch.md) — always create a fresh branch from main for each new PR/issue
- [Upgrade gstack locally](feedback_gstack_upgrade.md) — always upgrade from the project's .claude/skills/gstack, not global
- [No lookbehind regex](feedback_no_lookbehind_regex.md) — never use `(?<=)` or `(?<!)` in JS/TS; build check rejects them
- [Use worktree](feedback_use_worktree.md) — never `git worktree add` directly; always `pnpm worktree:new` before PR review, issue fix, or feature work
- [en/translation.json holds ONLY plural variants + proper nouns](feedback_en_plurals_manual.md) — non-plural strings stay out (defaultValue: key is the en source); plural strings (`_('...', { count })`) need hand-added `_one`/`_other` entries or the singular renders as "1 days"
- [Never push on every change](feedback_dont_push_every_change.md) — hold pushes during active bug iteration; commit locally only until user confirms or work hits a clean done-state
- [No test seams in production code](feedback_no_test_seams_in_prod.md) — production must never import or call `__reset*ForTests`; cross-module test resets belong in the test file's beforeEach/afterEach
- [Dependabot transitive fixes](dependabot-pnpm-overrides.md) — pin patched min-version in `pnpm-workspace.yaml` `overrides:` (NOT package.json `pnpm.overrides`, which pnpm 9+ ignores); watch for existing too-low pins; alert#≠issue# so no `Closes #` (PR #4523)
- [CI/PR delivery + push keepalive](ci-pr-delivery-and-push.md) — package small PRs from a dirty dev tree via temp-index plumbing (no worktree); slow pre-push hook (~55s full suite) + SOCKS-proxy SSH → idle "Broken pipe", fixed with `ServerAliveInterval`; `--no-verify` safe once the hook already passed (always `git ls-remote` to confirm a push landed)
## Workflow & Feedback
- [Commit messages English-only](feedback-commit-message-english-only.md) — commit msgs + PR titles English only (no CJK, no em/en dashes). PR #4660
- [Test file filter](feedback_test_file_filter.md) — `pnpm test <path>` without `--` runs a single file
- [Rebase before PR](feedback_pr_rebase.md) — rebase onto origin/main before PRs
- [New branch per PR](feedback_pr_new_branch.md) — fresh branch from main per PR/issue
- [Use worktree](feedback_use_worktree.md) — never `git worktree add`; always `pnpm worktree:new`
- [Never push every change](feedback_dont_push_every_change.md) — commit locally until user confirms or clean done-state
- [No test seams in prod](feedback_no_test_seams_in_prod.md) — prod never imports `__reset*ForTests`
- [No lookbehind regex](feedback_no_lookbehind_regex.md) — never `(?<=)`/`(?<!)`; build check rejects
- [en plurals manual](feedback_en_plurals_manual.md) — only plural variants + proper nouns; plurals need `_one`/`_other`
- [Dependabot transitive fixes](dependabot-pnpm-overrides.md) — pin in `pnpm-workspace.yaml` `overrides:`; alert#≠issue#
- [Upgrade gstack locally](feedback_gstack_upgrade.md) — upgrade from project `.claude/skills/gstack`
@@ -0,0 +1,26 @@
---
name: grimmory-native-sync
description: Grimmory (Booklore fork) sync API surface + CORS analysis for adding native grimmory sync to Readest
metadata:
node_type: memory
type: project
originSessionId: ef2f9371-2968-4f81-abe4-a9349547542b
---
Goal: add **native grimmory sync** to Readest (vs the current OPDS+KOReader-compat detour, which causes 3-way KOReader↔Kobo↔grimmory desync — see discussion grimmory-tools/discussions/1417). Grimmory repo at `/Users/chrox/dev/grimmory` (Java/Spring backend, package `org.booklore`).
**Native API (use this, not KOSync):** JWT bearer. `POST /api/v1/auth/login` `{username,password}``{accessToken,refreshToken,expires}` (2h/30d); `POST /api/v1/auth/refresh`. Progress: `POST /api/v1/books/progress` `{bookId, fileProgress: BookFileProgress{bookFileId, progressPercent 0-100, positionData (CFI for EPUB), positionHref, ttsPositionCfi}, dateFinished}`; `GET /api/v1/books` to list — **the Book DTO does NOT expose the file hash** (no native hash lookup endpoint; match by metadata, see below). Annotations `/api/v1/annotations/**`, bookmarks `/api/v1/bookmarks/**`, download `/api/v1/books/{id}/download` (Range OK), cover `/api/v1/media/{id}/cover`. KOReader-compat path exists at `/api/koreader/**` (X-Auth-User + X-Auth-Key=md5(pw)) but is the thing we're replacing.
**CORS (`SecurityConfig.java`): per-filter-chain only — NO global CorsFilter/addCorsMappings.** Policy (`:340-368`): origins default `*` (env `ALLOWED_ORIGINS`, uses `setAllowedOriginPatterns` so `*`+credentials valid); methods all; **allowed-headers is a FIXED whitelist** = `Authorization, Cache-Control, Content-Type, Range, If-None-Match, If-Modified-Since` (NOT `*`, and **excludes X-Auth-User/X-Auth-Key**); allowCredentials true. Chains WITH `.cors()`: jwtApi (order10: `/api/**` minus whitelist → books/progress/annotations/bookmarks/reading-sessions/koreader-users), bookDownload(8), epub/audiobook/custom-font/ws(5-9). Chains WITHOUT `.cors()`: opds(1), komga(2), **koreader(3)**, kobo(3), **media/cover(4)**, **catch-all static(11)**. CRITICAL GAP: `/api/v1/auth/login` + `/auth/refresh` are whitelisted OUT of order10's matcher (`:265-289`) so they hit order11 catch-all = **no CORS** → cross-origin browser login fails (invisible to grimmory's own SPA, served same-origin from `classpath:/static/`).
**What it means for Readest:** Tauri desktop/mobile = CORS irrelevant (`@tauri-apps/plugin-http` is native, all endpoints work incl. login). Readest **web build** = JWT data endpoints work cross-origin once token obtained, but **login + koreader need a server-side proxy** (same pattern as existing `/api/kosync`, `/api/opds/proxy`) or same-origin reverse proxy.
**Readest extension points (template = KOSync):** new `src/services/grimmory/GrimmoryClient.ts` (connect/getProgress/updateProgress mirroring `KOSyncClient.ts`), `src/app/reader/hooks/useGrimmorySync.ts` (mirror `useKOSync.ts`), `GrimmorySettings` in `src/types/settings.ts`, `GrimmoryForm.tsx` wired into `IntegrationsPanel.tsx`. Progress mapping: Readest `BookProgress.location` (CFI) ↔ grimmory `BookFileProgress.positionData`; grimmory has `EpubCfiService` for CFI↔XPointer. Related: [[kosync-cfi-spine-resolution]], [[kosync-connect-false-positive-4692]].
**STATUS: NOT shipped.** A full vertical slice was built on 2026-06-23 (GrimmoryClient + useGrimmorySync hook + `/api/grimmory` proxy + GrimmoryForm/IntegrationsPanel + settings/types; metadata-match identity cached in BookConfig; native `/api/v1/books/progress`; tests+lint green) then **REVERTED at the maintainer's request ("not ready yet")**. Working tree fully restored (all grimmory files deleted, the 7 edited shared files reverted; lint + test green). Re-attempt later — the design below + the two findings below are the distilled learnings. Reverted because the native-progress identity story was judged immature; the more robust paths (OPDS acquisition capture, or mirroring the official koplugin) hadn't been built yet.
**FINDING A — how the OFFICIAL koplugin (`github.com/grimmory-tools/grimmory.koplugin`) maps identifiers (it does NOT use `/api/v1/books/progress`).** Local SQLite `book(book_path, partial_md5, grimmory_id)` stores BOTH ids per file. TWO paths: (1) native `grimmory_id` (= book.id) for sessions/downloads/shelves, resolved by **ISBN13/ISBN10/ISBN/ASIN only** (`doc_metadata.lua isBook` — NOT title/author), persisted via `repository.upsertBook(path, book.id)`; sessions → `POST /api/v1/reading-sessions` keyed by grimmory_id. (2) reading **PROGRESS via the KOReader-compat endpoint** `GET/PUT /api/koreader/syncs/progress[/{partialMD5}]`, keyed by KOReader's own `util.partialMD5(book_path)` (NOT grimmory_id), with creds auto-provisioned from native `GET/PUT /api/v1/koreader-users/me` (`getKoreaderCredentials``md5(secret)` → X-Auth-User/X-Auth-Key). ⇒ The proven progress path reuses our existing KOSync XPointer/partial-MD5 machinery against `/api/koreader/...`, not the native progress API. Caveat: backend `FileFingerprint.generateHash` samples i=-1 at `1024L<<-2` → Java overflow to offset **0**, vs KOReader LuaJIT `bit.lshift(1024,-2)` → offset **256**; first block MAY differ ⇒ partial-MD5 progress-by-hash could silently mismatch — VERIFY (hash one real downloaded file both ways) before relying.
**FINDING B — OPDS acquisition-time capture (the chosen "best match", not yet built).** Grimmory OPDS fingerprints (`OpdsFeedService.java`): every `<id>` is `urn:booklore:*` (root `urn:booklore:root`, books `urn:booklore:book:{bookId}`); feed `<title>Booklore Catalog`; self/start link `/api/v1/opds`. The book acquisition link encodes BOTH ids: `<link href="/api/v1/opds/{bookId}/download?fileId={fileId}" rel="http://opds-spec.org/acquisition">`. So at OPDS download (`src/app/opds/page.tsx` ~line 505 has `url`; already persists sourceUrl via `upsertOPDSSourceMapping`) parse `bookId` (path) + `fileId` (query); corroborate via `urn:booklore:` entry id OR same-origin with configured grimmory serverUrl; write the ids into config. Authoritative, no metadata guessing — best identity strategy for grimmory-sourced books.
Identity options ranked (native path): (1) OPDS acquisition capture [authoritative]; (2) cached ids; (3) ISBN/ASIN exact; (4) gated title+author (require format + fileSizeKb match, abstain on ambiguity — wrong match corrupts another book's progress). `fileSizeKb` IS exposed on BookFile (size corroborator); hash is NOT.
@@ -0,0 +1,43 @@
---
name: instant-highlight-tap-paginate
description: Instant Highlight quick action swallowed tap/swipe-to-paginate on Android; fixed with a 300ms still-hold gate
metadata:
node_type: memory
type: project
originSessionId: d92c120f-6272-4366-92b8-e2d8f32dfd52
---
After the 2026-06-19 update, Android users reported tap-to-paginate failing in
paginated mode: tapping TEXT didn't turn the page, only tapping the empty side
MARGINS worked. Trigger = **Instant Highlight** quick action enabled (3rd toolbar
icon / highlighter; setting = `enableAnnotationQuickActions && annotationQuickAction === 'highlight'`).
**Root cause:** `useTextSelector.handlePointerDown` called `ev.preventDefault()` +
`startInstantAnnotating()` on EVERY pointer-down over selectable text. The
`preventDefault` suppressed the native click that drives tap-to-paginate (iframe
`handleClick``iframe-single-click` → usePagination). Margins worked only because
`handleInstantAnnotationPointerDown``isSelectableContent` returns false there.
The synthetic-mousedown fallback in `handlePointerUp` is dead on Android because the
native-touch `touchend` calls `handlePointerUp(doc, index)` with NO `ev` (Annotator.tsx
`handleNativeTouch`), and `if (isInstantAnnotating.current && ev)` skips.
**Fix (PR/commit on `dev`):** gate instant-highlight engagement behind a still hold
for touch/pen — `INSTANT_HOLD_MS = 300`, `INSTANT_HOLD_MOVE_PX = 10` in useTextSelector.ts.
- `armInstantHold` (touch/pen) records the press, starts a 300ms timer, does NOT
preventDefault. A tap releases first (`handlePointerUp`/`handlePointerCancel`
`cancelInstantHold`) → native click → paginate. A swipe moves first
(`maybeCancelInstantHoldOnMove`, called in BOTH `handlePointerMove` and
`handleNativeTouchMove`, compares window-coord `pointerPos` vs `instantHoldStartWindow`)
→ native swipe → paginate. Only a still hold fires the timer → `startInstantAnnotating`.
- Mouse path unchanged (immediate `preventDefault` + start) — click vs. press-drag is
already unambiguous; matches the existing "mouse shouldn't be time-gated" stance.
- Refactor: `startInstantAnnotating(target, startPoint)` / `stopInstantAnnotating()` no
longer take `ev`; the down `target` is stored in `instantAnnotationTarget` so the exact
element gets `user-select` restored (pointerup target may differ after the finger moves).
Two parallel instant-highlight mechanisms share the same enable flag: (1) the
`useInstantAnnotation` live drag-to-highlight (this fix), and (2) the
quick-action-on-selection deferred path (`beginGesture`/`deferredQuickActionRef`/
`pointerDownTimeRef` in Annotator.tsx) which ALREADY long-press-gates touch on
iOS/desktop but not Android. Test: `useTextSelector-instantHold.test.ts`. See
[[keyboard-selection-adjust-4728]] for the adjacent `isPointerDown`/`handleSelectionchange` logic.
@@ -0,0 +1,22 @@
---
name: keyboard-selection-adjust-4728
description: "After a reader text selection, keystrokes land in the PARENT (container focus), not the iframe — fix Shift/Ctrl/Alt+Arrow selection refine in useBookShortcuts"
metadata:
node_type: memory
type: project
originSessionId: 9ebaeccc-0436-4c7b-a81e-1a4aa3de64dd
---
#4728: standard desktop selection shortcuts — `Shift+←/→` refine selection by character, `Ctrl/Alt(Option)+Shift+←/→` by word — implemented in the **parent** shortcut system, not the iframe.
**Critical gotcha (cost a full redesign):** after a text selection, `Annotator.handleShowAnnotPopup` calls `containerRef.current?.focus()` on desktop, so `document.activeElement` is a **parent-document DIV**, not the book iframe. Real OS keystrokes therefore go to the parent `window``useShortcuts` (native keydown path) → page-turn shortcuts (`shift+ArrowRight`=`onGoNext`/`onGoForward`). A fix inside the iframe `handleKeydown` is **bypassed** for real keystrokes — it only fires if focus is in the iframe (e.g. quick-actions config). JS-dispatched `KeyboardEvent`s into the iframe doc DO hit the iframe handler, so they falsely "pass" — only a **real OS key** (`computer.key`) reveals the parent-focus path. Always verify with a real keystroke, not a synthetic dispatch.
**Fix shape:**
- `utils/sel.ts`: pure `getKeyboardSelectionAdjustment(KeyModifiers)``{direction:'left'|'right', granularity:'character'|'word'}|null` (Shift=char, Ctrl||Alt=word, metaKey→null so native Cmd+Shift line-select survives; 'left'/'right' visual dir for RTL). `extendSelectionFromContents(contents, ev, extend)` walks `view.renderer.getContents()` (`{doc}[]`), finds the non-collapsed `doc.defaultView.getSelection()`, and (if `extend`) `sel.modify('extend', dir, gran)`; returns whether a selection was found.
- `helpers/shortcuts.ts`: new `onAdjustTextSelection` (section 'Selection') with keys `shift+Arrow{Left,Right}` + `ctrl/alt+shift+Arrow{...}`.
- `useBookShortcuts.ts`: `adjustTextSelection` wired **first** in the `useShortcuts` actions map so it intercepts before `onGoNext/Prev/...`. Native keydown (parent focus) → extend ourselves; forwarded iframe-keydown MessageEvent (iframe already extended natively) → `extend:false`, just report presence to suppress nav. Returns true ⇒ `processKeyEvent` stops ⇒ no page turn.
- `useTextSelector.handleSelectionchange`: desktop normally defers to pointerup; relaxed the gate to `!isAndroid && !isTouchInput && isPointerDown.current` (new `isPointerDown` ref set in pointerdown, cleared in pointerup/cancel) so a keyboard-driven `selectionchange` (no pointer drag) refreshes the popup/range. This realm-agnostic gate refreshes for BOTH the parent-modify and native-iframe-modify paths.
**Selection.modify test artifact:** in browser-lane tests build the starting selection with `setBaseAndExtent` (or collapse+extend), NOT `addRange``addRange` leaves the selection directionless so backward `modify('extend','left'/'backward')` silently no-ops; `setBaseAndExtent` establishes anchor/focus like a real mouse drag.
Verified live on Chrome with real keystrokes (Alice EPUB, scrolled mode): `Shift+→` "Queen"→"Queen." no turn; `Opt+Shift+→` "two"→"two miles" (word); popup follows; no selection ⇒ `Shift+→` still scrolls (nav preserved). Paginated auto-scroll-to-follow when extending past the page edge is NOT wired (foliate's `isKeyboardSelecting` scrollToAnchor only fires for iframe-focus keydowns; parent-focus has none) — minor known limitation. See [[layout-ui-fixes]].
@@ -0,0 +1,42 @@
---
name: scrolled-header-title-center-4436
description: "Scrolled-mode header chapter title lagged because getVisibleRange picked the topmost sliver view, not the viewport-center section"
metadata:
node_type: memory
type: project
originSessionId: 0c504495-68fe-4a26-b314-644bbc496581
---
#4436 — In scrolled mode the reader header chapter title was wrong vs paginated
mode while transitioning between sections. Title comes from foliate `tocItem =
TOCProgress.getProgress(index, range)`; `index`/`range` come from the
paginator's relocate detail, ultimately from `#getVisibleRange()`.
**Root cause:** the scrolled branch of `#getVisibleRange` (`packages/foliate-js/paginator.js`)
returned the FIRST overlapping view (lowest index = topmost in scroll order).
When the tail of section K is a thin text-bearing sliver at the very top of the
viewport but section K+1 occupies the centre/majority, it returned K's range →
title showed K while the reader was reading K+1. Paginated mode never shows this
because each page belongs to one section. (`comparePoint` end-boundary logic in
`progress.js` is shared by both modes and was NOT the divergence — the view
choice was.)
**Fix:** prefer the view whose visible band covers the viewport CENTRE
(`center = #renderedStart + size/2`; `center >= off && center < off+vSize`);
keep the first valid non-collapsed range as a `fallback` for when no loaded view
covers the centre (very top/bottom of book). Also fixed `#afterScroll` scrolled
fraction to size against `this.#views.get(index)` (the relocated view) instead of
`#primaryView`, since the relocated `index` can now differ from `#primaryIndex`.
`#detectPrimaryView`/`#primaryIndex` left UNCHANGED (drives preload/trim/bg;
guarded by #4112/#3987 tests) — only the relocate index/range moved to centre.
Accepted side effect: scrolled CFI/anchor now reflect the centre section (reopen
lands at centre section top) — minor, arguably better.
**Test:** `paginator-scrolled.browser.test.ts` "should report the section
occupying the viewport centre…" — real paginator + sample-alice, two adjacent
tall linear sections, `setAttribute('no-preload','')` AFTER fill to freeze view
offsets (else backward-preload scroll-compensation shifts the absolute scrollTop
target), nudge-scroll (first debounced scroll only clears `#justAnchored`; need a
2nd to fire `afterScroll('scroll')`), assert relocate `index` == centre section.
See [[issue-4112-scroll-anchoring]].
@@ -0,0 +1,171 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { cleanup, renderHook } from '@testing-library/react';
// The instant-highlight quick action must require a deliberate still hold before
// it engages on touch, otherwise it eats the single tap / swipe that the reader
// uses to turn the page (the Android regression behind this test).
const HOLD_MS = 300;
const h = vi.hoisted(() => ({
view: {
next: vi.fn(),
prev: vi.fn(),
deselect: vi.fn(),
getCFI: vi.fn(() => 'cfi'),
renderer: { containerPosition: 100, scrollLocked: false },
},
appService: { isAndroidApp: false, isMobile: false },
osPlatform: 'macos',
viewSettings: { scrolled: false } as { scrolled: boolean; vertical?: boolean },
// Whether the pointer landed on selectable text (instant-highlight eligible).
eligible: true,
}));
vi.mock('@/context/EnvContext', () => ({
useEnv: () => ({ appService: h.appService }),
}));
vi.mock('@/store/readerStore', () => ({
useReaderStore: () => ({
getView: () => h.view,
getViewSettings: () => h.viewSettings,
getProgress: () => null,
}),
}));
vi.mock('@/store/bookDataStore', () => ({
useBookDataStore: () => ({ getBookData: () => ({}) }),
}));
vi.mock('@/utils/event', () => ({
eventDispatcher: { onSync: vi.fn(), offSync: vi.fn(), on: vi.fn(), off: vi.fn() },
}));
vi.mock('@/app/reader/hooks/useInstantAnnotation', () => ({
useInstantAnnotation: () => ({
isInstantAnnotationEnabled: () => true,
handleInstantAnnotationPointerDown: vi.fn(() => h.eligible),
handleInstantAnnotationPointerMove: vi.fn(() => true),
handleInstantAnnotationPointerCancel: vi.fn(),
handleInstantAnnotationPointerUp: vi.fn(async () => false),
cancelInstantAnnotation: vi.fn(),
}),
}));
vi.mock('@/utils/misc', async (importActual) => {
const actual = await importActual<typeof import('@/utils/misc')>();
return { ...actual, getOSPlatform: () => h.osPlatform };
});
import { useTextSelector } from '@/app/reader/hooks/useTextSelector';
const ZERO_INSETS = { top: 0, right: 0, bottom: 0, left: 0 };
const setup = () => {
const noop = vi.fn();
return renderHook(() =>
useTextSelector(
'book-1',
ZERO_INSETS,
noop,
noop,
noop,
vi.fn(async () => ''),
noop,
),
);
};
const doc = {
getSelection: () => null,
createRange: () => ({
setStart: () => {},
collapse: () => {},
getBoundingClientRect: () => ({ left: 0, right: 0, top: 0, bottom: 0 }),
}),
defaultView: { frameElement: { getBoundingClientRect: () => ({ left: 0, top: 0 }) } },
} as unknown as Document;
const pointerEvent = (pointerType: string, x: number, y: number) => {
const target = document.createElement('span');
return {
pointerType,
button: 0,
clientX: x,
clientY: y,
target,
preventDefault: vi.fn(),
} as unknown as PointerEvent & { preventDefault: ReturnType<typeof vi.fn> };
};
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
h.appService = { isAndroidApp: false, isMobile: false };
h.osPlatform = 'macos';
h.viewSettings = { scrolled: false };
h.view.renderer.scrollLocked = false;
h.eligible = true;
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
cleanup();
});
describe('useTextSelector instant-highlight still-hold gate', () => {
test('a quick touch tap does not start instant annotating (lets the tap paginate)', () => {
const { result } = setup();
const down = pointerEvent('touch', 100, 100);
result.current.handlePointerDown(doc, 0, down);
// The single tap must not be swallowed by instant highlight: no preventDefault,
// so the native click still fires and turns the page.
expect(down.preventDefault).not.toHaveBeenCalled();
expect(result.current.isInstantAnnotating.current).toBe(false);
result.current.handlePointerUp(doc, 0, pointerEvent('touch', 100, 100));
expect(result.current.isInstantAnnotating.current).toBe(false);
});
test('a still touch hold starts instant annotating after the hold elapses', async () => {
const { result } = setup();
const down = pointerEvent('touch', 100, 100);
result.current.handlePointerDown(doc, 0, down);
expect(result.current.isInstantAnnotating.current).toBe(false);
await vi.advanceTimersByTimeAsync(HOLD_MS + 20);
expect(result.current.isInstantAnnotating.current).toBe(true);
expect(h.view.renderer.scrollLocked).toBe(true);
});
test('a touch swipe before the hold cancels arming (lets the swipe paginate)', async () => {
const { result } = setup();
result.current.handlePointerDown(doc, 0, pointerEvent('touch', 100, 100));
// Move well past the stillness threshold before the hold elapses.
result.current.handlePointerMove(doc, 0, { clientX: 170, clientY: 105 } as PointerEvent);
await vi.advanceTimersByTimeAsync(HOLD_MS + 20);
expect(result.current.isInstantAnnotating.current).toBe(false);
});
test('a tap on a non-selectable margin never arms instant annotating', async () => {
h.eligible = false;
const { result } = setup();
const down = pointerEvent('touch', 100, 100);
result.current.handlePointerDown(doc, 0, down);
await vi.advanceTimersByTimeAsync(HOLD_MS + 20);
expect(down.preventDefault).not.toHaveBeenCalled();
expect(result.current.isInstantAnnotating.current).toBe(false);
});
test('a mouse press starts instant annotating immediately (no hold gate)', () => {
const { result } = setup();
const down = pointerEvent('mouse', 100, 100);
result.current.handlePointerDown(doc, 0, down);
expect(down.preventDefault).toHaveBeenCalled();
expect(result.current.isInstantAnnotating.current).toBe(true);
});
});
@@ -27,6 +27,17 @@ const AUTO_TURN_DWELL_MS = 500;
// of the page and turns the page unexpectedly.
const AUTO_TURN_CORNER_FRACTION = 0.15;
// Instant-highlight quick action: on touch a plain tap and a swipe are both
// page-turn gestures, so the highlight must not engage on pointer-down or it
// swallows the tap/swipe (an Android tap-to-paginate regression). It only engages
// after the finger has held still on the text for this long; a tap releases first
// and a swipe moves first, so both fall through to pagination. Mouse input is not
// gated — a click vs. a press-drag is already unambiguous.
const INSTANT_HOLD_MS = 300;
// Movement past this many CSS px during the hold means the user is swiping, not
// settling in to highlight, so the pending engagement is cancelled.
const INSTANT_HOLD_MOVE_PX = 10;
type Corner = 'br' | 'tl';
// Which screen corner a point sits in: bottom-right turns forward, top-left
@@ -138,6 +149,17 @@ export const useTextSelector = (
const isInstantAnnotating = useRef(false);
const isInstantAnnotated = useRef(false);
const annotationStartPoint = useRef<Point | null>(null);
// The element instant annotating set `user-select: none` on, so the exact same
// element is restored on release (the pointerup target may differ once the
// finger has moved across nodes).
const instantAnnotationTarget = useRef<HTMLElement | null>(null);
// Pending instant-highlight still-hold (touch/pen). While a hold is in flight
// these remember the press so the timer can engage at the same spot; the gate
// is armed in handlePointerDown and dropped by a release or a swipe.
const instantHoldTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const instantHoldTarget = useRef<HTMLElement | null>(null);
const instantHoldStartClient = useRef<Point | null>(null);
const instantHoldStartWindow = useRef<{ x: number; y: number } | null>(null);
const autoTurnTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// The corner an input signal is currently engaged in. Stays set after a turn so
// the dwell can't re-arm while held — a signal must leave the corner and return
@@ -214,20 +236,78 @@ export const useTextSelector = (
});
};
const startInstantAnnotating = (ev: PointerEvent) => {
const startInstantAnnotating = (target: HTMLElement, startPoint: Point) => {
isInstantAnnotating.current = true;
isInstantAnnotated.current = false;
annotationStartPoint.current = { x: ev.clientX, y: ev.clientY };
annotationStartPoint.current = startPoint;
instantAnnotationTarget.current = target;
if (view) view.renderer.scrollLocked = true;
(ev.target as HTMLElement).style.userSelect = 'none';
target.style.userSelect = 'none';
};
const stopInstantAnnotating = (ev: PointerEvent) => {
const stopInstantAnnotating = () => {
isInstantAnnotating.current = false;
isInstantAnnotated.current = false;
annotationStartPoint.current = null;
if (view) view.renderer.scrollLocked = false;
(ev.target as HTMLElement).style.userSelect = '';
if (instantAnnotationTarget.current) {
instantAnnotationTarget.current.style.userSelect = '';
instantAnnotationTarget.current = null;
}
};
// Drop a pending still-hold without engaging (tap released early, finger
// swiped, or the gesture was cancelled).
const cancelInstantHold = () => {
if (instantHoldTimer.current) {
clearTimeout(instantHoldTimer.current);
instantHoldTimer.current = null;
}
instantHoldTarget.current = null;
instantHoldStartClient.current = null;
instantHoldStartWindow.current = null;
};
// Begin the touch still-hold: engage the instant annotation only once the
// finger has stayed put on the text for INSTANT_HOLD_MS. preventDefault is NOT
// called here, so a tap or swipe that bows out keeps its native page-turn.
const armInstantHold = (doc: Document, ev: PointerEvent) => {
const feRect = doc.defaultView?.frameElement?.getBoundingClientRect();
instantHoldTarget.current = ev.target as HTMLElement;
instantHoldStartClient.current = { x: ev.clientX, y: ev.clientY };
instantHoldStartWindow.current = {
x: ev.clientX + (feRect?.left ?? 0),
y: ev.clientY + (feRect?.top ?? 0),
};
if (instantHoldTimer.current) clearTimeout(instantHoldTimer.current);
instantHoldTimer.current = setTimeout(() => {
instantHoldTimer.current = null;
const target = instantHoldTarget.current;
const startClient = instantHoldStartClient.current;
const start = instantHoldStartWindow.current;
const now = pointerPos.current;
cancelInstantHold();
if (!target || !startClient) return;
// Backstop the move-driven cancel: if the finger drifted during the hold,
// treat it as a swipe and bow out.
if (start && now && Math.hypot(now.x - start.x, now.y - start.y) > INSTANT_HOLD_MOVE_PX) {
handleInstantAnnotationPointerCancel();
return;
}
startInstantAnnotating(target, startClient);
}, INSTANT_HOLD_MS);
};
// While a still-hold is pending, a move past the threshold means the user is
// swiping to turn the page — cancel so the swipe isn't swallowed.
const maybeCancelInstantHoldOnMove = () => {
const start = instantHoldStartWindow.current;
const now = pointerPos.current;
if (!instantHoldTimer.current || !start || !now) return;
if (Math.hypot(now.x - start.x, now.y - start.y) > INSTANT_HOLD_MOVE_PX) {
cancelInstantHold();
handleInstantAnnotationPointerCancel();
}
};
const handlePointerDown = (doc: Document, index: number, ev: PointerEvent) => {
@@ -235,10 +315,16 @@ export const useTextSelector = (
isPointerDown.current = true;
if (isInstantAnnotationEnabled()) {
const handled = handleInstantAnnotationPointerDown(doc, index, ev);
if (handled) {
const eligible = handleInstantAnnotationPointerDown(doc, index, ev);
if (!eligible) return;
const isTouch = ev.pointerType === 'touch' || ev.pointerType === 'pen';
if (isTouch) {
// Touch: gate behind a still hold so a tap or swipe still turns the page.
armInstantHold(doc, ev);
} else {
// Mouse: a press-drag is an unambiguous highlight intent; engage at once.
ev.preventDefault();
startInstantAnnotating(ev);
startInstantAnnotating(ev.target as HTMLElement, { x: ev.clientX, y: ev.clientY });
}
}
};
@@ -252,6 +338,7 @@ export const useTextSelector = (
x: ev.clientX + (feRect?.left ?? 0),
y: ev.clientY + (feRect?.top ?? 0),
};
maybeCancelInstantHoldOnMove();
if (isInstantAnnotating.current) {
// In scroll mode, detect gesture direction before committing to annotation.
// Cancel if the gesture is along the scroll axis (vertical for normal, horizontal
@@ -263,7 +350,7 @@ export const useTextSelector = (
const viewSettings = getViewSettings(bookKey);
const isScrollGesture = viewSettings?.vertical ? dy < 3 * dx : dx < 3 * dy;
if (distance >= 10 && isScrollGesture) {
stopInstantAnnotating(ev);
stopInstantAnnotating();
handleInstantAnnotationPointerCancel();
return;
}
@@ -293,6 +380,7 @@ export const useTextSelector = (
const handleNativeTouchMove = (x: number, y: number, doc: Document) => {
const dpr = window.devicePixelRatio || 1;
pointerPos.current = { x: x / dpr, y: y / dpr };
maybeCancelInstantHoldOnMove();
const viewSettings = getViewSettings(bookKey);
const sel = doc.getSelection();
const valid = !!sel && isValidSelection(sel);
@@ -310,13 +398,17 @@ export const useTextSelector = (
}
};
const handlePointerCancel = (_doc: Document, _index: number, ev: PointerEvent) => {
const handlePointerCancel = (_doc: Document, _index: number, _ev: PointerEvent) => {
isPointerDown.current = false;
// A pending still-hold that never engaged: drop it so a swipe-takeover
// (Android fires pointercancel when the browser starts scrolling) keeps its
// native page-turn instead of being swallowed.
cancelInstantHold();
// NB: don't cancel the auto-turn here — on Android pointercancel fires mid
// edge-drag (browser takes over for scrolling), which is exactly when the
// user is dragging into the corner. Cancel only on a real release.
if (isInstantAnnotating.current) {
stopInstantAnnotating(ev);
stopInstantAnnotating();
handleInstantAnnotationPointerCancel();
}
};
@@ -404,8 +496,11 @@ export const useTextSelector = (
const handlePointerUp = async (doc: Document, index: number, ev?: PointerEvent) => {
isPointerDown.current = false;
// A tap (or a long-press shorter than the hold) that never engaged: drop the
// pending still-hold so the tap falls through to a page turn.
if (instantHoldTimer.current) cancelInstantHold();
if (isInstantAnnotating.current && ev) {
stopInstantAnnotating(ev);
stopInstantAnnotating();
const handled = await handleInstantAnnotationPointerUp(doc, index, ev);
if (handled) {
isTextSelected.current = true;
@@ -651,6 +746,7 @@ export const useTextSelector = (
return () => {
eventDispatcher.offSync('iframe-single-click', handleSingleClick);
if (autoTurnTimer.current) clearTimeout(autoTurnTimer.current);
if (instantHoldTimer.current) clearTimeout(instantHoldTimer.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);