mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-22 19:53:48 +08:00
feat(editor): add replace action in attachment toolbar (#12512)
Closes: [BS-3549](https://linear.app/affine-design/issue/BS-3549/附件-toolbar-上添加-replace-action) [Screen Recording 2025-06-04 at 15.37.40.mov <span class="graphite__hidden">(uploaded via Graphite)</span> <img class="graphite__hidden" src="https://app.graphite.dev/api/v1/graphite/video/thumbnail/8ypiIKZXudF5a0tIgIzf/480c8690-7ec9-4188-92fd-ee3339afb558.mov" />](https://app.graphite.dev/media/video/8ypiIKZXudF5a0tIgIzf/480c8690-7ec9-4188-92fd-ee3339afb558.mov) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Added the ability to replace attachments directly from the toolbar, allowing users to select and update files seamlessly. - **Bug Fixes** - Improved handling when replacing embedded attachments with unsupported file types, ensuring the view falls back to a card view as needed. - **Tests** - Introduced end-to-end tests to verify attachment replacement and correct UI behavior in both standard and edgeless editing modes. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -22,7 +22,10 @@ import {
|
||||
FileSizeLimitProvider,
|
||||
TelemetryProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { formatSize } from '@blocksuite/affine-shared/utils';
|
||||
import {
|
||||
formatSize,
|
||||
openSingleFileWith,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import {
|
||||
AttachmentIcon,
|
||||
ResetIcon,
|
||||
@@ -31,7 +34,7 @@ import {
|
||||
} from '@blocksuite/icons/lit';
|
||||
import { BlockSelection } from '@blocksuite/std';
|
||||
import { nanoid, Slice } from '@blocksuite/store';
|
||||
import { computed, signal } from '@preact/signals-core';
|
||||
import { batch, computed, signal } from '@preact/signals-core';
|
||||
import { html, type TemplateResult } from 'lit';
|
||||
import { choose } from 'lit/directives/choose.js';
|
||||
import { type ClassInfo, classMap } from 'lit/directives/class-map.js';
|
||||
@@ -42,7 +45,7 @@ import { filter } from 'rxjs/operators';
|
||||
|
||||
import { AttachmentEmbedProvider } from './embed';
|
||||
import { styles } from './styles';
|
||||
import { downloadAttachmentBlob, refreshData } from './utils';
|
||||
import { downloadAttachmentBlob, getFileType, refreshData } from './utils';
|
||||
|
||||
type AttachmentResolvedStateInfo = ResolvedStateInfo & {
|
||||
kind?: TemplateResult;
|
||||
@@ -129,12 +132,50 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent<Attachment
|
||||
|
||||
// Refreshes the embed component.
|
||||
reload = () => {
|
||||
if (this.model.props.embed) {
|
||||
this._refreshKey$.value = nanoid();
|
||||
return;
|
||||
}
|
||||
batch(() => {
|
||||
if (this.model.props.embed$.value) {
|
||||
this._refreshKey$.value = nanoid();
|
||||
return;
|
||||
}
|
||||
|
||||
this.refreshData();
|
||||
this.refreshData();
|
||||
});
|
||||
};
|
||||
|
||||
// Replaces the current attachment.
|
||||
replace = async () => {
|
||||
const state = this.resourceController.state$.peek();
|
||||
if (state.uploading) return;
|
||||
|
||||
const file = await openSingleFileWith();
|
||||
if (!file) return;
|
||||
|
||||
const sourceId = await this.std.store.blobSync.set(file);
|
||||
const type = await getFileType(file);
|
||||
const { name, size } = file;
|
||||
|
||||
let embed = this.model.props.embed$.value ?? false;
|
||||
|
||||
this.std.store.captureSync();
|
||||
this.std.store.transact(() => {
|
||||
this.std.store.updateBlock(this.blockId, {
|
||||
name,
|
||||
size,
|
||||
type,
|
||||
sourceId,
|
||||
embed: false,
|
||||
});
|
||||
|
||||
const provider = this.std.get(AttachmentEmbedProvider);
|
||||
embed &&= provider.embedded(this.model);
|
||||
|
||||
if (embed) {
|
||||
provider.convertTo(this.model);
|
||||
}
|
||||
|
||||
// Reloads
|
||||
this.reload();
|
||||
});
|
||||
};
|
||||
|
||||
private _selectBlock() {
|
||||
@@ -403,7 +444,7 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent<Attachment
|
||||
|
||||
protected renderEmbedView = () => {
|
||||
const { model, blobUrl } = this;
|
||||
if (!model.props.embed || !blobUrl) return null;
|
||||
if (!model.props.embed$.value || !blobUrl) return null;
|
||||
|
||||
const { std, _maxFileSize } = this;
|
||||
const provider = std.get(AttachmentEmbedProvider);
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
DownloadIcon,
|
||||
DuplicateIcon,
|
||||
EditIcon,
|
||||
ReplaceIcon,
|
||||
ResetIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import { BlockFlavourIdentifier } from '@blocksuite/std';
|
||||
@@ -139,27 +140,42 @@ export const attachmentViewDropdownMenu = {
|
||||
});
|
||||
};
|
||||
|
||||
return html`${keyed(
|
||||
model,
|
||||
html`<affine-view-dropdown-menu
|
||||
@toggle=${onToggle}
|
||||
.actions=${actions.value}
|
||||
.context=${ctx}
|
||||
.viewType$=${viewType$}
|
||||
></affine-view-dropdown-menu>`
|
||||
)}`;
|
||||
return html`<affine-view-dropdown-menu
|
||||
@toggle=${onToggle}
|
||||
.actions=${actions.value}
|
||||
.context=${ctx}
|
||||
.viewType$=${viewType$}
|
||||
></affine-view-dropdown-menu>`;
|
||||
},
|
||||
} as const satisfies ToolbarActionGroup<ToolbarAction>;
|
||||
|
||||
const replaceAction = {
|
||||
id: 'c.replace',
|
||||
tooltip: 'Replace attachment',
|
||||
icon: ReplaceIcon(),
|
||||
disabled(ctx) {
|
||||
const block = ctx.getCurrentBlockByType(AttachmentBlockComponent);
|
||||
if (!block) return true;
|
||||
|
||||
const { downloading = false, uploading = false } =
|
||||
block.resourceController.state$.value;
|
||||
return downloading || uploading;
|
||||
},
|
||||
run(ctx) {
|
||||
const block = ctx.getCurrentBlockByType(AttachmentBlockComponent);
|
||||
block?.replace().catch(console.error);
|
||||
},
|
||||
} as const satisfies ToolbarAction;
|
||||
|
||||
const downloadAction = {
|
||||
id: 'c.download',
|
||||
id: 'd.download',
|
||||
tooltip: 'Download',
|
||||
icon: DownloadIcon(),
|
||||
run(ctx) {
|
||||
const block = ctx.getCurrentBlockByType(AttachmentBlockComponent);
|
||||
block?.download();
|
||||
},
|
||||
when: ctx => {
|
||||
when(ctx) {
|
||||
const model = ctx.getCurrentModelByType(AttachmentBlockModel);
|
||||
if (!model) return false;
|
||||
// Current citation attachment block does not support download
|
||||
@@ -168,7 +184,7 @@ const downloadAction = {
|
||||
} as const satisfies ToolbarAction;
|
||||
|
||||
const captionAction = {
|
||||
id: 'd.caption',
|
||||
id: 'e.caption',
|
||||
tooltip: 'Caption',
|
||||
icon: CaptionIcon(),
|
||||
run(ctx) {
|
||||
@@ -221,6 +237,7 @@ const builtinToolbarConfig = {
|
||||
},
|
||||
},
|
||||
attachmentViewDropdownMenu,
|
||||
replaceAction,
|
||||
downloadAction,
|
||||
captionAction,
|
||||
{
|
||||
@@ -354,13 +371,17 @@ const builtinSurfaceToolbarConfig = {
|
||||
)}`;
|
||||
},
|
||||
} satisfies ToolbarActionGroup<ToolbarAction>,
|
||||
{
|
||||
...replaceAction,
|
||||
id: 'd.replace',
|
||||
},
|
||||
{
|
||||
...downloadAction,
|
||||
id: 'd.download',
|
||||
id: 'e.download',
|
||||
},
|
||||
{
|
||||
...captionAction,
|
||||
id: 'e.caption',
|
||||
id: 'f.caption',
|
||||
},
|
||||
],
|
||||
when: ctx => ctx.getSurfaceModelsByType(AttachmentBlockModel).length === 1,
|
||||
|
||||
Reference in New Issue
Block a user