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'
|
||||
|
||||
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 []
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user