⚡️(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:
@@ -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={
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user