mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 12:55:00 +00:00
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:
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user