♻️(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 { createDoc, verifyDocName } from './utils-common';
import { createDoc } from './utils-common';
import { tryFocusEditorContent } from './utils-editor';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -8,40 +9,58 @@ test.beforeEach(async ({ page }) => {
test.describe('Doc Table Content', () => {
test('it checks the doc table content', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(
page,
'doc-table-content',
browserName,
1,
);
await verifyDocName(page, randomDoc);
await page.locator('.ProseMirror').click();
await createDoc(page, 'doc-table-content', browserName, 1);
await expect(
page.getByRole('button', { name: 'Show the table of contents' }),
page.getByRole('button', { name: 'Show the table of contents sidebar' }),
).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 summaryContainer.click();
await page
.getByRole('button', { name: 'Show the table of contents sidebar' })
.click();
const level1 = summaryContainer.getByText('Level 1');
const level2 = summaryContainer.getByText('Level 2');
const level3 = summaryContainer.getByText('Level 3');
const elSidePanel = page.getByLabel('Table of contents side panel');
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).toHaveCSS('padding', /4px 0px/);
await expect(level1).toHaveAttribute('aria-selected', 'true');
await expect(level1).toHaveCSS('padding', /0px 0px 0px 8px/);
await expect(editorLevel1).not.toBeInViewport();
await expect(level1).toHaveAttribute('aria-selected', 'false');
await expect(level2).toBeVisible();
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).toHaveCSS('padding-left', /24px/);
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 { Doc } from '@/docs/doc-management';
import { avatarUrlFromName, useAuth } from '@/features/auth';
import { useRightPanelStore } from '@/features/right-panel/components/useRightPanelStore';
import { useAnalytics } from '@/libs/Analytics';
import { AI_FEATURE_FLAG, DEFAULT_LOCALE } from '../conf';
@@ -135,11 +136,10 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
);
// Comment sidebar
const {
threadsSidebarTarget,
filter: threadsSidebarFilter,
isSideBarOpen,
} = useCommentSidebarStore();
const { threadsSidebarTarget, filter: threadsSidebarFilter } =
useCommentSidebarStore();
const { activePanel, isPanelOpen } = useRightPanelStore();
const isCommentSideBarOpen = isPanelOpen && activePanel === 'comments';
const currentUserAvatarUrl = useMemo(() => {
if (canSeeComment) {
@@ -303,7 +303,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
<BlockNoteSuggestionMenu aiAllowed={aiBlockNoteAllowed} />
<BlockNoteToolbar aiAllowed={aiBlockNoteAllowed} />
{showComments && <FloatingComposerController />}
{showComments && !isSideBarOpen && <FloatingThreadController />}
{showComments && !isCommentSideBarOpen && <FloatingThreadController />}
{threadsSidebarTarget &&
createPortal(
<ThreadsSidebar
@@ -11,7 +11,6 @@ import {
useIsCollaborativeEditable,
useProviderStore,
} from '@/docs/doc-management';
import { TableContent } from '@/docs/doc-table-content/';
import { useAuth } from '@/features/auth/';
import { SkeletonEditorCore, useSkeletonStore } from '@/features/skeletons';
import { useSkeletonFadeOut } from '@/features/skeletons/hooks/useFadeOut';
@@ -86,7 +85,6 @@ interface DocEditorProps {
export const DocEditor = ({ doc }: DocEditorProps) => {
useCollaboration(doc.id);
const { isDesktop } = useResponsiveStore();
const { isEditable, isLoading } = useIsCollaborativeEditable(doc);
const isDeletedDoc = !!doc.deleted_at;
const readOnly =
@@ -126,8 +124,6 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
}, [authenticated, hasTracked, isPublicDoc, trackEvent]);
return (
<>
{isDesktop && <TableContent selector={`.${DOCS_EDITOR_CLASS}`} />}
<DocEditorContainer
docHeader={<DocHeader doc={doc} />}
isDeletedDoc={isDeletedDoc}
@@ -135,7 +131,6 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
>
<DocCoreEditor doc={doc} readOnly={readOnly} />
</DocEditorContainer>
</>
);
};
@@ -110,25 +110,28 @@ export const CommentSideBar = ({ onClose }: CommentSideBarProps) => {
export const CommentSideBarButton = () => {
const { t } = useTranslation();
const { isPanelOpen, togglePanel } = useRightPanelStore();
const { setIsSideBarOpen } = useCommentSidebarStore();
const { isPanelOpen, activePanel, setActivePanel, setIsPanelOpen } =
useRightPanelStore();
useEffect(() => {
setIsSideBarOpen(isPanelOpen);
}, [isPanelOpen, setIsSideBarOpen]);
const ariaLabel = isPanelOpen
const isActive = isPanelOpen && activePanel === 'comments';
const ariaLabel = isActive
? t('Hide the comments sidebar')
: t('Show the comments sidebar');
return (
<Button
size="small"
onClick={togglePanel}
onClick={() => {
if (isActive) {
setIsPanelOpen(false);
} else {
setActivePanel('comments');
}
}}
aria-label={ariaLabel}
aria-expanded={isPanelOpen}
aria-expanded={isActive}
color="neutral"
variant={isPanelOpen ? 'secondary' : 'tertiary'}
variant={isActive ? 'secondary' : 'tertiary'}
icon={<CommentsIcon width={24} height={24} aria-hidden="true" />}
></Button>
);
@@ -2,18 +2,14 @@ import { create } from 'zustand';
interface CommentSidebarStore {
filter: 'open' | 'resolved';
isSideBarOpen: boolean;
setIsSideBarOpen: (isSideBarOpen: boolean) => void;
setThreadsSidebarTarget: (el: HTMLElement | null) => void;
setFilter: (filter: 'open' | 'resolved') => void;
setThreadsSidebarTarget: (el: HTMLElement | null) => void;
threadsSidebarTarget: HTMLElement | null;
}
export const useCommentSidebarStore = create<CommentSidebarStore>((set) => ({
filter: 'open',
isSideBarOpen: false,
setFilter: (filter) => set(() => ({ filter })),
setIsSideBarOpen: (isSideBarOpen) => set(() => ({ isSideBarOpen })),
setThreadsSidebarTarget: (threadsSidebarTarget) => {
set(() => ({ threadsSidebarTarget }));
},
@@ -5,9 +5,9 @@ import { TFunction } from 'i18next';
import { useEffect } from 'react';
import { validate as uuidValidate } from 'uuid';
import { DocsBlockNoteEditor } from '@/docs/doc-editor';
import LinkPageIcon from '@/docs/doc-editor/assets/doc-link.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 { LinkSelected } from './LinkSelected';
@@ -22,15 +22,9 @@ export const useHeadings = (editor: DocsBlockNoteEditor) => {
timeoutId = setTimeout(() => {
const blocksChanges = context.getChanges();
if (!blocksChanges.length) {
return;
}
const blockChanges = blocksChanges[0];
if (
blockChanges.type !== 'update' ||
blockChanges.block.type !== 'heading'
!blocksChanges.length ||
!blocksChanges.find((change) => change.block.type === 'heading')
) {
return;
}
@@ -20,7 +20,7 @@ import { css } from 'styled-components';
import { Box, ButtonCloseModal, Text } from '@/components';
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 { fallbackLng } from '@/i18n/config';
@@ -3,13 +3,13 @@ import { css } from 'styled-components';
import { BoxButton, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { DocsBlockNoteEditor } from '@/docs/doc-editor';
import { DocsBlockNoteEditor } from '@/docs/doc-editor/types';
import { useResponsiveStore } from '@/stores';
const leftPaddingMap: { [key: number]: string } = {
3: '1.5rem',
2: '0.9rem',
1: '0.3rem',
1: 'xs',
};
export type HeadingsHighlight = {
@@ -40,7 +40,9 @@ export const Heading = ({
return (
<BoxButton
id={`heading-${headingId}`}
className="--docs--table-content-heading"
$width="100%"
$height="var(--c--globals--spacings--lg)"
onMouseOver={() => setIsHover(true)}
onMouseLeave={() => setIsHover(false)}
onClick={() => {
@@ -62,9 +64,10 @@ export const Heading = ({
$radius="var(--c--globals--spacings--st)"
$background={
isActive
? 'var(--c--contextuals--background--semantic--neutral--secondary)'
? 'var(--c--contextuals--background--semantic--overlay--primary)'
: 'none'
}
$justify="center"
$css={css`
text-align: left;
&:focus-visible {
@@ -74,15 +77,14 @@ export const Heading = ({
border-radius: var(--c--globals--spacings--st);
}
`}
className="--docs--table-content-heading"
aria-label={text}
aria-selected={isHighlight}
aria-current={isHighlight ? 'true' : undefined}
>
<Text
$width="100%"
$padding={{ vertical: 'xtiny', left: leftPaddingMap[level] }}
$weight={isHighlight ? 'bold' : 'normal'}
$size="sm"
$padding={{ left: leftPaddingMap[level] }}
$weight={isHighlight ? '700' : '500'}
$css="overflow-wrap: break-word;"
$hasTransition
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';
@@ -1,31 +1,57 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box } from '@/components';
import { CommentSideBar } from '@/features/docs/doc-editor/components/comments/CommentSideBar';
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 { useResponsiveStore } from '@/stores';
import { useRightPanelStore } from './useRightPanelStore';
import { RightPanelView, useRightPanelStore } from './useRightPanelStore';
export const RightPanel = () => {
const { t } = useTranslation();
const { currentDoc: doc } = useDocStore();
const { setIsPanelOpen, isPanelOpen } = useRightPanelStore();
const { setIsPanelOpen, isPanelOpen, activePanel } = useRightPanelStore();
const { isMobile } = useResponsiveStore();
const { provider, isReady } = useProviderStore();
const isProviderReady =
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) {
return null;
}
const ariaLabel = isPanelOpen
? t('Right panel, currently open')
: t('Right panel, currently closed');
const handleClose = () => setIsPanelOpen(false);
return (
<Box
className="--docs--right-panel"
aria-label={t('Right panel')}
aria-label={ariaLabel}
aria-expanded={isPanelOpen}
$width="300px"
$height={`calc(100dvh - ${HEADER_HEIGHT}px)`}
$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>
);
};
@@ -4,12 +4,16 @@ import { css } from 'styled-components';
import { Card } from '@/components';
import { CommentSideBarButton } from '@/features/docs/doc-editor/components/comments/CommentSideBar';
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 = () => {
const { threadStore } = useEditorStore();
const [hasThreads, setHasThreads] = useState(
!!threadStore?.getThreads().size,
);
const { headings } = useHeadingStore();
const hasHeadings = headings.length > 0;
useEffect(() => {
if (!threadStore) {
@@ -21,7 +25,7 @@ export const RightPanelCollapseButton = () => {
});
}, [threadStore]);
if (!hasThreads) {
if (!hasThreads && !hasHeadings) {
return null;
}
@@ -37,6 +41,7 @@ export const RightPanelCollapseButton = () => {
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.05);
`}
>
{hasHeadings && <TableContentSideBarButton />}
{hasThreads && <CommentSideBarButton />}
</Card>
);
@@ -1,17 +1,24 @@
import { create } from 'zustand';
export type RightPanelView = 'tableContent' | 'comments';
export interface UseRightPanelStore {
isPanelOpen: boolean;
activePanel: RightPanelView | null;
setActivePanel: (panel: RightPanelView | null) => void;
setIsPanelOpen: (isOpen: boolean) => void;
togglePanel: () => void;
}
export const useRightPanelStore = create<UseRightPanelStore>((set) => ({
isPanelOpen: false,
setIsPanelOpen: (isPanelOpen) => {
set(() => ({ isPanelOpen }));
},
togglePanel: () => {
set((state) => ({ isPanelOpen: !state.isPanelOpen }));
},
activePanel: null,
setActivePanel: (activePanel) =>
set(() => ({ activePanel, isPanelOpen: activePanel !== null })),
setIsPanelOpen: (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';
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,
loading: () => <DocEditorSkeleton />,