diff --git a/apps/documentation/src/components/MdxProvider/MdxTitle/MdxTitle.tsx b/apps/documentation/src/components/MdxProvider/MdxTitle/MdxTitle.tsx index 6cf75d9a..0f5c040c 100644 --- a/apps/documentation/src/components/MdxProvider/MdxTitle/MdxTitle.tsx +++ b/apps/documentation/src/components/MdxProvider/MdxTitle/MdxTitle.tsx @@ -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 { ) } -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} /> } diff --git a/apps/documentation/src/components/MdxWrapper/MdxWrapper.tsx b/apps/documentation/src/components/MdxWrapper/MdxWrapper.tsx new file mode 100644 index 00000000..c5175047 --- /dev/null +++ b/apps/documentation/src/components/MdxWrapper/MdxWrapper.tsx @@ -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> + ) +} diff --git a/apps/documentation/src/components/MdxWrapper/index.ts b/apps/documentation/src/components/MdxWrapper/index.ts new file mode 100644 index 00000000..76ac0e44 --- /dev/null +++ b/apps/documentation/src/components/MdxWrapper/index.ts @@ -0,0 +1,3 @@ +import { MdxWrapper } from './MdxWrapper' + +export default MdxWrapper diff --git a/apps/documentation/src/components/TableOfContents/TableOfContents.module.css b/apps/documentation/src/components/TableOfContents/TableOfContents.module.css index 80f23130..86212299 100644 --- a/apps/documentation/src/components/TableOfContents/TableOfContents.module.css +++ b/apps/documentation/src/components/TableOfContents/TableOfContents.module.css @@ -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); diff --git a/apps/documentation/src/components/TableOfContents/TableOfContents.tsx b/apps/documentation/src/components/TableOfContents/TableOfContents.tsx index 57bf3229..09f6b23c 100644 --- a/apps/documentation/src/components/TableOfContents/TableOfContents.tsx +++ b/apps/documentation/src/components/TableOfContents/TableOfContents.tsx @@ -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> diff --git a/apps/documentation/src/components/TableOfContents/getHeadings.ts b/apps/documentation/src/components/TableOfContents/getHeadings.ts index acee9300..54ea9aab 100644 --- a/apps/documentation/src/components/TableOfContents/getHeadings.ts +++ b/apps/documentation/src/components/TableOfContents/getHeadings.ts @@ -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 [] diff --git a/apps/documentation/src/routes/__layout.tsx b/apps/documentation/src/routes/__layout.tsx index 84191ce0..e0892f57 100644 --- a/apps/documentation/src/routes/__layout.tsx +++ b/apps/documentation/src/routes/__layout.tsx @@ -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>