fix: support desktop rich clipboard copying
This commit is contained in:
1 parent
1b4c7743fc
commit
b7043e7aaf
10 files changed
+130
-27
No files matched your search
@@ -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
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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) };
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,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" }));
|
||||
|
||||
|
||||
Vendored
+1
@@ -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
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user