308 lines
13 KiB
TypeScript
308 lines
13 KiB
TypeScript
import React, { useEffect, useState } from "react";
|
||
import { Button, Tooltip } from "antd";
|
||
import { CaretDownOutlined, CloseOutlined, MenuOutlined } from "@ant-design/icons";
|
||
import classNames from "classnames";
|
||
import Style from './tocView.module.less';
|
||
|
||
export type TocItem = {
|
||
id: string;
|
||
level: number;
|
||
text: string;
|
||
}
|
||
|
||
export function TocView(
|
||
props: {
|
||
toc: TocItem[];
|
||
showToc: boolean;
|
||
tocPosition: 'left' | 'right';
|
||
setShowToc: (showToc: boolean) => void;
|
||
highlightBgColor?: string; //暂时废弃背景色改为高亮文字
|
||
activeColor?: string;
|
||
headerTop?: number;
|
||
scrollId?: string;
|
||
closed?: boolean,
|
||
tocWidth?: number,
|
||
tocHeight?: number | string,
|
||
title?: string,
|
||
}
|
||
) {
|
||
const { toc, showToc, tocPosition, setShowToc, highlightBgColor, activeColor = 'var(--oak-color-primary)', headerTop = 0, scrollId, closed = false, tocWidth, tocHeight = '100vh', title } = props;
|
||
|
||
// useEffect(() => {
|
||
// document.documentElement.style.setProperty('--highlight-bg-color', highlightBgColor);
|
||
// }, [highlightBgColor]);
|
||
useEffect(() => {
|
||
document.documentElement.style.setProperty('--active-color', activeColor);
|
||
}, [activeColor]);
|
||
|
||
const generateTocList = (items: TocItem[], currentLevel: number = 1, parentId: string | null = null): React.ReactNode => {
|
||
//递归生成嵌套列表
|
||
const result: React.ReactNode[] = [];
|
||
let lastId: string | null = parentId;
|
||
|
||
while (items.length > 0) {
|
||
const item = items[0];
|
||
//有无子级标题,有则显示展开图标
|
||
const hasChildren = items.length > 1 && items[1].level > item.level;
|
||
|
||
if (item.level > currentLevel) {
|
||
// 递归生成子列表
|
||
const subList = generateTocList(items, item.level, item.id);
|
||
result.push(
|
||
<li key={item.id} id={`ul-${lastId}`} >
|
||
<ul style={{ listStyleType: 'none', paddingInlineStart: '6px' }}>
|
||
{subList}
|
||
</ul>
|
||
</li>
|
||
);
|
||
} else if (item.level < currentLevel) {
|
||
// 结束当前层级
|
||
break;
|
||
} else {
|
||
// 添加当前层级的 <li>
|
||
result.push(
|
||
<li className={Style.listItem} key={item.id} id={`li-${item.id}`} style={{ paddingLeft: `${(item.level - 1) * 6}px`, }}>
|
||
<CaretDownOutlined id={`icon-${item.id}`} className={Style.icon} style={{ visibility: hasChildren && item.level < 5 ? 'visible' : 'hidden', color: '#A5A5A5' }}
|
||
onClick={() => {
|
||
const iconElem = document.getElementById(`icon-${item.id}`);
|
||
const ulElem = document.getElementById(`ul-${item.id}`);
|
||
const isFolded = iconElem?.className.includes('iconFold') || ulElem?.className.includes('fold');
|
||
if (isFolded) {
|
||
iconElem?.classList.remove(Style.iconFold);
|
||
ulElem?.classList.remove(Style.fold);
|
||
} else {
|
||
iconElem?.classList.add(Style.iconFold);
|
||
ulElem?.classList.add(Style.fold);
|
||
}
|
||
}} />
|
||
<div
|
||
style={{ fontSize: '1em', fontWeight: item.level === 1 ? 'bold' : 'normal' }}
|
||
className={Style.tocItem}
|
||
// onClick={(event: any) => {
|
||
// //页面滚动到对应元素
|
||
// const elem = document.getElementById(item.id);
|
||
// const elemTop = elem?.getBoundingClientRect().top;
|
||
|
||
// if (scrollId) {
|
||
// const scrollContainer = document.getElementById(scrollId);
|
||
// const containerTop = scrollContainer?.getBoundingClientRect().top;
|
||
|
||
// scrollContainer?.scrollBy({
|
||
// top: elemTop! - containerTop!,
|
||
// behavior: 'smooth',
|
||
// });
|
||
// }
|
||
// else {
|
||
// // const containerTop = document.body.getBoundingClientRect().top;
|
||
// window.scrollBy({
|
||
// top: elemTop! - headerTop,
|
||
// behavior: 'smooth',
|
||
// });
|
||
// }
|
||
|
||
// //添加背景色
|
||
// elem?.classList.add(Style.highlight);
|
||
// //移除背景色类名
|
||
// setTimeout(function () {
|
||
// elem?.classList.remove(Style.highlight);
|
||
// }, 1000);
|
||
|
||
// event.preventDefault();
|
||
// event.stopPropagation();
|
||
// }}
|
||
>
|
||
{item.text}
|
||
</div>
|
||
</li >
|
||
);
|
||
items.shift(); // 移除已处理的项
|
||
lastId = item.id;
|
||
}
|
||
}
|
||
return result;
|
||
};
|
||
|
||
useEffect(() => {
|
||
const tocContainer = document.getElementById('tocContainer');
|
||
const tocItems = tocContainer?.querySelectorAll('li');
|
||
let sections = [];
|
||
for (const t of toc) {
|
||
const item = document.getElementById(t.id);
|
||
sections.push(item!);
|
||
}
|
||
|
||
let currentHighlighted = '';
|
||
let isClick = false;
|
||
let clickTimeout: NodeJS.Timeout | undefined = undefined;
|
||
|
||
// 滚动目录以确保指定目录项可见
|
||
function scrollToTocItem(item: any) {
|
||
if (tocContainer) {
|
||
const tocRect = tocContainer.getBoundingClientRect();
|
||
const itemRect = item.getBoundingClientRect();
|
||
|
||
// 检查目录项是否在可视区域内
|
||
if (itemRect.top < tocRect.top || itemRect.bottom > tocRect.bottom) {
|
||
const itemOffsetTop = item.offsetTop;
|
||
const containerHeight = tocContainer.clientHeight;
|
||
const itemHeight = item.offsetHeight;
|
||
|
||
let targetScrollTop;
|
||
if (itemRect.top < tocRect.top) {
|
||
// 目录项在可视区域上方
|
||
targetScrollTop = itemOffsetTop - 80;
|
||
} else {
|
||
// 目录项在可视区域下方
|
||
targetScrollTop = itemOffsetTop - containerHeight + itemHeight + 10;
|
||
}
|
||
|
||
tocContainer.scrollTo({
|
||
top: targetScrollTop,
|
||
behavior: 'smooth'
|
||
});
|
||
|
||
}
|
||
}
|
||
}
|
||
|
||
// 高亮指定的目录项
|
||
function highlightTocItem(targetId: string, source: 'click' | 'scroll') {
|
||
// 移除所有目录项的active样式
|
||
tocItems?.forEach(item => {
|
||
item.classList.remove(Style.active);
|
||
});
|
||
|
||
// 找到对应的目录项并添加active样式
|
||
const correspondingTocItem = document.querySelector(`#${targetId}`);
|
||
if (correspondingTocItem) {
|
||
correspondingTocItem.classList.add(Style.active);
|
||
|
||
// 滚动目录
|
||
scrollToTocItem(correspondingTocItem);
|
||
|
||
// 如果是点击触发的,设置超时
|
||
if (source === 'click') {
|
||
isClick = true;
|
||
|
||
// 设置超时,1秒后恢复滚动的高亮功能
|
||
clearTimeout(clickTimeout);
|
||
clickTimeout = setTimeout(() => {
|
||
isClick = false;
|
||
}, 1000);
|
||
}
|
||
currentHighlighted = targetId;
|
||
}
|
||
}
|
||
|
||
// 点击目录项平滑滚动到对应区块并高亮
|
||
const handleClick = function (event: any) {
|
||
const targetSection = event.target.closest('li');
|
||
const targetId = targetSection.getAttribute('id');
|
||
if (targetSection) {
|
||
// 高亮点击的目录项
|
||
highlightTocItem(targetId, 'click');
|
||
|
||
// 平滑滚动到目标位置
|
||
const itemId = targetId.substring(3);
|
||
const elem = document.getElementById(itemId);
|
||
const elemTop = elem?.getBoundingClientRect().top;
|
||
if (scrollId) {
|
||
const scrollContainer = document.getElementById(scrollId);
|
||
const containerTop = scrollContainer?.getBoundingClientRect().top;
|
||
scrollContainer?.scrollBy({
|
||
top: elemTop! - containerTop!,
|
||
behavior: 'smooth',
|
||
});
|
||
|
||
}
|
||
else {
|
||
window.scrollBy({
|
||
top: elemTop! - headerTop,
|
||
behavior: 'smooth',
|
||
});
|
||
}
|
||
}
|
||
}
|
||
tocItems?.forEach(item => {
|
||
item.addEventListener('click', handleClick);
|
||
});
|
||
|
||
// 创建Intersection Observer实例
|
||
const observer = new IntersectionObserver(entries => {
|
||
// 如果处于点击状态,跳过滚动检测
|
||
if (isClick) return;
|
||
|
||
entries.forEach(entry => {
|
||
if (entry.isIntersecting) {
|
||
// 获取当前可见区块的ID
|
||
const id = `li-${entry.target.getAttribute('id')}`;
|
||
|
||
if (id && id !== currentHighlighted) {
|
||
highlightTocItem(id, 'scroll');
|
||
}
|
||
}
|
||
});
|
||
}, {
|
||
threshold: 0.5,
|
||
rootMargin: '0px 0px -75% 0px',
|
||
});
|
||
|
||
// 开始观察所有内容区块
|
||
sections?.forEach(section => {
|
||
if (section) {
|
||
observer.observe(section);
|
||
}
|
||
});
|
||
|
||
return () => {
|
||
if (tocContainer) {
|
||
tocContainer.removeEventListener('click', handleClick);
|
||
observer.disconnect();
|
||
if (clickTimeout) {
|
||
clearTimeout(clickTimeout);
|
||
}
|
||
}
|
||
};
|
||
}, [toc, showToc]);
|
||
|
||
return (
|
||
<div
|
||
className={Style.tocContainer}
|
||
style={Object.assign({}, tocWidth ? { width: tocWidth } : {}, tocHeight ? { height: tocHeight } : {})}
|
||
>
|
||
{showToc ? (
|
||
<>
|
||
<div className={Style.catalogTitle}>
|
||
<div style={{ color: '#A5A5A5' }}>{title ?? '大纲'}</div>
|
||
{closed ? (<CloseOutlined style={{ color: '#A5A5A5' }} onClick={() => setShowToc(false)} />) : null}
|
||
</div>
|
||
|
||
{(toc && toc.length > 0) ? (
|
||
<ul id="tocContainer" style={{ listStyleType: 'none', paddingInlineStart: '0px', overflowX: 'hidden', overflowY: 'auto', height: '100%', borderLeft: tocPosition === 'right' ? '1px solid var(--oak-border-color)' : '' }}>{generateTocList([...toc])}</ul>
|
||
) : (
|
||
<div style={{ display: 'flex', alignItems: 'center', color: '#B1B1B1', height: '200px' }}>
|
||
<div>
|
||
<div>
|
||
对文档内容应用“标题”样式
|
||
</div>
|
||
<div>
|
||
即可生成大纲
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
) : (
|
||
<div className={classNames(Style.tocButton, { [Style.tocButtonRight]: tocPosition === 'right' })}>
|
||
<Tooltip title="显示大纲" placement={tocPosition === 'right' ? 'left' : 'right'}>
|
||
<Button
|
||
size="small"
|
||
icon={<MenuOutlined />}
|
||
onClick={() => setShowToc(true)}
|
||
/>
|
||
</Tooltip>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
} |