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

View File

@@ -9,11 +9,33 @@ export const useHeadings = (editor: DocsBlockNoteEditor) => {
useEffect(() => {
setHeadings(editor);
const unsubscribe = editor?.onChange(() => {
setHeadings(editor);
let timeoutId: NodeJS.Timeout;
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 () => {
clearTimeout(timeoutId);
resetHeadings();
unsubscribe();
};

View File

@@ -10,15 +10,112 @@ import { MAIN_LAYOUT_ID } from '@/layouts/conf';
import { Heading } from './Heading';
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 { editor } = useEditorStore();
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const [headingIdHighlight, setHeadingIdHighlight] = useState<string>();
const { t } = useTranslation();
const [isHover, setIsHover] = useState(false);
/**
* Handle scroll to highlight the current heading in the table of content
*/
useEffect(() => {
const handleScroll = () => {
if (!headings) {
@@ -69,23 +166,10 @@ export const TableContent = () => {
.getElementById(MAIN_LAYOUT_ID)
?.removeEventListener('scroll', scrollFn);
};
}, [headings, setHeadingIdHighlight]);
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
};
}, [headings]);
const onClose = () => {
setIsHover(false);
setIsOpen(false);
};
if (
@@ -99,129 +183,69 @@ export const TableContent = () => {
return (
<Box
as="nav"
id="summaryContainer"
$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')}
$width="100%"
$overflow="hidden"
$css={css`
top: var(--c--globals--spacings--0);
border: 1px solid ${colorsTokens['brand-100']};
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']};
`}
user-select: none;
padding: ${spacingsTokens['4xs']};
`}
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
onClick={onOpen}
$width="100%"
$height="100%"
onClick={onClose}
$justify="center"
$align="center"
aria-label={t('Summary')}
aria-expanded={isHover}
aria-expanded="true"
aria-controls="toc-list"
$css={css`
transition: none !important;
transform: rotate(180deg);
&:focus-visible {
outline: none;
box-shadow: 0 0 0 4px ${colorsTokens['brand-400']};
background: ${colorsTokens['brand-100']};
width: 90%;
height: 90%;
box-shadow: 0 0 0 2px ${colorsTokens['brand-400']};
border-radius: var(--c--globals--spacings--st);
}
`}
>
<Icon
$theme="brand"
$variation="tertiary"
iconName="list"
variant="symbols-outlined"
/>
<Icon iconName="menu_open" $theme="brand" $variation="tertiary" />
</BoxButton>
)}
{isHover && (
<Box
$width="100%"
$overflow="hidden"
$css={css`
user-select: none;
padding: ${spacingsTokens['4xs']};
`}
>
<Box
$margin={{ bottom: spacingsTokens.xs }}
$direction="row"
$justify="space-between"
$align="center"
>
<Text $weight="500" $size="sm">
{t('Summary')}
</Text>
<BoxButton
onClick={onClose}
$justify="center"
$align="center"
aria-label={t('Summary')}
aria-expanded={isHover}
aria-controls="toc-list"
$css={css`
transition: none !important;
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
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>
);
};