mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 21:05:19 +00:00
feat(core): support normal attachments (#13112)
fix AF-2722  #### PR Dependency Tree * **PR #13112** 👈 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 * **New Features** * Expanded comment editor attachment support to include any file type, not just images. * Added file preview and download functionality for non-image attachments. * Introduced notifications for attachment upload failures and downloads. * Added a new AI artifact tool component for enhanced AI tool integrations. * **Style** * Added new styles for generic file previews, including icons, file info, and delete button. * **Bug Fixes** * Improved error handling and user feedback for attachment uploads. * **Refactor** * Unified attachment UI rendering and handling for both images and other file types. * **Chores** * Removed obsolete editor state attribute from comment preview sidebar. <!-- end of auto-generated comment: release notes by coderabbit.ai --> #### PR Dependency Tree * **PR #13112** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal)
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
import { LoadingIcon } from '@blocksuite/affine/components/icons';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
|
||||
import type { ImageProxyService } from '@blocksuite/affine/shared/adapters';
|
||||
import { type BlockStdScope, ShadowlessElement } from '@blocksuite/affine/std';
|
||||
import type { Signal } from '@preact/signals-core';
|
||||
import {
|
||||
css,
|
||||
html,
|
||||
nothing,
|
||||
type PropertyValues,
|
||||
type TemplateResult,
|
||||
} from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
|
||||
import {
|
||||
isPreviewPanelOpen,
|
||||
renderPreviewPanel,
|
||||
} from './artifacts-preview-panel';
|
||||
|
||||
/**
|
||||
* Base web-component for AI artifact tools.
|
||||
* It encapsulates common reactive properties (data/std/width/…)
|
||||
* and automatically calls `updatePreviewPanel()` when the `data`
|
||||
* property changes while the preview panel is open.
|
||||
*/
|
||||
export abstract class ArtifactTool<
|
||||
TData extends { type: 'tool-result' | 'tool-call' },
|
||||
> extends SignalWatcher(WithDisposable(ShadowlessElement)) {
|
||||
static override styles = css`
|
||||
.artifact-tool-card {
|
||||
cursor: pointer;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.artifact-tool-card:hover {
|
||||
background-color: var(--affine-hover-color);
|
||||
}
|
||||
`;
|
||||
|
||||
/** Tool data coming from ChatGPT (tool-call / tool-result). */
|
||||
@property({ attribute: false })
|
||||
accessor data!: TData;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor width: Signal<number | undefined> | undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor imageProxyService: ImageProxyService | null | undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor std: BlockStdScope | undefined;
|
||||
|
||||
/* -------------------------- Card meta hooks -------------------------- */
|
||||
|
||||
/**
|
||||
* Sub-class must provide primary information for the card.
|
||||
*/
|
||||
protected abstract getCardMeta(): {
|
||||
title: string;
|
||||
/** Page / file icon shown when not loading */
|
||||
icon: TemplateResult | HTMLElement | string | null;
|
||||
/** Whether the spinner should be displayed */
|
||||
loading: boolean;
|
||||
/** Extra css class appended to card root */
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/** Banner shown on the right side of the card (can be undefined). */
|
||||
protected abstract getBanner():
|
||||
| TemplateResult
|
||||
| HTMLElement
|
||||
| string
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
/**
|
||||
* Provide the main TemplateResult shown in the preview panel.
|
||||
* Called each time the panel opens or the tool data updates.
|
||||
*/
|
||||
protected abstract getPreviewContent(): TemplateResult<1>;
|
||||
|
||||
/** Provide the action controls (right-side buttons) for the panel. */
|
||||
protected getPreviewControls(): TemplateResult<1> | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Open or refresh the preview panel. */
|
||||
private openOrUpdatePreviewPanel() {
|
||||
renderPreviewPanel(
|
||||
this,
|
||||
this.getPreviewContent(),
|
||||
this.getPreviewControls()
|
||||
);
|
||||
}
|
||||
|
||||
protected refreshPreviewPanel() {
|
||||
if (isPreviewPanelOpen(this)) {
|
||||
this.openOrUpdatePreviewPanel();
|
||||
}
|
||||
}
|
||||
|
||||
/** Optionally override to show an error card. Return null if no error. */
|
||||
protected getErrorTemplate(): TemplateResult | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
private readonly onCardClick = (_e: Event) => {
|
||||
this.openOrUpdatePreviewPanel();
|
||||
};
|
||||
|
||||
protected renderCard() {
|
||||
const { title, icon, loading, className } = this.getCardMeta();
|
||||
|
||||
const resolvedIcon = loading
|
||||
? LoadingIcon({
|
||||
size: '20px',
|
||||
})
|
||||
: icon;
|
||||
|
||||
const banner = this.getBanner();
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="affine-embed-linked-doc-block artifact-tool-card ${className ??
|
||||
''} horizontal"
|
||||
@click=${this.onCardClick}
|
||||
>
|
||||
<div class="affine-embed-linked-doc-content">
|
||||
<div class="affine-embed-linked-doc-content-title">
|
||||
<div class="affine-embed-linked-doc-content-title-icon">
|
||||
${resolvedIcon}
|
||||
</div>
|
||||
<div class="affine-embed-linked-doc-content-title-text">
|
||||
${title}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${banner
|
||||
? html`<div class="affine-embed-linked-doc-banner">${banner}</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
override render() {
|
||||
const err = this.getErrorTemplate();
|
||||
if (err) {
|
||||
return err;
|
||||
}
|
||||
return this.renderCard();
|
||||
}
|
||||
|
||||
override updated(changed: PropertyValues<this>) {
|
||||
super.updated(changed);
|
||||
if (changed.has('data') && isPreviewPanelOpen(this)) {
|
||||
this.openOrUpdatePreviewPanel();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { IconButton } from '@affine/component';
|
||||
import { IconButton, notify } 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 { getAttachmentFileIconRC } from '@blocksuite/affine/components/icons';
|
||||
import { type RichText, selectTextModel } from '@blocksuite/affine/rich-text';
|
||||
import { ViewportElementExtension } from '@blocksuite/affine/shared/services';
|
||||
import { openFilesWith } from '@blocksuite/affine/shared/utils';
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
} from '@blocksuite/icons/rc';
|
||||
import type { TextSelection } from '@blocksuite/std';
|
||||
import { useFramework, useService } from '@toeverything/infra';
|
||||
import bytes from 'bytes';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
forwardRef,
|
||||
@@ -30,7 +32,7 @@ import { useAsyncCallback } from '../../hooks/affine-async-hooks';
|
||||
import { getCommentEditorViewManager } from './specs';
|
||||
import * as styles from './style.css';
|
||||
|
||||
const MAX_IMAGE_COUNT = 10;
|
||||
const MAX_ATTACHMENT_COUNT = 10;
|
||||
const logger = new DebugLogger('CommentEditor');
|
||||
|
||||
const usePatchSpecs = (readonly: boolean) => {
|
||||
@@ -78,6 +80,16 @@ export interface CommentEditorRef {
|
||||
focus: () => void;
|
||||
}
|
||||
|
||||
const download = (url: string, name: string) => {
|
||||
const element = document.createElement('a');
|
||||
element.setAttribute('download', name);
|
||||
element.setAttribute('href', url);
|
||||
element.style.display = 'none';
|
||||
document.body.append(element);
|
||||
element.click();
|
||||
element.remove();
|
||||
};
|
||||
|
||||
// todo: get rid of circular data changes
|
||||
const useSnapshotDoc = (
|
||||
defaultSnapshotOrDoc: DocSnapshot | Store,
|
||||
@@ -109,6 +121,75 @@ const useSnapshotDoc = (
|
||||
return doc;
|
||||
};
|
||||
|
||||
const isImageAttachment = (att: EditorAttachment) => {
|
||||
const type = att.mimeType || att.file?.type || '';
|
||||
if (type) return type.startsWith('image/');
|
||||
return !!att.url && /\.(png|jpe?g|gif|webp|svg)$/i.test(att.url);
|
||||
};
|
||||
|
||||
const AttachmentPreviewItem: React.FC<{
|
||||
attachment: EditorAttachment;
|
||||
index: number;
|
||||
readonly?: boolean;
|
||||
handleAttachmentClick: (e: React.MouseEvent, index: number) => void;
|
||||
handleAttachmentRemove: (id: string) => void;
|
||||
}> = ({
|
||||
attachment,
|
||||
index,
|
||||
readonly,
|
||||
handleAttachmentClick,
|
||||
handleAttachmentRemove,
|
||||
}) => {
|
||||
const isImg = isImageAttachment(attachment);
|
||||
const Icon = !isImg
|
||||
? getAttachmentFileIconRC(
|
||||
attachment.mimeType ||
|
||||
attachment.file?.type ||
|
||||
attachment.filename?.split('.').pop() ||
|
||||
'none'
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={attachment.id}
|
||||
className={isImg ? styles.previewBox : styles.filePreviewBox}
|
||||
style={{
|
||||
backgroundImage: isImg
|
||||
? `url(${attachment.localUrl ?? attachment.url})`
|
||||
: undefined,
|
||||
}}
|
||||
onClick={e => handleAttachmentClick(e, index)}
|
||||
>
|
||||
{!isImg && Icon && <Icon className={styles.fileIcon} />}
|
||||
{!isImg && (
|
||||
<div className={styles.fileInfo}>
|
||||
<span className={styles.fileName}>
|
||||
{attachment.filename || attachment.file?.name || 'File'}
|
||||
</span>
|
||||
<span className={styles.fileSize}>
|
||||
{attachment.size ? bytes(attachment.size) : ''}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!readonly && (
|
||||
<IconButton
|
||||
size={12}
|
||||
className={styles.attachmentButton}
|
||||
loading={attachment.status === 'uploading'}
|
||||
variant="danger"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
handleAttachmentRemove(attachment.id);
|
||||
}}
|
||||
icon={<CloseIcon />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CommentEditor = forwardRef<CommentEditorRef, CommentEditorProps>(
|
||||
function CommentEditor(
|
||||
{
|
||||
@@ -143,25 +224,28 @@ export const CommentEditor = forwardRef<CommentEditorRef, CommentEditorProps>(
|
||||
[attachments, onAttachmentsChange]
|
||||
);
|
||||
|
||||
const isImageUploadDisabled = (attachments?.length ?? 0) >= MAX_IMAGE_COUNT;
|
||||
const isUploadDisabled = (attachments?.length ?? 0) >= MAX_ATTACHMENT_COUNT;
|
||||
const uploadingAttachments = attachments?.some(
|
||||
att => att.status === 'uploading'
|
||||
);
|
||||
const commitDisabled =
|
||||
(empty && (attachments?.length ?? 0) === 0) || uploadingAttachments;
|
||||
|
||||
const addImages = useAsyncCallback(
|
||||
const addAttachments = useAsyncCallback(
|
||||
async (files: File[]) => {
|
||||
if (!uploadCommentAttachment) return;
|
||||
const valid = files.filter(f => f.type.startsWith('image/'));
|
||||
const remaining = MAX_ATTACHMENT_COUNT - (attachments?.length ?? 0);
|
||||
const valid = files.slice(0, remaining);
|
||||
if (!valid.length) return;
|
||||
logger.info('addImages', { files: valid });
|
||||
logger.info('addAttachments', { files: valid });
|
||||
|
||||
const pendingAttachments: EditorAttachment[] = valid.map(f => ({
|
||||
id: nanoid(),
|
||||
file: f,
|
||||
localUrl: URL.createObjectURL(f),
|
||||
status: 'uploading',
|
||||
filename: f.name,
|
||||
mimeType: f.type,
|
||||
}));
|
||||
|
||||
setAttachments(prev => [...prev, ...pendingAttachments]);
|
||||
@@ -189,8 +273,12 @@ export const CommentEditor = forwardRef<CommentEditorRef, CommentEditorProps>(
|
||||
};
|
||||
return next;
|
||||
});
|
||||
} catch (e) {
|
||||
} catch (e: any) {
|
||||
logger.error('uploadCommentAttachment failed', { error: e });
|
||||
notify.error({
|
||||
title: 'Failed to upload attachment',
|
||||
message: e.message,
|
||||
});
|
||||
pending.localUrl && URL.revokeObjectURL(pending.localUrl);
|
||||
setAttachments(prev => {
|
||||
const index = prev.findIndex(att => att.id === pending.id);
|
||||
@@ -202,38 +290,38 @@ export const CommentEditor = forwardRef<CommentEditorRef, CommentEditorProps>(
|
||||
}
|
||||
}
|
||||
},
|
||||
[setAttachments, uploadCommentAttachment]
|
||||
[attachments?.length, setAttachments, uploadCommentAttachment]
|
||||
);
|
||||
|
||||
const handlePasteImage = useCallback(
|
||||
const handlePaste = 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) {
|
||||
if (item.kind === 'file') {
|
||||
const blob = item.getAsFile();
|
||||
if (blob) files.push(blob);
|
||||
}
|
||||
}
|
||||
if (files.length) {
|
||||
event.preventDefault();
|
||||
addImages(files);
|
||||
addAttachments(files);
|
||||
}
|
||||
},
|
||||
[addImages]
|
||||
[addAttachments]
|
||||
);
|
||||
|
||||
const uploadImageFiles = useAsyncCallback(async () => {
|
||||
if (isImageUploadDisabled) return;
|
||||
const files = await openFilesWith('Images');
|
||||
const openFilePicker = useAsyncCallback(async () => {
|
||||
if (isUploadDisabled) return;
|
||||
const files = await openFilesWith('Any');
|
||||
if (files) {
|
||||
addImages(files);
|
||||
addAttachments(files);
|
||||
}
|
||||
}, [isImageUploadDisabled, addImages]);
|
||||
}, [isUploadDisabled, addAttachments]);
|
||||
|
||||
const handleImageRemove = useCallback(
|
||||
const handleAttachmentRemove = useCallback(
|
||||
(id: string) => {
|
||||
setAttachments(prev => {
|
||||
const att = prev.find(att => att.id === id);
|
||||
@@ -247,10 +335,7 @@ export const CommentEditor = forwardRef<CommentEditorRef, CommentEditorProps>(
|
||||
const handleImagePreview = useCallback(
|
||||
(index: number) => {
|
||||
if (!attachments) return;
|
||||
|
||||
const imageAttachments = attachments.filter(
|
||||
att => att.url || att.localUrl
|
||||
);
|
||||
const imageAttachments = attachments.filter(isImageAttachment);
|
||||
|
||||
if (index >= imageAttachments.length) return;
|
||||
|
||||
@@ -291,12 +376,31 @@ export const CommentEditor = forwardRef<CommentEditorRef, CommentEditorProps>(
|
||||
[attachments, peekViewService]
|
||||
);
|
||||
|
||||
const handleImageClick = useCallback(
|
||||
const handleAttachmentClick = useCallback(
|
||||
(e: React.MouseEvent, index: number) => {
|
||||
e.stopPropagation();
|
||||
handleImagePreview(index);
|
||||
if (!attachments) return;
|
||||
const att = attachments[index];
|
||||
if (!att) return;
|
||||
const url = att.url || att.localUrl;
|
||||
if (!url) return;
|
||||
if (isImageAttachment(att)) {
|
||||
// translate attachment index to image index
|
||||
const imageAttachments = attachments.filter(isImageAttachment);
|
||||
const imageIndex = imageAttachments.findIndex(i => i.id === att.id);
|
||||
if (imageIndex >= 0) {
|
||||
handleImagePreview(imageIndex);
|
||||
}
|
||||
} else if (att.url || att.localUrl) {
|
||||
// todo: open attachment preview. for now, just download it
|
||||
download(url, att.filename ?? att.file?.name ?? 'attachment');
|
||||
notify({
|
||||
title: 'Downloading attachment',
|
||||
message: 'The attachment is being downloaded to your computer.',
|
||||
});
|
||||
}
|
||||
},
|
||||
[handleImagePreview]
|
||||
[attachments, handleImagePreview]
|
||||
);
|
||||
|
||||
// upload attachments and call original onCommit
|
||||
@@ -433,38 +537,24 @@ export const CommentEditor = forwardRef<CommentEditorRef, CommentEditorProps>(
|
||||
<div
|
||||
onClick={readonly ? undefined : handleClickEditor}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePasteImage}
|
||||
onPaste={handlePaste}
|
||||
data-readonly={!!readonly}
|
||||
className={clsx(styles.container, 'comment-editor-viewport')}
|
||||
>
|
||||
{attachments?.length && attachments.length > 0 ? (
|
||||
<div
|
||||
className={styles.previewRow}
|
||||
data-testid="comment-image-preview"
|
||||
data-testid="comment-attachment-preview"
|
||||
>
|
||||
{attachments.map((att, index) => (
|
||||
<div
|
||||
<AttachmentPreviewItem
|
||||
key={att.id}
|
||||
className={styles.previewBox}
|
||||
style={{
|
||||
backgroundImage: `url(${att.localUrl ?? att.url})`,
|
||||
}}
|
||||
onClick={e => handleImageClick(e, index)}
|
||||
>
|
||||
{!readonly && (
|
||||
<IconButton
|
||||
size={12}
|
||||
className={styles.attachmentButton}
|
||||
loading={att.status === 'uploading'}
|
||||
variant="danger"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
handleImageRemove(att.id);
|
||||
}}
|
||||
icon={<CloseIcon />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
attachment={att}
|
||||
index={index}
|
||||
readonly={readonly}
|
||||
handleAttachmentClick={handleAttachmentClick}
|
||||
handleAttachmentRemove={handleAttachmentRemove}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -476,8 +566,8 @@ export const CommentEditor = forwardRef<CommentEditorRef, CommentEditorProps>(
|
||||
<div className={styles.footer}>
|
||||
<IconButton
|
||||
icon={<AttachmentIcon />}
|
||||
onClick={uploadImageFiles}
|
||||
aria-disabled={isImageUploadDisabled}
|
||||
onClick={openFilePicker}
|
||||
disabled={isUploadDisabled}
|
||||
/>
|
||||
<button
|
||||
onClick={handleCommit}
|
||||
|
||||
@@ -106,3 +106,83 @@ export const attachmentButton = style({
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// New generic file preview box (non-image attachments)
|
||||
export const filePreviewBox = style({
|
||||
position: 'relative',
|
||||
width: 194,
|
||||
height: 62,
|
||||
borderRadius: 4,
|
||||
flex: '0 0 auto',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '0 4px',
|
||||
background: cssVarV2('layer/background/secondary'),
|
||||
border: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
|
||||
cursor: 'pointer',
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
opacity: 0.8,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const fileIcon = style({
|
||||
height: 36,
|
||||
width: 'auto',
|
||||
});
|
||||
|
||||
export const fileInfo = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
export const fileName = style({
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
fontSize: 14,
|
||||
flex: '1 1 auto',
|
||||
fontWeight: 600,
|
||||
});
|
||||
|
||||
export const fileSize = style({
|
||||
fontSize: 12,
|
||||
color: cssVarV2('text/secondary'),
|
||||
});
|
||||
|
||||
export const deleteBtn = style({
|
||||
position: 'absolute',
|
||||
top: -6,
|
||||
right: -6,
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: '50%',
|
||||
background: cssVarV2('layer/background/primary'),
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
border: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
background: cssVarV2('layer/background/error'),
|
||||
borderColor: cssVarV2('button/error'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const spinnerWrapper = style({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background: cssVarV2('layer/background/tertiary'),
|
||||
borderRadius: 4,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
});
|
||||
|
||||
@@ -470,10 +470,6 @@ const CommentItem = ({
|
||||
const canDelete =
|
||||
(isMyComment && canCreateComment) || (!isMyComment && canDeleteComment);
|
||||
|
||||
const isCommentInEditor = useLiveData(entity.commentsInEditor$).includes(
|
||||
comment.id
|
||||
);
|
||||
|
||||
// invalid comment, should not happen
|
||||
if (!comment.content) {
|
||||
return null;
|
||||
@@ -516,12 +512,7 @@ const CommentItem = ({
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
data-deleted={!isCommentInEditor}
|
||||
className={styles.previewContainer}
|
||||
>
|
||||
{comment.content?.preview}
|
||||
</div>
|
||||
<div className={styles.previewContainer}>{comment.content?.preview}</div>
|
||||
|
||||
<div className={styles.repliesContainer}>
|
||||
{isEditing && editingDoc ? (
|
||||
|
||||
@@ -351,6 +351,7 @@ export class DocCommentEntity extends Entity<{
|
||||
url,
|
||||
filename: file.name,
|
||||
mimeType: file.type,
|
||||
size: file.size,
|
||||
});
|
||||
|
||||
if (isPendingComment) {
|
||||
|
||||
@@ -13,6 +13,7 @@ export type CommentAttachment = {
|
||||
url?: string; // attachment may not be uploaded yet
|
||||
filename?: string;
|
||||
mimeType?: string;
|
||||
size?: number; // in bytes
|
||||
};
|
||||
|
||||
export interface BaseComment {
|
||||
|
||||
Reference in New Issue
Block a user