feat(core): reply actions (#13071)

fix AF-2717, AF-2716

#### PR Dependency Tree


* **PR #13071** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

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

## Summary by CodeRabbit

* **New Features**
* Introduced full editing, replying, and deletion capabilities for
individual replies within comments, with consistent UI and state
management.
* Added support for editing drafts for both comments and replies,
allowing users to start, commit, or dismiss edits.
* Improved editor focus behavior for a more seamless editing experience.
* Added permission checks for comment and reply creation, editing,
deletion, and resolution, controlling UI elements accordingly.
* Refactored reply rendering into dedicated components with enhanced
permission-aware interactions.

* **Bug Fixes**
* Enhanced comment normalization to handle cases where content may be
missing, preventing potential errors.

* **Style**
* Updated comment and reply UI styles for clearer editing and action
states, including new hover and visibility behaviors for reply actions.

* **Chores**
  * Removed unnecessary debugging statements from reply configuration.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->


#### PR Dependency Tree


* **PR #13071** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)
This commit is contained in:
Peng Xiao
2025-07-07 18:44:25 +08:00
committed by GitHub
parent 2b3152ee54
commit 0833d0314c
10 changed files with 558 additions and 135 deletions

View File

@@ -15,6 +15,10 @@ query getDocRolePermissions($workspaceId: String!, $docId: String!) {
Doc_Update
Doc_Users_Manage
Doc_Users_Read
Doc_Comments_Create
Doc_Comments_Delete
Doc_Comments_Read
Doc_Comments_Resolve
}
}
}

View File

@@ -1323,6 +1323,10 @@ export const getDocRolePermissionsQuery = {
Doc_Update
Doc_Users_Manage
Doc_Users_Read
Doc_Comments_Create
Doc_Comments_Delete
Doc_Comments_Read
Doc_Comments_Resolve
}
}
}

View File

@@ -4427,6 +4427,10 @@ export type GetDocRolePermissionsQuery = {
Doc_Update: boolean;
Doc_Users_Manage: boolean;
Doc_Users_Read: boolean;
Doc_Comments_Create: boolean;
Doc_Comments_Delete: boolean;
Doc_Comments_Read: boolean;
Doc_Comments_Resolve: boolean;
};
};
};

View File

@@ -1,9 +1,10 @@
import { LitDocEditor, type PageEditor } from '@affine/core/blocksuite/editors';
import { SnapshotHelper } from '@affine/core/modules/comment/services/snapshot-helper';
import { focusTextModel, type RichText } from '@blocksuite/affine/rich-text';
import { type RichText, selectTextModel } 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 type { TextSelection } from '@blocksuite/std';
import { useFramework, useService } from '@toeverything/infra';
import clsx from 'clsx';
import {
@@ -16,6 +17,7 @@ import {
useState,
} from 'react';
import { useAsyncCallback } from '../../hooks/affine-async-hooks';
import { getCommentEditorViewManager } from './specs';
import * as styles from './style.css';
@@ -46,6 +48,7 @@ interface CommentEditorProps {
export interface CommentEditorRef {
getSnapshot: () => DocSnapshot | null | undefined;
focus: () => void;
}
// todo: get rid of circular data changes
@@ -95,6 +98,32 @@ export const CommentEditor = forwardRef<CommentEditorRef, CommentEditorProps>(
const [empty, setEmpty] = useState(true);
const focusEditor = useAsyncCallback(async () => {
if (editorRef.current) {
const selectionService = editorRef.current.std.selection;
const selection = selectionService.value.at(-1) as TextSelection;
editorRef.current.std.event.active = true;
await editorRef.current.host?.updateComplete;
if (selection) {
selectTextModel(
editorRef.current.std,
selection.blockId,
selection.from.index,
selection.from.length
);
} else {
const richTexts = Array.from(
editorRef.current?.querySelectorAll('rich-text') ?? []
) as unknown as RichText[];
const lastRichText = richTexts.at(-1);
if (lastRichText) {
lastRichText.inlineEditor?.focusEnd();
}
}
}
}, [editorRef]);
useImperativeHandle(
ref,
() => ({
@@ -104,19 +133,11 @@ export const CommentEditor = forwardRef<CommentEditorRef, CommentEditorProps>(
}
return snapshotHelper.getSnapshot(doc);
},
focus: focusEditor,
}),
[doc, snapshotHelper]
[doc, focusEditor, snapshotHelper]
);
const focusEditor = useCallback(() => {
if (editorRef.current) {
const lastChild = editorRef.current.std.store.root?.lastChild();
if (lastChild) {
focusTextModel(editorRef.current.std, lastChild.id);
}
}
}, [editorRef]);
useEffect(() => {
let cancel = false;
if (autoFocus && editorRef.current && doc) {
@@ -195,7 +216,9 @@ export const CommentEditor = forwardRef<CommentEditorRef, CommentEditorProps>(
data-readonly={!!readonly}
className={clsx(styles.container, 'comment-editor-viewport')}
>
{doc && <LitDocEditor ref={editorRef} specs={specs} doc={doc} />}
{doc && (
<LitDocEditor key={doc.id} ref={editorRef} specs={specs} doc={doc} />
)}
{!readonly && (
<div className={styles.footer}>
<button

View File

@@ -104,7 +104,6 @@ export const createCommentLinkedWidgetConfig = (
const items = computed<LinkedMenuItem[]>(() => {
const members = memberSearchService.result$.signal.value;
console.log('members', members);
if (query.length === 0) {
return members

View File

@@ -7,12 +7,16 @@ import {
notify,
useConfirmModal,
} from '@affine/component';
import { useGuard } from '@affine/core/components/guard';
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 type {
DocComment,
DocCommentReply,
} 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';
@@ -29,7 +33,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
import { useAsyncCallback } from '../../hooks/affine-async-hooks';
import { CommentEditor } from '../comment-editor';
import { CommentEditor, type CommentEditorRef } from '../comment-editor';
import * as styles from './style.css';
interface CommentFilterState {
@@ -132,6 +136,11 @@ const CommentItem = ({
const session = useService(AuthService).session;
const account = useLiveData(session.account$);
const docId = entity.props.docId;
const canCreateComment = useGuard('Doc_Comments_Create', docId);
const canDeleteComment = useGuard('Doc_Comments_Delete', docId);
const canResolveComment = useGuard('Doc_Comments_Resolve', docId);
const pendingReply = useLiveData(entity.pendingReply$);
// Check if the pending reply belongs to this comment
const isReplyingToThisComment = pendingReply?.commentId === comment.id;
@@ -141,6 +150,13 @@ const CommentItem = ({
// Loading state for any async operation
const [isMutating, setIsMutating] = useState(false);
const editingDraft = useLiveData(entity.editingDraft$);
const isEditing =
editingDraft?.type === 'comment' && editingDraft.id === comment.id;
const editingDoc = isEditing ? editingDraft.doc : undefined;
const [replyEditor, setReplyEditor] = useState<CommentEditorRef | null>(null);
const handleDelete = useAsyncCallback(
async (e: React.MouseEvent) => {
e.stopPropagation();
@@ -178,22 +194,26 @@ const CommentItem = ({
[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 handleReply = useAsyncCallback(async () => {
if (comment.resolved) return;
await entity.addReply(comment.id);
entity.highlightComment(comment.id);
if (replyEditor) {
// todo: it seems we need to wait for 1000ms
// to ensure the menu closing animation is complete
setTimeout(() => {
replyEditor.focus();
}, 1000);
}
}, [entity, comment.id, comment.resolved, replyEditor]);
const handleCopyLink = useAsyncCallback(
async (e: React.MouseEvent) => {
e.stopPropagation();
// Create a URL with the comment ID
if (!comment.content) return;
const search = toDocSearchParams({
mode: comment.content.mode,
commentId: comment.id,
@@ -209,7 +229,7 @@ const CommentItem = ({
notify.success({ title: t['Copied link to clipboard']() });
},
[
comment.content.mode,
comment.content,
comment.id,
entity.props.docId,
serverService.server.baseUrl,
@@ -239,7 +259,7 @@ const CommentItem = ({
workbench.workbench.openDoc(
{
docId: entity.props.docId,
mode: comment.content.mode,
mode: comment.content?.mode,
commentId: comment.id,
refreshKey: 'comment-' + Date.now(),
},
@@ -248,7 +268,7 @@ const CommentItem = ({
}
);
entity.highlightComment(comment.id);
}, [comment.id, comment.content.mode, entity, workbench.workbench]);
}, [comment.id, comment.content?.mode, entity, workbench.workbench]);
useEffect(() => {
const subscription = entity.commentHighlighted$
@@ -293,73 +313,40 @@ const CommentItem = ({
const [menuOpen, setMenuOpen] = useState(false);
// When the comment item is rendered the first time, the replies will be collapsed by default
// The replies will be collapsed when replies length > 4, that is, the comment, first reply and the last 2 replies
// will be shown
// When new reply is added either by clicking the reply button or synced remotely, we will NOT collapse the replies
const [collapsed, setCollapsed] = useState(
(comment.replies?.length ?? 0) > 4
// Replies handled by ReplyList component; no local collapsed logic here.
const handleStartEdit = useAsyncCallback(
async (e: React.MouseEvent) => {
e.stopPropagation();
if (comment.resolved || !comment.content) return;
await entity.startEdit(comment.id, 'comment', comment.content.snapshot);
},
[entity, comment.id, comment.content, comment.resolved]
);
const renderedReplies = useMemo(() => {
// Sort replies ascending by createdAt
const sortedReplies =
comment.replies?.toSorted((a, b) => a.createdAt - b.createdAt) ?? [];
if (sortedReplies.length === 0) return null;
// If not collapsed or replies are fewer than threshold, render all
if (!collapsed || sortedReplies.length <= 4) {
return sortedReplies.map(reply => (
<ReadonlyCommentRenderer
key={reply.id}
avatarUrl={reply.user.avatarUrl}
name={reply.user.name}
time={reply.createdAt}
snapshot={reply.content.snapshot}
/>
));
const handleCommitEdit = useAsyncCallback(async () => {
setIsMutating(true);
try {
await entity.commitEditing();
} finally {
setIsMutating(false);
}
}, [entity]);
// Collapsed state: first reply + collapsed indicator + last two replies
const firstReply = sortedReplies[0];
const tailReplies = sortedReplies.slice(-2);
const handleCancelEdit = useCallback(() => {
entity.dismissDraftEditing();
}, [entity]);
return (
<>
{firstReply && (
<ReadonlyCommentRenderer
key={firstReply.id}
avatarUrl={firstReply.user.avatarUrl}
name={firstReply.user.name}
time={firstReply.createdAt}
snapshot={firstReply.content.snapshot}
/>
)}
<div
className={styles.collapsedReplies}
onClick={e => {
e.stopPropagation();
setCollapsed(false);
}}
>
<div className={styles.collapsedRepliesTitle}>
{t['com.affine.comment.reply.show-more']({
count: (sortedReplies.length - 4).toString(),
})}
</div>
</div>
{tailReplies.map(reply => (
<ReadonlyCommentRenderer
key={reply.id}
avatarUrl={reply.user.avatarUrl}
name={reply.user.name}
time={reply.createdAt}
snapshot={reply.content.snapshot}
/>
))}
</>
);
}, [collapsed, comment.replies, t]);
const isMyComment = account && account.id === comment.user.id;
const canReply = canCreateComment;
const canEdit = isMyComment && canCreateComment;
const canDelete =
(isMyComment && canCreateComment) || (!isMyComment && canDeleteComment);
// invalid comment, should not happen
if (!comment.content) {
return null;
}
return (
<div
@@ -370,13 +357,19 @@ const CommentItem = ({
className={styles.commentItem}
ref={commentRef}
>
<div className={styles.commentActions} data-menu-open={menuOpen}>
<IconButton
variant="solid"
onClick={handleResolve}
icon={<DoneIcon />}
disabled={isMutating}
/>
<div
className={styles.commentActions}
data-menu-open={menuOpen}
data-editing={isEditing}
>
{!comment.resolved && canResolveComment && (
<IconButton
variant="solid"
onClick={handleResolve}
icon={<DoneIcon />}
disabled={isMutating}
/>
)}
<Menu
rootOptions={{
open: menuOpen,
@@ -386,22 +379,34 @@ const CommentItem = ({
}}
items={
<>
<MenuItem
onClick={handleReply}
disabled={isMutating || comment.resolved}
>
{t['com.affine.comment.reply']()}
</MenuItem>
{canReply ? (
<MenuItem
onClick={handleReply}
disabled={isMutating || comment.resolved}
>
{t['com.affine.comment.reply']()}
</MenuItem>
) : null}
<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>
{canEdit ? (
<MenuItem
onClick={handleStartEdit}
disabled={isMutating || comment.resolved}
>
{t['Edit']()}
</MenuItem>
) : null}
{canDelete ? (
<MenuItem
onClick={handleDelete}
disabled={isMutating}
style={{ color: 'var(--affine-error-color)' }}
>
{t['Delete']()}
</MenuItem>
) : null}
</>
}
>
@@ -412,31 +417,57 @@ const CommentItem = ({
/>
</Menu>
</div>
<div className={styles.previewContainer}>{comment.content.preview}</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}
/>
{renderedReplies}
{isEditing && editingDoc ? (
<div className={styles.commentInputContainer}>
<div className={styles.userContainer}>
<Avatar url={account?.avatar} size={24} />
</div>
<CommentEditor
doc={editingDoc}
autoFocus
onCommit={isMutating ? undefined : handleCommitEdit}
onCancel={isMutating ? undefined : handleCancelEdit}
/>
</div>
) : (
<ReadonlyCommentRenderer
avatarUrl={comment.user.avatarUrl}
name={comment.user.name}
time={comment.createdAt}
snapshot={comment.content.snapshot}
/>
)}
{comment.replies && comment.replies.length > 0 && (
<ReplyList
replies={comment.replies}
parentComment={comment}
entity={entity}
replyEditor={replyEditor}
/>
)}
</div>
{highlighting &&
{!editingDraft &&
highlighting &&
isReplyingToThisComment &&
pendingReply &&
account &&
!comment.resolved && (
!comment.resolved &&
canCreateComment && (
<div className={styles.commentInputContainer}>
<div className={styles.userContainer}>
<Avatar url={account.avatar} size={24} />
</div>
<CommentEditor
ref={setReplyEditor}
autoFocus
doc={pendingReply.doc}
onCommit={isMutating ? undefined : handleCommitReply}
onCommit={
isMutating || !canCreateComment ? undefined : handleCommitReply
}
onCancel={isMutating ? undefined : handleCancelReply}
/>
</div>
@@ -508,7 +539,7 @@ const CommentList = ({ entity }: { entity: DocCommentEntity }) => {
if (filterState.onlyCurrentMode) {
filteredComments = filteredComments.filter(comment => {
return (
!comment.content.mode || !docMode || comment.content.mode === docMode
!comment.content?.mode || !docMode || comment.content.mode === docMode
);
});
}
@@ -568,6 +599,9 @@ const CommentInput = ({ entity }: { entity: DocCommentEntity }) => {
const pendingPreview = newPendingComment?.preview;
const [isMutating, setIsMutating] = useState(false);
const docId = entity.props.docId;
const canCreateComment = useGuard('Doc_Comments_Create', docId);
const handleCommit = useAsyncCallback(async () => {
if (!newPendingComment?.id) return;
setIsMutating(true);
@@ -587,7 +621,7 @@ const CommentInput = ({ entity }: { entity: DocCommentEntity }) => {
const session = useService(AuthService).session;
const account = useLiveData(session.account$);
if (!newPendingComment || !account) {
if (!newPendingComment || !account || !canCreateComment) {
return null;
}
@@ -603,7 +637,7 @@ const CommentInput = ({ entity }: { entity: DocCommentEntity }) => {
<CommentEditor
autoFocus
doc={newPendingComment.doc}
onCommit={isMutating ? undefined : handleCommit}
onCommit={isMutating || !canCreateComment ? undefined : handleCommit}
onCancel={isMutating ? undefined : handleCancel}
/>
</div>
@@ -611,6 +645,253 @@ const CommentInput = ({ entity }: { entity: DocCommentEntity }) => {
);
};
interface ReplyItemProps {
reply: DocCommentReply;
parentComment: DocComment;
entity: DocCommentEntity;
replyEditor: CommentEditorRef | null;
}
const ReplyItem = ({
reply,
parentComment,
entity,
replyEditor,
}: ReplyItemProps) => {
const t = useI18n();
const session = useService(AuthService).session;
const account = useLiveData(session.account$);
const { openConfirmModal } = useConfirmModal();
const [isMutating, setIsMutating] = useState(false);
const editingDraft = useLiveData(entity.editingDraft$);
const isEditingThisReply =
editingDraft?.type === 'reply' && editingDraft.id === reply.id;
const editingDoc = isEditingThisReply ? editingDraft.doc : undefined;
const docId = entity.props.docId;
const canCreateComment = useGuard('Doc_Comments_Create', docId);
const canDeleteComment = useGuard('Doc_Comments_Delete', docId);
const handleStartEdit = useAsyncCallback(async () => {
if (parentComment.resolved || !reply.content) return;
await entity.startEdit(reply.id, 'reply', reply.content.snapshot);
}, [entity, parentComment.resolved, reply.id, reply.content]);
const handleCommitEdit = useAsyncCallback(async () => {
setIsMutating(true);
try {
await entity.commitEditing();
} finally {
setIsMutating(false);
}
}, [entity]);
const handleCancelEdit = useCallback(
() => entity.dismissDraftEditing(),
[entity]
);
const handleDelete = useAsyncCallback(async () => {
openConfirmModal({
title: t['com.affine.comment.reply.delete.confirm.title'](),
description: t['com.affine.comment.reply.delete.confirm.description'](),
confirmText: t['Delete'](),
cancelText: t['Cancel'](),
confirmButtonOptions: {
variant: 'error',
},
onConfirm: async () => {
setIsMutating(true);
try {
await entity.deleteReply(reply.id);
} finally {
setIsMutating(false);
}
},
});
}, [openConfirmModal, t, entity, reply.id]);
const handleReply = useAsyncCallback(async () => {
if (parentComment.resolved) return;
await entity.addReply(parentComment.id, reply);
entity.highlightComment(parentComment.id);
if (replyEditor) {
// todo: find out why we need to wait for 100ms
setTimeout(() => {
replyEditor.focus();
}, 100);
}
}, [entity, parentComment.id, parentComment.resolved, reply, replyEditor]);
const isMyReply = account && account.id === reply.user.id;
const canReply = canCreateComment;
const canEdit = isMyReply && canCreateComment;
const canDelete =
(isMyReply && canCreateComment) || (!isMyReply && canDeleteComment);
const [isMenuOpen, setIsMenuOpen] = useState(false);
// invalid reply, should not happen
if (!reply.content) {
return null;
}
return (
<div className={styles.replyItem} data-reply-id={reply.id}>
<div
className={styles.replyActions}
data-menu-open={isMenuOpen}
data-editing={isEditingThisReply}
>
<Menu
rootOptions={{
onOpenChange: setIsMenuOpen,
open: isMenuOpen,
}}
items={
<>
{canReply ? (
<MenuItem
onClick={handleReply}
disabled={isMutating || parentComment.resolved}
>
{t['com.affine.comment.reply']()}
</MenuItem>
) : null}
{canEdit ? (
<MenuItem
onClick={handleStartEdit}
disabled={isMutating || parentComment.resolved}
>
{t['Edit']()}
</MenuItem>
) : null}
{canDelete ? (
<MenuItem
onClick={handleDelete}
style={{ color: 'var(--affine-error-color)' }}
disabled={isMutating}
>
{t['Delete']()}
</MenuItem>
) : null}
</>
}
>
<IconButton
icon={<MoreHorizontalIcon />}
variant="solid"
disabled={isMutating}
/>
</Menu>
</div>
{isEditingThisReply && editingDoc ? (
<div className={styles.commentInputContainer}>
<div className={styles.userContainer}>
<Avatar url={account?.avatar} size={24} />
</div>
<CommentEditor
doc={editingDoc}
autoFocus
onCommit={isMutating ? undefined : handleCommitEdit}
onCancel={isMutating ? undefined : handleCancelEdit}
/>
</div>
) : (
<ReadonlyCommentRenderer
avatarUrl={reply.user.avatarUrl}
name={reply.user.name}
time={reply.createdAt}
snapshot={reply.content.snapshot}
/>
)}
</div>
);
};
interface ReplyListProps {
replies: DocCommentReply[];
parentComment: DocComment;
entity: DocCommentEntity;
replyEditor: CommentEditorRef | null;
}
const ReplyList = ({
replies,
parentComment,
entity,
replyEditor,
}: ReplyListProps) => {
const t = useI18n();
// When the comment item is rendered the first time, the replies will be collapsed by default
// The replies will be collapsed when replies length > 4, that is, the comment, first reply and the last 2 replies
// will be shown
// When new reply is added either by clicking the reply button or synced remotely, we will NOT collapse the replies
const [collapsed, setCollapsed] = useState((replies.length ?? 0) > 4);
const renderedReplies = useMemo(() => {
// Sort replies ascending by createdAt
const sortedReplies =
replies.toSorted((a, b) => a.createdAt - b.createdAt) ?? [];
if (sortedReplies.length === 0) return null;
// If not collapsed or replies are fewer than threshold, render all
if (!collapsed || sortedReplies.length <= 4) {
return sortedReplies.map(reply => (
<ReplyItem
key={reply.id}
reply={reply}
parentComment={parentComment}
entity={entity}
replyEditor={replyEditor}
/>
));
}
// Collapsed state: first reply + collapsed indicator + last two replies
const firstReply = sortedReplies[0];
const tailReplies = sortedReplies.slice(-2);
return (
<>
<ReplyItem
key={firstReply.id}
reply={firstReply}
parentComment={parentComment}
entity={entity}
replyEditor={replyEditor}
/>
<div
className={styles.collapsedReplies}
onClick={e => {
e.stopPropagation();
setCollapsed(false);
}}
>
<div className={styles.collapsedRepliesTitle}>
{t['com.affine.comment.reply.show-more']({
count: (sortedReplies.length - 4).toString(),
})}
</div>
</div>
{tailReplies.map(reply => (
<ReplyItem
key={reply.id}
reply={reply}
parentComment={parentComment}
entity={entity}
replyEditor={replyEditor}
/>
))}
</>
);
}, [collapsed, replies, t, entity, parentComment, replyEditor]);
return <div className={styles.repliesContainer}>{renderedReplies}</div>;
};
const useCommentEntity = (docId: string | undefined) => {
const docCommentManager = useService(DocCommentManagerService);
const commentPanelService = useService(CommentPanelService);
@@ -652,12 +933,14 @@ export const CommentSidebar = () => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
entity?.highlightComment(null);
entity?.dismissDraftEditing();
}
};
const handleContainerClick = (event: MouseEvent) => {
const target = event.target as HTMLElement;
if (!target.closest('[data-comment-id]')) {
entity?.highlightComment(null);
entity?.dismissDraftEditing();
}
// if creating a new comment, dismiss the comment input
if (

View File

@@ -151,6 +151,9 @@ export const commentActions = style({
'&[data-menu-open="true"]': {
opacity: 1,
},
'&[data-editing="true"]': {
visibility: 'hidden',
},
},
});
@@ -159,6 +162,7 @@ export const readonlyCommentContainer = style({
flexDirection: 'column',
gap: '4px',
paddingLeft: '8px',
position: 'relative',
});
export const userContainer = style({
@@ -210,3 +214,32 @@ export const collapsedRepliesTitle = style({
fontSize: cssVar('fontXs'),
fontWeight: '500',
});
export const replyItem = style({
display: 'flex',
flexDirection: 'column',
position: 'relative',
});
export const replyActions = style({
display: 'flex',
opacity: 0,
gap: '8px',
position: 'absolute',
right: 0,
top: 0,
pointerEvents: 'none',
zIndex: 1,
selectors: {
[`${replyItem}:hover &`]: {
opacity: 1,
pointerEvents: 'auto',
},
'&[data-menu-open="true"]': {
opacity: 1,
},
'&[data-editing="true"]': {
visibility: 'hidden',
},
},
});

View File

@@ -49,7 +49,7 @@ const normalizeReply = (reply: GQLReplyType): DocCommentReply => ({
const normalizeComment = (comment: GQLCommentType): DocComment => ({
id: comment.id,
content: comment.content as DocCommentContent,
content: comment.content ? (comment.content as DocCommentContent) : undefined,
resolved: comment.resolved,
createdAt: new Date(comment.createdAt).getTime(),
updatedAt: new Date(comment.updatedAt).getTime(),
@@ -60,7 +60,9 @@ const normalizeComment = (comment: GQLCommentType): DocComment => ({
name: '',
avatarUrl: '',
},
mentions: findMentions(comment.content.snapshot.blocks),
mentions: comment.content
? findMentions(comment.content.snapshot.blocks)
: [],
replies: comment.replies?.map(normalizeReply) ?? [],
});

View File

@@ -1,5 +1,9 @@
import { type CommentChangeAction, DocMode } from '@affine/graphql';
import type { BaseSelection } from '@blocksuite/affine/store';
import type {
BaseSelection,
DocSnapshot,
Store,
} from '@blocksuite/affine/store';
import {
effect,
Entity,
@@ -63,6 +67,13 @@ export class DocCommentEntity extends Entity<{
// Only one pending reply at a time
readonly pendingReply$ = new LiveData<PendingComment | null>(null);
// Draft state for editing existing comment or reply (only one at a time)
readonly editingDraft$ = new LiveData<{
id: CommentId;
type: 'comment' | 'reply';
doc: Store;
} | null>(null);
private readonly commentAdded$ = new Subject<{
id: CommentId;
selections: BaseSelection[];
@@ -78,13 +89,15 @@ export class DocCommentEntity extends Entity<{
selections?: BaseSelection[],
preview?: string
): Promise<string> {
// todo: may need to properly bind the doc to the editor
const doc = await this.snapshotHelper.createStore();
// check if there is a pending comment, reuse it
let pendingComment = this.pendingComment$.value;
const doc =
pendingComment?.doc ?? (await this.snapshotHelper.createStore());
if (!doc) {
throw new Error('Failed to create doc');
}
const id = nanoid();
const pendingComment: PendingComment = {
pendingComment = {
id,
doc,
preview,
@@ -96,18 +109,35 @@ export class DocCommentEntity extends Entity<{
return id;
}
async addReply(commentId: string): Promise<string> {
const doc = await this.snapshotHelper.createStore();
/**
* Add a reply to a comment.
* If reply is provided, the content should @mention the user who is replying to.
*/
async addReply(commentId: string, reply?: DocCommentReply): Promise<string> {
// check if there is a pending reply, reuse it
let pendingReply = this.pendingReply$.value;
const doc = pendingReply?.doc ?? (await this.snapshotHelper.createStore());
if (!doc) {
throw new Error('Failed to create doc');
}
const mention: string | undefined = reply?.user.id;
if (mention) {
// insert mention at the end of the paragraph
const paragraph = doc.getModelsByFlavour('affine:paragraph').at(-1);
if (paragraph) {
paragraph.text?.insert(' ', paragraph.text.length, {
mention: {
member: mention,
},
});
}
}
const id = nanoid();
const pendingReply: PendingComment = {
pendingReply = {
id,
doc,
commentId,
};
// Replace any existing pending reply (only one at a time)
this.pendingReply$.setValue(pendingReply);
return id;
@@ -121,6 +151,47 @@ export class DocCommentEntity extends Entity<{
this.pendingReply$.setValue(null);
}
/**
* Start editing an existing comment or reply.
* This will dismiss any previous editing draft so that only one can exist.
*/
async startEdit(
id: CommentId,
type: 'comment' | 'reply',
snapshot: DocSnapshot
): Promise<void> {
const doc = await this.snapshotHelper.createStore(snapshot);
if (!doc) {
throw new Error('Failed to create doc for editing');
}
this.editingDraft$.setValue({ id, type, doc });
}
/** Commit current editing draft (if any) */
async commitEditing(): Promise<void> {
const draft = this.editingDraft$.value;
if (!draft) return;
const snapshot = this.snapshotHelper.getSnapshot(draft.doc);
if (!snapshot) {
throw new Error('Failed to get snapshot');
}
if (draft.type === 'comment') {
await this.updateComment(draft.id, { snapshot });
} else {
await this.updateReply(draft.id, { snapshot });
}
this.editingDraft$.setValue(null);
this.revalidate();
}
/** Dismiss current editing draft without saving */
dismissDraftEditing(): void {
this.editingDraft$.setValue(null);
}
get docMode$() {
return this.framework.get(GlobalContextService).globalContext.docMode.$;
}

View File

@@ -10,7 +10,7 @@ export type CommentId = string;
export interface BaseComment {
id: CommentId;
content: DocCommentContent;
content?: DocCommentContent;
createdAt: number;
updatedAt: number;
user: PublicUserType;