@@ -13,12 +13,56 @@ import cx from 'classnames';
1313import { useTocHighlight } from './useTocHighlight' ;
1414import type { Toc } from '../MDX/TocContext' ;
1515
16+ let animationFrameId : number ;
17+
18+ const smoothScrollTo = ( targetId : string ) => {
19+ const element = document . getElementById ( targetId ) ;
20+ if ( ! element ) return ;
21+
22+ ( window as any ) . __isAutoScrolling = true ;
23+
24+ const headerOffset = 84 ;
25+ const startPosition = window . scrollY ;
26+ const elementPosition = element . getBoundingClientRect ( ) . top ;
27+ const targetPosition = startPosition + elementPosition - headerOffset ;
28+ const distance = targetPosition - startPosition ;
29+ const duration = 300 ;
30+ let startTime : number | null = null ;
31+
32+ const easeInOutCubic = ( t : number ) : number => {
33+ return t < 0.5 ? 4 * t * t * t : 1 - Math . pow ( - 2 * t + 2 , 3 ) / 2 ;
34+ } ;
35+
36+ const animation = ( currentTime : number ) => {
37+ if ( startTime === null ) startTime = currentTime ;
38+ const timeElapsed = currentTime - startTime ;
39+ const progress = Math . min ( timeElapsed / duration , 1 ) ;
40+ const easedProgress = easeInOutCubic ( progress ) ;
41+
42+ window . scrollTo ( 0 , startPosition + distance * easedProgress ) ;
43+
44+ if ( progress < 1 ) {
45+ animationFrameId = requestAnimationFrame ( animation ) ;
46+ } else {
47+ ( window as any ) . __isAutoScrolling = false ;
48+ }
49+ } ;
50+
51+ cancelAnimationFrame ( animationFrameId ) ;
52+ animationFrameId = requestAnimationFrame ( animation ) ;
53+ } ;
54+
55+ const getAnchorIdFromUrl = ( url : string ) => {
56+ return url . startsWith ( '#' ) ? url . substring ( 1 ) : url ;
57+ } ;
58+
1659export function Toc ( { headings} : { headings : Toc } ) {
1760 const { currentIndex} = useTocHighlight ( ) ;
1861 // TODO: We currently have a mismatch between the headings in the document
1962 // and the headings we find in MarkdownPage (i.e. we don't find Recap or Challenges).
2063 // Select the max TOC item we have here for now, but remove this after the fix.
2164 const selectedIndex = Math . min ( currentIndex , headings . length - 1 ) ;
65+
2266 return (
2367 < nav role = "navigation" className = "pt-20 sticky top-0 end-0" >
2468 { headings . length > 0 && (
@@ -37,11 +81,12 @@ export function Toc({headings}: {headings: Toc}) {
3781 if ( ! h . url && process . env . NODE_ENV === 'development' ) {
3882 console . error ( 'Heading does not have URL' ) ;
3983 }
84+ const anchorId = getAnchorIdFromUrl ( h . url ) ;
4085 return (
4186 < li
4287 key = { `heading-${ h . url } -${ i } ` }
4388 className = { cx (
44- 'text-sm px-2 rounded-s-xl' ,
89+ 'text-sm px-2 rounded-s-xl transition-colors duration-200 ease-in-out ' ,
4590 selectedIndex === i
4691 ? 'bg-highlight dark:bg-highlight-dark'
4792 : null ,
@@ -55,9 +100,14 @@ export function Toc({headings}: {headings: Toc}) {
55100 selectedIndex === i
56101 ? 'text-link dark:text-link-dark font-bold'
57102 : 'text-secondary dark:text-secondary-dark' ,
58- 'block hover:text-link dark:hover:text-link-dark leading-normal py-2'
103+ 'block hover:text-link dark:hover:text-link-dark leading-normal py-2 transition-colors duration-200 ease-in-out '
59104 ) }
60- href = { h . url } >
105+ href = { h . url }
106+ onClick = { ( e ) => {
107+ e . preventDefault ( ) ;
108+ smoothScrollTo ( anchorId ) ;
109+ window . history . pushState ( null , '' , `#${ anchorId } ` ) ;
110+ } } >
61111 { h . text }
62112 </ a >
63113 </ li >
0 commit comments