From 8726b0e462ccd3ff3b6f8bc213700d795cd95da2 Mon Sep 17 00:00:00 2001 From: fundon Date: Sun, 18 May 2025 01:57:42 +0000 Subject: [PATCH] refactor(editor): optimize pasting process of attachments and images (#12276) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related to: [BS-3146](https://linear.app/affine-design/issue/BS-3146/import-paste-接口改进优化) --- .../blocks/attachment/src/attachment-block.ts | 6 +- .../image/src/components/page-image-block.ts | 37 ++---- .../affine/blocks/image/src/image-block.ts | 14 ++- .../blocks/image/src/image-edgeless-block.ts | 6 +- .../root/src/clipboard/page-clipboard.ts | 4 + .../components/src/resource/resource.ts | 1 + .../affine/shared/src/adapters/attachment.ts | 47 +++++--- .../affine/shared/src/adapters/image.ts | 45 ++++--- .../shared/src/adapters/middlewares/index.ts | 1 + .../src/adapters/middlewares/replace-id.ts | 12 +- .../shared/src/adapters/middlewares/upload.ts | 114 ++++++++++++++++++ .../framework/store/src/transformer/assets.ts | 11 ++ .../store/src/transformer/transformer.ts | 7 +- tests/blocksuite/e2e/utils/actions/misc.ts | 11 +- 14 files changed, 240 insertions(+), 76 deletions(-) create mode 100644 blocksuite/affine/shared/src/adapters/middlewares/upload.ts diff --git a/blocksuite/affine/blocks/attachment/src/attachment-block.ts b/blocksuite/affine/blocks/attachment/src/attachment-block.ts index 382a87daa8..910036ec69 100644 --- a/blocksuite/affine/blocks/attachment/src/attachment-block.ts +++ b/blocksuite/affine/blocks/attachment/src/attachment-block.ts @@ -143,7 +143,11 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent { + this.refreshData(); + }) + ); if (!this.model.props.style && !this.store.readonly) { this.store.withoutTransact(() => { diff --git a/blocksuite/affine/blocks/image/src/components/page-image-block.ts b/blocksuite/affine/blocks/image/src/components/page-image-block.ts index 592fe65946..a5d4c2a477 100644 --- a/blocksuite/affine/blocks/image/src/components/page-image-block.ts +++ b/blocksuite/affine/blocks/image/src/components/page-image-block.ts @@ -7,7 +7,7 @@ import { } from '@blocksuite/affine-shared/commands'; import { ImageSelection } from '@blocksuite/affine-shared/selection'; import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; -import { WithDisposable } from '@blocksuite/global/lit'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit'; import type { BlockComponent, UIEventStateContext } from '@blocksuite/std'; import { BlockSelection, @@ -15,8 +15,9 @@ import { TextSelection, } from '@blocksuite/std'; import type { BaseSelection } from '@blocksuite/store'; +import { computed } from '@preact/signals-core'; import { css, html, type PropertyValues } from 'lit'; -import { property, query, state } from 'lit/decorators.js'; +import { property, query } from 'lit/decorators.js'; import { styleMap } from 'lit/directives/style-map.js'; import { when } from 'lit/directives/when.js'; @@ -25,7 +26,9 @@ import { ImageResizeManager } from '../image-resize-manager'; import { shouldResizeImage } from '../utils'; import { ImageSelectedRect } from './image-selected-rect'; -export class ImageBlockPageComponent extends WithDisposable(ShadowlessElement) { +export class ImageBlockPageComponent extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { static override styles = css` affine-page-image { position: relative; @@ -68,6 +71,8 @@ export class ImageBlockPageComponent extends WithDisposable(ShadowlessElement) { } `; + resizeable$ = computed(() => this.block.resizeable$.value); + private _isDragging = false; private get _doc() { @@ -134,21 +139,21 @@ export class ImageBlockPageComponent extends WithDisposable(ShadowlessElement) { return true; }, Delete: ctx => { - if (this._host.store.readonly || !this._isSelected) return; + if (this._host.store.readonly || !this.resizeable$.peek()) return; addParagraph(ctx); this._doc.deleteBlock(this._model); return true; }, Backspace: ctx => { - if (this._host.store.readonly || !this._isSelected) return; + if (this._host.store.readonly || !this.resizeable$.peek()) return; addParagraph(ctx); this._doc.deleteBlock(this._model); return true; }, Enter: ctx => { - if (this._host.store.readonly || !this._isSelected) return; + if (this._host.store.readonly || !this.resizeable$.peek()) return; addParagraph(ctx); return true; @@ -213,19 +218,6 @@ export class ImageBlockPageComponent extends WithDisposable(ShadowlessElement) { private _handleSelection() { const selection = this._host.selection; - this._disposables.add( - selection.slots.changed.subscribe(selList => { - this._isSelected = selList.some( - sel => sel.blockId === this.block.blockId && sel.is(ImageSelection) - ); - }) - ); - - this._disposables.add( - this._model.propsUpdated.subscribe(() => { - this.requestUpdate(); - }) - ); this._disposables.addFromEvent( this.resizeImg, @@ -249,7 +241,7 @@ export class ImageBlockPageComponent extends WithDisposable(ShadowlessElement) { this.block.handleEvent( 'click', () => { - if (!this._isSelected) return; + if (!this.resizeable$.peek()) return; selection.update(selList => selList.filter( @@ -356,7 +348,7 @@ export class ImageBlockPageComponent extends WithDisposable(ShadowlessElement) { override render() { const imageSize = this._normalizeImageSize(); - const imageSelectedRect = this._isSelected + const imageSelectedRect = this.resizeable$.value ? ImageSelectedRect(this._doc.readonly) : null; @@ -389,9 +381,6 @@ export class ImageBlockPageComponent extends WithDisposable(ShadowlessElement) { `; } - @state() - accessor _isSelected = false; - @property({ attribute: false }) accessor block!: ImageBlockComponent; diff --git a/blocksuite/affine/blocks/image/src/image-block.ts b/blocksuite/affine/blocks/image/src/image-block.ts index 53f8bc57b4..de3f98ceeb 100644 --- a/blocksuite/affine/blocks/image/src/image-block.ts +++ b/blocksuite/affine/blocks/image/src/image-block.ts @@ -4,6 +4,7 @@ import { getLoadingIconWith } from '@blocksuite/affine-components/icons'; import { Peekable } from '@blocksuite/affine-components/peek'; import { ResourceController } from '@blocksuite/affine-components/resource'; import type { ImageBlockModel } from '@blocksuite/affine-model'; +import { ImageSelection } from '@blocksuite/affine-shared/selection'; import { ThemeProvider, ToolbarRegistryIdentifier, @@ -30,6 +31,13 @@ import { enableOn: () => !IS_MOBILE, }) export class ImageBlockComponent extends CaptionedBlockComponent { + resizeable$ = computed(() => + this.std.selection.value.some( + selection => + selection.is(ImageSelection) && selection.blockId === this.blockId + ) + ); + resourceController = new ResourceController( computed(() => this.model.props.sourceId$.value), 'Image' @@ -104,7 +112,11 @@ export class ImageBlockComponent extends CaptionedBlockComponent { + this.refreshData(); + }) + ); } override firstUpdated() { diff --git a/blocksuite/affine/blocks/image/src/image-edgeless-block.ts b/blocksuite/affine/blocks/image/src/image-edgeless-block.ts index b7262cf60a..9b99580faf 100644 --- a/blocksuite/affine/blocks/image/src/image-edgeless-block.ts +++ b/blocksuite/affine/blocks/image/src/image-edgeless-block.ts @@ -100,7 +100,11 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent { + this.refreshData(); + }) + ); } override renderGfxBlock() { diff --git a/blocksuite/affine/blocks/root/src/clipboard/page-clipboard.ts b/blocksuite/affine/blocks/root/src/clipboard/page-clipboard.ts index afeecb83ae..6e4c4b3123 100644 --- a/blocksuite/affine/blocks/root/src/clipboard/page-clipboard.ts +++ b/blocksuite/affine/blocks/root/src/clipboard/page-clipboard.ts @@ -3,6 +3,7 @@ import { pasteMiddleware, replaceIdMiddleware, surfaceRefToEmbed, + uploadMiddleware, } from '@blocksuite/affine-shared/adapters'; import { clearAndSelectFirstModelCommand, @@ -34,14 +35,17 @@ export class PageClipboard extends ReadOnlyClipboard { // When pastina a surface-ref block to another doc const surfaceRefToEmbedMiddleware = surfaceRefToEmbed(this.std); const replaceId = replaceIdMiddleware(this.std.store.workspace.idGenerator); + const upload = uploadMiddleware(this.std); this.std.clipboard.use(paste); this.std.clipboard.use(surfaceRefToEmbedMiddleware); this.std.clipboard.use(replaceId); + this.std.clipboard.use(upload); this._disposables.add({ dispose: () => { this.std.clipboard.unuse(paste); this.std.clipboard.unuse(surfaceRefToEmbedMiddleware); this.std.clipboard.unuse(replaceId); + this.std.clipboard.unuse(upload); }, }); }; diff --git a/blocksuite/affine/components/src/resource/resource.ts b/blocksuite/affine/components/src/resource/resource.ts index 463e9ed129..678d5dcaad 100644 --- a/blocksuite/affine/components/src/resource/resource.ts +++ b/blocksuite/affine/components/src/resource/resource.ts @@ -35,6 +35,7 @@ export type ResolvedStateInfo = StateInfo & ResolvedStateInfoPart; export class ResourceController implements Disposable { readonly blobUrl$ = signal(null); + // TODO(@fundon): default `loading` status. readonly state$ = signal>({}); readonly resolvedState$ = computed(() => { diff --git a/blocksuite/affine/shared/src/adapters/attachment.ts b/blocksuite/affine/shared/src/adapters/attachment.ts index 2be93c7b12..31f5a0440d 100644 --- a/blocksuite/affine/shared/src/adapters/attachment.ts +++ b/blocksuite/affine/shared/src/adapters/attachment.ts @@ -1,5 +1,5 @@ +import { AttachmentBlockSchema } from '@blocksuite/affine-model'; import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; -import { sha } from '@blocksuite/global/utils'; import { type AssetsManager, BaseAdapter, @@ -88,40 +88,49 @@ export class AttachmentAdapter extends BaseAdapter { ); } - override async toSliceSnapshot( - payload: AttachmentToSliceSnapshotPayload - ): Promise { + override async toSliceSnapshot({ + assets, + file: files, + pageId, + workspaceId, + }: AttachmentToSliceSnapshotPayload): Promise { + if (files.length === 0) return null; + const content: SliceSnapshot['content'] = []; - for (const item of payload.file) { - const blobId = await sha(await item.arrayBuffer()); - payload.assets?.getAssets().set(blobId, item); - await payload.assets?.writeToBlob(blobId); + const flavour = AttachmentBlockSchema.model.flavour; + + for (const blob of files) { + const id = nanoid(); + const { name, size, type } = blob; + + assets?.uploadingAssetsMap.set(id, { + blob, + mapInto: sourceId => ({ sourceId }), + }); + content.push({ type: 'block', - flavour: 'affine:attachment', - id: nanoid(), + flavour, + id, props: { - name: item.name, - size: item.size, - type: item.type, + name, + size, + type, embed: false, style: 'horizontalThin', index: 'a0', xywh: '[0,0,0,0]', rotate: 0, - sourceId: blobId, }, children: [], }); } - if (content.length === 0) { - return null; - } + return { type: 'slice', content, - workspaceId: payload.workspaceId, - pageId: payload.pageId, + pageId, + workspaceId, }; } } diff --git a/blocksuite/affine/shared/src/adapters/image.ts b/blocksuite/affine/shared/src/adapters/image.ts index 93a6e12028..151488ec0d 100644 --- a/blocksuite/affine/shared/src/adapters/image.ts +++ b/blocksuite/affine/shared/src/adapters/image.ts @@ -1,5 +1,5 @@ +import { ImageBlockSchema } from '@blocksuite/affine-model'; import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; -import { sha } from '@blocksuite/global/utils'; import { type AssetsManager, BaseAdapter, @@ -88,33 +88,40 @@ export class ImageAdapter extends BaseAdapter { ); } - override async toSliceSnapshot( - payload: ImageToSliceSnapshotPayload - ): Promise { + override async toSliceSnapshot({ + assets, + file: files, + pageId, + workspaceId, + }: ImageToSliceSnapshotPayload): Promise { + if (files.length === 0) return null; + const content: SliceSnapshot['content'] = []; - for (const item of payload.file) { - const blobId = await sha(await item.arrayBuffer()); - payload.assets?.getAssets().set(blobId, item); - await payload.assets?.writeToBlob(blobId); + const flavour = ImageBlockSchema.model.flavour; + + for (const blob of files) { + const id = nanoid(); + const { size } = blob; + + assets?.uploadingAssetsMap.set(id, { + blob, + mapInto: sourceId => ({ sourceId }), + }); + content.push({ type: 'block', - flavour: 'affine:image', - id: nanoid(), - props: { - size: item.size, - sourceId: blobId, - }, + flavour, + id, + props: { size }, children: [], }); } - if (content.length === 0) { - return null; - } + return { type: 'slice', content, - workspaceId: payload.workspaceId, - pageId: payload.pageId, + pageId, + workspaceId, }; } } diff --git a/blocksuite/affine/shared/src/adapters/middlewares/index.ts b/blocksuite/affine/shared/src/adapters/middlewares/index.ts index d830b359db..2fa9994e0b 100644 --- a/blocksuite/affine/shared/src/adapters/middlewares/index.ts +++ b/blocksuite/affine/shared/src/adapters/middlewares/index.ts @@ -9,3 +9,4 @@ export * from './proxy'; export * from './replace-id'; export * from './surface-ref-to-embed'; export * from './title'; +export * from './upload'; diff --git a/blocksuite/affine/shared/src/adapters/middlewares/replace-id.ts b/blocksuite/affine/shared/src/adapters/middlewares/replace-id.ts index b2648206de..a2c734dfb2 100644 --- a/blocksuite/affine/shared/src/adapters/middlewares/replace-id.ts +++ b/blocksuite/affine/shared/src/adapters/middlewares/replace-id.ts @@ -19,7 +19,7 @@ import { matchModels } from '../../utils'; export const replaceIdMiddleware = (idGenerator: () => string): TransformerMiddleware => - ({ slots, docCRUD }) => { + ({ slots, docCRUD, assetsManager }) => { const idMap = new Map(); // After Import @@ -169,6 +169,16 @@ export const replaceIdMiddleware = } snapshot.id = newId; + // Should be re-paired. + if (['affine:attachment', 'affine:image'].includes(snapshot.flavour)) { + if (!assetsManager.uploadingAssetsMap.has(original)) return; + + const data = assetsManager.uploadingAssetsMap.get(original)!; + assetsManager.uploadingAssetsMap.set(newId, data); + assetsManager.uploadingAssetsMap.delete(original); + return; + } + if (snapshot.flavour === 'affine:surface') { // Generate new IDs for images and frames in advance. snapshot.children.forEach(child => { diff --git a/blocksuite/affine/shared/src/adapters/middlewares/upload.ts b/blocksuite/affine/shared/src/adapters/middlewares/upload.ts new file mode 100644 index 0000000000..231aa80cb6 --- /dev/null +++ b/blocksuite/affine/shared/src/adapters/middlewares/upload.ts @@ -0,0 +1,114 @@ +import { sha } from '@blocksuite/global/utils'; +import type { BlockStdScope } from '@blocksuite/std'; +import type { + BlockModel, + BlockProps, + TransformerMiddleware, +} from '@blocksuite/store'; +import { filter, from, map, mergeMap } from 'rxjs'; + +const ALLOWED_FLAVOURS = new Set(['affine:attachment', 'affine:image']); + +export const uploadMiddleware = ( + std: BlockStdScope, + concurrent = 5 +): TransformerMiddleware => { + const blockView$ = std.view.viewUpdated.pipe( + filter(payload => payload.type === 'block'), + filter(payload => ALLOWED_FLAVOURS.has(payload.view.model.flavour)) + ); + + return ({ assetsManager }) => { + async function upload( + model: BlockModel, + { + blob, + mapInto, + abortController, + }: { + blob: Blob; + mapInto: (blobId: string) => Partial; + abortController?: AbortController; + } + ) { + if (!abortController) return null; + + const signal = abortController.signal; + if (signal.aborted) return null; + + // Double check + if (!model.store.hasBlock(model.id)) return null; + + try { + signal.throwIfAborted(); + + const blobId = await Promise.race([ + (async function processUpload() { + const blobId = await sha(await blob.arrayBuffer()); + + assetsManager.getAssets().set(blobId, blob); + + await assetsManager.writeToBlob(blobId); + + return await new Promise(resolve => { + model.store.withoutTransact(() => { + if (signal.aborted) return resolve(null); + + model.store.updateBlock(model, mapInto(blobId)); + + resolve(blobId); + }); + }); + })(), + // If the signal is not aborted, it will be in the pending state. + new Promise(resolve => { + signal.addEventListener('abort', () => resolve(null), { + once: true, + }); + if (signal.aborted) { + resolve(null); + } + }), + ]); + + return blobId; + } catch (err) { + console.error(err); + + return null; + } + } + + blockView$ + .pipe( + map(payload => { + if (assetsManager.uploadingAssetsMap.size === 0) return null; + + const model = payload.view.model; + if (!assetsManager.uploadingAssetsMap.has(model.id)) return null; + + const state = assetsManager.uploadingAssetsMap.get(model.id)!; + + if (payload.method === 'add') { + state.abortController = new AbortController(); + return { model, state }; + } else { + state.abortController?.abort(); + assetsManager.uploadingAssetsMap.delete(model.id); + return null; + } + }), + filter(Boolean), + mergeMap( + ({ model, state }) => + from( + upload(model, state).then(() => { + assetsManager.uploadingAssetsMap.delete(model.id); + }) + ), + concurrent + ) + ) + .subscribe(); + }; +}; diff --git a/blocksuite/framework/store/src/transformer/assets.ts b/blocksuite/framework/store/src/transformer/assets.ts index fe942dca2e..4a15644e96 100644 --- a/blocksuite/framework/store/src/transformer/assets.ts +++ b/blocksuite/framework/store/src/transformer/assets.ts @@ -1,5 +1,6 @@ import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; +import type { BlockProps } from '../model'; import type { BlobCRUD } from './type'; type AssetsManagerConfig = { @@ -18,6 +19,16 @@ function makeNewNameWhenConflict(names: Set, name: string) { } export class AssetsManager { + // `blockId` is the key. + readonly uploadingAssetsMap = new Map< + string, + { + blob: Blob; + abortController?: AbortController; + mapInto: (blobId: string) => Partial; + } + >(); + private readonly _assetsMap = new Map(); private readonly _blob: BlobCRUD; diff --git a/blocksuite/framework/store/src/transformer/transformer.ts b/blocksuite/framework/store/src/transformer/transformer.ts index 80342efac4..939f2bc7fb 100644 --- a/blocksuite/framework/store/src/transformer/transformer.ts +++ b/blocksuite/framework/store/src/transformer/transformer.ts @@ -245,8 +245,9 @@ export class Transformer { parent?: string, index?: number ): Promise => { - SliceSnapshotSchema.parse(snapshot); try { + SliceSnapshotSchema.parse(snapshot); + this._slots.beforeImport.next({ type: 'slice', snapshot, @@ -525,11 +526,11 @@ export class Transformer { for (let index = 0; index < nodes.length; index++) { const node = nodes[index]; const { draft } = node; - const { id, flavour } = draft; + const { id, flavour, props } = draft; const actualIndex = startIndex !== undefined ? startIndex + index : undefined; - doc.addBlock(flavour, { id, ...draft.props }, parentId, actualIndex); + doc.addBlock(flavour, { id, ...props }, parentId, actualIndex); const model = doc.getBlock(id)?.model; if (!model) { diff --git a/tests/blocksuite/e2e/utils/actions/misc.ts b/tests/blocksuite/e2e/utils/actions/misc.ts index 5691660a78..3035fc0368 100644 --- a/tests/blocksuite/e2e/utils/actions/misc.ts +++ b/tests/blocksuite/e2e/utils/actions/misc.ts @@ -771,14 +771,11 @@ export async function pasteContent( export async function pasteTestImage(page: Page) { await page.evaluate(async () => { - const imageBlob = await fetch(`${location.origin}/test-card-1.png`).then( - response => response.blob() - ); - - const imageFile = new File([imageBlob], 'test-card-1.png', { + const resp = await fetch(`${location.origin}/test-card-1.png`); + const blob = await resp.blob(); + const file = new File([blob], 'test-card-1.png', { type: 'image/png', }); - const e = new ClipboardEvent('paste', { clipboardData: new DataTransfer(), }); @@ -788,7 +785,7 @@ export async function pasteTestImage(page: Page) { value: document, }); - e.clipboardData?.items.add(imageFile); + e.clipboardData?.items.add(file); document.dispatchEvent(e); }); await waitNextFrame(page);