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 { LitDocEditor, type PageEditor } from '@affine/core/blocksuite/editors';
|
||||||
import { SnapshotHelper } from '@affine/core/modules/comment/services/snapshot-helper';
|
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 { type RichText, selectTextModel } from '@blocksuite/affine/rich-text';
|
||||||
import { ViewportElementExtension } from '@blocksuite/affine/shared/services';
|
import { ViewportElementExtension } from '@blocksuite/affine/shared/services';
|
||||||
import { type DocSnapshot, Store } from '@blocksuite/affine/store';
|
import { openFilesWith } from '@blocksuite/affine/shared/utils';
|
||||||
import { ArrowUpBigIcon } from '@blocksuite/icons/rc';
|
import { type DocSnapshot, nanoid, Store } from '@blocksuite/affine/store';
|
||||||
|
import {
|
||||||
|
ArrowUpBigIcon,
|
||||||
|
AttachmentIcon,
|
||||||
|
CloseIcon,
|
||||||
|
} from '@blocksuite/icons/rc';
|
||||||
import type { TextSelection } from '@blocksuite/std';
|
import type { TextSelection } from '@blocksuite/std';
|
||||||
import { useFramework, useService } from '@toeverything/infra';
|
import { useFramework, useService } from '@toeverything/infra';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
@@ -21,6 +30,9 @@ import { useAsyncCallback } from '../../hooks/affine-async-hooks';
|
|||||||
import { getCommentEditorViewManager } from './specs';
|
import { getCommentEditorViewManager } from './specs';
|
||||||
import * as styles from './style.css';
|
import * as styles from './style.css';
|
||||||
|
|
||||||
|
const MAX_IMAGE_COUNT = 10;
|
||||||
|
const logger = new DebugLogger('CommentEditor');
|
||||||
|
|
||||||
const usePatchSpecs = (readonly: boolean) => {
|
const usePatchSpecs = (readonly: boolean) => {
|
||||||
const framework = useFramework();
|
const framework = useFramework();
|
||||||
// const confirmModal = useConfirmModal();
|
// const confirmModal = useConfirmModal();
|
||||||
@@ -35,6 +47,12 @@ const usePatchSpecs = (readonly: boolean) => {
|
|||||||
return patchedSpecs;
|
return patchedSpecs;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface EditorAttachment extends CommentAttachment {
|
||||||
|
status?: 'uploading' | 'success' | 'error';
|
||||||
|
file?: File;
|
||||||
|
localUrl?: string; // for previewing
|
||||||
|
}
|
||||||
|
|
||||||
interface CommentEditorProps {
|
interface CommentEditorProps {
|
||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
doc?: Store;
|
doc?: Store;
|
||||||
@@ -43,7 +61,16 @@ interface CommentEditorProps {
|
|||||||
onChange?: (snapshot: DocSnapshot) => void;
|
onChange?: (snapshot: DocSnapshot) => void;
|
||||||
onCommit?: () => void;
|
onCommit?: () => void;
|
||||||
onCancel?: () => 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;
|
autoFocus?: boolean;
|
||||||
|
attachments?: EditorAttachment[];
|
||||||
|
onAttachmentsChange?: (atts: EditorAttachment[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CommentEditorRef {
|
export interface CommentEditorRef {
|
||||||
@@ -84,7 +111,17 @@ const useSnapshotDoc = (
|
|||||||
|
|
||||||
export const CommentEditor = forwardRef<CommentEditorRef, CommentEditorProps>(
|
export const CommentEditor = forwardRef<CommentEditorRef, CommentEditorProps>(
|
||||||
function CommentEditor(
|
function CommentEditor(
|
||||||
{ readonly, defaultSnapshot, doc: userDoc, onChange, onCommit, autoFocus },
|
{
|
||||||
|
readonly,
|
||||||
|
defaultSnapshot,
|
||||||
|
doc: userDoc,
|
||||||
|
onChange,
|
||||||
|
onCommit,
|
||||||
|
uploadCommentAttachment,
|
||||||
|
autoFocus,
|
||||||
|
attachments,
|
||||||
|
onAttachmentsChange,
|
||||||
|
},
|
||||||
ref
|
ref
|
||||||
) {
|
) {
|
||||||
const defaultSnapshotOrDoc = defaultSnapshot ?? userDoc;
|
const defaultSnapshotOrDoc = defaultSnapshot ?? userDoc;
|
||||||
@@ -94,10 +131,179 @@ export const CommentEditor = forwardRef<CommentEditorRef, CommentEditorProps>(
|
|||||||
const specs = usePatchSpecs(!!readonly);
|
const specs = usePatchSpecs(!!readonly);
|
||||||
const doc = useSnapshotDoc(defaultSnapshotOrDoc, readonly);
|
const doc = useSnapshotDoc(defaultSnapshotOrDoc, readonly);
|
||||||
const snapshotHelper = useService(SnapshotHelper);
|
const snapshotHelper = useService(SnapshotHelper);
|
||||||
|
const peekViewService = useService(PeekViewService);
|
||||||
const editorRef = useRef<PageEditor>(null);
|
const editorRef = useRef<PageEditor>(null);
|
||||||
|
|
||||||
const [empty, setEmpty] = useState(true);
|
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 () => {
|
const focusEditor = useAsyncCallback(async () => {
|
||||||
if (editorRef.current) {
|
if (editorRef.current) {
|
||||||
const selectionService = editorRef.current.std.selection;
|
const selectionService = editorRef.current.std.selection;
|
||||||
@@ -195,10 +401,10 @@ export const CommentEditor = forwardRef<CommentEditorRef, CommentEditorProps>(
|
|||||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onCommit?.();
|
handleCommit();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onCommit, readonly]
|
[handleCommit, readonly]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleClickEditor = useCallback(
|
const handleClickEditor = useCallback(
|
||||||
@@ -209,22 +415,72 @@ export const CommentEditor = forwardRef<CommentEditorRef, CommentEditorProps>(
|
|||||||
[focusEditor]
|
[focusEditor]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
// Cleanup any remaining local URLs on unmount
|
||||||
|
attachments?.forEach(att => {
|
||||||
|
if (att.localUrl) URL.revokeObjectURL(att.localUrl);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, [attachments]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={readonly ? undefined : handleClickEditor}
|
onClick={readonly ? undefined : handleClickEditor}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
|
onPaste={handlePasteImage}
|
||||||
data-readonly={!!readonly}
|
data-readonly={!!readonly}
|
||||||
className={clsx(styles.container, 'comment-editor-viewport')}
|
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 && (
|
{doc && (
|
||||||
<LitDocEditor key={doc.id} ref={editorRef} specs={specs} doc={doc} />
|
<LitDocEditor key={doc.id} ref={editorRef} specs={specs} doc={doc} />
|
||||||
)}
|
)}
|
||||||
{!readonly && (
|
{!readonly && (
|
||||||
<div className={styles.footer}>
|
<div className={styles.footer}>
|
||||||
|
<IconButton
|
||||||
|
icon={<AttachmentIcon />}
|
||||||
|
onClick={uploadImageFiles}
|
||||||
|
aria-disabled={isImageUploadDisabled}
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={onCommit}
|
onClick={handleCommit}
|
||||||
className={styles.commitButton}
|
className={styles.commitButton}
|
||||||
disabled={empty}
|
disabled={empty && (attachments?.length ?? 0) === 0}
|
||||||
>
|
>
|
||||||
<ArrowUpBigIcon />
|
<ArrowUpBigIcon />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ export const container = style({
|
|||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
border: `1px solid transparent`,
|
border: `1px solid transparent`,
|
||||||
|
overflow: 'hidden',
|
||||||
selectors: {
|
selectors: {
|
||||||
'&[data-readonly="false"]': {
|
'&[data-readonly="false"]': {
|
||||||
borderColor: cssVarV2('layer/insideBorder/border'),
|
borderColor: cssVarV2('layer/insideBorder/border'),
|
||||||
|
background: cssVarV2('layer/background/primary'),
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
padding: '0 8px',
|
padding: '0 8px',
|
||||||
},
|
},
|
||||||
@@ -56,5 +58,69 @@ export const commitButton = style({
|
|||||||
background: cssVarV2('button/disable'),
|
background: cssVarV2('button/disable'),
|
||||||
cursor: 'default',
|
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 { CommentPanelService } from '@affine/core/modules/comment/services/comment-panel-service';
|
||||||
import { DocCommentManagerService } from '@affine/core/modules/comment/services/doc-comment-manager';
|
import { DocCommentManagerService } from '@affine/core/modules/comment/services/doc-comment-manager';
|
||||||
import type {
|
import type {
|
||||||
|
CommentAttachment,
|
||||||
DocComment,
|
DocComment,
|
||||||
DocCommentReply,
|
DocCommentReply,
|
||||||
} from '@affine/core/modules/comment/types';
|
} 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 { WorkbenchService } from '@affine/core/modules/workbench';
|
||||||
import { copyTextToClipboard } from '@affine/core/utils/clipboard';
|
import { copyTextToClipboard } from '@affine/core/utils/clipboard';
|
||||||
import { i18nTime, useI18n } from '@affine/i18n';
|
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 { DoneIcon, FilterIcon, MoreHorizontalIcon } from '@blocksuite/icons/rc';
|
||||||
import {
|
import {
|
||||||
useLiveData,
|
useLiveData,
|
||||||
@@ -91,31 +92,152 @@ const SortFilterButton = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ReadonlyCommentRenderer = ({
|
// ---------------------------------------------------------------------------
|
||||||
avatarUrl,
|
// ActionMenu – reusable dropdown for comment / reply rows
|
||||||
name,
|
// ---------------------------------------------------------------------------
|
||||||
time,
|
|
||||||
snapshot,
|
const ActionMenu = ({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
canReply,
|
||||||
|
canEdit,
|
||||||
|
canDelete,
|
||||||
|
canCopyLink,
|
||||||
|
disabled,
|
||||||
|
resolved,
|
||||||
|
onReply,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onCopyLink,
|
||||||
}: {
|
}: {
|
||||||
avatarUrl: string | null;
|
open: boolean;
|
||||||
name: string;
|
onOpenChange: (v: boolean | ((prev: boolean) => boolean)) => void;
|
||||||
time: number;
|
canReply?: boolean;
|
||||||
snapshot: DocSnapshot;
|
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 (
|
return (
|
||||||
<div data-time={time} className={styles.readonlyCommentContainer}>
|
<Menu
|
||||||
<div className={styles.userContainer}>
|
rootOptions={{
|
||||||
<Avatar url={avatarUrl} size={24} />
|
open,
|
||||||
<div className={styles.userName}>{name}</div>
|
onOpenChange,
|
||||||
<div className={styles.time}>
|
}}
|
||||||
{i18nTime(time, {
|
items={
|
||||||
absolute: { accuracy: 'minute' },
|
<>
|
||||||
})}
|
{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>
|
</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>
|
</div>
|
||||||
|
<CommentEditor
|
||||||
|
ref={editorRefSetter}
|
||||||
|
attachments={attachments}
|
||||||
|
onAttachmentsChange={onAttachmentsChange}
|
||||||
|
doc={doc}
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
onCommit={onCommit}
|
||||||
|
onCancel={onCancel}
|
||||||
|
uploadCommentAttachment={uploadCommentAttachment}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -319,7 +441,12 @@ const CommentItem = ({
|
|||||||
async (e: React.MouseEvent) => {
|
async (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (comment.resolved || !comment.content) return;
|
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]
|
[entity, comment.id, comment.content, comment.resolved]
|
||||||
);
|
);
|
||||||
@@ -370,74 +497,50 @@ const CommentItem = ({
|
|||||||
disabled={isMutating}
|
disabled={isMutating}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Menu
|
<ActionMenu
|
||||||
rootOptions={{
|
open={menuOpen}
|
||||||
open: menuOpen,
|
onOpenChange={setMenuOpen}
|
||||||
onOpenChange: v => {
|
canReply={canReply}
|
||||||
setMenuOpen(v);
|
canCopyLink
|
||||||
},
|
canEdit={!!canEdit}
|
||||||
}}
|
canDelete={canDelete}
|
||||||
items={
|
disabled={isMutating}
|
||||||
<>
|
resolved={comment.resolved}
|
||||||
{canReply ? (
|
onReply={handleReply}
|
||||||
<MenuItem
|
onCopyLink={handleCopyLink}
|
||||||
onClick={handleReply}
|
onEdit={handleStartEdit}
|
||||||
disabled={isMutating || comment.resolved}
|
onDelete={handleDelete}
|
||||||
>
|
/>
|
||||||
{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>
|
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.previewContainer}>{comment.content?.preview}</div>
|
<div className={styles.previewContainer}>{comment.content?.preview}</div>
|
||||||
|
|
||||||
<div className={styles.repliesContainer}>
|
<div className={styles.repliesContainer}>
|
||||||
{isEditing && editingDoc ? (
|
{isEditing && editingDoc ? (
|
||||||
<div className={styles.commentInputContainer}>
|
<CommentRow
|
||||||
<div className={styles.userContainer}>
|
user={{ avatarUrl: account?.avatar ?? null, name: '' }}
|
||||||
<Avatar url={account?.avatar} size={24} />
|
doc={editingDoc}
|
||||||
</div>
|
autoFocus
|
||||||
<CommentEditor
|
onCommit={isMutating ? undefined : handleCommitEdit}
|
||||||
doc={editingDoc}
|
onCancel={isMutating ? undefined : handleCancelEdit}
|
||||||
autoFocus
|
attachments={editingDraft.attachments}
|
||||||
onCommit={isMutating ? undefined : handleCommitEdit}
|
onAttachmentsChange={attachments => {
|
||||||
onCancel={isMutating ? undefined : handleCancelEdit}
|
entity.updateEditingDraft(editingDraft.id, {
|
||||||
/>
|
attachments,
|
||||||
</div>
|
});
|
||||||
|
}}
|
||||||
|
uploadCommentAttachment={(id, file) => {
|
||||||
|
return entity.uploadCommentAttachment(id, file, editingDraft);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ReadonlyCommentRenderer
|
<CommentRow
|
||||||
avatarUrl={comment.user.avatarUrl}
|
user={{
|
||||||
name={comment.user.name}
|
avatarUrl: comment.user.avatarUrl,
|
||||||
|
name: comment.user.name,
|
||||||
|
}}
|
||||||
time={comment.createdAt}
|
time={comment.createdAt}
|
||||||
snapshot={comment.content.snapshot}
|
snapshot={comment.content.snapshot}
|
||||||
|
attachments={comment.content?.attachments}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{comment.replies && comment.replies.length > 0 && (
|
{comment.replies && comment.replies.length > 0 && (
|
||||||
@@ -457,20 +560,25 @@ const CommentItem = ({
|
|||||||
account &&
|
account &&
|
||||||
!comment.resolved &&
|
!comment.resolved &&
|
||||||
canCreateComment && (
|
canCreateComment && (
|
||||||
<div className={styles.commentInputContainer}>
|
<CommentRow
|
||||||
<div className={styles.userContainer}>
|
user={{ avatarUrl: account.avatar ?? null, name: '' }}
|
||||||
<Avatar url={account.avatar} size={24} />
|
doc={pendingReply.doc}
|
||||||
</div>
|
autoFocus
|
||||||
<CommentEditor
|
editorRefSetter={setReplyEditor}
|
||||||
ref={setReplyEditor}
|
onCommit={
|
||||||
autoFocus
|
isMutating || !canCreateComment ? undefined : handleCommitReply
|
||||||
doc={pendingReply.doc}
|
}
|
||||||
onCommit={
|
onCancel={isMutating ? undefined : handleCancelReply}
|
||||||
isMutating || !canCreateComment ? undefined : handleCommitReply
|
attachments={pendingReply.attachments}
|
||||||
}
|
onAttachmentsChange={attachments => {
|
||||||
onCancel={isMutating ? undefined : handleCancelReply}
|
entity.updatePendingReply(pendingReply.id, {
|
||||||
/>
|
attachments,
|
||||||
</div>
|
});
|
||||||
|
}}
|
||||||
|
uploadCommentAttachment={(id, file) => {
|
||||||
|
return entity.uploadCommentAttachment(id, file, pendingReply);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -630,17 +738,22 @@ const CommentInput = ({ entity }: { entity: DocCommentEntity }) => {
|
|||||||
{pendingPreview && (
|
{pendingPreview && (
|
||||||
<div className={styles.previewContainer}>{pendingPreview}</div>
|
<div className={styles.previewContainer}>{pendingPreview}</div>
|
||||||
)}
|
)}
|
||||||
<div className={styles.commentInputContainer}>
|
<CommentRow
|
||||||
<div className={styles.userContainer}>
|
user={{ avatarUrl: account.avatar ?? null, name: '' }}
|
||||||
<Avatar url={account.avatar} size={24} />
|
doc={newPendingComment.doc}
|
||||||
</div>
|
autoFocus
|
||||||
<CommentEditor
|
onCommit={isMutating || !canCreateComment ? undefined : handleCommit}
|
||||||
autoFocus
|
onCancel={isMutating ? undefined : handleCancel}
|
||||||
doc={newPendingComment.doc}
|
attachments={newPendingComment.attachments}
|
||||||
onCommit={isMutating || !canCreateComment ? undefined : handleCommit}
|
onAttachmentsChange={attachments => {
|
||||||
onCancel={isMutating ? undefined : handleCancel}
|
entity.updatePendingComment(newPendingComment.id, {
|
||||||
/>
|
attachments,
|
||||||
</div>
|
});
|
||||||
|
}}
|
||||||
|
uploadCommentAttachment={(id, file) => {
|
||||||
|
return entity.uploadCommentAttachment(id, file, newPendingComment);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -675,7 +788,12 @@ const ReplyItem = ({
|
|||||||
|
|
||||||
const handleStartEdit = useAsyncCallback(async () => {
|
const handleStartEdit = useAsyncCallback(async () => {
|
||||||
if (parentComment.resolved || !reply.content) return;
|
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]);
|
}, [entity, parentComment.resolved, reply.id, reply.content]);
|
||||||
|
|
||||||
const handleCommitEdit = useAsyncCallback(async () => {
|
const handleCommitEdit = useAsyncCallback(async () => {
|
||||||
@@ -744,67 +862,46 @@ const ReplyItem = ({
|
|||||||
data-menu-open={isMenuOpen}
|
data-menu-open={isMenuOpen}
|
||||||
data-editing={isEditingThisReply}
|
data-editing={isEditingThisReply}
|
||||||
>
|
>
|
||||||
<Menu
|
<ActionMenu
|
||||||
rootOptions={{
|
open={isMenuOpen}
|
||||||
onOpenChange: setIsMenuOpen,
|
onOpenChange={setIsMenuOpen}
|
||||||
open: isMenuOpen,
|
canReply={canReply}
|
||||||
}}
|
canEdit={!!canEdit}
|
||||||
items={
|
canDelete={canDelete}
|
||||||
<>
|
disabled={isMutating}
|
||||||
{canReply ? (
|
resolved={parentComment.resolved}
|
||||||
<MenuItem
|
onReply={handleReply}
|
||||||
onClick={handleReply}
|
onEdit={handleStartEdit}
|
||||||
disabled={isMutating || parentComment.resolved}
|
onDelete={handleDelete}
|
||||||
>
|
/>
|
||||||
{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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isEditingThisReply && editingDoc ? (
|
{isEditingThisReply && editingDoc ? (
|
||||||
<div className={styles.commentInputContainer}>
|
<CommentRow
|
||||||
<div className={styles.userContainer}>
|
user={{ avatarUrl: account?.avatar ?? null, name: '' }}
|
||||||
<Avatar url={account?.avatar} size={24} />
|
doc={editingDoc}
|
||||||
</div>
|
autoFocus
|
||||||
<CommentEditor
|
onCommit={isMutating ? undefined : handleCommitEdit}
|
||||||
doc={editingDoc}
|
onCancel={isMutating ? undefined : handleCancelEdit}
|
||||||
autoFocus
|
attachments={editingDraft.attachments}
|
||||||
onCommit={isMutating ? undefined : handleCommitEdit}
|
onAttachmentsChange={attachments => {
|
||||||
onCancel={isMutating ? undefined : handleCancelEdit}
|
entity.updateEditingDraft(editingDraft.id, {
|
||||||
/>
|
attachments,
|
||||||
</div>
|
});
|
||||||
|
}}
|
||||||
|
uploadCommentAttachment={(id, file) => {
|
||||||
|
return entity.uploadCommentAttachment(id, file, editingDraft);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ReadonlyCommentRenderer
|
<CommentRow
|
||||||
avatarUrl={reply.user.avatarUrl}
|
user={{
|
||||||
name={reply.user.name}
|
avatarUrl: reply.user.avatarUrl ?? null,
|
||||||
|
name: reply.user.name,
|
||||||
|
}}
|
||||||
time={reply.createdAt}
|
time={reply.createdAt}
|
||||||
snapshot={reply.content.snapshot}
|
snapshot={reply.content ? reply.content.snapshot : undefined}
|
||||||
|
attachments={reply.content?.attachments}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -856,12 +953,16 @@ const ReplyList = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ReplyItem
|
<CommentRow
|
||||||
key={firstReply.id}
|
user={{
|
||||||
reply={firstReply}
|
avatarUrl: firstReply.user.avatarUrl ?? null,
|
||||||
parentComment={parentComment}
|
name: firstReply.user.name,
|
||||||
entity={entity}
|
}}
|
||||||
replyEditor={replyEditor}
|
time={firstReply.createdAt}
|
||||||
|
snapshot={
|
||||||
|
firstReply.content ? firstReply.content.snapshot : undefined
|
||||||
|
}
|
||||||
|
attachments={firstReply.content?.attachments}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={styles.collapsedReplies}
|
className={styles.collapsedReplies}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
resolveCommentMutation,
|
resolveCommentMutation,
|
||||||
updateCommentMutation,
|
updateCommentMutation,
|
||||||
updateReplyMutation,
|
updateReplyMutation,
|
||||||
|
uploadCommentAttachmentMutation,
|
||||||
} from '@affine/graphql';
|
} from '@affine/graphql';
|
||||||
import { Entity } from '@toeverything/infra';
|
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 { GlobalContextService } from '../../global-context';
|
||||||
import type { SnapshotHelper } from '../services/snapshot-helper';
|
import type { SnapshotHelper } from '../services/snapshot-helper';
|
||||||
import type {
|
import type {
|
||||||
|
CommentAttachment,
|
||||||
CommentId,
|
CommentId,
|
||||||
DocComment,
|
DocComment,
|
||||||
DocCommentChangeListResult,
|
DocCommentChangeListResult,
|
||||||
@@ -40,6 +41,13 @@ import { DocCommentStore } from './doc-comment-store';
|
|||||||
|
|
||||||
type DisposeCallback = () => void;
|
type DisposeCallback = () => void;
|
||||||
|
|
||||||
|
type EditingDraft = {
|
||||||
|
id: CommentId;
|
||||||
|
type: 'comment' | 'reply';
|
||||||
|
doc: Store;
|
||||||
|
attachments: CommentAttachment[];
|
||||||
|
};
|
||||||
|
|
||||||
export class DocCommentEntity extends Entity<{
|
export class DocCommentEntity extends Entity<{
|
||||||
docId: string;
|
docId: string;
|
||||||
}> {
|
}> {
|
||||||
@@ -68,11 +76,7 @@ export class DocCommentEntity extends Entity<{
|
|||||||
readonly pendingReply$ = new LiveData<PendingComment | null>(null);
|
readonly pendingReply$ = new LiveData<PendingComment | null>(null);
|
||||||
|
|
||||||
// Draft state for editing existing comment or reply (only one at a time)
|
// Draft state for editing existing comment or reply (only one at a time)
|
||||||
readonly editingDraft$ = new LiveData<{
|
readonly editingDraft$ = new LiveData<EditingDraft | null>(null);
|
||||||
id: CommentId;
|
|
||||||
type: 'comment' | 'reply';
|
|
||||||
doc: Store;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
private readonly commentAdded$ = new Subject<{
|
private readonly commentAdded$ = new Subject<{
|
||||||
id: CommentId;
|
id: CommentId;
|
||||||
@@ -102,6 +106,7 @@ export class DocCommentEntity extends Entity<{
|
|||||||
doc,
|
doc,
|
||||||
preview,
|
preview,
|
||||||
selections,
|
selections,
|
||||||
|
attachments: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
// Replace any existing pending comment (only one at a time)
|
// Replace any existing pending comment (only one at a time)
|
||||||
@@ -137,6 +142,7 @@ export class DocCommentEntity extends Entity<{
|
|||||||
id,
|
id,
|
||||||
doc,
|
doc,
|
||||||
commentId,
|
commentId,
|
||||||
|
attachments: [],
|
||||||
};
|
};
|
||||||
// Replace any existing pending reply (only one at a time)
|
// Replace any existing pending reply (only one at a time)
|
||||||
this.pendingReply$.setValue(pendingReply);
|
this.pendingReply$.setValue(pendingReply);
|
||||||
@@ -158,13 +164,14 @@ export class DocCommentEntity extends Entity<{
|
|||||||
async startEdit(
|
async startEdit(
|
||||||
id: CommentId,
|
id: CommentId,
|
||||||
type: 'comment' | 'reply',
|
type: 'comment' | 'reply',
|
||||||
snapshot: DocSnapshot
|
snapshot: DocSnapshot,
|
||||||
|
attachments: CommentAttachment[]
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const doc = await this.snapshotHelper.createStore(snapshot);
|
const doc = await this.snapshotHelper.createStore(snapshot);
|
||||||
if (!doc) {
|
if (!doc) {
|
||||||
throw new Error('Failed to create doc for editing');
|
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) */
|
/** Commit current editing draft (if any) */
|
||||||
@@ -178,9 +185,15 @@ export class DocCommentEntity extends Entity<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (draft.type === 'comment') {
|
if (draft.type === 'comment') {
|
||||||
await this.updateComment(draft.id, { snapshot });
|
await this.updateComment(draft.id, {
|
||||||
|
snapshot,
|
||||||
|
attachments: draft.attachments,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
await this.updateReply(draft.id, { snapshot });
|
await this.updateReply(draft.id, {
|
||||||
|
snapshot,
|
||||||
|
attachments: draft.attachments,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
this.editingDraft$.setValue(null);
|
this.editingDraft$.setValue(null);
|
||||||
@@ -202,7 +215,7 @@ export class DocCommentEntity extends Entity<{
|
|||||||
console.warn('Pending comment not found:', id);
|
console.warn('Pending comment not found:', id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { doc, preview } = pendingComment;
|
const { doc, preview, attachments } = pendingComment;
|
||||||
const snapshot = this.snapshotHelper.getSnapshot(doc);
|
const snapshot = this.snapshotHelper.getSnapshot(doc);
|
||||||
if (!snapshot) {
|
if (!snapshot) {
|
||||||
throw new Error('Failed to get snapshot');
|
throw new Error('Failed to get snapshot');
|
||||||
@@ -212,6 +225,7 @@ export class DocCommentEntity extends Entity<{
|
|||||||
snapshot,
|
snapshot,
|
||||||
preview,
|
preview,
|
||||||
mode: this.docMode$.value ?? 'page',
|
mode: this.docMode$.value ?? 'page',
|
||||||
|
attachments,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const currentComments = this.comments$.value;
|
const currentComments = this.comments$.value;
|
||||||
@@ -230,7 +244,7 @@ export class DocCommentEntity extends Entity<{
|
|||||||
console.warn('Pending reply not found:', id);
|
console.warn('Pending reply not found:', id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { doc } = pendingReply;
|
const { doc, attachments } = pendingReply;
|
||||||
const snapshot = this.snapshotHelper.getSnapshot(doc);
|
const snapshot = this.snapshotHelper.getSnapshot(doc);
|
||||||
if (!snapshot) {
|
if (!snapshot) {
|
||||||
throw new Error('Failed to get 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, {
|
const reply = await this.store.createReply(pendingReply.commentId, {
|
||||||
content: {
|
content: {
|
||||||
snapshot,
|
snapshot,
|
||||||
|
attachments,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const currentComments = this.comments$.value;
|
const currentComments = this.comments$.value;
|
||||||
@@ -264,19 +279,56 @@ export class DocCommentEntity extends Entity<{
|
|||||||
this.revalidate();
|
this.revalidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteReply(id: string): Promise<void> {
|
async deleteReply(replyId: string): Promise<void> {
|
||||||
await this.store.deleteReply(id);
|
await this.store.deleteReply(replyId);
|
||||||
const currentComments = this.comments$.value;
|
const currentComments = this.comments$.value;
|
||||||
const updatedComments = currentComments.map(comment => {
|
const updatedComments = currentComments.map(comment => {
|
||||||
return {
|
return {
|
||||||
...comment,
|
...comment,
|
||||||
replies: comment.replies?.filter(r => r.id !== id),
|
replies: comment.replies?.filter(r => r.id !== replyId),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
this.comments$.setValue(updatedComments);
|
this.comments$.setValue(updatedComments);
|
||||||
this.revalidate();
|
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> {
|
async updateComment(id: string, content: DocCommentContent): Promise<void> {
|
||||||
await this.store.updateComment(id, { content });
|
await this.store.updateComment(id, { content });
|
||||||
const currentComments = this.comments$.value;
|
const currentComments = this.comments$.value;
|
||||||
@@ -297,6 +349,30 @@ export class DocCommentEntity extends Entity<{
|
|||||||
this.revalidate();
|
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> {
|
async resolveComment(id: CommentId, resolved: boolean): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.store.resolveComment(id, resolved);
|
await this.store.resolveComment(id, resolved);
|
||||||
|
|||||||
@@ -8,6 +8,13 @@ import type {
|
|||||||
|
|
||||||
export type CommentId = string;
|
export type CommentId = string;
|
||||||
|
|
||||||
|
export type CommentAttachment = {
|
||||||
|
id: string;
|
||||||
|
url?: string; // attachment may not be uploaded yet
|
||||||
|
filename?: string;
|
||||||
|
mimeType?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export interface BaseComment {
|
export interface BaseComment {
|
||||||
id: CommentId;
|
id: CommentId;
|
||||||
content?: DocCommentContent;
|
content?: DocCommentContent;
|
||||||
@@ -28,6 +35,7 @@ export type PendingComment = {
|
|||||||
preview?: string;
|
preview?: string;
|
||||||
selections?: BaseSelection[];
|
selections?: BaseSelection[];
|
||||||
commentId?: CommentId; // only for replies, points to the parent comment
|
commentId?: CommentId; // only for replies, points to the parent comment
|
||||||
|
attachments: CommentAttachment[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface DocCommentReply extends BaseComment {
|
export interface DocCommentReply extends BaseComment {
|
||||||
@@ -37,6 +45,7 @@ export interface DocCommentReply extends BaseComment {
|
|||||||
|
|
||||||
export type DocCommentContent = {
|
export type DocCommentContent = {
|
||||||
snapshot: DocSnapshot; // blocksuite snapshot
|
snapshot: DocSnapshot; // blocksuite snapshot
|
||||||
|
attachments?: CommentAttachment[];
|
||||||
mode?: DocMode;
|
mode?: DocMode;
|
||||||
preview?: string; // text preview of the target
|
preview?: string; // text preview of the target
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user