From 24859da9ff31e9f61a6b71a44f4b99572e15383d Mon Sep 17 00:00:00 2001
From: p-sw <shinwoo.park@psw.kr>
Date: Sun, 2 Jun 2024 10:23:50 +0900
Subject: [PATCH] feat: add toc highlight

---
 packages/react/src/App.tsx            | 67 ++++++++++++++++++++++++++-
 packages/react/src/DynamicLayout.tsx  | 14 ++++--
 packages/react/src/HeadingContext.tsx | 12 +++++
 3 files changed, 88 insertions(+), 5 deletions(-)
 create mode 100644 packages/react/src/HeadingContext.tsx

diff --git a/packages/react/src/App.tsx b/packages/react/src/App.tsx
index 288aed2..71b0d02 100644
--- a/packages/react/src/App.tsx
+++ b/packages/react/src/App.tsx
@@ -27,7 +27,66 @@ import ComponentsCheckbox, {
 import ComponentsDialog, {
   tableOfContents as componentsDialogToc,
 } from "./docs/components/Dialog.mdx";
-import { forwardRef } from "react";
+import { ForwardedRef, forwardRef, useContext, useEffect, useRef } from "react";
+import { HeadingContext } from "./HeadingContext";
+
+function buildThresholdList() {
+  let thresholds = [];
+  let numSteps = 20;
+
+  for (let i = 1.0; i <= numSteps; i++) {
+    let ratio = i / numSteps;
+    thresholds.push(ratio);
+  }
+
+  thresholds.push(0);
+  return thresholds;
+}
+
+function HashedHeaders(Level: `h${1 | 2 | 3 | 4 | 5 | 6}`) {
+  return (prop: any, ref: ForwardedRef<HTMLHeadingElement>) => {
+    const internalRef = useRef<HTMLHeadingElement | null>(null);
+    const [_, setActiveHeadings] = useContext(HeadingContext);
+
+    useEffect(() => {
+      const observer = new IntersectionObserver(
+        ([{ target, intersectionRatio }]) => {
+          if (intersectionRatio > 0.5) {
+            setActiveHeadings((prev) => [...prev, target.id]);
+          } else {
+            setActiveHeadings((prev) => prev.filter((id) => id !== target.id));
+          }
+        },
+        {
+          root: null,
+          rootMargin: "0px",
+          threshold: buildThresholdList(),
+        }
+      );
+      if (internalRef.current) {
+        observer.observe(internalRef.current);
+      }
+      return () => {
+        observer.disconnect();
+      };
+    }, [internalRef.current]);
+
+    return (
+      <Level
+        {...prop}
+        className={`${prop.className}`}
+        ref={(el) => {
+          internalRef.current = el;
+          if (typeof ref === "function") {
+            ref(el);
+          } else if (el && ref) {
+            ref.current = el;
+          }
+        }}
+      />
+    );
+  };
+}
 
 const overrideComponents = {
   pre: forwardRef<HTMLPreElement, any>((props: any, ref) => (
@@ -45,6 +104,12 @@ const overrideComponents = {
       <table ref={ref} {...props} className={`${props.className}`} />
     </div>
   )),
+  h1: forwardRef<HTMLHeadingElement, any>(HashedHeaders("h1")),
+  h2: forwardRef<HTMLHeadingElement, any>(HashedHeaders("h2")),
+  h3: forwardRef<HTMLHeadingElement, any>(HashedHeaders("h3")),
+  h4: forwardRef<HTMLHeadingElement, any>(HashedHeaders("h4")),
+  h5: forwardRef<HTMLHeadingElement, any>(HashedHeaders("h5")),
+  h6: forwardRef<HTMLHeadingElement, any>(HashedHeaders("h6")),
 };
 
 const router = createBrowserRouter(
diff --git a/packages/react/src/DynamicLayout.tsx b/packages/react/src/DynamicLayout.tsx
index 88542c3..e5f1fbd 100644
--- a/packages/react/src/DynamicLayout.tsx
+++ b/packages/react/src/DynamicLayout.tsx
@@ -1,9 +1,11 @@
-import { ReactNode, Fragment } from "react";
+import { ReactNode, Fragment, useState, useContext } from "react";
 import { type Toc } from "@stefanprobst/rehype-extract-toc";
 import { useLocation } from "react-router-dom";
+import { HeadingContext } from "./HeadingContext";
 
 function RecursivelyToc({ toc }: { toc: Toc }) {
   const location = useLocation();
+  const [activeHeadings] = useContext(HeadingContext);
 
   return (
     <ul>
@@ -16,7 +18,9 @@ function RecursivelyToc({ toc }: { toc: Toc }) {
               className="text-neutral-500 data-[active='true']:text-black dark:data-[active='true']:text-white text-sm font-medium"
               style={{ paddingLeft: `${tocEntry.depth - 1}rem` }}
               data-active={
-                location.hash.length > 0
+                activeHeadings.includes(tocEntry.id ?? "")
+                  ? true
+                  : location.hash.length > 0
                   ? location.hash === `#${tocEntry.id}`
                   : true
               }
@@ -40,8 +44,10 @@ export default function DynamicLayout({
   children: ReactNode;
   toc: Toc;
 }) {
+  const [activeHeadings, setActiveHeadings] = useState<string[]>([]);
+
   return (
-    <>
+    <HeadingContext.Provider value={[activeHeadings, setActiveHeadings]}>
       <div className="w-full flex flex-col items-center">
         <main className="w-full [:not(:where([class~='not-prose'],[class~='not-prose']_*))]:prose-sm prose lg:[:not(:where([class~='not-prose'],_[class~='not-prose']_*))]:prose-lg p-8 dark:prose-invert">
           {children}
@@ -52,6 +58,6 @@ export default function DynamicLayout({
 
         <RecursivelyToc toc={toc} />
       </nav>
-    </>
+    </HeadingContext.Provider>
   );
 }
diff --git a/packages/react/src/HeadingContext.tsx b/packages/react/src/HeadingContext.tsx
new file mode 100644
index 0000000..4ce18e2
--- /dev/null
+++ b/packages/react/src/HeadingContext.tsx
@@ -0,0 +1,12 @@
+import { Dispatch, SetStateAction, createContext } from "react";
+
+export const HeadingContext = createContext<
+  [string[], Dispatch<SetStateAction<string[]>>]
+>([
+  [],
+  () => {
+    if (process.env && process.env.NODE_ENV === "development") {
+      console.log("HeadingContext outside");
+    }
+  },
+]);