Files
wikiplus/components/table-of-contents.tsx
T
2026-01-24 16:52:19 -06:00

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