114 lines
3.0 KiB
TypeScript
114 lines
3.0 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useCallback } from 'react';
|
|
import { cn } from '@/lib/utils';
|
|
import type { TocItem } from './article-stream';
|
|
|
|
interface TableOfContentsProps {
|
|
headings: TocItem[];
|
|
}
|
|
|
|
export function TableOfContents({ headings }: TableOfContentsProps) {
|
|
const [activeId, setActiveId] = useState<string>('');
|
|
|
|
const updateActiveHeading = useCallback(() => {
|
|
if (headings.length === 0) return;
|
|
|
|
const headingElements = headings
|
|
.map((h) => ({
|
|
id: h.id,
|
|
element: document.getElementById(h.id),
|
|
}))
|
|
.filter((h) => h.element !== null);
|
|
|
|
if (headingElements.length === 0) return;
|
|
|
|
// Find the heading that's closest to the top of the viewport but still visible or just passed
|
|
const scrollPosition = window.scrollY + 120; // Account for sticky header
|
|
|
|
let currentHeading = headingElements[0].id;
|
|
|
|
for (const { id, element } of headingElements) {
|
|
if (element) {
|
|
const elementTop = element.offsetTop;
|
|
if (elementTop <= scrollPosition) {
|
|
currentHeading = id;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
setActiveId(currentHeading);
|
|
}, [headings]);
|
|
|
|
useEffect(() => {
|
|
// Initial update
|
|
updateActiveHeading();
|
|
|
|
// Throttled scroll handler
|
|
let ticking = false;
|
|
const handleScroll = () => {
|
|
if (!ticking) {
|
|
window.requestAnimationFrame(() => {
|
|
updateActiveHeading();
|
|
ticking = false;
|
|
});
|
|
ticking = true;
|
|
}
|
|
};
|
|
|
|
window.addEventListener('scroll', handleScroll, { passive: true });
|
|
|
|
return () => {
|
|
window.removeEventListener('scroll', handleScroll);
|
|
};
|
|
}, [updateActiveHeading]);
|
|
|
|
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>, id: string) => {
|
|
e.preventDefault();
|
|
const element = document.getElementById(id);
|
|
if (element) {
|
|
const yOffset = -100;
|
|
const y = element.getBoundingClientRect().top + window.pageYOffset + yOffset;
|
|
window.scrollTo({ top: y, behavior: 'smooth' });
|
|
setActiveId(id);
|
|
}
|
|
};
|
|
|
|
if (headings.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<nav className="sticky top-24">
|
|
<h4 className="text-sm font-semibold text-foreground mb-4">
|
|
On this page
|
|
</h4>
|
|
<ul className="space-y-1 text-sm border-l border-border">
|
|
{headings.map((heading) => (
|
|
<li
|
|
key={heading.id}
|
|
className={cn(
|
|
heading.level === 3 && 'ml-3'
|
|
)}
|
|
>
|
|
<a
|
|
href={`#${heading.id}`}
|
|
onClick={(e) => handleClick(e, heading.id)}
|
|
className={cn(
|
|
'block py-1.5 pl-4 -ml-px border-l-2 transition-colors',
|
|
activeId === heading.id
|
|
? 'border-blue-500 text-foreground font-medium'
|
|
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-muted-foreground'
|
|
)}
|
|
>
|
|
{heading.text}
|
|
</a>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</nav>
|
|
);
|
|
}
|