diff --git a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue index 3da526fc0a7..a8f2d0a2c9d 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue @@ -32,7 +32,6 @@ import { CONVERSATION_EVENTS, CAPTAIN_EVENTS, } from 'dashboard/helper/AnalyticsHelper/events'; -import { MESSAGE_EDITOR_IMAGE_RESIZES } from 'dashboard/constants/editor'; import { messageSchema, @@ -43,6 +42,7 @@ import { MessageMarkdownSerializer, EditorState, Selection, + imageResizeView, } from '@chatwoot/prosemirror-schema'; import { suggestionsPlugin, @@ -57,7 +57,6 @@ import { insertAtCursor, removeSignature as removeSignatureHelper, scrollCursorIntoView, - setURLWithQueryAndSize, getFormattingForEditor, getSelectionCoords, calculateMenuPosition, @@ -72,6 +71,7 @@ import { import { createTypingIndicator } from '@chatwoot/utils'; import { checkFileSizeLimit } from 'shared/helpers/FileHelper'; import { uploadFile } from 'dashboard/helper/uploadHelper'; +import { INBOX_TYPES } from 'dashboard/helper/inbox'; const props = defineProps({ modelValue: { type: String, default: '' }, @@ -93,7 +93,6 @@ const props = defineProps({ channelType: { type: String, default: '' }, conversationId: { type: Number, default: null }, medium: { type: String, default: '' }, - showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar focusOnMount: { type: Boolean, default: true }, }); @@ -119,6 +118,14 @@ const TYPING_INDICATOR_IDLE_TIME = 4000; const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB const DEFAULT_FORMATTING = 'Context::Default'; const PRIVATE_NOTE_FORMATTING = 'Context::PrivateNote'; +const MESSAGE_SIGNATURE_FORMATTING = 'Context::MessageSignature'; +const INLINE_IMAGE_PASTE_TYPES = [ + 'image/png', + 'image/jpeg', + 'image/jpg', + 'image/gif', + 'image/webp', +]; const effectiveChannelType = computed(() => getEffectiveChannelType(props.channelType, props.medium) @@ -192,12 +199,8 @@ const cannedSearchTerm = ref(''); const variableSearchTerm = ref(''); const emojiSearchTerm = ref(''); const range = ref(null); -const isImageNodeSelected = ref(false); -const toolbarPosition = ref({ top: 0, left: 0 }); -const selectedImageNode = ref(null); const isTextSelected = ref(false); // Tracks text selection and prevents unnecessary re-renders on mouse selection const showSelectionMenu = ref(false); -const sizes = MESSAGE_EDITOR_IMAGE_RESIZES; // element ref const editorRoot = useTemplateRef('editorRoot'); @@ -475,16 +478,6 @@ function removeSignature() { reloadState(content); } -function setToolbarPosition() { - const editorRect = editorRoot.value.getBoundingClientRect(); - const rect = selectedImageNode.value.getBoundingClientRect(); - - toolbarPosition.value = { - top: `${rect.top - editorRect.top - 30}px`, - left: `${rect.left - editorRect.left - 4}px`, - }; -} - function setMenubarPosition({ selection } = {}) { const wrapper = editorRoot.value; if (!selection || !wrapper) return; @@ -520,30 +513,6 @@ function checkSelection(editorState) { if (hasSelection) setMenubarPosition(editorState); } -function setURLWithQueryAndImageSize(size) { - if (!props.showImageResizeToolbar) { - return; - } - setURLWithQueryAndSize(selectedImageNode.value, size, editorView); - isImageNodeSelected.value = false; -} - -function isEditorMouseFocusedOnAnImage() { - if (!props.showImageResizeToolbar) { - return; - } - selectedImageNode.value = document.querySelector( - 'img.ProseMirror-selectednode' - ); - if (selectedImageNode.value) { - isImageNodeSelected.value = !!selectedImageNode.value; - // Get the position of the selected node - setToolbarPosition(); - } else { - isImageNodeSelected.value = false; - } -} - function emitOnChange() { emit('input', contentFromEditor()); emit('update:modelValue', contentFromEditor()); @@ -563,21 +532,6 @@ function toggleSignatureInEditor(signatureEnabled) { emitOnChange(); } -function updateImgToolbarOnDelete() { - // check if the selected node is present or not on keyup - // this is needed because the user can select an image and then delete it - // in that case, the selected node will be null and we need to hide the toolbar - // otherwise, the toolbar will be visible even when the image is deleted and cause some errors - if (selectedImageNode.value) { - const hasImgSelectedNode = document.querySelector( - 'img.ProseMirror-selectednode' - ); - if (!hasImgSelectedNode) { - isImageNodeSelected.value = false; - } - } -} - function isEnterToSendEnabled() { return isEditorHotKeyEnabled('enter'); } @@ -586,17 +540,6 @@ function isCmdPlusEnterToSendEnabled() { return isEditorHotKeyEnabled('cmd_enter'); } -useKeyboardEvents({ - 'Alt+KeyP': { - action: focusEditorInputField, - allowOnFocusedInput: false, - }, - 'Alt+KeyL': { - action: focusEditorInputField, - allowOnFocusedInput: false, - }, -}); - function onImageInsertInEditor(fileUrl) { const { tr } = editorView.state; @@ -617,7 +560,11 @@ async function uploadImageToStorage(file) { onImageInsertInEditor(fileUrl); } useAlert( - t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.IMAGE_UPLOAD_SUCCESS') + props.channelType === MESSAGE_SIGNATURE_FORMATTING + ? t( + 'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.IMAGE_UPLOAD_SUCCESS' + ) + : t('CONVERSATION.REPLYBOX.IMAGE_UPLOAD_SUCCESS') ); } catch (error) { useAlert( @@ -626,8 +573,8 @@ async function uploadImageToStorage(file) { } } -function onFileChange() { - const file = imageUpload.value.files[0]; +function uploadImageIfWithinSizeLimit(file) { + if (!file) return; if (checkFileSizeLimit(file, MAXIMUM_FILE_UPLOAD_SIZE)) { uploadImageToStorage(file); } else { @@ -640,10 +587,61 @@ function onFileChange() { ) ); } - - imageUpload.value = ''; } +function onFileChange() { + const input = imageUpload.value; + uploadImageIfWithinSizeLimit(input.files[0]); + input.value = ''; +} + +const allowsInlineImagePaste = computed( + () => + !props.isPrivate && + (props.channelType === INBOX_TYPES.EMAIL || + props.channelType === INBOX_TYPES.WEB) +); + +// Shift+Cmd/Ctrl+V on email/website: upload a clipboard image inline. This +// gesture's native paste event carries no image, so clipboard.read() is the +// only way to get the bytes. No preventDefault: text still pastes natively. +async function pasteInlineImageFromClipboard() { + if (!editorView?.hasFocus()) return; + if (!allowsInlineImagePaste.value || !navigator.clipboard?.read) return; + try { + const items = await navigator.clipboard.read(); + const imageItem = items.find(item => + item.types.some(type => INLINE_IMAGE_PASTE_TYPES.includes(type)) + ); + if (!imageItem) return; + const imageType = imageItem.types.find(type => + INLINE_IMAGE_PASTE_TYPES.includes(type) + ); + const blob = await imageItem.getType(imageType); + uploadImageIfWithinSizeLimit( + new File([blob], 'pasted-image', { type: imageType }) + ); + } catch (error) { + // clipboard-read denied/unfocused (NotAllowedError): image can't be read. + // Text paste is unaffected — ProseMirror handles it from the native event. + } +} + +useKeyboardEvents({ + 'Alt+KeyP': { + action: focusEditorInputField, + allowOnFocusedInput: false, + }, + 'Alt+KeyL': { + action: focusEditorInputField, + allowOnFocusedInput: false, + }, + '$mod+Shift+KeyV': { + action: pasteInlineImageFromClipboard, + allowOnFocusedInput: true, + }, +}); + function handleLineBreakWhenEnterToSendEnabled(event) { if ( hasPressedEnterAndNotCmdOrShift(event) && @@ -736,6 +734,9 @@ function createEditorView() { editorView = new EditorView(editor.value, { state: state, editable: () => !props.disabled, + nodeViews: { + image: imageResizeView, + }, dispatchTransaction: tx => { state = state.apply(tx); editorView.updateState(state); @@ -748,12 +749,10 @@ function createEditorView() { keyup: () => { if (!props.disabled) { typingIndicator.start(); - updateImgToolbarOnDelete(); } }, keydown: (view, event) => !props.disabled && onKeydown(event), focus: () => !props.disabled && emit('focus'), - click: () => !props.disabled && isEditorMouseFocusedOnAnImage(), blur: () => { if (props.disabled) return; typingIndicator.stop(); @@ -918,23 +917,6 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor); @change="onFileChange" />
-
- -
diff --git a/app/javascript/dashboard/constants/editor.js b/app/javascript/dashboard/constants/editor.js index 378d303b48d..5f4fe0e0332 100644 --- a/app/javascript/dashboard/constants/editor.js +++ b/app/javascript/dashboard/constants/editor.js @@ -14,6 +14,7 @@ export const FORMATTING = { 'link', 'bulletList', 'orderedList', + 'imageUpload', 'undo', 'redo', ], @@ -30,6 +31,7 @@ export const FORMATTING = { 'strike', 'bulletList', 'orderedList', + 'imageUpload', 'undo', 'redo', ], @@ -263,23 +265,3 @@ export const MARKDOWN_PATTERNS = [ ], }, ]; - -// Editor image resize options for Message Editor -export const MESSAGE_EDITOR_IMAGE_RESIZES = [ - { - name: 'Small', - height: '24px', - }, - { - name: 'Medium', - height: '48px', - }, - { - name: 'Large', - height: '72px', - }, - { - name: 'Original Size', - height: 'auto', - }, -]; diff --git a/app/javascript/dashboard/helper/editorHelper.js b/app/javascript/dashboard/helper/editorHelper.js index e4330b332c2..32f56172a4f 100644 --- a/app/javascript/dashboard/helper/editorHelper.js +++ b/app/javascript/dashboard/helper/editorHelper.js @@ -379,31 +379,6 @@ export const findNodeToInsertImage = (editorState, fileUrl) => { }; }; -/** - * Set URL with query and size. - * - * @param {Object} selectedImageNode - The current selected node. - * @param {Object} size - The size to set. - * @param {Object} editorView - The editor view. - */ -export function setURLWithQueryAndSize(selectedImageNode, size, editorView) { - if (selectedImageNode) { - // Create and apply the transaction - const tr = editorView.state.tr.setNodeMarkup( - editorView.state.selection.from, - null, - { - src: selectedImageNode.src, - height: size.height, - } - ); - - if (tr.docChanged) { - editorView.dispatch(tr); - } - } -} - /** * Strips unsupported markdown formatting from content based on the editor schema. * This ensures canned responses with rich formatting can be inserted into channels diff --git a/app/javascript/dashboard/helper/specs/editorHelper.spec.js b/app/javascript/dashboard/helper/specs/editorHelper.spec.js index 00d1e83d43b..7d32f63e05c 100644 --- a/app/javascript/dashboard/helper/specs/editorHelper.spec.js +++ b/app/javascript/dashboard/helper/specs/editorHelper.spec.js @@ -16,7 +16,6 @@ import { insertAtCursor, removeSignature, replaceSignature, - setURLWithQueryAndSize, stripInlineBase64Images, stripUnsupportedFormatting, stripUnsupportedMarkdown, @@ -653,71 +652,6 @@ describe('findNodeToInsertImage', () => { }); }); -describe('setURLWithQueryAndSize', () => { - let selectedNode; - let editorView; - - beforeEach(() => { - selectedNode = { - setAttribute: vi.fn(), - }; - - const tr = { - setNodeMarkup: vi.fn().mockReturnValue({ - docChanged: true, - }), - }; - - const state = { - selection: { from: 0 }, - tr, - }; - - editorView = { - state, - dispatch: vi.fn(), - }; - }); - - it('updates the URL with the given size and updates the editor view', () => { - const size = { height: '20px' }; - - setURLWithQueryAndSize(selectedNode, size, editorView); - - // Check if the editor view is updated - expect(editorView.dispatch).toHaveBeenCalledTimes(1); - }); - - it('updates the URL with the given size and updates the editor view with original size', () => { - const size = { height: 'auto' }; - - setURLWithQueryAndSize(selectedNode, size, editorView); - - // Check if the editor view is updated - expect(editorView.dispatch).toHaveBeenCalledTimes(1); - }); - - it('does not update the editor view if the document has not changed', () => { - editorView.state.tr.setNodeMarkup = vi.fn().mockReturnValue({ - docChanged: false, - }); - - const size = { height: '20px' }; - - setURLWithQueryAndSize(selectedNode, size, editorView); - - // Check if the editor view dispatch was not called - expect(editorView.dispatch).not.toHaveBeenCalled(); - }); - - it('does not perform any operations if selectedNode is not provided', () => { - setURLWithQueryAndSize(null, { height: '20px' }, editorView); - - // Ensure the dispatch method wasn't called - expect(editorView.dispatch).not.toHaveBeenCalled(); - }); -}); - describe('getContentNode', () => { let mockEditorView; diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json index 7b1b49493a8..3eb83fd311b 100644 --- a/app/javascript/dashboard/i18n/locale/en/conversation.json +++ b/app/javascript/dashboard/i18n/locale/en/conversation.json @@ -233,6 +233,7 @@ "TIP_AUDIORECORDER_ERROR": "Could not open the audio", "AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.", "DRAG_DROP": "Drag and drop here to attach", + "IMAGE_UPLOAD_SUCCESS": "Image uploaded successfully", "START_AUDIO_RECORDING": "Start audio recording", "STOP_AUDIO_RECORDING": "Stop audio recording", "COPILOT_THINKING": "Copilot is thinking", diff --git a/app/javascript/dashboard/routes/dashboard/settings/profile/MessageSignature.vue b/app/javascript/dashboard/routes/dashboard/settings/profile/MessageSignature.vue index b0dab9774f3..bf6f01f8241 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/profile/MessageSignature.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/profile/MessageSignature.vue @@ -48,7 +48,6 @@ const updateSignature = () => { :placeholder="$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE.PLACEHOLDER')" channel-type="Context::MessageSignature" :enable-suggestions="false" - show-image-resize-toolbar />
{ +const setImageSizing = inlineToken => { const imgSrc = inlineToken.attrGet('src'); if (!imgSrc) return; const url = new URL(imgSrc); + const width = url.searchParams.get('cw_image_width'); + if (width) { + inlineToken.attrSet( + 'style', + `width: ${width}; max-width: 100%; height: auto;` + ); + return; + } const height = url.searchParams.get('cw_image_height'); - if (!height) return; - inlineToken.attrSet('style', `height: ${height};`); + if (height) inlineToken.attrSet('style', `height: ${height};`); }; const processInlineToken = blockToken => { blockToken.children.forEach(inlineToken => { if (inlineToken.type === 'image') { - setImageHeight(inlineToken); + setImageSizing(inlineToken); } }); }; const imgResizeManager = md => { - // Custom rule for image resize in markdown - // If the image url has a query param cw_image_height, then add a style attribute to the image - md.core.ruler.after('inline', 'add-image-height', state => { + // If the image URL carries a cw_image_width or cw_image_height query param, + // add an inline style attribute so the rendered respects the agent's + // resize choice. Width takes precedence (HC drag-resize); height is kept for + // legacy messages and the message-signature use case. + md.core.ruler.after('inline', 'add-image-sizing', state => { state.tokens.forEach(blockToken => { if (blockToken.type === 'inline') { processInlineToken(blockToken); diff --git a/lib/base_markdown_renderer.rb b/lib/base_markdown_renderer.rb index f530e71ee5d..2329dea6d6e 100644 --- a/lib/base_markdown_renderer.rb +++ b/lib/base_markdown_renderer.rb @@ -1,9 +1,9 @@ class BaseMarkdownRenderer < CommonMarker::HtmlRenderer def image(node) src, title = extract_img_attributes(node) - height = extract_image_height(src) + sizing_style = extract_image_sizing_style(src) - render_img_tag(src, title, height) + render_img_tag(src, title, sizing_style) end private @@ -15,9 +15,25 @@ class BaseMarkdownRenderer < CommonMarker::HtmlRenderer ] end - def extract_image_height(src) + # Drag-resize from the reply editor encodes the chosen width as cw_image_width + # on the URL; the older message-signature picker uses cw_image_height. Width + # wins when both are set so the agent's most recent intent is honored. + def extract_image_sizing_style(src) query_params = parse_query_params(src) - query_params['cw_image_height']&.first + width = sanitize_pixel_value(query_params['cw_image_width']&.first) + return "width: #{width}; max-width: 100%; height: auto;" if width + + height = sanitize_pixel_value(query_params['cw_image_height']&.first) + height ? "height: #{height};" : nil + end + + # Only allow a bounded `px` value so the decoded query param can't + # break out of the inline style attribute (HTML attribute injection). + def sanitize_pixel_value(raw) + return unless raw =~ /\A(\d+)px\z/ + + px = Regexp.last_match(1).to_i + "#{px}px" if px.between?(1, 2000) end def parse_query_params(url) @@ -27,13 +43,13 @@ class BaseMarkdownRenderer < CommonMarker::HtmlRenderer {} end - def render_img_tag(src, title, height = nil) + def render_img_tag(src, title, sizing_style = nil) title_attribute = title.present? ? " title=\"#{title}\"" : '' - # Use inline style instead of the HTML height attribute: email clients and - # the in-app Letter view both run images through CSS (e.g. prose / + # Use inline style instead of HTML width/height attributes: email clients + # and the in-app Letter view both run images through CSS (e.g. prose / # lettersanitizer's `img { height: auto }`) which overrides presentational # attributes. Inline style has higher specificity and survives. - style_attribute = height ? " style=\"height: #{height};\"" : '' + style_attribute = sizing_style ? " style=\"#{sizing_style}\"" : '' plain do # plain ensures that the content is not wrapped in a paragraph tag diff --git a/package.json b/package.json index 081c706c60f..d8527051dab 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@amplitude/analytics-browser": "^2.11.10", "@breezystack/lamejs": "^1.2.7", "@chatwoot/ninja-keys": "1.2.3", - "@chatwoot/prosemirror-schema": "1.3.13", + "@chatwoot/prosemirror-schema": "1.3.17", "@chatwoot/utils": "^0.0.55", "@formkit/core": "^1.7.2", "@formkit/vue": "^1.7.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index afcf5b60f30..a4b61061c50 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,8 +25,8 @@ importers: specifier: 1.2.3 version: 1.2.3 '@chatwoot/prosemirror-schema': - specifier: 1.3.13 - version: 1.3.13 + specifier: 1.3.17 + version: 1.3.17 '@chatwoot/utils': specifier: ^0.0.55 version: 0.0.55 @@ -458,8 +458,8 @@ packages: '@chatwoot/ninja-keys@1.2.3': resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==} - '@chatwoot/prosemirror-schema@1.3.13': - resolution: {integrity: sha512-T6FBUinMJbwDCD7975g8M/Tsn2+G3O2pTGIXdcLkMRpbAAC6mVdl4ZcZektlt5y/PVmPVqNHPsfee1XB/C3vAw==} + '@chatwoot/prosemirror-schema@1.3.17': + resolution: {integrity: sha512-n78ZfMIzSrylImIN5cjCeEdTJ8ub0JtCybwUlqFyOyLy3ZzAZpOHvCSo+w/KmV4dCgOH2mBmYlxBQ9Rww+e0Rw==} '@chatwoot/utils@0.0.55': resolution: {integrity: sha512-8G6HYQe1ZEYfJEsSYfDVvE+uhf98JDRjtGlpB+bzMko+yltbrk4yACSo/ImC3jSaJ6K8yPTSjJToSRmsQbL2iQ==} @@ -5128,7 +5128,7 @@ snapshots: hotkeys-js: 3.8.7 lit: 2.2.6 - '@chatwoot/prosemirror-schema@1.3.13': + '@chatwoot/prosemirror-schema@1.3.17': dependencies: markdown-it-sup: 2.0.0 prosemirror-commands: 1.7.1 diff --git a/spec/lib/base_markdown_renderer_spec.rb b/spec/lib/base_markdown_renderer_spec.rb index f8bdae4beee..082f9fff6c6 100644 --- a/spec/lib/base_markdown_renderer_spec.rb +++ b/spec/lib/base_markdown_renderer_spec.rb @@ -11,8 +11,33 @@ describe BaseMarkdownRenderer do describe '#image' do context 'when image has a height' do it 'renders the img tag with the correct attributes' do - markdown = '![Sample Title](https://example.com/image.jpg?cw_image_height=100)' - expect(render_markdown(markdown)).to include('') + markdown = '![Sample Title](https://example.com/image.jpg?cw_image_height=100px)' + expect(render_markdown(markdown)).to include('') + end + end + + context 'when image has a width' do + it 'renders the img tag with the correct attributes' do + markdown = '![Sample Title](https://example.com/image.jpg?cw_image_width=200px)' + expect(render_markdown(markdown)).to include( + '' + ) + end + end + + context 'when the sizing param contains an attribute-injection payload' do + it 'drops the malicious height value' do + markdown = '![x](https://example.com/image.jpg?cw_image_height=1px%22%20onmouseover%3D%22alert(1))' + rendered = render_markdown(markdown) + expect(rendered).not_to include('style=') + expect(rendered).not_to include('onmouseover="') + end + + it 'drops the malicious width value' do + markdown = '![x](https://example.com/image.jpg?cw_image_width=1px%22%20onmouseover%3D%22alert(1))' + rendered = render_markdown(markdown) + expect(rendered).not_to include('style=') + expect(rendered).not_to include('onmouseover="') end end