feat: inline images in website and email channels (#14516)

# Pull Request Template

## Description

This PR adds support for inline image uploads in the reply editor for
Email and Website (chat widget) channels.

Agents can now insert images inline between text and resize them
directly in the editor by dragging the bottom corner, similar to the
help center editor experience.

Image sizes are preserved through markdown using the `cw_image_width`
URL param and render correctly in both outgoing emails and chat widget
messages.

Agents can also paste copied images directly into Email or Website
replies using **Shift+Cmd+V** (Shift+Ctrl+V on Windows/Linux). The image
gets inserted inline at the cursor position and supports resizing just
like uploaded images. Regular **Cmd+V / Ctrl+V** behavior remains
unchanged and continues to add images as attachments, so both inline and
attachment flows are supported.


### Prosemirror repo PR:
https://github.com/chatwoot/prosemirror-schema/pull/48

Fixes
https://linear.app/chatwoot/issue/CW-7133/inline-images-in-live-chat-and-email

https://linear.app/chatwoot/issue/CW-7225/ghsa-8j9w-jppp-xcfc-html-attribute-injection-via-unvalidated-cw-image

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

### Screencast



https://github.com/user-attachments/assets/a928f852-ab15-413a-9d35-6ea69b718ecf

<img width="414" height="654" alt="image"
src="https://github.com/user-attachments/assets/205e0729-8f2d-4cc5-9c55-7696f032eca4"
/>



## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
This commit is contained in:
Sivin Varghese 2026-06-03 15:05:17 +05:30 committed by GitHub
parent d028cc1984
commit 1beaa284c6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 150 additions and 227 deletions

View File

@ -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"
/>
<div ref="editor" />
<div
v-show="isImageNodeSelected && showImageResizeToolbar"
class="absolute shadow-md rounded-[6px] flex gap-1 py-1 px-1 bg-n-solid-3 outline outline-1 outline-n-weak text-n-slate-12"
:style="{
top: toolbarPosition.top,
left: toolbarPosition.left,
}"
>
<button
v-for="size in sizes"
:key="size.name"
class="text-xs font-medium rounded-[4px] outline outline-1 outline-n-strong px-1.5 py-0.5 hover:bg-n-slate-5"
@click="setURLWithQueryAndImageSize(size)"
>
{{ size.name }}
</button>
</div>
<slot name="footer" />
</div>
</template>

View File

@ -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',
},
];

View File

@ -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

View File

@ -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;

View File

@ -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",

View File

@ -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
/>
<div>
<NextButton

View File

@ -1,28 +1,37 @@
import MarkdownIt from 'markdown-it';
import mila from 'markdown-it-link-attributes';
import mentionPlugin from './markdownIt/link';
import MarkdownIt from 'markdown-it';
const setImageHeight = inlineToken => {
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 <img> 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);

View File

@ -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 `<digits>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

View File

@ -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",

View File

@ -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

View File

@ -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('<img src="https://example.com/image.jpg?cw_image_height=100" style="height: 100;" />')
markdown = '![Sample Title](https://example.com/image.jpg?cw_image_height=100px)'
expect(render_markdown(markdown)).to include('<img src="https://example.com/image.jpg?cw_image_height=100px" style="height: 100px;" />')
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(
'<img src="https://example.com/image.jpg?cw_image_width=200px" style="width: 200px; max-width: 100%; height: auto;" />'
)
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