mirror of
https://github.com/TommyFang2077/dsh-desktop.git
synced 2026-08-17 09:06:36 +08:00
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:
@@ -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();
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user