feat(toc): show current reading page under the active item (#4513) (#4525)

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) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-11 00:40:59 +08:00
committed by GitHub
parent 607e646bc6
commit 2ade769956
39 changed files with 362 additions and 46 deletions
@@ -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)
@@ -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/<N>` → get package + `first_patched_version` + vulnerable range.
2. Confirm parent ranges allow the patch (`cat node_modules/.pnpm/<parent>@*/.../package.json | grep '"<pkg>"'`).
3. Add/raise the entry in `pnpm-workspace.yaml` `overrides:` in the existing style: `<pkg>: '>=<patched>'` (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 <pkg>` (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/<N>` 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 <pkg>` which incidentally refreshes unrelated in-range patches (e.g. react-is). See [[feedback_pr_new_branch]] [[feedback_use_worktree]].
@@ -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]]).
@@ -1831,5 +1831,6 @@
"Book file is not available locally": "ملف الكتاب غير متوفر محليًا",
"Failed to send book": "فشل إرسال الكتاب",
"Send": "إرسال",
"Highlight Current Sentence": "تمييز الجملة الحالية"
"Highlight Current Sentence": "تمييز الجملة الحالية",
"File Path": "مسار الملف"
}
@@ -1699,5 +1699,6 @@
"Book file is not available locally": "বইয়ের ফাইলটি স্থানীয়ভাবে উপলব্ধ নয়",
"Failed to send book": "বই পাঠাতে ব্যর্থ হয়েছে",
"Send": "পাঠান",
"Highlight Current Sentence": "বর্তমান বাক্য হাইলাইট করুন"
"Highlight Current Sentence": "বর্তমান বাক্য হাইলাইট করুন",
"File Path": "ফাইলের পথ"
}
@@ -1666,5 +1666,6 @@
"Book file is not available locally": "དེབ་ཀྱི་ཡིག་ཆ་དེ་ས་གནས་སུ་མི་འདུག",
"Failed to send book": "དེབ་སྐུར་གཏོང་མ་ཐུབ།",
"Send": "སྐུར་གཏོང་།",
"Highlight Current Sentence": "ད་ལྟའི་ཚིག་གྲུབ་ལ་འོག་ཐིག་གཏོང་།"
"Highlight Current Sentence": "ད་ལྟའི་ཚིག་གྲུབ་ལ་འོག་ཐིག་གཏོང་།",
"File Path": "ཡིག་ཆའི་ལམ་ཐིག"
}
@@ -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"
}
@@ -1699,5 +1699,6 @@
"Book file is not available locally": "Το αρχείο του βιβλίου δεν είναι διαθέσιμο τοπικά",
"Failed to send book": "Αποτυχία αποστολής βιβλίου",
"Send": "Αποστολή",
"Highlight Current Sentence": "Επισήμανση τρέχουσας πρότασης"
"Highlight Current Sentence": "Επισήμανση τρέχουσας πρότασης",
"File Path": "Διαδρομή αρχείου"
}
@@ -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"
}
@@ -1699,5 +1699,6 @@
"Book file is not available locally": "فایل کتاب به صورت محلی در دسترس نیست",
"Failed to send book": "ارسال کتاب ناموفق بود",
"Send": "ارسال",
"Highlight Current Sentence": "برجسته‌سازی جملهٔ فعلی"
"Highlight Current Sentence": "برجسته‌سازی جملهٔ فعلی",
"File Path": "مسیر فایل"
}
@@ -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"
}
@@ -1732,5 +1732,6 @@
"Book file is not available locally": "קובץ הספר אינו זמין באופן מקומי",
"Failed to send book": "שליחת הספר נכשלה",
"Send": "שליחה",
"Highlight Current Sentence": "הדגשת המשפט הנוכחי"
"Highlight Current Sentence": "הדגשת המשפט הנוכחי",
"File Path": "נתיב הקובץ"
}
@@ -1699,5 +1699,6 @@
"Book file is not available locally": "पुस्तक फ़ाइल स्थानीय रूप से उपलब्ध नहीं है",
"Failed to send book": "पुस्तक भेजने में विफल",
"Send": "भेजें",
"Highlight Current Sentence": "वर्तमान वाक्य को हाइलाइट करें"
"Highlight Current Sentence": "वर्तमान वाक्य को हाइलाइट करें",
"File Path": "फाइल पथ"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -1666,5 +1666,6 @@
"Book file is not available locally": "書籍ファイルがローカルに存在しません",
"Failed to send book": "書籍の送信に失敗しました",
"Send": "送信",
"Highlight Current Sentence": "現在の文をハイライト"
"Highlight Current Sentence": "現在の文をハイライト",
"File Path": "ファイルパス"
}
@@ -1666,5 +1666,6 @@
"Book file is not available locally": "책 파일을 로컬에서 사용할 수 없습니다",
"Failed to send book": "책 보내기에 실패했습니다",
"Send": "보내기",
"Highlight Current Sentence": "현재 문장 강조"
"Highlight Current Sentence": "현재 문장 강조",
"File Path": "파일 경로"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -1765,5 +1765,6 @@
"Book file is not available locally": "Файл книги недоступен локально",
"Failed to send book": "Не удалось отправить книгу",
"Send": "Отправить",
"Highlight Current Sentence": "Выделять текущее предложение"
"Highlight Current Sentence": "Выделять текущее предложение",
"File Path": "Путь к файлу"
}
@@ -1699,5 +1699,6 @@
"Book file is not available locally": "පොත් ගොනුව දේශීයව ලබා ගත නොහැක",
"Failed to send book": "පොත යැවීමට අසමත් විය",
"Send": "යවන්න",
"Highlight Current Sentence": "වර්තමාන වාක්‍යය ඉස්මතු කරන්න"
"Highlight Current Sentence": "වර්තමාන වාක්‍යය ඉස්මතු කරන්න",
"File Path": "ගොනු මාර්ගය"
}
@@ -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"
}
@@ -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"
}
@@ -1699,5 +1699,6 @@
"Book file is not available locally": "புத்தக கோப்பு உள்ளூரில் கிடைக்கவில்லை",
"Failed to send book": "புத்தகத்தை அனுப்ப முடியவில்லை",
"Send": "அனுப்பு",
"Highlight Current Sentence": "தற்போதைய வாக்கியத்தை தனிப்படுத்து"
"Highlight Current Sentence": "தற்போதைய வாக்கியத்தை தனிப்படுத்து",
"File Path": "கோப்பு பாதை"
}
@@ -1666,5 +1666,6 @@
"Book file is not available locally": "ไม่มีไฟล์หนังสือในเครื่อง",
"Failed to send book": "ส่งหนังสือไม่สำเร็จ",
"Send": "ส่ง",
"Highlight Current Sentence": "ไฮไลต์ประโยคปัจจุบัน"
"Highlight Current Sentence": "ไฮไลต์ประโยคปัจจุบัน",
"File Path": "เส้นทางไฟล์"
}
@@ -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"
}
@@ -1765,5 +1765,6 @@
"Book file is not available locally": "Файл книги недоступний локально",
"Failed to send book": "Не вдалося надіслати книгу",
"Send": "Надіслати",
"Highlight Current Sentence": "Виділяти поточне речення"
"Highlight Current Sentence": "Виділяти поточне речення",
"File Path": "Шлях до файлу"
}
@@ -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"
}
@@ -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"
}
@@ -1666,5 +1666,6 @@
"Book file is not available locally": "本地没有该图书文件",
"Failed to send book": "发送图书失败",
"Send": "发送",
"Highlight Current Sentence": "高亮当前句子"
"Highlight Current Sentence": "高亮当前句子",
"File Path": "文件路径"
}
@@ -1666,5 +1666,6 @@
"Book file is not available locally": "本機沒有此書籍檔案",
"Failed to send book": "傳送書籍失敗",
"Send": "傳送",
"Highlight Current Sentence": "醒目標示目前句子"
"Highlight Current Sentence": "醒目標示目前句子",
"File Path": "檔案路徑"
}
@@ -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>): 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(
<div role='tree'>
<CurrentPositionRow depth={1} page={42} />
</div>,
);
expect(screen.getByText('Current position')).toBeTruthy();
expect(screen.getByText('42')).toBeTruthy();
});
it('renders an open-book icon', () => {
const { container } = render(<CurrentPositionRow depth={0} page={1} />);
expect(container.querySelector('svg')).toBeTruthy();
});
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`);
});
it('exposes the page number in its aria-label', () => {
render(<CurrentPositionRow depth={0} page={7} />);
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(<CurrentPositionRow depth={0} page={5} onClick={onClick} />);
fireEvent.click(screen.getByRole('treeitem'));
expect(onClick).toHaveBeenCalledTimes(1);
});
it('calls onClick when Enter is pressed', () => {
const onClick = vi.fn();
render(<CurrentPositionRow depth={0} page={5} onClick={onClick} />);
fireEvent.keyDown(screen.getByRole('treeitem'), { key: 'Enter' });
expect(onClick).toHaveBeenCalledTimes(1);
});
it('is focusable (tabIndex=0) when an onClick is provided', () => {
render(<CurrentPositionRow depth={0} page={5} onClick={vi.fn()} />);
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
@@ -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<ListRowProps> = ({
</div>
);
};
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 (
<div
className={clsx(
'border-base-300 w-full border-b sm:border-none',
'pe-4 ps-2 pt-[1px] sm:pe-2',
)}
title={label}
>
<div
tabIndex={onClick ? 0 : undefined}
role='treeitem'
aria-current='true'
aria-label={`${label}, ${page}`}
onClick={onClick ? handleClick : undefined}
onKeyDown={onClick ? (e) => 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` }}
>
<FiBookOpen className='h-4 w-4 shrink-0' aria-hidden='true' />
<div
className='ms-2 truncate text-ellipsis'
style={{ whiteSpace: 'nowrap', textOverflow: 'ellipsis' }}
>
{label}
</div>
<div aria-hidden='true' className='text-base-content/50 ms-auto ps-1 text-xs sm:pe-1'>
{page}
</div>
</div>
</div>
);
};
@@ -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<string>, 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) => (
<StaticListRow
bookKey={bookKey}
flatItem={flatItems[index]!}
activeHref={activeHref}
onToggleExpand={handleToggleExpand}
onItemClick={handleItemClick}
/>
)}
totalCount={displayItems.length}
itemContent={(index) => {
const row = displayItems[index]!;
if (isCurrentPositionItem(row)) {
return (
<CurrentPositionRow
depth={row.depth}
page={row.page}
onClick={handleCurrentPositionClick}
/>
);
}
return (
<StaticListRow
bookKey={bookKey}
flatItem={row}
activeHref={activeHref}
onToggleExpand={handleToggleExpand}
onItemClick={handleItemClick}
/>
);
}}
overscan={500}
/>
</div>