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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user