️(frontend) enhance Table of Contents

- the Table of Contents stickiness now covers the
full height of the viewport, before it was limited to
100vh
- we listen the scroll to highlight the heading
in the Table of Contents only when the Table of Contents
is open
- We debounce the editor change to avoid excessive updates
to the Table of Contents
This commit is contained in:
Anthony LC
2025-11-27 17:41:22 +01:00
parent b740ffa52c
commit f7d4e6810b
3 changed files with 175 additions and 141 deletions

View File

@@ -1,6 +1,5 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { css } from 'styled-components';
import { Box, Loading } from '@/components'; import { Box, Loading } from '@/components';
import { DocHeader } from '@/docs/doc-header/'; import { DocHeader } from '@/docs/doc-header/';
@@ -97,18 +96,7 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
return ( return (
<> <>
{isDesktop && ( {isDesktop && <TableContent />}
<Box
$height="100vh"
$position="absolute"
$css={css`
top: 72px;
right: 20px;
`}
>
<TableContent />
</Box>
)}
<DocEditorContainer <DocEditorContainer
docHeader={<DocHeader doc={doc} />} docHeader={<DocHeader doc={doc} />}
docEditor={ docEditor={

View File

@@ -9,11 +9,33 @@ export const useHeadings = (editor: DocsBlockNoteEditor) => {
useEffect(() => { useEffect(() => {
setHeadings(editor); setHeadings(editor);
const unsubscribe = editor?.onChange(() => { let timeoutId: NodeJS.Timeout;
setHeadings(editor); const DEBOUNCE_DELAY = 500;
const unsubscribe = editor?.onChange((_, context) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
const blocksChanges = context.getChanges();
if (!blocksChanges.length) {
return;
}
const blockChanges = blocksChanges[0];
if (
blockChanges.type !== 'update' ||
blockChanges.block.type !== 'heading'
) {
return;
}
setHeadings(editor);
}, DEBOUNCE_DELAY);
}); });
return () => { return () => {
clearTimeout(timeoutId);
resetHeadings(); resetHeadings();
unsubscribe(); unsubscribe();
}; };

View File

@@ -10,15 +10,112 @@ import { MAIN_LAYOUT_ID } from '@/layouts/conf';
import { Heading } from './Heading'; import { Heading } from './Heading';
export const TableContent = () => { export const TableContent = () => {
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const [containerHeight, setContainerHeight] = useState('100vh');
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
/**
* Calculate container height based on the scrollable content
*/
useEffect(() => {
const mainLayout = document.getElementById(MAIN_LAYOUT_ID);
if (mainLayout) {
setContainerHeight(`${mainLayout.scrollHeight}px`);
}
}, []);
const onOpen = () => {
setIsOpen(true);
};
return (
<Box
$height={containerHeight}
$position="absolute"
$css={css`
top: 72px;
right: 20px;
`}
>
<Box
as="nav"
id="summaryContainer"
$width={!isOpen ? '40px' : '200px'}
$height={!isOpen ? '40px' : 'auto'}
$maxHeight="calc(50vh - 60px)"
$zIndex={1000}
$align="center"
$padding={isOpen ? 'xs' : '0'}
$justify="center"
$position="sticky"
aria-label={t('Summary')}
$css={css`
top: var(--c--globals--spacings--0);
border: 1px solid ${colorsTokens['brand-100']};
overflow: hidden;
border-radius: ${spacingsTokens['3xs']};
background: ${colorsTokens['gray-000']};
${isOpen &&
css`
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
gap: ${spacingsTokens['2xs']};
`}
`}
className="--docs--table-content"
>
{!isOpen && (
<BoxButton
onClick={onOpen}
$width="100%"
$height="100%"
$justify="center"
$align="center"
aria-label={t('Summary')}
aria-expanded={isOpen}
aria-controls="toc-list"
$css={css`
&:focus-visible {
outline: none;
box-shadow: 0 0 0 4px ${colorsTokens['brand-400']};
background: ${colorsTokens['brand-100']};
width: 90%;
height: 90%;
}
`}
>
<Icon
$theme="brand"
$variation="tertiary"
iconName="list"
variant="symbols-outlined"
/>
</BoxButton>
)}
{isOpen && <TableContentOpened setIsOpen={setIsOpen} />}
</Box>
</Box>
);
};
const TableContentOpened = ({
setIsOpen,
}: {
setIsOpen: (isOpen: boolean) => void;
}) => {
const { headings } = useHeadingStore(); const { headings } = useHeadingStore();
const { editor } = useEditorStore(); const { editor } = useEditorStore();
const { spacingsTokens, colorsTokens } = useCunninghamTheme(); const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const [headingIdHighlight, setHeadingIdHighlight] = useState<string>(); const [headingIdHighlight, setHeadingIdHighlight] = useState<string>();
const { t } = useTranslation(); const { t } = useTranslation();
const [isHover, setIsHover] = useState(false);
/**
* Handle scroll to highlight the current heading in the table of content
*/
useEffect(() => { useEffect(() => {
const handleScroll = () => { const handleScroll = () => {
if (!headings) { if (!headings) {
@@ -69,23 +166,10 @@ export const TableContent = () => {
.getElementById(MAIN_LAYOUT_ID) .getElementById(MAIN_LAYOUT_ID)
?.removeEventListener('scroll', scrollFn); ?.removeEventListener('scroll', scrollFn);
}; };
}, [headings, setHeadingIdHighlight]); }, [headings]);
const onOpen = () => {
setIsHover(true);
setTimeout(() => {
const element = document.getElementById(`heading-${headingIdHighlight}`);
element?.scrollIntoView({
behavior: 'instant',
inline: 'center',
block: 'center',
});
}, 0); // 300ms is the transition time of the box
};
const onClose = () => { const onClose = () => {
setIsHover(false); setIsOpen(false);
}; };
if ( if (
@@ -99,129 +183,69 @@ export const TableContent = () => {
return ( return (
<Box <Box
as="nav" $width="100%"
id="summaryContainer" $overflow="hidden"
$width={!isHover ? '40px' : '200px'}
$height={!isHover ? '40px' : 'auto'}
$maxHeight="calc(50vh - 60px)"
$zIndex={1000}
$align="center"
$padding={isHover ? 'xs' : '0'}
$justify="center"
$position="sticky"
aria-label={t('Summary')}
$css={css` $css={css`
top: var(--c--globals--spacings--0); user-select: none;
border: 1px solid ${colorsTokens['brand-100']}; padding: ${spacingsTokens['4xs']};
overflow: hidden;
border-radius: ${spacingsTokens['3xs']};
background: ${colorsTokens['gray-000']};
${isHover &&
css`
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
gap: ${spacingsTokens['2xs']};
`}
`} `}
className="--docs--table-content"
> >
{!isHover && ( <Box
$margin={{ bottom: spacingsTokens.xs }}
$direction="row"
$justify="space-between"
$align="center"
>
<Text $weight="500" $size="sm">
{t('Summary')}
</Text>
<BoxButton <BoxButton
onClick={onOpen} onClick={onClose}
$width="100%"
$height="100%"
$justify="center" $justify="center"
$align="center" $align="center"
aria-label={t('Summary')} aria-label={t('Summary')}
aria-expanded={isHover} aria-expanded="true"
aria-controls="toc-list" aria-controls="toc-list"
$css={css` $css={css`
transition: none !important;
transform: rotate(180deg);
&:focus-visible { &:focus-visible {
outline: none; outline: none;
box-shadow: 0 0 0 4px ${colorsTokens['brand-400']}; box-shadow: 0 0 0 2px ${colorsTokens['brand-400']};
background: ${colorsTokens['brand-100']}; border-radius: var(--c--globals--spacings--st);
width: 90%;
height: 90%;
} }
`} `}
> >
<Icon <Icon iconName="menu_open" $theme="brand" $variation="tertiary" />
$theme="brand"
$variation="tertiary"
iconName="list"
variant="symbols-outlined"
/>
</BoxButton> </BoxButton>
)} </Box>
{isHover && ( <Box
<Box as="ul"
$width="100%" id="toc-list"
$overflow="hidden" role="list"
$css={css` $gap={spacingsTokens['3xs']}
user-select: none; $css={css`
padding: ${spacingsTokens['4xs']}; overflow-y: auto;
`} list-style: none;
> padding: ${spacingsTokens['3xs']};
<Box margin: 0;
$margin={{ bottom: spacingsTokens.xs }} `}
$direction="row" >
$justify="space-between" {headings?.map(
$align="center" (heading) =>
> heading.contentText && (
<Text $weight="500" $size="sm"> <Box as="li" role="listitem" key={heading.id}>
{t('Summary')} <Heading
</Text> editor={editor}
<BoxButton headingId={heading.id}
onClick={onClose} level={heading.props.level}
$justify="center" text={heading.contentText}
$align="center" isHighlight={headingIdHighlight === heading.id}
aria-label={t('Summary')} />
aria-expanded={isHover} </Box>
aria-controls="toc-list" ),
$css={css` )}
transition: none !important; </Box>
transform: rotate(180deg);
&:focus-visible {
outline: none;
box-shadow: 0 0 0 2px ${colorsTokens['brand-400']};
border-radius: var(--c--globals--spacings--st);
}
`}
>
<Icon iconName="menu_open" $theme="brand" $variation="tertiary" />
</BoxButton>
</Box>
<Box
as="ul"
id="toc-list"
role="list"
$gap={spacingsTokens['3xs']}
$css={css`
overflow-y: auto;
list-style: none;
padding: ${spacingsTokens['3xs']};
margin: 0;
`}
>
{headings?.map(
(heading) =>
heading.contentText && (
<Box as="li" role="listitem" key={heading.id}>
<Heading
editor={editor}
headingId={heading.id}
level={heading.props.level}
text={heading.contentText}
isHighlight={headingIdHighlight === heading.id}
/>
</Box>
),
)}
</Box>
</Box>
)}
</Box> </Box>
); );
}; };