feat(core): support normal attachments (#13112)

fix AF-2722


![image](https://github.com/user-attachments/assets/376a0119-ae8e-4cb4-a31c-2eb6bb56c868)


#### 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:
Peng Xiao
2025-07-09 19:22:04 +08:00
committed by GitHub
parent f839e5c136
commit d4c905600b
6 changed files with 382 additions and 60 deletions

View File

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

View File

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

View File

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

View File

@@ -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 ? (

View File

@@ -351,6 +351,7 @@ export class DocCommentEntity extends Entity<{
url,
filename: file.name,
mimeType: file.type,
size: file.size,
});
if (isPendingComment) {

View File

@@ -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 {