mosaicmap/components/table-of-contents.tsx
2025-08-19 12:17:11 +08:00

158 lines
5.5 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) => {
const element = document.getElementById(id);
if (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}>
<button
onClick={(e) => {
e.preventDefault();
scrollToHeading(item.id);
}}
className={cn(
"block w-full text-left py-1.5 px-2 text-xs rounded transition-all duration-200",
"hover:bg-gray-100/50 dark:hover:bg-gray-800/50 cursor-pointer",
"truncate overflow-hidden whitespace-nowrap",
{
'text-white font-bold': activeId === item.id,
'text-gray-500 dark:text-gray-400 font-normal': activeId !== item.id,
}
)}
style={{
paddingLeft: `${(item.level - 1) * 8 + 8}px`,
}}
type="button"
title={item.text} // Add tooltip for full text when truncated
>
{item.text}
</button>
</li>
))}
</ul>
</div>
</div>
</nav>
);
}