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"); + } + }, +]);