feat: image paste (Ctrl+V) and drag-and-drop into terminal (#84)

* feat: add image paste and drag-and-drop support

Clipboard paste (Ctrl+V) and drag-and-drop of image files into the
terminal. Images are saved to {workdir}/.claude-images/ and the
absolute path is inserted into the terminal input for Claude to read.

- POST /api/sessions/:id/paste-image endpoint (hand-parsed multipart)
- image-input.js mixin with paste trap technique (works on HTTP)
- Ctrl+V intercepted at xterm keyboard level, routes through hidden
  contenteditable div to capture both image and text clipboard data
- Drag-and-drop on terminal container with visual overlay
- Session cleanup deletes .claude-images/ on destroy

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* security: remove SVG from paste-image allowlist

Drops .svg / image/svg+xml from the paste-image endpoint. SVGs are
served as image/svg+xml via /api/sessions/:id/file-raw, same-origin,
under a CSP that permits inline scripts — which would execute on view.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: arkon <arkon.85@hotmail.com>
This commit is contained in:
aakhter
2026-05-17 05:57:07 +02:00
committed by GitHub
co-authored by Claude Opus 4.7 arkon
parent 98966def03
commit 94bcf524a2
7 changed files with 293 additions and 2 deletions
+143
View File
@@ -0,0 +1,143 @@
/**
* Image Input Mixin - Clipboard paste and drag-and-drop image support
*
* For paste: intercepts Ctrl+V at the xterm keyboard level, creates a temporary
* hidden contenteditable div ("paste trap"), lets the browser's native paste fill
* it, then checks for image data. This works on HTTP (no secure context needed).
*
* For drag-and-drop: listens on the terminal container for file drops.
*
* @dependency app.js (uses global `app` for sendInput, activeSessionId, showToast)
* @dependency panels-ui.js (provides showToast)
*/
Object.assign(CodemanApp.prototype, {
initImageInput() {
// Drag-and-drop handlers on terminal container
const container = document.getElementById('terminalContainer');
if (!container) return;
container.addEventListener('dragover', (e) => {
e.preventDefault();
if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
container.classList.add('drag-active');
}
});
container.addEventListener('dragleave', (e) => {
if (!container.contains(e.relatedTarget)) {
container.classList.remove('drag-active');
}
});
container.addEventListener('drop', (e) => {
e.preventDefault();
container.classList.remove('drag-active');
if (!this.activeSessionId) return;
if (!e.dataTransfer || !e.dataTransfer.files.length) return;
const imageFiles = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith('image/'));
if (imageFiles.length === 0) {
this.showToast('Only image files are supported', 'error');
return;
}
this._uploadAndInsertImages(imageFiles);
});
},
// Called from customKeyEventHandler in terminal-ui.js on Ctrl+V keydown.
// Creates a hidden paste trap, lets the browser paste into it, then inspects
// the result for images. Works on plain HTTP (no Clipboard API needed).
_handleImagePaste() {
const self = this;
// Create a hidden contenteditable div to receive the paste
const trap = document.createElement('div');
trap.contentEditable = 'true';
trap.style.cssText = 'position:fixed;left:-9999px;top:0;width:1px;height:1px;opacity:0;overflow:hidden';
document.body.appendChild(trap);
trap.focus();
// Listen for the paste event on our trap
trap.addEventListener('paste', function(e) {
e.stopPropagation();
// Check for images in clipboard items
var imageFiles = [];
var items = e.clipboardData && e.clipboardData.items;
if (items) {
for (var i = 0; i < items.length; i++) {
if (items[i].type.startsWith('image/')) {
var blob = items[i].getAsFile();
if (blob) imageFiles.push(blob);
}
}
}
// Clean up the trap
setTimeout(function() {
if (trap.parentNode) trap.parentNode.removeChild(trap);
// Refocus the terminal
if (self.terminal) self.terminal.focus();
}, 0);
if (imageFiles.length > 0) {
e.preventDefault();
self._uploadAndInsertImages(imageFiles);
} else {
// No image -- extract text and send to terminal
var text = e.clipboardData ? e.clipboardData.getData('text/plain') : '';
e.preventDefault();
if (text) self.sendInput(text);
}
});
// Trigger the browser's native paste via execCommand
// (this fires the paste event on our focused trap element)
document.execCommand('paste');
},
async _uploadAndInsertImages(files) {
const sessionId = this.activeSessionId;
if (!sessionId) return;
this.showToast('Uploading ' + files.length + ' image' + (files.length > 1 ? 's' : '') + '...', 'info');
const paths = [];
for (const file of files) {
try {
const path = await this._uploadPasteImage(sessionId, file);
paths.push(path);
} catch (err) {
this.showToast('Upload failed: ' + (err.message || 'unknown error'), 'error');
}
}
if (paths.length > 0) {
const pathStr = paths.join(' ');
await this.sendInput(pathStr);
this.showToast(paths.length + ' image' + (paths.length > 1 ? 's' : '') + ' ready', 'success');
}
},
async _uploadPasteImage(sessionId, file) {
const form = new FormData();
form.append('image', file);
const resp = await fetch('/api/sessions/' + sessionId + '/paste-image', {
method: 'POST',
body: form,
});
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data.error || 'HTTP ' + resp.status);
}
const data = await resp.json();
return data.path;
},
});