From 8298900f76d21658b587f7732b91b75dd53b0e83 Mon Sep 17 00:00:00 2001 From: 01zulfi <85733202+01zulfi@users.noreply.github.com> Date: Tue, 30 Dec 2025 13:46:24 +0500 Subject: [PATCH] editor: support ctrl+c for images (#8386) * editor: support ctrl+c for images Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com> * editor: add test for copy image functionality Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com> --- packages/editor/src/extensions/image/image.ts | 42 ++++++++- .../src/extensions/image/tests/image.test.ts | 91 +++++++++++++++++++ packages/editor/src/utils/downloader.ts | 12 +++ 3 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 packages/editor/src/extensions/image/tests/image.test.ts diff --git a/packages/editor/src/extensions/image/image.ts b/packages/editor/src/extensions/image/image.ts index 03f367be1..cf53ffad1 100644 --- a/packages/editor/src/extensions/image/image.ts +++ b/packages/editor/src/extensions/image/image.ts @@ -18,7 +18,10 @@ along with this program. If not, see . */ import { Node, nodeInputRule, mergeAttributes } from "@tiptap/core"; -import { hasSameAttributes } from "../../utils/prosemirror.js"; +import { + findSelectedNode, + hasSameAttributes +} from "../../utils/prosemirror.js"; import { ImageAlignmentOptions, ImageAttachment, @@ -29,6 +32,7 @@ import { TextDirections } from "../text-direction/index.js"; import { ImageComponent } from "./component.js"; import { tiptapKeys } from "@notesnook/common"; import { hasPermission } from "../../types.js"; +import { toBlob } from "../../utils/downloader.js"; export interface ImageOptions { inline: boolean; @@ -215,7 +219,41 @@ export const ImageNode = Node.create({ addKeyboardShortcuts() { return { [tiptapKeys.addImage.keys]: () => - this.editor.storage.openAttachmentPicker?.("image") || true + this.editor.storage.openAttachmentPicker?.("image") || true, + "Mod-c": () => { + if (!this.editor.isActive("image")) return false; + + const imageNode = findSelectedNode(this.editor, "image"); + if (!imageNode || imageNode.type.name !== "image") return false; + + const { hash, mime } = imageNode.attrs as ImageAttributes; + if (!hash || !mime) return false; + + (async () => { + try { + const imageData = await this.editor.storage.getAttachmentData?.({ + type: "image", + hash + }); + if (typeof imageData !== "string" || !imageData) return; + + const imageBlob = toBlob(imageData, mime); + if (!imageBlob) return; + + if (navigator.clipboard && navigator.clipboard.write) { + await navigator.clipboard.write([ + new ClipboardItem({ + [imageBlob.type]: imageBlob + }) + ]); + } + } catch (error) { + console.error("Failed to copy image to clipboard:", error); + } + })(); + + return true; + } }; } }); diff --git a/packages/editor/src/extensions/image/tests/image.test.ts b/packages/editor/src/extensions/image/tests/image.test.ts new file mode 100644 index 000000000..0f328a832 --- /dev/null +++ b/packages/editor/src/extensions/image/tests/image.test.ts @@ -0,0 +1,91 @@ +/* +This file is part of the Notesnook project (https://notesnook.com/) + +Copyright (C) 2023 Streetwriters (Private) Limited + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +import { expect, test, vi, beforeEach, afterEach } from "vitest"; +import { createEditor, h } from "../../../../test-utils"; +import { ImageNode } from "../image"; + +const mockClipboardWrite = vi.fn(); +const mockClipboardItem = vi.fn(); + +beforeEach(() => { + mockClipboardWrite.mockReset(); + mockClipboardItem.mockReset(); + + // @ts-expect-error supports is declared here + global.ClipboardItem = mockClipboardItem; + + Object.defineProperty(navigator, "clipboard", { + value: { + write: mockClipboardWrite + }, + writable: true + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +test("copy image to clipboard when Ctrl+C is pressed on selected image", async () => { + const testHash = "test-hash"; + const base64ImageData = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg="; + const mockImageData = "data:image/png;base64," + base64ImageData; + const expectedBlob = new Blob([Buffer.from(base64ImageData, "base64")], { + type: "image/png" + }); + + const editorElement = h("div"); + const { editor } = createEditor({ + element: editorElement, + extensions: { + image: ImageNode + } + }); + editor.storage.getAttachmentData = vi.fn().mockResolvedValue(mockImageData); + editor.commands.insertImage({ + src: "test.png", + hash: testHash, + mime: "image/png", + filename: "test.png" + }); + editor.commands.setNodeSelection(0); + + expect(editor.isActive("image")).toBe(true); + + const keydownEvent = new KeyboardEvent("keydown", { + key: "c", + ctrlKey: true, + bubbles: true, + cancelable: true + }); + editor.view.dom.dispatchEvent(keydownEvent); + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(editor.storage.getAttachmentData).toHaveBeenCalledWith({ + type: "image", + hash: testHash + }); + expect(mockClipboardItem).toHaveBeenCalledWith({ + "image/png": expectedBlob + }); + const clipboardItemInstance = mockClipboardItem.mock.results[0].value; + expect(mockClipboardWrite).toHaveBeenCalledWith([clipboardItemInstance]); +}); diff --git a/packages/editor/src/utils/downloader.ts b/packages/editor/src/utils/downloader.ts index 409f0da68..7653ab426 100644 --- a/packages/editor/src/utils/downloader.ts +++ b/packages/editor/src/utils/downloader.ts @@ -146,3 +146,15 @@ export function revokeBloburl(id: string) { URL.revokeObjectURL(url); OBJECT_URL_CACHE[id] = undefined; } + +export function toBlob(dataurl: string, mimeType: string): Blob | undefined { + if (!DataURL.isValid(dataurl)) return; + + const dataurlObject = DataURL.toObject(dataurl); + const data = dataurlObject.data; + if (!data) return; + + return new Blob([Buffer.from(data, "base64")], { + type: mimeType + }); +}