diff --git a/.gitignore b/.gitignore index cd0cee43..acd81701 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/apps/desktop/src/main/clipboard-write.mjs b/apps/desktop/src/main/clipboard-write.mjs new file mode 100644 index 00000000..591022a1 --- /dev/null +++ b/apps/desktop/src/main/clipboard-write.mjs @@ -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; +}; diff --git a/apps/desktop/src/main/clipboard-write.test.mjs b/apps/desktop/src/main/clipboard-write.test.mjs new file mode 100644 index 00000000..3342a9c8 --- /dev/null +++ b/apps/desktop/src/main/clipboard-write.test.mjs @@ -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: () => 'Hello', + }; + + expect(writeRichClipboard(clipboard, { + html: "Hello", + plainText: "Hello", + })).toBe(true); + expect(writes).toEqual([{ + html: "Hello", + 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: "Hello", + plainText: "Hello", + })).toBe(false); + }); + + test("rejects malformed renderer input", () => { + const clipboard = { write: () => {}, readText: () => "", readHTML: () => "" }; + expect(() => writeRichClipboard(clipboard, { html: "
Hello
" })).toThrow( + "Clipboard HTML and plain text must be strings", + ); + }); +}); diff --git a/apps/desktop/src/main/index.mjs b/apps/desktop/src/main/index.mjs index e44fab09..e3604a31 100644 --- a/apps/desktop/src/main/index.mjs +++ b/apps/desktop/src/main/index.mjs @@ -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) }; diff --git a/apps/desktop/src/preload/index.cjs b/apps/desktop/src/preload/index.cjs index 27e773f1..44bd0eff 100644 --- a/apps/desktop/src/preload/index.cjs +++ b/apps/desktop/src/preload/index.cjs @@ -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"), diff --git a/apps/web/src/lib/clipboard.test.mjs b/apps/web/src/lib/clipboard.test.mjs index 193dd87f..d3326469 100644 --- a/apps/web/src/lib/clipboard.test.mjs +++ b/apps/web/src/lib/clipboard.test.mjs @@ -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("Hello", "Hello")).resolves.toBeUndefined(); + expect(calls).toEqual([["Hello", "Hello"]]); + }); + + test("fails when native rich clipboard verification fails", async () => { + globalThis.window = { + edgeeverDesktop: { + isAvailable: true, + copyHtml: async () => false, + }, + }; + + await expect(copyHtmlToClipboard("Hello", "Hello")).rejects.toThrow( + "Native rich clipboard verification failed", + ); + }); +}); diff --git a/apps/web/src/lib/clipboard.ts b/apps/web/src/lib/clipboard.ts index b6a835ca..29c1594d 100644 --- a/apps/web/src/lib/clipboard.ts +++ b/apps/web/src/lib/clipboard.ts @@ -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"); +}; diff --git a/apps/web/src/lib/wechat-copy.ts b/apps/web/src/lib/wechat-copy.ts index 7dea72a4..898db615 100644 --- a/apps/web/src/lib/wechat-copy.ts +++ b/apps/web/src/lib/wechat-copy.ts @@ -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" })); diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index 97a80701..5d0b423c 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -14,6 +14,7 @@ interface EdgeEverDesktopBridge { setApiBaseUrl(value: string): Promise