mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-10-31 11:08:04 +00:00 
			
		
		
		
	 b474929f5e
			
		
	
	b474929f5e
	
	
	
		
			
			# Replace the deprecated `eventBus` with mitt.js ## Description Since eventBus and it's respective methods are deprecated and removed from all future releases of vue, this was blocking us from migrating. This PR replaces eventBus with [mitt](https://github.com/developit/mitt). I have created a wrapper mitt.js to simulate the same old event names so it's backwards compatible, without making a lot of changes. Fixes # (issue) ## Type of change Please delete options that are not relevant. - [x] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality not to work as expected) - [ ] This change requires a documentation update ## How Has This Been Tested? 1. Made sure all the places we're listening to bus events are working as expected. 2. Respective specsf or the events from mitt. ## 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 - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] 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 - [x] Any dependent changes have been merged and published in downstream modules
		
			
				
	
	
		
			811 lines
		
	
	
		
			23 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
			
		
		
	
	
			811 lines
		
	
	
		
			23 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
| <template>
 | |
|   <div ref="editorRoot" class="relative editor-root">
 | |
|     <tag-agents
 | |
|       v-if="showUserMentions && isPrivate"
 | |
|       :search-key="mentionSearchKey"
 | |
|       @click="insertMentionNode"
 | |
|     />
 | |
|     <canned-response
 | |
|       v-if="shouldShowCannedResponses"
 | |
|       :search-key="cannedSearchTerm"
 | |
|       @click="insertCannedResponse"
 | |
|     />
 | |
|     <variable-list
 | |
|       v-if="shouldShowVariables"
 | |
|       :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
 | |
|       v-show="isImageNodeSelected && showImageResizeToolbar"
 | |
|       class="absolute shadow-md rounded-[4px] flex gap-1 py-1 px-1 bg-slate-50 dark:bg-slate-700 text-slate-800 dark:text-slate-50"
 | |
|       :style="{
 | |
|         top: toolbarPosition.top,
 | |
|         left: toolbarPosition.left,
 | |
|       }"
 | |
|     >
 | |
|       <button
 | |
|         v-for="size in sizes"
 | |
|         :key="size.name"
 | |
|         class="text-xs font-medium rounded-[4px] border border-solid border-slate-200 dark:border-slate-600 px-1.5 py-0.5 hover:bg-slate-100 dark:hover:bg-slate-800"
 | |
|         @click="setURLWithQueryAndImageSize(size)"
 | |
|       >
 | |
|         {{ size.name }}
 | |
|       </button>
 | |
|     </div>
 | |
|     <slot name="footer" />
 | |
|   </div>
 | |
| </template>
 | |
| 
 | |
| <script>
 | |
| import {
 | |
|   messageSchema,
 | |
|   buildEditor,
 | |
|   EditorView,
 | |
|   MessageMarkdownTransformer,
 | |
|   MessageMarkdownSerializer,
 | |
|   EditorState,
 | |
|   Selection,
 | |
| } from '@chatwoot/prosemirror-schema';
 | |
| import {
 | |
|   suggestionsPlugin,
 | |
|   triggerCharacters,
 | |
| } from '@chatwoot/prosemirror-schema/src/mentions/plugin';
 | |
| import { BUS_EVENTS } from 'shared/constants/busEvents';
 | |
| 
 | |
| import TagAgents from '../conversation/TagAgents.vue';
 | |
| import CannedResponse from '../conversation/CannedResponse.vue';
 | |
| import VariableList from '../conversation/VariableList.vue';
 | |
| import {
 | |
|   appendSignature,
 | |
|   removeSignature,
 | |
|   insertAtCursor,
 | |
|   scrollCursorIntoView,
 | |
|   findNodeToInsertImage,
 | |
|   setURLWithQueryAndSize,
 | |
| } from 'dashboard/helper/editorHelper';
 | |
| 
 | |
| const TYPING_INDICATOR_IDLE_TIME = 4000;
 | |
| const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
 | |
| 
 | |
| import {
 | |
|   hasPressedEnterAndNotCmdOrShift,
 | |
|   hasPressedCommandAndEnter,
 | |
| } from 'shared/helpers/KeyboardHelpers';
 | |
| import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
 | |
| import uiSettingsMixin from 'dashboard/mixins/uiSettings';
 | |
| import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
 | |
| import {
 | |
|   replaceVariablesInMessage,
 | |
|   createTypingIndicator,
 | |
| } 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 {
 | |
|   MESSAGE_EDITOR_MENU_OPTIONS,
 | |
|   MESSAGE_EDITOR_IMAGE_RESIZES,
 | |
| } from 'dashboard/constants/editor';
 | |
| 
 | |
| const createState = (
 | |
|   content,
 | |
|   placeholder,
 | |
|   // eslint-disable-next-line default-param-last
 | |
|   plugins = [],
 | |
|   // eslint-disable-next-line default-param-last
 | |
|   methods = {},
 | |
|   enabledMenuOptions
 | |
| ) => {
 | |
|   return EditorState.create({
 | |
|     doc: new MessageMarkdownTransformer(messageSchema).parse(content),
 | |
|     plugins: buildEditor({
 | |
|       schema: messageSchema,
 | |
|       placeholder,
 | |
|       methods,
 | |
|       plugins,
 | |
|       enabledMenuOptions,
 | |
|     }),
 | |
|   });
 | |
| };
 | |
| 
 | |
| export default {
 | |
|   name: 'WootMessageEditor',
 | |
|   components: { TagAgents, CannedResponse, VariableList },
 | |
|   mixins: [keyboardEventListenerMixins, uiSettingsMixin, alertMixin],
 | |
|   props: {
 | |
|     value: { type: String, default: '' },
 | |
|     editorId: { type: String, default: '' },
 | |
|     placeholder: { type: String, default: '' },
 | |
|     isPrivate: { type: Boolean, default: false },
 | |
|     enableSuggestions: { type: Boolean, default: true },
 | |
|     overrideLineBreaks: { type: Boolean, default: false },
 | |
|     updateSelectionWith: { type: String, default: '' },
 | |
|     enableVariables: { type: Boolean, default: false },
 | |
|     enableCannedResponses: { type: Boolean, default: true },
 | |
|     variables: { type: Object, default: () => ({}) },
 | |
|     enabledMenuOptions: { type: Array, default: () => [] },
 | |
|     signature: { type: String, default: '' },
 | |
|     // allowSignature is a kill switch, ensuring no signature methods
 | |
|     // are triggered except when this flag is true
 | |
|     allowSignature: { type: Boolean, default: false },
 | |
|     channelType: { type: String, default: '' },
 | |
|     showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar
 | |
|   },
 | |
|   data() {
 | |
|     return {
 | |
|       typingIndicator: createTypingIndicator(
 | |
|         () => {
 | |
|           this.$emit('typing-on');
 | |
|         },
 | |
|         () => {
 | |
|           this.$emit('typing-off');
 | |
|         },
 | |
|         TYPING_INDICATOR_IDLE_TIME
 | |
|       ),
 | |
|       showUserMentions: false,
 | |
|       showCannedMenu: false,
 | |
|       showVariables: false,
 | |
|       mentionSearchKey: '',
 | |
|       cannedSearchTerm: '',
 | |
|       variableSearchTerm: '',
 | |
|       editorView: null,
 | |
|       range: null,
 | |
|       state: undefined,
 | |
|       isImageNodeSelected: false,
 | |
|       toolbarPosition: { top: 0, left: 0 },
 | |
|       sizes: MESSAGE_EDITOR_IMAGE_RESIZES,
 | |
|       selectedImageNode: null,
 | |
|     };
 | |
|   },
 | |
|   computed: {
 | |
|     editorRoot() {
 | |
|       return this.$refs.editorRoot;
 | |
|     },
 | |
|     contentFromEditor() {
 | |
|       return MessageMarkdownSerializer.serialize(this.editorView.state.doc);
 | |
|     },
 | |
|     shouldShowVariables() {
 | |
|       return this.enableVariables && this.showVariables && !this.isPrivate;
 | |
|     },
 | |
|     shouldShowCannedResponses() {
 | |
|       return (
 | |
|         this.enableCannedResponses && this.showCannedMenu && !this.isPrivate
 | |
|       );
 | |
|     },
 | |
|     editorMenuOptions() {
 | |
|       return this.enabledMenuOptions.length
 | |
|         ? this.enabledMenuOptions
 | |
|         : MESSAGE_EDITOR_MENU_OPTIONS;
 | |
|     },
 | |
|     plugins() {
 | |
|       if (!this.enableSuggestions) {
 | |
|         return [];
 | |
|       }
 | |
| 
 | |
|       return [
 | |
|         suggestionsPlugin({
 | |
|           matcher: triggerCharacters('@'),
 | |
|           onEnter: args => {
 | |
|             this.showUserMentions = true;
 | |
|             this.range = args.range;
 | |
|             this.editorView = args.view;
 | |
|             return false;
 | |
|           },
 | |
|           onChange: args => {
 | |
|             this.editorView = args.view;
 | |
|             this.range = args.range;
 | |
| 
 | |
|             this.mentionSearchKey = args.text.replace('@', '');
 | |
| 
 | |
|             return false;
 | |
|           },
 | |
|           onExit: () => {
 | |
|             this.mentionSearchKey = '';
 | |
|             this.showUserMentions = false;
 | |
|             return false;
 | |
|           },
 | |
|           onKeyDown: ({ event }) => {
 | |
|             return event.keyCode === 13 && this.showUserMentions;
 | |
|           },
 | |
|         }),
 | |
|         suggestionsPlugin({
 | |
|           matcher: triggerCharacters('/'),
 | |
|           suggestionClass: '',
 | |
|           onEnter: args => {
 | |
|             if (this.isPrivate) {
 | |
|               return false;
 | |
|             }
 | |
|             this.showCannedMenu = true;
 | |
|             this.range = args.range;
 | |
|             this.editorView = args.view;
 | |
|             return false;
 | |
|           },
 | |
|           onChange: args => {
 | |
|             this.editorView = args.view;
 | |
|             this.range = args.range;
 | |
| 
 | |
|             this.cannedSearchTerm = args.text.replace('/', '');
 | |
|             return false;
 | |
|           },
 | |
|           onExit: () => {
 | |
|             this.cannedSearchTerm = '';
 | |
|             this.showCannedMenu = false;
 | |
|             return false;
 | |
|           },
 | |
|           onKeyDown: ({ event }) => {
 | |
|             return event.keyCode === 13 && this.showCannedMenu;
 | |
|           },
 | |
|         }),
 | |
|         suggestionsPlugin({
 | |
|           matcher: triggerCharacters('{{'),
 | |
|           suggestionClass: '',
 | |
|           onEnter: args => {
 | |
|             if (this.isPrivate) {
 | |
|               return false;
 | |
|             }
 | |
|             this.showVariables = true;
 | |
|             this.range = args.range;
 | |
|             this.editorView = args.view;
 | |
|             return false;
 | |
|           },
 | |
|           onChange: args => {
 | |
|             this.editorView = args.view;
 | |
|             this.range = args.range;
 | |
| 
 | |
|             this.variableSearchTerm = args.text.replace('{{', '');
 | |
|             return false;
 | |
|           },
 | |
|           onExit: () => {
 | |
|             this.variableSearchTerm = '';
 | |
|             this.showVariables = false;
 | |
|             return false;
 | |
|           },
 | |
|           onKeyDown: ({ event }) => {
 | |
|             return event.keyCode === 13 && this.showVariables;
 | |
|           },
 | |
|         }),
 | |
|       ];
 | |
|     },
 | |
|     sendWithSignature() {
 | |
|       // this is considered the source of truth, we watch this property
 | |
|       // on change, we toggle the signature in the editor
 | |
|       if (this.allowSignature && !this.isPrivate && this.channelType) {
 | |
|         return this.fetchSignatureFlagFromUiSettings(this.channelType);
 | |
|       }
 | |
| 
 | |
|       return false;
 | |
|     },
 | |
|   },
 | |
|   watch: {
 | |
|     showUserMentions(updatedValue) {
 | |
|       this.$emit('toggle-user-mention', this.isPrivate && updatedValue);
 | |
|     },
 | |
|     showCannedMenu(updatedValue) {
 | |
|       this.$emit('toggle-canned-menu', !this.isPrivate && updatedValue);
 | |
|     },
 | |
|     showVariables(updatedValue) {
 | |
|       this.$emit('toggle-variables-menu', !this.isPrivate && updatedValue);
 | |
|     },
 | |
|     value(newVal = '') {
 | |
|       if (newVal !== this.contentFromEditor) {
 | |
|         this.reloadState(newVal);
 | |
|       }
 | |
|     },
 | |
|     editorId() {
 | |
|       this.showCannedMenu = false;
 | |
|       this.cannedSearchTerm = '';
 | |
|       this.reloadState(this.value);
 | |
|     },
 | |
|     isPrivate() {
 | |
|       this.reloadState(this.value);
 | |
|     },
 | |
|     updateSelectionWith(newValue, oldValue) {
 | |
|       if (!this.editorView) {
 | |
|         return null;
 | |
|       }
 | |
|       if (newValue !== oldValue) {
 | |
|         if (this.updateSelectionWith !== '') {
 | |
|           const node = this.editorView.state.schema.text(
 | |
|             this.updateSelectionWith
 | |
|           );
 | |
|           const tr = this.editorView.state.tr.replaceSelectionWith(node);
 | |
|           this.editorView.focus();
 | |
|           this.state = this.editorView.state.apply(tr);
 | |
|           this.editorView.updateState(this.state);
 | |
|           this.emitOnChange();
 | |
|           this.$emit('clear-selection');
 | |
|         }
 | |
|       }
 | |
|       return null;
 | |
|     },
 | |
|     sendWithSignature(newValue) {
 | |
|       // see if the allowSignature flag is true
 | |
|       if (this.allowSignature) {
 | |
|         this.toggleSignatureInEditor(newValue);
 | |
|       }
 | |
|     },
 | |
|   },
 | |
|   created() {
 | |
|     this.state = createState(
 | |
|       this.value,
 | |
|       this.placeholder,
 | |
|       this.plugins,
 | |
|       { onImageUpload: this.openFileBrowser },
 | |
|       this.editorMenuOptions
 | |
|     );
 | |
|   },
 | |
|   mounted() {
 | |
|     this.createEditorView();
 | |
|     this.editorView.updateState(this.state);
 | |
|     this.focusEditorInputField();
 | |
| 
 | |
|     // BUS Event to insert text or markdown into the editor at the
 | |
|     // current cursor position.
 | |
|     // Components using this
 | |
|     // 1. SearchPopover.vue
 | |
| 
 | |
|     this.$emitter.on(
 | |
|       BUS_EVENTS.INSERT_INTO_RICH_EDITOR,
 | |
|       this.insertContentIntoEditor
 | |
|     );
 | |
|   },
 | |
|   beforeDestroy() {
 | |
|     this.$emitter.off(
 | |
|       BUS_EVENTS.INSERT_INTO_RICH_EDITOR,
 | |
|       this.insertContentIntoEditor
 | |
|     );
 | |
|   },
 | |
|   methods: {
 | |
|     reloadState(content = this.value) {
 | |
|       this.state = createState(
 | |
|         content,
 | |
|         this.placeholder,
 | |
|         this.plugins,
 | |
|         { onImageUpload: this.openFileBrowser },
 | |
|         this.editorMenuOptions
 | |
|       );
 | |
|       this.editorView.updateState(this.state);
 | |
| 
 | |
|       this.focusEditor(content);
 | |
|     },
 | |
|     focusEditor(content) {
 | |
|       if (this.isBodyEmpty(content) && this.sendWithSignature) {
 | |
|         // reload state can be called when switching between conversations, or when drafts is loaded
 | |
|         // these drafts can also have a signature, so we need to check if the body is empty
 | |
|         // and handle things accordingly
 | |
|         this.handleEmptyBodyWithSignature();
 | |
|       } else {
 | |
|         // this is in the else block, handleEmptyBodyWithSignature also has a call to the focus method
 | |
|         // the position is set to start, because the signature is added at the end of the body
 | |
|         this.focusEditorInputField('end');
 | |
|       }
 | |
|     },
 | |
|     toggleSignatureInEditor(signatureEnabled) {
 | |
|       // The toggleSignatureInEditor gets the new value from the
 | |
|       // watcher, this means that if the value is true, the signature
 | |
|       // is supposed to be added, else we remove it.
 | |
|       if (signatureEnabled) {
 | |
|         this.addSignature();
 | |
|       } else {
 | |
|         this.removeSignature();
 | |
|       }
 | |
|     },
 | |
|     addSignature() {
 | |
|       let content = this.value;
 | |
|       // see if the content is empty, if it is before appending the signature
 | |
|       // we need to add a paragraph node and move the cursor at the start of the editor
 | |
|       const contentWasEmpty = this.isBodyEmpty(content);
 | |
|       content = appendSignature(content, this.signature);
 | |
|       // need to reload first, ensuring that the editorView is updated
 | |
|       this.reloadState(content);
 | |
| 
 | |
|       if (contentWasEmpty) {
 | |
|         this.handleEmptyBodyWithSignature();
 | |
|       }
 | |
|     },
 | |
|     removeSignature() {
 | |
|       if (!this.signature) return;
 | |
|       let content = this.value;
 | |
|       content = removeSignature(content, this.signature);
 | |
|       // reload the state, ensuring that the editorView is updated
 | |
|       this.reloadState(content);
 | |
|     },
 | |
|     isBodyEmpty(content) {
 | |
|       // if content is undefined, we assume that the body is empty
 | |
|       if (!content) return true;
 | |
| 
 | |
|       // if the signature is present, we need to remove it before checking
 | |
|       // note that we don't update the editorView, so this is safe
 | |
|       const bodyWithoutSignature = this.signature
 | |
|         ? removeSignature(content, this.signature)
 | |
|         : content;
 | |
| 
 | |
|       // trimming should remove all the whitespaces, so we can check the length
 | |
|       return bodyWithoutSignature.trim().length === 0;
 | |
|     },
 | |
|     handleEmptyBodyWithSignature() {
 | |
|       const { schema, tr } = this.state;
 | |
| 
 | |
|       // create a paragraph node and
 | |
|       // start a transaction to append it at the end
 | |
|       const paragraph = schema.nodes.paragraph.create();
 | |
|       const paragraphTransaction = tr.insert(0, paragraph);
 | |
|       this.editorView.dispatch(paragraphTransaction);
 | |
| 
 | |
|       // Set the focus at the start of the input field
 | |
|       this.focusEditorInputField('start');
 | |
|     },
 | |
|     createEditorView() {
 | |
|       this.editorView = new EditorView(this.$refs.editor, {
 | |
|         state: this.state,
 | |
|         dispatchTransaction: tx => {
 | |
|           this.state = this.state.apply(tx);
 | |
|           this.editorView.updateState(this.state);
 | |
|           this.emitOnChange();
 | |
|         },
 | |
|         handleDOMEvents: {
 | |
|           keyup: () => {
 | |
|             this.onKeyup();
 | |
|           },
 | |
|           keydown: (view, event) => {
 | |
|             this.onKeydown(event);
 | |
|           },
 | |
|           focus: () => {
 | |
|             this.onFocus();
 | |
|           },
 | |
|           click: () => {
 | |
|             this.isEditorMouseFocusedOnAnImage();
 | |
|           },
 | |
|           blur: () => {
 | |
|             this.onBlur();
 | |
|           },
 | |
|           paste: (view, event) => {
 | |
|             const data = event.clipboardData.files;
 | |
|             if (data.length > 0) {
 | |
|               event.preventDefault();
 | |
|             }
 | |
|           },
 | |
|         },
 | |
|       });
 | |
|     },
 | |
|     isEditorMouseFocusedOnAnImage() {
 | |
|       if (!this.showImageResizeToolbar) {
 | |
|         return;
 | |
|       }
 | |
|       this.selectedImageNode = document.querySelector(
 | |
|         'img.ProseMirror-selectednode'
 | |
|       );
 | |
|       if (this.selectedImageNode) {
 | |
|         this.isImageNodeSelected = !!this.selectedImageNode;
 | |
|         // Get the position of the selected node
 | |
|         this.setToolbarPosition();
 | |
|       } else {
 | |
|         this.isImageNodeSelected = false;
 | |
|       }
 | |
|     },
 | |
|     setToolbarPosition() {
 | |
|       const editorRect = this.editorRoot.getBoundingClientRect();
 | |
|       const rect = this.selectedImageNode.getBoundingClientRect();
 | |
|       this.toolbarPosition = {
 | |
|         top: `${rect.top - editorRect.top - 30}px`,
 | |
|         left: `${rect.left - editorRect.left - 4}px`,
 | |
|       };
 | |
|     },
 | |
|     setURLWithQueryAndImageSize(size) {
 | |
|       if (!this.showImageResizeToolbar) {
 | |
|         return;
 | |
|       }
 | |
|       setURLWithQueryAndSize(this.selectedImageNode, size, this.editorView);
 | |
|       this.isImageNodeSelected = false;
 | |
|     },
 | |
|     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 (this.selectedImageNode) {
 | |
|         const hasImgSelectedNode = document.querySelector(
 | |
|           'img.ProseMirror-selectednode'
 | |
|         );
 | |
|         if (!hasImgSelectedNode) {
 | |
|           this.isImageNodeSelected = false;
 | |
|         }
 | |
|       }
 | |
|     },
 | |
|     isEnterToSendEnabled() {
 | |
|       return isEditorHotKeyEnabled(this.uiSettings, 'enter');
 | |
|     },
 | |
|     isCmdPlusEnterToSendEnabled() {
 | |
|       return isEditorHotKeyEnabled(this.uiSettings, 'cmd_enter');
 | |
|     },
 | |
|     getKeyboardEvents() {
 | |
|       return {
 | |
|         'Alt+KeyP': {
 | |
|           action: () => {
 | |
|             this.focusEditorInputField();
 | |
|           },
 | |
|           allowOnFocusedInput: true,
 | |
|         },
 | |
|         'Alt+KeyL': {
 | |
|           action: () => {
 | |
|             this.focusEditorInputField();
 | |
|           },
 | |
|           allowOnFocusedInput: true,
 | |
|         },
 | |
|       };
 | |
|     },
 | |
|     focusEditorInputField(pos = 'end') {
 | |
|       const { tr } = this.editorView.state;
 | |
| 
 | |
|       const selection =
 | |
|         pos === 'end' ? Selection.atEnd(tr.doc) : Selection.atStart(tr.doc);
 | |
| 
 | |
|       this.editorView.dispatch(tr.setSelection(selection));
 | |
|       this.editorView.focus();
 | |
|     },
 | |
|     insertMentionNode(mentionItem) {
 | |
|       if (!this.editorView) {
 | |
|         return null;
 | |
|       }
 | |
|       const node = this.editorView.state.schema.nodes.mention.create({
 | |
|         userId: mentionItem.id,
 | |
|         userFullName: mentionItem.name,
 | |
|       });
 | |
| 
 | |
|       this.insertNodeIntoEditor(node, this.range.from, this.range.to);
 | |
|       this.$track(CONVERSATION_EVENTS.USED_MENTIONS);
 | |
| 
 | |
|       return false;
 | |
|     },
 | |
|     insertCannedResponse(cannedItem) {
 | |
|       const updatedMessage = replaceVariablesInMessage({
 | |
|         message: cannedItem,
 | |
|         variables: this.variables,
 | |
|       });
 | |
| 
 | |
|       if (!this.editorView) {
 | |
|         return null;
 | |
|       }
 | |
| 
 | |
|       let node = new MessageMarkdownTransformer(messageSchema).parse(
 | |
|         updatedMessage
 | |
|       );
 | |
| 
 | |
|       const from =
 | |
|         node.textContent === updatedMessage
 | |
|           ? this.range.from
 | |
|           : this.range.from - 1;
 | |
| 
 | |
|       this.insertNodeIntoEditor(node, from, this.range.to);
 | |
| 
 | |
|       this.$track(CONVERSATION_EVENTS.INSERTED_A_CANNED_RESPONSE);
 | |
|       return false;
 | |
|     },
 | |
|     insertVariable(variable) {
 | |
|       if (!this.editorView) {
 | |
|         return null;
 | |
|       }
 | |
| 
 | |
|       const content = `{{${variable}}}`;
 | |
|       let node = this.editorView.state.schema.text(content);
 | |
|       const { from, to } = this.range;
 | |
| 
 | |
|       this.insertNodeIntoEditor(node, from, to);
 | |
|       this.showVariables = false;
 | |
|       this.$track(CONVERSATION_EVENTS.INSERTED_A_VARIABLE);
 | |
|       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.$emit('input', this.contentFromEditor);
 | |
|     },
 | |
| 
 | |
|     hideMentions() {
 | |
|       this.showUserMentions = false;
 | |
|     },
 | |
|     handleLineBreakWhenEnterToSendEnabled(event) {
 | |
|       if (
 | |
|         hasPressedEnterAndNotCmdOrShift(event) &&
 | |
|         this.isEnterToSendEnabled() &&
 | |
|         !this.overrideLineBreaks
 | |
|       ) {
 | |
|         event.preventDefault();
 | |
|       }
 | |
|     },
 | |
|     handleLineBreakWhenCmdAndEnterToSendEnabled(event) {
 | |
|       if (
 | |
|         hasPressedCommandAndEnter(event) &&
 | |
|         this.isCmdPlusEnterToSendEnabled() &&
 | |
|         !this.overrideLineBreaks
 | |
|       ) {
 | |
|         event.preventDefault();
 | |
|       }
 | |
|     },
 | |
|     onKeyup() {
 | |
|       this.typingIndicator.start();
 | |
|       this.updateImgToolbarOnDelete();
 | |
|     },
 | |
|     onKeydown(event) {
 | |
|       if (this.isEnterToSendEnabled()) {
 | |
|         this.handleLineBreakWhenEnterToSendEnabled(event);
 | |
|       }
 | |
|       if (this.isCmdPlusEnterToSendEnabled()) {
 | |
|         this.handleLineBreakWhenCmdAndEnterToSendEnabled(event);
 | |
|       }
 | |
|     },
 | |
|     onBlur() {
 | |
|       this.typingIndicator.stop();
 | |
|       this.$emit('blur');
 | |
|     },
 | |
|     onFocus() {
 | |
|       this.$emit('focus');
 | |
|     },
 | |
|     insertContentIntoEditor(content, defaultFrom = 0) {
 | |
|       const from = defaultFrom || this.editorView.state.selection.from || 0;
 | |
|       let node = new MessageMarkdownTransformer(messageSchema).parse(content);
 | |
| 
 | |
|       this.insertNodeIntoEditor(node, from, undefined);
 | |
|     },
 | |
|     insertNodeIntoEditor(node, from = 0, to = 0) {
 | |
|       this.state = insertAtCursor(this.editorView, node, from, to);
 | |
|       this.emitOnChange();
 | |
|       this.$nextTick(() => {
 | |
|         scrollCursorIntoView(this.editorView);
 | |
|       });
 | |
|     },
 | |
|   },
 | |
| };
 | |
| </script>
 | |
| 
 | |
| <style lang="scss">
 | |
| @import '~@chatwoot/prosemirror-schema/src/styles/base.scss';
 | |
| 
 | |
| .ProseMirror-menubar-wrapper {
 | |
|   @apply flex flex-col;
 | |
|   .ProseMirror-menubar {
 | |
|     min-height: var(--space-two) !important;
 | |
|     @apply -ml-2.5 pb-0 bg-white dark:bg-slate-900 text-slate-700 dark:text-slate-100;
 | |
| 
 | |
|     .ProseMirror-menu-active {
 | |
|       @apply bg-slate-75 dark:bg-slate-800;
 | |
|     }
 | |
|   }
 | |
|   > .ProseMirror {
 | |
|     @apply p-0 break-words text-slate-800 dark:text-slate-100;
 | |
| 
 | |
|     h1,
 | |
|     h2,
 | |
|     h3,
 | |
|     h4,
 | |
|     h5,
 | |
|     h6,
 | |
|     p {
 | |
|       @apply text-slate-800 dark:text-slate-100;
 | |
|     }
 | |
| 
 | |
|     blockquote {
 | |
|       @apply border-slate-400 dark:border-slate-500;
 | |
| 
 | |
|       p {
 | |
|         @apply text-slate-600 dark:text-slate-400;
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     ol li {
 | |
|       @apply list-item list-decimal;
 | |
|     }
 | |
|   }
 | |
| }
 | |
| 
 | |
| .editor-root {
 | |
|   @apply w-full relative;
 | |
| }
 | |
| 
 | |
| .ProseMirror-woot-style {
 | |
|   @apply overflow-auto min-h-[5rem] max-h-[7.5rem];
 | |
| }
 | |
| 
 | |
| .ProseMirror-prompt {
 | |
|   @apply z-[9999] bg-slate-25 dark:bg-slate-700 rounded-md border border-solid border-slate-75 dark:border-slate-800 shadow-lg;
 | |
| 
 | |
|   h5 {
 | |
|     @apply dark:text-slate-25 text-slate-800;
 | |
|   }
 | |
| }
 | |
| 
 | |
| .is-private {
 | |
|   .prosemirror-mention-node {
 | |
|     @apply font-medium bg-yellow-100 dark:bg-yellow-800 text-slate-900 dark:text-slate-25 py-0 px-1;
 | |
|   }
 | |
| 
 | |
|   .ProseMirror-menubar-wrapper {
 | |
|     .ProseMirror-menubar {
 | |
|       @apply bg-yellow-100 dark:bg-yellow-800 text-slate-700 dark:text-slate-25;
 | |
|     }
 | |
| 
 | |
|     > .ProseMirror {
 | |
|       @apply text-slate-800 dark:text-slate-25;
 | |
| 
 | |
|       p {
 | |
|         @apply text-slate-800 dark:text-slate-25;
 | |
|       }
 | |
|     }
 | |
|   }
 | |
| }
 | |
| 
 | |
| .editor-wrap {
 | |
|   @apply mb-4;
 | |
| }
 | |
| 
 | |
| .message-editor {
 | |
|   @apply border border-solid border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-900 rounded-md py-0 px-1 mb-0;
 | |
| }
 | |
| 
 | |
| .editor_warning {
 | |
|   @apply border border-solid border-red-400 dark:border-red-400;
 | |
| }
 | |
| 
 | |
| .editor-warning__message {
 | |
|   @apply text-red-400 dark:text-red-400 font-normal text-sm pt-1 pb-0 px-0;
 | |
| }
 | |
| </style>
 |