diff --git a/apps/readest-app/.claude/memory/MEMORY.md b/apps/readest-app/.claude/memory/MEMORY.md index f0a00bd5..d719ac7f 100644 --- a/apps/readest-app/.claude/memory/MEMORY.md +++ b/apps/readest-app/.claude/memory/MEMORY.md @@ -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 (`` 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 `` + 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=required∪optional, `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 `` + 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 `` for `ACTION_PROCESS_TEXT` under targetSdk36 → dictionary apps invisible, only auto-visible browser returned (fix = add `` 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 `` 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 `` 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 `, 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 `` (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 = `` 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 ``; Firefox DOMParser → `` (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 `

`/`"` 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/` 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//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/`` 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` 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 `

`: `#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 `` 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-`` 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 ``; 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` 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 `` → 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 ``'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 ` 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 `(?` 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 `(?<=)`/`(?` is `urn:booklore:*` (root `urn:booklore:root`, books `urn:booklore:book:{bookId}`); feed `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. diff --git a/apps/readest-app/.claude/memory/instant-highlight-tap-paginate.md b/apps/readest-app/.claude/memory/instant-highlight-tap-paginate.md new file mode 100644 index 00000000..cbea40d1 --- /dev/null +++ b/apps/readest-app/.claude/memory/instant-highlight-tap-paginate.md @@ -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. diff --git a/apps/readest-app/.claude/memory/keyboard-selection-adjust-4728.md b/apps/readest-app/.claude/memory/keyboard-selection-adjust-4728.md new file mode 100644 index 00000000..f76e0e60 --- /dev/null +++ b/apps/readest-app/.claude/memory/keyboard-selection-adjust-4728.md @@ -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]]. diff --git a/apps/readest-app/.claude/memory/scrolled-header-title-center-4436.md b/apps/readest-app/.claude/memory/scrolled-header-title-center-4436.md new file mode 100644 index 00000000..33a78f69 --- /dev/null +++ b/apps/readest-app/.claude/memory/scrolled-header-title-center-4436.md @@ -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]]. diff --git a/apps/readest-app/src/__tests__/app/reader/hooks/useTextSelector-instantHold.test.ts b/apps/readest-app/src/__tests__/app/reader/hooks/useTextSelector-instantHold.test.ts new file mode 100644 index 00000000..580d2ca9 --- /dev/null +++ b/apps/readest-app/src/__tests__/app/reader/hooks/useTextSelector-instantHold.test.ts @@ -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); + }); +}); diff --git a/apps/readest-app/src/app/reader/hooks/useTextSelector.ts b/apps/readest-app/src/app/reader/hooks/useTextSelector.ts index ede8ee46..940395a8 100644 --- a/apps/readest-app/src/app/reader/hooks/useTextSelector.ts +++ b/apps/readest-app/src/app/reader/hooks/useTextSelector.ts @@ -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 }, []);