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:
@@ -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);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -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