188 lines
5.5 KiB
TypeScript
188 lines
5.5 KiB
TypeScript
import React, { useEffect, useRef, useState } from 'react';
|
|
|
|
import { md5 } from 'js-md5';
|
|
import { TOCItem } from '@/libs/document';
|
|
import { useReaderStore } from '@/store/readerStore';
|
|
import { useFoliateEvents } from '../hooks/useFoliateEvents';
|
|
|
|
const findParentPath = (toc: TOCItem[], href: string): TOCItem[] => {
|
|
for (const item of toc) {
|
|
if (item.href === href) {
|
|
return [item];
|
|
}
|
|
if (item.subitems) {
|
|
const path = findParentPath(item.subitems, href);
|
|
if (path.length) {
|
|
return [item, ...path];
|
|
}
|
|
}
|
|
}
|
|
return [];
|
|
};
|
|
|
|
const getHrefMd5 = (href: string) => md5(JSON.stringify(href));
|
|
|
|
const createExpanderIcon = (isExpanded: boolean) => {
|
|
return (
|
|
<svg
|
|
viewBox='0 0 8 10'
|
|
width='8'
|
|
height='10'
|
|
className={`transform transition-transform ${isExpanded ? 'rotate-90' : 'rotate-0'}`}
|
|
style={{ transformOrigin: 'center' }}
|
|
>
|
|
<polygon points='0 0, 8 5, 0 10' />
|
|
</svg>
|
|
);
|
|
};
|
|
|
|
const TOCItemView: React.FC<{
|
|
bookKey: string;
|
|
item: TOCItem;
|
|
depth: number;
|
|
setCurrentHref: (href: string) => void;
|
|
currentHref: string | null;
|
|
expandedItems: string[];
|
|
}> = ({ bookKey, item, depth, setCurrentHref, currentHref, expandedItems }) => {
|
|
const [isExpanded, setIsExpanded] = useState(expandedItems.includes(item.href || ''));
|
|
const { getFoliateView } = useReaderStore();
|
|
|
|
const handleToggleExpand = (event: React.MouseEvent) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
setIsExpanded((prev) => !prev);
|
|
};
|
|
|
|
const handleClickItem = (event: React.MouseEvent) => {
|
|
event.preventDefault();
|
|
if (item.href) {
|
|
getFoliateView(bookKey)?.goTo(item.href);
|
|
setCurrentHref(item.href);
|
|
}
|
|
};
|
|
|
|
const isActive = currentHref === item.href;
|
|
|
|
useEffect(() => {
|
|
setIsExpanded(expandedItems.includes(item.href || ''));
|
|
}, [expandedItems, item.href]);
|
|
|
|
return (
|
|
<li className='w-full' style={{ paddingTop: '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 ? getHrefMd5(item.href) : undefined}
|
|
className={`flex w-full cursor-pointer items-center rounded-md py-2 ${
|
|
isActive ? 'bg-gray-300 hover:bg-gray-400' : 'hover:bg-gray-300'
|
|
}`}
|
|
>
|
|
{item.subitems && (
|
|
<span onClick={handleToggleExpand} className='inline-block cursor-pointer'>
|
|
{createExpanderIcon(isExpanded)}
|
|
</span>
|
|
)}
|
|
<span
|
|
className='ml-2 truncate text-ellipsis font-sans text-sm font-light'
|
|
style={{
|
|
maxWidth: 'calc(100% - 24px)',
|
|
whiteSpace: 'nowrap',
|
|
textOverflow: 'ellipsis',
|
|
}}
|
|
>
|
|
{item.label}
|
|
</span>
|
|
</span>
|
|
{item.subitems && isExpanded && (
|
|
<ol role='group'>
|
|
{item.subitems.map((subitem) => (
|
|
<TOCItemView
|
|
bookKey={bookKey}
|
|
key={subitem.label}
|
|
item={subitem}
|
|
depth={depth + 1}
|
|
setCurrentHref={setCurrentHref}
|
|
currentHref={currentHref}
|
|
expandedItems={expandedItems}
|
|
/>
|
|
))}
|
|
</ol>
|
|
)}
|
|
</li>
|
|
);
|
|
};
|
|
|
|
const TOCView: React.FC<{
|
|
bookKey: string;
|
|
toc: TOCItem[];
|
|
currentHref: string | null;
|
|
}> = ({ bookKey, toc, currentHref: href }) => {
|
|
const [currentHref, setCurrentHref] = useState<string | null>(href);
|
|
const [expandedItems, setExpandedItems] = useState<string[]>([]);
|
|
const { getFoliateView } = useReaderStore();
|
|
const tocRef = useRef<HTMLUListElement | null>(null);
|
|
|
|
const tocRelocateHandler = (event: Event) => {
|
|
const detail = (event as CustomEvent).detail;
|
|
const { tocItem } = detail;
|
|
if (tocItem?.href) {
|
|
setCurrentHref(tocItem.href);
|
|
}
|
|
};
|
|
|
|
const foliateView = getFoliateView(bookKey);
|
|
useFoliateEvents(bookKey, foliateView, { onRelocate: tocRelocateHandler });
|
|
|
|
useEffect(() => {
|
|
setCurrentHref(href);
|
|
}, [href]);
|
|
|
|
const expandParents = (toc: TOCItem[], href: string) => {
|
|
const parentPath = findParentPath(toc, href).map((item) => item.href);
|
|
setExpandedItems(parentPath.filter(Boolean) as string[]);
|
|
};
|
|
|
|
useEffect(() => {
|
|
const hrefMd5 = currentHref ? getHrefMd5(currentHref) : '';
|
|
const currentItem = tocRef.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: 'smooth', block: 'center' });
|
|
}
|
|
(currentItem as HTMLElement).setAttribute('aria-current', 'page');
|
|
}
|
|
if (currentHref) {
|
|
expandParents(toc, currentHref);
|
|
}
|
|
}, [toc, currentHref]);
|
|
|
|
return (
|
|
<div className='relative'>
|
|
<div className='max-h-[calc(100vh-173px)] overflow-y-auto rounded border'>
|
|
<ul role='tree' ref={tocRef} className='overflow-y-auto px-2'>
|
|
{toc &&
|
|
toc.map((item) => (
|
|
<TOCItemView
|
|
bookKey={bookKey}
|
|
key={item.label}
|
|
item={item}
|
|
depth={0}
|
|
setCurrentHref={setCurrentHref}
|
|
currentHref={currentHref}
|
|
expandedItems={expandedItems}
|
|
/>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default TOCView;
|