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 = ''
- expect(render_markdown(markdown)).to include('
')
+ markdown = ''
+ 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 = ''
+ 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 = ')'
+ 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 = ')'
+ rendered = render_markdown(markdown)
+ expect(rendered).not_to include('style=')
+ expect(rendered).not_to include('onmouseover="')
end
end