From b96a9c599a36f95c5baf7173895433ab09b4b5ac Mon Sep 17 00:00:00 2001 From: Matthew Campbell Date: Tue, 21 Apr 2026 00:20:09 +1000 Subject: [PATCH 01/13] Inline images, lightbox, and attachment UX Paste/drop to upload images in the editor with drag-resize handles and a size toolbar. Image attachments open in a lightbox with zoom/pan and keyboard navigation. File attachments get type-specific icons and a PDF preview pane. Outgoing email inlines images as multipart/related CID parts so recipients see them even if our /uploads/ URLs later expire or the server is unreachable. Orphan uploads (pasted into the reply before the message is persisted) are embedded too; cross-conversation references are still rejected. Also tightens replaceCIDInContent HTML safety and scopes the uploads URL regex to attributes only. --- .../main/src/components/ImageLightbox.vue | 277 +++++++++++++ .../main/src/components/editor/TextEditor.vue | 390 +++++++++++++++++- .../conversation/message/MessageBubble.vue | 61 ++- .../attachment/FileAttachmentPreview.vue | 148 +++++-- .../attachment/ImageAttachmentPreview.vue | 40 +- .../attachment/MessageAttachmentPreview.vue | 59 ++- frontend/shared-ui/assets/styles/main.scss | 8 + i18n/en.json | 8 + internal/conversation/conversation.go | 1 + internal/conversation/message.go | 188 ++++++++- 10 files changed, 1123 insertions(+), 57 deletions(-) create mode 100644 frontend/apps/main/src/components/ImageLightbox.vue diff --git a/frontend/apps/main/src/components/ImageLightbox.vue b/frontend/apps/main/src/components/ImageLightbox.vue new file mode 100644 index 00000000..8a47cade --- /dev/null +++ b/frontend/apps/main/src/components/ImageLightbox.vue @@ -0,0 +1,277 @@ + + + diff --git a/frontend/apps/main/src/components/editor/TextEditor.vue b/frontend/apps/main/src/components/editor/TextEditor.vue index 4406030a..151b1bd6 100644 --- a/frontend/apps/main/src/components/editor/TextEditor.vue +++ b/frontend/apps/main/src/components/editor/TextEditor.vue @@ -150,7 +150,11 @@ import TableRow from '@tiptap/extension-table-row' import TableCell from '@tiptap/extension-table-cell' import TableHeader from '@tiptap/extension-table-header' import { useTypingIndicator } from '@shared-ui/composables' +import { handleHTTPError } from '@shared-ui/utils/http.js' import { useConversationStore } from '@main/stores/conversation' +import { useEmitter } from '@main/composables/useEmitter' +import { EMITTER_EVENTS } from '@main/constants/emitterEvents' +import api from '@main/api' import mentionSuggestion from './mentionSuggestion' const textContent = defineModel('textContent', { default: '' }) @@ -184,9 +188,127 @@ const props = defineProps({ } }) -const emit = defineEmits(['send', 'aiPromptSelected', 'mentionsChanged']) +const emit = defineEmits(['send', 'aiPromptSelected', 'mentionsChanged', 'filesDropped']) const emitPrompt = (key) => emit('aiPromptSelected', key) +const emitter = useEmitter() +const isUploadingImage = ref(false) + +// Downscale images larger than MAX_UPLOAD_DIM before upload. Display size is +// controlled separately by the editor's image toolbar, so there's no point +// uploading multi-megapixel screenshots in full resolution. +const MAX_UPLOAD_DIM = 2000 +const resizeImage = (file) => { + return new Promise((resolve) => { + if (!file.type.startsWith('image/') || file.type === 'image/gif') { + resolve(file) + return + } + const img = new window.Image() + const url = URL.createObjectURL(file) + img.onload = () => { + URL.revokeObjectURL(url) + if (img.width <= MAX_UPLOAD_DIM && img.height <= MAX_UPLOAD_DIM) { + resolve(file) + return + } + let w = img.width + let h = img.height + if (w > MAX_UPLOAD_DIM) { + h = Math.round(h * (MAX_UPLOAD_DIM / w)) + w = MAX_UPLOAD_DIM + } + if (h > MAX_UPLOAD_DIM) { + w = Math.round(w * (MAX_UPLOAD_DIM / h)) + h = MAX_UPLOAD_DIM + } + const canvas = document.createElement('canvas') + canvas.width = w + canvas.height = h + canvas.getContext('2d').drawImage(img, 0, 0, w, h) + canvas.toBlob( + (blob) => resolve(blob ? new File([blob], file.name, { type: file.type }) : file), + file.type, + 0.92 + ) + } + img.onerror = () => { + URL.revokeObjectURL(url) + resolve(file) + } + img.src = url + }) +} + +const uploadImage = async (file) => { + file = await resizeImage(file) + isUploadingImage.value = true + try { + const response = await api.uploadMedia({ + files: file, + inline: true, + linked_model: 'messages' + }) + return response.data.data.url + } catch (error) { + emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { + variant: 'destructive', + description: handleHTTPError(error).message || 'Failed to upload image' + }) + return null + } finally { + isUploadingImage.value = false + } +} + +const insertImage = (url) => { + if (url && editor.value) { + editor.value.chain().focus().setImage({ src: url }).run() + } +} + +// Paste handler: catch image content from the clipboard, upload, then insert. +const handlePaste = (view, event) => { + const items = event.clipboardData?.items + if (!items) return false + for (const item of items) { + if (item.type.startsWith('image/')) { + event.preventDefault() + const file = item.getAsFile() + if (file) { + uploadImage(file).then((url) => { + if (url) insertImage(url) + }) + } + return true + } + } + return false +} + +// Drop handler: image files go inline, everything else is emitted as +// `filesDropped` so the parent can attach them as regular attachments. +const handleDrop = (view, event) => { + const files = event.dataTransfer?.files + if (!files || files.length === 0) return false + + const imageFiles = [] + const otherFiles = [] + for (const file of files) { + if (file.type.startsWith('image/')) imageFiles.push(file) + else otherFiles.push(file) + } + if (imageFiles.length === 0 && otherFiles.length === 0) return false + + event.preventDefault() + for (const file of imageFiles) { + uploadImage(file).then((url) => { + if (url) insertImage(url) + }) + } + if (otherFiles.length > 0) emit('filesDropped', otherFiles) + return true +} // Set up typing indicator const conversationStore = useConversationStore() @@ -252,12 +374,174 @@ const CustomMention = Mention.extend({ } }) +// Custom Image extension with drag-handle resizing and Gmail-style size presets +// (Small / Best fit / Original / Remove). Renders a node-view that wraps the +// with a corner resize handle and a hover toolbar. +const ResizableImage = Image.extend({ + addAttributes () { + return { + ...this.parent?.(), + width: { + default: null, + parseHTML: (el) => el.getAttribute('width') || el.style.width?.replace('px', '') || null, + renderHTML: (attrs) => { + if (!attrs.width) return {} + return { width: attrs.width, style: `width: ${attrs.width}px` } + } + }, + height: { + default: null, + parseHTML: (el) => el.getAttribute('height') || null, + renderHTML: (attrs) => (attrs.height ? { height: attrs.height } : {}) + } + } + }, + addNodeView () { + return ({ node, getPos, editor: nodeEditor }) => { + const wrapper = document.createElement('div') + wrapper.classList.add('image-resizer') + wrapper.style.display = 'inline-block' + wrapper.style.position = 'relative' + wrapper.style.lineHeight = '0' + + const img = document.createElement('img') + img.src = node.attrs.src + img.alt = node.attrs.alt || '' + img.title = node.attrs.title || '' + img.classList.add('inline-image') + img.style.maxWidth = '100%' + img.style.height = 'auto' + if (node.attrs.width) img.style.width = node.attrs.width + 'px' + wrapper.appendChild(img) + + // Toolbar (visible when wrapper is selected) + const toolbar = document.createElement('div') + toolbar.classList.add('image-size-toolbar') + + let naturalWidth = 0 + img.addEventListener('load', () => { naturalWidth = img.naturalWidth }) + + const commitWidth = (newWidth) => { + const pos = getPos() + if (typeof pos !== 'number') return + nodeEditor.chain().focus().command(({ tr }) => { + tr.setNodeMarkup(pos, undefined, { ...node.attrs, width: newWidth || null }) + return true + }).run() + } + + const sizes = [ + { label: 'Small', value: 400 }, + { label: 'Best fit', value: 'fit' }, + { label: 'Original', value: 'original' } + ] + // Toolbar buttons use pointerdown so touch + pen + mouse all work. + // preventDefault avoids stealing focus from the editor. + sizes.forEach(({ label, value }) => { + const btn = document.createElement('button') + btn.textContent = label + btn.type = 'button' + btn.addEventListener('pointerdown', (e) => { + e.preventDefault() + e.stopPropagation() + if (value === 'original') { + img.style.width = naturalWidth ? naturalWidth + 'px' : 'auto' + commitWidth(naturalWidth || null) + } else if (value === 'fit') { + img.style.width = '' + commitWidth(null) + } else { + img.style.width = value + 'px' + commitWidth(value) + } + }) + toolbar.appendChild(btn) + }) + + const sep = document.createElement('span') + sep.classList.add('image-toolbar-sep') + toolbar.appendChild(sep) + + const removeBtn = document.createElement('button') + removeBtn.textContent = 'Remove' + removeBtn.type = 'button' + removeBtn.classList.add('image-toolbar-remove') + removeBtn.addEventListener('pointerdown', (e) => { + e.preventDefault() + e.stopPropagation() + const pos = getPos() + if (typeof pos === 'number') { + nodeEditor.chain().focus().deleteRange({ from: pos, to: pos + 1 }).run() + } + }) + toolbar.appendChild(removeBtn) + wrapper.appendChild(toolbar) + + // Bottom-right resize handle. We don't manage selected state ourselves; + // CSS keys off ProseMirror's `.ProseMirror-selectednode` class which + // ProseMirror toggles automatically when the image node is selected. + // That avoids a global document click listener per image (which leaks + // closures across the entire page for every embedded image). + const handle = document.createElement('div') + handle.classList.add('image-resize-handle') + wrapper.appendChild(handle) + + // Drag the corner handle to resize. Pointer events for touch + pen. + let startX = 0 + let startWidth = 0 + const onPointerMove = (e) => { + const newWidth = Math.max(50, startWidth + (e.clientX - startX)) + img.style.width = newWidth + 'px' + } + const onPointerUp = () => { + window.removeEventListener('pointermove', onPointerMove) + window.removeEventListener('pointerup', onPointerUp) + wrapper.classList.remove('resizing') + try { + commitWidth(Math.round(img.offsetWidth)) + } catch (err) { + // Node may have been removed/replaced mid-drag (autosave + // re-render, paste over selection, etc.). Drop the commit. + } + } + const onPointerDown = (e) => { + e.preventDefault() + e.stopPropagation() + startX = e.clientX + startWidth = img.offsetWidth + window.addEventListener('pointermove', onPointerMove) + window.addEventListener('pointerup', onPointerUp) + wrapper.classList.add('resizing') + } + handle.addEventListener('pointerdown', onPointerDown) + + return { + dom: wrapper, + update: (updatedNode) => { + if (updatedNode.type.name !== 'image') return false + img.src = updatedNode.attrs.src + img.style.width = updatedNode.attrs.width ? updatedNode.attrs.width + 'px' : '' + return true + }, + destroy: () => { + handle.removeEventListener('pointerdown', onPointerDown) + window.removeEventListener('pointermove', onPointerMove) + window.removeEventListener('pointerup', onPointerUp) + } + } + } + } +}) + const isInternalUpdate = ref(false) const buildExtensions = () => { const extensions = [ StarterKit.configure(), - Image.configure({ HTMLAttributes: { class: 'inline-image' } }), + ResizableImage.configure({ + HTMLAttributes: { class: 'inline-image', style: 'max-width: 100%; height: auto;' }, + allowBase64: false + }), Placeholder.configure({ placeholder: () => props.placeholder }), Link, CustomTable.configure({ resizable: false }), @@ -309,6 +593,8 @@ const editor = useEditor({ editorProps: { attributes: { class: 'outline-none' }, getSuggestions: props.getSuggestions, + handlePaste, + handleDrop, handleKeyDown: (view, event) => { if (event.ctrlKey && event.key.toLowerCase() === 'b') { event.stopPropagation() @@ -451,5 +737,105 @@ defineExpose({ focus, extractMentions }) color: hsl(var(--primary)); font-weight: 500; } + + // Selected image gets an outline so the user knows what's focused. + // Hardcoded brand blue rather than a theme token so it stays visible + // against arbitrary email content (light backgrounds, dark images, etc.). + .ProseMirror-selectednode .inline-image { + outline: 2px solid #0066cc; + } + + // Wrapper added by ResizableImage's nodeView. + .image-resizer { + display: inline-block; + position: relative; + margin: 4px 0; + + .image-resize-handle { + display: none; + position: absolute; + bottom: 4px; + right: 4px; + width: 12px; + height: 12px; + background: #0066cc; + border: 2px solid white; + border-radius: 2px; + cursor: nwse-resize; + z-index: 10; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.15); + } + + // Floating size toolbar — sits above image to avoid BubbleMenu overlap. + .image-size-toolbar { + display: none; + position: absolute; + top: 4px; + left: 50%; + transform: translateX(-50%); + background: hsl(var(--background) / 0.95); + border: 1px solid hsl(var(--border)); + border-radius: 6px; + padding: 2px; + z-index: 10000; + white-space: nowrap; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + backdrop-filter: blur(4px); + + button { + padding: 2px 8px; + font-size: 11px; + color: hsl(var(--muted-foreground)); + background: none; + border: none; + border-radius: 4px; + cursor: pointer; + line-height: 1.6; + + &:hover { + background: hsl(var(--accent)); + color: hsl(var(--accent-foreground)); + } + } + + .image-toolbar-sep { + width: 1px; + height: 14px; + background: hsl(var(--border)); + margin: 0 2px; + align-self: center; + } + + .image-toolbar-remove { + color: hsl(var(--destructive)) !important; + + &:hover { + background: hsl(var(--destructive) / 0.1) !important; + color: hsl(var(--destructive)) !important; + } + } + } + + // ProseMirror toggles `.ProseMirror-selectednode` on the wrapper for us + // when the image node is selected, so we don't need to manage selected + // state with a document-level click listener. + &.ProseMirror-selectednode .image-resize-handle, + &.resizing .image-resize-handle { + display: block; + } + + &.ProseMirror-selectednode .image-size-toolbar { + display: flex; + } + + &.ProseMirror-selectednode .inline-image, + &.resizing .inline-image { + outline: 2px solid #0066cc; + } + + &.resizing .inline-image { + opacity: 0.8; + } + } } \ No newline at end of file diff --git a/frontend/apps/main/src/features/conversation/message/MessageBubble.vue b/frontend/apps/main/src/features/conversation/message/MessageBubble.vue index b8eb9482..614db588 100644 --- a/frontend/apps/main/src/features/conversation/message/MessageBubble.vue +++ b/frontend/apps/main/src/features/conversation/message/MessageBubble.vue @@ -60,12 +60,19 @@ > {{ sanitizedContent }} - + + + + @@ -156,6 +163,7 @@ import { Spinner } from '@shared-ui/components/ui/spinner' import { formatMessageTimestamp, formatFullTimestamp } from '@shared-ui/utils/datetime.js' import { Avatar, AvatarFallback, AvatarImage } from '@shared-ui/components/ui/avatar' import { Letter } from 'vue-letter' +import ImageLightbox from '@/components/ImageLightbox.vue' import MessageAttachmentPreview from '@main/features/conversation/message/attachment/MessageAttachmentPreview.vue' import MessageEnvelope from './MessageEnvelope.vue' import CSATResponseDisplay from './CSATResponseDisplay.vue' @@ -240,6 +248,47 @@ const toggleQuote = () => { showQuotedText.value = !showQuotedText.value } +// Inline image lightbox: click an in the rendered email body to open it. +// We enumerate images from the rendered DOM rather than the HTML source so we +// inherit vue-letter's sanitization and don't have to parse HTML with regex +// (which trips on attributes containing '>' and similar edge cases). +const messageContentEl = ref(null) +const inlineLightboxOpen = ref(false) +const inlineLightboxIndex = ref(0) +const inlineImages = ref([]) + +// Re-walk the rendered set on click. Cheaper than maintaining a watcher +// on sanitizedContent, and always reflects what the user actually sees. +const refreshInlineImages = () => { + const root = messageContentEl.value + if (!root) { + inlineImages.value = [] + return + } + inlineImages.value = Array.from(root.querySelectorAll('img')) + .map((el) => ({ url: el.getAttribute('src'), name: el.getAttribute('alt') || '' })) + .filter((img) => img.url) +} + +const onMessageContentClick = (event) => { + // Walk up so clicks on nested wrappers (e.g. ) still resolve. + const img = event.target?.closest?.('img') + if (!img || !messageContentEl.value?.contains(img)) return + + // If the image is inside an anchor, suppress the navigation so the + // lightbox can take over. + const wrappingAnchor = img.closest('a') + if (wrappingAnchor && messageContentEl.value.contains(wrappingAnchor)) { + event.preventDefault() + } + + refreshInlineImages() + const src = img.getAttribute('src') + const idx = inlineImages.value.findIndex((entry) => entry.url === src) + inlineLightboxIndex.value = idx >= 0 ? idx : 0 + inlineLightboxOpen.value = true +} + // Envelope visibility (both directions) const showEnvelope = computed(() => { return ( diff --git a/frontend/apps/main/src/features/conversation/message/attachment/FileAttachmentPreview.vue b/frontend/apps/main/src/features/conversation/message/attachment/FileAttachmentPreview.vue index fcb6f4c5..3ab5c1fe 100644 --- a/frontend/apps/main/src/features/conversation/message/attachment/FileAttachmentPreview.vue +++ b/frontend/apps/main/src/features/conversation/message/attachment/FileAttachmentPreview.vue @@ -1,42 +1,142 @@