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