diff --git a/blocksuite/affine/block-image/src/utils.ts b/blocksuite/affine/block-image/src/utils.ts index 702bc0ecfbb6c..1c4425a31f1f1 100644 --- a/blocksuite/affine/block-image/src/utils.ts +++ b/blocksuite/affine/block-image/src/utils.ts @@ -4,6 +4,7 @@ import type { ImageBlockModel, ImageBlockProps, } from '@blocksuite/affine-model'; +import { NativeClipboardProvider } from '@blocksuite/affine-shared/services'; import { downloadBlob, humanFileSize, @@ -12,7 +13,6 @@ import { } from '@blocksuite/affine-shared/utils'; import type { BlockStdScope, EditorHost } from '@blocksuite/block-std'; import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx'; -import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; import { Bound, type IVec, Point, Vec } from '@blocksuite/global/utils'; import type { BlockModel } from '@blocksuite/store'; @@ -200,15 +200,6 @@ export async function resetImageSize( }); } -function convertToString(blob: Blob): Promise { - return new Promise(resolve => { - const reader = new FileReader(); - reader.addEventListener('load', _ => resolve(reader.result as string)); - reader.addEventListener('error', () => resolve(null)); - reader.readAsDataURL(blob); - }); -} - function convertToPng(blob: Blob): Promise { return new Promise(resolve => { const reader = new FileReader(); @@ -234,33 +225,35 @@ function convertToPng(blob: Blob): Promise { export async function copyImageBlob( block: ImageBlockComponent | ImageEdgelessBlockComponent ) { - const { host, model } = block; + const { host, model, std } = block; let blob = await getImageBlob(model); if (!blob) { console.error('Failed to get image blob'); return; } + let copied = false; + try { - // @ts-expect-error FIXME: BS-2239 - if (window.apis?.clipboard?.copyAsImageFromString) { - const dataURL = await convertToString(blob); - if (!dataURL) - throw new BlockSuiteError( - ErrorCode.DefaultRuntimeError, - 'Cant convert a blob to data URL.' - ); - // @ts-expect-error FIXME: BS-2239 - await window.apis.clipboard?.copyAsImageFromString(dataURL); - } else { - // DOMException: Type image/jpeg not supported on write. + // Copies the image as PNG in Electron. + const copyAsPNG = std.getOptional(NativeClipboardProvider)?.copyAsPNG; + if (copyAsPNG) { + copied = await copyAsPNG(await blob.arrayBuffer()); + } + + // The current clipboard only supports the `image/png` image format. + // The `ClipboardItem.supports('image/svg+xml')` is not currently used, + // because when pasting, the content is not read correctly. + // + // https://developer.mozilla.org/en-US/docs/Web/API/ClipboardItem + // https://alexharri.com/blog/clipboard + if (!copied) { if (blob.type !== 'image/png') { - const pngBlob = await convertToPng(blob); - if (!pngBlob) { + blob = await convertToPng(blob); + if (!blob) { console.error('Failed to convert blob to PNG'); return; } - blob = pngBlob; } if (!globalThis.isSecureContext) { diff --git a/blocksuite/affine/shared/src/services/index.ts b/blocksuite/affine/shared/src/services/index.ts index 1c805af7c8732..ae5a0d51f8ddb 100644 --- a/blocksuite/affine/shared/src/services/index.ts +++ b/blocksuite/affine/shared/src/services/index.ts @@ -6,6 +6,7 @@ export * from './editor-setting-service'; export * from './embed-option-service'; export * from './font-loader'; export * from './generate-url-service'; +export * from './native-clipboard-service'; export * from './notification-service'; export * from './page-viewport-service'; export * from './parse-url-service'; diff --git a/blocksuite/affine/shared/src/services/native-clipboard-service.ts b/blocksuite/affine/shared/src/services/native-clipboard-service.ts new file mode 100644 index 0000000000000..8add2c657eded --- /dev/null +++ b/blocksuite/affine/shared/src/services/native-clipboard-service.ts @@ -0,0 +1,23 @@ +import type { ExtensionType } from '@blocksuite/block-std'; +import { createIdentifier } from '@blocksuite/global/di'; + +/** + * Copies the image as PNG in Electron. + */ +export interface NativeClipboardService { + copyAsPNG(arrayBuffer: ArrayBuffer): Promise; +} + +export const NativeClipboardProvider = createIdentifier( + 'NativeClipboardService' +); + +export function NativeClipboardExtension( + nativeClipboardProvider: NativeClipboardService +): ExtensionType { + return { + setup: di => { + di.addImpl(NativeClipboardProvider, nativeClipboardProvider); + }, + }; +} diff --git a/blocksuite/blocks/src/root-block/widgets/surface-ref-toolbar/utils.ts b/blocksuite/blocks/src/root-block/widgets/surface-ref-toolbar/utils.ts index 5e768d31dbce2..b081a1feee41f 100644 --- a/blocksuite/blocks/src/root-block/widgets/surface-ref-toolbar/utils.ts +++ b/blocksuite/blocks/src/root-block/widgets/surface-ref-toolbar/utils.ts @@ -40,11 +40,5 @@ export const edgelessToBlob = async ( }; export const writeImageBlobToClipboard = async (blob: Blob) => { - // @ts-expect-error FIXME: BS-2239 - if (window.apis?.clipboard?.copyAsImageFromString) { - // @ts-expect-error FIXME: BS-2239 - await window.apis.clipboard?.copyAsImageFromString(blob); - } else { - await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]); - } + await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]); }; diff --git a/packages/frontend/apps/electron/src/main/clipboard/index.ts b/packages/frontend/apps/electron/src/main/clipboard/index.ts index 2d8d394d45fac..9a70ea5cb2b0a 100644 --- a/packages/frontend/apps/electron/src/main/clipboard/index.ts +++ b/packages/frontend/apps/electron/src/main/clipboard/index.ts @@ -1,10 +1,12 @@ -import type { IpcMainInvokeEvent } from 'electron'; import { clipboard, nativeImage } from 'electron'; import type { NamespaceHandlers } from '../type'; export const clipboardHandlers = { - copyAsImageFromString: async (_: IpcMainInvokeEvent, dataURL: string) => { - clipboard.writeImage(nativeImage.createFromDataURL(dataURL)); + copyAsPNG: async (_, arrayBuffer: ArrayBuffer) => { + const image = nativeImage.createFromBuffer(Buffer.from(arrayBuffer)); + if (image.isEmpty()) return false; + clipboard.writeImage(image); + return true; }, } satisfies NamespaceHandlers; diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-editor/lit-adaper.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-editor/lit-adaper.tsx index 244a8a9b3f09f..a28707bb2e6a6 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-editor/lit-adaper.tsx +++ b/packages/frontend/core/src/components/blocksuite/block-suite-editor/lit-adaper.tsx @@ -55,6 +55,7 @@ import { patchEdgelessClipboard, patchEmbedLinkedDocBlockConfig, patchForAttachmentEmbedViews, + patchForClipboardInElectron, patchForMobile, patchForSharedPage, patchGenerateDocUrlExtension, @@ -170,6 +171,9 @@ const usePatchSpecs = (shared: boolean, mode: DocMode) => { if (BUILD_CONFIG.isMobileEdition) { patched = patched.concat(patchForMobile()); } + if (BUILD_CONFIG.isElectron) { + patched = patched.concat(patchForClipboardInElectron(framework)); + } patched = patched.concat( patchDocModeService(docService, docsService, editorService) ); diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/spec-patchers.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/spec-patchers.tsx index 7e8992bebf326..d1870a4c4f287 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/spec-patchers.tsx +++ b/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/spec-patchers.tsx @@ -9,6 +9,7 @@ import { } from '@affine/component'; import { AIChatBlockSchema } from '@affine/core/blocksuite/blocks'; import { WorkspaceServerService } from '@affine/core/modules/cloud'; +import { DesktopApiService } from '@affine/core/modules/desktop-api'; import { type DocService, DocsService } from '@affine/core/modules/doc'; import type { EditorService } from '@affine/core/modules/editor'; import { EditorSettingService } from '@affine/core/modules/editor-setting'; @@ -53,6 +54,7 @@ import { EmbedLinkedDocBlockConfigExtension, GenerateDocUrlExtension, MobileSpecsPatches, + NativeClipboardExtension, NotificationExtension, ParseDocUrlExtension, PeekViewExtension, @@ -618,3 +620,10 @@ export function patchForAttachmentEmbedViews( }, }; } + +export function patchForClipboardInElectron(framework: FrameworkProvider) { + const desktopApi = framework.get(DesktopApiService); + return NativeClipboardExtension({ + copyAsPNG: desktopApi.handler.clipboard.copyAsPNG, + }); +} diff --git a/tests/affine-desktop/e2e/image.spec.ts b/tests/affine-desktop/e2e/image.spec.ts new file mode 100644 index 0000000000000..657d283d6926c --- /dev/null +++ b/tests/affine-desktop/e2e/image.spec.ts @@ -0,0 +1,52 @@ +import { test } from '@affine-test/kit/electron'; +import { importImage } from '@affine-test/kit/utils/image'; +import { pasteByKeyboard } from '@affine-test/kit/utils/keyboard'; +import { + clickNewPageButton, + getBlockSuiteEditorTitle, +} from '@affine-test/kit/utils/page-logic'; +import { expect } from '@playwright/test'; + +test('should be able to insert SVG images', async ({ page }) => { + await page.waitForTimeout(500); + await clickNewPageButton(page); + const title = getBlockSuiteEditorTitle(page); + await title.focus(); + await page.keyboard.press('Enter'); + + await importImage(page, 'affine.svg'); + + const svg = page.locator('affine-image').first(); + await expect(svg).toBeVisible(); +}); + +test('should paste it as PNG after copying SVG', async ({ page }) => { + await page.waitForTimeout(500); + await clickNewPageButton(page); + const title = getBlockSuiteEditorTitle(page); + await title.focus(); + await page.keyboard.press('Enter'); + + await importImage(page, 'affine.svg'); + + const svg = page.locator('affine-image').first(); + await expect(svg).toBeVisible(); + + await svg.hover(); + + await page.waitForTimeout(500); + const imageToolbar = page.locator('affine-image-toolbar'); + await expect(imageToolbar).toBeVisible(); + await imageToolbar.getByRole('button', { name: 'More' }).click(); + + const moveMenu = page.locator('.image-more-popup-menu'); + await moveMenu.getByRole('button', { name: /^Copy$/ }).click(); + + await svg.click(); + + await page.keyboard.press('Enter'); + await pasteByKeyboard(page); + + const png = page.locator('affine-image').nth(1); + await expect(png).toBeVisible(); +}); diff --git a/tests/fixtures/affine.svg b/tests/fixtures/affine.svg new file mode 100644 index 0000000000000..d7c980a1444a8 --- /dev/null +++ b/tests/fixtures/affine.svg @@ -0,0 +1,3 @@ + + +