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' import { Title, type TitleProps } from '@mantine/core'
export default function MdxTitle(props: TitleProps): JSX.Element { export default function MdxTitle(props: TitleProps): JSX.Element {
@@ -6,19 +6,39 @@ export default function MdxTitle(props: TitleProps): JSX.Element {
<Title <Title
data-heading={props.children} data-heading={props.children}
data-order={props.order} data-order={props.order}
mt={20} style={{ scrollMargin: 70 }}
{...props} {...props}
// eslint-disable-next-line @typescript-eslint/no-base-to-string id={idGen(props.children)}
id={String(props.children ?? '')
.toLowerCase()
.replaceAll(' ', '-')}
/> />
) )
} }
export const h = ( function idGen(children: ReactNode): string {
order: 1 | 2 | 3 | 4 | 5 | 6, if (Array.isArray(children)) {
): React.ElementType<TitleProps> => { 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 { function render(props: TitleProps): JSX.Element {
return <MdxTitle order={order} {...props} /> 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 { .wrapper {
padding-left: var(--mantine-spacing-md);
position: sticky; position: sticky;
top: var(--mantine-spacing-xl); top: var(--mantine-spacing-xl);
right: 0;
padding-top: 55px; padding-top: 55px;
flex: 0 0 calc(var(--docs-table-of-contents-width) - 20px); flex: 0 0 calc(250px - 20px);
width: 200px; min-width: 220px;
max-width: 220px;
height: 100vh;
@mixin rtl { /* var(--mantine-breakpoint-md) = 62em */
padding-left: 0; @media (max-width: 62em) {
padding-right: var(--mantine-spacing-md); display: none;
right: auto;
left: 0;
}
&[data-with-tabs] {
padding-top: 0;
top: calc(var(--mantine-spacing-xl) + 60px);
} }
} }
.inner { .inner {
padding-bottom: var(--mantine-spacing-xl);
padding-left: var(--mantine-spacing-md);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: space-between; justify-content: space-between;
@mixin rtl {
padding-left: 0;
padding-right: var(--mantine-spacing-md);
}
} }
.link { .link {
white-space: normal;
word-wrap: break-word;
overflow-wrap: break-word;
display: block; display: block;
border-left: 1px solid transparent; border-left: 1px solid transparent;
padding: 8px var(--mantine-spacing-md); padding: 8px var(--mantine-spacing-md);
margin-left: -1px; 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-top-right-radius: var(--mantine-radius-sm);
border-bottom-right-radius: var(--mantine-radius-sm); border-bottom-right-radius: var(--mantine-radius-sm);
@@ -58,10 +44,19 @@
border-right: 1px solid transparent; border-right: 1px solid transparent;
margin-left: 0; margin-left: 0;
margin-right: -1px; margin-right: -1px;
border-top-left-radius: var(--mantine-radius-sm); border-radius: var(--mantine-radius-sm) 0 0 var(--mantine-radius-sm);
border-bottom-left-radius: var(--mantine-radius-sm); }
border-top-right-radius: 0;
border-bottom-right-radius: 0; &: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] { &[data-active] {
@@ -69,22 +64,14 @@
@mixin light { @mixin light {
color: var(--mantine-color-violet-8); color: var(--mantine-color-violet-8);
background-color: var(--mantine-color-violet-0);
} }
@mixin dark { @mixin dark {
color: var(--mantine-color-violet-1); color: var(--mantine-color-violet-1);
background-color: #534a6d;
} }
} }
} }
.header {
display: flex;
align-items: center;
margin-bottom: var(--mantine-spacing-md);
}
.title { .title {
margin-left: var(--mantine-spacing-md); margin-left: var(--mantine-spacing-md);
@@ -1,115 +1,117 @@
/* import type { JSX, MouseEvent } from 'react'
* Component inspired by: https://github.com/mantinedev/mantine/tree/master/apps/mantine.dev/src/components/TableOfContents import { useRef, useState, useEffect } from 'react'
*/ import { useRouter } from 'tuono'
import type { JSX } from 'react' import { Box, Text } from '@mantine/core'
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 { getHeadings, type Heading } from './getHeadings' import { getHeadings, type Heading } from './getHeadings'
import classes from './TableOfContents.module.css' import classes from './TableOfContents.module.css'
function getActiveElement(rects: Array<DOMRect>): number { export function TableOfContents(): JSX.Element | null {
if (rects.length === 0) { const [activeHeadingIndex, setActiveHeadingIndex] = useState<number | null>(
return -1 null,
}
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 },
) )
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 [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 router = useRouter()
const filteredHeadings = headings.filter((heading) => heading.depth > 1)
const handleScroll = (): void => {
setActive(
getActiveElement(
headingsRef.current.map((d) => d.getNode().getBoundingClientRect()),
),
)
}
useEffect(() => { useEffect(() => {
const _headings = getHeadings() const _headings = getHeadings()
headingsRef.current = _headings
setHeadings(_headings) setHeadings(_headings)
setActive( headingsRef.current = _headings.map((heading) => heading.getNode())
getActiveElement(
_headings.map((d) => d.getNode().getBoundingClientRect()), 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 => { return (): void => {
window.removeEventListener('scroll', handleScroll) observer.disconnect()
window.removeEventListener('hashchange', handleHashChange)
} }
}, [router.pathname]) }, [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 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 ( return (
<Box <Box component="nav" className={classes.wrapper}>
component="nav"
mod={{ 'with-tabs': withTabs }}
className={classes.wrapper}
>
<div className={classes.inner}> <div className={classes.inner}>
<div> <div>
<div className={classes.header}> <Text className={classes.title} mb={8}>
<IconList On this page
style={{ width: rem(20), height: rem(20) }} </Text>
stroke={1.5} <div className={classes.items}>
/> {headings.slice(1).map((heading, index) => (
<Text className={classes.title}>Table of contents</Text> <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> </div>
<ScrollArea.Autosize
mah={`calc(100vh - ${rem(140)})`}
type="never"
offsetScrollbars
>
<div className={classes.items}>{items}</div>
</ScrollArea.Autosize>
</div> </div>
</div> </div>
</Box> </Box>
@@ -8,14 +8,27 @@ export interface Heading {
getNode: () => HTMLHeadingElement 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> { function getHeadingsData(headings: Array<HTMLHeadingElement>): Array<Heading> {
const result: Array<Heading> = [] const result: Array<Heading> = []
for (const heading of headings) { for (const heading of headings) {
if (heading.id) { const depth = parseInt(heading.getAttribute('data-order') || '1', 10)
if (depth <= 3 && heading.id) {
result.push({ result.push({
depth: parseInt(heading.getAttribute('data-order') || '1', 10), depth,
content: heading.getAttribute('data-heading') || '', content: getCleanedText(heading),
id: heading.id, id: heading.id,
getNode: () => getNode: () =>
document.getElementById(heading.id) as HTMLHeadingElement, document.getElementById(heading.id) as HTMLHeadingElement,
@@ -28,7 +41,6 @@ function getHeadingsData(headings: Array<HTMLHeadingElement>): Array<Heading> {
export function getHeadings(): Array<Heading> { export function getHeadings(): Array<Heading> {
const root = document.getElementById('mdx-root') const root = document.getElementById('mdx-root')
console.log(root)
if (!root) { if (!root) {
return [] return []
+5 -10
View File
@@ -6,14 +6,12 @@ import {
createTheme, createTheme,
MantineProvider, MantineProvider,
AppShell, AppShell,
Container,
mantineHtmlProps, mantineHtmlProps,
type CSSVariablesResolver, type CSSVariablesResolver,
} from '@mantine/core' } from '@mantine/core'
import { useDisclosure } from '@mantine/hooks' import { useDisclosure } from '@mantine/hooks'
import EditPage from '@/components/EditPage' import MdxWrapper from '@/components/MdxWrapper'
import MdxProvider from '@/components/MdxProvider'
import Navbar from '@/components/Navbar' import Navbar from '@/components/Navbar'
import Sidebar from '@/components/Sidebar' import Sidebar from '@/components/Sidebar'
@@ -38,8 +36,8 @@ const theme = createTheme({
}, },
fontSizes: { fontSizes: {
// 'xs' | 'sm' | 'md' | 'lg' | 'xl' // 'xs' | 'sm' | 'md' | 'lg' | 'xl'
xs: '16px', xs: '14px',
sm: '16px', sm: '14px',
}, },
colors: { colors: {
dark: [ dark: [
@@ -125,11 +123,8 @@ export default function RootRoute({ children }: RootRouteProps): JSX.Element {
> >
<Navbar toggle={toggle} /> <Navbar toggle={toggle} />
<Sidebar close={toggle} /> <Sidebar close={toggle} />
<AppShell.Main> <AppShell.Main pt={0} px="auto">
<Container id="mdx-root" component="article" size="md" p={20}> <MdxWrapper>{children}</MdxWrapper>
<MdxProvider>{children}</MdxProvider>
<EditPage />
</Container>
</AppShell.Main> </AppShell.Main>
</AppShell> </AppShell>
</MantineProvider> </MantineProvider>