165 lines
5.8 KiB
TypeScript
165 lines
5.8 KiB
TypeScript
"use client";
|
||
import { useEffect, useState, useRef, useCallback } from 'react';
|
||
import { cn } from '@/lib/utils';
|
||
|
||
export interface TOCItem {
|
||
id: string;
|
||
text: string;
|
||
level: number;
|
||
element: HTMLElement;
|
||
}
|
||
|
||
interface TableOfContentsProps {
|
||
className?: string;
|
||
}
|
||
|
||
export default function TableOfContents({ className }: TableOfContentsProps) {
|
||
const [toc, setToc] = useState<TOCItem[]>([]);
|
||
const [activeId, setActiveId] = useState<string>('');
|
||
const observerRef = useRef<IntersectionObserver | null>(null);
|
||
|
||
// 提取页面中的标题
|
||
const extractHeadings = useCallback(() => {
|
||
const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
||
const tocItems: TOCItem[] = [];
|
||
|
||
headings.forEach((heading, index) => {
|
||
const element = heading as HTMLElement;
|
||
const text = element.textContent || '';
|
||
const level = parseInt(element.tagName.charAt(1));
|
||
|
||
// 为标题添加 ID(如果没有的话)
|
||
let id = element.id;
|
||
if (!id) {
|
||
id = text
|
||
.toLowerCase()
|
||
.replace(/[^\w\s-]/g, '') // 移除特殊字符
|
||
.replace(/[\s_-]+/g, '-') // 替换空格和下划线为连字符
|
||
.replace(/^-+|-+$/g, ''); // 移除首尾的连字符
|
||
|
||
// 确保 ID 唯一
|
||
let uniqueId = id;
|
||
let counter = 1;
|
||
while (document.getElementById(uniqueId)) {
|
||
uniqueId = `${id}-${counter}`;
|
||
counter++;
|
||
}
|
||
|
||
element.id = uniqueId;
|
||
id = uniqueId;
|
||
}
|
||
|
||
tocItems.push({
|
||
id,
|
||
text,
|
||
level,
|
||
element
|
||
});
|
||
});
|
||
|
||
setToc(tocItems);
|
||
return tocItems;
|
||
}, []);
|
||
|
||
// 设置 Intersection Observer 来追踪当前可见的标题
|
||
useEffect(() => {
|
||
const headings = extractHeadings();
|
||
|
||
if (headings.length === 0) return;
|
||
|
||
observerRef.current = new IntersectionObserver(
|
||
(entries) => {
|
||
// 找到所有可见的标题
|
||
const visibleHeadings = entries
|
||
.filter(entry => entry.isIntersecting)
|
||
.map(entry => entry.target.id);
|
||
|
||
if (visibleHeadings.length > 0) {
|
||
// 设置第一个可见标题为活跃状态
|
||
setActiveId(visibleHeadings[0]);
|
||
}
|
||
},
|
||
{
|
||
rootMargin: '-80px 0px -80%',
|
||
threshold: 0
|
||
}
|
||
);
|
||
|
||
// 观察所有标题
|
||
headings.forEach((item) => {
|
||
observerRef.current?.observe(item.element);
|
||
});
|
||
|
||
return () => {
|
||
observerRef.current?.disconnect();
|
||
};
|
||
}, [extractHeadings]);
|
||
|
||
// 点击目录项滚动到对应位置
|
||
const scrollToHeading = useCallback((id: string) => {
|
||
console.log('Scrolling to:', id); // 调试日志
|
||
const element = document.getElementById(id);
|
||
if (element) {
|
||
console.log('Element found:', element); // 调试日志
|
||
const offsetTop = element.getBoundingClientRect().top + window.pageYOffset - 100;
|
||
window.scrollTo({
|
||
top: offsetTop,
|
||
behavior: 'smooth'
|
||
});
|
||
|
||
// 更新活跃状态
|
||
setActiveId(id);
|
||
} else {
|
||
console.log('Element not found for id:', id); // 调试日志
|
||
}
|
||
}, []);
|
||
|
||
if (toc.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<nav className={className}>
|
||
<div className=" shadow-lg p-4 max-w-xs">
|
||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-4 uppercase tracking-wider">
|
||
Navigation
|
||
</h3>
|
||
<div className={"max-h-[60vh] overflow-y-auto"}>
|
||
<ul className="relative space-y-1">
|
||
{toc.map((item) => (
|
||
<li
|
||
key={item.id}
|
||
className={cn({
|
||
'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20 font-medium': activeId === item.id
|
||
})}
|
||
>
|
||
<button
|
||
onClick={(e) => {
|
||
e.preventDefault();
|
||
scrollToHeading(item.id);
|
||
}}
|
||
className={cn(
|
||
"block w-full text-left py-1.5 px-2 text-xs rounded transition-colors duration-200",
|
||
"hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer",
|
||
{
|
||
'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20 font-medium':
|
||
activeId === item.id,
|
||
'text-gray-600 dark:text-gray-400':
|
||
activeId !== item.id,
|
||
}
|
||
)}
|
||
style={{
|
||
paddingLeft: `${(item.level - 1) * 8 + 8}px`,
|
||
}}
|
||
type="button"
|
||
>
|
||
{item.text}
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</nav>
|
||
);
|
||
} |