fix: support desktop rich clipboard copying

This commit is contained in:
tianma-if committed 2026-08-10 10:58:37 +08:00
1 parent 1b4c7743fc
commit b7043e7aaf
10 files changed
+130 -27

No files matched your search

+5
View File
@@ -40,3 +40,8 @@ credentials.json
# Local App Review crash dumps / image self-test screenshots.
.tmp-app-review-crashes/
# Local brand concept and research drafts.
/assets/brand/edgeever-concept-13.png
/docs/notion-ai-feature-landscape.en-US.md
/docs/notion-ai-feature-landscape.zh-CN.md
+10
View File
@@ -0,0 +1,10 @@
export const writeRichClipboard = (clipboard, input) => {
const html = input?.html;
const plainText = input?.plainText;
if (typeof html !== "string" || typeof plainText !== "string") {
throw new TypeError("Clipboard HTML and plain text must be strings");
}
clipboard.write({ html, text: plainText });
return clipboard.readText() === plainText && clipboard.readHTML().trim().length > 0;
};
@@ -0,0 +1,42 @@
import { describe, expect, test } from "bun:test";
import { writeRichClipboard } from "./clipboard-write.mjs";
describe("writeRichClipboard", () => {
test("writes and verifies both rich HTML and plain text", () => {
const writes = [];
const clipboard = {
write: (value) => writes.push(value),
readText: () => "Hello",
readHTML: () => '<meta charset="utf-8"><strong>Hello</strong>',
};
expect(writeRichClipboard(clipboard, {
html: "<strong>Hello</strong>",
plainText: "Hello",
})).toBe(true);
expect(writes).toEqual([{
html: "<strong>Hello</strong>",
text: "Hello",
}]);
});
test("does not report success when rich HTML is missing after the write", () => {
const clipboard = {
write: () => {},
readText: () => "Hello",
readHTML: () => "",
};
expect(writeRichClipboard(clipboard, {
html: "<strong>Hello</strong>",
plainText: "Hello",
})).toBe(false);
});
test("rejects malformed renderer input", () => {
const clipboard = { write: () => {}, readText: () => "", readHTML: () => "" };
expect(() => writeRichClipboard(clipboard, { html: "<p>Hello</p>" })).toThrow(
"Clipboard HTML and plain text must be strings",
);
});
});
+2
View File
@@ -21,6 +21,7 @@ import { userDataDirectoryFromArguments } from "./user-data-directory.mjs";
import { isAllowedPrintPreviewUrl } from "./window-open-policy.mjs";
import { showWindow } from "./window-visibility.mjs";
import { trayIconPath } from "./tray-icon.mjs";
import { writeRichClipboard } from "./clipboard-write.mjs";
import electronUpdater from "electron-updater";
const { autoUpdater } = electronUpdater;
@@ -680,6 +681,7 @@ app.whenReady().then(async () => {
clipboard.writeText(value);
return clipboard.readText() === value;
});
ipcMain.handle("desktop:copy-html", (_event, input) => writeRichClipboard(clipboard, input));
ipcMain.handle("desktop:set-session-token", async (_event, value) => {
await saveDesktopSessionToken(value);
return { stored: Boolean(desktopSessionToken) };
+1
View File
@@ -8,6 +8,7 @@ contextBridge.exposeInMainWorld("edgeeverDesktop", Object.freeze({
setApiBaseUrl: (value) => ipcRenderer.invoke("desktop:set-api-base-url", value),
getSessionToken: () => ipcRenderer.sendSync("desktop:session-token-sync"),
copyText: (value) => ipcRenderer.invoke("desktop:copy-text", value),
copyHtml: (html, plainText) => ipcRenderer.invoke("desktop:copy-html", { html, plainText }),
setSessionToken: (value) => ipcRenderer.invoke("desktop:set-session-token", value),
clearSessionToken: () => ipcRenderer.invoke("desktop:clear-session-token"),
updateStatus: () => ipcRenderer.invoke("desktop:update-status"),
+36 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, test } from "bun:test";
import { copyTextToClipboard } from "./clipboard.ts";
import { copyHtmlToClipboard, copyTextToClipboard } from "./clipboard.ts";
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, "navigator");
@@ -63,3 +63,38 @@ describe("copyTextToClipboard", () => {
expect(calls).toEqual(["browser value"]);
});
});
describe("copyHtmlToClipboard", () => {
test("uses the native rich clipboard bridge in the desktop app", async () => {
const calls = [];
globalThis.window = {
edgeeverDesktop: {
isAvailable: true,
copyHtml: async (...values) => {
calls.push(values);
return true;
},
},
};
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: { clipboard: { write: () => { throw new Error("browser clipboard should not be used"); } } },
});
await expect(copyHtmlToClipboard("<strong>Hello</strong>", "Hello")).resolves.toBeUndefined();
expect(calls).toEqual([["<strong>Hello</strong>", "Hello"]]);
});
test("fails when native rich clipboard verification fails", async () => {
globalThis.window = {
edgeeverDesktop: {
isAvailable: true,
copyHtml: async () => false,
},
};
await expect(copyHtmlToClipboard("<strong>Hello</strong>", "Hello")).rejects.toThrow(
"Native rich clipboard verification failed",
);
});
});
+31
View File
@@ -35,3 +35,34 @@ export const copyTextToClipboard = async (text: string) => {
document.body.removeChild(textarea);
}
};
export const copyHtmlToClipboard = async (html: string, plainText: string) => {
if (typeof window !== "undefined" && window.edgeeverDesktop?.isAvailable) {
const copied = await window.edgeeverDesktop.copyHtml(html, plainText);
if (!copied) throw new Error("Native rich clipboard verification failed");
return;
}
if (navigator.clipboard && "ClipboardItem" in window) {
await navigator.clipboard.write([new ClipboardItem({
"text/html": new Blob([html], { type: "text/html" }),
"text/plain": new Blob([plainText], { type: "text/plain" }),
})]);
return;
}
const selection = window.getSelection();
const range = document.createRange();
const container = document.createElement("div");
container.setAttribute("contenteditable", "true");
container.style.cssText = "position: fixed; left: -99999px; top: 0;";
container.innerHTML = html;
document.body.appendChild(container);
range.selectNodeContents(container);
selection?.removeAllRanges();
selection?.addRange(range);
const copied = document.execCommand("copy");
selection?.removeAllRanges();
container.remove();
if (!copied) throw new Error("Clipboard copy was not available");
};
+1 -25
View File
@@ -1,6 +1,7 @@
import type { Editor } from "@tiptap/react";
import { MEMO_CONTENT_STYLE } from "@edgeever/shared";
import { marked } from "marked";
import { copyHtmlToClipboard } from "@/lib/clipboard";
import { parseCustomCssToStyles } from "@/lib/css-sandbox";
const BODY_LINE_HEIGHT = MEMO_CONTENT_STYLE.body.lineHeight / MEMO_CONTENT_STYLE.body.fontSize;
@@ -386,31 +387,6 @@ export const buildWeChatClipboardHtml = async (editor: Editor) => {
return container.outerHTML;
};
const copyHtmlToClipboard = async (html: string, plainText: string) => {
if (navigator.clipboard && "ClipboardItem" in window) {
await navigator.clipboard.write([new ClipboardItem({
"text/html": new Blob([html], { type: "text/html" }),
"text/plain": new Blob([plainText], { type: "text/plain" }),
})]);
return;
}
const selection = window.getSelection();
const range = document.createRange();
const container = document.createElement("div");
container.setAttribute("contenteditable", "true");
container.style.cssText = "position: fixed; left: -99999px; top: 0;";
container.innerHTML = html;
document.body.appendChild(container);
range.selectNodeContents(container);
selection?.removeAllRanges();
selection?.addRange(range);
const copied = document.execCommand("copy");
selection?.removeAllRanges();
container.remove();
if (!copied) throw new Error("Clipboard copy was not available");
};
export const copyEditorToWeChat = async (editor: Editor) =>
copyHtmlToClipboard(await buildWeChatClipboardHtml(editor), editor.getText({ blockSeparator: "\n" }));
+1
View File
@@ -14,6 +14,7 @@ interface EdgeEverDesktopBridge {
setApiBaseUrl(value: string): Promise<string>;
getSessionToken(): string;
copyText(value: string): Promise<boolean>;
copyHtml(html: string, plainText: string): Promise<boolean>;
setSessionToken(value: string): Promise<{ stored: boolean }>;
clearSessionToken(): Promise<{ stored: false }>;
sidecarStatus(): Promise<{ available: boolean; path: string; scope: string }>;
+1 -1
View File
@@ -48,7 +48,7 @@
"release": "bun scripts/release.mjs",
"publish:stores": "bun scripts/store-delivery.mjs",
"test:desktop:sidecar": "bun scripts/test-desktop-sidecar.mjs",
"test:desktop": "bun test apps/desktop/src/main/account-scope.test.mjs apps/desktop/src/main/diagnostic-log.test.mjs apps/desktop/src/main/file-association.test.mjs apps/desktop/src/main/file-permissions.test.mjs apps/desktop/src/main/installation-location.test.mjs apps/desktop/src/main/resource-request.test.mjs apps/desktop/src/main/resource-url.test.mjs apps/desktop/src/main/staged-resource.test.mjs apps/desktop/src/main/tray-icon.test.mjs apps/desktop/src/main/user-data-directory.test.mjs apps/desktop/src/main/window-open-policy.test.mjs apps/desktop/src/main/window-visibility.test.mjs apps/web/src/lib/api.test.mjs apps/web/src/lib/app-page-path.test.mjs apps/web/src/lib/clipboard.test.mjs apps/web/src/lib/desktop-repository.test.mjs apps/web/src/lib/desktop-resources.test.mjs apps/web/src/lib/desktop-sync.test.mjs apps/web/src/lib/local-mirror.test.mjs apps/web/src/lib/repository.test.mjs apps/web/src/lib/runtime.test.mjs apps/web/src/lib/sync-queue.test.mjs && bun run test:desktop:sidecar",
"test:desktop": "bun test apps/desktop/src/main/account-scope.test.mjs apps/desktop/src/main/clipboard-write.test.mjs apps/desktop/src/main/diagnostic-log.test.mjs apps/desktop/src/main/file-association.test.mjs apps/desktop/src/main/file-permissions.test.mjs apps/desktop/src/main/installation-location.test.mjs apps/desktop/src/main/resource-request.test.mjs apps/desktop/src/main/resource-url.test.mjs apps/desktop/src/main/staged-resource.test.mjs apps/desktop/src/main/tray-icon.test.mjs apps/desktop/src/main/user-data-directory.test.mjs apps/desktop/src/main/window-open-policy.test.mjs apps/desktop/src/main/window-visibility.test.mjs apps/web/src/lib/api.test.mjs apps/web/src/lib/app-page-path.test.mjs apps/web/src/lib/clipboard.test.mjs apps/web/src/lib/desktop-repository.test.mjs apps/web/src/lib/desktop-resources.test.mjs apps/web/src/lib/desktop-sync.test.mjs apps/web/src/lib/local-mirror.test.mjs apps/web/src/lib/repository.test.mjs apps/web/src/lib/runtime.test.mjs apps/web/src/lib/sync-queue.test.mjs && bun run test:desktop:sidecar",
"verify:desktop-package": "bun scripts/verify-desktop-package.mjs",
"build:cloudflare": "bun run typecheck && bun run build",
"build:site": "bun --cwd apps/site build",