mirror of
https://github.com/suitenumerique/docs.git
synced 2026-08-17 21:25:43 +02:00
♻️(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:
@@ -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 |
+6
-6
@@ -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,16 +124,13 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
|
||||
}, [authenticated, hasTracked, isPublicDoc, trackEvent]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isDesktop && <TableContent selector={`.${DOCS_EDITOR_CLASS}`} />}
|
||||
<DocEditorContainer
|
||||
docHeader={<DocHeader doc={doc} />}
|
||||
isDeletedDoc={isDeletedDoc}
|
||||
readOnly={readOnly}
|
||||
>
|
||||
<DocCoreEditor doc={doc} readOnly={readOnly} />
|
||||
</DocEditorContainer>
|
||||
</>
|
||||
<DocEditorContainer
|
||||
docHeader={<DocHeader doc={doc} />}
|
||||
isDeletedDoc={isDeletedDoc}
|
||||
readOnly={readOnly}
|
||||
>
|
||||
<DocCoreEditor doc={doc} readOnly={readOnly} />
|
||||
</DocEditorContainer>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+13
-10
@@ -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>
|
||||
);
|
||||
|
||||
+1
-5
@@ -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 }));
|
||||
},
|
||||
|
||||
+1
-1
@@ -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';
|
||||
|
||||
|
||||
+9
-7
@@ -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}
|
||||
|
||||
-284
@@ -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>
|
||||
);
|
||||
};
|
||||
+166
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+6
-1
@@ -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>
|
||||
);
|
||||
|
||||
+13
-6
@@ -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 />,
|
||||
|
||||
Reference in New Issue
Block a user