diff --git a/frontend/apps/main/src/components/ImageLightbox.vue b/frontend/apps/main/src/components/ImageLightbox.vue index 8a47cade..e4111efc 100644 --- a/frontend/apps/main/src/components/ImageLightbox.vue +++ b/frontend/apps/main/src/components/ImageLightbox.vue @@ -42,16 +42,16 @@ :href="currentImage.url" download class="text-white/70 hover:text-white" - :title="t('imageLightbox.download')" - :aria-label="t('imageLightbox.download')" + :title="t('globals.terms.download')" + :aria-label="t('globals.terms.download')" @click.stop > diff --git a/frontend/apps/main/src/components/editor/TextEditor.vue b/frontend/apps/main/src/components/editor/TextEditor.vue index 151b1bd6..c1d988e4 100644 --- a/frontend/apps/main/src/components/editor/TextEditor.vue +++ b/frontend/apps/main/src/components/editor/TextEditor.vue @@ -141,7 +141,7 @@ import { DialogDescription } from '@shared-ui/components/ui/dialog' import Placeholder from '@tiptap/extension-placeholder' -import Image from '@tiptap/extension-image' +import ResizableImage from './extensions/ResizableImage' import StarterKit from '@tiptap/starter-kit' import Link from '@tiptap/extension-link' import Mention from '@tiptap/extension-mention' @@ -374,165 +374,6 @@ 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 = () => { diff --git a/frontend/apps/main/src/components/editor/extensions/ResizableImage.js b/frontend/apps/main/src/components/editor/extensions/ResizableImage.js new file mode 100644 index 00000000..2d8ee851 --- /dev/null +++ b/frontend/apps/main/src/components/editor/extensions/ResizableImage.js @@ -0,0 +1,160 @@ +import Image from '@tiptap/extension-image' + +// Custom Image extension with drag-handle resizing and Gmail-style size presets +// (Small / Best fit / Original / Remove). Styles for .image-resizer, +// .image-resize-handle, and .image-size-toolbar live in TextEditor.vue's +// global \ No newline at end of file diff --git a/frontend/apps/main/src/features/conversation/message/attachment/AttachmentItem.vue b/frontend/apps/main/src/features/conversation/message/attachment/AttachmentItem.vue new file mode 100644 index 00000000..f915df32 --- /dev/null +++ b/frontend/apps/main/src/features/conversation/message/attachment/AttachmentItem.vue @@ -0,0 +1,156 @@ + + + + + + + {{ shortName(attachment.name) }} + {{ formatBytes(attachment.size) }} + + + + + + + + + + {{ shortName(attachment.name) }} + + {{ formatBytes(attachment.size) }} + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/apps/main/src/features/conversation/message/attachment/FileAttachmentPreview.vue b/frontend/apps/main/src/features/conversation/message/attachment/FileAttachmentPreview.vue deleted file mode 100644 index 3ab5c1fe..00000000 --- a/frontend/apps/main/src/features/conversation/message/attachment/FileAttachmentPreview.vue +++ /dev/null @@ -1,142 +0,0 @@ - - - - - - - - - {{ shortName(attachment.name) }} - - {{ formatBytes(attachment.size) }} - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/apps/main/src/features/conversation/message/attachment/ImageAttachmentPreview.vue b/frontend/apps/main/src/features/conversation/message/attachment/ImageAttachmentPreview.vue deleted file mode 100644 index 67d81ff8..00000000 --- a/frontend/apps/main/src/features/conversation/message/attachment/ImageAttachmentPreview.vue +++ /dev/null @@ -1,46 +0,0 @@ - - - - - - - - {{ trimAttachmentName(attachment.name) }} - {{ formatBytes(attachment.size) }} - - - - - - - - - - - - - - diff --git a/frontend/apps/main/src/features/conversation/message/attachment/MessageAttachmentPreview.vue b/frontend/apps/main/src/features/conversation/message/attachment/MessageAttachmentPreview.vue index e60c03ae..8913f4f1 100644 --- a/frontend/apps/main/src/features/conversation/message/attachment/MessageAttachmentPreview.vue +++ b/frontend/apps/main/src/features/conversation/message/attachment/MessageAttachmentPreview.vue @@ -6,14 +6,9 @@ class="flex items-center cursor-pointer" > - @@ -22,14 +17,14 @@ :href="attachment.url" download class="p-1 rounded hover:bg-muted shrink-0" - :title="t('imageLightbox.download')" - :aria-label="t('imageLightbox.download')" + :title="t('globals.terms.download')" + :aria-label="t('globals.terms.download')" @click.stop > - + @@ -45,8 +40,7 @@ import { ref, computed } from 'vue' import { useI18n } from 'vue-i18n' import { Download } from 'lucide-vue-next' -import ImageAttachmentPreview from '@/features/conversation/message/attachment/ImageAttachmentPreview.vue' -import FileAttachmentPreview from '@/features/conversation/message/attachment/FileAttachmentPreview.vue' +import AttachmentItem from '@/features/conversation/message/attachment/AttachmentItem.vue' import ImageLightbox from '@/components/ImageLightbox.vue' const props = defineProps({ diff --git a/frontend/shared-ui/assets/styles/main.scss b/frontend/shared-ui/assets/styles/main.scss index f7ac39c2..16c17f5f 100644 --- a/frontend/shared-ui/assets/styles/main.scss +++ b/frontend/shared-ui/assets/styles/main.scss @@ -51,14 +51,6 @@ margin-bottom: 0.5rem; } - // Stop emails with explicit width/height attributes from forcing a - // wider rendered width than the message bubble. - img { - max-width: 100%; - height: auto; - cursor: zoom-in; - } - ul { list-style-type: disc; margin-left: 1.5rem; diff --git a/i18n/en.json b/i18n/en.json index 36c58358..044d5629 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -394,7 +394,6 @@ "ai.apiKey.description": "{provider} API Key is not set or invalid. Please enter a valid API key to use AI features.", "ai.apiKeyNotSet": "{provider} API Key is not set. Please ask your administrator to set it up", "ai.enterOpenAIAPIKey": "Enter OpenAI API Key", - "attachment.preview": "Preview", "auth.backToLogin": "Back to login", "auth.checkEmailForReset": "Check your email for the password reset link.", "auth.confirmPassword": "Confirm password", @@ -690,6 +689,7 @@ "globals.terms.description": "Description | Descriptions", "globals.terms.disabled": "Disabled", "globals.terms.draft": "Draft", + "globals.terms.download": "Download", "globals.terms.email": "Email | Emails", "globals.terms.enabled": "Enabled", "globals.terms.error": "Error | Errors", @@ -838,8 +838,6 @@ "globals.terms.white": "White", "globals.terms.workspace": "Workspace", "globals.terms.you": "You", - "imageLightbox.close": "Close", - "imageLightbox.download": "Download", "imageLightbox.next": "Next image", "imageLightbox.previous": "Previous image", "imageLightbox.resetZoom": "Reset zoom", diff --git a/internal/conversation/message.go b/internal/conversation/message.go index c361a25e..b33ccf7a 100644 --- a/internal/conversation/message.go +++ b/internal/conversation/message.go @@ -1214,12 +1214,6 @@ func (m *Manager) uploadMessageAttachments(message *models.Message) error { } } - // Now that the file is uploaded, swap any cid: reference for the upload URL. - // For non-images this turns the broken tag into a download link. - if contentID != "" { - message.Content = replaceCIDInContent(message.Content, fmt.Sprintf("cid:%s", contentID), "/uploads/"+media.UUID, attachment.Name, attachment.ContentType) - } - message.Media = append(message.Media, media) } return nil
{{ shortName(attachment.name) }}
{{ formatBytes(attachment.size) }}
+ {{ shortName(attachment.name) }} +
- {{ shortName(attachment.name) }} -
{{ trimAttachmentName(attachment.name) }}