diff --git a/packages/frontend/core/src/components/comment/comment-editor/index.tsx b/packages/frontend/core/src/components/comment/comment-editor/index.tsx index f8409ee87..6ad7a2b70 100644 --- a/packages/frontend/core/src/components/comment/comment-editor/index.tsx +++ b/packages/frontend/core/src/components/comment/comment-editor/index.tsx @@ -1,9 +1,18 @@ +import { IconButton, Loading } from '@affine/component'; import { LitDocEditor, type PageEditor } from '@affine/core/blocksuite/editors'; import { SnapshotHelper } from '@affine/core/modules/comment/services/snapshot-helper'; +import type { CommentAttachment } from '@affine/core/modules/comment/types'; +import { PeekViewService } from '@affine/core/modules/peek-view'; +import { DebugLogger } from '@affine/debug'; 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 { openFilesWith } from '@blocksuite/affine/shared/utils'; +import { type DocSnapshot, nanoid, Store } from '@blocksuite/affine/store'; +import { + ArrowUpBigIcon, + AttachmentIcon, + CloseIcon, +} from '@blocksuite/icons/rc'; import type { TextSelection } from '@blocksuite/std'; import { useFramework, useService } from '@toeverything/infra'; import clsx from 'clsx'; @@ -21,6 +30,9 @@ import { useAsyncCallback } from '../../hooks/affine-async-hooks'; import { getCommentEditorViewManager } from './specs'; import * as styles from './style.css'; +const MAX_IMAGE_COUNT = 10; +const logger = new DebugLogger('CommentEditor'); + const usePatchSpecs = (readonly: boolean) => { const framework = useFramework(); // const confirmModal = useConfirmModal(); @@ -35,6 +47,12 @@ const usePatchSpecs = (readonly: boolean) => { return patchedSpecs; }; +interface EditorAttachment extends CommentAttachment { + status?: 'uploading' | 'success' | 'error'; + file?: File; + localUrl?: string; // for previewing +} + interface CommentEditorProps { readonly?: boolean; doc?: Store; @@ -43,7 +61,16 @@ interface CommentEditorProps { onChange?: (snapshot: DocSnapshot) => void; onCommit?: () => void; onCancel?: () => void; + + /** + * upload comment attachment to the server + * @param file + * @returns remote url of the attachment + */ + uploadCommentAttachment?: (id: string, file: File) => Promise; autoFocus?: boolean; + attachments?: EditorAttachment[]; + onAttachmentsChange?: (atts: EditorAttachment[]) => void; } export interface CommentEditorRef { @@ -84,7 +111,17 @@ const useSnapshotDoc = ( export const CommentEditor = forwardRef( function CommentEditor( - { readonly, defaultSnapshot, doc: userDoc, onChange, onCommit, autoFocus }, + { + readonly, + defaultSnapshot, + doc: userDoc, + onChange, + onCommit, + uploadCommentAttachment, + autoFocus, + attachments, + onAttachmentsChange, + }, ref ) { const defaultSnapshotOrDoc = defaultSnapshot ?? userDoc; @@ -94,10 +131,179 @@ export const CommentEditor = forwardRef( const specs = usePatchSpecs(!!readonly); const doc = useSnapshotDoc(defaultSnapshotOrDoc, readonly); const snapshotHelper = useService(SnapshotHelper); + const peekViewService = useService(PeekViewService); const editorRef = useRef(null); - const [empty, setEmpty] = useState(true); + const setAttachments = useCallback( + (updater: (prev: EditorAttachment[]) => EditorAttachment[]) => { + const next = updater(attachments ?? []); + onAttachmentsChange?.(next); + }, + [attachments, onAttachmentsChange] + ); + + const isImageUploadDisabled = (attachments?.length ?? 0) >= MAX_IMAGE_COUNT; + + const addImages = useAsyncCallback( + async (files: File[]) => { + if (!uploadCommentAttachment) return; + const valid = files.filter(f => f.type.startsWith('image/')); + if (!valid.length) return; + logger.info('addImages', { files: valid }); + + const pendingAttachments: EditorAttachment[] = valid.map(f => ({ + id: nanoid(), + file: f, + localUrl: URL.createObjectURL(f), + status: 'uploading', + })); + + setAttachments(prev => [...prev, ...pendingAttachments]); + + for (const pending of pendingAttachments) { + if (!pending.file) continue; // should not happen + try { + const remoteUrl = await uploadCommentAttachment( + pending.id, + pending.file + ); + logger.info('uploadCommentAttachment success', { + remoteUrl, + }); + pending.localUrl && URL.revokeObjectURL(pending.localUrl); + setAttachments(prev => { + const index = prev.findIndex(att => att.id === pending.id); + if (index === -1) return prev; + // create a shallow copy to trigger re-render + const next = [...prev]; + next[index] = { + ...next[index], + status: 'success', + url: remoteUrl, + }; + return next; + }); + } catch (e) { + logger.error('uploadCommentAttachment failed', { error: e }); + pending.localUrl && URL.revokeObjectURL(pending.localUrl); + setAttachments(prev => { + const index = prev.findIndex(att => att.id === pending.id); + if (index === -1) return prev; + const next = [...prev]; + next[index] = { ...next[index], status: 'error' }; + return next; + }); + } + } + }, + [setAttachments, uploadCommentAttachment] + ); + + const handlePasteImage = useCallback( + (event: React.ClipboardEvent) => { + const items = event.clipboardData?.items; + if (!items) return; + const files: File[] = []; + for (const index in items) { + const item = items[index as any]; + if (item.kind === 'file' && item.type.indexOf('image') >= 0) { + const blob = item.getAsFile(); + if (blob) files.push(blob); + } + } + if (files.length) { + event.preventDefault(); + addImages(files); + } + }, + [addImages] + ); + + const uploadImageFiles = useAsyncCallback(async () => { + if (isImageUploadDisabled) return; + const files = await openFilesWith('Images'); + if (files) { + addImages(files); + } + }, [isImageUploadDisabled, addImages]); + + const handleImageRemove = useCallback( + (id: string) => { + setAttachments(prev => { + const att = prev.find(att => att.id === id); + if (att?.localUrl) URL.revokeObjectURL(att.localUrl); + return prev.filter(att => att.id !== id); + }); + }, + [setAttachments] + ); + + const handleImagePreview = useCallback( + (index: number) => { + if (!attachments) return; + + const imageAttachments = attachments.filter( + att => att.url || att.localUrl + ); + + if (index >= imageAttachments.length) return; + + const getImageData = (currentIndex: number) => { + const attachment = imageAttachments[currentIndex]; + if (!attachment) return undefined; + + return { + index: currentIndex, + url: attachment.url || attachment.localUrl || '', + caption: attachment.file?.name || `Image ${currentIndex + 1}`, + previous: + currentIndex > 0 + ? () => getImageData(currentIndex - 1) + : undefined, + next: + currentIndex < imageAttachments.length - 1 + ? () => getImageData(currentIndex + 1) + : undefined, + }; + }; + + const imageData = getImageData(index); + if (!imageData) return; + + peekViewService.peekView + .open({ + type: 'image-list', + data: { + image: imageData, + total: imageAttachments.length, + }, + }) + .catch(error => { + console.error('Failed to open image preview', error); + }); + }, + [attachments, peekViewService] + ); + + const handleImageClick = useCallback( + (e: React.MouseEvent, index: number) => { + e.stopPropagation(); + handleImagePreview(index); + }, + [handleImagePreview] + ); + + // upload attachments and call original onCommit + const handleCommit = useAsyncCallback(async () => { + if (readonly) return; + onCommit?.(); + setAttachments(prev => { + prev.forEach(att => att.localUrl && URL.revokeObjectURL(att.localUrl)); + return []; + }); + }, [readonly, onCommit, setAttachments]); + const focusEditor = useAsyncCallback(async () => { if (editorRef.current) { const selectionService = editorRef.current.std.selection; @@ -195,10 +401,10 @@ export const CommentEditor = forwardRef( if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); e.stopPropagation(); - onCommit?.(); + handleCommit(); } }, - [onCommit, readonly] + [handleCommit, readonly] ); const handleClickEditor = useCallback( @@ -209,22 +415,72 @@ export const CommentEditor = forwardRef( [focusEditor] ); + useEffect(() => { + return () => { + // Cleanup any remaining local URLs on unmount + attachments?.forEach(att => { + if (att.localUrl) URL.revokeObjectURL(att.localUrl); + }); + }; + }, [attachments]); + return (
+ {attachments?.length && attachments.length > 0 ? ( +
+ {attachments.map((att, index) => ( +
handleImageClick(e, index)} + > + {!readonly && ( +
{ + e.stopPropagation(); + handleImageRemove(att.id); + }} + > + +
+ )} + {att.status === 'uploading' && ( +
+ +
+ )} +
+ ))} +
+ ) : null} + {doc && ( )} {!readonly && (
+ } + onClick={uploadImageFiles} + aria-disabled={isImageUploadDisabled} + /> diff --git a/packages/frontend/core/src/components/comment/comment-editor/style.css.ts b/packages/frontend/core/src/components/comment/comment-editor/style.css.ts index 137e7efcc..7c1005d71 100644 --- a/packages/frontend/core/src/components/comment/comment-editor/style.css.ts +++ b/packages/frontend/core/src/components/comment/comment-editor/style.css.ts @@ -6,9 +6,11 @@ export const container = style({ width: '100%', height: '100%', border: `1px solid transparent`, + overflow: 'hidden', selectors: { '&[data-readonly="false"]': { borderColor: cssVarV2('layer/insideBorder/border'), + background: cssVarV2('layer/background/primary'), borderRadius: 16, padding: '0 8px', }, @@ -56,5 +58,69 @@ export const commitButton = style({ background: cssVarV2('button/disable'), cursor: 'default', }, + '&:hover': { + opacity: 0.8, + }, }, }); + +export const previewRow = style({ + display: 'flex', + gap: 4, + padding: '8px 0', + flexWrap: 'nowrap', + overflowX: 'auto', +}); + +export const previewBox = style({ + position: 'relative', + width: 62, + height: 62, + aspectRatio: '1/1', + objectFit: 'cover', + borderRadius: 4, + flex: '0 0 auto', + backgroundSize: 'cover', + backgroundPosition: 'center', + border: `1px solid ${cssVarV2('layer/insideBorder/border')}`, + cursor: 'pointer', + selectors: { + '&:hover': { + opacity: 0.8, + }, + }, +}); + +export const deleteBtn = style({ + position: 'absolute', + top: -6, + right: -6, + width: 16, + height: 16, + borderRadius: 4, + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + border: `0.5px solid ${cssVarV2('layer/insideBorder/border')}`, + backgroundColor: cssVarV2('layer/background/primary'), + cursor: 'pointer', + selectors: { + '&:hover': { + backgroundColor: cssVarV2('layer/background/error'), + borderColor: cssVarV2('button/error'), + color: cssVarV2('button/error'), + }, + }, +}); + +export const spinnerWrapper = style({ + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + backgroundColor: 'rgba(255,255,255,0.6)', +}); diff --git a/packages/frontend/core/src/components/comment/sidebar/index.tsx b/packages/frontend/core/src/components/comment/sidebar/index.tsx index b14ed84e6..e4140070e 100644 --- a/packages/frontend/core/src/components/comment/sidebar/index.tsx +++ b/packages/frontend/core/src/components/comment/sidebar/index.tsx @@ -14,6 +14,7 @@ import { type DocCommentEntity } from '@affine/core/modules/comment/entities/doc import { CommentPanelService } from '@affine/core/modules/comment/services/comment-panel-service'; import { DocCommentManagerService } from '@affine/core/modules/comment/services/doc-comment-manager'; import type { + CommentAttachment, DocComment, DocCommentReply, } from '@affine/core/modules/comment/types'; @@ -22,7 +23,7 @@ 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 type { DocSnapshot, Store } from '@blocksuite/affine/store'; import { DoneIcon, FilterIcon, MoreHorizontalIcon } from '@blocksuite/icons/rc'; import { useLiveData, @@ -91,31 +92,152 @@ const SortFilterButton = ({ ); }; -const ReadonlyCommentRenderer = ({ - avatarUrl, - name, - time, - snapshot, +// --------------------------------------------------------------------------- +// ActionMenu – reusable dropdown for comment / reply rows +// --------------------------------------------------------------------------- + +const ActionMenu = ({ + open, + onOpenChange, + canReply, + canEdit, + canDelete, + canCopyLink, + disabled, + resolved, + onReply, + onEdit, + onDelete, + onCopyLink, }: { - avatarUrl: string | null; - name: string; - time: number; - snapshot: DocSnapshot; + open: boolean; + onOpenChange: (v: boolean | ((prev: boolean) => boolean)) => void; + canReply?: boolean; + canEdit?: boolean; + canDelete?: boolean; + canCopyLink?: boolean; + disabled?: boolean; + resolved?: boolean; + onReply?: (e: React.MouseEvent) => void; + onEdit?: (e: React.MouseEvent) => void; + onDelete?: (e: React.MouseEvent) => void; + onCopyLink?: (e: React.MouseEvent) => void; }) => { + const t = useI18n(); + return ( -
-
- -
{name}
-
- {i18nTime(time, { - absolute: { accuracy: 'minute' }, - })} + + {canReply ? ( + + {t['com.affine.comment.reply']()} + + ) : null} + {canCopyLink ? ( + + {t['com.affine.comment.copy-link']()} + + ) : null} + {canEdit ? ( + + {t['Edit']()} + + ) : null} + {canDelete ? ( + + {t['Delete']()} + + ) : null} + + } + > + } + disabled={disabled} + /> + + ); +}; + +interface CommentRowProps { + user: { avatarUrl: string | null; name: string }; + // Read-only variant + snapshot?: DocSnapshot; + time?: number; + // Editable variant + doc?: Store; + autoFocus?: boolean; + onCommit?: () => void; + onCancel?: () => void; + attachments?: CommentAttachment[]; + onAttachmentsChange?: (atts: CommentAttachment[]) => void; + uploadCommentAttachment?: (id: string, file: File) => Promise; + editorRefSetter?: (ref: CommentEditorRef | null) => void; +} + +const CommentRow = ({ + user, + snapshot, + time, + doc, + autoFocus, + onCommit, + onCancel, + attachments, + onAttachmentsChange, + uploadCommentAttachment, + editorRefSetter, +}: CommentRowProps) => { + if (snapshot) { + return ( +
+
+ +
{user.name}
+ {time ? ( +
+ {i18nTime(time, { + absolute: { accuracy: 'minute' }, + })} +
+ ) : null} +
+
+
-
- + ); + } + + if (!doc) { + return null; + } + + return ( +
+
+
+
); }; @@ -319,7 +441,12 @@ const CommentItem = ({ async (e: React.MouseEvent) => { e.stopPropagation(); if (comment.resolved || !comment.content) return; - await entity.startEdit(comment.id, 'comment', comment.content.snapshot); + await entity.startEdit( + comment.id, + 'comment', + comment.content.snapshot, + comment.content.attachments ?? [] + ); }, [entity, comment.id, comment.content, comment.resolved] ); @@ -370,74 +497,50 @@ const CommentItem = ({ disabled={isMutating} /> )} - { - setMenuOpen(v); - }, - }} - items={ - <> - {canReply ? ( - - {t['com.affine.comment.reply']()} - - ) : null} - - {t['com.affine.comment.copy-link']()} - - {canEdit ? ( - - {t['Edit']()} - - ) : null} - {canDelete ? ( - - {t['Delete']()} - - ) : null} - - } - > - } - disabled={isMutating} - /> - +
{comment.content?.preview}
{isEditing && editingDoc ? ( -
-
- -
- -
+ { + entity.updateEditingDraft(editingDraft.id, { + attachments, + }); + }} + uploadCommentAttachment={(id, file) => { + return entity.uploadCommentAttachment(id, file, editingDraft); + }} + /> ) : ( - )} {comment.replies && comment.replies.length > 0 && ( @@ -457,20 +560,25 @@ const CommentItem = ({ account && !comment.resolved && canCreateComment && ( -
-
- -
- -
+ { + entity.updatePendingReply(pendingReply.id, { + attachments, + }); + }} + uploadCommentAttachment={(id, file) => { + return entity.uploadCommentAttachment(id, file, pendingReply); + }} + /> )}
); @@ -630,17 +738,22 @@ const CommentInput = ({ entity }: { entity: DocCommentEntity }) => { {pendingPreview && (
{pendingPreview}
)} -
-
- -
- -
+ { + entity.updatePendingComment(newPendingComment.id, { + attachments, + }); + }} + uploadCommentAttachment={(id, file) => { + return entity.uploadCommentAttachment(id, file, newPendingComment); + }} + />
); }; @@ -675,7 +788,12 @@ const ReplyItem = ({ const handleStartEdit = useAsyncCallback(async () => { if (parentComment.resolved || !reply.content) return; - await entity.startEdit(reply.id, 'reply', reply.content.snapshot); + await entity.startEdit( + reply.id, + 'reply', + reply.content.snapshot, + reply.content.attachments ?? [] + ); }, [entity, parentComment.resolved, reply.id, reply.content]); const handleCommitEdit = useAsyncCallback(async () => { @@ -744,67 +862,46 @@ const ReplyItem = ({ data-menu-open={isMenuOpen} data-editing={isEditingThisReply} > - - {canReply ? ( - - {t['com.affine.comment.reply']()} - - ) : null} - {canEdit ? ( - - {t['Edit']()} - - ) : null} - {canDelete ? ( - - {t['Delete']()} - - ) : null} - - } - > - } - variant="solid" - disabled={isMutating} - /> - +
{isEditingThisReply && editingDoc ? ( -
-
- -
- -
+ { + entity.updateEditingDraft(editingDraft.id, { + attachments, + }); + }} + uploadCommentAttachment={(id, file) => { + return entity.uploadCommentAttachment(id, file, editingDraft); + }} + /> ) : ( - )}
@@ -856,12 +953,16 @@ const ReplyList = ({ return ( <> -
=> { + 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; + }; } diff --git a/packages/frontend/core/src/modules/comment/entities/doc-comment.ts b/packages/frontend/core/src/modules/comment/entities/doc-comment.ts index 0e4765833..26fd3d084 100644 --- a/packages/frontend/core/src/modules/comment/entities/doc-comment.ts +++ b/packages/frontend/core/src/modules/comment/entities/doc-comment.ts @@ -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(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(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 { 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 { - await this.store.deleteReply(id); + async deleteReply(replyId: string): Promise { + 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 => { + // 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 { 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): 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): 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): 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 { try { await this.store.resolveComment(id, resolved); diff --git a/packages/frontend/core/src/modules/comment/types.ts b/packages/frontend/core/src/modules/comment/types.ts index dbe047442..27e4036f5 100644 --- a/packages/frontend/core/src/modules/comment/types.ts +++ b/packages/frontend/core/src/modules/comment/types.ts @@ -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 };