This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user