@@ -1,4 +1,4 @@
1−import { useState, useEffect } from "react";
1+import { useState, useEffect, useRef } from "react";
2import { useRouter } from "next/router";
2import { useRouter } from "next/router";
3import Head from "next/head";
3import Head from "next/head";
4import ErrorPage from "../pages/404";
4import ErrorPage from "../pages/404";
@@ -12,13 +12,14 @@ import { decode } from "html-entities";
13const TableOfContents = () => {
13const TableOfContents = () => {
14 const { nestedHeadings } = useHeadingsData();
14 const { nestedHeadings } = useHeadingsData();
15+ const [activeId, setActiveId] = useState();
16+ useIntersectionObserver(setActiveId);
17− className="sticky min-h-0 h-screen overflow-y-scroll w-52 flex-shrink-0 pb-8 hidden lg:block"
19+ className="sticky min-h-0 overflow-y-scroll w-52 flex-shrink-0 pb-24 hidden lg:block"
18− style={{ top: 0 }}
20+ style={{ top: "8rem", height: "calc(100vh - 16rem)" }}
21− <Headings headings={nestedHeadings} />
22+ <Headings headings={nestedHeadings} activeId={activeId} />
@@ -55,17 +56,20 @@ const useHeadingsData = () => {
55 return { nestedHeadings };
56 return { nestedHeadings };
58−const Headings = ({ headings }) => (
59+const Headings = ({ headings, activeId }) => (
60 {headings.map((heading) => (
61 {headings.map((heading) => (
61 <li key={heading.id}>
62 <li key={heading.id}>
62− <a className="font-semibold text-sm" href={`#${heading.id}`}>
63+ <a className="font-semibold" href={`#${heading.id}`}>
63 {heading.title}
64 {heading.title}
65 {heading.items.length > 0 && (
66 {heading.items.length > 0 && (
66− <ul className="pl-2 text-sm">
67+ <ul className="pl-2">
67 {heading.items.map((child) => (
68 {heading.items.map((child) => (
68− <li key={child.id}>
70+ key={child.id}
71+ className={child.id === activeId ? "text-green" : "text-gray"}
69 <a href={`#${child.id}`}>{child.title}</a>
73 <a href={`#${child.id}`}>{child.title}</a>
@@ -76,6 +80,46 @@ const Headings = ({ headings }) => (
83+const useIntersectionObserver = (setActiveId) => {
84+ const headingElementsRef = useRef({});
86+ const callback = (headings) => {
87+ headingElementsRef.current = headings.reduce((map, headingElement) => {
88+ map[headingElement.target.id] = headingElement;
90+ }, headingElementsRef.current);
92+ const visibleHeadings = [];
93+ Object.keys(headingElementsRef.current).forEach((key) => {
94+ const headingElement = headingElementsRef.current[key];
95+ if (headingElement.isIntersecting) visibleHeadings.push(headingElement);
98+ const getIndexFromId = (id) =>
99+ headingElements.findIndex((heading) => heading.id === id);
101+ if (visibleHeadings.length === 1) {
102+ setActiveId(visibleHeadings[0].target.id);
103+ } else if (visibleHeadings.length > 1) {
104+ const sortedVisibleHeadings = visibleHeadings.sort(
105+ (a, b) => getIndexFromId(a.target.id) > getIndexFromId(b.target.id)
107+ setActiveId(sortedVisibleHeadings[0].target.id);
111+ const observer = new IntersectionObserver(callback, {
112+ rootMargin: "0px 0px -50% 0px",
115+ const headingElements = Array.from(document.querySelectorAll("h2, h3"));
117+ headingElements.forEach((element) => observer.observe(element));
119+ return () => observer.disconnect();
79export default function PageWithIndex({ post, markdown, search }) {
123export default function PageWithIndex({ post, markdown, search }) {
80 const router = useRouter();
124 const router = useRouter();
81 if (!router.isFallback && !post?.slug) {
125 if (!router.isFallback && !post?.slug) {