diff --git a/apps/readest-app/package.json b/apps/readest-app/package.json index 4879d929..a38e73a5 100644 --- a/apps/readest-app/package.json +++ b/apps/readest-app/package.json @@ -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", diff --git a/apps/readest-app/src/app/reader/components/sidebar/Content.tsx b/apps/readest-app/src/app/reader/components/sidebar/Content.tsx index 0f1f2f7c..d35e239f 100644 --- a/apps/readest-app/src/app/reader/components/sidebar/Content.tsx +++ b/apps/readest-app/src/app/reader/components/sidebar/Content.tsx @@ -70,14 +70,14 @@ const SidebarContent: React.FC<{ <>
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 ( -
    -
      - {toc && - toc.map((item, index) => ( - + {flatItems.length > 256 ? ( + + {VirtualListRow} + + ) : ( +
      + {flatItems.map((flatItem, index) => ( + ))} -
    +
    + )}
    ); }; 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