♻️(frontend) move table of content to right panel

We move the floating table of content to the
right panel. This allows us to have a more consistent
UI and to make room for the right sidebar.
This commit is contained in:
Anthony LC
2026-05-27 17:03:26 +02:00
parent 2c4317947b
commit 68b9342401
17 changed files with 328 additions and 368 deletions
@@ -1,6 +1,7 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { createDoc, verifyDocName } from './utils-common'; import { createDoc } from './utils-common';
import { tryFocusEditorContent } from './utils-editor';
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await page.goto('/'); await page.goto('/');
@@ -8,40 +9,58 @@ test.beforeEach(async ({ page }) => {
test.describe('Doc Table Content', () => { test.describe('Doc Table Content', () => {
test('it checks the doc table content', async ({ page, browserName }) => { test('it checks the doc table content', async ({ page, browserName }) => {
const [randomDoc] = await createDoc( await createDoc(page, 'doc-table-content', browserName, 1);
page,
'doc-table-content',
browserName,
1,
);
await verifyDocName(page, randomDoc);
await page.locator('.ProseMirror').click();
await expect( await expect(
page.getByRole('button', { name: 'Show the table of contents' }), page.getByRole('button', { name: 'Show the table of contents sidebar' }),
).toBeHidden(); ).toBeHidden();
await page.keyboard.type('# Level 1\n## Level 2\n### Level 3'); const editor = await tryFocusEditorContent({ page });
await page.keyboard.type('# Level 1');
for (let i = 0; i < 20; i++) {
await page.keyboard.press('Enter');
}
await page.keyboard.type('## Level 2');
for (let i = 0; i < 20; i++) {
await page.keyboard.press('Enter');
}
await page.keyboard.type('### Level 3');
const summaryContainer = page.locator('#summaryContainer'); await page
await summaryContainer.click(); .getByRole('button', { name: 'Show the table of contents sidebar' })
.click();
const level1 = summaryContainer.getByText('Level 1'); const elSidePanel = page.getByLabel('Table of contents side panel');
const level2 = summaryContainer.getByText('Level 2');
const level3 = summaryContainer.getByText('Level 3'); const level1 = elSidePanel.getByText('Level 1');
const editorLevel1 = editor.getByText('Level 1');
const level2 = elSidePanel.getByText('Level 2');
const editorLevel2 = editor.getByText('Level 2');
const level3 = elSidePanel.getByText('Level 3');
await expect(level1).toBeVisible(); await expect(level1).toBeVisible();
await expect(level1).toHaveCSS('padding', /4px 0px/); await expect(level1).toHaveCSS('padding', /0px 0px 0px 8px/);
await expect(level1).toHaveAttribute('aria-selected', 'true'); await expect(editorLevel1).not.toBeInViewport();
await expect(level1).toHaveAttribute('aria-selected', 'false');
await expect(level2).toBeVisible(); await expect(level2).toBeVisible();
await expect(level2).toHaveCSS('padding-left', /14.4px/); await expect(level2).toHaveCSS('padding-left', /14.4px/);
await expect(level2).toHaveAttribute('aria-selected', 'false'); await expect(editorLevel2).toBeInViewport();
await expect(level2).toHaveAttribute('aria-selected', 'true');
await expect(level3).toBeVisible(); await expect(level3).toBeVisible();
await expect(level3).toHaveCSS('padding-left', /24px/); await expect(level3).toHaveCSS('padding-left', /24px/);
await expect(level3).toHaveAttribute('aria-selected', 'false'); await expect(level3).toHaveAttribute('aria-selected', 'false');
await level1.click();
await expect(editorLevel1).toBeInViewport();
await expect(level1).toHaveAttribute('aria-selected', 'true');
await expect(level2).toHaveAttribute('aria-selected', 'false');
await level2.click();
await expect(editorLevel1).not.toBeInViewport();
await expect(editorLevel2).toBeInViewport();
await expect(level2).toHaveAttribute('aria-selected', 'true');
await expect(level1).toHaveAttribute('aria-selected', 'false');
}); });
}); });
@@ -0,0 +1,26 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M3.00977 18C3.56205 18 4.00977 18.4477 4.00977 19C4.00977 19.5523 3.56205 20 3.00977 20H3C2.44772 20 2 19.5523 2 19C2 18.4477 2.44772 18 3 18H3.00977Z"
fill="currentColor"
/>
<path
d="M21 18C21.5523 18 22 18.4477 22 19C22 19.5523 21.5523 20 21 20H8C7.44772 20 7 19.5523 7 19C7 18.4477 7.44772 18 8 18H21Z"
fill="currentColor"
/>
<path
d="M3.00977 11C3.56205 11 4.00977 11.4477 4.00977 12C4.00977 12.5523 3.56205 13 3.00977 13H3C2.44772 13 2 12.5523 2 12C2 11.4477 2.44772 11 3 11H3.00977Z"
fill="currentColor"
/>
<path
d="M21 11C21.5523 11 22 11.4477 22 12C22 12.5523 21.5523 13 21 13H8C7.44772 13 7 12.5523 7 12C7 11.4477 7.44772 11 8 11H21Z"
fill="currentColor"
/>
<path
d="M3.00977 4C3.56205 4 4.00977 4.44772 4.00977 5C4.00977 5.55228 3.56205 6 3.00977 6H3C2.44772 6 2 5.55228 2 5C2 4.44772 2.44772 4 3 4H3.00977Z"
fill="currentColor"
/>
<path
d="M21 4C21.5523 4 22 4.44772 22 5C22 5.55228 21.5523 6 21 6H8C7.44772 6 7 5.55228 7 5C7 4.44772 7.44772 4 8 4H21Z"
fill="currentColor"
/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -29,6 +29,7 @@ import { useConfig } from '@/core';
import { useCunninghamTheme } from '@/cunningham'; import { useCunninghamTheme } from '@/cunningham';
import { Doc } from '@/docs/doc-management'; import { Doc } from '@/docs/doc-management';
import { avatarUrlFromName, useAuth } from '@/features/auth'; import { avatarUrlFromName, useAuth } from '@/features/auth';
import { useRightPanelStore } from '@/features/right-panel/components/useRightPanelStore';
import { useAnalytics } from '@/libs/Analytics'; import { useAnalytics } from '@/libs/Analytics';
import { AI_FEATURE_FLAG, DEFAULT_LOCALE } from '../conf'; import { AI_FEATURE_FLAG, DEFAULT_LOCALE } from '../conf';
@@ -135,11 +136,10 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
); );
// Comment sidebar // Comment sidebar
const { const { threadsSidebarTarget, filter: threadsSidebarFilter } =
threadsSidebarTarget, useCommentSidebarStore();
filter: threadsSidebarFilter, const { activePanel, isPanelOpen } = useRightPanelStore();
isSideBarOpen, const isCommentSideBarOpen = isPanelOpen && activePanel === 'comments';
} = useCommentSidebarStore();
const currentUserAvatarUrl = useMemo(() => { const currentUserAvatarUrl = useMemo(() => {
if (canSeeComment) { if (canSeeComment) {
@@ -303,7 +303,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
<BlockNoteSuggestionMenu aiAllowed={aiBlockNoteAllowed} /> <BlockNoteSuggestionMenu aiAllowed={aiBlockNoteAllowed} />
<BlockNoteToolbar aiAllowed={aiBlockNoteAllowed} /> <BlockNoteToolbar aiAllowed={aiBlockNoteAllowed} />
{showComments && <FloatingComposerController />} {showComments && <FloatingComposerController />}
{showComments && !isSideBarOpen && <FloatingThreadController />} {showComments && !isCommentSideBarOpen && <FloatingThreadController />}
{threadsSidebarTarget && {threadsSidebarTarget &&
createPortal( createPortal(
<ThreadsSidebar <ThreadsSidebar
@@ -11,7 +11,6 @@ import {
useIsCollaborativeEditable, useIsCollaborativeEditable,
useProviderStore, useProviderStore,
} from '@/docs/doc-management'; } from '@/docs/doc-management';
import { TableContent } from '@/docs/doc-table-content/';
import { useAuth } from '@/features/auth/'; import { useAuth } from '@/features/auth/';
import { SkeletonEditorCore, useSkeletonStore } from '@/features/skeletons'; import { SkeletonEditorCore, useSkeletonStore } from '@/features/skeletons';
import { useSkeletonFadeOut } from '@/features/skeletons/hooks/useFadeOut'; import { useSkeletonFadeOut } from '@/features/skeletons/hooks/useFadeOut';
@@ -86,7 +85,6 @@ interface DocEditorProps {
export const DocEditor = ({ doc }: DocEditorProps) => { export const DocEditor = ({ doc }: DocEditorProps) => {
useCollaboration(doc.id); useCollaboration(doc.id);
const { isDesktop } = useResponsiveStore();
const { isEditable, isLoading } = useIsCollaborativeEditable(doc); const { isEditable, isLoading } = useIsCollaborativeEditable(doc);
const isDeletedDoc = !!doc.deleted_at; const isDeletedDoc = !!doc.deleted_at;
const readOnly = const readOnly =
@@ -126,16 +124,13 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
}, [authenticated, hasTracked, isPublicDoc, trackEvent]); }, [authenticated, hasTracked, isPublicDoc, trackEvent]);
return ( return (
<> <DocEditorContainer
{isDesktop && <TableContent selector={`.${DOCS_EDITOR_CLASS}`} />} docHeader={<DocHeader doc={doc} />}
<DocEditorContainer isDeletedDoc={isDeletedDoc}
docHeader={<DocHeader doc={doc} />} readOnly={readOnly}
isDeletedDoc={isDeletedDoc} >
readOnly={readOnly} <DocCoreEditor doc={doc} readOnly={readOnly} />
> </DocEditorContainer>
<DocCoreEditor doc={doc} readOnly={readOnly} />
</DocEditorContainer>
</>
); );
}; };
@@ -110,25 +110,28 @@ export const CommentSideBar = ({ onClose }: CommentSideBarProps) => {
export const CommentSideBarButton = () => { export const CommentSideBarButton = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { isPanelOpen, togglePanel } = useRightPanelStore(); const { isPanelOpen, activePanel, setActivePanel, setIsPanelOpen } =
const { setIsSideBarOpen } = useCommentSidebarStore(); useRightPanelStore();
useEffect(() => { const isActive = isPanelOpen && activePanel === 'comments';
setIsSideBarOpen(isPanelOpen); const ariaLabel = isActive
}, [isPanelOpen, setIsSideBarOpen]);
const ariaLabel = isPanelOpen
? t('Hide the comments sidebar') ? t('Hide the comments sidebar')
: t('Show the comments sidebar'); : t('Show the comments sidebar');
return ( return (
<Button <Button
size="small" size="small"
onClick={togglePanel} onClick={() => {
if (isActive) {
setIsPanelOpen(false);
} else {
setActivePanel('comments');
}
}}
aria-label={ariaLabel} aria-label={ariaLabel}
aria-expanded={isPanelOpen} aria-expanded={isActive}
color="neutral" color="neutral"
variant={isPanelOpen ? 'secondary' : 'tertiary'} variant={isActive ? 'secondary' : 'tertiary'}
icon={<CommentsIcon width={24} height={24} aria-hidden="true" />} icon={<CommentsIcon width={24} height={24} aria-hidden="true" />}
></Button> ></Button>
); );
@@ -2,18 +2,14 @@ import { create } from 'zustand';
interface CommentSidebarStore { interface CommentSidebarStore {
filter: 'open' | 'resolved'; filter: 'open' | 'resolved';
isSideBarOpen: boolean;
setIsSideBarOpen: (isSideBarOpen: boolean) => void;
setThreadsSidebarTarget: (el: HTMLElement | null) => void;
setFilter: (filter: 'open' | 'resolved') => void; setFilter: (filter: 'open' | 'resolved') => void;
setThreadsSidebarTarget: (el: HTMLElement | null) => void;
threadsSidebarTarget: HTMLElement | null; threadsSidebarTarget: HTMLElement | null;
} }
export const useCommentSidebarStore = create<CommentSidebarStore>((set) => ({ export const useCommentSidebarStore = create<CommentSidebarStore>((set) => ({
filter: 'open', filter: 'open',
isSideBarOpen: false,
setFilter: (filter) => set(() => ({ filter })), setFilter: (filter) => set(() => ({ filter })),
setIsSideBarOpen: (isSideBarOpen) => set(() => ({ isSideBarOpen })),
setThreadsSidebarTarget: (threadsSidebarTarget) => { setThreadsSidebarTarget: (threadsSidebarTarget) => {
set(() => ({ threadsSidebarTarget })); set(() => ({ threadsSidebarTarget }));
}, },
@@ -5,9 +5,9 @@ import { TFunction } from 'i18next';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { validate as uuidValidate } from 'uuid'; import { validate as uuidValidate } from 'uuid';
import { DocsBlockNoteEditor } from '@/docs/doc-editor';
import LinkPageIcon from '@/docs/doc-editor/assets/doc-link.svg'; import LinkPageIcon from '@/docs/doc-editor/assets/doc-link.svg';
import AddPageIcon from '@/docs/doc-editor/assets/doc-plus.svg'; import AddPageIcon from '@/docs/doc-editor/assets/doc-plus.svg';
import { DocsBlockNoteEditor } from '@/docs/doc-editor/types';
import { useCreateChildDocTree, useDocStore } from '@/docs/doc-management'; import { useCreateChildDocTree, useDocStore } from '@/docs/doc-management';
import { LinkSelected } from './LinkSelected'; import { LinkSelected } from './LinkSelected';
@@ -22,15 +22,9 @@ export const useHeadings = (editor: DocsBlockNoteEditor) => {
timeoutId = setTimeout(() => { timeoutId = setTimeout(() => {
const blocksChanges = context.getChanges(); const blocksChanges = context.getChanges();
if (!blocksChanges.length) {
return;
}
const blockChanges = blocksChanges[0];
if ( if (
blockChanges.type !== 'update' || !blocksChanges.length ||
blockChanges.block.type !== 'heading' !blocksChanges.find((change) => change.block.type === 'heading')
) { ) {
return; return;
} }
@@ -20,7 +20,7 @@ import { css } from 'styled-components';
import { Box, ButtonCloseModal, Text } from '@/components'; import { Box, ButtonCloseModal, Text } from '@/components';
import { useMediaUrl } from '@/core'; import { useMediaUrl } from '@/core';
import { useEditorStore } from '@/docs/doc-editor'; import { useEditorStore } from '@/docs/doc-editor/stores/useEditorStore';
import { Doc, useTrans } from '@/docs/doc-management'; import { Doc, useTrans } from '@/docs/doc-management';
import { fallbackLng } from '@/i18n/config'; import { fallbackLng } from '@/i18n/config';
@@ -3,13 +3,13 @@ import { css } from 'styled-components';
import { BoxButton, Text } from '@/components'; import { BoxButton, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham'; import { useCunninghamTheme } from '@/cunningham';
import { DocsBlockNoteEditor } from '@/docs/doc-editor'; import { DocsBlockNoteEditor } from '@/docs/doc-editor/types';
import { useResponsiveStore } from '@/stores'; import { useResponsiveStore } from '@/stores';
const leftPaddingMap: { [key: number]: string } = { const leftPaddingMap: { [key: number]: string } = {
3: '1.5rem', 3: '1.5rem',
2: '0.9rem', 2: '0.9rem',
1: '0.3rem', 1: 'xs',
}; };
export type HeadingsHighlight = { export type HeadingsHighlight = {
@@ -40,7 +40,9 @@ export const Heading = ({
return ( return (
<BoxButton <BoxButton
id={`heading-${headingId}`} id={`heading-${headingId}`}
className="--docs--table-content-heading"
$width="100%" $width="100%"
$height="var(--c--globals--spacings--lg)"
onMouseOver={() => setIsHover(true)} onMouseOver={() => setIsHover(true)}
onMouseLeave={() => setIsHover(false)} onMouseLeave={() => setIsHover(false)}
onClick={() => { onClick={() => {
@@ -62,9 +64,10 @@ export const Heading = ({
$radius="var(--c--globals--spacings--st)" $radius="var(--c--globals--spacings--st)"
$background={ $background={
isActive isActive
? 'var(--c--contextuals--background--semantic--neutral--secondary)' ? 'var(--c--contextuals--background--semantic--overlay--primary)'
: 'none' : 'none'
} }
$justify="center"
$css={css` $css={css`
text-align: left; text-align: left;
&:focus-visible { &:focus-visible {
@@ -74,15 +77,14 @@ export const Heading = ({
border-radius: var(--c--globals--spacings--st); border-radius: var(--c--globals--spacings--st);
} }
`} `}
className="--docs--table-content-heading"
aria-label={text} aria-label={text}
aria-selected={isHighlight} aria-selected={isHighlight}
aria-current={isHighlight ? 'true' : undefined} aria-current={isHighlight ? 'true' : undefined}
> >
<Text <Text
$width="100%" $size="sm"
$padding={{ vertical: 'xtiny', left: leftPaddingMap[level] }} $padding={{ left: leftPaddingMap[level] }}
$weight={isHighlight ? 'bold' : 'normal'} $weight={isHighlight ? '700' : '500'}
$css="overflow-wrap: break-word;" $css="overflow-wrap: break-word;"
$hasTransition $hasTransition
aria-selected={isHighlight} aria-selected={isHighlight}
@@ -1,284 +0,0 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, BoxButton, Icon, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import {
DocsBlockNoteEditor,
HeadingBlock,
useEditorStore,
useHeadingStore,
} from '@/docs/doc-editor';
import { MAIN_LAYOUT_ID } from '@/layouts/conf';
import { Heading } from './Heading';
export const TableContent = ({ selector }: { selector: string }) => {
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const [containerHeight, setContainerHeight] = useState('100vh');
const { headings } = useHeadingStore();
const { editor } = useEditorStore();
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
/**
* Calculate container height based on the scrollable content
*/
useEffect(() => {
const layout = document.querySelector<HTMLElement>(selector);
if (!layout) {
return;
}
let timeout: ReturnType<typeof setTimeout>;
const updateHeight = () => {
clearTimeout(timeout);
timeout = setTimeout(() => {
setContainerHeight(`${layout.scrollHeight}px`);
}, 300);
};
updateHeight();
const observer = new ResizeObserver(updateHeight);
observer.observe(layout);
return () => {
clearTimeout(timeout);
observer.disconnect();
};
}, [selector]);
const onOpen = () => {
setIsOpen(true);
};
if (
!editor ||
!headings ||
headings.length === 0 ||
(headings.length === 1 && !headings[0].contentText)
) {
return null;
}
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: var(--c--contextuals--background--surface--primary);
${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('Show the table of contents')}
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}
headings={headings}
editor={editor}
/>
)}
</Box>
</Box>
);
};
const TableContentOpened = ({
setIsOpen,
headings,
editor,
}: {
setIsOpen: (isOpen: boolean) => void;
headings: HeadingBlock[];
editor: DocsBlockNoteEditor;
}) => {
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const [headingIdHighlight, setHeadingIdHighlight] = useState<string>();
const { t } = useTranslation();
/**
* Handle scroll to highlight the current heading in the table of content
*/
useEffect(() => {
const handleScroll = () => {
if (!headings) {
return;
}
for (const heading of headings) {
const elHeading = document.body.querySelector(
`.bn-block-outer[data-id="${heading.id}"] [data-content-type="heading"]:first-child`,
);
if (!elHeading) {
return;
}
const rect = elHeading.getBoundingClientRect();
const isVisible =
rect.top + rect.height >= 1 &&
rect.bottom <=
(window.innerHeight || document.documentElement.clientHeight);
if (isVisible) {
setHeadingIdHighlight(heading.id);
break;
}
}
};
let timeout: NodeJS.Timeout;
const scrollFn = () => {
if (timeout) {
clearTimeout(timeout);
}
timeout = setTimeout(() => {
handleScroll();
}, 300);
};
document
.getElementById(MAIN_LAYOUT_ID)
?.addEventListener('scroll', scrollFn);
handleScroll();
return () => {
document
.getElementById(MAIN_LAYOUT_ID)
?.removeEventListener('scroll', scrollFn);
};
}, [headings]);
const onClose = () => {
setIsOpen(false);
};
return (
<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('Hide the table of contents')}
aria-expanded={true}
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>
);
};
@@ -0,0 +1,166 @@
import { Button } from '@gouvfr-lasuite/cunningham-react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import TableContentIcon from '@/assets/icons/ui-kit/bulleted-list.svg';
import { Box, ButtonCloseModal, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { useEditorStore } from '@/docs/doc-editor/stores/useEditorStore';
import { useHeadingStore } from '@/docs/doc-editor/stores/useHeadingStore';
import { useRightPanelStore } from '@/features/right-panel/components/useRightPanelStore';
import { MAIN_LAYOUT_ID } from '@/layouts/conf';
import { Heading } from './Heading';
interface TableContentSideBarProps {
onClose: () => void;
}
export const TableContentSideBar = ({ onClose }: TableContentSideBarProps) => {
const { t } = useTranslation();
const { spacingsTokens } = useCunninghamTheme();
const { headings } = useHeadingStore();
const { editor } = useEditorStore();
const [headingIdHighlight, setHeadingIdHighlight] = useState<string>();
useEffect(() => {
const handleScroll = () => {
if (!headings) {
return;
}
let activeHeadingId: string | undefined;
for (const heading of headings) {
const elHeading = document.body.querySelector(
`.bn-block-outer[data-id="${heading.id}"] [data-content-type="heading"]:first-child`,
);
if (!elHeading) {
continue;
}
const rect = elHeading.getBoundingClientRect();
if (rect.top > 0) {
activeHeadingId = heading.id;
break;
}
}
// If no heading has passed the top yet, fall back to the first heading
if (!activeHeadingId && headings.length > 0) {
activeHeadingId = headings[0].id;
}
setHeadingIdHighlight(activeHeadingId);
};
let timeout: NodeJS.Timeout;
const scrollFn = () => {
if (timeout) {
clearTimeout(timeout);
}
timeout = setTimeout(() => {
handleScroll();
}, 300);
};
document
.getElementById(MAIN_LAYOUT_ID)
?.addEventListener('scroll', scrollFn);
handleScroll();
return () => {
if (timeout) {
clearTimeout(timeout);
}
document
.getElementById(MAIN_LAYOUT_ID)
?.removeEventListener('scroll', scrollFn);
};
}, [headings]);
return (
<Box $height="inherit">
<Box
$padding={{ vertical: 'base', horizontal: 'sm' }}
$css={css`
border-bottom: 1px solid
var(--c--contextuals--border--surface--primary);
`}
>
<Box $direction="row" $align="center" $justify="space-between">
<Text $weight="bold">{t('Table of Contents')}</Text>
<ButtonCloseModal
aria-label={t('Close the table of contents sidebar')}
onClick={onClose}
/>
</Box>
</Box>
{editor && headings && headings.length > 0 && (
<Box
as="ul"
role="list"
$gap={spacingsTokens['3xs']}
$padding={{
vertical: 'base',
horizontal: 'sm',
}}
$css={css`
overflow-y: auto;
list-style: none;
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>
);
};
export const TableContentSideBarButton = () => {
const { t } = useTranslation();
const { isPanelOpen, activePanel, setActivePanel, setIsPanelOpen } =
useRightPanelStore();
const isActive = isPanelOpen && activePanel === 'tableContent';
const ariaLabel = isActive
? t('Hide the table of contents sidebar')
: t('Show the table of contents sidebar');
return (
<Button
size="small"
onClick={() => {
if (isActive) {
setIsPanelOpen(false);
} else {
setActivePanel('tableContent');
}
}}
aria-label={ariaLabel}
aria-expanded={isActive}
color="neutral"
variant={isActive ? 'secondary' : 'tertiary'}
icon={<TableContentIcon width={24} height={24} aria-hidden="true" />}
></Button>
);
};
@@ -1,2 +1 @@
export * from './TableContent';
export * from './Heading'; export * from './Heading';
@@ -1,31 +1,57 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { css } from 'styled-components'; import { css } from 'styled-components';
import { Box } from '@/components'; import { Box } from '@/components';
import { CommentSideBar } from '@/features/docs/doc-editor/components/comments/CommentSideBar'; import { CommentSideBar } from '@/features/docs/doc-editor/components/comments/CommentSideBar';
import { useDocStore, useProviderStore } from '@/features/docs/doc-management'; import { useDocStore, useProviderStore } from '@/features/docs/doc-management';
import { TableContentSideBar } from '@/features/docs/doc-table-content/components/TableContentSideBar';
import { HEADER_HEIGHT } from '@/features/header'; import { HEADER_HEIGHT } from '@/features/header';
import { useResponsiveStore } from '@/stores'; import { useResponsiveStore } from '@/stores';
import { useRightPanelStore } from './useRightPanelStore'; import { RightPanelView, useRightPanelStore } from './useRightPanelStore';
export const RightPanel = () => { export const RightPanel = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { currentDoc: doc } = useDocStore(); const { currentDoc: doc } = useDocStore();
const { setIsPanelOpen, isPanelOpen } = useRightPanelStore(); const { setIsPanelOpen, isPanelOpen, activePanel } = useRightPanelStore();
const { isMobile } = useResponsiveStore(); const { isMobile } = useResponsiveStore();
const { provider, isReady } = useProviderStore(); const { provider, isReady } = useProviderStore();
const isProviderReady = const isProviderReady =
isReady && provider && provider?.configuration.name === doc?.id; isReady && provider && provider?.configuration.name === doc?.id;
/**
* Keep rendering the last active panel during the close animation,
* so the content doesn't vanish before the panel finishes sliding out.
* When switching panels, the content swaps instantly.
*/
const [renderedPanel, setRenderedPanel] = useState<RightPanelView | null>(
null,
);
useEffect(() => {
if (activePanel !== null) {
setRenderedPanel(activePanel);
} else {
const timer = setTimeout(() => setRenderedPanel(null), 500);
return () => clearTimeout(timer);
}
}, [activePanel]);
if (!doc || !isProviderReady) { if (!doc || !isProviderReady) {
return null; return null;
} }
const ariaLabel = isPanelOpen
? t('Right panel, currently open')
: t('Right panel, currently closed');
const handleClose = () => setIsPanelOpen(false);
return ( return (
<Box <Box
className="--docs--right-panel" className="--docs--right-panel"
aria-label={t('Right panel')} aria-label={ariaLabel}
aria-expanded={isPanelOpen}
$width="300px" $width="300px"
$height={`calc(100dvh - ${HEADER_HEIGHT}px)`} $height={`calc(100dvh - ${HEADER_HEIGHT}px)`}
$position={isMobile ? 'absolute' : 'sticky'} $position={isMobile ? 'absolute' : 'sticky'}
@@ -49,7 +75,10 @@ export const RightPanel = () => {
`} `}
`} `}
> >
<CommentSideBar onClose={() => setIsPanelOpen(false)} /> {renderedPanel === 'tableContent' && (
<TableContentSideBar onClose={handleClose} />
)}
{renderedPanel === 'comments' && <CommentSideBar onClose={handleClose} />}
</Box> </Box>
); );
}; };
@@ -4,12 +4,16 @@ import { css } from 'styled-components';
import { Card } from '@/components'; import { Card } from '@/components';
import { CommentSideBarButton } from '@/features/docs/doc-editor/components/comments/CommentSideBar'; import { CommentSideBarButton } from '@/features/docs/doc-editor/components/comments/CommentSideBar';
import { useEditorStore } from '@/features/docs/doc-editor/stores/useEditorStore'; import { useEditorStore } from '@/features/docs/doc-editor/stores/useEditorStore';
import { useHeadingStore } from '@/features/docs/doc-editor/stores/useHeadingStore';
import { TableContentSideBarButton } from '@/features/docs/doc-table-content/components/TableContentSideBar';
export const RightPanelCollapseButton = () => { export const RightPanelCollapseButton = () => {
const { threadStore } = useEditorStore(); const { threadStore } = useEditorStore();
const [hasThreads, setHasThreads] = useState( const [hasThreads, setHasThreads] = useState(
!!threadStore?.getThreads().size, !!threadStore?.getThreads().size,
); );
const { headings } = useHeadingStore();
const hasHeadings = headings.length > 0;
useEffect(() => { useEffect(() => {
if (!threadStore) { if (!threadStore) {
@@ -21,7 +25,7 @@ export const RightPanelCollapseButton = () => {
}); });
}, [threadStore]); }, [threadStore]);
if (!hasThreads) { if (!hasThreads && !hasHeadings) {
return null; return null;
} }
@@ -37,6 +41,7 @@ export const RightPanelCollapseButton = () => {
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.05); box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.05);
`} `}
> >
{hasHeadings && <TableContentSideBarButton />}
{hasThreads && <CommentSideBarButton />} {hasThreads && <CommentSideBarButton />}
</Card> </Card>
); );
@@ -1,17 +1,24 @@
import { create } from 'zustand'; import { create } from 'zustand';
export type RightPanelView = 'tableContent' | 'comments';
export interface UseRightPanelStore { export interface UseRightPanelStore {
isPanelOpen: boolean; isPanelOpen: boolean;
activePanel: RightPanelView | null;
setActivePanel: (panel: RightPanelView | null) => void;
setIsPanelOpen: (isOpen: boolean) => void; setIsPanelOpen: (isOpen: boolean) => void;
togglePanel: () => void; togglePanel: () => void;
} }
export const useRightPanelStore = create<UseRightPanelStore>((set) => ({ export const useRightPanelStore = create<UseRightPanelStore>((set) => ({
isPanelOpen: false, isPanelOpen: false,
setIsPanelOpen: (isPanelOpen) => { activePanel: null,
set(() => ({ isPanelOpen })); setActivePanel: (activePanel) =>
}, set(() => ({ activePanel, isPanelOpen: activePanel !== null })),
togglePanel: () => { setIsPanelOpen: (isPanelOpen) =>
set((state) => ({ isPanelOpen: !state.isPanelOpen })); set((state) => ({
}, isPanelOpen,
activePanel: isPanelOpen ? state.activePanel : null,
})),
togglePanel: () => set((state) => ({ isPanelOpen: !state.isPanelOpen })),
})); }));
@@ -25,7 +25,10 @@ import { MAIN_LAYOUT_ID } from '@/layouts/conf';
import { NextPageWithLayout } from '@/types/next'; import { NextPageWithLayout } from '@/types/next';
const DocEditor = dynamic( const DocEditor = dynamic(
() => import('@/docs/doc-editor').then((mod) => ({ default: mod.DocEditor })), () =>
import('@/docs/doc-editor/components/DocEditor').then((mod) => ({
default: mod.DocEditor,
})),
{ {
ssr: false, ssr: false,
loading: () => <DocEditorSkeleton />, loading: () => <DocEditorSkeleton />,