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:
1 parent
bf0d51a02d
commit
f29d4e65d9
8 files changed
+267
-41
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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) };
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
Vendored
+1
@@ -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>;
|
||||
|
||||
Reference in new issue
Block a user