mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-24 09:52:49 +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:
@@ -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>
|
||||
|
||||
@@ -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)',
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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