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

@@ -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<string, unknown>
) => Promise<Record<string, unknown>> | Record<string, unknown>
updateItems: <T extends Record<string, unknown>>(items: T) => Promise<T> | 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 = `<div data-blocksuite-snapshot='${snapshot}'>${innerHTML}</div>`;
const htmlBlob = new Blob([html], {
type: 'text/html',
});
const clipboardItems: Record<string, Blob> = {
'text/html': htmlBlob,
};
const clipboardItems: Record<string, Blob> = {};
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 = `<div data-blocksuite-snapshot='${snapshot}'>${innerHTML}</div>`;
clipboardItems[type] = new Blob([html], { type });
}
await navigator.clipboard.write([new ClipboardItem(clipboardItems)]);
}
}