mirror of
https://github.com/tuono-labs/tuono
synced 2026-07-25 12:52:47 -07:00
docs: table of contents (#394)
Co-authored-by: Valerio Ageno <valerioageno@yahoo.it>
This commit is contained in:
@@ -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 []
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user