perf: optimize toc list view for very large toc list, closes #386 (#1370)

This commit is contained in:
Huang Xin
2025-06-08 21:01:34 +08:00
committed by GitHub
parent 95af3bd3d1
commit 69d418aa61
6 changed files with 376 additions and 173 deletions
+2
View File
@@ -78,6 +78,7 @@
"react-i18next": "^15.2.0",
"react-icons": "^5.4.0",
"react-responsive": "^10.0.0",
"react-window": "^1.8.11",
"semver": "^7.7.1",
"tinycolor2": "^1.6.0",
"zustand": "5.0.1"
@@ -91,6 +92,7 @@
"@types/react": "18.3.12",
"@types/react-color": "^3.0.13",
"@types/react-dom": "18.3.1",
"@types/react-window": "^1.8.8",
"@types/semver": "^7.7.0",
"@types/tinycolor2": "^1.4.6",
"autoprefixer": "^10.4.20",
@@ -70,14 +70,14 @@ const SidebarContent: React.FC<{
<>
<div
className={clsx(
'sidebar-content flex min-h-0 flex-grow flex-col shadow-inner',
'sidebar-content flex h-full min-h-0 flex-grow flex-col shadow-inner',
'font-sans text-base font-normal sm:text-sm',
)}
>
<div
ref={scrollContainerRef}
className={clsx(
'scroll-container overflow-y-auto transition-opacity duration-300 ease-in-out',
'scroll-container min-h-0 flex-1 overflow-y-auto transition-opacity duration-300 ease-in-out',
{ 'opacity-0': fade, 'opacity-100': !fade },
)}
>
@@ -0,0 +1,176 @@
import clsx from 'clsx';
import React, { useCallback } from 'react';
import { ListChildComponentProps } from 'react-window';
import { TOCItem } from '@/libs/document';
import { getContentMd5 } from '@/utils/misc';
const createExpanderIcon = (isExpanded: boolean) => {
return (
<svg
viewBox='0 0 8 10'
width='8'
height='10'
className={clsx(
'text-base-content transform transition-transform',
isExpanded ? 'rotate-90' : 'rotate-0',
)}
style={{ transformOrigin: 'center' }}
fill='currentColor'
>
<polygon points='0 0, 8 5, 0 10' />
</svg>
);
};
export interface FlatTOCItem {
item: TOCItem;
depth: number;
index: number;
isExpanded?: boolean;
}
const TOCItemView = React.memo<{
bookKey: string;
flatItem: FlatTOCItem;
itemSize?: number;
isActive: boolean;
onToggleExpand: (item: TOCItem) => void;
onItemClick: (item: TOCItem) => void;
}>(({ flatItem, itemSize, isActive, onToggleExpand, onItemClick }) => {
const { item, depth } = flatItem;
const handleToggleExpand = useCallback(
(event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
onToggleExpand(item);
},
[item, onToggleExpand],
);
const handleClickItem = useCallback(
(event: React.MouseEvent) => {
event.preventDefault();
onItemClick(item);
},
[item, onItemClick],
);
return (
<span
role='treeitem'
tabIndex={-1}
onClick={item.href ? handleClickItem : undefined}
aria-expanded={flatItem.isExpanded ? 'true' : 'false'}
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
? 'sm:bg-base-300/85 sm:hover:bg-base-300 sm:text-base-content text-blue-500'
: 'sm:hover:bg-base-300/85',
)}
style={{
height: itemSize ? `${itemSize}px` : 'auto',
paddingInlineStart: `${(depth + 1) * 12}px`,
}}
>
{item.subitems && (
<span
onClick={handleToggleExpand}
className='inline-block cursor-pointer'
style={{
padding: '12px',
margin: '-12px',
}}
>
{createExpanderIcon(flatItem.isExpanded || false)}
</span>
)}
<span
className='ms-2 truncate text-ellipsis'
style={{
maxWidth: 'calc(100% - 24px)',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
}}
>
{item.label}
</span>
{item.location && (
<span className='text-base-content/50 ms-auto ps-1 text-xs sm:pe-1'>
{item.location.current + 1}
</span>
)}
</span>
);
});
TOCItemView.displayName = 'TOCItemView';
interface ListRowProps {
bookKey: string;
flatItem: FlatTOCItem;
itemSize?: number;
activeHref: string | null;
onToggleExpand: (item: TOCItem) => void;
onItemClick: (item: TOCItem) => void;
}
export const StaticListRow: React.FC<ListRowProps> = ({
bookKey,
flatItem,
itemSize,
activeHref,
onToggleExpand,
onItemClick,
}) => {
const isActive = activeHref === flatItem.item.href;
return (
<div
className={clsx(
'border-base-300 w-full border-b sm:border-none',
'pe-4 ps-2 pt-[1px] sm:pe-2',
)}
>
<TOCItemView
bookKey={bookKey}
flatItem={flatItem}
itemSize={itemSize}
isActive={isActive}
onToggleExpand={onToggleExpand}
onItemClick={onItemClick}
/>
</div>
);
};
export const VirtualListRow: React.FC<
ListChildComponentProps & {
data: {
bookKey: string;
flatItems: FlatTOCItem[];
itemSize: number;
activeHref: string | null;
onToggleExpand: (item: TOCItem) => void;
onItemClick: (item: TOCItem) => void;
};
}
> = ({ index, style, data }) => {
const { flatItems, bookKey, activeHref, itemSize, onToggleExpand, onItemClick } = data;
const flatItem = flatItems[index];
return (
<div style={style}>
<StaticListRow
bookKey={bookKey}
flatItem={flatItem}
itemSize={itemSize - 1}
activeHref={activeHref}
onToggleExpand={onToggleExpand}
onItemClick={onItemClick}
/>
</div>
);
};
@@ -1,198 +1,199 @@
import clsx from 'clsx';
import React, { useEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { FixedSizeList as VirtualList } from 'react-window';
import { TOCItem } from '@/libs/document';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { findParentPath } from '@/utils/toc';
import { getContentMd5 } from '@/utils/misc';
import { eventDispatcher } from '@/utils/event';
import { BookProgress } from '@/types/book';
import { getContentMd5 } from '@/utils/misc';
import { useTextTranslation } from '../../hooks/useTextTranslation';
import { FlatTOCItem, StaticListRow, VirtualListRow } from './TOCItem';
const createExpanderIcon = (isExpanded: boolean) => {
return (
<svg
viewBox='0 0 8 10'
width='8'
height='10'
className={clsx(
'text-base-content transform transition-transform',
isExpanded ? 'rotate-90' : 'rotate-0',
)}
style={{ transformOrigin: 'center' }}
fill='currentColor'
>
<polygon points='0 0, 8 5, 0 10' />
</svg>
);
};
const useFlattenedTOC = (toc: TOCItem[], expandedItems: Set<string>) => {
return useMemo(() => {
const flattenTOC = (items: TOCItem[], depth = 0): FlatTOCItem[] => {
const result: FlatTOCItem[] = [];
items.forEach((item, index) => {
const isExpanded = expandedItems.has(item.href || '');
result.push({ item, depth, index, isExpanded });
if (item.subitems && isExpanded) {
result.push(...flattenTOC(item.subitems, depth + 1));
}
});
return result;
};
const TOCItemView: React.FC<{
bookKey: string;
item: TOCItem;
depth: number;
expandedItems: string[];
}> = ({ bookKey, item, depth, expandedItems }) => {
const [isExpanded, setIsExpanded] = useState(expandedItems.includes(item.href || ''));
const { getView, getProgress } = useReaderStore();
const progress = getProgress(bookKey);
const handleToggleExpand = (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
setIsExpanded((prev) => !prev);
};
const handleClickItem = (event: React.MouseEvent) => {
event.preventDefault();
eventDispatcher.dispatch('navigate', { bookKey, href: item.href });
if (item.href) {
getView(bookKey)?.goTo(item.href);
}
};
const isActive = progress ? progress.sectionHref === item.href : false;
useEffect(() => {
setIsExpanded(expandedItems.includes(item.href || ''));
}, [expandedItems, item.href]);
return (
<li className='border-base-300 w-full border-b sm:border-none sm:pt-[1px]'>
<span
role='treeitem'
tabIndex={-1}
onClick={item.href ? handleClickItem : undefined}
style={{ paddingInlineStart: `${(depth + 1) * 12}px` }}
aria-expanded={isExpanded ? 'true' : 'false'}
aria-selected={isActive ? 'true' : 'false'}
data-href={item.href ? getContentMd5(item.href) : undefined}
className={`flex w-full cursor-pointer items-center rounded-md py-4 sm:py-2 ${
isActive
? 'sm:bg-base-300/85 sm:hover:bg-base-300 sm:text-base-content text-blue-500'
: 'sm:hover:bg-base-300/85'
}`}
>
{item.subitems && (
<span
onClick={handleToggleExpand}
className='inline-block cursor-pointer'
style={{
padding: '12px',
margin: '-12px',
}}
>
{createExpanderIcon(isExpanded)}
</span>
)}
<span
className='ms-2 truncate text-ellipsis'
style={{
maxWidth: 'calc(100% - 24px)',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
}}
>
{item.label}
</span>
{item.location && (
<span className='text-base-content/50 ms-auto ps-1 text-xs sm:pe-1'>
{item.location.current + 1}
</span>
)}
</span>
{item.subitems && isExpanded && (
<ol role='group'>
{item.subitems.map((subitem, index) => (
<TOCItemView
bookKey={bookKey}
key={`${index}-${subitem.href}`}
item={subitem}
depth={depth + 1}
expandedItems={expandedItems}
/>
))}
</ol>
)}
</li>
);
return flattenTOC(toc);
}, [toc, expandedItems]);
};
const TOCView: React.FC<{
bookKey: string;
toc: TOCItem[];
}> = ({ bookKey, toc }) => {
const { getProgress } = useReaderStore();
const { getView, getProgress, getViewSettings } = useReaderStore();
const { sideBarBookKey, isSideBarVisible } = useSidebarStore();
const viewSettings = getViewSettings(bookKey)!;
const progress = getProgress(bookKey);
const [expandedItems, setExpandedItems] = useState<string[]>([]);
const viewRef = useRef<HTMLUListElement | null>(null);
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
const [containerHeight, setContainerHeight] = useState(400);
useTextTranslation(bookKey, viewRef.current);
const containerRef = useRef<HTMLDivElement | null>(null);
const vitualListRef = useRef<VirtualList | null>(null);
const staticListRef = useRef<HTMLDivElement | null>(null);
const expandParents = (toc: TOCItem[], href: string) => {
const parentPath = findParentPath(toc, href).map((item) => item.href);
setExpandedItems(parentPath.filter(Boolean) as string[]);
};
const scrollToProgress = (progress: BookProgress) => {
const { sectionHref: currentHref } = progress;
const hrefMd5 = currentHref ? getContentMd5(currentHref) : '';
const currentItem = viewRef.current?.querySelector(`[data-href="${hrefMd5}"]`);
if (currentItem) {
const rect = currentItem.getBoundingClientRect();
const isVisible = rect.top >= 0 && rect.bottom <= window.innerHeight;
if (!isVisible) {
(currentItem as HTMLElement).scrollIntoView({ behavior: 'instant', block: 'center' });
}
(currentItem as HTMLElement).setAttribute('aria-current', 'page');
}
};
useTextTranslation(bookKey, containerRef.current);
useEffect(() => {
const observer = new MutationObserver(() => {
const progress = getProgress(bookKey);
if (progress && viewRef.current) {
scrollToProgress(progress);
observer.disconnect();
const updateHeight = () => {
if (containerRef.current) {
const rect = containerRef.current.getBoundingClientRect();
const parentContainer = containerRef.current.closest('.scroll-container');
if (parentContainer) {
const parentRect = parentContainer.getBoundingClientRect();
const availableHeight = parentRect.height - (rect.top - parentRect.top);
setContainerHeight(Math.max(400, availableHeight));
}
}
};
updateHeight();
window.addEventListener('resize', updateHeight);
let resizeObserver: ResizeObserver | null = null;
if (containerRef.current) {
const parentContainer = containerRef.current.closest('.scroll-container');
if (parentContainer) {
resizeObserver = new ResizeObserver(updateHeight);
resizeObserver.observe(parentContainer);
}
});
if (viewRef.current) {
observer.observe(viewRef.current, { childList: true, subtree: true });
}
return () => observer.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [viewRef.current]);
return () => {
window.removeEventListener('resize', updateHeight);
if (resizeObserver) {
resizeObserver.disconnect();
}
};
}, []);
const activeHref = useMemo(() => progress?.sectionHref || null, [progress]);
const flatItems = useFlattenedTOC(toc, expandedItems);
const handleToggleExpand = useCallback((item: TOCItem) => {
const href = item.href || '';
setExpandedItems((prev) => {
const newSet = new Set(prev);
if (newSet.has(href)) {
newSet.delete(href);
} else {
newSet.add(href);
}
return newSet;
});
}, []);
const handleItemClick = useCallback(
(item: TOCItem) => {
eventDispatcher.dispatch('navigate', { bookKey, href: item.href });
if (item.href) {
getView(bookKey)?.goTo(item.href);
}
},
[bookKey, getView],
);
const expandParents = useCallback((toc: TOCItem[], href: string) => {
const parentPath = findParentPath(toc, href).map((item) => item.href);
const parentHrefs = parentPath.filter(Boolean) as string[];
setExpandedItems(new Set(parentHrefs));
}, []);
const scrollToActiveItem = useCallback(() => {
if (!activeHref) return;
if (vitualListRef.current) {
const activeIndex = flatItems.findIndex((flatItem) => flatItem.item.href === activeHref);
if (activeIndex !== -1) {
vitualListRef.current.scrollToItem(activeIndex, 'center');
}
}
if (staticListRef.current) {
const hrefMd5 = activeHref ? getContentMd5(activeHref) : '';
const activeItem = staticListRef.current?.querySelector(`[data-href="${hrefMd5}"]`);
if (activeItem) {
const rect = activeItem.getBoundingClientRect();
const isVisible = rect.top >= 0 && rect.bottom <= window.innerHeight;
if (!isVisible) {
(activeItem as HTMLElement).scrollIntoView({ behavior: 'instant', block: 'center' });
}
(activeItem as HTMLElement).setAttribute('aria-current', 'page');
}
}
}, [activeHref, flatItems]);
const virtualItemSize = useMemo(() => {
return window.innerWidth >= 640 && !viewSettings?.translationEnabled ? 37 : 57;
}, [viewSettings]);
const virtualListData = useMemo(
() => ({
flatItems,
itemSize: virtualItemSize,
bookKey,
activeHref,
onToggleExpand: handleToggleExpand,
onItemClick: handleItemClick,
}),
[flatItems, virtualItemSize, bookKey, activeHref, handleToggleExpand, handleItemClick],
);
useEffect(() => {
if (!progress || eventDispatcher.dispatchSync('tts-is-speaking')) return;
if (sideBarBookKey !== bookKey) return;
if (!isSideBarVisible) return;
const { sectionHref: currentHref } = progress;
if (currentHref) {
expandParents(toc, currentHref);
}
scrollToProgress(progress);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [toc, progress, sideBarBookKey, isSideBarVisible]);
}, [toc, progress, sideBarBookKey, isSideBarVisible, bookKey, expandParents]);
useEffect(() => {
if (flatItems.length > 0) {
setTimeout(scrollToActiveItem, 0);
}
}, [flatItems, scrollToActiveItem]);
return (
<div className='rounded pt-2'>
<ul role='tree' ref={viewRef} className='pe-4 ps-2 sm:pe-2'>
{toc &&
toc.map((item, index) => (
<TOCItemView
<div className='rounded' ref={containerRef}>
{flatItems.length > 256 ? (
<VirtualList
ref={vitualListRef}
width='100%'
height={containerHeight}
itemCount={flatItems.length}
itemSize={virtualItemSize}
itemData={virtualListData}
overscanCount={20}
>
{VirtualListRow}
</VirtualList>
) : (
<div className='pt-2' ref={staticListRef}>
{flatItems.map((flatItem, index) => (
<StaticListRow
key={`static-row-${index}`}
bookKey={bookKey}
key={`${index}-${item.href}`}
item={item}
depth={0}
expandedItems={expandedItems}
flatItem={flatItem}
activeHref={activeHref}
onToggleExpand={handleToggleExpand}
onItemClick={handleItemClick}
/>
))}
</ul>
</div>
)}
</div>
);
};
+37 -13
View File
@@ -161,9 +161,12 @@ importers:
react-responsive:
specifier: ^10.0.0
version: 10.0.0(react@19.0.0)
react-window:
specifier: ^1.8.11
version: 1.8.11(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
semver:
specifier: ^7.7.1
version: 7.7.1
version: 7.7.2
tinycolor2:
specifier: ^1.6.0
version: 1.6.0
@@ -195,6 +198,9 @@ importers:
'@types/react-dom':
specifier: 18.3.1
version: 18.3.1
'@types/react-window':
specifier: ^1.8.8
version: 1.8.8
'@types/semver':
specifier: ^7.7.0
version: 7.7.0
@@ -2751,6 +2757,9 @@ packages:
'@types/react-dom@18.3.1':
resolution: {integrity: sha512-qW1Mfv8taImTthu4KoXgDfLuk4bydU6Q/TkADnDWWHwi4NX4BR+LWfTp2sVmTqRrsHvyDDTelgelxJ+SsejKKQ==}
'@types/react-window@1.8.8':
resolution: {integrity: sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==}
'@types/react@18.3.12':
resolution: {integrity: sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw==}
@@ -4470,6 +4479,9 @@ packages:
resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
engines: {node: '>= 0.8'}
memoize-one@5.2.1:
resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==}
merge-descriptors@2.0.0:
resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==}
engines: {node: '>=18'}
@@ -5076,6 +5088,13 @@ packages:
peerDependencies:
react: '>=16.8.0'
react-window@1.8.11:
resolution: {integrity: sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==}
engines: {node: '>8.0.0'}
peerDependencies:
react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react@18.3.1:
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
engines: {node: '>=0.10.0'}
@@ -5229,11 +5248,6 @@ packages:
engines: {node: '>=10'}
hasBin: true
semver@7.7.1:
resolution: {integrity: sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==}
engines: {node: '>=10'}
hasBin: true
semver@7.7.2:
resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==}
engines: {node: '>=10'}
@@ -9529,6 +9543,10 @@ snapshots:
dependencies:
'@types/react': 18.3.12
'@types/react-window@1.8.8':
dependencies:
'@types/react': 18.3.12
'@types/react@18.3.12':
dependencies:
'@types/prop-types': 15.7.13
@@ -9616,7 +9634,7 @@ snapshots:
fast-glob: 3.3.2
is-glob: 4.0.3
minimatch: 9.0.5
semver: 7.7.1
semver: 7.7.2
ts-api-utils: 1.4.3(typescript@5.7.2)
optionalDependencies:
typescript: 5.7.2
@@ -11182,7 +11200,7 @@ snapshots:
is-bun-module@1.3.0:
dependencies:
semver: 7.7.1
semver: 7.7.2
is-callable@1.2.7: {}
@@ -11456,6 +11474,8 @@ snapshots:
media-typer@1.1.0: {}
memoize-one@5.2.1: {}
merge-descriptors@2.0.0: {}
merge-stream@2.0.0: {}
@@ -11965,6 +11985,13 @@ snapshots:
react: 19.0.0
shallow-equal: 3.1.0
react-window@1.8.11(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies:
'@babel/runtime': 7.26.0
memoize-one: 5.2.1
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
react@18.3.1:
dependencies:
loose-envify: 1.4.0
@@ -12149,10 +12176,7 @@ snapshots:
semver@7.6.3: {}
semver@7.7.1: {}
semver@7.7.2:
optional: true
semver@7.7.2: {}
send@1.1.0:
dependencies:
@@ -12214,7 +12238,7 @@ snapshots:
dependencies:
color: 4.2.3
detect-libc: 2.0.3
semver: 7.7.1
semver: 7.7.2
optionalDependencies:
'@img/sharp-darwin-arm64': 0.33.5
'@img/sharp-darwin-x64': 0.33.5