TypeScript · 2622 bytes Raw Blame History
1 "use client";
2
3 import { useEffect, useState } from "react";
4 import { usePathname } from "next/navigation";
5
6 interface Heading {
7 id: string;
8 text: string;
9 level: number;
10 }
11
12 export default function TableOfContents() {
13 const [headings, setHeadings] = useState<Heading[]>([]);
14 const [activeId, setActiveId] = useState<string>("");
15 const pathname = usePathname();
16
17 useEffect(() => {
18 setHeadings([]);
19 setActiveId("");
20
21 const timer = setTimeout(() => {
22 const article = document.querySelector("article");
23 if (!article) return;
24
25 const elements = article.querySelectorAll("h2, h3");
26 const items: Heading[] = [];
27
28 elements.forEach((el) => {
29 if (!el.id) {
30 el.id =
31 el.textContent
32 ?.toLowerCase()
33 .replace(/[^a-z0-9]+/g, "-")
34 .replace(/(^-|-$)/g, "") || "";
35 }
36
37 items.push({
38 id: el.id,
39 text: el.textContent || "",
40 level: el.tagName === "H2" ? 2 : 3,
41 });
42 });
43
44 setHeadings(items);
45 }, 100);
46
47 return () => clearTimeout(timer);
48 }, [pathname]);
49
50 useEffect(() => {
51 if (headings.length === 0) return;
52
53 const observer = new IntersectionObserver(
54 (entries) => {
55 entries.forEach((entry) => {
56 if (entry.isIntersecting) {
57 setActiveId(entry.target.id);
58 }
59 });
60 },
61 { rootMargin: "-80px 0px -80% 0px" }
62 );
63
64 headings.forEach((heading) => {
65 const el = document.getElementById(heading.id);
66 if (el) observer.observe(el);
67 });
68
69 return () => observer.disconnect();
70 }, [headings]);
71
72 if (headings.length === 0) return null;
73
74 return (
75 <nav className="w-56 shrink-0 hidden xl:block">
76 <div className="sticky top-8 p-4">
77 <h4 className="text-sm font-semibold text-surface-900 dark:text-surface-100 mb-4">
78 On This Page
79 </h4>
80 <ul className="space-y-2 text-sm">
81 {headings.map((heading) => (
82 <li key={heading.id}>
83 <a
84 href={`#${heading.id}`}
85 className={`block transition-colors ${
86 heading.level === 3 ? "pl-3" : ""
87 } ${
88 activeId === heading.id
89 ? "text-surface-900 dark:text-surface-100 font-medium"
90 : "text-surface-500 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-100"
91 }`}
92 >
93 {heading.text}
94 </a>
95 </li>
96 ))}
97 </ul>
98 </div>
99 </nav>
100 );
101 }