Skip to content

Commit 59d02bd

Browse files
authored
feat: make the links in the TOC reactive to the visibility on the screen (#338)
* feat: make the table of contents sections reactive to user-scroll * style: prettier format * chore: remove `console.log`
1 parent 9faa26f commit 59d02bd

File tree

5 files changed

+65
-15
lines changed

5 files changed

+65
-15
lines changed

app/components/Doc.tsx

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,51 @@ export function Doc({
4343

4444
const isTocVisible = shouldRenderToc && headings && headings.length > 1
4545

46+
const markdownContainerRef = React.useRef<HTMLDivElement>(null)
47+
const [activeHeadings, setActiveHeadings] = React.useState<Array<string>>([])
48+
49+
const headingElementRefs = React.useRef<
50+
Record<string, IntersectionObserverEntry>
51+
>({})
52+
53+
React.useEffect(() => {
54+
const callback = (headingsList: Array<IntersectionObserverEntry>) => {
55+
headingElementRefs.current = headingsList.reduce(
56+
(map, headingElement) => {
57+
map[headingElement.target.id] = headingElement
58+
return map
59+
},
60+
headingElementRefs.current
61+
)
62+
63+
const visibleHeadings: Array<IntersectionObserverEntry> = []
64+
Object.keys(headingElementRefs.current).forEach((key) => {
65+
const headingElement = headingElementRefs.current[key]
66+
if (headingElement.isIntersecting) {
67+
visibleHeadings.push(headingElement)
68+
}
69+
})
70+
71+
if (visibleHeadings.length >= 1) {
72+
setActiveHeadings(visibleHeadings.map((h) => h.target.id))
73+
}
74+
}
75+
76+
const observer = new IntersectionObserver(callback, {
77+
rootMargin: '0px',
78+
threshold: 0.2,
79+
})
80+
81+
const headingElements = Array.from(
82+
markdownContainerRef.current?.querySelectorAll(
83+
'h2[id], h3[id], h4[id], h5[id], h6[id]'
84+
) ?? []
85+
)
86+
headingElements.forEach((el) => observer.observe(el))
87+
88+
return () => observer.disconnect()
89+
}, [])
90+
4691
return (
4792
<div
4893
className={twMerge(
@@ -61,9 +106,11 @@ export function Doc({
61106
<div className="h-px bg-gray-500 opacity-20" />
62107
<div className="h-4" />
63108
<div
109+
ref={markdownContainerRef}
64110
className={twMerge(
65111
'prose prose-gray prose-sm prose-p:leading-7 dark:prose-invert max-w-none',
66-
isTocVisible && 'pr-4 lg:pr-6'
112+
isTocVisible && 'pr-4 lg:pr-6',
113+
'styled-markdown-content'
67114
)}
68115
>
69116
<Markdown htmlMarkup={markup} />
@@ -83,7 +130,12 @@ export function Doc({
83130

84131
{isTocVisible && (
85132
<div className="border-l border-gray-500/20 max-w-52 w-full hidden 2xl:block transition-all">
86-
<Toc headings={headings} colorFrom={colorFrom} colorTo={colorTo} />
133+
<Toc
134+
headings={headings}
135+
activeHeadings={activeHeadings}
136+
colorFrom={colorFrom}
137+
colorTo={colorTo}
138+
/>
87139
</div>
88140
)}
89141
</div>

app/components/Toc.tsx

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import * as React from 'react'
22
import { twMerge } from 'tailwind-merge'
33
import { HeadingData } from 'marked-gfm-heading-id'
4-
import { useLocation } from '@tanstack/react-router'
54

65
const headingLevels: Record<number, string> = {
76
1: 'pl-2',
@@ -16,17 +15,15 @@ type TocProps = {
1615
headings: HeadingData[]
1716
colorFrom?: string
1817
colorTo?: string
18+
activeHeadings: Array<string>
1919
}
2020

21-
export function Toc({ headings, colorFrom, colorTo }: TocProps) {
22-
const location = useLocation()
23-
24-
const [hash, setHash] = React.useState('')
25-
26-
React.useEffect(() => {
27-
setHash(location.hash)
28-
}, [location])
29-
21+
export function Toc({
22+
headings,
23+
colorFrom,
24+
colorTo,
25+
activeHeadings,
26+
}: TocProps) {
3027
return (
3128
<nav className="flex flex-col sticky top-2 max-h-screen divide-y divide-gray-500/20">
3229
<div className="p-2">
@@ -48,7 +45,7 @@ export function Toc({ headings, colorFrom, colorTo }: TocProps) {
4845
<a
4946
title={heading.id}
5047
href={`#${heading.id}`}
51-
aria-current={hash === heading.id && 'location'}
48+
aria-current={activeHeadings.includes(heading.id) && 'location'}
5249
className={`truncate block aria-current:bg-gradient-to-r ${colorFrom} ${colorTo} aria-current:bg-clip-text aria-current:text-transparent`}
5350
dangerouslySetInnerHTML={{
5451
__html: heading.text,

app/routes/$libraryId/$version.docs.framework.$framework.$.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ function Docs() {
5050
return (
5151
<DocContainer>
5252
<Doc
53+
key={filePath}
5354
title={title}
5455
content={content}
5556
repo={library.repo}

app/utils/handleRedirects.server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { redirect } from '@tanstack/react-router';
1+
import { redirect } from '@tanstack/react-router'
22

33
type RedirectItem = { from: string; to: string }
44

app/utils/useLocalStorage.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState, useEffect } from 'react';
1+
import { useState, useEffect } from 'react'
22

33
function getWithExpiry<T>(key: string) {
44
if (typeof window !== 'undefined') {

0 commit comments

Comments
 (0)