mirror of
https://github.com/tuono-labs/tuono
synced 2026-07-25 12:52:47 -07:00
feat: add table of content
This commit is contained in:
@@ -1,17 +0,0 @@
|
||||
import { Title } from '@mantine/core'
|
||||
import { useRef, type HTMLAttributes } from 'react'
|
||||
|
||||
export default function MdxH2(
|
||||
props: HTMLAttributes<HTMLHeadingElement>,
|
||||
): JSX.Element {
|
||||
return (
|
||||
<Title
|
||||
{...props}
|
||||
mt={20}
|
||||
order={2}
|
||||
id={String(props.children ?? '')
|
||||
.toLowerCase()
|
||||
.replaceAll(' ', '-')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -5,9 +5,8 @@ import type { ReactNode } from 'react'
|
||||
import MdxPre from './mdx-pre'
|
||||
import MdxQuote from './mdx-quote'
|
||||
import MdxCode from './mdx-code'
|
||||
import MdxTitle from './mdx-title'
|
||||
import { h } from './mdx-title'
|
||||
import MdxBold from './mdx-bold/mdx-bold'
|
||||
import MdxH2 from './mdx-h2/mdx-h2'
|
||||
|
||||
interface MdxProviderProps {
|
||||
children: ReactNode
|
||||
@@ -24,8 +23,12 @@ export default function MdxProvider({
|
||||
pre: MdxPre,
|
||||
blockquote: MdxQuote,
|
||||
code: MdxCode,
|
||||
h1: MdxTitle,
|
||||
h2: MdxH2,
|
||||
h1: h(1),
|
||||
h2: h(2),
|
||||
h3: h(3),
|
||||
h4: h(4),
|
||||
h5: h(5),
|
||||
h6: h(6),
|
||||
strong: MdxBold,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import MdxTitle from './mdx-title'
|
||||
import MdxTitle, { h } from './mdx-title'
|
||||
|
||||
export default MdxTitle
|
||||
|
||||
export { h }
|
||||
|
||||
@@ -2,15 +2,23 @@ import { Title } from '@mantine/core'
|
||||
import type { HTMLAttributes } from 'react'
|
||||
|
||||
export default function MdxTitle(
|
||||
props: HTMLAttributes<HTMLHeadingElement>,
|
||||
props: HTMLAttributes<HTMLHeadingElement> & { order: number },
|
||||
): JSX.Element {
|
||||
return (
|
||||
<Title
|
||||
data-heading={props.children}
|
||||
data-order={props.order}
|
||||
mt={20}
|
||||
{...props}
|
||||
order={1}
|
||||
id={String(props.children ?? '')
|
||||
.toLowerCase()
|
||||
.replaceAll(' ', '-')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const h =
|
||||
(order: 1 | 2 | 3 | 4 | 5 | 6) =>
|
||||
(props: HTMLAttributes<HTMLHeadingElement>): JSX.Element => (
|
||||
<MdxTitle order={order} {...props} />
|
||||
)
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Component inspired by: https://github.com/mantinedev/mantine/tree/master/apps/mantine.dev/src/components/TableOfContents
|
||||
*/
|
||||
export interface Heading {
|
||||
depth: number
|
||||
content: string
|
||||
id: string
|
||||
getNode: () => HTMLHeadingElement
|
||||
}
|
||||
|
||||
function getHeadingsData(headings: HTMLHeadingElement[]): Heading[] {
|
||||
const result: Heading[] = []
|
||||
|
||||
for (const heading of headings) {
|
||||
if (heading.id) {
|
||||
result.push({
|
||||
depth: parseInt(heading.getAttribute('data-order'), 10),
|
||||
content: heading.getAttribute('data-heading') || '',
|
||||
id: heading.id,
|
||||
getNode: () =>
|
||||
document.getElementById(heading.id) as HTMLHeadingElement,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function getHeadings(): Heading[] {
|
||||
const root = document.getElementById('mdx-root')
|
||||
console.log(root)
|
||||
|
||||
if (!root) {
|
||||
return []
|
||||
}
|
||||
|
||||
return getHeadingsData(Array.from(root.querySelectorAll('[data-heading]')))
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/*
|
||||
* Component inspired by: https://github.com/mantinedev/mantine/tree/master/apps/mantine.dev/src/components/TableOfContents
|
||||
*/
|
||||
import { TableOfContents } from './table-of-content'
|
||||
|
||||
export default TableOfContents
|
||||
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
@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);
|
||||
}
|
||||
}
|
||||
|
||||
.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 {
|
||||
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));
|
||||
border-top-right-radius: var(--mantine-radius-sm);
|
||||
border-bottom-right-radius: var(--mantine-radius-sm);
|
||||
|
||||
@mixin light {
|
||||
color: var(--mantine-color-gray-7);
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
color: var(--mantine-color-dark-1);
|
||||
}
|
||||
|
||||
@mixin rtl {
|
||||
border-left: 0;
|
||||
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;
|
||||
}
|
||||
|
||||
&[data-active] {
|
||||
border-color: var(--mantine-color-violet-5);
|
||||
|
||||
@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);
|
||||
|
||||
@mixin rtl {
|
||||
margin-left: 0;
|
||||
margin-right: var(--mantine-spacing-md);
|
||||
}
|
||||
}
|
||||
|
||||
.items {
|
||||
border-left: 1px solid;
|
||||
|
||||
@mixin light {
|
||||
border-color: var(--mantine-color-gray-3) !important;
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
border-color: var(--mantine-color-dark-4) !important;
|
||||
}
|
||||
|
||||
@mixin rtl {
|
||||
border-left: 0;
|
||||
border-right: 1px solid;
|
||||
|
||||
@mixin light {
|
||||
border-color: var(--mantine-color-gray-3) !important;
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
border-color: var(--mantine-color-dark-4) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* Component inspired by: https://github.com/mantinedev/mantine/tree/master/apps/mantine.dev/src/components/TableOfContents
|
||||
*/
|
||||
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 './get-headings'
|
||||
import classes from './table-of-content.module.css'
|
||||
|
||||
interface TableOfContentsProps {
|
||||
withTabs: boolean
|
||||
}
|
||||
|
||||
function getActiveElement(rects: 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 },
|
||||
)
|
||||
|
||||
return closest.index
|
||||
}
|
||||
|
||||
export function TableOfContents({
|
||||
withTabs,
|
||||
}: TableOfContentsProps): JSX.Element | null {
|
||||
const [active, setActive] = useState(0)
|
||||
const [headings, setHeadings] = useState<Heading[]>([])
|
||||
const headingsRef = useRef<Heading[]>([])
|
||||
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()),
|
||||
),
|
||||
)
|
||||
window.addEventListener('scroll', handleScroll)
|
||||
return (): void => window.removeEventListener('scroll', handleScroll)
|
||||
}, [router.pathname])
|
||||
|
||||
if (filteredHeadings.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const items = filteredHeadings.map((heading, index) => (
|
||||
<Text<Link>
|
||||
key={heading.id}
|
||||
component={Link}
|
||||
fz="sm"
|
||||
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}
|
||||
visibleFrom="lg"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
<ScrollArea.Autosize
|
||||
mah={`calc(100vh - ${rem(140)})`}
|
||||
type="never"
|
||||
offsetScrollbars
|
||||
>
|
||||
<div className={classes.items}>{items}</div>
|
||||
</ScrollArea.Autosize>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { AppShell, Container } from '@mantine/core'
|
||||
import { AppShell, Container, Flex } from '@mantine/core'
|
||||
import MdxProvider from '../../components/mdx-provider'
|
||||
import EditPage from '../../components/edit-page'
|
||||
import TableOfContents from '../../components/table-of-content'
|
||||
|
||||
interface RootRouteProps {
|
||||
children: ReactNode
|
||||
@@ -10,9 +11,16 @@ interface RootRouteProps {
|
||||
export default function RootRoute({ children }: RootRouteProps): JSX.Element {
|
||||
return (
|
||||
<AppShell.Main>
|
||||
<Container component="article" p={20}>
|
||||
<MdxProvider>{children}</MdxProvider>
|
||||
<EditPage />
|
||||
<Container p={20} size="lg">
|
||||
<Flex>
|
||||
<Container id="mdx-root" component="article">
|
||||
<MdxProvider>{children}</MdxProvider>
|
||||
<EditPage />
|
||||
</Container>
|
||||
<Container size="xs">
|
||||
<TableOfContents withTabs={false} />
|
||||
</Container>
|
||||
</Flex>
|
||||
</Container>
|
||||
</AppShell.Main>
|
||||
)
|
||||
|
||||
@@ -12,6 +12,8 @@ import Breadcrumbs, { Element } from '../../components/breadcrumbs'
|
||||
|
||||
# Getting started
|
||||
|
||||
## The CLI
|
||||
|
||||
Tuono is the CLI that provides all the needed commands to handle the fullstack project.
|
||||
|
||||
> ☝️ Check the [installation](/documentation/installation) page if you haven't installed the
|
||||
|
||||
Reference in New Issue
Block a user