feat(core): comment with attachment uploads (#13089)

fix AF-2721, BS-3611

#### PR Dependency Tree


* **PR #13089** 👈

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 support for image attachments in comments and replies, including
upload, preview, removal, and paste-from-clipboard capabilities.
* Users can add images via file picker or clipboard paste, preview
thumbnails with navigation, and remove images before submitting.
* Commit button activates only when text content or attachments are
present.

* **UI Improvements**
* Enhanced comment editor with a scrollable preview row showing image
thumbnails, delete buttons, and upload status spinners.
* Unified comment and reply components with consistent attachment
support and streamlined action menus.

* **Bug Fixes**
* Fixed minor inconsistencies in editing and deleting comments and
replies.

* **Chores**
* Improved internal handling of comment attachments, upload workflows,
and state management.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->





#### PR Dependency Tree


* **PR #13089** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)
This commit is contained in:
Peng Xiao
2025-07-08 16:59:24 +08:00
committed by GitHub
parent 6dac94d90a
commit 839706cf65
6 changed files with 722 additions and 191 deletions
@@ -10,6 +10,7 @@ import {
resolveCommentMutation,
updateCommentMutation,
updateReplyMutation,
uploadCommentAttachmentMutation,
} from '@affine/graphql';
import { Entity } from '@toeverything/infra';
@@ -323,4 +324,26 @@ export class DocCommentStore extends Entity<{
},
});
}
/**
* Upload a comment attachment blob and obtain the remote URL.
* @param file File (image/blob) selected by user
* @returns url string returned by server
*/
uploadCommentAttachment = async (file: File): Promise<string> => {
const graphql = this.graphqlService;
if (!graphql) {
throw new Error('GraphQL service not found');
}
const res = await graphql.gql({
query: uploadCommentAttachmentMutation,
variables: {
workspaceId: this.currentWorkspaceId,
docId: this.props.docId,
attachment: file,
},
});
return res.uploadCommentAttachment;
};
}
@@ -28,6 +28,7 @@ import { type DocDisplayMetaService } from '../../doc-display-meta';
import { GlobalContextService } from '../../global-context';
import type { SnapshotHelper } from '../services/snapshot-helper';
import type {
CommentAttachment,
CommentId,
DocComment,
DocCommentChangeListResult,
@@ -40,6 +41,13 @@ import { DocCommentStore } from './doc-comment-store';
type DisposeCallback = () => void;
type EditingDraft = {
id: CommentId;
type: 'comment' | 'reply';
doc: Store;
attachments: CommentAttachment[];
};
export class DocCommentEntity extends Entity<{
docId: string;
}> {
@@ -68,11 +76,7 @@ export class DocCommentEntity extends Entity<{
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);
readonly editingDraft$ = new LiveData<EditingDraft | null>(null);
private readonly commentAdded$ = new Subject<{
id: CommentId;
@@ -102,6 +106,7 @@ export class DocCommentEntity extends Entity<{
doc,
preview,
selections,
attachments: [],
};
// Replace any existing pending comment (only one at a time)
@@ -137,6 +142,7 @@ export class DocCommentEntity extends Entity<{
id,
doc,
commentId,
attachments: [],
};
// Replace any existing pending reply (only one at a time)
this.pendingReply$.setValue(pendingReply);
@@ -158,13 +164,14 @@ export class DocCommentEntity extends Entity<{
async startEdit(
id: CommentId,
type: 'comment' | 'reply',
snapshot: DocSnapshot
snapshot: DocSnapshot,
attachments: CommentAttachment[]
): 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 });
this.editingDraft$.setValue({ id, type, doc, attachments });
}
/** Commit current editing draft (if any) */
@@ -178,9 +185,15 @@ export class DocCommentEntity extends Entity<{
}
if (draft.type === 'comment') {
await this.updateComment(draft.id, { snapshot });
await this.updateComment(draft.id, {
snapshot,
attachments: draft.attachments,
});
} else {
await this.updateReply(draft.id, { snapshot });
await this.updateReply(draft.id, {
snapshot,
attachments: draft.attachments,
});
}
this.editingDraft$.setValue(null);
@@ -202,7 +215,7 @@ export class DocCommentEntity extends Entity<{
console.warn('Pending comment not found:', id);
return;
}
const { doc, preview } = pendingComment;
const { doc, preview, attachments } = pendingComment;
const snapshot = this.snapshotHelper.getSnapshot(doc);
if (!snapshot) {
throw new Error('Failed to get snapshot');
@@ -212,6 +225,7 @@ export class DocCommentEntity extends Entity<{
snapshot,
preview,
mode: this.docMode$.value ?? 'page',
attachments,
},
});
const currentComments = this.comments$.value;
@@ -230,7 +244,7 @@ export class DocCommentEntity extends Entity<{
console.warn('Pending reply not found:', id);
return;
}
const { doc } = pendingReply;
const { doc, attachments } = pendingReply;
const snapshot = this.snapshotHelper.getSnapshot(doc);
if (!snapshot) {
throw new Error('Failed to get snapshot');
@@ -243,6 +257,7 @@ export class DocCommentEntity extends Entity<{
const reply = await this.store.createReply(pendingReply.commentId, {
content: {
snapshot,
attachments,
},
});
const currentComments = this.comments$.value;
@@ -264,19 +279,56 @@ export class DocCommentEntity extends Entity<{
this.revalidate();
}
async deleteReply(id: string): Promise<void> {
await this.store.deleteReply(id);
async deleteReply(replyId: string): Promise<void> {
await this.store.deleteReply(replyId);
const currentComments = this.comments$.value;
const updatedComments = currentComments.map(comment => {
return {
...comment,
replies: comment.replies?.filter(r => r.id !== id),
replies: comment.replies?.filter(r => r.id !== replyId),
};
});
this.comments$.setValue(updatedComments);
this.revalidate();
}
/**
* Upload an attachment file for the draft/editing comment/reply.
* @param file File to upload
* @returns
*/
uploadCommentAttachment = async (
id: string,
file: File,
pending: PendingComment | EditingDraft
): Promise<string> => {
// check if the given pending comment is the same as the current comment or reply
const isPendingComment = pending.id === this.pendingComment$.value?.id;
const isPendingReply = pending.id === this.pendingReply$.value?.id;
const isEditingDraft = pending.id === this.editingDraft$.value?.id;
if (!isPendingComment && !isPendingReply && !isEditingDraft) {
throw new Error('Pending comment/reply not found');
}
const url = await this.store.uploadCommentAttachment(file);
// todo: should be immutable
pending.attachments.push({
id,
url,
filename: file.name,
mimeType: file.type,
});
if (isPendingComment) {
this.pendingComment$.setValue(pending as PendingComment);
} else if (isPendingReply) {
this.pendingReply$.setValue(pending as PendingComment);
} else if (isEditingDraft) {
this.editingDraft$.setValue(pending as EditingDraft);
}
return url;
};
async updateComment(id: string, content: DocCommentContent): Promise<void> {
await this.store.updateComment(id, { content });
const currentComments = this.comments$.value;
@@ -297,6 +349,30 @@ export class DocCommentEntity extends Entity<{
this.revalidate();
}
updatePendingComment(id: string, patch: Partial<PendingComment>): void {
const pendingComment = this.pendingComment$.value;
if (!pendingComment || pendingComment.id !== id) {
throw new Error('Pending comment not found');
}
this.pendingComment$.setValue({ ...pendingComment, ...patch });
}
updatePendingReply(id: string, patch: Partial<PendingComment>): void {
const pendingReply = this.pendingReply$.value;
if (!pendingReply || pendingReply.id !== id) {
throw new Error('Pending reply not found');
}
this.pendingReply$.setValue({ ...pendingReply, ...patch });
}
updateEditingDraft(id: string, patch: Partial<EditingDraft>): void {
const draft = this.editingDraft$.value;
if (!draft || draft.id !== id) {
throw new Error('Editing draft not found');
}
this.editingDraft$.setValue({ ...draft, ...patch });
}
async resolveComment(id: CommentId, resolved: boolean): Promise<void> {
try {
await this.store.resolveComment(id, resolved);
@@ -8,6 +8,13 @@ import type {
export type CommentId = string;
export type CommentAttachment = {
id: string;
url?: string; // attachment may not be uploaded yet
filename?: string;
mimeType?: string;
};
export interface BaseComment {
id: CommentId;
content?: DocCommentContent;
@@ -28,6 +35,7 @@ export type PendingComment = {
preview?: string;
selections?: BaseSelection[];
commentId?: CommentId; // only for replies, points to the parent comment
attachments: CommentAttachment[];
};
export interface DocCommentReply extends BaseComment {
@@ -37,6 +45,7 @@ export interface DocCommentReply extends BaseComment {
export type DocCommentContent = {
snapshot: DocSnapshot; // blocksuite snapshot
attachments?: CommentAttachment[];
mode?: DocMode;
preview?: string; // text preview of the target
};