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>
This commit is contained in:
01zulfi
2025-12-30 13:46:24 +05:00
committed by GitHub
parent b0c18a8ece
commit 8298900f76
3 changed files with 143 additions and 2 deletions

View File

@@ -18,7 +18,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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<ImageOptions>({
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;
}
};
}
});

View File

@@ -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 <http://www.gnu.org/licenses/>.
*/
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]);
});

View File

@@ -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
});
}