forked from akai/readest
chore: only show the current position item in TOC and update agent memories (#4665)
This commit is contained in:
@@ -64,6 +64,7 @@
|
||||
- [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
|
||||
|
||||
## Feature Notes
|
||||
- [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)
|
||||
@@ -82,6 +83,9 @@
|
||||
- [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`
|
||||
|
||||
## 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
|
||||
@@ -96,6 +100,9 @@
|
||||
- [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
|
||||
@@ -104,6 +111,7 @@
|
||||
## 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`
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
name: feedback-commit-message-english-only
|
||||
description: "Commit messages (and PR titles) must be English-only — no CJK characters, no em/en dashes"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: feedback
|
||||
originSessionId: c0199d69-f314-45ee-bf7c-867b908641cc
|
||||
---
|
||||
|
||||
Git commit messages must be **English only**: no CJK characters (no 中文/量词/example glyphs like 第一封信) and no em/en dashes (— –). Use plain ASCII punctuation (comma, colon, parentheses, `...`). The same applies to PR titles for consistency.
|
||||
|
||||
**Why:** the user (a maintainer of readest/readest) keeps the project's git history English-only and clean.
|
||||
|
||||
**How to apply:** when a fix is about Chinese/CJK text, describe the concept in English in the commit subject/body (e.g. "measure-word prose", "the classifiers for 'letter' and 'book'") instead of pasting the glyphs. Keep the concrete CJK examples and screenshots in the PR *body* / code / tests, where they aid understanding — that is fine. First seen on PR #4660 ([[txt-chapter-measure-word-4658]]), where "量词" in the subject had to be amended to "measure-word".
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
name: tts-reuse-session-mode-entry
|
||||
description: Paragraph/RSVP modes auto-sync to a live TTS session on entry (no stop/restart)
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: tts-wordsync-drift
|
||||
---
|
||||
|
||||
# Reuse the live TTS session when entering Paragraph / RSVP mode
|
||||
|
||||
Goal: TTS started in normal mode keeps playing and the visual mode syncs to it the moment you enter Paragraph or RSVP — previously you had to stop TTS and restart it inside the mode. Scope chosen: enter-from-normal only (exit-to-normal already kept playing since normal mode highlights natively from the same controller). Paused sessions engage too (synced-but-paused).
|
||||
|
||||
**Root cause of the old behavior.** Both modes engaged following only inside their `tts-playback-state: 'playing'` handler branch (`followingTtsRef`/`syncState.following = true`). Entering a mode while TTS was ALREADY playing registered the listeners but no fresh `'playing'` event fired, so following never engaged and incoming `tts-position` events were dropped (`if (!following) return`). The modes reacted to future state changes, never the current one.
|
||||
|
||||
**Fix = replay current state on mode entry (Approach A, event-bus request/response).**
|
||||
1. `TTSController.redispatchPosition()` — re-emits the current position via the existing private `#dispatchPosition` (fresh monotonic sequence so it isn't dropped as stale). Word cfi if `#wordHighlightActive`+`#lastSpeakWordRange`, else `getLastRange()` sentence; no-op if `#ttsSectionIndex < 0`.
|
||||
2. `useTTSControl`: cache last emitted state in `playbackStateRef` (set in `emitPlaybackState`); new `tts-sync-request {bookKey}` listener (registered with tts-speak/tts-stop). On a matching request with a live session (state playing|paused), it **redispatches position FIRST, then re-emits the playback state**. Order matters: RSVP's `'paused'` handler sets `following=false`, which would discard a position arriving after it; position-first lets the follower sync the word before a paused state lands. Only the entering mode listens, so order is deterministic.
|
||||
3. Paragraph (`useParagraphMode`) & RSVP (`RSVPControl`): a new effect, declared AFTER the follow-listener effect (so handlers register first in effect-declaration order), runs on entry — sets following=true, resets lastSequenceSeen to -Infinity (lets the replay through past a prior session's higher sequence), and dispatches `tts-sync-request`. Gated off on fixed-layout. No-op when no session (request returns early), so plain mode usage is unaffected.
|
||||
4. RSVP only: its `'paused'` branch now also calls `controller.setExternallyDriven(true)` so entering RSVP while TTS is paused freezes RSVP's own timer (live flow already had it true from 'playing'; matches the branch's stated intent).
|
||||
|
||||
**Tests.** `redispatchPosition` unit tests (tts-controller.test.ts: word/sentence kind, fresh increasing sequence, no-op when inactive). `useTTSControl` integration tests (useTTSControl.test.tsx: replays position-before-state, ignores other bookKey, no-op once stopped) — mock controller needs a `redispatchPosition: vi.fn()`.
|
||||
|
||||
**RSVP: don't show the get-ready countdown when reusing a TTS session** (follow-up). RSVP normally counts down `startDelaySeconds` (default 3) before its own pacing; meaningless when TTS drives. Fix: (a) `RSVPController.startCountdown` skips (instant `onComplete`) when `#externallyDriven`; (b) `setExternallyDriven(true)` also `clearCountdown()` (covers TTS engaging mid-countdown, e.g. in-RSVP audio toggle); (c) `RSVPControl.handleStart` calls `controller.setExternallyDriven(!!getViewState(bookKey)?.ttsEnabled)` right after creating the controller — BEFORE start/dialog — so the countdown never starts (flash-free) and re-enables for plain RSVP. `ttsEnabled` (readerStore viewState, per-book) is the live-session signal (true from speak success → false on stop). Set explicitly both ways since handleStart only runs when RSVP isn't already active. Two RSVP component test mocks (rsvp-tts-sync, rsvp-section-advance) needed `getViewState` added or `getViewState is not a function` cascades 9 failures. Tests: rsvp-controller.test.ts (skip when externally driven; setExternallyDriven hides in-progress countdown).
|
||||
|
||||
**RSVP entry-flow refinements (follow-ups).** (1) Don't show the "Start RSVP Reading" dialog when reusing a TTS session — in `handleStart`'s `handleStartChoice`, if a session is active, `startFromCurrentPosition()` + setIsActive immediately (the engage-on-entry sync overrides the start position anyway, so prompting is pointless). (2) When the TTS session **stops**, RSVP must freeze, not resume its own pacing — the `'stopped'` branch of `handlePlaybackState` now calls `controller.pause()` BEFORE `setExternallyDriven(false)` (the latter calls `scheduleNextWord()` when `playing`, which was restarting RSVP's timer — the "it still runs" bug). (3) Robust session detection: a `ttsEnabled` skew (store flag false while the replay's `playbackStateRef` was playing/paused) caused a countdown to flash then clear (startCountdown ran, then the replay's `setExternallyDriven(true)` cleared it). Fixed with an always-on `tts-playback-state` listener in RSVPControl maintaining `ttsSessionActiveRef` (seeded from `getViewState().ttsEnabled` at mount) — it mirrors the EXACT signal the replay keys off, so the countdown/dialog gate can never disagree with the replay. `handleStart` reads that ref, not the store. Tests: rsvp-tts-sync.test.tsx "pauses RSVP … when the TTS session stops" (mock controller needs `pause`).
|
||||
|
||||
**Env gotcha that blocks live re-verification:** the book open in TWO tabs (user session + automation tab) floods `NoModificationAllowedError: createSyncAccessHandle … another open Access Handle` (OPFS), which intermittently fails TTS section-doc init → `view.tts` null, `ttsEnabled` false, TTS won't start. Close the duplicate tab for a clean single-tab session before live TTS testing.
|
||||
|
||||
**Live-verified (dev-web, Chrome): BOTH modes.** Paragraph: start TTS in normal mode (select word → popup headphone) → View menu → Paragraph Mode (or Shift+P): focus overlay opens already "🔊 Following audio" on the spoken paragraph, follows word-by-word; bar shows "Pause audio" (engaged). RSVP: with TTS playing, Shift+V → start dialog → From Current Page → RSVP overlay opens showing the spoken word ("As") with **no countdown** and "🔊 Following audio". Gotchas: claude-in-chrome viewport auto-resizes (use element refs from read_page, not coordinates); focus-mode overlay swallows Shift+P/Escape (exit via the bar's "Exit Paragraph Mode" ref); book open in two tabs → OPFS `NoModificationAllowedError` + flaky `view.tts`. Related: [[tts-sync-paragraph-rsvp-3235]] [[edge-tts-word-highlighting-4017]] [[tts-word-highlight-singletextnode-drift]].
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
name: tts-start-from-selection
|
||||
description: TTS started-from-word-selection bugs — next-sentence start + lingering selection highlight
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: tts-wordsync-drift
|
||||
---
|
||||
|
||||
# TTS "Speak from selection" — started at next sentence + left the word selected
|
||||
|
||||
Workflow: select a word → annotation popup → headphone (TTS) icon. Path: `Annotator.handleSpeakText` → `eventDispatcher.dispatch('tts-speak', {range: selection.range, index})` → `useTTSControl.handleTTSSpeak` → `view.tts?.from(ttsFromRange)` (foliate). Quick-action (oneTime) uses `genSSMLRaw(range.toString())` instead.
|
||||
|
||||
## Bug 1 — starts from the NEXT sentence (foliate `from()`)
|
||||
`packages/foliate-js/tts.js` `from(range)` picked the mark via "first mark whose start is at/after the selection":
|
||||
```js
|
||||
if (range.compareBoundaryPoints(Range.START_TO_START, range_) <= 0) { mark = name; break }
|
||||
```
|
||||
For a selection that is NOT the sentence's first word, `selection.start > mark.start` for the containing sentence → skipped → picked the NEXT sentence's mark. (First word worked; that's why it was intermittent.) Fix = pick the LAST mark starting at/before the selection (the containing sentence):
|
||||
```js
|
||||
for (const [name, range_] of this.#ranges.entries()) {
|
||||
if (range.compareBoundaryPoints(Range.START_TO_START, range_) < 0) break
|
||||
mark = name
|
||||
}
|
||||
```
|
||||
`compareBoundaryPoints(START_TO_START, range_)` compares selection.start vs mark.start: 1=after, 0=equal, -1=before. Block selection above (`END_TO_START <= 0`) was already correct. `from()` returns SSML from the chosen mark to END of block (strips earlier sentences, keeps later ones) — assert start-of-text, not absence of later text.
|
||||
|
||||
## Bug 2 — selection stayed highlighted after TTS started
|
||||
`handleSpeakText` never cleared the selection. Fix = in `Annotator.handleSpeakText`, pass `selection.range.cloneRange()` (clone so clearing the live selection can't disturb the start range) and call `view?.deselect()` right after dispatch. Do it in the Annotator (immediate), NOT in `handleTTSSpeak` (which only reaches the deselect after Edge init → laggy). `selection.range` is the LIVE range (`sel.getRangeAt(0)`, useTextSelector makeSelection). `view.deselect()` = `getSelection().removeAllRanges()` on every section doc (foliate view.js); handleStop already calls it on stop.
|
||||
|
||||
## Testing
|
||||
- foliate `from()` unit-tested in `src/__tests__/document/tts.test.ts` (describe 'from() selection start'): mid-sentence / first-word / last-sentence cases. Build a word `Range` in a single-`<span>` paragraph and assert `stripTags(tts.from(range))` start.
|
||||
- **jsdom gap:** `from()` calls `CSS.escape` (mark[name=…]); jsdom has no global `CSS`. `start()` tests dodge it (lastMark null → `#getMarkElement` early-returns). Added a standard `CSS.escape` polyfill to `vitest.setup.ts`.
|
||||
- Live verified (dev-web, Chrome): select "fraught" mid-sentence-1 → highlights `Frowning·Li·Mutian's·mind·was·fraught·with·worries·At…` (started at sentence head, continued past) AND all section docs `getSelection()===''`. Dev server DID recompile the foliate workspace edit on reload (no manual restart needed this time, contra older notes). OPFS `NoModificationAllowedError` spam = book open in 2 tabs (user session + mine) → flaky `view.tts` (nulls after playback). Related: [[tts-word-highlight-singletextnode-drift]] [[edge-tts-word-highlighting-4017]].
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
name: tts-word-highlight-singletextnode-drift
|
||||
description: Edge TTS word highlight drifts/garbled on middle sentences of single-text-node paragraphs
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: tts-wordsync-drift
|
||||
---
|
||||
|
||||
# Edge TTS word highlight drift — `rangeTextExcludingInert` ignored range offsets (single text node)
|
||||
|
||||
**Symptom.** In Edge TTS word-by-word mode, the 1st sentence of a paragraph highlights correctly, but the **2nd/3rd (middle) sentences are "skipped"** — actually garbled: word highlights land on shifted fragments (`spoken "Those"` → `highlighted "if th"`, `"they're"`→`"y're on s"`). Last sentence works again. Book: "The Mirror Legacy" (each `<p>` wraps all text in ONE `<span>` = one text node).
|
||||
|
||||
**Root cause.** `rangeTextExcludingInert` (`src/services/tts/wordHighlight.ts`) had a single-text-node fast path `if (root.nodeType===TEXT_NODE) return root.data` that **ignored `base.startOffset`/`base.endOffset`** — returning the WHOLE paragraph text. `computeWordOffsets` then matched the sentence's Edge boundary words against the full paragraph (offsets relative to paragraph start), but `getTextSubRange` (the sibling that paints the highlight) DOES honor offsets (slices `[startOffset,endOffset]`). The mismatch shifts every word by the sentence's start offset within the node → drift.
|
||||
|
||||
**Why only middle sentences.** `getBlocks`/segmenter ranges: 1st sentence's range starts at the `<p>`'s leading-whitespace text node, last sentence's extends into trailing whitespace → both span MULTIPLE text nodes → `commonAncestorContainer` is `P`/`BODY` → multi-node path (correct). Middle sentences sit entirely inside the single `<span>` text node → `commonAncestorContainer` is that `TEXT_NODE` → buggy path. (Verified live: entry 0 cac=`P` ✓, entries 1&2 cac=`TEXT_NODE` ✗ returned 332-char full para, entry 3 cac=`BODY` ✓.)
|
||||
|
||||
**Fix (PR/commit on `dev`).** In the TEXT_NODE branch: `return (root as Text).data.slice(base.startOffset, base.endOffset)` (keep the `isInertText(root)` → `''` guard first). Mirrors `getTextSubRange`'s single-node handling.
|
||||
|
||||
**Scope.** Edge-only. `rangeTextExcludingInert` is used ONLY by `TTSController.prepareSpeakWords`, called ONLY by `EdgeTTSClient.#startWordTracking`. WebSpeech & Native report `supportsWordBoundaries()=false`, never compute word offsets; they sentence-highlight via `setMark`→`#getHighlighter` using the Range directly (unaffected). The `words.length===0` fallback also highlights via the Range, not this fn.
|
||||
|
||||
**Dev debuggability added (same change).** Dev-only trace in `prepareSpeakWords`: `console.log('[TTS] word-sync', {sentence, words:[{spoken,highlighted}]})`, gated by `process.env.NODE_ENV!=='production'` (DCE'd in prod). One log per sentence; a drift shows as `{spoken:'Those', highlighted:'if th'}`, an empty `words` shows missing boundaries. User chose gated-console-logging + dev-build-only over a window inspector / settings toggle.
|
||||
|
||||
**Chrome verify recipe.** Direct browser WSS to `speech.platform.bing.com` is blocked from localhost (Chrome UA/origin) → `wserr`; app's Edge path still works in-tab (authenticated `/api/tts/edge` https proxy). Start TTS at a chosen paragraph via **select a word → popup toolbar headphone icon** (`tts.from(range)`), not the global Speak (which starts at page top). Overlayer highlight calls are the ground truth: patch `content.overlayer.add/remove` (`fv.renderer.getContents()`) to log painted ranges — garbled fragments are unmistakable there. Related: [[edge-tts-word-highlighting-4017]] [[tts-sync-paragraph-rsvp-3235]].
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: txt-chapter-measure-word-4658
|
||||
description: TXT import detected 第一封信/第四本书 (量词 prose) as chapters; split units into strong vs weak (separator-required)
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: c0199d69-f314-45ee-bf7c-867b908641cc
|
||||
---
|
||||
|
||||
#4658 (目录检测优化): TXT→EPUB chapter detection (`src/utils/txt.ts` `createChapterRegexps('zh')`, first regex) wrongly surfaced prose lines as TOC entries:
|
||||
- `第一封信。` / `第七封信` (the Nth *letter*) — measure word `封` was a chapter unit.
|
||||
- `第四本书记载着锤法,来自孙家,可惜对秦铭用处不大了…` (the fourth *book* + full sentence) — measure word `本`.
|
||||
|
||||
Root cause: the unit class `[章卷节回讲篇封本册部话]` treated 量词 (measure words) the same as real chapter units, and allowed a title to attach DIRECTLY after the unit (`封信`, `本书`).
|
||||
|
||||
Fix = split units into two tiers in the regex:
|
||||
- **Strong** `[章节回讲篇话]` — may carry an attached title (`第一章天地初开`), unchanged: `(?:[::、 \(\)0-9]*[^\n-]{0,36})`.
|
||||
- **Weak / 量词** `[卷本册部封]` — title MUST be introduced by a separator or the line ends, i.e. the char after the unit must be `[::、 \(\)]` (or boundary), never a bare noun: `(?:[::、 \(\)][::、 \(\)0-9]*[^\n-]{0,36})?`.
|
||||
|
||||
So `第一封 致读者` / `第一本 标题` / `第二部 中篇` / `第一卷 起始篇` / standalone `第一本\n` still match (existing tests rely on this); `第一封信` / `第四本书...` do not. Weak set = the same units used by the `isVolume` regex (`卷|本|册|部`) plus `封`. `(?!\S)` end-anchor does the rejection by backtracking when a noun follows. Tests in `__tests__/utils/txt.test.ts` ("measure-word false positives", two e2e). Not touched: 节 (kept strong despite 第三节课 risk — not in scope).
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
name: webtoon-mode-3647
|
||||
description: "Webtoon Mode (#3647) — seamless no-gap scrolled reading for image books; fixed-layout scroll-mode facts + submodule PR flow"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 4df24b79-87bd-4089-be36-fe30a0ba0a77
|
||||
---
|
||||
|
||||
Webtoon Mode (#3647): toggle for image books (CBZ/manga/PDF/fixed-layout EPUB) = scrolled flow + 0 page gap + fit-width. PRs open (not merged): **readest/readest#4662** + **readest/foliate-js#30** (submodule). Branched from origin/main at 1faa931a0.
|
||||
|
||||
**Key architecture fact (non-obvious):** the foliate-js **fixed-layout scrolled renderer is fit-width by construction** — `#renderScrollMode` scales each page `hostWidth/vpWidth * scaleFactor`; it **ignores the `zoom` attribute** (fit-page/fit-width only matter in paginated/fxl non-scroll). So in scroll mode the ONLY thing that breaks fit-width is `scale-factor ≠ 100` (i.e. `zoomLevel ≠ 100`). Webtoon Mode forces `zoomLevel=100`. The 4px inter-page gap was the hardcoded `margin: 4px 0` on `.scroll-page` (`packages/foliate-js/fixed-layout.js`).
|
||||
|
||||
**Implementation:** new `scroll-gap` observed attribute → host `--scroll-page-gap` var → `.scroll-page { margin: var(--scroll-page-gap, 4px) 0 }` (backward-compat default; capture/restore scroll anchor in the handler so toggling doesn't jump). Pure exports `scrollGapToCss` (foliate) + `getScrollGapAttr` (`src/utils/webtoon.ts`, `true→'0'`/`false→'4'`). Boolean `webtoonMode` in `BookLayout` (global+per-book, `skipGlobal=false`). ViewMenu toggle in the `pre-paginated` block reuses the existing `scrolled`+`zoomLevel` effects. **Leaving scrolled clears webtoonMode in TWO places:** the ViewMenu `isScrolledMode` effect AND `useBookShortcuts.ts toggleScrollMode` (Shift+J — the final review caught this; ControlPanel scrolled toggle is `disabled` for fixed-layout). Host CSS custom props inherit across the shadow boundary (cf. `--scroll-bg-color` set on `:root` in globals.css). See [[edge-tts-word-highlighting-4017]] for the overlay-in-shadow-root pattern.
|
||||
|
||||
**Submodule PR-flow gotcha:** `pnpm worktree:new` sets the worktree's foliate-js submodule `origin` to a **local path** (`.git/modules/...`), and the submodule is at **detached HEAD**. To publish a foliate-js commit you must push the SHA directly to the GitHub fork: `git push git@github.com:readest/foliate-js.git HEAD:refs/heads/<branch>`, then `gh pr create --repo readest/foliate-js`. The fork uses a PR flow (#28/#29/#30), so do a **separate foliate-js PR first**; the readest pointer resolves by SHA once the commit is on the fork (even pre-merge). If the foliate PR squash-merges (new SHA), re-bump the readest pointer.
|
||||
@@ -175,7 +175,7 @@ describe('CurrentPositionRow', () => {
|
||||
it('indents according to depth (matching a child of the active item)', () => {
|
||||
const { container } = render(<CurrentPositionRow depth={2} page={1} />);
|
||||
const row = container.querySelector('[role="treeitem"]') as HTMLElement;
|
||||
expect(row.style.paddingInlineStart).toBe(`${(2 + 1) * 12}px`);
|
||||
expect(row.style.paddingInlineStart).toBe(`${2 * 12 + 8}px`);
|
||||
});
|
||||
|
||||
it('exposes the page number in its aria-label', () => {
|
||||
|
||||
@@ -118,10 +118,7 @@ const TOCItemView = React.memo<{
|
||||
aria-selected={isActive ? 'true' : 'false'}
|
||||
data-href={item.href ? getContentMd5(item.href) : undefined}
|
||||
className={clsx(
|
||||
'flex w-full cursor-pointer items-center rounded-md py-4 sm:py-2',
|
||||
isActive
|
||||
? 'text-bold-in-eink sm:bg-base-300/65 sm:hover:bg-base-300/75 sm:text-base-content text-blue-500'
|
||||
: 'sm:hover:bg-base-300/75',
|
||||
'flex w-full cursor-pointer items-center rounded-md py-4 sm:py-2 sm:hover:bg-base-300/75',
|
||||
)}
|
||||
style={{
|
||||
height: itemSize ? `${itemSize}px` : 'auto',
|
||||
@@ -240,7 +237,7 @@ export const CurrentPositionRow: React.FC<{
|
||||
'text-bold-in-eink sm:bg-base-300/65 sm:text-base-content text-blue-500',
|
||||
onClick && 'cursor-pointer sm:hover:bg-base-300/75',
|
||||
)}
|
||||
style={{ paddingInlineStart: `${(depth + 1) * 12}px` }}
|
||||
style={{ paddingInlineStart: `${depth * 12 + 8}px` }}
|
||||
>
|
||||
<FiBookOpen className='h-4 w-4 shrink-0' aria-hidden='true' />
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user