From f793522ab45fc07ff06e46da7d47b22281f9b617 Mon Sep 17 00:00:00 2001 From: Maciej Pienczyn Date: Mon, 30 Mar 2026 16:06:50 +0200 Subject: [PATCH] fix(messages): override Quill clipboard.onPaste for single image insert + resize CSS Root cause of double paste: Quill's internal clipboard handler AND my DOM handler both processed the image. Fix: override clipboard.onPaste directly to intercept images before Quill processes them. - uploadAndInsertImage() extracted as reusable method - CSS: img.selected gets resize:both for native browser resize handle - Removed complex DOM resize hack (didn't work with Quill contenteditable) Co-Authored-By: Claude Opus 4.6 (1M context) --- static/js/conversations.js | 130 +++++++++----------------- templates/messages/conversations.html | 12 ++- 2 files changed, 56 insertions(+), 86 deletions(-) diff --git a/static/js/conversations.js b/static/js/conversations.js index 01268a7..aff7fed 100644 --- a/static/js/conversations.js +++ b/static/js/conversations.js @@ -1268,97 +1268,34 @@ }, }); - // Image resize: click on image in editor to show resize handles - state.quill.root.addEventListener('click', function(e) { - if (e.target.tagName === 'IMG') { - var img = e.target; - // Remove any existing resize wrapper - document.querySelectorAll('.img-resize-wrapper').forEach(function(w) { - var inner = w.querySelector('img'); - if (inner) w.parentNode.replaceChild(inner, w); - }); - // Wrap image in resize container - var wrapper = document.createElement('div'); - wrapper.className = 'img-resize-wrapper'; - wrapper.contentEditable = 'false'; - wrapper.style.cssText = 'display:inline-block; position:relative; border:2px solid #3b82f6; padding:2px;'; - img.parentNode.insertBefore(wrapper, img); - wrapper.appendChild(img); - // Add resize handle - var handle = document.createElement('div'); - handle.style.cssText = 'position:absolute; right:-4px; bottom:-4px; width:12px; height:12px; background:#3b82f6; border-radius:2px; cursor:nwse-resize;'; - wrapper.appendChild(handle); - // Drag to resize - handle.addEventListener('mousedown', function(me) { - me.preventDefault(); - var startX = me.clientX; - var startW = img.offsetWidth; - function onMove(mm) { - var newW = Math.max(50, startW + (mm.clientX - startX)); - img.style.width = newW + 'px'; - img.style.height = 'auto'; - } - function onUp() { - document.removeEventListener('mousemove', onMove); - document.removeEventListener('mouseup', onUp); - } - document.addEventListener('mousemove', onMove); - document.addEventListener('mouseup', onUp); - }); - } + // Override Quill's image handler — upload pasted/dropped images to server + // This replaces Quill's default clipboard image handling completely + state.quill.getModule('toolbar').addHandler('image', function() { + // Manual image button (if ever added to toolbar) + var input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/*'; + input.onchange = function() { if (input.files[0]) Composer.uploadAndInsertImage(input.files[0]); }; + input.click(); }); - // Click outside image removes resize wrapper - document.addEventListener('click', function(e) { - if (!e.target.closest('.img-resize-wrapper') && !e.target.closest('.ql-editor img')) { - document.querySelectorAll('.img-resize-wrapper').forEach(function(w) { - var inner = w.querySelector('img'); - if (inner) w.parentNode.replaceChild(inner, w); - }); - } - }); - - // Block Quill's default image paste (prevents double insert) - var Delta = Quill.import('delta'); - state.quill.clipboard.addMatcher('IMG', function(node, delta) { - return new Delta(); // Remove pasted images from Quill's clipboard processing - }); - - // Handle pasted images ourselves — upload to server - state.quill.root.addEventListener('paste', function(e) { + // Intercept paste at Quill's clipboard module level + var originalPaste = state.quill.clipboard.onPaste; + state.quill.clipboard.onPaste = function(e) { var clipboardData = e.clipboardData || window.clipboardData; - if (!clipboardData || !clipboardData.items) return; - for (var i = 0; i < clipboardData.items.length; i++) { - var item = clipboardData.items[i]; - if (item.type.indexOf('image') !== -1) { - e.preventDefault(); - e.stopImmediatePropagation(); - var file = item.getAsFile(); - if (file) { - var fd = new FormData(); - fd.append('image', file, 'pasted-image.png'); - api('/api/messages/upload-image', 'POST', fd) - .then(function(result) { - if (result && result.url) { - var range = state.quill.getSelection(true); - state.quill.insertEmbed(range.index, 'image', result.url); - state.quill.setSelection(range.index + 1); - } - }) - .catch(function(err) { - var reader = new FileReader(); - reader.onload = function(ev) { - var range = state.quill.getSelection(true); - state.quill.insertEmbed(range.index, 'image', ev.target.result); - state.quill.setSelection(range.index + 1); - }; - reader.readAsDataURL(file); - }); + if (clipboardData && clipboardData.items) { + for (var i = 0; i < clipboardData.items.length; i++) { + if (clipboardData.items[i].type.indexOf('image') !== -1) { + e.preventDefault(); + var file = clipboardData.items[i].getAsFile(); + if (file) Composer.uploadAndInsertImage(file); + return; // Don't call original paste } - return; } } - }, true); + // No image — let Quill handle text paste normally + if (originalPaste) originalPaste.call(this, e); + }; // Typing indicator state.quill.on('text-change', function () { @@ -1401,6 +1338,29 @@ _sendQueue: [], _queueProcessing: false, + // Upload image and insert into Quill editor + uploadAndInsertImage: function(file) { + var fd = new FormData(); + fd.append('image', file, file.name || 'pasted-image.png'); + api('/api/messages/upload-image', 'POST', fd) + .then(function(result) { + if (result && result.url) { + var range = state.quill.getSelection(true) || { index: state.quill.getLength() }; + state.quill.insertEmbed(range.index, 'image', result.url); + state.quill.setSelection(range.index + 1); + } + }) + .catch(function() { + var reader = new FileReader(); + reader.onload = function(ev) { + var range = state.quill.getSelection(true) || { index: state.quill.getLength() }; + state.quill.insertEmbed(range.index, 'image', ev.target.result); + state.quill.setSelection(range.index + 1); + }; + reader.readAsDataURL(file); + }); + }, + sendContent: function (html, text) { if (!state.currentConversationId) return; // Add to queue with current state snapshot diff --git a/templates/messages/conversations.html b/templates/messages/conversations.html index 5baa853..dd9b5dd 100644 --- a/templates/messages/conversations.html +++ b/templates/messages/conversations.html @@ -19,13 +19,23 @@ object-fit: contain; } .message-content img:hover { opacity: 0.9; } - /* Images in Quill editor — resizable */ + /* Images in Quill editor — click to select, drag corner to resize */ .ql-editor img { max-width: 100%; max-height: 200px; border-radius: 4px; + cursor: pointer; + } + .ql-editor img:hover { + outline: 2px solid #3b82f6; + outline-offset: 2px; + } + .ql-editor img.selected { + outline: 2px solid #3b82f6; + outline-offset: 2px; resize: both; overflow: hidden; + display: inline-block; } /* Hide floating buttons that overlap with chat input area */ .pwa-smart-banner, .staging-panel-toggle { display: none !important; }