+
diff --git a/src/components/nav-list.tsx b/src/components/nav-list.tsx
index 325b20071..084a13a53 100644
--- a/src/components/nav-list.tsx
+++ b/src/components/nav-list.tsx
@@ -1,10 +1,11 @@
import { CloseButton } from "@headlessui/react";
import clsx from "clsx";
+import { forwardRef } from "react";
import Link from "next/link";
-export function NavList({ children, ...rest }: React.PropsWithChildren) {
+export function NavList({ children, className, ...rest }: React.ComponentPropsWithoutRef<"div">) {
return (
-
+
{children}
);
@@ -37,15 +38,12 @@ export function NavListItem({ children }: React.PropsWithChildren) {
return
{children};
}
-export function NavListLink({
- href,
- children,
- nested = false,
- ...props
-}: React.PropsWithChildren<{ href: string; nested?: boolean }>) {
- return (
+export const NavListLink = forwardRef
>(
+ function NavListLink({ href, children, nested = false, ...props }, ref) {
+ return (
{children}
- );
-}
+ );
+ },
+);
diff --git a/src/components/table-of-contents.tsx b/src/components/table-of-contents.tsx
index 17ea88203..5a1bb37de 100644
--- a/src/components/table-of-contents.tsx
+++ b/src/components/table-of-contents.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useEffect, useState } from "react";
+import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { NavList, NavListHeading, NavListItem, NavListItems, NavListLink } from "./nav-list";
export type TOCEntry = {
@@ -12,6 +12,22 @@ export type TOCEntry = {
export default function TableOfContents({ tableOfContents }: { tableOfContents: TOCEntry[] }) {
let [activeSection, setActiveSection] = useState(null);
+ let activeLinkRef = useRef(null);
+ let userScrollLockRef = useRef(0);
+ let isAutoScrollingRef = useRef(false);
+
+ function scrollActiveLinkIntoView(container: HTMLElement, activeLink: HTMLAnchorElement) {
+ let containerRect = container.getBoundingClientRect();
+ let linkRect = activeLink.getBoundingClientRect();
+ let nextTop = container.scrollTop + (linkRect.top - containerRect.top) - container.clientHeight / 2 + linkRect.height / 2;
+
+ isAutoScrollingRef.current = true;
+ container.scrollTo({ top: nextTop, behavior: "smooth" });
+ window.setTimeout(() => {
+ isAutoScrollingRef.current = false;
+ }, 250);
+ }
+
useEffect(() => {
const root = document.querySelector('[data-content="true"]');
if (!root) return;
@@ -51,20 +67,85 @@ export default function TableOfContents({ tableOfContents }: { tableOfContents:
return () => observer.disconnect();
}, []);
+ useLayoutEffect(() => {
+ let container = document.querySelector('[data-toc-scroll-container="true"]');
+ let activeLink = activeLinkRef.current;
+
+ if (!container || !activeLink || !activeSection) return;
+ if (performance.now() < userScrollLockRef.current) return;
+
+ let containerRect = container.getBoundingClientRect();
+ let linkRect = activeLink.getBoundingClientRect();
+ let edgeThreshold = 56;
+
+ if (linkRect.top < containerRect.top + edgeThreshold || linkRect.bottom > containerRect.bottom - edgeThreshold) {
+ scrollActiveLinkIntoView(container, activeLink);
+ }
+ }, [activeSection]);
+
+ useEffect(() => {
+ let container = document.querySelector('[data-toc-scroll-container="true"]');
+ if (!container) return;
+
+ let lockTimer: ReturnType | null = null;
+
+ let lockUserScrolling = () => {
+ if (isAutoScrollingRef.current) return;
+
+ userScrollLockRef.current = performance.now() + 800;
+
+ if (lockTimer !== null) {
+ clearTimeout(lockTimer);
+ }
+
+ lockTimer = setTimeout(() => {
+ userScrollLockRef.current = 0;
+ }, 800);
+ };
+
+ container.addEventListener("pointerdown", lockUserScrolling, { passive: true });
+ container.addEventListener("pointermove", lockUserScrolling, { passive: true });
+ container.addEventListener("pointerup", lockUserScrolling, { passive: true });
+ container.addEventListener("pointercancel", lockUserScrolling, { passive: true });
+ container.addEventListener("wheel", lockUserScrolling, { passive: true });
+ container.addEventListener("touchstart", lockUserScrolling, { passive: true });
+ return () => {
+ container.removeEventListener("pointerdown", lockUserScrolling);
+ container.removeEventListener("pointermove", lockUserScrolling);
+ container.removeEventListener("pointerup", lockUserScrolling);
+ container.removeEventListener("pointercancel", lockUserScrolling);
+ container.removeEventListener("wheel", lockUserScrolling);
+ container.removeEventListener("touchstart", lockUserScrolling);
+
+ if (lockTimer !== null) {
+ clearTimeout(lockTimer);
+ }
+ };
+ }, []);
+
return (
On this page
{tableOfContents.map(({ text, slug, children }, i) => (
-
+
{text}
{children.length > 0 && (
{children.map(({ text, slug }, i) => (
-
+
{text}