@@ -1,3 +1,4 @@
import { useEffect , useMemo , useRef , useState } from "react" ;
import { cn } from "@/lib/utils" ;
import type { HeadingItem } from "@/utils/markdown-manipulation" ;
@@ -12,8 +13,95 @@ const levelIndent: Record<number, string> = {
4 : "ml-8" ,
} ;
/** Outline navigation for memo headings (h1– h4). */
const areStringArraysEqual = ( a : string [ ] , b : string [ ] ) = > {
if ( a . length !== b . length ) return false ;
return a . every ( ( item , index ) = > item === b [ index ] ) ;
} ;
/** Outline navigation for memo headings (h1-h4). */
const MemoOutline = ( { headings } : MemoOutlineProps ) = > {
const outlineRef = useRef < HTMLElement > ( null ) ;
const itemRefs = useRef ( new Map < string , HTMLAnchorElement > ( ) ) ;
const [ activeSlugs , setActiveSlugs ] = useState < string [ ] > ( [ ] ) ;
const orderedSlugs = useMemo ( ( ) = > headings . map ( ( heading ) = > heading . slug ) . filter ( Boolean ) , [ headings ] ) ;
const activeSlugSet = useMemo ( ( ) = > new Set ( activeSlugs ) , [ activeSlugs ] ) ;
useEffect ( ( ) = > {
setActiveSlugs ( [ ] ) ;
const elements = orderedSlugs
. map ( ( slug ) = > document . getElementById ( slug ) )
. filter ( ( element ) : element is HTMLElement = > Boolean ( element ) ) ;
if ( elements . length === 0 ) {
return ;
}
let animationFrame = 0 ;
const updateActiveSlugs = ( ) = > {
animationFrame = 0 ;
const viewportTop = window . scrollY ;
const viewportBottom = viewportTop + ( window . innerHeight || document . documentElement . clientHeight ) ;
const documentBottom = Math . max ( document . body . scrollHeight , document . documentElement . scrollHeight ) ;
const headingSections = elements . map ( ( element , index ) = > {
const sectionTop = element . getBoundingClientRect ( ) . top + window . scrollY ;
const nextElement = elements [ index + 1 ] ;
const sectionBottom = nextElement ? nextElement . getBoundingClientRect ( ) . top + window . scrollY : documentBottom ;
return { slug : element.id , top : sectionTop , bottom : sectionBottom } ;
} ) ;
const nextActiveSlugs = headingSections
. filter ( ( section ) = > section . bottom > viewportTop && section . top < viewportBottom )
. map ( ( section ) = > section . slug ) ;
setActiveSlugs ( ( previousSlugs ) = > ( areStringArraysEqual ( previousSlugs , nextActiveSlugs ) ? previousSlugs : nextActiveSlugs ) ) ;
} ;
const scheduleUpdateActiveSlugs = ( ) = > {
if ( animationFrame ) {
return ;
}
animationFrame = window . requestAnimationFrame ( updateActiveSlugs ) ;
} ;
updateActiveSlugs ( ) ;
window . addEventListener ( "scroll" , scheduleUpdateActiveSlugs , { passive : true } ) ;
window . addEventListener ( "resize" , scheduleUpdateActiveSlugs ) ;
return ( ) = > {
if ( animationFrame ) {
window . cancelAnimationFrame ( animationFrame ) ;
}
window . removeEventListener ( "scroll" , scheduleUpdateActiveSlugs ) ;
window . removeEventListener ( "resize" , scheduleUpdateActiveSlugs ) ;
} ;
} , [ orderedSlugs ] ) ;
useEffect ( ( ) = > {
const firstActiveSlug = activeSlugs [ 0 ] ;
const outline = outlineRef . current ;
const activeItem = firstActiveSlug ? itemRefs . current . get ( firstActiveSlug ) : undefined ;
if ( ! outline || ! activeItem || outline . scrollHeight <= outline . clientHeight ) {
return ;
}
const itemTop = activeItem . offsetTop ;
const itemBottom = itemTop + activeItem . offsetHeight ;
const comfortableTop = outline . scrollTop + 24 ;
const comfortableBottom = outline . scrollTop + outline . clientHeight - 24 ;
if ( itemTop >= comfortableTop && itemBottom <= comfortableBottom ) {
return ;
}
outline . scrollTo ( {
top : Math.max ( 0 , itemTop - outline . clientHeight / 2 + activeItem . offsetHeight / 2 ) ,
behavior : "smooth" ,
} ) ;
} , [ activeSlugs ] ) ;
const handleClick = ( e : React.MouseEvent < HTMLAnchorElement > , slug : string ) = > {
e . preventDefault ( ) ;
const el = document . getElementById ( slug ) ;
@@ -24,27 +112,50 @@ const MemoOutline = ({ headings }: MemoOutlineProps) => {
} ;
return (
< nav className = "relative flex flex-col " >
{ headings . map ( ( heading , index ) = > (
< nav ref = { outlineRef } className = "relative flex max-h-[45vh] flex-col overflow-y-auto overscroll-contain pr-1 " >
{ headings . map ( ( heading , index ) = > {
const isActive = activeSlugSet . has ( heading . slug ) ;
return (
< a
key = { ` ${ heading . slug } - ${ index } ` }
ref = { ( element ) = > {
if ( element ) {
itemRefs . current . set ( heading . slug , element ) ;
} else {
itemRefs . current . delete ( heading . slug ) ;
}
} }
href = { ` # ${ heading . slug } ` }
aria-current = { activeSlugs [ 0 ] === heading . slug ? "location" : undefined }
onClick = { ( e ) = > handleClick ( e , heading . slug ) }
className = { cn (
"group relative block py-[5px] pr-1 text-[13px] leading-snug truncate" ,
"text-muted-foreground/60 hover:text-foreground/90" ,
"group relative block shrink-0 rounded-l-sm py-[5px] pr-1 pl-2 text-[13px] leading-snug truncate" ,
"transition-colors duration-200 ease-out" ,
isActive ? "bg-primary/10 font-medium text-primary" : "text-muted-foreground/60 hover:text-foreground/90" ,
levelIndent [ heading . level ] ,
heading . level === 1 && "font-medium text-muted-foreground/80" ,
heading . level === 1 && ! isActive && "font-medium text-muted-foreground/80" ,
) }
title = { heading . text }
>
< span
className = { cn (
"absolute top-1.5 bottom-1.5 left-0 w-0.5 rounded-full bg-primary transition-opacity duration-200" ,
isActive ? "opacity-100" : "opacity-0" ,
) }
/ >
< span className = "relative" >
{ heading . text }
< span className = "absolute -bottom-px left-0 h-px w-0 bg-foreground/30 transition-all duration-200 group-hover:w-full" / >
< span
className = { cn (
"absolute -bottom-px left-0 h-px bg-foreground/30 transition-all duration-200" ,
isActive ? "w-full bg-primary/50" : "w-0 group-hover:w-full" ,
) }
/ >
< / span >
< / a >
) ) }
) ;
} ) }
< / nav >
) ;
} ;