fix: support image drop and paste in terminal
Allow drag/drop and clipboard image paste in the dashboard terminal so users can quickly upload images into the active shell session, with a visible hint for discoverability. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,57 @@
|
||||
let tabCounter = 0;
|
||||
const tabData = new Map();
|
||||
|
||||
function cleanName(name) {
|
||||
return (name || "image")
|
||||
.replace(/[^a-zA-Z0-9._-]/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "") || "image";
|
||||
}
|
||||
|
||||
function extensionFor(type) {
|
||||
if (type === "image/png") return "png";
|
||||
if (type === "image/jpeg") return "jpg";
|
||||
if (type === "image/webp") return "webp";
|
||||
if (type === "image/gif") return "gif";
|
||||
return "bin";
|
||||
}
|
||||
|
||||
async function fileToBase64(file) {
|
||||
const buf = await file.arrayBuffer();
|
||||
let binary = "";
|
||||
const bytes = new Uint8Array(buf);
|
||||
const chunkSize = 0x8000;
|
||||
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||||
binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
async function sendDroppedImage(term, ws, file) {
|
||||
if (!file?.type?.startsWith("image/")) return;
|
||||
if (ws.readyState !== 1) return;
|
||||
|
||||
const ext = extensionFor(file.type);
|
||||
const baseName = cleanName(file.name).replace(/\.[a-zA-Z0-9]+$/, "");
|
||||
const stamp = Date.now();
|
||||
const imagePath = `/tmp/claude-images/${baseName || "image"}-${stamp}.${ext}`;
|
||||
const b64 = await fileToBase64(file);
|
||||
|
||||
const cmd = [
|
||||
"python3 - <<'PY'",
|
||||
"import base64, pathlib",
|
||||
`p = pathlib.Path(${JSON.stringify(imagePath)})`,
|
||||
"p.parent.mkdir(parents=True, exist_ok=True)",
|
||||
`p.write_bytes(base64.b64decode(${JSON.stringify(b64)}))`,
|
||||
`print(f'saved image -> {p}')`,
|
||||
"PY",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
ws.send(new TextEncoder().encode(cmd));
|
||||
term.write(`\r\n\x1b[90m[Uploaded image to ${imagePath}]\x1b[0m\r\n`);
|
||||
}
|
||||
|
||||
function addTab(hostId) {
|
||||
const id = ++tabCounter;
|
||||
const label = hosts.find((h) => h.id === hostId).label;
|
||||
@@ -29,6 +80,9 @@
|
||||
if (d) {
|
||||
d.voice?.destroy();
|
||||
d.ro?.disconnect();
|
||||
d.term?.element?.removeEventListener("dragover", d.handleDragOver);
|
||||
d.term?.element?.removeEventListener("drop", d.handleDrop);
|
||||
d.term?.textarea?.removeEventListener("paste", d.handlePaste);
|
||||
d.ws?.close();
|
||||
d.term?.dispose();
|
||||
tabData.delete(id);
|
||||
@@ -89,17 +143,52 @@
|
||||
ws.onerror = () => updateTab(tabId, { connected: false, error: "Connection failed" });
|
||||
term.onData((data) => ws.readyState === 1 && ws.send(new TextEncoder().encode(data)));
|
||||
|
||||
const handleDragOver = (e) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const handleDrop = async (e) => {
|
||||
e.preventDefault();
|
||||
if (activeTab !== tabId) return;
|
||||
const files = [...(e.dataTransfer?.files || [])].filter((f) => f.type?.startsWith("image/"));
|
||||
for (const file of files) {
|
||||
await sendDroppedImage(term, ws, file);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaste = async (e) => {
|
||||
if (activeTab !== tabId) return;
|
||||
const items = [...(e.clipboardData?.items || [])]
|
||||
.filter((it) => it.kind === "file" && it.type?.startsWith("image/"));
|
||||
for (const item of items) {
|
||||
const file = item.getAsFile();
|
||||
if (file) await sendDroppedImage(term, ws, file);
|
||||
}
|
||||
};
|
||||
|
||||
term.element?.addEventListener("dragover", handleDragOver);
|
||||
term.element?.addEventListener("drop", handleDrop);
|
||||
term.textarea?.addEventListener("paste", handlePaste);
|
||||
|
||||
const ro = new ResizeObserver(() => { fit.fit(); sendSize(); });
|
||||
ro.observe(el);
|
||||
const voice = new VoiceSession(() => { voiceTick++; });
|
||||
voice.attach(term, ws);
|
||||
tabData.set(tabId, { ws, term, ro, el, voice });
|
||||
tabData.set(tabId, { ws, term, ro, el, voice, handleDragOver, handleDrop, handlePaste });
|
||||
})();
|
||||
}
|
||||
|
||||
function activeVoice() { return voiceTick >= 0 && tabData.get(activeTab)?.voice; }
|
||||
|
||||
onDestroy(() => tabData.forEach((d) => { d.voice?.destroy(); d.ro?.disconnect(); d.ws?.close(); d.term?.dispose(); }));
|
||||
onDestroy(() => tabData.forEach((d) => {
|
||||
d.voice?.destroy();
|
||||
d.ro?.disconnect();
|
||||
d.term?.element?.removeEventListener("dragover", d.handleDragOver);
|
||||
d.term?.element?.removeEventListener("drop", d.handleDrop);
|
||||
d.term?.textarea?.removeEventListener("paste", d.handlePaste);
|
||||
d.ws?.close();
|
||||
d.term?.dispose();
|
||||
}));
|
||||
</script>
|
||||
<div class={activeVoice()?.voiceMode ? 'flex flex-col h-[calc(100vh-4rem)] overflow-hidden gap-1' : 'space-y-2'}>
|
||||
{#if !activeVoice()?.voiceMode}
|
||||
@@ -160,6 +249,10 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-surface-500">
|
||||
Tip: drag & drop or paste images into the active terminal tab to upload them to <code class="text-surface-400">/tmp/claude-images</code>.
|
||||
</p>
|
||||
|
||||
{#if !tabs.length}
|
||||
<div class="flex items-center justify-center h-[calc(100vh-12rem)] text-surface-500 text-sm">
|
||||
Click + to open a terminal
|
||||
|
||||
Reference in New Issue
Block a user