mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 13:39:41 +02:00
- Extend the keyboard accessory paste dialog with an image picker (camera / photo library) plus best-effort image paste, routing selected files through the existing _uploadAndInsertImages pipeline - Re-encode images to standard JPEG/PNG in the browser before upload, so mislabeled gallery images (e.g. MIUI WebP claiming image/jpeg) pass the server magic-byte check; PNG keeps transparency, GIF passes through untouched, decode failures fall back to the original file - Log the real byte header on the paste-image magic-mismatch branch to pin down any remaining format mismatches without a guessing loop - Ignore the runtime .claude-images/ upload directory Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
201 lines
7.1 KiB
JavaScript
201 lines
7.1 KiB
JavaScript
/**
|
|
* 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 -- route text through xterm's paste() so bracketed-paste
|
|
// markers (CSI 200~ ... CSI 201~) survive when the inner application
|
|
// has enabled bracketed-paste mode (Claude Code does). Sending text
|
|
// via raw sendInput() strips those markers and makes pasted input
|
|
// indistinguishable from typed input, weakening the CLI's
|
|
// prompt-injection defenses.
|
|
var text = e.clipboardData ? e.clipboardData.getData('text/plain') : '';
|
|
e.preventDefault();
|
|
if (text && self.terminal) self.terminal.paste(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 {
|
|
// Re-encode to a standard JPEG/PNG before upload. Galleries on some
|
|
// phones (notably Android/MIUI) hand back a WebP/HEIF whose filename and
|
|
// MIME claim "image/jpeg", which passes the server's extension allowlist
|
|
// but fails its magic-byte check ("bytes do not match declared type").
|
|
// Decoding through the browser and re-encoding guarantees the bytes
|
|
// match the extension we send.
|
|
const normalized = await this._normalizeImageForUpload(file);
|
|
const path = await this._uploadPasteImage(sessionId, normalized);
|
|
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;
|
|
},
|
|
|
|
// Decode an image File through the browser and re-encode it to a format the
|
|
// server accepts, so the uploaded bytes always match their declared
|
|
// extension. PNG is re-encoded as PNG (preserves transparency); everything
|
|
// else (JPEG, WebP, HEIF, unknown) becomes JPEG. Animated GIFs are passed
|
|
// through untouched since a canvas would flatten them to one frame. On any
|
|
// decode/encode failure the original file is returned unchanged so the server
|
|
// still gets a chance (and logs a precise diagnostic).
|
|
async _normalizeImageForUpload(file) {
|
|
if (file.type === 'image/gif') return file;
|
|
|
|
const toPng = file.type === 'image/png';
|
|
const url = URL.createObjectURL(file);
|
|
try {
|
|
const img = new Image();
|
|
await new Promise((resolve, reject) => {
|
|
img.onload = () => resolve();
|
|
img.onerror = () => reject(new Error('decode failed'));
|
|
img.src = url;
|
|
});
|
|
|
|
const width = img.naturalWidth;
|
|
const height = img.naturalHeight;
|
|
if (!width || !height) return file;
|
|
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) return file;
|
|
ctx.drawImage(img, 0, 0);
|
|
|
|
const mime = toPng ? 'image/png' : 'image/jpeg';
|
|
const blob = await new Promise((resolve) => canvas.toBlob(resolve, mime, 0.92));
|
|
if (!blob) return file;
|
|
|
|
const baseName = (file.name || 'image').replace(/\.[^.]+$/, '') || 'image';
|
|
return new File([blob], baseName + (toPng ? '.png' : '.jpg'), { type: mime });
|
|
} catch (err) {
|
|
console.warn('Image re-encode failed, uploading original:', err);
|
|
return file;
|
|
} finally {
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
},
|
|
|
|
});
|