(frontend) add comment side panel

Add comment side panel to the right panel.
We will be able to manage the threads of the
document and see the content of the comments in
a side panel.
The advantage of this approach is that we will
be able to:
- see the comments that have been removed because
  of deleted text
- see the resolved comments
- see the unresolved comments
This commit is contained in:
Anthony LC
2026-05-27 17:03:26 +02:00
parent 896444aa13
commit 2c4317947b
17 changed files with 517 additions and 92 deletions
+1
View File
@@ -9,6 +9,7 @@ and this project adheres to
### Added
- ✨(backend) support creating subdoc from file #1987
- ✨(frontend) comment side panel #2279
- ✨(buildpack) add PaaS deployment support, tested with Scalingo #2293
- 🔧(backend) allow configuring settings OIDC_OP_USER_ENDPOINT_FORMAT
@@ -6,7 +6,11 @@ import {
getOtherBrowserName,
verifyDocName,
} from './utils-common';
import { getEditor, writeInEditor } from './utils-editor';
import {
getEditor,
tryFocusEditorContent,
writeInEditor,
} from './utils-editor';
import {
addNewMember,
connectOtherUserToDoc,
@@ -430,3 +434,117 @@ test.describe('Doc Comments mobile', () => {
await expect(thread.getByText('This is a comment').first()).toBeVisible();
});
});
test.describe('Doc Comments Side Panel', () => {
test('it checks comments side bar interaction', async ({
page,
browserName,
}) => {
await createDoc(page, 'comment-doc-panel', browserName, 1);
await expect(
page.getByRole('button', { name: 'Show the comments sidebar' }),
).toBeHidden();
// Create comment thread
const editor = await writeInEditor({ page, text: 'Hello World' });
await editor.getByText('Hello').selectText();
await page.getByRole('button', { name: 'Add comment' }).click();
const thread = page.locator('.bn-thread');
await thread.getByRole('paragraph').first().fill('This is a comment');
await thread.locator('[data-test="save"]').click();
// Open comment side panel and check comment is visible in side panel
await page
.getByRole('button', { name: 'Show the comments sidebar' })
.click();
const elCommentsSidePanel = page.getByLabel('Comments side panel');
await expect(
elCommentsSidePanel.getByText('This is a comment'),
).toBeVisible();
// Click on comment in side panel and check it scrolls to the comment in the doc
await tryFocusEditorContent({ page });
for (let i = 0; i < 20; i++) {
await page.keyboard.press('Enter');
}
await writeInEditor({
page,
text: 'New paragraph',
});
await expect(editor.getByText('New paragraph')).toBeInViewport();
await elCommentsSidePanel.getByText('This is a comment').click();
await expect(editor.getByText('Hello World')).toBeVisible();
await expect(editor.getByText('New paragraph')).not.toBeInViewport();
await expect(editor.getByText('Hello World')).toHaveClass(
'bn-thread-mark-selected',
);
// Add a comment in the side panel
await elCommentsSidePanel
.locator(
'.bn-editor[contenteditable="true"] div[data-content-type="paragraph"]',
)
.first()
.fill('This is another comment');
await elCommentsSidePanel.locator('[data-test="save"]').click();
await expect(
elCommentsSidePanel
.locator(
'.bn-editor[contenteditable="false"] div[data-content-type="paragraph"]',
)
.getByText('This is another comment'),
).toBeVisible();
// Close the side panel and check the comments in the doc
await page
.getByRole('button', { name: 'Close the comments sidebar' })
.click();
await expect(elCommentsSidePanel).toBeHidden();
await editor.getByText('Hello World').click();
await expect(thread.getByText('This is another comment')).toBeVisible();
// Resolve the comment and check it disappears from the side panel and the doc
await thread.getByText('This is a comment').first().hover();
await thread.locator('[data-test="resolve"]').click();
await expect(thread).toBeHidden();
await page
.getByRole('button', { name: 'Show the comments sidebar' })
.click();
await expect(
elCommentsSidePanel.getByText('This is a comment'),
).toBeHidden();
// Goto resolved part, the comment should be visible in the side panel
await elCommentsSidePanel
.getByRole('button', { name: 'Filter comments' })
.click();
await page.getByRole('menuitem', { name: 'Resolved' }).click();
await elCommentsSidePanel.getByText('This is a comment').click();
await expect(editor.getByText('Hello World')).toHaveClass(
'bn-thread-mark-selected',
);
// Unresolve the comment and check it does not appears in the side panel resolved part
await thread.getByText('This is a comment').first().hover();
await thread.locator('[data-test="re-open"]').click();
await expect(
elCommentsSidePanel.getByText('This is a comment'),
).toBeHidden();
// It should be back in the side panel and in the doc
await page.getByRole('button', { name: 'Filter comments' }).click();
await page.getByRole('menuitem', { name: 'Open' }).click();
await expect(
elCommentsSidePanel.getByText('This is a comment'),
).toBeVisible();
await elCommentsSidePanel.getByText('This is a comment').click();
await expect(editor.getByText('Hello World')).toHaveClass(
'bn-thread-mark-selected',
);
});
});
@@ -3,11 +3,24 @@
import { Page } from '@playwright/test';
export const getEditor = async ({ page }: { page: Page }) => {
const editor = page.locator('.ProseMirror');
const editor = page.locator('.--docs--editor-container .ProseMirror');
await editor.click();
return editor;
};
export const tryFocusEditorContent = async ({ page }: { page: Page }) => {
const editor = await getEditor({ page });
if (
(await editor.locator('.bn-trailing-block.ProseMirror-widget').count()) > 0
) {
await editor.locator('.bn-trailing-block.ProseMirror-widget').click();
} else {
await editor.click();
}
return editor;
};
export const openSuggestionMenu = async ({
page,
suggestion,
@@ -15,14 +28,7 @@ export const openSuggestionMenu = async ({
page: Page;
suggestion?: string;
}) => {
const editor = await getEditor({ page });
if (
(await editor.locator('.bn-trailing-block.ProseMirror-widget').count()) > 0
) {
await editor.locator('.bn-trailing-block.ProseMirror-widget').click();
} else {
await editor.click();
}
const editor = await tryFocusEditorContent({ page });
await page.keyboard.press('Enter');
await page.keyboard.type('/');
@@ -1,6 +1,12 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 9C18.1667 9 17.4583 8.70833 16.875 8.125C16.2917 7.54167 16 6.83333 16 6C16 5.16667 16.2917 4.45833 16.875 3.875C17.4583 3.29167 18.1667 3 19 3C19.8333 3 20.5417 3.29167 21.125 3.875C21.7083 4.45833 22 5.16667 22 6C22 6.83333 21.7083 7.54167 21.125 8.125C20.5417 8.70833 19.8333 9 19 9Z" fill="#222631"/>
<path d="M14.4042 8C14.1363 7.38254 14 6.71027 14 6H3V8H14.4042Z" fill="#222631"/>
<path d="M10 16V18H14V16H10Z" fill="#222631"/>
<path d="M6 11V13H18V11H6Z" fill="#222631"/>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M19 9C18.1667 9 17.4583 8.70833 16.875 8.125C16.2917 7.54167 16 6.83333 16 6C16 5.16667 16.2917 4.45833 16.875 3.875C17.4583 3.29167 18.1667 3 19 3C19.8333 3 20.5417 3.29167 21.125 3.875C21.7083 4.45833 22 5.16667 22 6C22 6.83333 21.7083 7.54167 21.125 8.125C20.5417 8.70833 19.8333 9 19 9Z"
fill="currentColor"
/>
<path
d="M14.4042 8C14.1363 7.38254 14 6.71027 14 6H3V8H14.4042Z"
fill="currentColor"
/>
<path d="M10 16V18H14V16H10Z" fill="currentColor" />
<path d="M6 11V13H18V11H6Z" fill="currentColor" />
</svg>

Before

Width:  |  Height:  |  Size: 596 B

After

Width:  |  Height:  |  Size: 625 B

@@ -1,3 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 18V16H14V18H10ZM6 13V11H18V13H6ZM3 8V6H21V8H3Z" fill="#222631"/>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M10 18V16H14V18H10ZM6 13V11H18V13H6ZM3 8V6H21V8H3Z"
fill="currentColor"
/>
</svg>

Before

Width:  |  Height:  |  Size: 181 B

After

Width:  |  Height:  |  Size: 176 B

@@ -1,3 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.32391 19.677C4.17816 19.5379 4.08209 19.3723 4.03571 19.1801C3.98934 18.988 3.98934 18.7959 4.03571 18.6037C4.08872 18.4116 4.18478 18.2493 4.32391 18.1168L10.4258 11.995L4.32391 5.88323C4.18478 5.75072 4.09203 5.58841 4.04565 5.39627C3.99927 5.20414 3.99927 5.01201 4.04565 4.81988C4.09203 4.62774 4.18478 4.46211 4.32391 4.32298C4.46967 4.17723 4.63861 4.08116 4.83074 4.03478C5.02288 3.98841 5.21501 3.98841 5.40714 4.03478C5.59927 4.08116 5.76491 4.17391 5.90404 4.31304L12.0158 10.4248L18.1177 4.31304C18.2568 4.16729 18.4225 4.07453 18.6146 4.03478C18.8067 3.98841 18.9955 3.98841 19.1811 4.03478C19.3732 4.08116 19.5454 4.17723 19.6978 4.32298C19.837 4.46211 19.9297 4.62774 19.9761 4.81988C20.0291 5.01201 20.0291 5.20414 19.9761 5.39627C19.9297 5.58178 19.837 5.74741 19.6978 5.89317L13.596 11.995L19.6978 18.1068C19.837 18.2526 19.9297 18.4215 19.9761 18.6137C20.0225 18.7992 20.0225 18.988 19.9761 19.1801C19.9297 19.3723 19.837 19.5379 19.6978 19.677C19.5521 19.8228 19.3831 19.9188 19.191 19.9652C18.9989 20.0116 18.8067 20.0116 18.6146 19.9652C18.4225 19.9188 18.2568 19.8261 18.1177 19.687L12.0158 13.5752L5.90404 19.687C5.76491 19.8261 5.59927 19.9188 5.40714 19.9652C5.22163 20.0116 5.0295 20.0116 4.83074 19.9652C4.63861 19.9188 4.46967 19.8228 4.32391 19.677Z" fill="#222631"/>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M4.32391 19.677C4.17816 19.5379 4.08209 19.3723 4.03571 19.1801C3.98934 18.988 3.98934 18.7959 4.03571 18.6037C4.08872 18.4116 4.18478 18.2493 4.32391 18.1168L10.4258 11.995L4.32391 5.88323C4.18478 5.75072 4.09203 5.58841 4.04565 5.39627C3.99927 5.20414 3.99927 5.01201 4.04565 4.81988C4.09203 4.62774 4.18478 4.46211 4.32391 4.32298C4.46967 4.17723 4.63861 4.08116 4.83074 4.03478C5.02288 3.98841 5.21501 3.98841 5.40714 4.03478C5.59927 4.08116 5.76491 4.17391 5.90404 4.31304L12.0158 10.4248L18.1177 4.31304C18.2568 4.16729 18.4225 4.07453 18.6146 4.03478C18.8067 3.98841 18.9955 3.98841 19.1811 4.03478C19.3732 4.08116 19.5454 4.17723 19.6978 4.32298C19.837 4.46211 19.9297 4.62774 19.9761 4.81988C20.0291 5.01201 20.0291 5.20414 19.9761 5.39627C19.9297 5.58178 19.837 5.74741 19.6978 5.89317L13.596 11.995L19.6978 18.1068C19.837 18.2526 19.9297 18.4215 19.9761 18.6137C20.0225 18.7992 20.0225 18.988 19.9761 19.1801C19.9297 19.3723 19.837 19.5379 19.6978 19.677C19.5521 19.8228 19.3831 19.9188 19.191 19.9652C18.9989 20.0116 18.8067 20.0116 18.6146 19.9652C18.4225 19.9188 18.2568 19.8261 18.1177 19.687L12.0158 13.5752L5.90404 19.687C5.76491 19.8261 5.59927 19.9188 5.40714 19.9652C5.22163 20.0116 5.0295 20.0116 4.83074 19.9652C4.63861 19.9188 4.46967 19.8228 4.32391 19.677Z"
fill="currentColor"
/>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -1,7 +1,6 @@
import { Button, type ButtonProps } from '@gouvfr-lasuite/cunningham-react';
import React from 'react';
import { Icon } from '@/components';
import CloseIcon from '@/assets/icons/ui-kit/x-mark.svg';
export const ButtonCloseModal = (props: ButtonProps) => {
return (
@@ -10,14 +9,7 @@ export const ButtonCloseModal = (props: ButtonProps) => {
size="small"
color="neutral"
variant="tertiary"
icon={
<Icon
iconName="close"
className="material-icons-filled"
$size="24px!important"
$color="var(--c--contextuals--content--semantic--neutral--secondary)"
/>
}
icon={<CloseIcon width="24" height="24" aria-hidden="true" />}
{...props}
/>
);
@@ -11,9 +11,15 @@ import '@blocknote/core/fonts/inter.css';
import * as localesBN from '@blocknote/core/locales';
import { BlockNoteView } from '@blocknote/mantine';
import '@blocknote/mantine/style.css';
import { useCreateBlockNote } from '@blocknote/react';
import {
FloatingComposerController,
FloatingThreadController,
ThreadsSidebar,
useCreateBlockNote,
} from '@blocknote/react';
import { HocuspocusProvider } from '@hocuspocus/provider';
import { useEffect, useMemo, useRef } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import type { Awareness } from 'y-protocols/awareness';
import * as Y from 'yjs';
@@ -41,7 +47,11 @@ import { randomColor, sanitizeColor } from '../utils';
import BlockNoteAI from './AI';
import { BlockNoteSuggestionMenu } from './BlockNoteSuggestionMenu';
import { BlockNoteToolbar } from './BlockNoteToolBar/BlockNoteToolbar';
import { DocsCommentsStyle, useComments } from './comments/';
import {
DocsCommentsStyle,
useCommentSidebarStore,
useComments,
} from './comments/';
import { CalloutBlock, PdfBlock, UploadLoaderBlock } from './custom-blocks';
const AIMenu = BlockNoteAI?.AIMenu;
const AIMenuController = BlockNoteAI?.AIMenuController;
@@ -82,11 +92,8 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
const { setEditor } = useEditorStore();
const { themeTokens } = useCunninghamTheme();
const refEditorContainer = useRef<HTMLDivElement>(null);
const canSeeComment = doc.abilities.comment;
// Determine if comments should be visible in the UI
const showComments = canSeeComment;
useSaveDoc(doc.id, provider.document);
const { i18n, t } = useTranslation();
const langLocalesBN =
!i18n.resolvedLanguage || !(i18n.resolvedLanguage in localesBN)
@@ -118,12 +125,22 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
const cursorName = collabName || t('Anonymous');
const showCursorLabels: 'always' | 'activity' | (string & {}) = 'activity';
// Comments
const canSeeComment = doc.abilities.comment;
const showComments = canSeeComment; // Determine if comments should be visible in the UI
const { resolveUsers, threadStore } = useComments(
doc.id,
canSeeComment,
user,
);
// Comment sidebar
const {
threadsSidebarTarget,
filter: threadsSidebarFilter,
isSideBarOpen,
} = useCommentSidebarStore();
const currentUserAvatarUrl = useMemo(() => {
if (canSeeComment) {
return avatarUrlFromName(collabName, themeTokens?.font?.families?.base);
@@ -275,14 +292,26 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
formattingToolbar={false}
slashMenu={false}
theme="light"
comments={showComments}
comments={false}
aria-label={t('Document editor')}
// To not clipped the floating part in the editor area
portalElements={{ default: null }}
>
{aiBlockNoteAllowed && AIMenuController && AIMenu && (
<AIMenuController aiMenu={AIMenu} />
)}
<BlockNoteSuggestionMenu aiAllowed={aiBlockNoteAllowed} />
<BlockNoteToolbar aiAllowed={aiBlockNoteAllowed} />
{showComments && <FloatingComposerController />}
{showComments && !isSideBarOpen && <FloatingThreadController />}
{threadsSidebarTarget &&
createPortal(
<ThreadsSidebar
filter={threadsSidebarFilter}
sort="recent-activity"
/>,
threadsSidebarTarget,
)}
</BlockNoteView>
</Box>
);
@@ -45,6 +45,7 @@ export const DocEditorContainer = ({
$width="100%"
$flex="1"
className={DOCS_EDITOR_CLASS}
$margin={{ horizontal: 'auto' }}
>
<Box
$padding={{ horizontal: isDesktop ? '54px' : 'base' }}
@@ -0,0 +1,135 @@
import { Button, Tooltip } from '@gouvfr-lasuite/cunningham-react';
import { DropdownMenu } from '@gouvfr-lasuite/ui-kit';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import CommentsIcon from '@/assets/icons/ui-kit/bubble-text.svg';
import SortingResolvedSVG from '@/assets/icons/ui-kit/filter-notification.svg';
import SortingOpenSVG from '@/assets/icons/ui-kit/filter_list.svg';
import { Box, ButtonCloseModal, Text } from '@/components/';
import { useRightPanelStore } from '@/features/right-panel/components/useRightPanelStore';
import { useCommentSidebarStore } from './useCommentSidebarStore';
interface CommentSideBarProps {
onClose: () => void;
}
export const CommentSideBar = ({ onClose }: CommentSideBarProps) => {
const { t } = useTranslation();
const { setThreadsSidebarTarget, filter, setFilter } =
useCommentSidebarStore();
const portalRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
useEffect(() => {
if (portalRef.current) {
setThreadsSidebarTarget(portalRef.current);
}
return () => {
setThreadsSidebarTarget(null);
};
}, [setThreadsSidebarTarget]);
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">
<Box $direction="row" $align="center" $gap="2xs">
<Text $weight="bold">{t('Comments')}</Text>
<DropdownMenu
options={[
{
label: t('Open'),
callback: () => setFilter('open'),
isChecked: filter === 'open',
},
{
label: t('Resolved'),
callback: () => setFilter('resolved'),
isChecked: filter === 'resolved',
},
]}
isOpen={open}
shouldCloseOnInteractOutside={() => true}
onOpenChange={setOpen}
>
<Tooltip content={t('Filter comments')} placement="bottom">
<Button
aria-label={t('Filter comments')}
size="nano"
icon={
filter === 'open' ? (
<SortingOpenSVG
width={18}
height={18}
aria-hidden="true"
/>
) : (
<SortingResolvedSVG
width={18}
height={18}
aria-hidden="true"
/>
)
}
color={filter === 'open' ? 'neutral' : 'brand'}
variant={filter === 'open' ? 'tertiary' : 'secondary'}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setOpen((o) => !o);
}}
tabIndex={-1}
/>
</Tooltip>
</DropdownMenu>
</Box>
<ButtonCloseModal
aria-label={t('Close the comments sidebar')}
onClick={onClose}
/>
</Box>
</Box>
<div
ref={portalRef}
className="--docs--comments-sidebar bn-root bn-mantine"
data-mantine-color-scheme="light"
/>
</Box>
);
};
export const CommentSideBarButton = () => {
const { t } = useTranslation();
const { isPanelOpen, togglePanel } = useRightPanelStore();
const { setIsSideBarOpen } = useCommentSidebarStore();
useEffect(() => {
setIsSideBarOpen(isPanelOpen);
}, [isPanelOpen, setIsSideBarOpen]);
const ariaLabel = isPanelOpen
? t('Hide the comments sidebar')
: t('Show the comments sidebar');
return (
<Button
size="small"
onClick={togglePanel}
aria-label={ariaLabel}
aria-expanded={isPanelOpen}
color="neutral"
variant={isPanelOpen ? 'secondary' : 'tertiary'}
icon={<CommentsIcon width={24} height={24} aria-hidden="true" />}
></Button>
);
};
@@ -47,16 +47,6 @@ export const CommentToolbarButton = () => {
return !!selectedBlocks.find((block) => block.content !== undefined);
}, [selectedBlocks]);
const focusOnInputThread = () => {
// Use setTimeout to ensure the DOM has been updated with the new comment
setTimeout(() => {
const threadElement = document.querySelector<HTMLElement>(
'.bn-thread .bn-editor',
);
threadElement?.focus();
}, 400);
};
if (
!comments ||
!show ||
@@ -74,7 +64,6 @@ export const CommentToolbarButton = () => {
onClick={() => {
comments.startPendingComment();
store.setState(false);
focusOnInputThread();
}}
aria-haspopup="dialog"
data-test="comment-toolbar-button"
@@ -1,3 +1,4 @@
export * from './CommentToolbarButton';
export * from './styles';
export * from './useCommentSidebarStore';
export * from './useComments';
@@ -5,7 +5,9 @@ export const DocsCommentsStyle = createGlobalStyle<{
currentUserAvatarUrl?: string;
}>`
.--docs--main-editor.bn-root,
.--docs--main-editor.bn-root .ProseMirror {
.--docs--main-editor.bn-root .ProseMirror,
.--docs--comments-sidebar.bn-root,
.--docs--comments-sidebar.bn-root .ProseMirror {
// Comments marks in the editor
.bn-editor {
// Resets blocknote comments styles
@@ -38,6 +40,22 @@ export const DocsCommentsStyle = createGlobalStyle<{
);
}
}
.bn-thread-mark[data-orphan='true']:has(> .bn-thread-mark-selected) {
background-color: color-mix(
in srgb,
var(--c--contextuals--background--palette--blue-1--tertiary) 40%,
transparent
);
border-bottom: 2px solid
var(--c--contextuals--background--palette--blue-1--secondary);
mix-blend-mode: multiply;
transition:
background-color var(--c--globals--transitions--duration),
border-bottom-color var(--c--globals--transitions--duration);
}
`}
[data-show-selection] {
@@ -56,7 +74,7 @@ export const DocsCommentsStyle = createGlobalStyle<{
min-width: calc(min(400px, 90vw));
max-width: calc(min(400px, 90vw));
max-height: calc(min(500px, 60vh));
padding: 8px;
padding: var(--c--globals--spacings--xxs) var(--c--globals--spacings--xxxs);
box-shadow: 0px 6px 18px 0px #00001229;
margin-left: 20px;
margin-right: 20px;
@@ -81,10 +99,20 @@ export const DocsCommentsStyle = createGlobalStyle<{
display: none;
}
& .bn-thread-comments {
gap: var(--c--globals--spacings--xxxs);
}
.bn-thread-comment {
padding: 8px;
flex-wrap: nowrap;
gap: 0px;
flex-direction: column;
align-items: initial;
& > div:first-child {
flex-direction: row;
}
& .bn-editor {
padding-left: var(--c--globals--spacings--lg);
@@ -100,7 +128,7 @@ export const DocsCommentsStyle = createGlobalStyle<{
padding: var(--c--globals--spacings--0)
var(--c--globals--spacings--st);
background: none;
border: 1px solid var(--c--globals--colors--gray-300);
border: 1px solid var(--c--contextuals--border--semantic--neutral--tertiary);
border-radius: var(--c--globals--spacings--st);
height: var(--c--globals--spacings--md);
}
@@ -131,6 +159,8 @@ export const DocsCommentsStyle = createGlobalStyle<{
// Date
span.mantine-focus-auto {
font-weight: 400;
margin-left: var(--c--globals--spacings--2xs) !important;
}
.bn-comment-actions {
@@ -139,6 +169,9 @@ export const DocsCommentsStyle = createGlobalStyle<{
.mantine-Button-root {
background-color: transparent;
height: var(--c--globals--spacings--md);
width: var(--c--globals--spacings--md);
padding: var(--c--globals--spacings--0);
&:hover {
background-color: var(--c--globals--colors--gray-100);
@@ -167,19 +200,20 @@ export const DocsCommentsStyle = createGlobalStyle<{
& > button {
height: var(--c--globals--spacings--md);
padding-inline: var(--c--globals--spacings--st);
border: 1px solid
var(--c--contextuals--background--semantic--brand--primary);
background: var(
--c--contextuals--background--semantic--brand--primary
);
color: var(
--c--contextuals--content--semantic--brand--on-brand
);
&[data-test='save'] {
border: 1px solid
var(--c--contextuals--background--semantic--brand--primary);
background: var(
--c--contextuals--background--semantic--brand--primary
);
color: var(
--c--contextuals--content--semantic--brand--on-brand
);
&:hover {
background-color: var(--c--contextuals--background--semantic--brand--primary);
}
&[data-test='cancel'] {
&:last-child {
background: white;
border: 1px solid
var(--c--contextuals--border--surface--primary);
@@ -214,6 +248,11 @@ export const DocsCommentsStyle = createGlobalStyle<{
background-repeat: no-repeat;
background-size: cover;
}
& .bn-block-content:has(.ProseMirror-trailingBreak:only-child):after {
color: var(--c--contextuals--content--semantic--neutral--tertiary);
font-style: normal;
}
}
// Actions button send comment
@@ -223,6 +262,7 @@ export const DocsCommentsStyle = createGlobalStyle<{
.bn-action-toolbar.bn-comment-actions {
border: none;
background-color: transparent;
button {
font-size: 0;
@@ -261,4 +301,74 @@ export const DocsCommentsStyle = createGlobalStyle<{
}
}
}
/**
* Styles for the comments sidebar
*/
.--docs--comments-sidebar.bn-root{
flex: 1;
min-height: 0;
overflow: auto;
.bn-threads-sidebar {
gap: 0;
border-radius: 0;
min-height: 100%;
.bn-thread-expand-prompt p {
font-size: var(--c--globals--font--sizes--xs);
}
.bn-thread {
margin: 0;
max-width: 100%;
width: 100%;
min-width: 0;
overflow: visible;
padding: var(--c--globals--spacings--xxs) var(--c--globals--spacings--xxxs);
border: none;
border-radius: 0;
border-bottom: 1px solid var(--c--contextuals--border--surface--primary);
&.selected {
border: none;
background: var(--c--contextuals--background--semantic--neutral--tertiary);
max-height: none;
}
&:hover {
background: var(--c--contextuals--background--semantic--neutral--tertiary);
}
& .bn-header-text {
display: none;
}
&.bn-thread-orphaned {
& .bn-header-text {
display: block;
padding-inline: var(--c--globals--spacings--xs);
}
}
.bn-thread-comment {
padding: var(--c--globals--spacings--xs);
&:has(.bn-comment-actions) {
& > .mantine-Group-root:first-child {
background: linear-gradient(
to left,
var(--c--contextuals--background--semantic--neutral--tertiary) 90%,
rgba(255, 255, 255, 0) 100%
);
}
.bn-menu-dropdown {
box-shadow: 0px 0px 6px 0px #0000911a;
}
}
}
}
}
}
`;
@@ -0,0 +1,21 @@
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;
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 }));
},
threadsSidebarTarget: null,
}));
@@ -4,6 +4,22 @@ export const DocsEditorStyle = createGlobalStyle`
.bn-container {
height: 100%;
}
/**
* Token Blocknote
*/
.bn-root[data-color-scheme] {
--bn-colors-editor-text: var(
--c--contextuals--content--semantic--neutral--primary
);
--bn-colors-side-menu: var(
--c--contextuals--content--semantic--neutral--tertiary
);
}
.bn-root .mantine-Chip-label {
--chip-color: var(--c--contextuals--content--semantic--brand--tertiary);
--mantine-primary-color-filled-hover: var(--c--contextuals--content--semantic--brand--tertiary);
}
.bn-root {
.bn-editor {
height: 100%;
@@ -14,16 +30,6 @@ export const DocsEditorStyle = createGlobalStyle`
font-family: var(--c--components--button--font-family);
}
/**
* Token Mantine
*/
--bn-colors-editor-text: var(
--c--contextuals--content--semantic--neutral--primary
);
--bn-colors-side-menu: var(
--c--contextuals--content--semantic--neutral--tertiary
);
/**
* Ensure long placeholder text is truncated with ellipsis
*/
@@ -2,6 +2,7 @@ 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 { HEADER_HEIGHT } from '@/features/header';
import { useResponsiveStore } from '@/stores';
@@ -11,7 +12,7 @@ import { useRightPanelStore } from './useRightPanelStore';
export const RightPanel = () => {
const { t } = useTranslation();
const { currentDoc: doc } = useDocStore();
const { _, isPanelOpen } = useRightPanelStore();
const { setIsPanelOpen, isPanelOpen } = useRightPanelStore();
const { isMobile } = useResponsiveStore();
const { provider, isReady } = useProviderStore();
const isProviderReady =
@@ -47,6 +48,8 @@ export const RightPanel = () => {
width: 0;
`}
`}
></Box>
>
<CommentSideBar onClose={() => setIsPanelOpen(false)} />
</Box>
);
};
@@ -1,19 +1,29 @@
import { Button } from '@gouvfr-lasuite/cunningham-react';
import { useTranslation } from 'react-i18next';
import { useEffect, useState } from 'react';
import { css } from 'styled-components';
import CommentsIcon from '@/assets/icons/ui-kit/bubble-text.svg';
import { Card } from '@/components';
import { useRightPanelStore } from './useRightPanelStore';
import { CommentSideBarButton } from '@/features/docs/doc-editor/components/comments/CommentSideBar';
import { useEditorStore } from '@/features/docs/doc-editor/stores/useEditorStore';
export const RightPanelCollapseButton = () => {
const { t } = useTranslation();
const { isPanelOpen, togglePanel } = useRightPanelStore();
const { threadStore } = useEditorStore();
const [hasThreads, setHasThreads] = useState(
!!threadStore?.getThreads().size,
);
const ariaLabel = isPanelOpen
? t('Hide the right side panel')
: t('Show the right side panel');
useEffect(() => {
if (!threadStore) {
setHasThreads(false);
return;
}
return threadStore.subscribe((threads) => {
setHasThreads(threads.size > 0);
});
}, [threadStore]);
if (!hasThreads) {
return null;
}
return (
<Card
@@ -27,16 +37,7 @@ export const RightPanelCollapseButton = () => {
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.05);
`}
>
<Button
size="small"
onClick={togglePanel}
aria-label={ariaLabel}
aria-expanded={isPanelOpen}
color="neutral"
variant={isPanelOpen ? 'secondary' : 'tertiary'}
icon={<CommentsIcon width={24} height={24} aria-hidden="true" />}
data-testid="floating-bar-toggle-right-panel"
></Button>
{hasThreads && <CommentSideBarButton />}
</Card>
);
};