diff --git a/apps/readest-app/src/app/reader/components/sidebar/TOCItem.tsx b/apps/readest-app/src/app/reader/components/sidebar/TOCItem.tsx
new file mode 100644
index 00000000..44281c1f
--- /dev/null
+++ b/apps/readest-app/src/app/reader/components/sidebar/TOCItem.tsx
@@ -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 (
+
+ );
+};
+
+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 (
+
+ {item.subitems && (
+
+ {createExpanderIcon(flatItem.isExpanded || false)}
+
+ )}
+
+ {item.label}
+
+ {item.location && (
+
+ {item.location.current + 1}
+
+ )}
+
+ );
+});
+
+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
= ({
+ bookKey,
+ flatItem,
+ itemSize,
+ activeHref,
+ onToggleExpand,
+ onItemClick,
+}) => {
+ const isActive = activeHref === flatItem.item.href;
+
+ return (
+
+
+
+ );
+};
+
+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 (
+
+
+
+ );
+};
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 37fd3bbc..7aaec070 100644
--- a/apps/readest-app/src/app/reader/components/sidebar/TOCView.tsx
+++ b/apps/readest-app/src/app/reader/components/sidebar/TOCView.tsx
@@ -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 (
-
- );
-};
+const useFlattenedTOC = (toc: TOCItem[], expandedItems: Set) => {
+ 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 (
-
-
- {item.subitems && (
-
- {createExpanderIcon(isExpanded)}
-
- )}
-
- {item.label}
-
- {item.location && (
-
- {item.location.current + 1}
-
- )}
-
- {item.subitems && isExpanded && (
-
- {item.subitems.map((subitem, index) => (
-
- ))}
-
- )}
-
- );
+ 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([]);
- const viewRef = useRef(null);
+ const [expandedItems, setExpandedItems] = useState>(new Set());
+ const [containerHeight, setContainerHeight] = useState(400);
- useTextTranslation(bookKey, viewRef.current);
+ const containerRef = useRef(null);
+ const vitualListRef = useRef(null);
+ const staticListRef = useRef(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 (
-
);
};
diff --git a/packages/foliate-js b/packages/foliate-js
index 19c6744d..d5286aec 160000
--- a/packages/foliate-js
+++ b/packages/foliate-js
@@ -1 +1 @@
-Subproject commit 19c6744d4bcd2c5fb25b953b2815f6a523024786
+Subproject commit d5286aeccf4c2fdc917c31e7bcec6a2209886782
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 77a5c907..98ed2b79 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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