From e39d19b1e8de4e2e75926be1bb64d2ef1a6a3bce Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Mon, 11 Sep 2023 10:23:45 +0530 Subject: [PATCH] feat: Adds image support for message signature (#7827) Co-authored-by: Shivam Mishra --- .../components/widgets/WootWriter/Editor.vue | 69 +++++++++++- app/javascript/dashboard/constants/editor.js | 1 + .../dashboard/helper/messageEditorHelper.js | 40 +++++++ .../helper/specs/messageEditorHelper.spec.js | 100 ++++++++++++++++++ .../dashboard/i18n/locale/en/settings.json | 5 +- .../settings/profile/MessageSignature.vue | 1 + package.json | 2 +- yarn.lock | 4 +- 8 files changed, 217 insertions(+), 5 deletions(-) create mode 100644 app/javascript/dashboard/helper/messageEditorHelper.js create mode 100644 app/javascript/dashboard/helper/specs/messageEditorHelper.spec.js diff --git a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue index cd275e051..abd8209b8 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue @@ -15,6 +15,13 @@ :search-key="variableSearchTerm" @click="insertVariable" /> +
@@ -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); diff --git a/app/javascript/dashboard/constants/editor.js b/app/javascript/dashboard/constants/editor.js index e0e7f5804..517063425 100644 --- a/app/javascript/dashboard/constants/editor.js +++ b/app/javascript/dashboard/constants/editor.js @@ -15,6 +15,7 @@ export const MESSAGE_SIGNATURE_EDITOR_MENU_OPTIONS = [ 'link', 'undo', 'redo', + 'imageUpload', ]; export const ARTICLE_EDITOR_MENU_OPTIONS = [ diff --git a/app/javascript/dashboard/helper/messageEditorHelper.js b/app/javascript/dashboard/helper/messageEditorHelper.js new file mode 100644 index 000000000..1355f734a --- /dev/null +++ b/app/javascript/dashboard/helper/messageEditorHelper.js @@ -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, + }; +}; diff --git a/app/javascript/dashboard/helper/specs/messageEditorHelper.spec.js b/app/javascript/dashboard/helper/specs/messageEditorHelper.spec.js new file mode 100644 index 000000000..3d4b6719c --- /dev/null +++ b/app/javascript/dashboard/helper/specs/messageEditorHelper.spec.js @@ -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); + }); +}); diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index 158207bc7..7969f807f 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -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", diff --git a/app/javascript/dashboard/routes/dashboard/settings/profile/MessageSignature.vue b/app/javascript/dashboard/routes/dashboard/settings/profile/MessageSignature.vue index 471629aac..ad3474ca7 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/profile/MessageSignature.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/profile/MessageSignature.vue @@ -101,6 +101,7 @@ export default { } } finally { this.isUpdating = false; + this.initValues(); this.showAlert(this.errorMessage); } }, diff --git a/package.json b/package.json index 2e48ea79b..255fbdaad 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/yarn.lock b/yarn.lock index 2a1c373a7..710019a84 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"