Skip to content

Commit 4b62585

Browse files
fix: preserve rich clipboard formats on restore (#1020)
* fix: preserve rich clipboard formats on restore * refactor: remove dead html restore branch and redundant guards _saveClipboard no longer emits the html snapshot type, so its restore branch is unreachable. clipboard.readRTF always exists in Electron, and image snapshots are only saved when non-empty, so both guards are redundant. --------- Co-authored-by: Gabriel Stein <gabrielstein416@gmail.com>
1 parent fc6f548 commit 4b62585

2 files changed

Lines changed: 105 additions & 17 deletions

File tree

src/helpers/clipboard.js

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -611,21 +611,44 @@ class ClipboardManager {
611611

612612
_saveClipboard() {
613613
const formats = clipboard.availableFormats();
614+
const data = {};
615+
616+
const text = clipboard.readText();
617+
if (text) data.text = text;
618+
619+
if (formats.includes("text/html")) {
620+
const html = clipboard.readHTML();
621+
if (html) data.html = html;
622+
}
623+
624+
if (formats.includes("text/rtf") || formats.includes("public.rtf")) {
625+
const rtf = clipboard.readRTF();
626+
if (rtf) data.rtf = rtf;
627+
}
628+
614629
if (formats.some((f) => f.startsWith("image/"))) {
615-
return { type: "image", data: clipboard.readImage() };
616-
} else if (formats.includes("text/html")) {
617-
return { type: "html", text: clipboard.readText(), html: clipboard.readHTML() };
618-
} else {
619-
return { type: "text", data: clipboard.readText() };
630+
const image = clipboard.readImage();
631+
if (image && !image.isEmpty()) data.image = image;
632+
}
633+
634+
const keys = Object.keys(data);
635+
if (keys.length === 1 && keys[0] === "image") {
636+
return { type: "image", data: data.image };
620637
}
638+
if (keys.length === 1 && keys[0] === "text") {
639+
return { type: "text", data: data.text };
640+
}
641+
if (keys.length > 0) return { type: "formats", data };
642+
643+
return { type: "text", data: text };
621644
}
622645

623646
_restoreClipboard(original) {
624647
if (!original) return;
625-
if (original.type === "image") {
626-
if (!original.data.isEmpty()) clipboard.writeImage(original.data);
627-
} else if (original.type === "html") {
628-
clipboard.write({ text: original.text, html: original.html });
648+
if (original.type === "formats") {
649+
clipboard.write(original.data);
650+
} else if (original.type === "image") {
651+
clipboard.writeImage(original.data);
629652
} else {
630653
clipboard.writeText(original.data);
631654
}

test/helpers/clipboardRestore.test.js

Lines changed: 73 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ const Module = require("node:module");
55
const fakeClipboard = {
66
text: "",
77
html: "",
8+
rtf: "",
9+
image: null,
810
formats: ["text/plain"],
11+
writes: [],
912
availableFormats() {
1013
return this.formats;
1114
},
@@ -14,20 +17,46 @@ const fakeClipboard = {
1417
},
1518
writeText(text) {
1619
this.text = text;
20+
this.html = "";
21+
this.rtf = "";
22+
this.image = null;
23+
this.formats = ["text/plain"];
24+
this.writes.push(["writeText", text]);
1725
},
1826
readHTML() {
1927
return this.html;
2028
},
29+
readRTF() {
30+
return this.rtf;
31+
},
2132
write(payload) {
22-
this.text = payload.text;
23-
this.html = payload.html;
33+
this.text = payload.text || "";
34+
this.html = payload.html || "";
35+
this.rtf = payload.rtf || "";
36+
this.image = payload.image || null;
37+
this.formats = [];
38+
if (Object.hasOwn(payload, "text")) this.formats.push("text/plain");
39+
if (Object.hasOwn(payload, "html")) this.formats.push("text/html");
40+
if (Object.hasOwn(payload, "rtf")) this.formats.push("text/rtf");
41+
if (Object.hasOwn(payload, "image")) this.formats.push("image/png");
42+
this.writes.push(["write", payload]);
2443
},
2544
readImage() {
26-
return { isEmpty: () => true };
45+
return this.image || emptyImage;
46+
},
47+
writeImage(image) {
48+
this.text = "";
49+
this.html = "";
50+
this.rtf = "";
51+
this.image = image;
52+
this.formats = image && !image.isEmpty() ? ["image/png"] : [];
53+
this.writes.push(["writeImage", image]);
2754
},
28-
writeImage() {},
2955
};
3056

57+
const emptyImage = { isEmpty: () => true };
58+
const nonEmptyImage = { isEmpty: () => false };
59+
3160
const originalLoad = Module._load;
3261
Module._load = function loadWithElectronMock(request, parent, isMain) {
3362
if (request === "electron") {
@@ -44,12 +73,48 @@ Module._load = function loadWithElectronMock(request, parent, isMain) {
4473
const ClipboardManager = require("../../src/helpers/clipboard");
4574
Module._load = originalLoad;
4675

47-
function resetClipboard() {
48-
fakeClipboard.text = "";
49-
fakeClipboard.html = "";
50-
fakeClipboard.formats = ["text/plain"];
76+
function resetClipboard({
77+
text = "",
78+
html = "",
79+
rtf = "",
80+
image = null,
81+
formats = ["text/plain"],
82+
} = {}) {
83+
fakeClipboard.text = text;
84+
fakeClipboard.html = html;
85+
fakeClipboard.rtf = rtf;
86+
fakeClipboard.image = image;
87+
fakeClipboard.formats = formats;
88+
fakeClipboard.writes = [];
5189
}
5290

91+
test("restore preserves rich clipboard formats atomically", () => {
92+
resetClipboard({
93+
formats: ["text/html", "text/rtf", "text/plain", "image/png"],
94+
text: "plain before",
95+
html: "<b>html before</b>",
96+
rtf: "{\\rtf1 before}",
97+
image: nonEmptyImage,
98+
});
99+
const manager = new ClipboardManager();
100+
101+
const snapshot = manager._saveClipboard();
102+
fakeClipboard.writeText("dictated text");
103+
manager._restoreClipboard(snapshot);
104+
105+
assert.deepEqual([...fakeClipboard.availableFormats()].sort(), [
106+
"image/png",
107+
"text/html",
108+
"text/plain",
109+
"text/rtf",
110+
]);
111+
assert.equal(fakeClipboard.text, "plain before");
112+
assert.equal(fakeClipboard.html, "<b>html before</b>");
113+
assert.equal(fakeClipboard.rtf, "{\\rtf1 before}");
114+
assert.equal(fakeClipboard.image, nonEmptyImage);
115+
assert.equal(fakeClipboard.writes.at(-1)[0], "write");
116+
});
117+
53118
test("restore runs when clipboard still contains the pasted text", async () => {
54119
resetClipboard();
55120
fakeClipboard.text = "dictated text";

0 commit comments

Comments
 (0)