mosaicmap/components/table-of-contents.tsx
2025-08-12 21:25:52 +08:00

165 lines
5.8 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.

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