mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-02 12:08:01 +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"
|
||||
@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>
|
||||
</template>
|
||||
@@ -39,6 +46,7 @@ import CannedResponse from '../conversation/CannedResponse';
|
||||
import VariableList from '../conversation/VariableList';
|
||||
|
||||
const TYPING_INDICATOR_IDLE_TIME = 4000;
|
||||
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
|
||||
|
||||
import {
|
||||
hasPressedEnterAndNotCmdOrShift,
|
||||
@@ -51,12 +59,17 @@ import uiSettingsMixin from 'dashboard/mixins/uiSettings';
|
||||
import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
|
||||
import { replaceVariablesInMessage } from '@chatwoot/utils';
|
||||
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';
|
||||
|
||||
const createState = (
|
||||
content,
|
||||
placeholder,
|
||||
plugins = [],
|
||||
methods = {},
|
||||
enabledMenuOptions
|
||||
) => {
|
||||
return EditorState.create({
|
||||
@@ -64,6 +77,7 @@ const createState = (
|
||||
plugins: buildEditor({
|
||||
schema: messageSchema,
|
||||
placeholder,
|
||||
methods,
|
||||
plugins,
|
||||
enabledMenuOptions,
|
||||
}),
|
||||
@@ -73,7 +87,7 @@ const createState = (
|
||||
export default {
|
||||
name: 'WootMessageEditor',
|
||||
components: { TagAgents, CannedResponse, VariableList },
|
||||
mixins: [eventListenerMixins, uiSettingsMixin],
|
||||
mixins: [eventListenerMixins, uiSettingsMixin, alertMixin],
|
||||
props: {
|
||||
value: { type: String, default: '' },
|
||||
editorId: { type: String, default: '' },
|
||||
@@ -255,6 +269,7 @@ export default {
|
||||
this.value,
|
||||
this.placeholder,
|
||||
this.plugins,
|
||||
{ onImageUpload: this.openFileBrowser },
|
||||
this.editorMenuOptions
|
||||
);
|
||||
},
|
||||
@@ -269,6 +284,7 @@ export default {
|
||||
content,
|
||||
this.placeholder,
|
||||
this.plugins,
|
||||
{ onImageUpload: this.openFileBrowser },
|
||||
this.editorMenuOptions
|
||||
);
|
||||
this.editorView.updateState(this.state);
|
||||
@@ -397,6 +413,57 @@ export default {
|
||||
tr.scrollIntoView();
|
||||
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() {
|
||||
this.editorView.updateState(this.state);
|
||||
|
||||
@@ -15,6 +15,7 @@ export const MESSAGE_SIGNATURE_EDITOR_MENU_OPTIONS = [
|
||||
'link',
|
||||
'undo',
|
||||
'redo',
|
||||
'imageUpload',
|
||||
];
|
||||
|
||||
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.",
|
||||
"BTN_TEXT": "Save message signature",
|
||||
"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": {
|
||||
"LABEL": "Message Signature",
|
||||
|
||||
@@ -101,6 +101,7 @@ export default {
|
||||
}
|
||||
} finally {
|
||||
this.isUpdating = false;
|
||||
this.initValues();
|
||||
this.showAlert(this.errorMessage);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"@hcaptcha/vue-hcaptcha": "^0.3.2",
|
||||
"@june-so/analytics-next": "^1.36.5",
|
||||
|
||||
@@ -2994,9 +2994,9 @@
|
||||
is-url "^1.2.4"
|
||||
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"
|
||||
resolved "https://github.com/chatwoot/prosemirror-schema.git#5f6ec0888948e7b16f64c1f2779114162e8fa449"
|
||||
resolved "https://github.com/chatwoot/prosemirror-schema.git#825755b53f1ef4ae14fac2393b53a02e14ce21e0"
|
||||
dependencies:
|
||||
markdown-it-sup "^1.0.0"
|
||||
prosemirror-commands "^1.1.4"
|
||||
|
||||
Reference in New Issue
Block a user