diff --git a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue index f18adc418..235d9f34c 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue @@ -41,6 +41,7 @@ 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'; @@ -48,6 +49,8 @@ import VariableList from '../conversation/VariableList.vue'; import { appendSignature, removeSignature, + insertAtCursor, + scrollCursorIntoView, } from 'dashboard/helper/editorHelper'; const TYPING_INDICATOR_IDLE_TIME = 4000; @@ -273,6 +276,7 @@ export default { 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'); } @@ -298,7 +302,17 @@ export default { mounted() { this.createEditorView(); this.editorView.updateState(this.state); - this.focusEditor(this.value); + this.focusEditorInputField(); + + // BUS Event to insert text or markdown into the editor at the + // current cursor position. + // Components using this + // 1. SearchPopover.vue + + bus.$on(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, this.insertContentIntoEditor); + }, + beforeDestroy() { + bus.$off(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, this.insertContentIntoEditor); }, methods: { reloadState(content = this.value) { @@ -385,6 +399,7 @@ export default { state: this.state, dispatchTransaction: tx => { this.state = this.state.apply(tx); + this.editorView.updateState(this.state); this.emitOnChange(); }, handleDOMEvents: { @@ -441,11 +456,7 @@ export default { userFullName: mentionItem.name, }); - const tr = this.editorView.state.tr - .replaceWith(this.range.from, this.range.to, node) - .insertText(` `); - this.state = this.editorView.state.apply(tr); - this.emitOnChange(); + this.insertNodeIntoEditor(node, this.range.from, this.range.to); this.$track(CONVERSATION_EVENTS.USED_MENTIONS); return false; @@ -459,26 +470,12 @@ export default { return null; } - let from = this.range.from - 1; let node = new MessageMarkdownTransformer(messageSchema).parse( updatedMessage ); - if (node.textContent === updatedMessage) { - node = this.editorView.state.schema.text(updatedMessage); - from = this.range.from; - } + this.insertNodeIntoEditor(node, this.range.from, this.range.to); - const tr = this.editorView.state.tr.replaceWith( - from, - this.range.to, - node - ); - - this.state = this.editorView.state.apply(tr); - this.emitOnChange(); - - tr.scrollIntoView(); this.$track(CONVERSATION_EVENTS.INSERTED_A_CANNED_RESPONSE); return false; }, @@ -486,23 +483,14 @@ export default { if (!this.editorView) { return null; } - let node = this.editorView.state.schema.text(`{{${variable}}}`); - const from = this.range.from; - const tr = this.editorView.state.tr.replaceWith( - from, - this.range.to, - node - ); + const content = `{{${variable}}}`; + let node = this.editorView.state.schema.text(content); + const { from, to } = this.range; - this.state = this.editorView.state.apply(tr); - this.emitOnChange(); - - // The `{{ }}` are added to the message, but the cursor is placed - // and onExit of suggestionsPlugin is not called. So we need to manually hide + this.insertNodeIntoEditor(node, from, to); this.showVariables = false; this.$track(CONVERSATION_EVENTS.INSERTED_A_VARIABLE); - tr.scrollIntoView(); return false; }, openFileBrowser() { @@ -558,8 +546,6 @@ export default { }, emitOnChange() { - this.editorView.updateState(this.state); - this.$emit('input', this.contentFromEditor); }, @@ -619,6 +605,19 @@ export default { 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); + }); + }, }, }; diff --git a/app/javascript/dashboard/helper/editorHelper.js b/app/javascript/dashboard/helper/editorHelper.js index 6d122d9d5..0b5171b32 100644 --- a/app/javascript/dashboard/helper/editorHelper.js +++ b/app/javascript/dashboard/helper/editorHelper.js @@ -156,3 +156,62 @@ export function extractTextFromMarkdown(markdown) { .replace(/\n{2,}/g, '\n') // Remove multiple consecutive newlines (blank lines) .trim(); // Trim any extra space } + +/** + * Scrolls the editor view into current cursor position + * + * @param {EditorView} view - The Prosemirror EditorView + * + */ +export const scrollCursorIntoView = view => { + // Get the current selection's head position (where the cursor is). + const pos = view.state.selection.head; + + // Get the corresponding DOM node for that position. + const domAtPos = view.domAtPos(pos); + const node = domAtPos.node; + + // Scroll the node into view. + if (node && node.scrollIntoView) { + node.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } +}; + +/** + * Returns a transaction that inserts a node into editor at the given position + * Has an optional param 'content' to check if the + * + * @param {Node} node - The prosemirror node that needs to be inserted into the editor + * @param {number} from - Position in the editor where the node needs to be inserted + * @param {number} to - Position in the editor where the node needs to be replaced + * + */ +export function insertAtCursor(editorView, node, from, to) { + if (!editorView) { + return undefined; + } + + // This is a workaround to prevent inserting content into new line rather than on the exiting line + // If the node is of type 'doc' and has only one child which is a paragraph, + // then extract its inline content to be inserted as inline. + const isWrappedInParagraph = + node.type.name === 'doc' && + node.childCount === 1 && + node.firstChild.type.name === 'paragraph'; + + if (isWrappedInParagraph) { + node = node.firstChild.content; + } + + let tr; + if (to) { + tr = editorView.state.tr.replaceWith(from, to, node).insertText(` `); + } else { + tr = editorView.state.tr.insert(from, node); + } + const state = editorView.state.apply(tr); + editorView.updateState(state); + editorView.focus(); + + return state; +} diff --git a/app/javascript/dashboard/helper/specs/editorHelper.spec.js b/app/javascript/dashboard/helper/specs/editorHelper.spec.js index cd471d2f8..b99a1156f 100644 --- a/app/javascript/dashboard/helper/specs/editorHelper.spec.js +++ b/app/javascript/dashboard/helper/specs/editorHelper.spec.js @@ -5,7 +5,41 @@ import { replaceSignature, cleanSignature, extractTextFromMarkdown, + insertAtCursor, } from '../editorHelper'; +import { EditorState } from 'prosemirror-state'; +import { EditorView } from 'prosemirror-view'; +import { Schema } from 'prosemirror-model'; + +// Define a basic ProseMirror schema +const schema = new Schema({ + nodes: { + doc: { content: 'paragraph+' }, + paragraph: { + content: 'text*', + toDOM: () => ['p', 0], // Represents a paragraph as a

tag in the DOM. + }, + text: { + toDOM: node => node.text, // Represents text as its actual string value. + }, + }, +}); + +// Initialize a basic EditorState for testing +const createEditorState = (content = '') => { + if (!content) { + return EditorState.create({ + schema, + doc: schema.node('doc', null, [schema.node('paragraph')]), + }); + } + return EditorState.create({ + schema, + doc: schema.node('doc', null, [ + schema.node('paragraph', null, [schema.text(content)]), + ]), + }); +}; const NEW_SIGNATURE = 'This is a new signature'; @@ -198,3 +232,44 @@ describe('extractTextFromMarkdown', () => { expect(extractTextFromMarkdown(markdown)).toEqual(expected); }); }); + +describe('insertAtCursor', () => { + it('should return undefined if editorView is not provided', () => { + const result = insertAtCursor(undefined, schema.text('Hello'), 0); + expect(result).toBeUndefined(); + }); + + it('should unwrap doc nodes that are wrapped in a paragraph', () => { + const docNode = schema.node('doc', null, [ + schema.node('paragraph', null, [schema.text('Hello')]), + ]); + + const editorState = createEditorState(); + const editorView = new EditorView(document.body, { state: editorState }); + + insertAtCursor(editorView, docNode, 0); + + // Check if node was unwrapped and inserted correctly + expect(editorView.state.doc.firstChild.firstChild.text).toBe('Hello'); + }); + + it('should insert node without replacing any content if "to" is not provided', () => { + const editorState = createEditorState(); + const editorView = new EditorView(document.body, { state: editorState }); + + insertAtCursor(editorView, schema.text('Hello'), 0); + + // Check if node was inserted correctly + expect(editorView.state.doc.firstChild.firstChild.text).toBe('Hello'); + }); + + it('should replace content between "from" and "to" with the provided node', () => { + const editorState = createEditorState('ReplaceMe'); + const editorView = new EditorView(document.body, { state: editorState }); + + insertAtCursor(editorView, schema.text('Hello'), 0, 8); + + // Check if content was replaced correctly + expect(editorView.state.doc.firstChild.firstChild.text).toBe('Hello Me'); + }); +});