mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-19 19:16:29 +08:00
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:
@@ -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
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user