From 93425c7dcfac413a31088a330d819af0cc8c89d1 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Wed, 17 Dec 2025 11:02:50 +0000 Subject: [PATCH] feat: Add inline image support in editor - Add paste handler for clipboard images - Add drag & drop support for images - Add image button in bubble menu toolbar - Add upload indicator during image upload - Fix media serving for unlinked inline images (images uploaded before message is sent now display correctly) Closes #66 --- cmd/media.go | 3 +- frontend/src/components/editor/TextEditor.vue | 191 +++++++++++++++++- 2 files changed, 182 insertions(+), 12 deletions(-) diff --git a/cmd/media.go b/cmd/media.go index 426764dd..0aa2db13 100644 --- a/cmd/media.go +++ b/cmd/media.go @@ -173,7 +173,8 @@ func handleServeMedia(r *fastglue.Request) error { } // For messages, check access to the conversation this message is part of. - if media.Model.String == "messages" { + // Skip this check if ModelID is not valid (e.g., inline images uploaded before message is sent). + if media.Model.String == "messages" && media.ModelID.Valid { conversation, err := app.conversation.GetConversationByMessageID(media.ModelID.Int) if err != nil { return sendErrorEnvelope(r, err) diff --git a/frontend/src/components/editor/TextEditor.vue b/frontend/src/components/editor/TextEditor.vue index f9ea4256..4ebdca5f 100644 --- a/frontend/src/components/editor/TextEditor.vue +++ b/frontend/src/components/editor/TextEditor.vue @@ -51,7 +51,6 @@ > - + + + + + + +
+ + Uploading image... +
+ @@ -117,6 +140,8 @@ import { List, ListOrdered, Link as LinkIcon, + Image as ImageIcon, + Loader2, } from 'lucide-vue-next' import { Button } from '@/components/ui/button' import { @@ -142,11 +167,18 @@ import Table from '@tiptap/extension-table' import TableRow from '@tiptap/extension-table-row' import TableCell from '@tiptap/extension-table-cell' import TableHeader from '@tiptap/extension-table-header' +import { useEmitter } from '@/composables/useEmitter' +import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' +import { handleHTTPError } from '@/utils/http' +import api from '@/api' const textContent = defineModel('textContent', { default: '' }) const htmlContent = defineModel('htmlContent', { default: '' }) const showLinkDialog = ref(false) const linkUrl = ref('') +const imageInput = ref(null) +const isUploadingImage = ref(false) +const emitter = useEmitter() const props = defineProps({ placeholder: String, @@ -165,8 +197,104 @@ const emit = defineEmits(['send', 'aiPromptSelected']) const emitPrompt = (key) => emit('aiPromptSelected', key) -// To preseve the table styling in emails, need to set the table style inline. -// Created these custom extensions to set the table style inline. +/** + * Upload an image file to the server and return the URL + */ +const uploadImage = async (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 + } +} + +/** + * Insert an image into the editor at the current cursor position + */ +const insertImage = (url) => { + if (url && editor.value) { + editor.value.chain().focus().setImage({ src: url }).run() + } +} + +/** + * Handle paste events to capture images from clipboard + */ +const handlePaste = async (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) { + const url = await uploadImage(file) + if (url) { + insertImage(url) + } + } + return true + } + } + return false +} + +/** + * Handle drop events for drag & drop images + */ +const handleDrop = async (view, event) => { + const files = event.dataTransfer?.files + if (!files || files.length === 0) return false + + for (const file of files) { + if (file.type.startsWith('image/')) { + event.preventDefault() + const url = await uploadImage(file) + if (url) { + insertImage(url) + } + return true + } + } + return false +} + +/** + * Trigger the hidden file input for image selection + */ +const triggerImageUpload = () => { + imageInput.value?.click() +} + +/** + * Handle image selection from file input + */ +const handleImageSelect = async (event) => { + const file = event.target.files?.[0] + if (file && file.type.startsWith('image/')) { + const url = await uploadImage(file) + if (url) { + insertImage(url) + } + } + // Reset the input so the same file can be selected again + event.target.value = '' +} + +// Custom table extensions with inline styles for email compatibility const CustomTable = Table.extend({ addAttributes() { return { @@ -206,12 +334,40 @@ const CustomTableHeader = TableHeader.extend({ } }) +// Custom Image extension with resizing support +const ResizableImage = Image.extend({ + addAttributes() { + return { + ...this.parent?.(), + width: { + default: null, + parseHTML: element => element.getAttribute('width'), + renderHTML: attributes => { + if (!attributes.width) return {} + return { width: attributes.width } + } + }, + height: { + default: null, + parseHTML: element => element.getAttribute('height'), + renderHTML: attributes => { + if (!attributes.height) return {} + return { height: attributes.height } + } + } + } + } +}) + const isInternalUpdate = ref(false) const editor = useEditor({ 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 }), @@ -223,6 +379,8 @@ const editor = useEditor({ content: htmlContent.value, editorProps: { attributes: { class: 'outline-none' }, + handlePaste, + handleDrop, handleKeyDown: (view, event) => { if (event.ctrlKey && event.key.toLowerCase() === 'b') { event.stopPropagation() @@ -234,7 +392,6 @@ const editor = useEditor({ } } }, - // To update state when user types. onUpdate: ({ editor }) => { isInternalUpdate.value = true htmlContent.value = editor.getHTML() @@ -255,7 +412,6 @@ watch( { immediate: true } ) -// Insert content at cursor position when insertContent prop changes. watch( () => props.insertContent, (val) => { @@ -290,7 +446,6 @@ const unsetLink = () => {