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

View File

@@ -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<string>;
autoFocus?: boolean;
attachments?: EditorAttachment[];
onAttachmentsChange?: (atts: EditorAttachment[]) => void;
}
export interface CommentEditorRef {
@@ -84,7 +111,17 @@ const useSnapshotDoc = (
export const CommentEditor = forwardRef<CommentEditorRef, CommentEditorProps>(
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<CommentEditorRef, CommentEditorProps>(
const specs = usePatchSpecs(!!readonly);
const doc = useSnapshotDoc(defaultSnapshotOrDoc, readonly);
const snapshotHelper = useService(SnapshotHelper);
const peekViewService = useService(PeekViewService);
const editorRef = useRef<PageEditor>(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<CommentEditorRef, CommentEditorProps>(
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<CommentEditorRef, CommentEditorProps>(
[focusEditor]
);
useEffect(() => {
return () => {
// Cleanup any remaining local URLs on unmount
attachments?.forEach(att => {
if (att.localUrl) URL.revokeObjectURL(att.localUrl);
});
};
}, [attachments]);
return (
<div
onClick={readonly ? undefined : handleClickEditor}
onKeyDown={handleKeyDown}
onPaste={handlePasteImage}
data-readonly={!!readonly}
className={clsx(styles.container, 'comment-editor-viewport')}
>
{attachments?.length && attachments.length > 0 ? (
<div
className={styles.previewRow}
data-testid="comment-image-preview"
>
{attachments.map((att, index) => (
<div
key={att.id}
className={styles.previewBox}
style={{
backgroundImage: `url(${att.localUrl ?? att.url})`,
}}
onClick={e => handleImageClick(e, index)}
>
{!readonly && (
<div
className={styles.deleteBtn}
onClick={e => {
e.stopPropagation();
handleImageRemove(att.id);
}}
>
<CloseIcon width={12} height={12} />
</div>
)}
{att.status === 'uploading' && (
<div className={styles.spinnerWrapper}>
<Loading size={16} />
</div>
)}
</div>
))}
</div>
) : null}
{doc && (
<LitDocEditor key={doc.id} ref={editorRef} specs={specs} doc={doc} />
)}
{!readonly && (
<div className={styles.footer}>
<IconButton
icon={<AttachmentIcon />}
onClick={uploadImageFiles}
aria-disabled={isImageUploadDisabled}
/>
<button
onClick={onCommit}
onClick={handleCommit}
className={styles.commitButton}
disabled={empty}
disabled={empty && (attachments?.length ?? 0) === 0}
>
<ArrowUpBigIcon />
</button>

View File

@@ -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)',
});

View File

@@ -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 (
<div data-time={time} className={styles.readonlyCommentContainer}>
<div className={styles.userContainer}>
<Avatar url={avatarUrl} size={24} />
<div className={styles.userName}>{name}</div>
<div className={styles.time}>
{i18nTime(time, {
absolute: { accuracy: 'minute' },
})}
<Menu
rootOptions={{
open,
onOpenChange,
}}
items={
<>
{canReply ? (
<MenuItem onClick={onReply} disabled={!!disabled || !!resolved}>
{t['com.affine.comment.reply']()}
</MenuItem>
) : null}
{canCopyLink ? (
<MenuItem onClick={onCopyLink} disabled={disabled}>
{t['com.affine.comment.copy-link']()}
</MenuItem>
) : null}
{canEdit ? (
<MenuItem onClick={onEdit} disabled={!!disabled || !!resolved}>
{t['Edit']()}
</MenuItem>
) : null}
{canDelete ? (
<MenuItem onClick={onDelete} type="danger" disabled={disabled}>
{t['Delete']()}
</MenuItem>
) : null}
</>
}
>
<IconButton
variant="solid"
icon={<MoreHorizontalIcon />}
disabled={disabled}
/>
</Menu>
);
};
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<string>;
editorRefSetter?: (ref: CommentEditorRef | null) => void;
}
const CommentRow = ({
user,
snapshot,
time,
doc,
autoFocus,
onCommit,
onCancel,
attachments,
onAttachmentsChange,
uploadCommentAttachment,
editorRefSetter,
}: CommentRowProps) => {
if (snapshot) {
return (
<div data-time={time} className={styles.readonlyCommentContainer}>
<div className={styles.userContainer}>
<Avatar url={user.avatarUrl ?? null} size={24} />
<div className={styles.userName}>{user.name}</div>
{time ? (
<div className={styles.time}>
{i18nTime(time, {
absolute: { accuracy: 'minute' },
})}
</div>
) : null}
</div>
<div style={{ marginLeft: '34px' }}>
<CommentEditor
readonly
defaultSnapshot={snapshot}
attachments={attachments}
/>
</div>
</div>
<div style={{ marginLeft: '34px' }}>
<CommentEditor readonly defaultSnapshot={snapshot} />
);
}
if (!doc) {
return null;
}
return (
<div className={styles.commentInputContainer}>
<div className={styles.userContainer}>
<Avatar url={user.avatarUrl ?? null} size={24} />
</div>
<CommentEditor
ref={editorRefSetter}
attachments={attachments}
onAttachmentsChange={onAttachmentsChange}
doc={doc}
autoFocus={autoFocus}
onCommit={onCommit}
onCancel={onCancel}
uploadCommentAttachment={uploadCommentAttachment}
/>
</div>
);
};
@@ -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}
/>
)}
<Menu
rootOptions={{
open: menuOpen,
onOpenChange: v => {
setMenuOpen(v);
},
}}
items={
<>
{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>
{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}
</>
}
>
<IconButton
variant="solid"
icon={<MoreHorizontalIcon />}
disabled={isMutating}
/>
</Menu>
<ActionMenu
open={menuOpen}
onOpenChange={setMenuOpen}
canReply={canReply}
canCopyLink
canEdit={!!canEdit}
canDelete={canDelete}
disabled={isMutating}
resolved={comment.resolved}
onReply={handleReply}
onCopyLink={handleCopyLink}
onEdit={handleStartEdit}
onDelete={handleDelete}
/>
</div>
<div className={styles.previewContainer}>{comment.content?.preview}</div>
<div className={styles.repliesContainer}>
{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>
<CommentRow
user={{ avatarUrl: account?.avatar ?? null, name: '' }}
doc={editingDoc}
autoFocus
onCommit={isMutating ? undefined : handleCommitEdit}
onCancel={isMutating ? undefined : handleCancelEdit}
attachments={editingDraft.attachments}
onAttachmentsChange={attachments => {
entity.updateEditingDraft(editingDraft.id, {
attachments,
});
}}
uploadCommentAttachment={(id, file) => {
return entity.uploadCommentAttachment(id, file, editingDraft);
}}
/>
) : (
<ReadonlyCommentRenderer
avatarUrl={comment.user.avatarUrl}
name={comment.user.name}
<CommentRow
user={{
avatarUrl: comment.user.avatarUrl,
name: comment.user.name,
}}
time={comment.createdAt}
snapshot={comment.content.snapshot}
attachments={comment.content?.attachments}
/>
)}
{comment.replies && comment.replies.length > 0 && (
@@ -457,20 +560,25 @@ const CommentItem = ({
account &&
!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 || !canCreateComment ? undefined : handleCommitReply
}
onCancel={isMutating ? undefined : handleCancelReply}
/>
</div>
<CommentRow
user={{ avatarUrl: account.avatar ?? null, name: '' }}
doc={pendingReply.doc}
autoFocus
editorRefSetter={setReplyEditor}
onCommit={
isMutating || !canCreateComment ? undefined : handleCommitReply
}
onCancel={isMutating ? undefined : handleCancelReply}
attachments={pendingReply.attachments}
onAttachmentsChange={attachments => {
entity.updatePendingReply(pendingReply.id, {
attachments,
});
}}
uploadCommentAttachment={(id, file) => {
return entity.uploadCommentAttachment(id, file, pendingReply);
}}
/>
)}
</div>
);
@@ -630,17 +738,22 @@ const CommentInput = ({ entity }: { entity: DocCommentEntity }) => {
{pendingPreview && (
<div className={styles.previewContainer}>{pendingPreview}</div>
)}
<div className={styles.commentInputContainer}>
<div className={styles.userContainer}>
<Avatar url={account.avatar} size={24} />
</div>
<CommentEditor
autoFocus
doc={newPendingComment.doc}
onCommit={isMutating || !canCreateComment ? undefined : handleCommit}
onCancel={isMutating ? undefined : handleCancel}
/>
</div>
<CommentRow
user={{ avatarUrl: account.avatar ?? null, name: '' }}
doc={newPendingComment.doc}
autoFocus
onCommit={isMutating || !canCreateComment ? undefined : handleCommit}
onCancel={isMutating ? undefined : handleCancel}
attachments={newPendingComment.attachments}
onAttachmentsChange={attachments => {
entity.updatePendingComment(newPendingComment.id, {
attachments,
});
}}
uploadCommentAttachment={(id, file) => {
return entity.uploadCommentAttachment(id, file, newPendingComment);
}}
/>
</div>
);
};
@@ -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}
>
<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>
<ActionMenu
open={isMenuOpen}
onOpenChange={setIsMenuOpen}
canReply={canReply}
canEdit={!!canEdit}
canDelete={canDelete}
disabled={isMutating}
resolved={parentComment.resolved}
onReply={handleReply}
onEdit={handleStartEdit}
onDelete={handleDelete}
/>
</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>
<CommentRow
user={{ avatarUrl: account?.avatar ?? null, name: '' }}
doc={editingDoc}
autoFocus
onCommit={isMutating ? undefined : handleCommitEdit}
onCancel={isMutating ? undefined : handleCancelEdit}
attachments={editingDraft.attachments}
onAttachmentsChange={attachments => {
entity.updateEditingDraft(editingDraft.id, {
attachments,
});
}}
uploadCommentAttachment={(id, file) => {
return entity.uploadCommentAttachment(id, file, editingDraft);
}}
/>
) : (
<ReadonlyCommentRenderer
avatarUrl={reply.user.avatarUrl}
name={reply.user.name}
<CommentRow
user={{
avatarUrl: reply.user.avatarUrl ?? null,
name: reply.user.name,
}}
time={reply.createdAt}
snapshot={reply.content.snapshot}
snapshot={reply.content ? reply.content.snapshot : undefined}
attachments={reply.content?.attachments}
/>
)}
</div>
@@ -856,12 +953,16 @@ const ReplyList = ({
return (
<>
<ReplyItem
key={firstReply.id}
reply={firstReply}
parentComment={parentComment}
entity={entity}
replyEditor={replyEditor}
<CommentRow
user={{
avatarUrl: firstReply.user.avatarUrl ?? null,
name: firstReply.user.name,
}}
time={firstReply.createdAt}
snapshot={
firstReply.content ? firstReply.content.snapshot : undefined
}
attachments={firstReply.content?.attachments}
/>
<div
className={styles.collapsedReplies}

View File

@@ -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;
};
}

View File

@@ -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);

View File

@@ -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
};