Files
dev.2ha.me/components/TOCInline.tsx
2024-04-15 13:00:40 -07:00

135 lines
3.8 KiB
TypeScript

'use client'
import React, { useEffect, useState } from 'react'
import { Toc } from 'pliny/mdx-plugins/remark-toc-headings'
type TocItem = {
value: string
url: string
depth: number
}
export interface TOCInlineProps {
toc: Toc
fromHeading?: number
toHeading?: number
asDisclosure?: boolean
exclude?: string | string[]
collapse?: boolean
ulClassName?: string
liClassName?: string
}
export interface NestedTocItem extends TocItem {
children?: NestedTocItem[]
}
const createNestedList = (items: TocItem[]): NestedTocItem[] => {
const nestedList: NestedTocItem[] = []
const stack: NestedTocItem[] = []
items.forEach((item) => {
const newItem: NestedTocItem = { ...item }
while (stack.length > 0 && stack[stack.length - 1].depth >= newItem.depth) {
stack.pop()
}
const parent = stack.length > 0 ? stack[stack.length - 1] : null
if (parent) {
parent.children = parent.children || []
parent.children.push(newItem)
} else {
nestedList.push(newItem)
}
stack.push(newItem)
})
return nestedList
}
const TOCInline = ({
toc,
fromHeading = 1,
toHeading = 6,
asDisclosure = false,
exclude = '',
collapse = false,
ulClassName = '',
liClassName = '',
}: TOCInlineProps) => {
const [activeId, setActiveId] = useState<string | null>(null)
const re = Array.isArray(exclude)
? new RegExp('^(' + exclude.join('|') + ')$', 'i')
: new RegExp('^(' + exclude + ')$', 'i')
const filteredToc = toc.filter(
(heading) =>
heading.depth >= fromHeading && heading.depth <= toHeading && !re.test(heading.value)
)
const handleScroll = () => {
const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6')
const scrollPosition = window.pageYOffset || document.documentElement.scrollTop
let currentId = ''
headings.forEach((heading) => {
const id = heading.getAttribute('id')
if (id) {
const rect = heading.getBoundingClientRect()
if (rect.top + window.pageYOffset - 300 <= scrollPosition) {
currentId = id
}
}
})
setActiveId(currentId)
}
useEffect(() => {
window.addEventListener('scroll', handleScroll)
return () => {
window.removeEventListener('scroll', handleScroll)
}
}, [])
const createList = (items: NestedTocItem[] | undefined) => {
if (!items || items.length === 0) {
return null
}
return (
<ul className={ulClassName}>
{items.map((item, index) => (
<li key={index} className={liClassName}>
<a
href={item.url}
className={item.url === `#${activeId}` ? 'active-header' : ''}
>
{item.value}
</a>
{createList(item.children)}
</li>
))}
</ul>
)
}
const nestedList = createNestedList(filteredToc)
return (
<>
{!asDisclosure && (
<div className="ml-6 pb-2 pt-2 text-base font-bold">Table of Contents</div>
)}
{asDisclosure ? (
<details open={!collapse}>
<summary className="ml-6 pb-2 pt-2 text-xl font-bold">Table of Contents</summary>
<div className="ml-6">{createList(nestedList)}</div>
</details>
) : (
createList(nestedList)
)}
</>
)
}
export default TOCInline