fix: read Linux clipboard images natively and ingest them as paste

GTK/wl-paste/xclip fallbacks plus a paste-event path so ModLens can take over image paste instead of only synthesizing drop.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-16 12:39:08 +08:00
parent d6f5359873
commit 00209d55e6
7 changed files with 521 additions and 67 deletions

View File

@@ -146,23 +146,76 @@
inject();
}
function clipboardText(e, type) {
try {
return e.clipboardData ? (e.clipboardData.getData(type) || "") : "";
} catch (err) {
return "";
}
}
async function readAsyncClipboardImages() {
if (!navigator.clipboard || !navigator.clipboard.read) return [];
try {
const items = await navigator.clipboard.read();
const files = [];
for (const item of items) {
for (const type of item.types) {
if (type.indexOf("image/") !== 0) continue;
const blob = await item.getType(type);
if (!blob || !blob.size) continue;
const ext = (type.split("/")[1] || "png").replace("jpeg", "jpg");
files.push(new File([blob], "clipboard." + ext, { type: blob.type || type }));
}
}
return files;
} catch (err) {
return [];
}
}
const harness = /^(127\.0\.0\.1|localhost|\[::1\])$/.test(location.hostname);
if (harness && location.protocol.indexOf("http") === 0) {
document.addEventListener("paste", async function (e) {
const t = api();
if (!t || !t.core) return;
if (window.__dshDesktopIngesting) return;
const ingest = window.__dshDesktopIngestFiles;
const fromEvent = window.__dshDesktopImageFilesFromEvent;
const pathLike = window.__dshDesktopLooksLikeImagePath;
if (!ingest) return;
try {
const items = e.clipboardData ? Array.from(e.clipboardData.items) : [];
if (items.some(function (item) { return item.kind === "file" && item.getAsFile(); })) {
return;
const local = fromEvent ? fromEvent(e) : [];
const text = clipboardText(e, "text/plain");
const uris = clipboardText(e, "text/uri-list");
const steal = local.length > 0
|| (pathLike && (pathLike(text) || pathLike(uris)))
|| !String(text).trim();
if (!steal) return;
e.preventDefault();
e.stopImmediatePropagation();
window.__dshDesktopIngesting = true;
try {
if (local.length) {
await ingest(local);
return;
}
let files = await readAsyncClipboardImages();
if (!files.length) {
const t = api();
if (t && t.core) {
const payload = await t.core.invoke("read_clipboard_images");
if (payload && payload.length && window.__dshDesktopPasteFiles) {
await window.__dshDesktopPasteFiles(payload);
return;
}
}
}
if (files.length) await ingest(files);
} finally {
window.__dshDesktopIngesting = false;
}
const files = await t.core.invoke("read_clipboard_images");
if (files && files.length) {
e.preventDefault();
e.stopImmediatePropagation();
if (window.__dshDesktopPasteFiles) window.__dshDesktopPasteFiles(files);
}
} catch (err) {}
} catch (err) {
window.__dshDesktopIngesting = false;
}
}, true);
}
})();

View File

@@ -1,45 +1,179 @@
(function () {
const IMAGE_PATH = /^(?:file:\/\/|(?:\/|\.{1,2}\/)).+\.(?:png|jpe?g|gif|webp|bmp|tiff?)$/i;
const IMAGE_MIME = /^image\/(png|jpe?g|gif|webp|bmp|tiff?)$/i;
function looksLikeImagePath(text) {
const first = (text || "").trim().split(/\s+/, 1)[0] || "";
return IMAGE_PATH.test(first);
}
document.addEventListener("paste", function (e) {
try {
const items = Array.from(e.clipboardData ? e.clipboardData.items : []);
const files = items.filter(function (item) { return item.kind === "file"; })
.map(function (item) { return item.getAsFile(); })
.filter(Boolean);
if (files.length > 0) return;
const text = e.clipboardData ? (e.clipboardData.getData("text/plain") || "") : "";
const uris = e.clipboardData ? (e.clipboardData.getData("text/uri-list") || "") : "";
if (looksLikeImagePath(text) || looksLikeImagePath(uris)) {
e.preventDefault();
e.stopImmediatePropagation();
function addImageFile(out, seen, file) {
if (!file || !file.size) return;
const type = file.type || "";
if (type && !IMAGE_MIME.test(type)) return;
if (!type && !IMAGE_PATH.test(file.name || "")) return;
const key = (file.name || "") + ":" + file.size + ":" + type;
if (seen.has(key)) return;
seen.add(key);
out.push(file);
}
function imageFilesFromEvent(e) {
const out = [];
const seen = new Set();
const data = e.clipboardData || e.dataTransfer;
if (!data) return out;
const items = data.items ? Array.from(data.items) : [];
for (const item of items) {
if (item.kind === "file" || (item.type && item.type.indexOf("image/") === 0)) {
try {
addImageFile(out, seen, item.getAsFile());
} catch (err) {}
}
}
if (data.files) {
for (const file of data.files) addImageFile(out, seen, file);
}
return out;
}
function composer() {
const el = document.activeElement;
if (el && (el.tagName === "TEXTAREA" || el.tagName === "INPUT" || el.isContentEditable)) {
return el;
}
return document.querySelector("form textarea, textarea, [contenteditable='true']");
}
function insertText(target, text) {
const el = target && (target.tagName === "TEXTAREA" || target.tagName === "INPUT")
? target
: composer();
if (!el || (el.tagName !== "TEXTAREA" && el.tagName !== "INPUT")) return;
el.focus();
let inserted = false;
try {
inserted = document.execCommand("insertText", false, text);
} catch (err) {
inserted = false;
}
if (!inserted) {
const proto = el.tagName === "TEXTAREA"
? window.HTMLTextAreaElement.prototype
: window.HTMLInputElement.prototype;
const setter = Object.getOwnPropertyDescriptor(proto, "value").set;
const start = el.selectionStart || el.value.length;
const end = el.selectionEnd || start;
setter.call(el, el.value.slice(0, start) + text + el.value.slice(end));
el.dispatchEvent(new Event("input", { bubbles: true }));
}
}
function dataTransferOf(files) {
const dt = new DataTransfer();
for (const file of files) dt.items.add(file);
return dt;
}
function dispatchWithData(type, target, dt, key) {
let ev;
try {
if (type === "paste") {
ev = new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: dt });
} else {
ev = new DragEvent(type, { bubbles: true, cancelable: true, dataTransfer: dt });
}
} catch (err) {
ev = new Event(type, { bubbles: true, cancelable: true });
}
try {
Object.defineProperty(ev, key, { value: dt, configurable: true });
} catch (err) {}
}, true);
target.dispatchEvent(ev);
return ev;
}
function currentModelLabel() {
const buttons = document.querySelectorAll("button[aria-label]");
for (const button of buttons) {
const label = button.getAttribute("aria-label") || "";
if (/选择模型|select model|current model/i.test(label)) return label;
}
return "";
}
function shouldTakeoverPaste() {
const label = currentModelLabel();
return fetch("/modlens/paste?model=" + encodeURIComponent(label))
.then(function (res) {
if (!res.ok) return false;
return res.json().then(function (body) {
return body && body.takeover === true;
});
})
.catch(function () {
return false;
});
}
function uploadModlens(files) {
return Promise.all(
files.map(function (file) {
return file.arrayBuffer().then(function (buffer) {
return fetch("/modlens/paste", { method: "POST", body: buffer }).then(function (res) {
if (!res.ok) throw new Error("modlens paste failed " + res.status);
return res.json();
});
});
})
).then(function (results) {
const text = results.map(function (r) { return r.path; }).filter(Boolean).join(" ");
if (text) insertText(composer(), text + " ");
});
}
function deliverNative(files) {
const prev = window.__dshDesktopIngesting;
window.__dshDesktopIngesting = true;
try {
const dt = dataTransferOf(files);
const target = composer() || document;
const paste = dispatchWithData("paste", target, dt, "clipboardData");
if (paste.defaultPrevented) return;
dispatchWithData("drop", document, dt, "dataTransfer");
} finally {
window.__dshDesktopIngesting = prev;
}
}
window.__dshDesktopLooksLikeImagePath = looksLikeImagePath;
window.__dshDesktopImageFilesFromEvent = imageFilesFromEvent;
window.__dshDesktopIngestFiles = function (files) {
if (!files || !files.length) return Promise.resolve();
return shouldTakeoverPaste()
.then(function (takeover) {
if (takeover) return uploadModlens(files);
deliverNative(files);
})
.catch(function (err) {
console.error("dsh-desktop paste image failed", err);
});
};
window.__dshDesktopPasteFiles = function (items) {
try {
const dt = new DataTransfer();
const files = [];
for (const item of items) {
const bin = atob(item.b64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
dt.items.add(new File([bytes], item.name, { type: item.type }));
files.push(new File([bytes], item.name, { type: item.type }));
}
const drop = new DragEvent("drop", {
bubbles: true,
cancelable: true,
dataTransfer: dt
});
try {
Object.defineProperty(drop, "dataTransfer", { value: dt, configurable: true });
} catch (e) {}
document.dispatchEvent(drop);
return window.__dshDesktopIngestFiles(files);
} catch (err) {
console.error("dsh-desktop paste image failed", err);
return Promise.resolve();
}
};
})();