diff --git a/next-env.d.ts b/next-env.d.ts index 9edff1c7..c4b7818f 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/src/components/mdx/Toc/Toc.tsx b/src/components/mdx/Toc/Toc.tsx index 482ebbbf..b7d328ee 100644 --- a/src/components/mdx/Toc/Toc.tsx +++ b/src/components/mdx/Toc/Toc.tsx @@ -2,12 +2,16 @@ import type { DocToC } from '@/app/[...slug]/DocsContext' import cn from '@/lib/cn' -import { ComponentProps, useCallback, useEffect, useState } from 'react' +import { ComponentProps, useCallback, useEffect, useRef, useState } from 'react' export function Toc({ className, toc }: ComponentProps<'div'> & { toc: DocToC[] }) { // console.log('toc', toc) const [activeIndex, setActiveIndex] = useState() + const [visibleSections, setVisibleSections] = useState>(new Set()) + const tocRef = useRef(null) + const pathRef = useRef(null) + const itemRefs = useRef<(HTMLElement | null)[]>([]) const updateActiveIndex = useCallback( (hash: string) => { @@ -32,35 +36,127 @@ export function Toc({ className, toc }: ComponentProps<'div'> & { toc: DocToC[] } }, [updateActiveIndex]) - // React.useEffect(() => { - // const headings = toc.map((heading) => document.getElementById(heading.id)) + // Progressive navigation with intersection observer + useEffect(() => { + const headings = toc.map((heading) => document.getElementById(heading.id)) + + const observer = new IntersectionObserver( + (entries) => { + setVisibleSections((prev) => { + const newVisibleSections = new Set(prev) + + entries.forEach((entry) => { + const headingIndex = headings.indexOf(entry.target as HTMLElement) + if (headingIndex !== -1) { + if (entry.isIntersecting) { + newVisibleSections.add(headingIndex) + } else { + newVisibleSections.delete(headingIndex) + } + } + }) + + return newVisibleSections + }) + }, + { + rootMargin: '-80px 0px -80px 0px', + threshold: 0, + }, + ) + + for (const heading of headings) { + if (heading) observer.observe(heading) + } + + return () => observer.disconnect() + }, [toc]) + + // Draw and update SVG path + useEffect(() => { + if (!pathRef.current || !tocRef.current || itemRefs.current.length === 0) return + + // Maximum stroke-dasharray length - ensures the path extends beyond visible area + const MAX_DASH_LENGTH = 1000 - // const observer = new IntersectionObserver(([entry]) => { - // if (entry.intersectionRatio > 0) { - // const headingIndex = headings.indexOf(entry.target as HTMLElement) - // setActiveIndex(headingIndex) - // } - // }) + const drawPath = () => { + const path: (string | number)[] = [] + let pathIndent = 0 + const itemPositions: { pathStart: number; pathEnd: number }[] = [] + let pathLength = 0 + + itemRefs.current.forEach((item, i) => { + if (!item) return + + const x = item.offsetLeft - 5 + const y = item.offsetTop + const height = item.offsetHeight + + if (i === 0) { + path.push('M', x, y, 'L', x, y + height) + pathLength += height + } else { + // Account for horizontal movement in path length + if (pathIndent !== x) { + path.push('L', pathIndent, y) + pathLength += Math.abs(pathIndent - x) + } + path.push('L', x, y) + pathLength += Math.abs(y - (itemRefs.current[i - 1]?.offsetTop ?? 0)) + path.push('L', x, y + height) + pathLength += height + } + + pathIndent = x + itemPositions[i] = { pathStart: pathLength - height, pathEnd: pathLength } + }) + + pathRef.current?.setAttribute('d', path.join(' ')) + + // Update stroke-dasharray based on visible sections + if (visibleSections.size > 0) { + const visibleArray = Array.from(visibleSections).sort((a, b) => a - b) + const pathStart = itemPositions[visibleArray[0]]?.pathStart ?? 0 + const pathEnd = itemPositions[visibleArray[visibleArray.length - 1]]?.pathEnd ?? 0 + + if (pathStart <= pathEnd) { + pathRef.current?.setAttribute('stroke-dashoffset', '1') + pathRef.current?.setAttribute( + 'stroke-dasharray', + `1, ${pathStart}, ${pathEnd - pathStart}, ${MAX_DASH_LENGTH}`, + ) + pathRef.current?.setAttribute('opacity', '1') + } else { + pathRef.current?.setAttribute('opacity', '0') + } + } else { + pathRef.current?.setAttribute('opacity', '0') + } + } - // for (const heading of headings) { - // if (heading) observer.observe(heading) - // } + drawPath() - // return () => observer.disconnect() - // }, [toc]) + window.addEventListener('resize', drawPath) + return () => window.removeEventListener('resize', drawPath) + }, [visibleSections, toc]) return ( -
+

On This Page

{toc.map(({ title, id, level }, index) => ( -

+

{ + itemRefs.current[index] = el + }} + > & { toc: DocToC[]

))} + + +
) }