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
@@ -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>