From 7d1f2adb7fd4f69dcfefd2f1d0daee6cd8b83a18 Mon Sep 17 00:00:00 2001 From: fundon Date: Fri, 6 Jun 2025 01:17:58 +0000 Subject: [PATCH] fix(editor): support copying single image from edgeless and pasting to page (#12709) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes: [BS-3586](https://linear.app/affine-design/issue/BS-3586/复制白板图片,然后粘贴到-page,图片失败) ## Summary by CodeRabbit - **New Features** - Copying a single selected image in edgeless mode now places the image directly onto the system clipboard as a native image blob for smoother pasting. - **Bug Fixes** - Enhanced clipboard handling to better manage image and text data inclusion, with improved fallback for snapshot HTML. - **Tests** - Added an end-to-end test verifying image copy-paste functionality between edgeless and page editor modes. --- .../components/fullscreen-toolbar.ts | 8 +- blocksuite/affine/blocks/image/src/utils.ts | 23 +---- .../root/src/edgeless/clipboard/clipboard.ts | 46 ++++++++- blocksuite/affine/shared/src/utils/image.ts | 31 ++++++ .../framework/std/src/clipboard/clipboard.ts | 96 +++++++++++-------- .../core/src/utils/clipboard/index.ts | 10 +- .../blocksuite/clipboard/clipboard.spec.ts | 36 +++++++ 7 files changed, 176 insertions(+), 74 deletions(-) diff --git a/blocksuite/affine/blocks/embed/src/embed-html-block/components/fullscreen-toolbar.ts b/blocksuite/affine/blocks/embed/src/embed-html-block/components/fullscreen-toolbar.ts index f86a655a1a..c952c6f603 100644 --- a/blocksuite/affine/blocks/embed/src/embed-html-block/components/fullscreen-toolbar.ts +++ b/blocksuite/affine/blocks/embed/src/embed-html-block/components/fullscreen-toolbar.ts @@ -107,10 +107,10 @@ export class EmbedHtmlFullscreenToolbar extends LitElement { if (this._copied) return; this.embedHtml.std.clipboard - .writeToClipboard(items => { - items['text/plain'] = this.embedHtml.model.props.html ?? ''; - return items; - }) + .writeToClipboard(items => ({ + ...items, + 'text/plain': this.embedHtml.model.props.html ?? '', + })) .then(() => { this._copied = true; setTimeout(() => (this._copied = false), 1500); diff --git a/blocksuite/affine/blocks/image/src/utils.ts b/blocksuite/affine/blocks/image/src/utils.ts index 78f243f4bb..e2c7866719 100644 --- a/blocksuite/affine/blocks/image/src/utils.ts +++ b/blocksuite/affine/blocks/image/src/utils.ts @@ -11,6 +11,7 @@ import { NativeClipboardProvider, } from '@blocksuite/affine-shared/services'; import { + convertToPng, formatSize, getBlockProps, isInsidePageEditor, @@ -111,28 +112,6 @@ export async function resetImageSize( block.store.updateBlock(model, props); } -function convertToPng(blob: Blob): Promise { - return new Promise(resolve => { - const reader = new FileReader(); - reader.addEventListener('load', _ => { - const img = new Image(); - img.onload = () => { - const c = document.createElement('canvas'); - c.width = img.width; - c.height = img.height; - const ctx = c.getContext('2d'); - if (!ctx) return; - ctx.drawImage(img, 0, 0); - c.toBlob(resolve, 'image/png'); - }; - img.onerror = () => resolve(null); - img.src = reader.result as string; - }); - reader.addEventListener('error', () => resolve(null)); - reader.readAsDataURL(blob); - }); -} - export async function copyImageBlob( block: ImageBlockComponent | ImageEdgelessBlockComponent ) { diff --git a/blocksuite/affine/blocks/root/src/edgeless/clipboard/clipboard.ts b/blocksuite/affine/blocks/root/src/edgeless/clipboard/clipboard.ts index 2e2d9d5ebc..5f33f9b273 100644 --- a/blocksuite/affine/blocks/root/src/edgeless/clipboard/clipboard.ts +++ b/blocksuite/affine/blocks/root/src/edgeless/clipboard/clipboard.ts @@ -35,6 +35,7 @@ import { TelemetryProvider, } from '@blocksuite/affine-shared/services'; import { + convertToPng, isInsidePageEditor, isTopLevelBlock, isUrlInClipboard, @@ -67,7 +68,7 @@ import * as Y from 'yjs'; import { PageClipboard } from '../../clipboard/index.js'; import { getSortedCloneElements } from '../utils/clone-utils.js'; -import { isCanvasElementWithText } from '../utils/query.js'; +import { isCanvasElementWithText, isImageBlock } from '../utils/query.js'; import { createElementsFromClipboardDataCommand } from './command.js'; import { isPureFileInClipboard, @@ -126,6 +127,49 @@ export class EdgelessClipboardController extends PageClipboard { return; } + // Only when an image is selected, it can be pasted normally to page mode. + if (elements.length === 1 && isImageBlock(elements[0])) { + const element = elements[0]; + const sourceId = element.props.sourceId$.peek(); + if (!sourceId) return; + + await this.std.clipboard.writeToClipboard(async items => { + const job = this.std.store.getTransformer(); + await job.assetsManager.readFromBlob(sourceId); + + let blob = job.assetsManager.getAssets().get(sourceId) ?? null; + if (!blob) { + return items; + } + + let type = blob.type; + let supported = false; + + try { + supported = ClipboardItem?.supports(type) ?? false; + } catch (err) { + console.error(err); + } + + // TODO(@fundon): when converting jpeg to png, image may become larger and exceed the limit. + if (!supported) { + type = 'image/png'; + blob = await convertToPng(blob); + } + + if (blob) { + return { + ...items, + [`${type}`]: blob, + }; + } + + return items; + }); + + return; + } + await this.std.clipboard.writeToClipboard(async _items => { const data = await prepareClipboardData(elements, this.std); return { diff --git a/blocksuite/affine/shared/src/utils/image.ts b/blocksuite/affine/shared/src/utils/image.ts index a781897f47..1562e3a797 100644 --- a/blocksuite/affine/shared/src/utils/image.ts +++ b/blocksuite/affine/shared/src/utils/image.ts @@ -26,3 +26,34 @@ export function readImageSize(file: File | Blob) { img.src = sanitizedURL; }); } + +export function convertToPng(blob: Blob): Promise { + return new Promise(resolve => { + const reader = new FileReader(); + + reader.addEventListener('load', _ => { + const img = new Image(); + + img.onload = () => { + const c = document.createElement('canvas'); + c.width = img.width; + c.height = img.height; + const ctx = c.getContext('2d'); + if (!ctx) { + resolve(null); + return; + } + ctx.drawImage(img, 0, 0); + c.toBlob(resolve, 'image/png'); + }; + + img.onerror = () => resolve(null); + + img.src = reader.result as string; + }); + + reader.addEventListener('error', () => resolve(null)); + + reader.readAsDataURL(blob); + }); +} diff --git a/blocksuite/framework/std/src/clipboard/clipboard.ts b/blocksuite/framework/std/src/clipboard/clipboard.ts index d818d43ab0..8661fe949b 100644 --- a/blocksuite/framework/std/src/clipboard/clipboard.ts +++ b/blocksuite/framework/std/src/clipboard/clipboard.ts @@ -124,18 +124,23 @@ export class Clipboard extends LifeCycleWatcher { copySlice = async (slice: Slice) => { const adapterKeys = this._adapters.map(adapter => adapter.mimeType); - await this.writeToClipboard(async _items => { - const items = { ..._items }; + await this.writeToClipboard(async items => { + const filtered = ( + await Promise.all( + adapterKeys.map(async type => { + const item = await this._getClipboardItem(slice, type); + if (typeof item === 'string') { + return [type, item]; + } + return null; + }) + ) + ).filter((adapter): adapter is string[] => Boolean(adapter)); - await Promise.all( - adapterKeys.map(async type => { - const item = await this._getClipboardItem(slice, type); - if (typeof item === 'string') { - items[type] = item; - } - }) - ); - return items; + return { + ...items, + ...Object.fromEntries(filtered), + }; }); }; @@ -263,49 +268,56 @@ export class Clipboard extends LifeCycleWatcher { } async writeToClipboard( - updateItems: ( - items: Record - ) => Promise> | Record + updateItems: >(items: T) => Promise | T ) { - const _items = { + const items = await updateItems< + Partial<{ + 'text/plain': string; + 'text/html': string; + 'image/png': string | Blob; + }> + >({ 'text/plain': '', 'text/html': '', 'image/png': '', - }; - - const items = await updateItems(_items); - - const text = items['text/plain'] as string; - const innerHTML = items['text/html'] as string; - const png = items['image/png'] as string | Blob; + }); + const text = items['text/plain'] ?? ''; + const innerHTML = items['text/html'] ?? ''; + const image = items['image/png']; delete items['text/plain']; delete items['text/html']; - delete items['image/png']; - const snapshot = lz.compressToEncodedURIComponent(JSON.stringify(items)); - const html = `
${innerHTML}
`; - const htmlBlob = new Blob([html], { - type: 'text/html', - }); - const clipboardItems: Record = { - 'text/html': htmlBlob, - }; + const clipboardItems: Record = {}; + + if (image) { + const type = 'image/png'; + + delete items[type]; + + if (typeof image === 'string') { + clipboardItems[type] = new Blob([image], { type }); + } else if (image instanceof Blob) { + clipboardItems[type] = image; + } + } + if (text.length > 0) { - const textBlob = new Blob([text], { - type: 'text/plain', - }); - clipboardItems['text/plain'] = textBlob; + const type = 'text/plain'; + clipboardItems[type] = new Blob([text], { type }); } - if (png instanceof Blob) { - clipboardItems['image/png'] = png; - } else if (png.length > 0) { - const pngBlob = new Blob([png], { - type: 'image/png', - }); - clipboardItems['image/png'] = pngBlob; + const hasInnerHTML = Boolean(innerHTML.length); + const isEmpty = Object.keys(clipboardItems).length === 0; + + // If there are no items, fall back to snapshot. + if (hasInnerHTML || isEmpty) { + const type = 'text/html'; + const snapshot = lz.compressToEncodedURIComponent(JSON.stringify(items)); + const html = `
${innerHTML}
`; + clipboardItems[type] = new Blob([html], { type }); } + await navigator.clipboard.write([new ClipboardItem(clipboardItems)]); } } diff --git a/packages/frontend/core/src/utils/clipboard/index.ts b/packages/frontend/core/src/utils/clipboard/index.ts index 88f2049ea0..0e803df66b 100644 --- a/packages/frontend/core/src/utils/clipboard/index.ts +++ b/packages/frontend/core/src/utils/clipboard/index.ts @@ -34,12 +34,12 @@ export const copyLinkToBlockStdScopeClipboard = async ( if (clipboardWriteIsSupported) { try { - await clipboard.writeToClipboard(items => { - items['text/plain'] = text; + await clipboard.writeToClipboard(items => ({ + ...items, + 'text/plain': text, // wrap a link - items['text/html'] = `${text}`; - return items; - }); + 'text/html': `${text}`, + })); success = true; } catch (error) { console.error(error); diff --git a/tests/affine-local/e2e/blocksuite/clipboard/clipboard.spec.ts b/tests/affine-local/e2e/blocksuite/clipboard/clipboard.spec.ts index 3503a03a20..f646808f19 100644 --- a/tests/affine-local/e2e/blocksuite/clipboard/clipboard.spec.ts +++ b/tests/affine-local/e2e/blocksuite/clipboard/clipboard.spec.ts @@ -1,11 +1,14 @@ import { test } from '@affine-test/kit/playwright'; +import { importFile } from '@affine-test/kit/utils/attachment'; import { pasteContent } from '@affine-test/kit/utils/clipboard'; import { clickEdgelessModeButton, clickPageModeButton, + clickView, getCodeBlockIds, getParagraphIds, locateEditorContainer, + toViewCoord, } from '@affine-test/kit/utils/editor'; import { copyByKeyboard, @@ -470,3 +473,36 @@ test.describe('paste in readonly mode', () => { await verifyParagraphContent(page, 0, 'This is a test paragraph'); }); }); + +test('should copy single image from edgeless and paste to page', async ({ + page, +}) => { + await clickEdgelessModeButton(page); + + const button = page.locator('edgeless-mindmap-tool-button'); + await button.click(); + + const menu = page.locator('edgeless-mindmap-menu'); + const mediaItem = menu.locator('.media-item'); + await mediaItem.click(); + + await importFile(page, 'large-image.png', async () => { + await toViewCoord(page, [100, 250]); + await clickView(page, [100, 250]); + }); + + const image = page.locator('affine-edgeless-image').first(); + await image.click(); + + await copyByKeyboard(page); + + await clickPageModeButton(page); + await waitForEditorLoad(page); + + const container = locateEditorContainer(page); + await container.click(); + + await pasteByKeyboard(page); + + await expect(page.locator('affine-page-image')).toBeVisible(); +});