From 2ade769956c94b6f697fbe1901cd3fab9efdf1c7 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Thu, 11 Jun 2026 00:40:59 +0800 Subject: [PATCH] feat(toc): show current reading page under the active item (#4513) (#4525) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Insert a "Current position" row in the TOC sidebar directly under the highlighted section, indented one level deeper, with an open-book icon and the live reading page number. Clicking it navigates to the exact current reading location (progress.location) — distinct from the section header, which jumps to the section start. Implemented via a pure buildTOCDisplayItems() helper that injects the synthetic row after the active item, keeping the active item's index stable so the existing TOC auto-scroll logic stays untouched. The page number uses the same muted color as the other rows. Also fills in the missing "File Path" i18n translations across all locales (surfaced by i18n:extract) and records project memory notes. Closes #4513. Co-authored-by: Claude Opus 4.8 (1M context) --- apps/readest-app/.claude/memory/MEMORY.md | 2 + .../memory/dependabot-pnpm-overrides.md | 21 ++++ .../memory/toc-current-position-row.md | 25 ++++ .../public/locales/ar/translation.json | 3 +- .../public/locales/bn/translation.json | 3 +- .../public/locales/bo/translation.json | 3 +- .../public/locales/de/translation.json | 3 +- .../public/locales/el/translation.json | 3 +- .../public/locales/es/translation.json | 3 +- .../public/locales/fa/translation.json | 3 +- .../public/locales/fr/translation.json | 3 +- .../public/locales/he/translation.json | 3 +- .../public/locales/hi/translation.json | 3 +- .../public/locales/hu/translation.json | 3 +- .../public/locales/id/translation.json | 3 +- .../public/locales/it/translation.json | 3 +- .../public/locales/ja/translation.json | 3 +- .../public/locales/ko/translation.json | 3 +- .../public/locales/ms/translation.json | 3 +- .../public/locales/nl/translation.json | 3 +- .../public/locales/pl/translation.json | 3 +- .../public/locales/pt-BR/translation.json | 3 +- .../public/locales/pt/translation.json | 3 +- .../public/locales/ro/translation.json | 3 +- .../public/locales/ru/translation.json | 3 +- .../public/locales/si/translation.json | 3 +- .../public/locales/sl/translation.json | 3 +- .../public/locales/sv/translation.json | 3 +- .../public/locales/ta/translation.json | 3 +- .../public/locales/th/translation.json | 3 +- .../public/locales/tr/translation.json | 3 +- .../public/locales/uk/translation.json | 3 +- .../public/locales/uz/translation.json | 3 +- .../public/locales/vi/translation.json | 3 +- .../public/locales/zh-CN/translation.json | 3 +- .../public/locales/zh-TW/translation.json | 3 +- .../src/__tests__/components/TOCItem.test.tsx | 116 +++++++++++++++++- .../app/reader/components/sidebar/TOCItem.tsx | 91 ++++++++++++++ .../app/reader/components/sidebar/TOCView.tsx | 54 ++++++-- 39 files changed, 362 insertions(+), 46 deletions(-) create mode 100644 apps/readest-app/.claude/memory/dependabot-pnpm-overrides.md create mode 100644 apps/readest-app/.claude/memory/toc-current-position-row.md diff --git a/apps/readest-app/.claude/memory/MEMORY.md b/apps/readest-app/.claude/memory/MEMORY.md index 6741cb20..8f50e525 100644 --- a/apps/readest-app/.claude/memory/MEMORY.md +++ b/apps/readest-app/.claude/memory/MEMORY.md @@ -13,6 +13,7 @@ - [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 @@ -85,3 +86,4 @@ - [en/translation.json holds ONLY plural variants + proper nouns](feedback_en_plurals_manual.md) — non-plural strings stay out (defaultValue: key is the en source); plural strings (`_('...', { count })`) need hand-added `_one`/`_other` entries or the singular renders as "1 days" - [Never push on every change](feedback_dont_push_every_change.md) — hold pushes during active bug iteration; commit locally only until user confirms or work hits a clean done-state - [No test seams in production code](feedback_no_test_seams_in_prod.md) — production must never import or call `__reset*ForTests`; cross-module test resets belong in the test file's beforeEach/afterEach +- [Dependabot transitive fixes](dependabot-pnpm-overrides.md) — pin patched min-version in `pnpm-workspace.yaml` `overrides:` (NOT package.json `pnpm.overrides`, which pnpm 9+ ignores); watch for existing too-low pins; alert#≠issue# so no `Closes #` (PR #4523) diff --git a/apps/readest-app/.claude/memory/dependabot-pnpm-overrides.md b/apps/readest-app/.claude/memory/dependabot-pnpm-overrides.md new file mode 100644 index 00000000..03553bfa --- /dev/null +++ b/apps/readest-app/.claude/memory/dependabot-pnpm-overrides.md @@ -0,0 +1,21 @@ +--- +name: dependabot-pnpm-overrides +description: How to fix transitive-dependency Dependabot/CVE alerts in the readest monorepo (pnpm overrides location + style) +metadata: + node_type: memory + type: project + originSessionId: cdc9c728-a2c7-4a9e-b87a-44046560a4fa +--- + +Transitive npm security advisories (Dependabot alerts against `pnpm-lock.yaml`) are fixed by pinning a **minimum patched version** in the `overrides:` block of **`pnpm-workspace.yaml`** at the monorepo root — NOT `package.json`'s `pnpm.overrides`. + +**Why:** the repo uses pnpm 9+ (`pnpm@11.x`), which reads `overrides`/`patchedDependencies`/`catalog` from `pnpm-workspace.yaml`. A `pnpm.overrides` block added to root `package.json` is silently ignored — `pnpm install` runs fast and the lockfile doesn't change. There is already a long list of security pins in that `overrides:` block (glob, undici, qs, body-parser, etc.). + +**How to apply:** +1. `gh api repos/readest/readest/dependabot/alerts/` → get package + `first_patched_version` + vulnerable range. +2. Confirm parent ranges allow the patch (`cat node_modules/.pnpm/@*/.../package.json | grep '""'`). +3. Add/raise the entry in `pnpm-workspace.yaml` `overrides:` in the existing style: `: '>='` (e.g. `shell-quote: '>=1.8.4'`). **Check for an existing too-low pin** — e.g. `qs: '>=6.14.2'` still allowed the vulnerable 6.15.1; had to raise to `'>=6.15.2'`. +4. `pnpm install --lockfile-only` then `pnpm install`. Verify with `pnpm why -r ` (should show only the patched version). Stale dirs may linger in `node_modules/.pnpm` but are harmless if the lockfile has zero refs to the old version. +5. Dependabot **alert numbers are not GitHub issue numbers** — don't use `Closes #N`; alerts auto-dismiss when the vulnerable version leaves the default-branch lockfile. Reference the `/security/dependabot/` URLs in the PR body instead. + +First done in PR #4523 (shell-quote 1.8.4 / GHSA-w7jw-789q-3m8p, qs 6.15.2 / GHSA-q8mj-m7cp-5q26). Diff stays scoped to just the bumped packages; prefer this over `pnpm update ` which incidentally refreshes unrelated in-range patches (e.g. react-is). See [[feedback_pr_new_branch]] [[feedback_use_worktree]]. diff --git a/apps/readest-app/.claude/memory/toc-current-position-row.md b/apps/readest-app/.claude/memory/toc-current-position-row.md new file mode 100644 index 00000000..64e7977a --- /dev/null +++ b/apps/readest-app/.claude/memory/toc-current-position-row.md @@ -0,0 +1,25 @@ +--- +name: toc-current-position-row +description: "TOC \"Current position\" row showing the live reading page under the active item" +metadata: + node_type: memory + type: project + originSessionId: 7c3a3d3c-86fe-4f6a-b539-8bf8052d68bd +--- + +The TOC sidebar shows a synthetic "Current position" row (open-book `FiBookOpen` icon + `_('Current position')` label + live page number) directly under the highlighted/active TOC item, indented one level deeper. The row is **clickable** → navigates to `progress.location` (the exact current reading CFI, more precise than the section header which goes to section start); passes `onClick` from TOCView's `handleCurrentPositionClick` which dispatches `navigate` + `view.goTo(location)` (mirrors `BooknoteItem`). Page number uses the ordinary muted `text-base-content/50` (NOT the blue highlight — explicit user ask); icon + label stay highlighted. + +`'Current position'` was ALREADY an existing i18n key (used by `src/app/reader/hooks/kosyncPreview.ts`), translated in all 33 locales — reusing it needed no new translation. + +Key files: +- `src/app/reader/components/sidebar/TOCItem.tsx` — `CurrentPositionRow` component + pure helpers `buildTOCDisplayItems(flatItems, activeHref, currentPage)`, `isCurrentPositionItem`, types `CurrentPositionItem`/`TOCDisplayItem`. +- `src/app/reader/components/sidebar/TOCView.tsx` — `displayItems = useMemo(buildTOCDisplayItems(flatItems, activeHref, progress?.page))`; Virtuoso renders `displayItems` (totalCount + itemContent discriminates on `isCurrentPositionItem`). + +Design facts: +- Page number = `progress.page` (already `pageInfo.current + 1`, resolves fixed-layout vs reflowable). Same scale as the per-item page numbers (`item.location.current + 1` / `item.index + 1`). The active section header shows its START page; the current-position row shows the LIVE page, so the gap = how far into the section you are. +- Indent: synthetic row depth = `activeItem.depth + 1`, rendered with the same `(depth + 1) * 12` formula → matches a child item's indent. +- Highlight reuses the active-item classes (`text-bold-in-eink sm:bg-base-300/65 sm:text-base-content text-blue-500`) so eink behavior is already covered. Icon inherits `currentColor`. + +**INVARIANT (do not break):** the row is inserted *after* the active item, so the active item's index in `displayItems` equals its index in `flatItems`. This is why the auto-scroll effects (which still `flatItems.findIndex(...).scrollToIndex`) keep working untouched. If you ever insert anything BEFORE the active item, the scroll-to-active index math breaks. See [[toc-expand-and-autoscroll]] and [[booknote-view-autoscroll-4352]]. + +Tests: `src/__tests__/components/TOCItem.test.tsx` (`CurrentPositionRow` + `buildTOCDisplayItems` suites). 'Current position' is a non-plural string → no en/translation.json entry needed ([[feedback_en_plurals_manual]]). diff --git a/apps/readest-app/public/locales/ar/translation.json b/apps/readest-app/public/locales/ar/translation.json index 1e2e31b7..79228e91 100644 --- a/apps/readest-app/public/locales/ar/translation.json +++ b/apps/readest-app/public/locales/ar/translation.json @@ -1831,5 +1831,6 @@ "Book file is not available locally": "ملف الكتاب غير متوفر محليًا", "Failed to send book": "فشل إرسال الكتاب", "Send": "إرسال", - "Highlight Current Sentence": "تمييز الجملة الحالية" + "Highlight Current Sentence": "تمييز الجملة الحالية", + "File Path": "مسار الملف" } diff --git a/apps/readest-app/public/locales/bn/translation.json b/apps/readest-app/public/locales/bn/translation.json index 775027fc..5d4b168d 100644 --- a/apps/readest-app/public/locales/bn/translation.json +++ b/apps/readest-app/public/locales/bn/translation.json @@ -1699,5 +1699,6 @@ "Book file is not available locally": "বইয়ের ফাইলটি স্থানীয়ভাবে উপলব্ধ নয়", "Failed to send book": "বই পাঠাতে ব্যর্থ হয়েছে", "Send": "পাঠান", - "Highlight Current Sentence": "বর্তমান বাক্য হাইলাইট করুন" + "Highlight Current Sentence": "বর্তমান বাক্য হাইলাইট করুন", + "File Path": "ফাইলের পথ" } diff --git a/apps/readest-app/public/locales/bo/translation.json b/apps/readest-app/public/locales/bo/translation.json index 6d9805fa..2cd073f9 100644 --- a/apps/readest-app/public/locales/bo/translation.json +++ b/apps/readest-app/public/locales/bo/translation.json @@ -1666,5 +1666,6 @@ "Book file is not available locally": "དེབ་ཀྱི་ཡིག་ཆ་དེ་ས་གནས་སུ་མི་འདུག", "Failed to send book": "དེབ་སྐུར་གཏོང་མ་ཐུབ།", "Send": "སྐུར་གཏོང་།", - "Highlight Current Sentence": "ད་ལྟའི་ཚིག་གྲུབ་ལ་འོག་ཐིག་གཏོང་།" + "Highlight Current Sentence": "ད་ལྟའི་ཚིག་གྲུབ་ལ་འོག་ཐིག་གཏོང་།", + "File Path": "ཡིག་ཆའི་ལམ་ཐིག" } diff --git a/apps/readest-app/public/locales/de/translation.json b/apps/readest-app/public/locales/de/translation.json index 0d745095..be63055c 100644 --- a/apps/readest-app/public/locales/de/translation.json +++ b/apps/readest-app/public/locales/de/translation.json @@ -1699,5 +1699,6 @@ "Book file is not available locally": "Buchdatei ist nicht lokal verfügbar", "Failed to send book": "Senden des Buchs fehlgeschlagen", "Send": "Senden", - "Highlight Current Sentence": "Aktuellen Satz hervorheben" + "Highlight Current Sentence": "Aktuellen Satz hervorheben", + "File Path": "Dateipfad" } diff --git a/apps/readest-app/public/locales/el/translation.json b/apps/readest-app/public/locales/el/translation.json index 68799d54..0cdcf73f 100644 --- a/apps/readest-app/public/locales/el/translation.json +++ b/apps/readest-app/public/locales/el/translation.json @@ -1699,5 +1699,6 @@ "Book file is not available locally": "Το αρχείο του βιβλίου δεν είναι διαθέσιμο τοπικά", "Failed to send book": "Αποτυχία αποστολής βιβλίου", "Send": "Αποστολή", - "Highlight Current Sentence": "Επισήμανση τρέχουσας πρότασης" + "Highlight Current Sentence": "Επισήμανση τρέχουσας πρότασης", + "File Path": "Διαδρομή αρχείου" } diff --git a/apps/readest-app/public/locales/es/translation.json b/apps/readest-app/public/locales/es/translation.json index bf5951f7..30031a69 100644 --- a/apps/readest-app/public/locales/es/translation.json +++ b/apps/readest-app/public/locales/es/translation.json @@ -1732,5 +1732,6 @@ "Book file is not available locally": "El archivo del libro no está disponible localmente", "Failed to send book": "No se pudo enviar el libro", "Send": "Enviar", - "Highlight Current Sentence": "Resaltar la oración actual" + "Highlight Current Sentence": "Resaltar la oración actual", + "File Path": "Ruta del archivo" } diff --git a/apps/readest-app/public/locales/fa/translation.json b/apps/readest-app/public/locales/fa/translation.json index 9213644a..8903eed2 100644 --- a/apps/readest-app/public/locales/fa/translation.json +++ b/apps/readest-app/public/locales/fa/translation.json @@ -1699,5 +1699,6 @@ "Book file is not available locally": "فایل کتاب به صورت محلی در دسترس نیست", "Failed to send book": "ارسال کتاب ناموفق بود", "Send": "ارسال", - "Highlight Current Sentence": "برجسته‌سازی جملهٔ فعلی" + "Highlight Current Sentence": "برجسته‌سازی جملهٔ فعلی", + "File Path": "مسیر فایل" } diff --git a/apps/readest-app/public/locales/fr/translation.json b/apps/readest-app/public/locales/fr/translation.json index 15b495a0..3619afd7 100644 --- a/apps/readest-app/public/locales/fr/translation.json +++ b/apps/readest-app/public/locales/fr/translation.json @@ -1732,5 +1732,6 @@ "Book file is not available locally": "Le fichier du livre n'est pas disponible localement", "Failed to send book": "Échec de l'envoi du livre", "Send": "Envoyer", - "Highlight Current Sentence": "Surligner la phrase actuelle" + "Highlight Current Sentence": "Surligner la phrase actuelle", + "File Path": "Chemin du fichier" } diff --git a/apps/readest-app/public/locales/he/translation.json b/apps/readest-app/public/locales/he/translation.json index 505c5c5f..232f39f0 100644 --- a/apps/readest-app/public/locales/he/translation.json +++ b/apps/readest-app/public/locales/he/translation.json @@ -1732,5 +1732,6 @@ "Book file is not available locally": "קובץ הספר אינו זמין באופן מקומי", "Failed to send book": "שליחת הספר נכשלה", "Send": "שליחה", - "Highlight Current Sentence": "הדגשת המשפט הנוכחי" + "Highlight Current Sentence": "הדגשת המשפט הנוכחי", + "File Path": "נתיב הקובץ" } diff --git a/apps/readest-app/public/locales/hi/translation.json b/apps/readest-app/public/locales/hi/translation.json index 9a1bcfe1..ec8babbe 100644 --- a/apps/readest-app/public/locales/hi/translation.json +++ b/apps/readest-app/public/locales/hi/translation.json @@ -1699,5 +1699,6 @@ "Book file is not available locally": "पुस्तक फ़ाइल स्थानीय रूप से उपलब्ध नहीं है", "Failed to send book": "पुस्तक भेजने में विफल", "Send": "भेजें", - "Highlight Current Sentence": "वर्तमान वाक्य को हाइलाइट करें" + "Highlight Current Sentence": "वर्तमान वाक्य को हाइलाइट करें", + "File Path": "फाइल पथ" } diff --git a/apps/readest-app/public/locales/hu/translation.json b/apps/readest-app/public/locales/hu/translation.json index 80446ac7..1c075308 100644 --- a/apps/readest-app/public/locales/hu/translation.json +++ b/apps/readest-app/public/locales/hu/translation.json @@ -1699,5 +1699,6 @@ "Book file is not available locally": "A könyvfájl nem érhető el helyileg", "Failed to send book": "A könyv küldése sikertelen", "Send": "Küldés", - "Highlight Current Sentence": "Aktuális mondat kiemelése" + "Highlight Current Sentence": "Aktuális mondat kiemelése", + "File Path": "Fájl elérési útja" } diff --git a/apps/readest-app/public/locales/id/translation.json b/apps/readest-app/public/locales/id/translation.json index 6a8cf2b3..ad7aa46b 100644 --- a/apps/readest-app/public/locales/id/translation.json +++ b/apps/readest-app/public/locales/id/translation.json @@ -1666,5 +1666,6 @@ "Book file is not available locally": "Berkas buku tidak tersedia secara lokal", "Failed to send book": "Gagal mengirim buku", "Send": "Kirim", - "Highlight Current Sentence": "Sorot kalimat saat ini" + "Highlight Current Sentence": "Sorot kalimat saat ini", + "File Path": "Jalur File" } diff --git a/apps/readest-app/public/locales/it/translation.json b/apps/readest-app/public/locales/it/translation.json index f7cb01e1..02e6e3e0 100644 --- a/apps/readest-app/public/locales/it/translation.json +++ b/apps/readest-app/public/locales/it/translation.json @@ -1732,5 +1732,6 @@ "Book file is not available locally": "Il file del libro non è disponibile localmente", "Failed to send book": "Invio del libro non riuscito", "Send": "Invia", - "Highlight Current Sentence": "Evidenzia la frase corrente" + "Highlight Current Sentence": "Evidenzia la frase corrente", + "File Path": "Percorso del file" } diff --git a/apps/readest-app/public/locales/ja/translation.json b/apps/readest-app/public/locales/ja/translation.json index 595f6048..a41fe8cd 100644 --- a/apps/readest-app/public/locales/ja/translation.json +++ b/apps/readest-app/public/locales/ja/translation.json @@ -1666,5 +1666,6 @@ "Book file is not available locally": "書籍ファイルがローカルに存在しません", "Failed to send book": "書籍の送信に失敗しました", "Send": "送信", - "Highlight Current Sentence": "現在の文をハイライト" + "Highlight Current Sentence": "現在の文をハイライト", + "File Path": "ファイルパス" } diff --git a/apps/readest-app/public/locales/ko/translation.json b/apps/readest-app/public/locales/ko/translation.json index ee849e5f..eeeff46c 100644 --- a/apps/readest-app/public/locales/ko/translation.json +++ b/apps/readest-app/public/locales/ko/translation.json @@ -1666,5 +1666,6 @@ "Book file is not available locally": "책 파일을 로컬에서 사용할 수 없습니다", "Failed to send book": "책 보내기에 실패했습니다", "Send": "보내기", - "Highlight Current Sentence": "현재 문장 강조" + "Highlight Current Sentence": "현재 문장 강조", + "File Path": "파일 경로" } diff --git a/apps/readest-app/public/locales/ms/translation.json b/apps/readest-app/public/locales/ms/translation.json index 59eb94b7..b82ba499 100644 --- a/apps/readest-app/public/locales/ms/translation.json +++ b/apps/readest-app/public/locales/ms/translation.json @@ -1666,5 +1666,6 @@ "Book file is not available locally": "Fail buku tidak tersedia secara setempat", "Failed to send book": "Gagal menghantar buku", "Send": "Hantar", - "Highlight Current Sentence": "Serlahkan ayat semasa" + "Highlight Current Sentence": "Serlahkan ayat semasa", + "File Path": "Laluan Fail" } diff --git a/apps/readest-app/public/locales/nl/translation.json b/apps/readest-app/public/locales/nl/translation.json index 4f2b4b33..35511f6e 100644 --- a/apps/readest-app/public/locales/nl/translation.json +++ b/apps/readest-app/public/locales/nl/translation.json @@ -1699,5 +1699,6 @@ "Book file is not available locally": "Boekbestand is niet lokaal beschikbaar", "Failed to send book": "Verzenden van boek mislukt", "Send": "Verzenden", - "Highlight Current Sentence": "Huidige zin markeren" + "Highlight Current Sentence": "Huidige zin markeren", + "File Path": "Bestandspad" } diff --git a/apps/readest-app/public/locales/pl/translation.json b/apps/readest-app/public/locales/pl/translation.json index c40c632b..c35b67b3 100644 --- a/apps/readest-app/public/locales/pl/translation.json +++ b/apps/readest-app/public/locales/pl/translation.json @@ -1765,5 +1765,6 @@ "Book file is not available locally": "Plik książki nie jest dostępny lokalnie", "Failed to send book": "Nie udało się wysłać książki", "Send": "Wyślij", - "Highlight Current Sentence": "Podświetl bieżące zdanie" + "Highlight Current Sentence": "Podświetl bieżące zdanie", + "File Path": "Ścieżka pliku" } diff --git a/apps/readest-app/public/locales/pt-BR/translation.json b/apps/readest-app/public/locales/pt-BR/translation.json index 92b6e681..e69567d3 100644 --- a/apps/readest-app/public/locales/pt-BR/translation.json +++ b/apps/readest-app/public/locales/pt-BR/translation.json @@ -1732,5 +1732,6 @@ "Book file is not available locally": "O arquivo do livro não está disponível localmente", "Failed to send book": "Falha ao enviar o livro", "Send": "Enviar", - "Highlight Current Sentence": "Destacar a frase atual" + "Highlight Current Sentence": "Destacar a frase atual", + "File Path": "Caminho do arquivo" } diff --git a/apps/readest-app/public/locales/pt/translation.json b/apps/readest-app/public/locales/pt/translation.json index f642ee57..c66d5eaf 100644 --- a/apps/readest-app/public/locales/pt/translation.json +++ b/apps/readest-app/public/locales/pt/translation.json @@ -1732,5 +1732,6 @@ "Book file is not available locally": "O ficheiro do livro não está disponível localmente", "Failed to send book": "Falha ao enviar o livro", "Send": "Enviar", - "Highlight Current Sentence": "Destacar a frase atual" + "Highlight Current Sentence": "Destacar a frase atual", + "File Path": "Caminho do Arquivo" } diff --git a/apps/readest-app/public/locales/ro/translation.json b/apps/readest-app/public/locales/ro/translation.json index 062f4659..b74fecca 100644 --- a/apps/readest-app/public/locales/ro/translation.json +++ b/apps/readest-app/public/locales/ro/translation.json @@ -1732,5 +1732,6 @@ "Book file is not available locally": "Fișierul cărții nu este disponibil local", "Failed to send book": "Trimiterea cărții a eșuat", "Send": "Trimite", - "Highlight Current Sentence": "Evidențiază propoziția curentă" + "Highlight Current Sentence": "Evidențiază propoziția curentă", + "File Path": "Calea fișierului" } diff --git a/apps/readest-app/public/locales/ru/translation.json b/apps/readest-app/public/locales/ru/translation.json index cdc8775a..9814b3cf 100644 --- a/apps/readest-app/public/locales/ru/translation.json +++ b/apps/readest-app/public/locales/ru/translation.json @@ -1765,5 +1765,6 @@ "Book file is not available locally": "Файл книги недоступен локально", "Failed to send book": "Не удалось отправить книгу", "Send": "Отправить", - "Highlight Current Sentence": "Выделять текущее предложение" + "Highlight Current Sentence": "Выделять текущее предложение", + "File Path": "Путь к файлу" } diff --git a/apps/readest-app/public/locales/si/translation.json b/apps/readest-app/public/locales/si/translation.json index 9e407695..9015831b 100644 --- a/apps/readest-app/public/locales/si/translation.json +++ b/apps/readest-app/public/locales/si/translation.json @@ -1699,5 +1699,6 @@ "Book file is not available locally": "පොත් ගොනුව දේශීයව ලබා ගත නොහැක", "Failed to send book": "පොත යැවීමට අසමත් විය", "Send": "යවන්න", - "Highlight Current Sentence": "වර්තමාන වාක්‍යය ඉස්මතු කරන්න" + "Highlight Current Sentence": "වර්තමාන වාක්‍යය ඉස්මතු කරන්න", + "File Path": "ගොනු මාර්ගය" } diff --git a/apps/readest-app/public/locales/sl/translation.json b/apps/readest-app/public/locales/sl/translation.json index be165932..0c0a09a8 100644 --- a/apps/readest-app/public/locales/sl/translation.json +++ b/apps/readest-app/public/locales/sl/translation.json @@ -1765,5 +1765,6 @@ "Book file is not available locally": "Datoteka knjige ni na voljo lokalno", "Failed to send book": "Pošiljanje knjige ni uspelo", "Send": "Pošlji", - "Highlight Current Sentence": "Označi trenutni stavek" + "Highlight Current Sentence": "Označi trenutni stavek", + "File Path": "Pot do datoteke" } diff --git a/apps/readest-app/public/locales/sv/translation.json b/apps/readest-app/public/locales/sv/translation.json index 31b1fd4d..56eff1d8 100644 --- a/apps/readest-app/public/locales/sv/translation.json +++ b/apps/readest-app/public/locales/sv/translation.json @@ -1699,5 +1699,6 @@ "Book file is not available locally": "Bokfilen är inte tillgänglig lokalt", "Failed to send book": "Det gick inte att skicka boken", "Send": "Skicka", - "Highlight Current Sentence": "Markera aktuell mening" + "Highlight Current Sentence": "Markera aktuell mening", + "File Path": "Filsökväg" } diff --git a/apps/readest-app/public/locales/ta/translation.json b/apps/readest-app/public/locales/ta/translation.json index c0db9498..f28a74ec 100644 --- a/apps/readest-app/public/locales/ta/translation.json +++ b/apps/readest-app/public/locales/ta/translation.json @@ -1699,5 +1699,6 @@ "Book file is not available locally": "புத்தக கோப்பு உள்ளூரில் கிடைக்கவில்லை", "Failed to send book": "புத்தகத்தை அனுப்ப முடியவில்லை", "Send": "அனுப்பு", - "Highlight Current Sentence": "தற்போதைய வாக்கியத்தை தனிப்படுத்து" + "Highlight Current Sentence": "தற்போதைய வாக்கியத்தை தனிப்படுத்து", + "File Path": "கோப்பு பாதை" } diff --git a/apps/readest-app/public/locales/th/translation.json b/apps/readest-app/public/locales/th/translation.json index 1dd097a8..ee8689f2 100644 --- a/apps/readest-app/public/locales/th/translation.json +++ b/apps/readest-app/public/locales/th/translation.json @@ -1666,5 +1666,6 @@ "Book file is not available locally": "ไม่มีไฟล์หนังสือในเครื่อง", "Failed to send book": "ส่งหนังสือไม่สำเร็จ", "Send": "ส่ง", - "Highlight Current Sentence": "ไฮไลต์ประโยคปัจจุบัน" + "Highlight Current Sentence": "ไฮไลต์ประโยคปัจจุบัน", + "File Path": "เส้นทางไฟล์" } diff --git a/apps/readest-app/public/locales/tr/translation.json b/apps/readest-app/public/locales/tr/translation.json index 509d9675..d9d5c6ce 100644 --- a/apps/readest-app/public/locales/tr/translation.json +++ b/apps/readest-app/public/locales/tr/translation.json @@ -1699,5 +1699,6 @@ "Book file is not available locally": "Kitap dosyası yerel olarak mevcut değil", "Failed to send book": "Kitap gönderilemedi", "Send": "Gönder", - "Highlight Current Sentence": "Geçerli cümleyi vurgula" + "Highlight Current Sentence": "Geçerli cümleyi vurgula", + "File Path": "Dosya Yolu" } diff --git a/apps/readest-app/public/locales/uk/translation.json b/apps/readest-app/public/locales/uk/translation.json index c38518b1..7b5d6b5b 100644 --- a/apps/readest-app/public/locales/uk/translation.json +++ b/apps/readest-app/public/locales/uk/translation.json @@ -1765,5 +1765,6 @@ "Book file is not available locally": "Файл книги недоступний локально", "Failed to send book": "Не вдалося надіслати книгу", "Send": "Надіслати", - "Highlight Current Sentence": "Виділяти поточне речення" + "Highlight Current Sentence": "Виділяти поточне речення", + "File Path": "Шлях до файлу" } diff --git a/apps/readest-app/public/locales/uz/translation.json b/apps/readest-app/public/locales/uz/translation.json index b4742e33..1b7e1949 100644 --- a/apps/readest-app/public/locales/uz/translation.json +++ b/apps/readest-app/public/locales/uz/translation.json @@ -1699,5 +1699,6 @@ "Book file is not available locally": "Kitob fayli mahalliy tarzda mavjud emas", "Failed to send book": "Kitobni yuborib bo'lmadi", "Send": "Yuborish", - "Highlight Current Sentence": "Joriy jumlani ajratib ko'rsatish" + "Highlight Current Sentence": "Joriy jumlani ajratib ko'rsatish", + "File Path": "Fayl yo'li" } diff --git a/apps/readest-app/public/locales/vi/translation.json b/apps/readest-app/public/locales/vi/translation.json index 32051b66..62d20512 100644 --- a/apps/readest-app/public/locales/vi/translation.json +++ b/apps/readest-app/public/locales/vi/translation.json @@ -1666,5 +1666,6 @@ "Book file is not available locally": "Tệp sách không có sẵn trên thiết bị", "Failed to send book": "Gửi sách thất bại", "Send": "Gửi", - "Highlight Current Sentence": "Tô sáng câu hiện tại" + "Highlight Current Sentence": "Tô sáng câu hiện tại", + "File Path": "Đường dẫn tệp" } diff --git a/apps/readest-app/public/locales/zh-CN/translation.json b/apps/readest-app/public/locales/zh-CN/translation.json index aebf33c2..5c1fd367 100644 --- a/apps/readest-app/public/locales/zh-CN/translation.json +++ b/apps/readest-app/public/locales/zh-CN/translation.json @@ -1666,5 +1666,6 @@ "Book file is not available locally": "本地没有该图书文件", "Failed to send book": "发送图书失败", "Send": "发送", - "Highlight Current Sentence": "高亮当前句子" + "Highlight Current Sentence": "高亮当前句子", + "File Path": "文件路径" } diff --git a/apps/readest-app/public/locales/zh-TW/translation.json b/apps/readest-app/public/locales/zh-TW/translation.json index 4f102e7d..eda56a88 100644 --- a/apps/readest-app/public/locales/zh-TW/translation.json +++ b/apps/readest-app/public/locales/zh-TW/translation.json @@ -1666,5 +1666,6 @@ "Book file is not available locally": "本機沒有此書籍檔案", "Failed to send book": "傳送書籍失敗", "Send": "傳送", - "Highlight Current Sentence": "醒目標示目前句子" + "Highlight Current Sentence": "醒目標示目前句子", + "File Path": "檔案路徑" } diff --git a/apps/readest-app/src/__tests__/components/TOCItem.test.tsx b/apps/readest-app/src/__tests__/components/TOCItem.test.tsx index ee4ad241..814f8fd2 100644 --- a/apps/readest-app/src/__tests__/components/TOCItem.test.tsx +++ b/apps/readest-app/src/__tests__/components/TOCItem.test.tsx @@ -1,13 +1,23 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; -import { render, screen, cleanup } from '@testing-library/react'; +import { render, screen, cleanup, fireEvent } from '@testing-library/react'; -import { StaticListRow } from '@/app/reader/components/sidebar/TOCItem'; +import { + StaticListRow, + CurrentPositionRow, + buildTOCDisplayItems, + isCurrentPositionItem, + type FlatTOCItem, +} from '@/app/reader/components/sidebar/TOCItem'; import { TOCItem } from '@/libs/document'; vi.mock('@/utils/misc', () => ({ getContentMd5: (s: string) => s, })); +vi.mock('@/hooks/useTranslation', () => ({ + useTranslation: () => (key: string) => key, +})); + const makeLeafItem = (overrides?: Partial): TOCItem => ({ id: 1, label: 'Chapter 1', @@ -146,6 +156,108 @@ describe('aria-current on active treeitem', () => { }); }); +describe('CurrentPositionRow', () => { + it('renders the "Current position" label and the current page number', () => { + render( +
+ +
, + ); + expect(screen.getByText('Current position')).toBeTruthy(); + expect(screen.getByText('42')).toBeTruthy(); + }); + + it('renders an open-book icon', () => { + const { container } = render(); + expect(container.querySelector('svg')).toBeTruthy(); + }); + + it('indents according to depth (matching a child of the active item)', () => { + const { container } = render(); + const row = container.querySelector('[role="treeitem"]') as HTMLElement; + expect(row.style.paddingInlineStart).toBe(`${(2 + 1) * 12}px`); + }); + + it('exposes the page number in its aria-label', () => { + render(); + const row = screen.getByRole('treeitem'); + expect(row.getAttribute('aria-label')).toContain('Current position'); + expect(row.getAttribute('aria-label')).toContain('7'); + }); + + it('calls onClick when clicked', () => { + const onClick = vi.fn(); + render(); + fireEvent.click(screen.getByRole('treeitem')); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('calls onClick when Enter is pressed', () => { + const onClick = vi.fn(); + render(); + fireEvent.keyDown(screen.getByRole('treeitem'), { key: 'Enter' }); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('is focusable (tabIndex=0) when an onClick is provided', () => { + render(); + expect(screen.getByRole('treeitem').getAttribute('tabindex')).toBe('0'); + }); +}); + +describe('buildTOCDisplayItems', () => { + const makeFlat = (count: number, depth = 0): FlatTOCItem[] => + Array.from({ length: count }, (_, i) => ({ + item: { id: i + 1, label: `Chapter ${i}`, href: `ch${i}.html`, index: i }, + depth, + index: i, + })); + + it('inserts a current-position row right after the active item', () => { + const flat = makeFlat(4); + const result = buildTOCDisplayItems(flat, 'ch1.html', 10); + expect(result).toHaveLength(5); + const inserted = result[2]!; + expect(isCurrentPositionItem(inserted)).toBe(true); + if (isCurrentPositionItem(inserted)) expect(inserted.page).toBe(10); + }); + + it('indents the current-position row one level deeper than the active item', () => { + const flat = makeFlat(3, 1); + const result = buildTOCDisplayItems(flat, 'ch0.html', 5); + const inserted = result[1]!; + expect(isCurrentPositionItem(inserted)).toBe(true); + if (isCurrentPositionItem(inserted)) expect(inserted.depth).toBe(2); + }); + + it('keeps the active item index unchanged so auto-scroll still targets it', () => { + const flat = makeFlat(4); + const result = buildTOCDisplayItems(flat, 'ch2.html', 9); + const activeIdx = result.findIndex( + (r) => !isCurrentPositionItem(r) && r.item.href === 'ch2.html', + ); + expect(activeIdx).toBe(2); + expect(isCurrentPositionItem(result[3]!)).toBe(true); + }); + + it('returns the original list when no item matches the active href', () => { + const flat = makeFlat(3); + const result = buildTOCDisplayItems(flat, 'missing.html', 5); + expect(result).toBe(flat); + }); + + it('returns the original list when the current page is unknown', () => { + const flat = makeFlat(3); + expect(buildTOCDisplayItems(flat, 'ch1.html', null)).toBe(flat); + expect(buildTOCDisplayItems(flat, 'ch1.html', undefined)).toBe(flat); + }); + + it('returns the original list when there is no active href', () => { + const flat = makeFlat(3); + expect(buildTOCDisplayItems(flat, null, 5)).toBe(flat); + }); +}); + describe('TOCView tree role', () => { it('static list container has role="tree"', async () => { // This is tested indirectly via TOCView, but we verify the container role diff --git a/apps/readest-app/src/app/reader/components/sidebar/TOCItem.tsx b/apps/readest-app/src/app/reader/components/sidebar/TOCItem.tsx index a1dd34d7..491af624 100644 --- a/apps/readest-app/src/app/reader/components/sidebar/TOCItem.tsx +++ b/apps/readest-app/src/app/reader/components/sidebar/TOCItem.tsx @@ -1,6 +1,8 @@ import clsx from 'clsx'; import React, { useCallback } from 'react'; +import { FiBookOpen } from 'react-icons/fi'; import { TOCItem } from '@/libs/document'; +import { useTranslation } from '@/hooks/useTranslation'; import { getContentMd5 } from '@/utils/misc'; const createExpanderIcon = (isExpanded: boolean) => { @@ -30,6 +32,42 @@ export interface FlatTOCItem { isExpanded?: boolean; } +// Synthetic row injected right under the active TOC item to surface the +// current reading page (see buildTOCDisplayItems). +export interface CurrentPositionItem { + isCurrentPosition: true; + depth: number; + page: number; +} + +export type TOCDisplayItem = FlatTOCItem | CurrentPositionItem; + +export const isCurrentPositionItem = (item: TOCDisplayItem): item is CurrentPositionItem => + 'isCurrentPosition' in item; + +// Insert a "current position" row immediately after the active TOC item so the +// reader can see exactly how far they've progressed within the highlighted +// section. The row sits one level deeper than the active item. Inserting it +// *after* the active item leaves that item's index untouched, so the auto-scroll +// logic in TOCView keeps targeting the right row. +export const buildTOCDisplayItems = ( + flatItems: FlatTOCItem[], + activeHref: string | null, + currentPage: number | null | undefined, +): TOCDisplayItem[] => { + if (!activeHref || currentPage == null) return flatItems; + const activeIndex = flatItems.findIndex((f) => f.item.href === activeHref); + if (activeIndex === -1) return flatItems; + const currentRow: CurrentPositionItem = { + isCurrentPosition: true, + depth: flatItems[activeIndex]!.depth + 1, + page: currentPage, + }; + const result: TOCDisplayItem[] = flatItems.slice(); + result.splice(activeIndex + 1, 0, currentRow); + return result; +}; + const TOCItemView = React.memo<{ bookKey: string; flatItem: FlatTOCItem; @@ -165,3 +203,56 @@ export const StaticListRow: React.FC = ({ ); }; + +export const CurrentPositionRow: React.FC<{ + depth: number; + page: number; + onClick?: () => void; +}> = ({ depth, page, onClick }) => { + const _ = useTranslation(); + const label = _('Current position'); + + const handleClick = useCallback( + (event: React.MouseEvent | React.KeyboardEvent) => { + event.preventDefault(); + onClick?.(); + }, + [onClick], + ); + + return ( +
+
e.key === 'Enter' && handleClick(e) : undefined} + className={clsx( + 'flex w-full items-center rounded-md py-4 sm:py-2', + '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` }} + > +
+
+ ); +}; diff --git a/apps/readest-app/src/app/reader/components/sidebar/TOCView.tsx b/apps/readest-app/src/app/reader/components/sidebar/TOCView.tsx index 77a04d5f..4d084487 100644 --- a/apps/readest-app/src/app/reader/components/sidebar/TOCView.tsx +++ b/apps/readest-app/src/app/reader/components/sidebar/TOCView.tsx @@ -8,7 +8,13 @@ import { useReaderStore } from '@/store/readerStore'; import { useSidebarStore } from '@/store/sidebarStore'; import { eventDispatcher } from '@/utils/event'; import { useTextTranslation } from '../../hooks/useTextTranslation'; -import { FlatTOCItem, StaticListRow } from './TOCItem'; +import { + buildTOCDisplayItems, + CurrentPositionRow, + FlatTOCItem, + isCurrentPositionItem, + StaticListRow, +} from './TOCItem'; import { computeExpandedSet, getItemIdentifier } from './tocTree'; const flattenTOC = (items: TOCItem[], expandedItems: Set, depth = 0): FlatTOCItem[] => { @@ -177,6 +183,13 @@ const TOCView: React.FC<{ const activeHref = progress?.sectionHref ?? null; const flatItems = useMemo(() => flattenTOC(toc, expandedItems), [toc, expandedItems]); + // Inject a "current position" row under the active item showing the current + // reading page. It sits after the active item, so flatItems indices (used by + // the auto-scroll effects) stay valid against this rendered list. + const displayItems = useMemo( + () => buildTOCDisplayItems(flatItems, activeHref, progress?.page), + [flatItems, activeHref, progress?.page], + ); // Keep the refs read by the OverlayScrollbars `initialized` callback current. activeHrefRef.current = activeHref; flatItemsRef.current = flatItems; @@ -204,6 +217,13 @@ const TOCView: React.FC<{ [bookKey, getView], ); + const handleCurrentPositionClick = useCallback(() => { + const location = getProgress(bookKey)?.location; + if (!location) return; + eventDispatcher.dispatch('navigate', { bookKey, cfi: location }); + getView(bookKey)?.goTo(location); + }, [bookKey, getView, getProgress]); + useEffect(() => { if (!isSideBarVisible || sideBarBookKey !== bookKey) { userScrolledRef.current = false; @@ -287,16 +307,28 @@ const TOCView: React.FC<{ }, 10000); }} style={{ height: containerHeight }} - totalCount={flatItems.length} - itemContent={(index) => ( - - )} + totalCount={displayItems.length} + itemContent={(index) => { + const row = displayItems[index]!; + if (isCurrentPositionItem(row)) { + return ( + + ); + } + return ( + + ); + }} overscan={500} />