mirror of
https://github.com/waynesutton/markdown-site.git
synced 2026-01-12 04:09:14 +00:00
130 lines
3.5 KiB
TypeScript
130 lines
3.5 KiB
TypeScript
import { useState, useEffect, useRef, useCallback } from "react";
|
|
import type { Heading } from "../utils/extractHeadings";
|
|
|
|
interface DocsTOCProps {
|
|
headings: Heading[];
|
|
}
|
|
|
|
// Get absolute position of element from top of document
|
|
function getElementTop(element: HTMLElement): number {
|
|
const rect = element.getBoundingClientRect();
|
|
return rect.top + window.scrollY;
|
|
}
|
|
|
|
export default function DocsTOC({ headings }: DocsTOCProps) {
|
|
const [activeId, setActiveId] = useState<string>("");
|
|
const isNavigatingRef = useRef(false);
|
|
|
|
// Scroll tracking to highlight active heading
|
|
useEffect(() => {
|
|
if (headings.length === 0) return;
|
|
|
|
const handleScroll = () => {
|
|
// Skip during programmatic navigation
|
|
if (isNavigatingRef.current) return;
|
|
|
|
const scrollPosition = window.scrollY + 120; // Header offset
|
|
|
|
// Find the heading that's currently in view
|
|
let currentId = "";
|
|
for (const heading of headings) {
|
|
const element = document.getElementById(heading.id);
|
|
if (element) {
|
|
const top = getElementTop(element);
|
|
if (scrollPosition >= top) {
|
|
currentId = heading.id;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
setActiveId(currentId);
|
|
};
|
|
|
|
// Initial check
|
|
handleScroll();
|
|
|
|
window.addEventListener("scroll", handleScroll, { passive: true });
|
|
return () => window.removeEventListener("scroll", handleScroll);
|
|
}, [headings]);
|
|
|
|
// Navigate to heading
|
|
const navigateToHeading = useCallback((id: string) => {
|
|
const element = document.getElementById(id);
|
|
if (!element) return;
|
|
|
|
isNavigatingRef.current = true;
|
|
setActiveId(id);
|
|
|
|
// Scroll with header offset
|
|
const headerOffset = 80;
|
|
const elementTop = getElementTop(element);
|
|
const targetPosition = elementTop - headerOffset;
|
|
|
|
window.scrollTo({
|
|
top: Math.max(0, targetPosition),
|
|
behavior: "smooth",
|
|
});
|
|
|
|
// Update URL hash
|
|
window.history.pushState(null, "", `#${id}`);
|
|
|
|
// Re-enable scroll tracking after animation
|
|
setTimeout(() => {
|
|
isNavigatingRef.current = false;
|
|
}, 500);
|
|
}, []);
|
|
|
|
// Handle hash changes (browser back/forward)
|
|
useEffect(() => {
|
|
const handleHashChange = () => {
|
|
const hash = window.location.hash.slice(1);
|
|
if (hash && headings.some((h) => h.id === hash)) {
|
|
navigateToHeading(hash);
|
|
}
|
|
};
|
|
|
|
window.addEventListener("hashchange", handleHashChange);
|
|
return () => window.removeEventListener("hashchange", handleHashChange);
|
|
}, [headings, navigateToHeading]);
|
|
|
|
// Initial hash navigation on mount
|
|
useEffect(() => {
|
|
const hash = window.location.hash.slice(1);
|
|
if (hash && headings.some((h) => h.id === hash)) {
|
|
// Delay to ensure DOM is ready
|
|
requestAnimationFrame(() => {
|
|
navigateToHeading(hash);
|
|
});
|
|
}
|
|
}, [headings, navigateToHeading]);
|
|
|
|
// No headings, don't render
|
|
if (headings.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<nav className="docs-toc">
|
|
<h3 className="docs-toc-title">On this page</h3>
|
|
<ul className="docs-toc-list">
|
|
{headings.map((heading) => (
|
|
<li key={heading.id} className="docs-toc-item">
|
|
<a
|
|
href={`#${heading.id}`}
|
|
className={`docs-toc-link level-${heading.level} ${activeId === heading.id ? "active" : ""}`}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
navigateToHeading(heading.id);
|
|
}}
|
|
>
|
|
{heading.text}
|
|
</a>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</nav>
|
|
);
|
|
}
|