oak-general-business/src/components/article/toc/tocView.tsx

308 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
)
}