fix(web): make the share-image copy button write the image (#429)

Desktop copies go through the main-process clipboard, and a failed copy now tells the user to download instead of doing nothing.
This commit is contained in:
tianma-if committed 2026-09-23 16:32:13 +08:00
1 parent bf0d51a02d
commit f29d4e65d9
8 files changed
+267 -41

No files matched your search

+28
View File
@@ -7,6 +7,34 @@ export const writeTextClipboard = async (clipboard, value) => {
return await clipboard.readText() === value;
};
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
const MAX_IMAGE_CLIPBOARD_BYTES = 32 * 1024 * 1024;
const pngBytes = (value) => {
let bytes = null;
if (value instanceof Uint8Array) bytes = value;
else if (value instanceof ArrayBuffer) bytes = new Uint8Array(value);
else if (ArrayBuffer.isView(value)) bytes = new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
else if (value && value.type === "Buffer" && Array.isArray(value.data)) bytes = Uint8Array.from(value.data);
else throw new TypeError("Clipboard image must be PNG bytes");
if (bytes.byteLength === 0 || bytes.byteLength > MAX_IMAGE_CLIPBOARD_BYTES) return null;
for (let index = 0; index < PNG_SIGNATURE.length; index += 1) {
if (bytes[index] !== PNG_SIGNATURE[index]) return null;
}
return bytes;
};
export const writeImageClipboard = async (clipboard, ClipboardItem, value) => {
const bytes = pngBytes(value);
if (!bytes) return false;
await clipboard.write([new ClipboardItem({
"image/png": new Blob([bytes], { type: "image/png" }),
})]);
return await clipboard.has("image/png");
};
export const writeRichClipboard = async (clipboard, ClipboardItem, input) => {
const html = input?.html;
const plainText = input?.plainText;
+56 -1
View File
@@ -1,5 +1,18 @@
import { describe, expect, test } from "bun:test";
import { writeRichClipboard, writeTextClipboard } from "./clipboard-write.mjs";
import { readFileSync } from "node:fs";
import { writeImageClipboard, writeRichClipboard, writeTextClipboard } from "./clipboard-write.mjs";
const PNG = Uint8Array.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53,
0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41,
0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00,
0x00, 0x00, 0x03, 0x00, 0x01, 0x18, 0xdd, 0x8d,
0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e,
0x44, 0xae, 0x42, 0x60, 0x82,
]);
class TestClipboardItem {
constructor(data) {
@@ -42,6 +55,48 @@ describe("writeTextClipboard", () => {
});
});
describe("writeImageClipboard", () => {
test("writes PNG bytes and verifies the image is on the clipboard", async () => {
const writes = [];
const clipboard = {
write: async (value) => writes.push(value),
has: async (type) => type === "image/png",
};
await expect(writeImageClipboard(clipboard, TestClipboardItem, PNG)).resolves.toBe(true);
expect(writes).toHaveLength(1);
const blob = writes[0][0].data["image/png"];
expect(blob).toBeInstanceOf(Blob);
expect(blob.type).toBe("image/png");
expect(new Uint8Array(await blob.arrayBuffer())).toEqual(PNG);
});
test("does not report success when the image cannot be read back", async () => {
const clipboard = {
write: async () => {},
has: async () => false,
};
await expect(writeImageClipboard(clipboard, TestClipboardItem, PNG)).resolves.toBe(false);
});
test("rejects a non-image payload and ignores bytes that are not a PNG", async () => {
const clipboard = { write: async () => {}, has: async () => true };
await expect(writeImageClipboard(clipboard, TestClipboardItem, "nope")).rejects.toThrow(
"Clipboard image must be PNG bytes",
);
await expect(writeImageClipboard(clipboard, TestClipboardItem, Uint8Array.from([1, 2, 3]))).resolves.toBe(false);
await expect(writeImageClipboard(clipboard, TestClipboardItem, new Uint8Array())).resolves.toBe(false);
});
test("desktop image copy is wired through the main process", () => {
const main = readFileSync(new URL("./index.mjs", import.meta.url), "utf8");
const preload = readFileSync(new URL("../preload/index.cjs", import.meta.url), "utf8");
expect(main).toContain('ipcMain.handle("desktop:copy-image"');
expect(preload).toContain('ipcRenderer.invoke("desktop:copy-image"');
});
});
describe("writeRichClipboard", () => {
test("writes and verifies both rich HTML and plain text with the Electron 44 API", async () => {
const writes = [];
+2 -1
View File
@@ -30,7 +30,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, writeTextClipboard } from "./clipboard-write.mjs";
import { writeImageClipboard, writeRichClipboard, writeTextClipboard } from "./clipboard-write.mjs";
import { captureScreenToNote, createScreenshotCaptureGuard, screenshotImportIpcPayload, writeScreenshotTempPath } from "./screenshot-capture.mjs";
import { LocalDataResetError, scheduleMacLocalDataReset } from "./local-data-reset.mjs";
import { buildDesktopDiagnosticIssueUrl, normalizeDesktopDiagnostic } from "./desktop-diagnostics.mjs";
@@ -1531,6 +1531,7 @@ const startApplication = async () => {
ipcMain.on("desktop:recovered-after-abnormal-exit-sync", (event) => { event.returnValue = recoveredAfterAbnormalExit; });
ipcMain.handle("desktop:copy-text", (_event, value) => writeTextClipboard(clipboard, value));
ipcMain.handle("desktop:copy-html", (_event, input) => writeRichClipboard(clipboard, ClipboardItem, input));
ipcMain.handle("desktop:copy-image", (_event, bytes) => writeImageClipboard(clipboard, ClipboardItem, bytes));
ipcMain.handle("desktop:set-session-token", async (_event, value) => {
await saveDesktopSessionToken(value);
return { stored: Boolean(desktopSessionToken) };
+1
View File
@@ -36,6 +36,7 @@ contextBridge.exposeInMainWorld("edgeeverDesktop", Object.freeze({
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 }),
copyImage: (bytes) => ipcRenderer.invoke("desktop:copy-image", bytes),
setSessionToken: (value) => ipcRenderer.invoke("desktop:set-session-token", value),
clearSessionToken: () => ipcRenderer.invoke("desktop:clear-session-token"),
publicNetworkFetch: (requestId, input) => ipcRenderer.invoke("desktop:public-network-fetch", requestId, input),
@@ -86,8 +86,9 @@ export const ShareNoteImageDialog = ({
const [prepared, setPrepared] = useState<PreparedNoteImage | null>(null);
const [previewUrl, setPreviewUrl] = useState("");
const [error, setError] = useState(false);
const [copied, setCopied] = useState(false);
const [copyState, setCopyState] = useState<"idle" | "copying" | "copied" | "failed">("idle");
const generationRef = useRef(0);
const copyAttemptRef = useRef(0);
useEffect(() => {
if (!open) return;
@@ -101,12 +102,14 @@ export const ShareNoteImageDialog = ({
setShowTags(false);
setShowUpdatedAt(true);
setShowBranding(true);
setCopied(false);
setCopyState("idle");
}, [open, source.title]);
useEffect(() => {
if (!open) return;
const generation = ++generationRef.current;
copyAttemptRef.current += 1;
setCopyState("idle");
setPrepared(null);
setError(false);
const timer = window.setTimeout(() => {
@@ -170,12 +173,20 @@ export const ShareNoteImageDialog = ({
const noticeKind = prepared ? getHtmlImageEmbedNoticeKind(prepared.images) : "none";
const handleCopyImage = async () => {
if (!prepared) return;
if (!prepared || copyState === "copying") return;
const attempt = ++copyAttemptRef.current;
setCopyState("copying");
const success = await copyImageBlobToClipboard(prepared.blob);
if (success) {
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
if (attempt !== copyAttemptRef.current) return;
if (!success) {
setCopyState("failed");
return;
}
setCopyState("copied");
window.setTimeout(() => {
if (attempt !== copyAttemptRef.current) return;
setCopyState((current) => (current === "copied" ? "idle" : current));
}, 2000);
};
const share = async () => {
@@ -388,6 +399,11 @@ export const ShareNoteImageDialog = ({
</div>
</div>
{copyState === "failed" ? (
<p className="border-t border-rose-100 bg-rose-50 px-5 py-2 text-xs text-rose-700" role="alert">
{t("editor.imageShare.copyFailed")}
</p>
) : null}
<DialogFooter className="border-t border-slate-200 px-5 py-3.5">
<Button variant="ghost" size="sm" onClick={() => onOpenChange(false)}>
{t("common.cancel")}
@@ -395,10 +411,10 @@ export const ShareNoteImageDialog = ({
<Button
variant="outline"
size="sm"
disabled={!prepared}
disabled={!prepared || copyState === "copying"}
onClick={() => void handleCopyImage()}
>
{copied ? (
{copyState === "copied" ? (
<>
<Check className="h-4 w-4 text-emerald-600" />
<span className="text-emerald-700">{t("editor.imageShare.copied")}</span>
+108 -1
View File
@@ -1,9 +1,12 @@
import { afterEach, describe, expect, test } from "bun:test";
import { copyHtmlToClipboard, copyTextToClipboard } from "./clipboard.ts";
import { readFileSync } from "node:fs";
import { copyHtmlToClipboard, copyImageBlobToClipboard, copyTextToClipboard } from "./clipboard.ts";
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, "navigator");
const originalDocument = Object.getOwnPropertyDescriptor(globalThis, "document");
const originalClipboardItem = Object.getOwnPropertyDescriptor(globalThis, "ClipboardItem");
const originalURL = Object.getOwnPropertyDescriptor(globalThis, "URL");
const restoreGlobal = (name, descriptor) => {
if (descriptor) {
@@ -17,6 +20,8 @@ afterEach(() => {
restoreGlobal("window", originalWindow);
restoreGlobal("navigator", originalNavigator);
restoreGlobal("document", originalDocument);
restoreGlobal("ClipboardItem", originalClipboardItem);
restoreGlobal("URL", originalURL);
});
describe("copyTextToClipboard", () => {
@@ -98,3 +103,105 @@ describe("copyHtmlToClipboard", () => {
);
});
});
const installClipboardItem = () => {
globalThis.ClipboardItem = class {
constructor(data) {
this.data = data;
}
};
};
describe("copyImageBlobToClipboard", () => {
test("uses the native image clipboard bridge in the desktop app", async () => {
const calls = [];
const png = new Blob([Uint8Array.from([0x89, 0x50, 0x4e, 0x47])], { type: "image/png" });
globalThis.window = {
edgeeverDesktop: {
isAvailable: true,
copyImage: async (bytes) => {
calls.push(bytes);
return true;
},
},
};
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: { clipboard: { write: () => { throw new Error("browser clipboard should not be used"); } } },
});
await expect(copyImageBlobToClipboard(png)).resolves.toBe(true);
expect(calls).toEqual([new Uint8Array(await png.arrayBuffer())]);
});
test("does not report success when native image verification fails", async () => {
globalThis.window = {
edgeeverDesktop: {
isAvailable: true,
copyImage: async () => false,
},
};
await expect(copyImageBlobToClipboard(new Blob([Uint8Array.from([1])], { type: "image/png" }))).resolves.toBe(false);
});
test("starts the browser image write during the click and converts jpeg inside the payload promise", async () => {
const writes = [];
const jpeg = new Blob([Uint8Array.from([1, 2, 3])], { type: "image/jpeg" });
const png = new Blob([Uint8Array.from([9])], { type: "image/png" });
globalThis.window = {};
installClipboardItem();
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: { clipboard: { write: async (items) => writes.push(items) } },
});
globalThis.document = {
createElement: (tag) => {
if (tag === "img") {
const image = {
naturalWidth: 1,
naturalHeight: 1,
set src(_value) {
queueMicrotask(() => image.onload?.());
},
};
return image;
}
return {
width: 0,
height: 0,
getContext: () => ({ drawImage() {} }),
toBlob: (callback) => callback(png),
};
},
};
globalThis.URL = {
createObjectURL: () => "blob:image",
revokeObjectURL() {},
};
const pending = copyImageBlobToClipboard(jpeg);
expect(writes).toHaveLength(1);
const payload = writes[0][0].data["image/png"];
expect(payload).toBeInstanceOf(Promise);
await expect(payload).resolves.toBe(png);
await expect(pending).resolves.toBe(true);
});
test("returns false when the browser refuses the image write", async () => {
globalThis.window = {};
installClipboardItem();
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: { clipboard: { write: async () => { throw new Error("NotAllowedError"); } } },
});
await expect(copyImageBlobToClipboard(new Blob([Uint8Array.from([1])], { type: "image/png" }))).resolves.toBe(false);
});
test("share image dialog surfaces the existing copy failure message", () => {
const source = readFileSync(new URL("../components/dialogs/ShareNoteImageDialog.tsx", import.meta.url), "utf8");
expect(source).toContain('t("editor.imageShare.copyFailed")');
expect(source).toContain('role="alert"');
});
});
+47 -30
View File
@@ -67,47 +67,64 @@ export const copyHtmlToClipboard = async (html: string, plainText: string) => {
if (!copied) throw new Error("Clipboard copy was not available");
};
const toPngBlob = (blob: Blob): Promise<Blob | null> => {
if (blob.type === "image/png") return Promise.resolve(blob);
if (blob.type === "") return Promise.resolve(new Blob([blob], { type: "image/png" }));
if (typeof document === "undefined") return Promise.resolve(null);
return new Promise((resolve) => {
const image = document.createElement("img");
const url = URL.createObjectURL(blob);
image.onload = () => {
const canvas = document.createElement("canvas");
canvas.width = image.naturalWidth;
canvas.height = image.naturalHeight;
canvas.getContext("2d")?.drawImage(image, 0, 0);
URL.revokeObjectURL(url);
canvas.toBlob((png) => resolve(png), "image/png");
};
image.onerror = () => {
URL.revokeObjectURL(url);
resolve(null);
};
image.src = url;
});
};
export const copyImageBlobToClipboard = async (blob: Blob): Promise<boolean> => {
const copyImage = typeof window !== "undefined" ? window.edgeeverDesktop?.copyImage : undefined;
if (copyImage) {
// Renderer clipboard writes are denied in the desktop app. Text and HTML
// already go through the main process; image bytes follow the same path.
try {
const png = await toPngBlob(blob);
if (!png) return false;
return await copyImage(new Uint8Array(await png.arrayBuffer()));
} catch {
return false;
}
}
if (typeof window === "undefined" || !navigator.clipboard?.write || typeof ClipboardItem === "undefined") {
return false;
}
try {
const type = blob.type === "image/jpeg" ? "image/jpeg" : "image/png";
// Safari keeps the click gesture only when write() runs now and the
// ClipboardItem payload is still a Promise. JPEG conversion stays inside it.
const pngPromise = toPngBlob(blob).then((png) => {
if (!png) throw new Error("PNG conversion failed");
return png;
});
await navigator.clipboard.write([
new ClipboardItem({
[type]: blob,
"image/png": pngPromise,
}),
]);
return true;
} catch (error) {
// If writing jpeg directly fails in some browsers (e.g. Safari only supports image/png),
// try converting blob to png via an image/canvas in memory
try {
const img = document.createElement("img");
const url = URL.createObjectURL(blob);
img.src = url;
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () => reject(new Error("Image load failed for clipboard conversion"));
});
const canvas = document.createElement("canvas");
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext("2d");
ctx?.drawImage(img, 0, 0);
URL.revokeObjectURL(url);
const pngBlob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, "image/png"));
if (!pngBlob) return false;
await navigator.clipboard.write([
new ClipboardItem({
"image/png": pngBlob,
}),
]);
return true;
} catch {
console.warn("Failed to write image blob to clipboard:", error);
return false;
}
console.warn("Failed to write image blob to clipboard:", error);
return false;
}
};
+1
View File
@@ -23,6 +23,7 @@ interface EdgeEverDesktopBridge {
getSessionToken(): string;
copyText(value: string): Promise<boolean>;
copyHtml(html: string, plainText: string): Promise<boolean>;
copyImage(bytes: Uint8Array): Promise<boolean>;
setSessionToken(value: string): Promise<{ stored: boolean }>;
clearSessionToken(): Promise<{ stored: false }>;
publicNetworkFetch(requestId: string, input: import("@edgeever/shared").PluginPublicFetchRequest): Promise<import("@edgeever/shared").PluginPublicFetchResponse>;