mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-06 03:49:56 +08: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 { 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 type { CommentAttachment } from '@affine/core/modules/comment/types';
|
||||||
import { PeekViewService } from '@affine/core/modules/peek-view';
|
import { PeekViewService } from '@affine/core/modules/peek-view';
|
||||||
import { DebugLogger } from '@affine/debug';
|
import { DebugLogger } from '@affine/debug';
|
||||||
|
import { getAttachmentFileIconRC } from '@blocksuite/affine/components/icons';
|
||||||
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 { openFilesWith } from '@blocksuite/affine/shared/utils';
|
import { openFilesWith } from '@blocksuite/affine/shared/utils';
|
||||||
@@ -15,6 +16,7 @@ import {
|
|||||||
} from '@blocksuite/icons/rc';
|
} 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 bytes from 'bytes';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import {
|
import {
|
||||||
forwardRef,
|
forwardRef,
|
||||||
@@ -30,7 +32,7 @@ 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 MAX_ATTACHMENT_COUNT = 10;
|
||||||
const logger = new DebugLogger('CommentEditor');
|
const logger = new DebugLogger('CommentEditor');
|
||||||
|
|
||||||
const usePatchSpecs = (readonly: boolean) => {
|
const usePatchSpecs = (readonly: boolean) => {
|
||||||
@@ -78,6 +80,16 @@ export interface CommentEditorRef {
|
|||||||
focus: () => void;
|
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
|
// todo: get rid of circular data changes
|
||||||
const useSnapshotDoc = (
|
const useSnapshotDoc = (
|
||||||
defaultSnapshotOrDoc: DocSnapshot | Store,
|
defaultSnapshotOrDoc: DocSnapshot | Store,
|
||||||
@@ -109,6 +121,75 @@ const useSnapshotDoc = (
|
|||||||
return doc;
|
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>(
|
export const CommentEditor = forwardRef<CommentEditorRef, CommentEditorProps>(
|
||||||
function CommentEditor(
|
function CommentEditor(
|
||||||
{
|
{
|
||||||
@@ -143,25 +224,28 @@ export const CommentEditor = forwardRef<CommentEditorRef, CommentEditorProps>(
|
|||||||
[attachments, onAttachmentsChange]
|
[attachments, onAttachmentsChange]
|
||||||
);
|
);
|
||||||
|
|
||||||
const isImageUploadDisabled = (attachments?.length ?? 0) >= MAX_IMAGE_COUNT;
|
const isUploadDisabled = (attachments?.length ?? 0) >= MAX_ATTACHMENT_COUNT;
|
||||||
const uploadingAttachments = attachments?.some(
|
const uploadingAttachments = attachments?.some(
|
||||||
att => att.status === 'uploading'
|
att => att.status === 'uploading'
|
||||||
);
|
);
|
||||||
const commitDisabled =
|
const commitDisabled =
|
||||||
(empty && (attachments?.length ?? 0) === 0) || uploadingAttachments;
|
(empty && (attachments?.length ?? 0) === 0) || uploadingAttachments;
|
||||||
|
|
||||||
const addImages = useAsyncCallback(
|
const addAttachments = useAsyncCallback(
|
||||||
async (files: File[]) => {
|
async (files: File[]) => {
|
||||||
if (!uploadCommentAttachment) return;
|
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;
|
if (!valid.length) return;
|
||||||
logger.info('addImages', { files: valid });
|
logger.info('addAttachments', { files: valid });
|
||||||
|
|
||||||
const pendingAttachments: EditorAttachment[] = valid.map(f => ({
|
const pendingAttachments: EditorAttachment[] = valid.map(f => ({
|
||||||
id: nanoid(),
|
id: nanoid(),
|
||||||
file: f,
|
file: f,
|
||||||
localUrl: URL.createObjectURL(f),
|
localUrl: URL.createObjectURL(f),
|
||||||
status: 'uploading',
|
status: 'uploading',
|
||||||
|
filename: f.name,
|
||||||
|
mimeType: f.type,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
setAttachments(prev => [...prev, ...pendingAttachments]);
|
setAttachments(prev => [...prev, ...pendingAttachments]);
|
||||||
@@ -189,8 +273,12 @@ export const CommentEditor = forwardRef<CommentEditorRef, CommentEditorProps>(
|
|||||||
};
|
};
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
logger.error('uploadCommentAttachment failed', { error: e });
|
logger.error('uploadCommentAttachment failed', { error: e });
|
||||||
|
notify.error({
|
||||||
|
title: 'Failed to upload attachment',
|
||||||
|
message: e.message,
|
||||||
|
});
|
||||||
pending.localUrl && URL.revokeObjectURL(pending.localUrl);
|
pending.localUrl && URL.revokeObjectURL(pending.localUrl);
|
||||||
setAttachments(prev => {
|
setAttachments(prev => {
|
||||||
const index = prev.findIndex(att => att.id === pending.id);
|
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) => {
|
(event: React.ClipboardEvent) => {
|
||||||
const items = event.clipboardData?.items;
|
const items = event.clipboardData?.items;
|
||||||
if (!items) return;
|
if (!items) return;
|
||||||
const files: File[] = [];
|
const files: File[] = [];
|
||||||
for (const index in items) {
|
for (const index in items) {
|
||||||
const item = items[index as any];
|
const item = items[index as any];
|
||||||
if (item.kind === 'file' && item.type.indexOf('image') >= 0) {
|
if (item.kind === 'file') {
|
||||||
const blob = item.getAsFile();
|
const blob = item.getAsFile();
|
||||||
if (blob) files.push(blob);
|
if (blob) files.push(blob);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (files.length) {
|
if (files.length) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
addImages(files);
|
addAttachments(files);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[addImages]
|
[addAttachments]
|
||||||
);
|
);
|
||||||
|
|
||||||
const uploadImageFiles = useAsyncCallback(async () => {
|
const openFilePicker = useAsyncCallback(async () => {
|
||||||
if (isImageUploadDisabled) return;
|
if (isUploadDisabled) return;
|
||||||
const files = await openFilesWith('Images');
|
const files = await openFilesWith('Any');
|
||||||
if (files) {
|
if (files) {
|
||||||
addImages(files);
|
addAttachments(files);
|
||||||
}
|
}
|
||||||
}, [isImageUploadDisabled, addImages]);
|
}, [isUploadDisabled, addAttachments]);
|
||||||
|
|
||||||
const handleImageRemove = useCallback(
|
const handleAttachmentRemove = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
setAttachments(prev => {
|
setAttachments(prev => {
|
||||||
const att = prev.find(att => att.id === id);
|
const att = prev.find(att => att.id === id);
|
||||||
@@ -247,10 +335,7 @@ export const CommentEditor = forwardRef<CommentEditorRef, CommentEditorProps>(
|
|||||||
const handleImagePreview = useCallback(
|
const handleImagePreview = useCallback(
|
||||||
(index: number) => {
|
(index: number) => {
|
||||||
if (!attachments) return;
|
if (!attachments) return;
|
||||||
|
const imageAttachments = attachments.filter(isImageAttachment);
|
||||||
const imageAttachments = attachments.filter(
|
|
||||||
att => att.url || att.localUrl
|
|
||||||
);
|
|
||||||
|
|
||||||
if (index >= imageAttachments.length) return;
|
if (index >= imageAttachments.length) return;
|
||||||
|
|
||||||
@@ -291,12 +376,31 @@ export const CommentEditor = forwardRef<CommentEditorRef, CommentEditorProps>(
|
|||||||
[attachments, peekViewService]
|
[attachments, peekViewService]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleImageClick = useCallback(
|
const handleAttachmentClick = useCallback(
|
||||||
(e: React.MouseEvent, index: number) => {
|
(e: React.MouseEvent, index: number) => {
|
||||||
e.stopPropagation();
|
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
|
// upload attachments and call original onCommit
|
||||||
@@ -433,38 +537,24 @@ export const CommentEditor = forwardRef<CommentEditorRef, CommentEditorProps>(
|
|||||||
<div
|
<div
|
||||||
onClick={readonly ? undefined : handleClickEditor}
|
onClick={readonly ? undefined : handleClickEditor}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onPaste={handlePasteImage}
|
onPaste={handlePaste}
|
||||||
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 ? (
|
{attachments?.length && attachments.length > 0 ? (
|
||||||
<div
|
<div
|
||||||
className={styles.previewRow}
|
className={styles.previewRow}
|
||||||
data-testid="comment-image-preview"
|
data-testid="comment-attachment-preview"
|
||||||
>
|
>
|
||||||
{attachments.map((att, index) => (
|
{attachments.map((att, index) => (
|
||||||
<div
|
<AttachmentPreviewItem
|
||||||
key={att.id}
|
key={att.id}
|
||||||
className={styles.previewBox}
|
attachment={att}
|
||||||
style={{
|
index={index}
|
||||||
backgroundImage: `url(${att.localUrl ?? att.url})`,
|
readonly={readonly}
|
||||||
}}
|
handleAttachmentClick={handleAttachmentClick}
|
||||||
onClick={e => handleImageClick(e, index)}
|
handleAttachmentRemove={handleAttachmentRemove}
|
||||||
>
|
/>
|
||||||
{!readonly && (
|
|
||||||
<IconButton
|
|
||||||
size={12}
|
|
||||||
className={styles.attachmentButton}
|
|
||||||
loading={att.status === 'uploading'}
|
|
||||||
variant="danger"
|
|
||||||
onClick={e => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleImageRemove(att.id);
|
|
||||||
}}
|
|
||||||
icon={<CloseIcon />}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -476,8 +566,8 @@ export const CommentEditor = forwardRef<CommentEditorRef, CommentEditorProps>(
|
|||||||
<div className={styles.footer}>
|
<div className={styles.footer}>
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={<AttachmentIcon />}
|
icon={<AttachmentIcon />}
|
||||||
onClick={uploadImageFiles}
|
onClick={openFilePicker}
|
||||||
aria-disabled={isImageUploadDisabled}
|
disabled={isUploadDisabled}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={handleCommit}
|
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 =
|
const canDelete =
|
||||||
(isMyComment && canCreateComment) || (!isMyComment && canDeleteComment);
|
(isMyComment && canCreateComment) || (!isMyComment && canDeleteComment);
|
||||||
|
|
||||||
const isCommentInEditor = useLiveData(entity.commentsInEditor$).includes(
|
|
||||||
comment.id
|
|
||||||
);
|
|
||||||
|
|
||||||
// invalid comment, should not happen
|
// invalid comment, should not happen
|
||||||
if (!comment.content) {
|
if (!comment.content) {
|
||||||
return null;
|
return null;
|
||||||
@@ -516,12 +512,7 @@ const CommentItem = ({
|
|||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className={styles.previewContainer}>{comment.content?.preview}</div>
|
||||||
data-deleted={!isCommentInEditor}
|
|
||||||
className={styles.previewContainer}
|
|
||||||
>
|
|
||||||
{comment.content?.preview}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.repliesContainer}>
|
<div className={styles.repliesContainer}>
|
||||||
{isEditing && editingDoc ? (
|
{isEditing && editingDoc ? (
|
||||||
|
|||||||
@@ -351,6 +351,7 @@ export class DocCommentEntity extends Entity<{
|
|||||||
url,
|
url,
|
||||||
filename: file.name,
|
filename: file.name,
|
||||||
mimeType: file.type,
|
mimeType: file.type,
|
||||||
|
size: file.size,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isPendingComment) {
|
if (isPendingComment) {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export type CommentAttachment = {
|
|||||||
url?: string; // attachment may not be uploaded yet
|
url?: string; // attachment may not be uploaded yet
|
||||||
filename?: string;
|
filename?: string;
|
||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
|
size?: number; // in bytes
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface BaseComment {
|
export interface BaseComment {
|
||||||
|
|||||||
Reference in New Issue
Block a user