table-of-contents.tsx 2.1 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283
  1. "use client";
  2. import { useEffect, useState } from "react";
  3. import { usePathname } from "next/navigation";
  4. import { cn } from "@/lib/utils";
  5. type Heading = {
  6. id: string;
  7. text: string;
  8. level: number;
  9. };
  10. function getHeadings(): Heading[] {
  11. const article = document.querySelector("article");
  12. if (!article) return [];
  13. const elements = article.querySelectorAll("h2[id], h3[id]");
  14. return Array.from(elements).map((el) => ({
  15. id: el.id,
  16. text: el.textContent?.replace(/#$/, "").trim() ?? "",
  17. level: el.tagName === "H3" ? 3 : 2,
  18. }));
  19. }
  20. export function TableOfContents() {
  21. const pathname = usePathname();
  22. const [headings, setHeadings] = useState<Heading[]>([]);
  23. const [activeId, setActiveId] = useState<string>("");
  24. useEffect(() => {
  25. const timer = setTimeout(() => setHeadings(getHeadings()), 100);
  26. return () => clearTimeout(timer);
  27. }, [pathname]);
  28. useEffect(() => {
  29. if (headings.length === 0) return;
  30. const observer = new IntersectionObserver(
  31. (entries) => {
  32. for (const entry of entries) {
  33. if (entry.isIntersecting) {
  34. setActiveId(entry.target.id);
  35. }
  36. }
  37. },
  38. { rootMargin: "0px 0px -75% 0px", threshold: 0.1 },
  39. );
  40. for (const h of headings) {
  41. const el = document.getElementById(h.id);
  42. if (el) observer.observe(el);
  43. }
  44. return () => observer.disconnect();
  45. }, [headings]);
  46. if (headings.length === 0) return null;
  47. return (
  48. <nav aria-label="On this page">
  49. <h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-3">
  50. On this page
  51. </h4>
  52. <ul className="space-y-1">
  53. {headings.map((h) => (
  54. <li key={h.id}>
  55. <a
  56. href={`#${h.id}`}
  57. className={cn(
  58. "block text-xs leading-relaxed py-0.5 transition-colors",
  59. h.level === 3 && "pl-3",
  60. activeId === h.id
  61. ? "text-foreground"
  62. : "text-muted-foreground hover:text-foreground",
  63. )}
  64. >
  65. {h.text}
  66. </a>
  67. </li>
  68. ))}
  69. </ul>
  70. </nav>
  71. );
  72. }