mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-03 12:37:56 +00:00
feat: Adds image support for message signature (#7827)
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
This commit is contained in:
@@ -15,6 +15,13 @@
|
|||||||
:search-key="variableSearchTerm"
|
:search-key="variableSearchTerm"
|
||||||
@click="insertVariable"
|
@click="insertVariable"
|
||||||
/>
|
/>
|
||||||
|
<input
|
||||||
|
ref="imageUpload"
|
||||||
|
type="file"
|
||||||
|
accept="image/png, image/jpeg, image/jpg, image/gif, image/webp"
|
||||||
|
hidden
|
||||||
|
@change="onFileChange"
|
||||||
|
/>
|
||||||
<div ref="editor" />
|
<div ref="editor" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -39,6 +46,7 @@ import CannedResponse from '../conversation/CannedResponse';
|
|||||||
import VariableList from '../conversation/VariableList';
|
import VariableList from '../conversation/VariableList';
|
||||||
|
|
||||||
const TYPING_INDICATOR_IDLE_TIME = 4000;
|
const TYPING_INDICATOR_IDLE_TIME = 4000;
|
||||||
|
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
|
||||||
|
|
||||||
import {
|
import {
|
||||||
hasPressedEnterAndNotCmdOrShift,
|
hasPressedEnterAndNotCmdOrShift,
|
||||||
@@ -51,12 +59,17 @@ import uiSettingsMixin from 'dashboard/mixins/uiSettings';
|
|||||||
import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
|
import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
|
||||||
import { replaceVariablesInMessage } from '@chatwoot/utils';
|
import { replaceVariablesInMessage } from '@chatwoot/utils';
|
||||||
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
|
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
|
||||||
|
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||||
|
import { uploadFile } from 'dashboard/helper/uploadHelper';
|
||||||
|
import alertMixin from 'shared/mixins/alertMixin';
|
||||||
|
import { findNodeToInsertImage } from 'dashboard/helper/messageEditorHelper';
|
||||||
import { MESSAGE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
|
import { MESSAGE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
|
||||||
|
|
||||||
const createState = (
|
const createState = (
|
||||||
content,
|
content,
|
||||||
placeholder,
|
placeholder,
|
||||||
plugins = [],
|
plugins = [],
|
||||||
|
methods = {},
|
||||||
enabledMenuOptions
|
enabledMenuOptions
|
||||||
) => {
|
) => {
|
||||||
return EditorState.create({
|
return EditorState.create({
|
||||||
@@ -64,6 +77,7 @@ const createState = (
|
|||||||
plugins: buildEditor({
|
plugins: buildEditor({
|
||||||
schema: messageSchema,
|
schema: messageSchema,
|
||||||
placeholder,
|
placeholder,
|
||||||
|
methods,
|
||||||
plugins,
|
plugins,
|
||||||
enabledMenuOptions,
|
enabledMenuOptions,
|
||||||
}),
|
}),
|
||||||
@@ -73,7 +87,7 @@ const createState = (
|
|||||||
export default {
|
export default {
|
||||||
name: 'WootMessageEditor',
|
name: 'WootMessageEditor',
|
||||||
components: { TagAgents, CannedResponse, VariableList },
|
components: { TagAgents, CannedResponse, VariableList },
|
||||||
mixins: [eventListenerMixins, uiSettingsMixin],
|
mixins: [eventListenerMixins, uiSettingsMixin, alertMixin],
|
||||||
props: {
|
props: {
|
||||||
value: { type: String, default: '' },
|
value: { type: String, default: '' },
|
||||||
editorId: { type: String, default: '' },
|
editorId: { type: String, default: '' },
|
||||||
@@ -255,6 +269,7 @@ export default {
|
|||||||
this.value,
|
this.value,
|
||||||
this.placeholder,
|
this.placeholder,
|
||||||
this.plugins,
|
this.plugins,
|
||||||
|
{ onImageUpload: this.openFileBrowser },
|
||||||
this.editorMenuOptions
|
this.editorMenuOptions
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -269,6 +284,7 @@ export default {
|
|||||||
content,
|
content,
|
||||||
this.placeholder,
|
this.placeholder,
|
||||||
this.plugins,
|
this.plugins,
|
||||||
|
{ onImageUpload: this.openFileBrowser },
|
||||||
this.editorMenuOptions
|
this.editorMenuOptions
|
||||||
);
|
);
|
||||||
this.editorView.updateState(this.state);
|
this.editorView.updateState(this.state);
|
||||||
@@ -397,6 +413,57 @@ export default {
|
|||||||
tr.scrollIntoView();
|
tr.scrollIntoView();
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
|
openFileBrowser() {
|
||||||
|
this.$refs.imageUpload.click();
|
||||||
|
},
|
||||||
|
onFileChange() {
|
||||||
|
const file = this.$refs.imageUpload.files[0];
|
||||||
|
if (checkFileSizeLimit(file, MAXIMUM_FILE_UPLOAD_SIZE)) {
|
||||||
|
this.uploadImageToStorage(file);
|
||||||
|
} else {
|
||||||
|
this.showAlert(
|
||||||
|
this.$t(
|
||||||
|
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.IMAGE_UPLOAD_SIZE_ERROR',
|
||||||
|
{
|
||||||
|
size: MAXIMUM_FILE_UPLOAD_SIZE,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$refs.imageUpload.value = '';
|
||||||
|
},
|
||||||
|
async uploadImageToStorage(file) {
|
||||||
|
try {
|
||||||
|
const { fileUrl } = await uploadFile(file);
|
||||||
|
if (fileUrl) {
|
||||||
|
this.onImageInsertInEditor(fileUrl);
|
||||||
|
}
|
||||||
|
this.showAlert(
|
||||||
|
this.$t(
|
||||||
|
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.IMAGE_UPLOAD_SUCCESS'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.showAlert(
|
||||||
|
this.$t(
|
||||||
|
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.IMAGE_UPLOAD_ERROR'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onImageInsertInEditor(fileUrl) {
|
||||||
|
const { tr } = this.editorView.state;
|
||||||
|
|
||||||
|
const insertData = findNodeToInsertImage(this.editorView.state, fileUrl);
|
||||||
|
|
||||||
|
if (insertData) {
|
||||||
|
this.editorView.dispatch(
|
||||||
|
tr.insert(insertData.pos, insertData.node).scrollIntoView()
|
||||||
|
);
|
||||||
|
this.focusEditorInputField();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
emitOnChange() {
|
emitOnChange() {
|
||||||
this.editorView.updateState(this.state);
|
this.editorView.updateState(this.state);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export const MESSAGE_SIGNATURE_EDITOR_MENU_OPTIONS = [
|
|||||||
'link',
|
'link',
|
||||||
'undo',
|
'undo',
|
||||||
'redo',
|
'redo',
|
||||||
|
'imageUpload',
|
||||||
];
|
];
|
||||||
|
|
||||||
export const ARTICLE_EDITOR_MENU_OPTIONS = [
|
export const ARTICLE_EDITOR_MENU_OPTIONS = [
|
||||||
|
|||||||
40
app/javascript/dashboard/helper/messageEditorHelper.js
Normal file
40
app/javascript/dashboard/helper/messageEditorHelper.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* Determines the appropriate node and position to insert an image in the editor.
|
||||||
|
*
|
||||||
|
* Based on the current editor state and the provided image URL, this function finds out the correct node (either
|
||||||
|
* a standalone image node or an image wrapped in a paragraph) and its respective position in the editor.
|
||||||
|
*
|
||||||
|
* 1. If the current node is a paragraph and doesn't contain an image or text, the image is inserted directly into it.
|
||||||
|
* 2. If the current node isn't a paragraph or it's a paragraph containing text, the image will be wrapped
|
||||||
|
* in a new paragraph and then inserted.
|
||||||
|
* 3. If the current node is a paragraph containing an image, the new image will be inserted directly into it.
|
||||||
|
*
|
||||||
|
* @param {Object} editorState - The current state of the editor. It provides necessary details like selection, schema, etc.
|
||||||
|
* @param {string} fileUrl - The URL of the image to be inserted into the editor.
|
||||||
|
* @returns {Object|null} An object containing details about the node to be inserted and its position. It returns null if no image node can be created.
|
||||||
|
* @property {Node} node - The ProseMirror node to be inserted (either an image node or a paragraph containing the image).
|
||||||
|
* @property {number} pos - The position where the new node should be inserted in the editor.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const findNodeToInsertImage = (editorState, fileUrl) => {
|
||||||
|
const { selection, schema } = editorState;
|
||||||
|
const { nodes } = schema;
|
||||||
|
const currentNode = selection.$from.node();
|
||||||
|
const {
|
||||||
|
type: { name: typeName },
|
||||||
|
content: { size, content },
|
||||||
|
} = currentNode;
|
||||||
|
|
||||||
|
const imageNode = nodes.image.create({ src: fileUrl });
|
||||||
|
|
||||||
|
if (!imageNode) return null;
|
||||||
|
|
||||||
|
const isInParagraph = typeName === 'paragraph';
|
||||||
|
const needsNewLine =
|
||||||
|
!content.some(n => n.type.name === 'image') && size !== 0 ? 1 : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
node: isInParagraph ? imageNode : nodes.paragraph.create({}, imageNode),
|
||||||
|
pos: selection.from + needsNewLine,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { findNodeToInsertImage } from '../messageEditorHelper';
|
||||||
|
|
||||||
|
describe('findNodeToInsertImage', () => {
|
||||||
|
let mockEditorState;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockEditorState = {
|
||||||
|
selection: {
|
||||||
|
$from: {
|
||||||
|
node: jest.fn(() => ({})),
|
||||||
|
},
|
||||||
|
from: 0,
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
nodes: {
|
||||||
|
image: {
|
||||||
|
create: jest.fn(attrs => ({ type: { name: 'image' }, attrs })),
|
||||||
|
},
|
||||||
|
paragraph: {
|
||||||
|
create: jest.fn((_, node) => ({
|
||||||
|
type: { name: 'paragraph' },
|
||||||
|
content: [node],
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should insert image directly into an empty paragraph', () => {
|
||||||
|
const mockNode = {
|
||||||
|
type: { name: 'paragraph' },
|
||||||
|
content: { size: 0, content: [] },
|
||||||
|
};
|
||||||
|
mockEditorState.selection.$from.node.mockReturnValueOnce(mockNode);
|
||||||
|
|
||||||
|
const result = findNodeToInsertImage(mockEditorState, 'image-url');
|
||||||
|
expect(result).toEqual({
|
||||||
|
node: { type: { name: 'image' }, attrs: { src: 'image-url' } },
|
||||||
|
pos: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should insert image directly into a paragraph without an image but with other content', () => {
|
||||||
|
const mockNode = {
|
||||||
|
type: { name: 'paragraph' },
|
||||||
|
content: {
|
||||||
|
size: 1,
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: { name: 'text' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
mockEditorState.selection.$from.node.mockReturnValueOnce(mockNode);
|
||||||
|
mockEditorState.selection.from = 1;
|
||||||
|
|
||||||
|
const result = findNodeToInsertImage(mockEditorState, 'image-url');
|
||||||
|
expect(result).toEqual({
|
||||||
|
node: { type: { name: 'image' }, attrs: { src: 'image-url' } },
|
||||||
|
pos: 2, // Because it should insert after the text, on a new line.
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should wrap image in a new paragraph when the current node isn't a paragraph", () => {
|
||||||
|
const mockNode = {
|
||||||
|
type: { name: 'not-a-paragraph' },
|
||||||
|
content: { size: 0, content: [] },
|
||||||
|
};
|
||||||
|
mockEditorState.selection.$from.node.mockReturnValueOnce(mockNode);
|
||||||
|
|
||||||
|
const result = findNodeToInsertImage(mockEditorState, 'image-url');
|
||||||
|
expect(result.node.type.name).toBe('paragraph');
|
||||||
|
expect(result.node.content[0].type.name).toBe('image');
|
||||||
|
expect(result.node.content[0].attrs.src).toBe('image-url');
|
||||||
|
expect(result.pos).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should insert a new image directly into the paragraph that already contains an image', () => {
|
||||||
|
const mockNode = {
|
||||||
|
type: { name: 'paragraph' },
|
||||||
|
content: {
|
||||||
|
size: 1,
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: { name: 'image', attrs: { src: 'existing-image-url' } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
mockEditorState.selection.$from.node.mockReturnValueOnce(mockNode);
|
||||||
|
mockEditorState.selection.from = 1;
|
||||||
|
|
||||||
|
const result = findNodeToInsertImage(mockEditorState, 'image-url');
|
||||||
|
expect(result.node.type.name).toBe('image');
|
||||||
|
expect(result.node.attrs.src).toBe('image-url');
|
||||||
|
expect(result.pos).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -39,7 +39,10 @@
|
|||||||
"NOTE": "Create a personal message signature that would be added to all the messages you send from your email inbox. Use the rich content editor to create a highly personalised signature.",
|
"NOTE": "Create a personal message signature that would be added to all the messages you send from your email inbox. Use the rich content editor to create a highly personalised signature.",
|
||||||
"BTN_TEXT": "Save message signature",
|
"BTN_TEXT": "Save message signature",
|
||||||
"API_ERROR": "Couldn't save signature! Try again",
|
"API_ERROR": "Couldn't save signature! Try again",
|
||||||
"API_SUCCESS": "Signature saved successfully"
|
"API_SUCCESS": "Signature saved successfully",
|
||||||
|
"IMAGE_UPLOAD_ERROR": "Couldn't upload image! Try again",
|
||||||
|
"IMAGE_UPLOAD_SUCCESS": "Image added successfully. Please click on save to save the signature",
|
||||||
|
"IMAGE_UPLOAD_SIZE_ERROR": "Image size should be less than {size}MB"
|
||||||
},
|
},
|
||||||
"MESSAGE_SIGNATURE": {
|
"MESSAGE_SIGNATURE": {
|
||||||
"LABEL": "Message Signature",
|
"LABEL": "Message Signature",
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ export default {
|
|||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
this.isUpdating = false;
|
this.isUpdating = false;
|
||||||
|
this.initValues();
|
||||||
this.showAlert(this.errorMessage);
|
this.showAlert(this.errorMessage);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@braid/vue-formulate": "^2.5.2",
|
"@braid/vue-formulate": "^2.5.2",
|
||||||
"@chatwoot/prosemirror-schema": "https://github.com/chatwoot/prosemirror-schema.git#5f6ec0888948e7b16f64c1f2779114162e8fa449",
|
"@chatwoot/prosemirror-schema": "https://github.com/chatwoot/prosemirror-schema.git#825755b53f1ef4ae14fac2393b53a02e14ce21e0",
|
||||||
"@chatwoot/utils": "^0.0.16",
|
"@chatwoot/utils": "^0.0.16",
|
||||||
"@hcaptcha/vue-hcaptcha": "^0.3.2",
|
"@hcaptcha/vue-hcaptcha": "^0.3.2",
|
||||||
"@june-so/analytics-next": "^1.36.5",
|
"@june-so/analytics-next": "^1.36.5",
|
||||||
|
|||||||
@@ -2994,9 +2994,9 @@
|
|||||||
is-url "^1.2.4"
|
is-url "^1.2.4"
|
||||||
nanoid "^2.1.11"
|
nanoid "^2.1.11"
|
||||||
|
|
||||||
"@chatwoot/prosemirror-schema@https://github.com/chatwoot/prosemirror-schema.git#5f6ec0888948e7b16f64c1f2779114162e8fa449":
|
"@chatwoot/prosemirror-schema@https://github.com/chatwoot/prosemirror-schema.git#825755b53f1ef4ae14fac2393b53a02e14ce21e0":
|
||||||
version "1.0.0"
|
version "1.0.0"
|
||||||
resolved "https://github.com/chatwoot/prosemirror-schema.git#5f6ec0888948e7b16f64c1f2779114162e8fa449"
|
resolved "https://github.com/chatwoot/prosemirror-schema.git#825755b53f1ef4ae14fac2393b53a02e14ce21e0"
|
||||||
dependencies:
|
dependencies:
|
||||||
markdown-it-sup "^1.0.0"
|
markdown-it-sup "^1.0.0"
|
||||||
prosemirror-commands "^1.1.4"
|
prosemirror-commands "^1.1.4"
|
||||||
|
|||||||
Reference in New Issue
Block a user