docs: table of contents (#394)

Co-authored-by: Valerio Ageno <valerioageno@yahoo.it>
This commit is contained in:
Jacob Marshall
2025-01-26 15:36:18 +00:00
committed by GitHub
parent 99df4a5575
commit 83f3cae6f0
7 changed files with 193 additions and 149 deletions
@@ -1,4 +1,4 @@
import type { JSX } from 'react'
import type { ElementType, JSX, ReactNode } from 'react'
import { Title, type TitleProps } from '@mantine/core'
export default function MdxTitle(props: TitleProps): JSX.Element {
@@ -6,19 +6,39 @@ export default function MdxTitle(props: TitleProps): JSX.Element {
<Title
data-heading={props.children}
data-order={props.order}
mt={20}
style={{ scrollMargin: 70 }}
{...props}
// eslint-disable-next-line @typescript-eslint/no-base-to-string
id={String(props.children ?? '')
.toLowerCase()
.replaceAll(' ', '-')}
id={idGen(props.children)}
/>
)
}
export const h = (
order: 1 | 2 | 3 | 4 | 5 | 6,
): React.ElementType<TitleProps> => {
function idGen(children: ReactNode): string {
if (Array.isArray(children)) {
const result = children
.map((child) => {
if (typeof child === 'string') {
return child
}
if (typeof child === 'object' && child !== null && 'props' in child) {
const childWithProps = child as { props?: { children?: ReactNode } }
return typeof childWithProps.props?.children === 'string'
? childWithProps.props.children
: ''
}
return ''
})
.join('')
return result.toLowerCase().replace(/\s+/g, '-')
}
return typeof children === 'string'
? children.toLowerCase().replace(/\s+/g, '-')
: ''
}
export const h = (order: 1 | 2 | 3 | 4 | 5 | 6): ElementType<TitleProps> => {
function render(props: TitleProps): JSX.Element {
return <MdxTitle order={order} {...props} />
}
@@ -0,0 +1,25 @@
import type { JSX, ReactNode } from 'react'
import { Box, Container } from '@mantine/core'
import TableOfContents from '@/components/TableOfContents'
import EditPage from '../EditPage'
import MdxProvider from '../MdxProvider'
interface MdxWrapperProps {
children: ReactNode
}
export function MdxWrapper({ children }: MdxWrapperProps): JSX.Element {
return (
<Container size={1000} w="100%" display="flex" style={{ gap: 12 }}>
<Box id="mdx-root" component="article" mt="xl" px={16} py={36}>
<MdxProvider>{children}</MdxProvider>
<EditPage />
</Box>
<Box>
<TableOfContents />
</Box>
</Container>
)
}
@@ -0,0 +1,3 @@
import { MdxWrapper } from './MdxWrapper'
export default MdxWrapper
@@ -1,47 +1,33 @@
/*
* Component inspired by: https://github.com/mantinedev/mantine/tree/master/apps/mantine.dev/src/components/TableOfContents
*/
.wrapper {
padding-left: var(--mantine-spacing-md);
position: sticky;
top: var(--mantine-spacing-xl);
right: 0;
padding-top: 55px;
flex: 0 0 calc(var(--docs-table-of-contents-width) - 20px);
width: 200px;
flex: 0 0 calc(250px - 20px);
min-width: 220px;
max-width: 220px;
height: 100vh;
@mixin rtl {
padding-left: 0;
padding-right: var(--mantine-spacing-md);
right: auto;
left: 0;
}
&[data-with-tabs] {
padding-top: 0;
top: calc(var(--mantine-spacing-xl) + 60px);
/* var(--mantine-breakpoint-md) = 62em */
@media (max-width: 62em) {
display: none;
}
}
.inner {
padding-bottom: var(--mantine-spacing-xl);
padding-left: var(--mantine-spacing-md);
display: flex;
flex-direction: column;
justify-content: space-between;
@mixin rtl {
padding-left: 0;
padding-right: var(--mantine-spacing-md);
}
}
.link {
white-space: normal;
word-wrap: break-word;
overflow-wrap: break-word;
display: block;
border-left: 1px solid transparent;
padding: 8px var(--mantine-spacing-md);
margin-left: -1px;
padding-left: calc(var(--toc-link-offset) * var(--mantine-spacing-lg));
padding-left: calc(1 * var(--mantine-spacing-lg));
border-top-right-radius: var(--mantine-radius-sm);
border-bottom-right-radius: var(--mantine-radius-sm);
@@ -58,10 +44,19 @@
border-right: 1px solid transparent;
margin-left: 0;
margin-right: -1px;
border-top-left-radius: var(--mantine-radius-sm);
border-bottom-left-radius: var(--mantine-radius-sm);
border-top-right-radius: 0;
border-bottom-right-radius: 0;
border-radius: var(--mantine-radius-sm) 0 0 var(--mantine-radius-sm);
}
&:hover {
border-color: var(--mantine-color-violet-1);
@mixin light {
color: var(--mantine-color-black);
}
@mixin dark {
color: var(--mantine-color-violet-1);
}
}
&[data-active] {
@@ -69,22 +64,14 @@
@mixin light {
color: var(--mantine-color-violet-8);
background-color: var(--mantine-color-violet-0);
}
@mixin dark {
color: var(--mantine-color-violet-1);
background-color: #534a6d;
}
}
}
.header {
display: flex;
align-items: center;
margin-bottom: var(--mantine-spacing-md);
}
.title {
margin-left: var(--mantine-spacing-md);
@@ -1,115 +1,117 @@
/*
* Component inspired by: https://github.com/mantinedev/mantine/tree/master/apps/mantine.dev/src/components/TableOfContents
*/
import type { JSX } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useRouter, Link } from 'tuono'
import { IconList } from '@tabler/icons-react'
import { Box, rem, ScrollArea, Text } from '@mantine/core'
import type { JSX, MouseEvent } from 'react'
import { useRef, useState, useEffect } from 'react'
import { useRouter } from 'tuono'
import { Box, Text } from '@mantine/core'
import { getHeadings, type Heading } from './getHeadings'
import classes from './TableOfContents.module.css'
function getActiveElement(rects: Array<DOMRect>): number {
if (rects.length === 0) {
return -1
}
const closest = rects.reduce(
(acc, item, index) => {
if (Math.abs(acc.position) < Math.abs(item.y)) {
return acc
}
return {
index,
position: item.y,
}
},
{ index: 0, position: rects[0].y },
export function TableOfContents(): JSX.Element | null {
const [activeHeadingIndex, setActiveHeadingIndex] = useState<number | null>(
null,
)
return closest.index
}
interface TableOfContentsProps {
withTabs: boolean
}
export function TableOfContents({
withTabs,
}: TableOfContentsProps): JSX.Element | null {
const [active, setActive] = useState(0)
const [headings, setHeadings] = useState<Array<Heading>>([])
const headingsRef = useRef<Array<Heading>>([])
const headingsRef = useRef<Array<HTMLElement>>([])
const observerRef = useRef<IntersectionObserver | null>(null)
const router = useRouter()
const filteredHeadings = headings.filter((heading) => heading.depth > 1)
const handleScroll = (): void => {
setActive(
getActiveElement(
headingsRef.current.map((d) => d.getNode().getBoundingClientRect()),
),
)
}
useEffect(() => {
const _headings = getHeadings()
headingsRef.current = _headings
setHeadings(_headings)
setActive(
getActiveElement(
_headings.map((d) => d.getNode().getBoundingClientRect()),
),
headingsRef.current = _headings.map((heading) => heading.getNode())
if (observerRef.current) {
observerRef.current.disconnect()
}
const observer = new IntersectionObserver(
(entries) => {
const visibleEntries = entries
.filter((entry) => entry.isIntersecting)
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)
if (visibleEntries.length > 0) {
setActiveHeadingIndex(
_headings.findIndex((h) => h.id === visibleEntries[0].target.id),
)
}
},
{
rootMargin: '-50px 0px -80% 0px',
threshold: [0.1, 0.5, 1.0],
},
)
window.addEventListener('scroll', handleScroll)
headingsRef.current.forEach((node) => {
observer.observe(node)
})
observerRef.current = observer
const handleHashChange = (): void => {
setTimeout(() => {
observerRef.current?.disconnect()
observerRef.current = observer
headingsRef.current.forEach((node) => {
observer.observe(node)
})
}, 300)
}
window.addEventListener('hashchange', handleHashChange)
return (): void => {
window.removeEventListener('scroll', handleScroll)
observer.disconnect()
window.removeEventListener('hashchange', handleHashChange)
}
}, [router.pathname])
if (filteredHeadings.length === 0) {
const handleHeadingClick = (
event: MouseEvent<HTMLAnchorElement>,
id: string,
): void => {
event.preventDefault()
const element = document.getElementById(id)
if (element) {
element.scrollIntoView({
behavior: 'instant',
block: 'start',
})
}
}
// Avoid to show it in case of a TODO page
if (headings.length === 1) {
return null
}
const items = filteredHeadings.map((heading, index) => (
<Text
key={heading.id}
component={Link}
fz="sm"
p={10}
className={classes.link}
mod={{ active: active === index }}
href={`#${heading.id}`}
__vars={{ '--toc-link-offset': `${heading.depth - 1}` }}
>
{heading.content}
</Text>
))
return (
<Box
component="nav"
mod={{ 'with-tabs': withTabs }}
className={classes.wrapper}
>
<Box component="nav" className={classes.wrapper}>
<div className={classes.inner}>
<div>
<div className={classes.header}>
<IconList
style={{ width: rem(20), height: rem(20) }}
stroke={1.5}
/>
<Text className={classes.title}>Table of contents</Text>
<Text className={classes.title} mb={8}>
On this page
</Text>
<div className={classes.items}>
{headings.slice(1).map((heading, index) => (
<Text
key={heading.id}
component="a"
fz="sm"
px={8}
w="fit-content"
py={4}
className={classes.link}
mod={{ active: activeHeadingIndex === index + 1 }}
href={`#${heading.id}`}
onClick={(e) => {
handleHeadingClick(e, heading.id)
}}
__vars={{ '--toc-link-offset': `${heading.depth - 1}` }}
>
{heading.content}
</Text>
))}
</div>
<ScrollArea.Autosize
mah={`calc(100vh - ${rem(140)})`}
type="never"
offsetScrollbars
>
<div className={classes.items}>{items}</div>
</ScrollArea.Autosize>
</div>
</div>
</Box>
@@ -8,14 +8,27 @@ export interface Heading {
getNode: () => HTMLHeadingElement
}
function getCleanedText(element: HTMLElement): string {
const clone = element.cloneNode(true) as HTMLElement
clone.querySelectorAll('code, pre').forEach((codeBlock) => {
const textNode = document.createTextNode(codeBlock.textContent || '')
codeBlock.replaceWith(textNode)
})
return clone.textContent?.trim() || ''
}
function getHeadingsData(headings: Array<HTMLHeadingElement>): Array<Heading> {
const result: Array<Heading> = []
for (const heading of headings) {
if (heading.id) {
const depth = parseInt(heading.getAttribute('data-order') || '1', 10)
if (depth <= 3 && heading.id) {
result.push({
depth: parseInt(heading.getAttribute('data-order') || '1', 10),
content: heading.getAttribute('data-heading') || '',
depth,
content: getCleanedText(heading),
id: heading.id,
getNode: () =>
document.getElementById(heading.id) as HTMLHeadingElement,
@@ -28,7 +41,6 @@ function getHeadingsData(headings: Array<HTMLHeadingElement>): Array<Heading> {
export function getHeadings(): Array<Heading> {
const root = document.getElementById('mdx-root')
console.log(root)
if (!root) {
return []
+5 -10
View File
@@ -6,14 +6,12 @@ import {
createTheme,
MantineProvider,
AppShell,
Container,
mantineHtmlProps,
type CSSVariablesResolver,
} from '@mantine/core'
import { useDisclosure } from '@mantine/hooks'
import EditPage from '@/components/EditPage'
import MdxProvider from '@/components/MdxProvider'
import MdxWrapper from '@/components/MdxWrapper'
import Navbar from '@/components/Navbar'
import Sidebar from '@/components/Sidebar'
@@ -38,8 +36,8 @@ const theme = createTheme({
},
fontSizes: {
// 'xs' | 'sm' | 'md' | 'lg' | 'xl'
xs: '16px',
sm: '16px',
xs: '14px',
sm: '14px',
},
colors: {
dark: [
@@ -125,11 +123,8 @@ export default function RootRoute({ children }: RootRouteProps): JSX.Element {
>
<Navbar toggle={toggle} />
<Sidebar close={toggle} />
<AppShell.Main>
<Container id="mdx-root" component="article" size="md" p={20}>
<MdxProvider>{children}</MdxProvider>
<EditPage />
</Container>
<AppShell.Main pt={0} px="auto">
<MdxWrapper>{children}</MdxWrapper>
</AppShell.Main>
</AppShell>
</MantineProvider>