diff --git a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue index 77c8a832e..22f45041c 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue @@ -58,7 +58,6 @@ 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'; @@ -66,10 +65,6 @@ import VariableList from '../conversation/VariableList.vue'; import { appendSignature, removeSignature, - insertAtCursor, - scrollCursorIntoView, - findNodeToInsertImage, - setURLWithQueryAndSize, } from 'dashboard/helper/editorHelper'; const TYPING_INDICATOR_IDLE_TIME = 4000; @@ -305,7 +300,6 @@ 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'); } @@ -331,17 +325,7 @@ export default { 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 - - bus.$on(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, this.insertContentIntoEditor); - }, - beforeDestroy() { - bus.$off(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, this.insertContentIntoEditor); + this.focusEditor(this.value); }, methods: { reloadState(content = this.value) { @@ -428,7 +412,6 @@ export default { state: this.state, dispatchTransaction: tx => { this.state = this.state.apply(tx); - this.editorView.updateState(this.state); this.emitOnChange(); }, handleDOMEvents: { @@ -532,7 +515,11 @@ export default { userFullName: mentionItem.name, }); - this.insertNodeIntoEditor(node, this.range.from, this.range.to); + 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.$track(CONVERSATION_EVENTS.USED_MENTIONS); return false; @@ -546,12 +533,26 @@ export default { return null; } + let from = this.range.from - 1; let node = new MessageMarkdownTransformer(messageSchema).parse( updatedMessage ); - this.insertNodeIntoEditor(node, this.range.from, this.range.to); + if (node.textContent === updatedMessage) { + node = this.editorView.state.schema.text(updatedMessage); + from = this.range.from; + } + 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; }, @@ -559,14 +560,23 @@ export default { if (!this.editorView) { return null; } + let node = this.editorView.state.schema.text(`{{${variable}}}`); + const from = this.range.from; - const content = `{{${variable}}}`; - let node = this.editorView.state.schema.text(content); - const { from, to } = this.range; + const tr = this.editorView.state.tr.replaceWith( + from, + this.range.to, + node + ); - this.insertNodeIntoEditor(node, from, to); + 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.showVariables = false; this.$track(CONVERSATION_EVENTS.INSERTED_A_VARIABLE); + tr.scrollIntoView(); return false; }, openFileBrowser() { @@ -622,6 +632,8 @@ export default { }, emitOnChange() { + this.editorView.updateState(this.state); + this.$emit('input', this.contentFromEditor); }, @@ -682,19 +694,6 @@ 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 0403b62fe..6d122d9d5 100644 --- a/app/javascript/dashboard/helper/editorHelper.js +++ b/app/javascript/dashboard/helper/editorHelper.js @@ -156,128 +156,3 @@ 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; -} - -/** - * 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, - }; -}; - -/** - * Set URL with query and size. - * - * @param {Object} selectedImageNode - The current selected node. - * @param {Object} size - The size to set. - * @param {Object} editorView - The editor view. - */ -export function setURLWithQueryAndSize(selectedImageNode, size, editorView) { - if (selectedImageNode) { - // Create and apply the transaction - const tr = editorView.state.tr.setNodeMarkup( - editorView.state.selection.from, - null, - { - src: selectedImageNode.src, - height: size.height, - } - ); - - if (tr.docChanged) { - editorView.dispatch(tr); - } - } -} diff --git a/app/javascript/dashboard/helper/specs/editorHelper.spec.js b/app/javascript/dashboard/helper/specs/editorHelper.spec.js index 55d0fe853..cd471d2f8 100644 --- a/app/javascript/dashboard/helper/specs/editorHelper.spec.js +++ b/app/javascript/dashboard/helper/specs/editorHelper.spec.js @@ -5,43 +5,7 @@ import { replaceSignature, cleanSignature, extractTextFromMarkdown, - insertAtCursor, - findNodeToInsertImage, - setURLWithQueryAndSize, } 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'; @@ -234,208 +198,3 @@ 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'); - }); -}); - -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); - }); -}); - -describe('setURLWithQueryAndSize', () => { - let selectedNode; - let editorView; - - beforeEach(() => { - selectedNode = { - setAttribute: jest.fn(), - }; - - const tr = { - setNodeMarkup: jest.fn().mockReturnValue({ - docChanged: true, - }), - }; - - const state = { - selection: { from: 0 }, - tr, - }; - - editorView = { - state, - dispatch: jest.fn(), - }; - }); - - it('updates the URL with the given size and updates the editor view', () => { - const size = { height: '20px' }; - - setURLWithQueryAndSize(selectedNode, size, editorView); - - // Check if the editor view is updated - expect(editorView.dispatch).toHaveBeenCalledTimes(1); - }); - - it('updates the URL with the given size and updates the editor view with original size', () => { - const size = { height: 'auto' }; - - setURLWithQueryAndSize(selectedNode, size, editorView); - - // Check if the editor view is updated - expect(editorView.dispatch).toHaveBeenCalledTimes(1); - }); - - it('does not update the editor view if the document has not changed', () => { - editorView.state.tr.setNodeMarkup = jest.fn().mockReturnValue({ - docChanged: false, - }); - - const size = { height: '20px' }; - - setURLWithQueryAndSize(selectedNode, size, editorView); - - // Check if the editor view dispatch was not called - expect(editorView.dispatch).not.toHaveBeenCalled(); - }); - - it('does not perform any operations if selectedNode is not provided', () => { - setURLWithQueryAndSize(null, { height: '20px' }, editorView); - - // Ensure the dispatch method wasn't called - expect(editorView.dispatch).not.toHaveBeenCalled(); - }); -});