Skip to content

Commit

Permalink
refactor(editor): add native clipboard extension
Browse files Browse the repository at this point in the history
  • Loading branch information
fundon committed Jan 6, 2025
1 parent 46c8c4a commit b32ff2c
Show file tree
Hide file tree
Showing 9 changed files with 117 additions and 36 deletions.
45 changes: 19 additions & 26 deletions blocksuite/affine/block-image/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
ImageBlockModel,
ImageBlockProps,
} from '@blocksuite/affine-model';
import { NativeClipboardProvider } from '@blocksuite/affine-shared/services';
import {
downloadBlob,
humanFileSize,
Expand All @@ -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';

Expand Down Expand Up @@ -200,15 +200,6 @@ export async function resetImageSize(
});
}

function convertToString(blob: Blob): Promise<string | null> {
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<Blob | null> {
return new Promise(resolve => {
const reader = new FileReader();
Expand All @@ -234,33 +225,35 @@ function convertToPng(blob: Blob): Promise<Blob | null> {
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) {
Expand Down
1 change: 1 addition & 0 deletions blocksuite/affine/shared/src/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
23 changes: 23 additions & 0 deletions blocksuite/affine/shared/src/services/native-clipboard-service.ts
Original file line number Diff line number Diff line change
@@ -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<boolean>;
}

export const NativeClipboardProvider = createIdentifier<NativeClipboardService>(
'NativeClipboardService'
);

export function NativeClipboardExtension(
nativeClipboardProvider: NativeClipboardService
): ExtensionType {
return {
setup: di => {
di.addImpl(NativeClipboardProvider, nativeClipboardProvider);
},
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 })]);
};
8 changes: 5 additions & 3 deletions packages/frontend/apps/electron/src/main/clipboard/index.ts
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import {
patchEdgelessClipboard,
patchEmbedLinkedDocBlockConfig,
patchForAttachmentEmbedViews,
patchForClipboardInElectron,
patchForMobile,
patchForSharedPage,
patchGenerateDocUrlExtension,
Expand Down Expand Up @@ -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)
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -53,6 +54,7 @@ import {
EmbedLinkedDocBlockConfigExtension,
GenerateDocUrlExtension,
MobileSpecsPatches,
NativeClipboardExtension,
NotificationExtension,
ParseDocUrlExtension,
PeekViewExtension,
Expand Down Expand Up @@ -618,3 +620,10 @@ export function patchForAttachmentEmbedViews(
},
};
}

export function patchForClipboardInElectron(framework: FrameworkProvider) {
const desktopApi = framework.get(DesktopApiService);
return NativeClipboardExtension({
copyAsPNG: desktopApi.handler.clipboard.copyAsPNG,
});
}
52 changes: 52 additions & 0 deletions tests/affine-desktop/e2e/image.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
3 changes: 3 additions & 0 deletions tests/fixtures/affine.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit b32ff2c

Please sign in to comment.