fix(core): some ux enhancements on comments (#13105)

fix PD-2688

#### PR Dependency Tree


* **PR #13105** 👈

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**
* Added configurable support for enabling or disabling inline comments.
* Introduced visual indication (strikethrough) for deleted comments in
the comment sidebar.
  * Sidebar now shows when a comment is no longer present in the editor.
* Added a localized placeholder prompt ("What are your thoughts?") in
the comment editor.
* Integrated detailed event tracking for comment actions: create, edit,
delete, and resolve.

* **Improvements**
  * Inline comments are now disabled in shared mode.
* Enhanced synchronization between editor comments and provider state to
remove stale comments.
  * Inline comment features now respect the document’s read-only state.
* Improved mention handling and tracking in comment creation and
editing.
* Comment manager and entities now dynamically track comments present in
the editor.
* Comment configuration updated to enable or disable inline comments
based on settings.

* **Bug Fixes**
  * Prevented comment block creation when in read-only mode.

* **Localization**
  * Added English localization for the comment prompt.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->


#### PR Dependency Tree


* **PR #13105** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)
This commit is contained in:
Peng Xiao
2025-07-09 12:49:46 +08:00
committed by GitHub
parent ce7fffda08
commit a50270fc03
17 changed files with 194 additions and 31 deletions
@@ -179,13 +179,14 @@ export class DocCommentStore extends Entity<{
async createComment(commentInput: {
content: DocCommentContent;
mentions?: string[];
}): Promise<DocComment> {
const graphql = this.graphqlService;
if (!graphql) {
throw new Error('GraphQL service not found');
}
const mentions = findMentions(commentInput.content.snapshot.blocks);
const mentions = commentInput.mentions;
const response = await graphql.gql({
query: createCommentMutation,
@@ -265,6 +266,7 @@ export class DocCommentStore extends Entity<{
commentId: string,
replyInput: {
content: DocCommentContent;
mentions?: string[];
}
): Promise<DocCommentReply> {
const graphql = this.graphqlService;
@@ -272,8 +274,6 @@ export class DocCommentStore extends Entity<{
throw new Error('GraphQL service not found');
}
const mentions = findMentions(replyInput.content.snapshot.blocks);
const response = await graphql.gql({
query: createReplyMutation,
variables: {
@@ -282,7 +282,7 @@ export class DocCommentStore extends Entity<{
content: replyInput.content,
docMode: this.props.getDocMode(),
docTitle: this.props.getDocTitle(),
mentions: mentions,
mentions: replyInput.mentions,
},
},
});
@@ -1,9 +1,12 @@
import { type CommentChangeAction, DocMode } from '@affine/graphql';
import { track } from '@affine/track';
import { InlineCommentManager } from '@blocksuite/affine/inlines/comment';
import type {
BaseSelection,
DocSnapshot,
Store,
} from '@blocksuite/affine/store';
import type { BlockStdScope } from '@blocksuite/std';
import {
effect,
Entity,
@@ -38,6 +41,7 @@ import type {
PendingComment,
} from '../types';
import { DocCommentStore } from './doc-comment-store';
import { findMentions } from './utils';
type DisposeCallback = () => void;
@@ -50,6 +54,7 @@ type EditingDraft = {
export class DocCommentEntity extends Entity<{
docId: string;
std: BlockStdScope | null;
}> {
constructor(
private readonly snapshotHelper: SnapshotHelper,
@@ -69,6 +74,8 @@ export class DocCommentEntity extends Entity<{
loading$ = new LiveData<boolean>(false);
comments$ = new LiveData<DocComment[]>([]);
commentsInEditor$ = new LiveData<string[]>([]);
// Only one pending comment at a time (for new comments)
readonly pendingComment$ = new LiveData<PendingComment | null>(null);
@@ -195,7 +202,9 @@ export class DocCommentEntity extends Entity<{
attachments: draft.attachments,
});
}
track.$.commentPanel.$.editComment({
type: draft.type === 'comment' ? 'root' : 'node',
});
this.editingDraft$.setValue(null);
this.revalidate();
}
@@ -220,6 +229,7 @@ export class DocCommentEntity extends Entity<{
if (!snapshot) {
throw new Error('Failed to get snapshot');
}
const mentions = findMentions(snapshot.blocks);
const comment = await this.store.createComment({
content: {
snapshot,
@@ -227,6 +237,7 @@ export class DocCommentEntity extends Entity<{
mode: this.docMode$.value ?? 'page',
attachments,
},
mentions,
});
const currentComments = this.comments$.value;
this.comments$.setValue([...currentComments, comment]);
@@ -234,6 +245,19 @@ export class DocCommentEntity extends Entity<{
id: comment.id,
selections: pendingComment.selections || [],
});
// for block's preview, it will be something like <Paragraph>
// extract the block type from the preview
const blockType = preview?.match(/<([^>]+)>/)?.[1];
track.$.commentPanel.$.createComment({
type: 'root',
withAttachment: (attachments?.length ?? 0) > 0,
withMention: mentions.length > 0,
category: blockType
? blockType
: (this.docMode$.value ?? 'page') === 'page'
? 'Page'
: 'Note',
});
this.pendingComment$.setValue(null);
this.revalidate();
}
@@ -254,11 +278,13 @@ export class DocCommentEntity extends Entity<{
throw new Error('Pending reply has no commentId');
}
const mentions = findMentions(snapshot.blocks);
const reply = await this.store.createReply(pendingReply.commentId, {
content: {
snapshot,
attachments,
},
mentions,
});
const currentComments = this.comments$.value;
const updatedComments = currentComments.map(comment =>
@@ -267,6 +293,12 @@ export class DocCommentEntity extends Entity<{
: comment
);
this.comments$.setValue(updatedComments);
track.$.commentPanel.$.createComment({
type: 'node',
withAttachment: (attachments?.length ?? 0) > 0,
withMention: mentions.length > 0,
category: (this.docMode$.value ?? 'page') === 'page' ? 'Page' : 'Note',
});
this.pendingReply$.setValue(null);
this.revalidate();
}
@@ -275,6 +307,7 @@ export class DocCommentEntity extends Entity<{
await this.store.deleteComment(id);
const currentComments = this.comments$.value;
this.comments$.setValue(currentComments.filter(c => c.id !== id));
track.$.commentPanel.$.deleteComment({ type: 'root' });
this.commentDeleted$.next(id);
this.revalidate();
}
@@ -289,6 +322,7 @@ export class DocCommentEntity extends Entity<{
};
});
this.comments$.setValue(updatedComments);
track.$.commentPanel.$.deleteComment({ type: 'node' });
this.revalidate();
}
@@ -385,6 +419,9 @@ export class DocCommentEntity extends Entity<{
this.comments$.setValue(updatedComments);
this.commentResolved$.next(id);
track.$.commentPanel.$.resolveComment({
type: resolved ? 'on' : 'off',
});
this.revalidate();
} catch (error) {
console.error('Failed to resolve comment:', error);
@@ -459,6 +496,7 @@ export class DocCommentEntity extends Entity<{
// Initial load
this.revalidate();
this.revalidateCommentsInEditor();
// Set up polling every 10 seconds
const polling$ = timer(10000, 10000).pipe(
@@ -508,6 +546,7 @@ export class DocCommentEntity extends Entity<{
return allComments;
}).pipe(
tap(() => this.revalidateCommentsInEditor()),
catchError(error => {
console.error('Failed to fetch comments:', error);
return of(null);
@@ -642,7 +681,7 @@ export class DocCommentEntity extends Entity<{
const allComments: DocComment[] = [];
let cursor = '';
let firstResult: DocCommentListResult | null = null;
this.revalidateCommentsInEditor();
// Fetch all pages of comments
while (true) {
const result = await this.store.listComments({ after: cursor });
@@ -661,6 +700,7 @@ export class DocCommentEntity extends Entity<{
return allComments;
}).pipe(
tap(allComments => {
this.revalidateCommentsInEditor();
// Update state with all comments
this.comments$.setValue(allComments);
}),
@@ -675,6 +715,18 @@ export class DocCommentEntity extends Entity<{
})
);
private readonly revalidateCommentsInEditor = () => {
this.commentsInEditor$.setValue(this.getCommentsInEditor());
};
private getCommentsInEditor(): string[] {
const inlineCommentManager = this.props.std?.get(InlineCommentManager);
if (!inlineCommentManager) {
return [];
}
return inlineCommentManager.getCommentsInEditor();
}
override dispose(): void {
this.stop();
this.commentAdded$.complete();
@@ -1,3 +1,4 @@
import type { BlockStdScope } from '@blocksuite/std';
import { ObjectPool, Service } from '@toeverything/infra';
import { DocCommentEntity } from '../entities/doc-comment';
@@ -9,6 +10,8 @@ export class DocCommentManagerService extends Service {
super();
}
std: BlockStdScope | null = null;
private readonly pool = new ObjectPool<DocId, DocCommentEntity>({
onDelete: entity => {
entity.dispose();
@@ -18,9 +21,21 @@ export class DocCommentManagerService extends Service {
get(docId: DocId) {
let commentRef = this.pool.get(docId);
if (!commentRef) {
const comment = this.framework.createEntity(DocCommentEntity, {
docId,
});
const props = new Proxy(
{
docId,
std: this.std,
},
{
get: (target, prop) => {
if (prop === 'std') {
return this.std;
}
return target[prop as keyof typeof target];
},
}
);
const comment = this.framework.createEntity(DocCommentEntity, props);
commentRef = this.pool.put(docId, comment);
// todo: add LRU cache for the pool?
}