fix(editor): support copying single image from edgeless and pasting to page (#12709)

Closes: [BS-3586](https://linear.app/affine-design/issue/BS-3586/复制白板图片,然后粘贴到-page,图片失败)

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
fundon
2025-06-06 01:17:58 +00:00
parent 512a908fd4
commit 7d1f2adb7f
7 changed files with 176 additions and 74 deletions

View File

@@ -107,10 +107,10 @@ export class EmbedHtmlFullscreenToolbar extends LitElement {
if (this._copied) return; if (this._copied) return;
this.embedHtml.std.clipboard this.embedHtml.std.clipboard
.writeToClipboard(items => { .writeToClipboard(items => ({
items['text/plain'] = this.embedHtml.model.props.html ?? ''; ...items,
return items; 'text/plain': this.embedHtml.model.props.html ?? '',
}) }))
.then(() => { .then(() => {
this._copied = true; this._copied = true;
setTimeout(() => (this._copied = false), 1500); setTimeout(() => (this._copied = false), 1500);

View File

@@ -11,6 +11,7 @@ import {
NativeClipboardProvider, NativeClipboardProvider,
} from '@blocksuite/affine-shared/services'; } from '@blocksuite/affine-shared/services';
import { import {
convertToPng,
formatSize, formatSize,
getBlockProps, getBlockProps,
isInsidePageEditor, isInsidePageEditor,
@@ -111,28 +112,6 @@ export async function resetImageSize(
block.store.updateBlock(model, props); block.store.updateBlock(model, props);
} }
function convertToPng(blob: Blob): Promise<Blob | null> {
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( export async function copyImageBlob(
block: ImageBlockComponent | ImageEdgelessBlockComponent block: ImageBlockComponent | ImageEdgelessBlockComponent
) { ) {

View File

@@ -35,6 +35,7 @@ import {
TelemetryProvider, TelemetryProvider,
} from '@blocksuite/affine-shared/services'; } from '@blocksuite/affine-shared/services';
import { import {
convertToPng,
isInsidePageEditor, isInsidePageEditor,
isTopLevelBlock, isTopLevelBlock,
isUrlInClipboard, isUrlInClipboard,
@@ -67,7 +68,7 @@ import * as Y from 'yjs';
import { PageClipboard } from '../../clipboard/index.js'; import { PageClipboard } from '../../clipboard/index.js';
import { getSortedCloneElements } from '../utils/clone-utils.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 { createElementsFromClipboardDataCommand } from './command.js';
import { import {
isPureFileInClipboard, isPureFileInClipboard,
@@ -126,6 +127,49 @@ export class EdgelessClipboardController extends PageClipboard {
return; 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 => { await this.std.clipboard.writeToClipboard(async _items => {
const data = await prepareClipboardData(elements, this.std); const data = await prepareClipboardData(elements, this.std);
return { return {

View File

@@ -26,3 +26,34 @@ export function readImageSize(file: File | Blob) {
img.src = sanitizedURL; img.src = sanitizedURL;
}); });
} }
export function convertToPng(blob: Blob): Promise<Blob | null> {
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);
});
}

View File

@@ -124,18 +124,23 @@ export class Clipboard extends LifeCycleWatcher {
copySlice = async (slice: Slice) => { copySlice = async (slice: Slice) => {
const adapterKeys = this._adapters.map(adapter => adapter.mimeType); const adapterKeys = this._adapters.map(adapter => adapter.mimeType);
await this.writeToClipboard(async _items => { await this.writeToClipboard(async items => {
const items = { ..._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( return {
adapterKeys.map(async type => { ...items,
const item = await this._getClipboardItem(slice, type); ...Object.fromEntries(filtered),
if (typeof item === 'string') { };
items[type] = item;
}
})
);
return items;
}); });
}; };
@@ -263,49 +268,56 @@ export class Clipboard extends LifeCycleWatcher {
} }
async writeToClipboard( async writeToClipboard(
updateItems: ( updateItems: <T extends Record<string, unknown>>(items: T) => Promise<T> | T
items: Record<string, unknown>
) => Promise<Record<string, unknown>> | Record<string, unknown>
) { ) {
const _items = { const items = await updateItems<
Partial<{
'text/plain': string;
'text/html': string;
'image/png': string | Blob;
}>
>({
'text/plain': '', 'text/plain': '',
'text/html': '', 'text/html': '',
'image/png': '', 'image/png': '',
}; });
const text = items['text/plain'] ?? '';
const items = await updateItems(_items); const innerHTML = items['text/html'] ?? '';
const image = items['image/png'];
const text = items['text/plain'] as string;
const innerHTML = items['text/html'] as string;
const png = items['image/png'] as string | Blob;
delete items['text/plain']; delete items['text/plain'];
delete items['text/html']; delete items['text/html'];
delete items['image/png'];
const snapshot = lz.compressToEncodedURIComponent(JSON.stringify(items)); const clipboardItems: Record<string, Blob> = {};
const html = `<div data-blocksuite-snapshot='${snapshot}'>${innerHTML}</div>`;
const htmlBlob = new Blob([html], { if (image) {
type: 'text/html', const type = 'image/png';
});
const clipboardItems: Record<string, Blob> = { delete items[type];
'text/html': htmlBlob,
}; if (typeof image === 'string') {
clipboardItems[type] = new Blob([image], { type });
} else if (image instanceof Blob) {
clipboardItems[type] = image;
}
}
if (text.length > 0) { if (text.length > 0) {
const textBlob = new Blob([text], { const type = 'text/plain';
type: 'text/plain', clipboardItems[type] = new Blob([text], { type });
});
clipboardItems['text/plain'] = textBlob;
} }
if (png instanceof Blob) { const hasInnerHTML = Boolean(innerHTML.length);
clipboardItems['image/png'] = png; const isEmpty = Object.keys(clipboardItems).length === 0;
} else if (png.length > 0) {
const pngBlob = new Blob([png], { // If there are no items, fall back to snapshot.
type: 'image/png', if (hasInnerHTML || isEmpty) {
}); const type = 'text/html';
clipboardItems['image/png'] = pngBlob; const snapshot = lz.compressToEncodedURIComponent(JSON.stringify(items));
const html = `<div data-blocksuite-snapshot='${snapshot}'>${innerHTML}</div>`;
clipboardItems[type] = new Blob([html], { type });
} }
await navigator.clipboard.write([new ClipboardItem(clipboardItems)]); await navigator.clipboard.write([new ClipboardItem(clipboardItems)]);
} }
} }

View File

@@ -34,12 +34,12 @@ export const copyLinkToBlockStdScopeClipboard = async (
if (clipboardWriteIsSupported) { if (clipboardWriteIsSupported) {
try { try {
await clipboard.writeToClipboard(items => { await clipboard.writeToClipboard(items => ({
items['text/plain'] = text; ...items,
'text/plain': text,
// wrap a link // wrap a link
items['text/html'] = `<a href="${text}">${text}</a>`; 'text/html': `<a href="${text}">${text}</a>`,
return items; }));
});
success = true; success = true;
} catch (error) { } catch (error) {
console.error(error); console.error(error);

View File

@@ -1,11 +1,14 @@
import { test } from '@affine-test/kit/playwright'; import { test } from '@affine-test/kit/playwright';
import { importFile } from '@affine-test/kit/utils/attachment';
import { pasteContent } from '@affine-test/kit/utils/clipboard'; import { pasteContent } from '@affine-test/kit/utils/clipboard';
import { import {
clickEdgelessModeButton, clickEdgelessModeButton,
clickPageModeButton, clickPageModeButton,
clickView,
getCodeBlockIds, getCodeBlockIds,
getParagraphIds, getParagraphIds,
locateEditorContainer, locateEditorContainer,
toViewCoord,
} from '@affine-test/kit/utils/editor'; } from '@affine-test/kit/utils/editor';
import { import {
copyByKeyboard, copyByKeyboard,
@@ -470,3 +473,36 @@ test.describe('paste in readonly mode', () => {
await verifyParagraphContent(page, 0, 'This is a test paragraph'); 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();
});