feat(core): comment panel (#12989)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Introduced a full-featured document comment system with sidebar for
viewing, filtering, replying, resolving, and managing comments and
replies.
* Added a rich text comment editor supporting editable and read-only
modes.
  * Enabled comment-based navigation and highlighting within documents.
* Integrated comment functionality into the workspace sidebar (excluding
local workspaces).
* Added internationalization support and new UI strings for comment
features.
* Added new feature flag `enable_comment` for toggling comment
functionality.
  * Enhanced editor focus to support comment-related selections.
  * Added snapshot and store helpers for comment content management.
* Implemented backend GraphQL support for comment and reply operations.
* Added services for comment entity management and comment panel
behavior.
* Extended comment configuration to support optional framework
providers.
* Added preview generation from user selections when creating comments.
  * Enabled automatic sidebar opening on new pending comments.
  * Added comment-related query parameter support for navigation.
  * Included inline comment module exports for integration.
* Improved comment provider implementation with full lifecycle
management and UI integration.
* Added comment highlight state tracking and refined selection handling
in inline comments.

* **Style**
* Added comprehensive styles for the comment editor and sidebar
components.

* **Chores**
  * Updated language completeness percentages for multiple locales.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: L-Sun <zover.v@gmail.com>
This commit is contained in:
Peng Xiao
2025-07-02 23:47:00 +08:00
committed by GitHub
parent a59448ec4b
commit a21f1c943e
30 changed files with 2572 additions and 68 deletions
@@ -0,0 +1,210 @@
import { useConfirmModal, useLitPortalFactory } from '@affine/component';
import { LitDocEditor, type PageEditor } from '@affine/core/blocksuite/editors';
import { getViewManager } from '@affine/core/blocksuite/manager/view';
import { SnapshotHelper } from '@affine/core/modules/comment/services/snapshot-helper';
import type { RichText } from '@blocksuite/affine/rich-text';
import { ViewportElementExtension } from '@blocksuite/affine/shared/services';
import { type DocSnapshot, Store } from '@blocksuite/affine/store';
import { ArrowUpBigIcon } from '@blocksuite/icons/rc';
import { useFramework, useService } from '@toeverything/infra';
import clsx from 'clsx';
import {
forwardRef,
Fragment,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import * as styles from './style.css';
const usePatchSpecs = (readonly: boolean) => {
const [reactToLit, portals] = useLitPortalFactory();
const framework = useFramework();
const confirmModal = useConfirmModal();
const patchedSpecs = useMemo(() => {
const manager = getViewManager()
.config.init()
.foundation(framework)
.theme(framework)
.editorConfig(framework)
.editorView({
framework,
reactToLit,
confirmModal,
})
.linkedDoc(framework)
.paragraph(false)
.codeBlockHtmlPreview(framework).value;
return manager
.get(readonly ? 'preview-page' : 'page')
.concat([ViewportElementExtension('.comment-editor-viewport')]);
}, [confirmModal, framework, reactToLit, readonly]);
return [
patchedSpecs,
useMemo(
() => (
<>
{portals.map(p => (
<Fragment key={p.id}>{p.portal}</Fragment>
))}
</>
),
[portals]
),
] as const;
};
interface CommentEditorProps {
readonly?: boolean;
doc?: Store;
defaultSnapshot?: DocSnapshot;
// for performance, we only update the snapshot when the editor blurs
onChange?: (snapshot: DocSnapshot) => void;
onCommit?: () => void;
onCancel?: () => void;
autoFocus?: boolean;
}
export interface CommentEditorRef {
getSnapshot: () => DocSnapshot | null | undefined;
}
// todo: get rid of circular data changes
const useSnapshotDoc = (
defaultSnapshotOrDoc: DocSnapshot | Store,
readonly?: boolean
) => {
const snapshotHelper = useService(SnapshotHelper);
const [doc, setDoc] = useState<Store | undefined>(
defaultSnapshotOrDoc instanceof Store ? defaultSnapshotOrDoc : undefined
);
useEffect(() => {
if (defaultSnapshotOrDoc instanceof Store) {
return;
}
snapshotHelper
.createStore(defaultSnapshotOrDoc)
.then(d => {
if (d) {
setDoc(d);
d.readonly = readonly ?? false;
}
})
.catch(e => {
console.error(e);
});
}, [defaultSnapshotOrDoc, readonly, snapshotHelper]);
return doc;
};
export const CommentEditor = forwardRef<CommentEditorRef, CommentEditorProps>(
function CommentEditor(
{ readonly, defaultSnapshot, doc: userDoc, onChange, onCommit, autoFocus },
ref
) {
const defaultSnapshotOrDoc = defaultSnapshot ?? userDoc;
if (!defaultSnapshotOrDoc) {
throw new Error('Either defaultSnapshot or doc must be provided');
}
const [specs, portals] = usePatchSpecs(!!readonly);
const doc = useSnapshotDoc(defaultSnapshotOrDoc, readonly);
const snapshotHelper = useService(SnapshotHelper);
const editorRef = useRef<PageEditor>(null);
useImperativeHandle(
ref,
() => ({
getSnapshot: () => {
if (!doc) {
return null;
}
return snapshotHelper.getSnapshot(doc);
},
}),
[doc, snapshotHelper]
);
useEffect(() => {
let cancel = false;
if (autoFocus && editorRef.current && doc) {
// fixme: the following does not work
// Wait for editor to be fully loaded before focusing
editorRef.current.updateComplete
.then(async () => {
if (cancel) return;
const richText = editorRef.current?.querySelector(
'rich-text'
) as unknown as RichText;
if (!richText) return;
// Finally focus the inline editor
const inlineEditor = richText.inlineEditor;
richText.focus();
richText.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
inlineEditor?.focusEnd();
})
.catch(console.error);
}
return () => {
cancel = true;
};
}, [autoFocus, doc]);
useEffect(() => {
if (doc && onChange) {
const subscription = doc.slots.blockUpdated.subscribe(() => {
const snapshot = snapshotHelper.getSnapshot(doc);
if (snapshot) {
onChange?.(snapshot);
}
});
return () => {
subscription?.unsubscribe();
};
}
return;
}, [doc, onChange, snapshotHelper]);
const handleClickEditor = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
if (editorRef.current) {
editorRef.current.focus();
}
},
[editorRef]
);
return (
<div
onClick={readonly ? undefined : handleClickEditor}
data-readonly={!!readonly}
className={clsx(styles.container, 'comment-editor-viewport')}
>
{doc && <LitDocEditor ref={editorRef} specs={specs} doc={doc} />}
{portals}
{!readonly && (
<div className={styles.footer}>
<button onClick={onCommit} className={styles.commitButton}>
<ArrowUpBigIcon />
</button>
</div>
)}
</div>
);
}
);
@@ -0,0 +1,60 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { globalStyle, style } from '@vanilla-extract/css';
export const container = style({
width: '100%',
height: '100%',
border: `1px solid transparent`,
selectors: {
'&[data-readonly="false"]': {
borderColor: cssVarV2('layer/insideBorder/border'),
borderRadius: 16,
padding: '0 8px',
},
'&[data-readonly="false"]:focus-within': {
borderColor: cssVarV2('layer/insideBorder/primaryBorder'),
boxShadow: cssVar('activeShadow'),
},
},
vars: {
'--affine-paragraph-margin': '0',
'--affine-font-base': '14px',
},
});
export const footer = style({
display: 'flex',
justifyContent: 'flex-end',
gap: 8,
paddingBottom: 8,
});
globalStyle(`${container} .affine-page-root-block-container`, {
padding: '8px 0',
minHeight: '32px',
});
globalStyle(`${container} .affine-paragraph-rich-text-wrapper`, {
margin: 0,
});
export const commitButton = style({
background: cssVarV2('button/primary'),
border: 'none',
cursor: 'pointer',
color: cssVarV2('layer/pureWhite'),
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '28px',
height: '28px',
fontSize: 20,
selectors: {
'&[data-disabled="true"]': {
background: cssVarV2('button/disable'),
cursor: 'default',
},
},
});
@@ -0,0 +1,616 @@
import {
Avatar,
IconButton,
Loading,
Menu,
MenuItem,
notify,
useConfirmModal,
} from '@affine/component';
import { ServerService } from '@affine/core/modules/cloud';
import { AuthService } from '@affine/core/modules/cloud/services/auth';
import { type DocCommentEntity } from '@affine/core/modules/comment/entities/doc-comment';
import { CommentPanelService } from '@affine/core/modules/comment/services/comment-panel-service';
import { DocCommentManagerService } from '@affine/core/modules/comment/services/doc-comment-manager';
import type { DocComment } from '@affine/core/modules/comment/types';
import { DocService } from '@affine/core/modules/doc';
import { toDocSearchParams } from '@affine/core/modules/navigation';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { copyTextToClipboard } from '@affine/core/utils/clipboard';
import { i18nTime, useI18n } from '@affine/i18n';
import type { DocSnapshot } from '@blocksuite/affine/store';
import { DoneIcon, FilterIcon, MoreHorizontalIcon } from '@blocksuite/icons/rc';
import {
useLiveData,
useService,
useServiceOptional,
} from '@toeverything/infra';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useAsyncCallback } from '../../hooks/affine-async-hooks';
import { CommentEditor } from '../comment-editor';
import * as styles from './style.css';
interface CommentFilterState {
showResolvedComments: boolean;
onlyMyReplies: boolean;
onlyCurrentMode: boolean;
}
const SortFilterButton = ({
filterState,
onFilterChange,
}: {
filterState: CommentFilterState;
onFilterChange: (key: keyof CommentFilterState, value: boolean) => void;
}) => {
const t = useI18n();
return (
<Menu
rootOptions={{ modal: false }}
items={
<>
<MenuItem
checked={filterState.showResolvedComments}
onSelect={() =>
onFilterChange(
'showResolvedComments',
!filterState.showResolvedComments
)
}
>
{t['com.affine.comment.filter.show-resolved']()}
</MenuItem>
<MenuItem
checked={filterState.onlyMyReplies}
onSelect={() =>
onFilterChange('onlyMyReplies', !filterState.onlyMyReplies)
}
>
{t['com.affine.comment.filter.only-my-replies']()}
</MenuItem>
<MenuItem
checked={filterState.onlyCurrentMode}
onSelect={() =>
onFilterChange('onlyCurrentMode', !filterState.onlyCurrentMode)
}
>
{t['com.affine.comment.filter.only-current-mode']()}
</MenuItem>
</>
}
>
<IconButton icon={<FilterIcon />} />
</Menu>
);
};
const ReadonlyCommentRenderer = ({
avatarUrl,
name,
time,
snapshot,
}: {
avatarUrl: string | null;
name: string;
time: number;
snapshot: DocSnapshot;
}) => {
return (
<div data-time={time} className={styles.readonlyCommentContainer}>
<div className={styles.userContainer}>
<Avatar url={avatarUrl} size={24} />
<div className={styles.userName}>{name}</div>
<div className={styles.time}>
{i18nTime(time, {
absolute: { accuracy: 'minute' },
})}
</div>
</div>
<div style={{ marginLeft: '34px' }}>
<CommentEditor readonly defaultSnapshot={snapshot} />
</div>
</div>
);
};
const CommentItem = ({
comment,
entity,
}: {
comment: DocComment;
entity: DocCommentEntity;
}) => {
const workbench = useService(WorkbenchService);
const serverService = useService(ServerService);
const highlighting = useLiveData(entity.commentHighlighted$) === comment.id;
const t = useI18n();
const { openConfirmModal } = useConfirmModal();
const session = useService(AuthService).session;
const account = useLiveData(session.account$);
const pendingReply = useLiveData(entity.pendingReply$);
// Check if the pending reply belongs to this comment
const isReplyingToThisComment = pendingReply?.commentId === comment.id;
const commentRef = useRef<HTMLDivElement>(null);
// Loading state for any async operation
const [isMutating, setIsMutating] = useState(false);
const handleDelete = useAsyncCallback(
async (e: React.MouseEvent) => {
e.stopPropagation();
openConfirmModal({
title: t['com.affine.comment.delete.confirm.title'](),
description: t['com.affine.comment.delete.confirm.description'](),
confirmText: t['Delete'](),
cancelText: t['Cancel'](),
confirmButtonOptions: {
variant: 'error',
},
onConfirm: async () => {
setIsMutating(true);
try {
await entity.deleteComment(comment.id);
} finally {
setIsMutating(false);
}
},
});
},
[entity, comment.id, openConfirmModal, t]
);
const handleResolve = useAsyncCallback(
async (e: React.MouseEvent) => {
e.stopPropagation();
setIsMutating(true);
try {
await entity.resolveComment(comment.id, !comment.resolved);
} finally {
setIsMutating(false);
}
},
[entity, comment.id, comment.resolved]
);
const handleReply = useAsyncCallback(
async (e: React.MouseEvent) => {
e.stopPropagation();
if (!comment.resolved) {
await entity.addReply(comment.id);
entity.highlightComment(comment.id);
}
},
[entity, comment.id, comment.resolved]
);
const handleCopyLink = useAsyncCallback(
async (e: React.MouseEvent) => {
e.stopPropagation();
// Create a URL with the comment ID
const search = toDocSearchParams({
mode: comment.content.mode,
commentId: comment.id,
});
const url = new URL(
workbench.workbench.basename$.value + '/' + entity.props.docId,
serverService.server.baseUrl
);
if (search?.size) url.search = search.toString();
await copyTextToClipboard(url.toString());
notify.success({ title: t['Copied link to clipboard']() });
},
[
comment.content.mode,
comment.id,
entity.props.docId,
serverService.server.baseUrl,
t,
workbench.workbench.basename$.value,
]
);
const handleCommitReply = useAsyncCallback(async () => {
if (!pendingReply?.id) return;
setIsMutating(true);
try {
await entity.commitReply(pendingReply.id);
} finally {
setIsMutating(false);
}
}, [entity, pendingReply]);
const handleCancelReply = useCallback(() => {
if (!pendingReply?.id) return;
entity.dismissDraftReply();
}, [entity, pendingReply]);
const handleClickPreview = useCallback(() => {
workbench.workbench.openDoc(
{
docId: entity.props.docId,
mode: comment.content.mode,
commentId: comment.id,
refreshKey: 'comment-' + Date.now(),
},
{
show: true,
}
);
entity.highlightComment(comment.id);
}, [comment.id, comment.content.mode, entity, workbench.workbench]);
useEffect(() => {
const subscription = entity.commentHighlighted$
.distinctUntilChanged()
.subscribe(id => {
if (id === comment.id && commentRef.current) {
commentRef.current.scrollIntoView({ behavior: 'smooth' });
// Auto-start reply when comment becomes highlighted, but only if not resolved
if (!isReplyingToThisComment && !comment.resolved) {
entity.addReply(comment.id).catch(() => {
// Handle error if adding reply fails
console.error('Failed to add reply for comment:', comment.id);
});
}
} else if (
id !== comment.id &&
isReplyingToThisComment &&
pendingReply
) {
// Cancel reply when comment is no longer highlighted
entity.dismissDraftReply();
}
});
return () => {
subscription.unsubscribe();
};
}, [
comment.id,
comment.resolved,
entity.commentHighlighted$,
isReplyingToThisComment,
pendingReply,
entity,
]);
// Clean up pending reply if comment becomes resolved
useEffect(() => {
if (comment.resolved && isReplyingToThisComment && pendingReply) {
entity.dismissDraftReply();
}
}, [comment.resolved, isReplyingToThisComment, pendingReply, entity]);
const [menuOpen, setMenuOpen] = useState(false);
return (
<div
onClick={handleClickPreview}
data-comment-id={comment.id}
data-resolved={comment.resolved}
data-highlighting={highlighting || menuOpen}
className={styles.commentItem}
ref={commentRef}
>
<div className={styles.commentActions} data-menu-open={menuOpen}>
<IconButton
variant="solid"
onClick={handleResolve}
icon={<DoneIcon />}
disabled={isMutating}
/>
<Menu
rootOptions={{
open: menuOpen,
onOpenChange: v => {
setMenuOpen(v);
},
}}
items={
<>
<MenuItem
onClick={handleReply}
disabled={isMutating || comment.resolved}
>
{t['com.affine.comment.reply']()}
</MenuItem>
<MenuItem onClick={handleCopyLink} disabled={isMutating}>
{t['com.affine.comment.copy-link']()}
</MenuItem>
<MenuItem
onClick={handleDelete}
disabled={isMutating}
style={{ color: 'var(--affine-error-color)' }}
>
{t['Delete']()}
</MenuItem>
</>
}
>
<IconButton
variant="solid"
icon={<MoreHorizontalIcon />}
disabled={isMutating}
/>
</Menu>
</div>
<div className={styles.previewContainer}>{comment.content.preview}</div>
<div className={styles.repliesContainer}>
<ReadonlyCommentRenderer
avatarUrl={comment.user.avatarUrl}
name={comment.user.name}
time={comment.createdAt}
snapshot={comment.content.snapshot}
/>
{/* unlike comment, replies are sorted by createdAt in ascending order */}
{comment.replies
?.toSorted((a, b) => a.createdAt - b.createdAt)
.map(reply => (
<ReadonlyCommentRenderer
key={reply.id}
avatarUrl={reply.user.avatarUrl}
name={reply.user.name}
time={reply.createdAt}
snapshot={reply.content.snapshot}
/>
))}
</div>
{highlighting &&
isReplyingToThisComment &&
pendingReply &&
account &&
!comment.resolved && (
<div className={styles.commentInputContainer}>
<div className={styles.userContainer}>
<Avatar url={account.avatar} size={24} />
</div>
<CommentEditor
autoFocus
doc={pendingReply.doc}
onCommit={isMutating ? undefined : handleCommitReply}
onCancel={isMutating ? undefined : handleCancelReply}
/>
</div>
)}
</div>
);
};
const CommentList = ({ entity }: { entity: DocCommentEntity }) => {
const comments = useLiveData(entity.comments$);
const session = useService(AuthService).session;
const account = useLiveData(session.account$);
const t = useI18n();
const docMode = useLiveData(entity.docMode$);
// Filter state management
const [filterState, setFilterState] = useState<CommentFilterState>({
showResolvedComments: false,
onlyMyReplies: false,
onlyCurrentMode: true,
});
const onFilterChange = useCallback(
(key: keyof CommentFilterState, value: boolean) => {
setFilterState(prev => ({ ...prev, [key]: value }));
},
[]
);
// Filter and sort comments based on filter state
const filteredAndSortedComments = useMemo(() => {
let filteredComments = comments;
// Filter by resolved status
if (!filterState.showResolvedComments) {
filteredComments = filteredComments.filter(comment => !comment.resolved);
}
// Filter by only my replies and mentions
if (filterState.onlyMyReplies) {
filteredComments = filteredComments.filter(comment => {
return (
comment.user.id === account?.id ||
comment.replies?.some(reply => reply.user.id === account?.id)
);
});
}
// Filter by only current mode
if (filterState.onlyCurrentMode) {
filteredComments = filteredComments.filter(comment => {
return (
!comment.content.mode || !docMode || comment.content.mode === docMode
);
});
}
return filteredComments.toSorted((a, b) => b.createdAt - a.createdAt);
}, [
comments,
filterState.showResolvedComments,
filterState.onlyMyReplies,
filterState.onlyCurrentMode,
account?.id,
docMode,
]);
const newPendingComment = useLiveData(entity.pendingComment$);
const loading = useLiveData(entity.loading$);
return (
<>
<div className={styles.header}>
<div className={styles.headerTitle}>
{t['com.affine.comment.comments']()}
</div>
{comments.length > 0 && (
<SortFilterButton
filterState={filterState}
onFilterChange={onFilterChange}
/>
)}
</div>
<CommentInput entity={entity} />
{filteredAndSortedComments.length === 0 &&
!newPendingComment &&
!loading && (
<div className={styles.empty}>
{t['com.affine.comment.no-comments']()}
</div>
)}
{loading &&
filteredAndSortedComments.length === 0 &&
!newPendingComment && (
<div className={styles.loading}>
<Loading size={32} />
</div>
)}
<div className={styles.commentList}>
{filteredAndSortedComments.map(comment => (
<CommentItem key={comment.id} comment={comment} entity={entity} />
))}
</div>
</>
);
};
// handling pending comment
const CommentInput = ({ entity }: { entity: DocCommentEntity }) => {
const newPendingComment = useLiveData(entity.pendingComment$);
const pendingPreview = newPendingComment?.preview;
const [isMutating, setIsMutating] = useState(false);
const handleCommit = useAsyncCallback(async () => {
if (!newPendingComment?.id) return;
setIsMutating(true);
try {
await entity.commitComment(newPendingComment.id);
} finally {
setIsMutating(false);
}
}, [entity, newPendingComment]);
const handleCancel = useCallback(() => {
if (!newPendingComment?.id) return;
entity.dismissDraftComment();
}, [entity, newPendingComment]);
const session = useService(AuthService).session;
const account = useLiveData(session.account$);
if (!newPendingComment || !account) {
return null;
}
return (
<div className={styles.pendingComment} data-pending-comment>
{pendingPreview && (
<div className={styles.previewContainer}>{pendingPreview}</div>
)}
<div className={styles.commentInputContainer}>
<div className={styles.userContainer}>
<Avatar url={account.avatar} size={24} />
</div>
<CommentEditor
autoFocus
doc={newPendingComment.doc}
onCommit={isMutating ? undefined : handleCommit}
onCancel={isMutating ? undefined : handleCancel}
/>
</div>
</div>
);
};
const useCommentEntity = (docId: string | undefined) => {
const docCommentManager = useService(DocCommentManagerService);
const commentPanelService = useService(CommentPanelService);
const [entity, setEntity] = useState<DocCommentEntity | null>(null);
useEffect(() => {
if (!docId) {
return;
}
const entityRef = docCommentManager.get(docId);
setEntity(entityRef.obj);
entityRef.obj.start();
entityRef.obj.revalidate();
// Set up pending comment watching to auto-open sidebar
const unwatchPending = commentPanelService.watchForPendingComments(
entityRef.obj
);
return () => {
unwatchPending();
entityRef.release();
};
}, [docCommentManager, commentPanelService, docId]);
return entity;
};
export const CommentSidebar = () => {
const doc = useServiceOptional(DocService)?.doc;
const entity = useCommentEntity(doc?.id);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const container = containerRef.current;
// dismiss the highlight when ESC is pressed
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
entity?.highlightComment(null);
}
};
const handleContainerClick = (event: MouseEvent) => {
const target = event.target as HTMLElement;
if (!target.closest('[data-comment-id]')) {
entity?.highlightComment(null);
}
// if creating a new comment, dismiss the comment input
if (
entity?.pendingComment$.value &&
!target.closest('[data-pending-comment]')
) {
entity.dismissDraftComment();
}
};
document.addEventListener('keydown', handleKeyDown);
container?.addEventListener('click', handleContainerClick);
return () => {
document.removeEventListener('keydown', handleKeyDown);
container?.removeEventListener('click', handleContainerClick);
entity?.highlightComment(null);
};
}, [entity]);
if (!entity) {
return null;
}
return (
<div className={styles.container} ref={containerRef}>
<CommentList entity={entity} />
</div>
);
};
@@ -0,0 +1,187 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const container = style({
display: 'flex',
flexDirection: 'column',
alignItems: 'stretch',
paddingTop: '8px',
paddingBottom: '64px',
position: 'relative',
minHeight: '100%',
});
export const header = style({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '0 16px',
gap: '8px',
height: '32px',
});
export const headerTitle = style({
fontSize: cssVar('fontSm'),
fontWeight: '500',
color: cssVarV2('text/secondary'),
});
export const commentList = style({
display: 'flex',
flexDirection: 'column',
gap: '8px',
});
export const empty = style({
height: '100%',
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: cssVarV2('text/secondary'),
});
export const commentItem = style({
padding: '12px',
position: 'relative',
display: 'flex',
flexDirection: 'column',
gap: '8px',
':hover': {
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
},
selectors: {
'&[data-highlighting="true"]:before': {
content: '',
display: 'block',
width: '2px',
height: '100%',
backgroundColor: cssVarV2('layer/insideBorder/primaryBorder'),
position: 'absolute',
top: '0',
left: '0',
},
'&[data-highlighting="true"]': {
backgroundColor: cssVarV2('block/comment/hanelActive'),
},
'&[data-resolved="true"]': {
opacity: 0.5,
},
},
});
export const loading = style({
height: '100%',
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
});
export const repliesContainer = style({
display: 'flex',
flexDirection: 'column',
gap: '8px',
});
export const pendingComment = style({
padding: '12px',
position: 'relative',
display: 'flex',
flexDirection: 'column',
gap: '8px',
background: cssVarV2('block/comment/hanelActive'),
selectors: {
'&:before': {
content: '',
display: 'block',
width: '2px',
height: '100%',
backgroundColor: cssVarV2('layer/insideBorder/primaryBorder'),
position: 'absolute',
left: '0',
top: '0',
bottom: '0',
},
},
});
export const previewContainer = style({
fontSize: cssVar('fontSm'),
color: cssVarV2('text/secondary'),
paddingLeft: '10px',
position: 'relative',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
lineHeight: '22px',
selectors: {
'&:before': {
content: '',
display: 'block',
width: '2px',
height: '100%',
position: 'absolute',
left: '0',
top: '0',
backgroundColor: cssVarV2('layer/insideBorder/primaryBorder'),
},
},
});
export const commentActions = style({
display: 'flex',
opacity: 0,
gap: '8px',
marginTop: '8px',
position: 'absolute',
right: 12,
top: 4,
zIndex: 1,
pointerEvents: 'none',
selectors: {
[`${commentItem}:hover &`]: {
opacity: 1,
pointerEvents: 'auto',
},
'&[data-menu-open="true"]': {
opacity: 1,
},
},
});
export const readonlyCommentContainer = style({
display: 'flex',
flexDirection: 'column',
gap: '4px',
paddingLeft: '8px',
});
export const userContainer = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start',
marginTop: '5px',
gap: 10,
});
export const commentInputContainer = style({
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'flex-start',
gap: '4px',
paddingLeft: '8px',
});
export const userName = style({
fontSize: cssVar('fontSm'),
color: cssVarV2('text/primary'),
fontWeight: '500',
});
export const time = style({
fontSize: cssVar('fontSm'),
color: cssVarV2('text/secondary'),
fontWeight: '500',
});