chore: Adds a bus event to insert text at cursor in editor (#7968)

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
This commit is contained in:
Nithin David Thomas
2023-10-05 14:37:01 +05:30
committed by GitHub
parent e5c198f839
commit e27274a5a8
3 changed files with 169 additions and 36 deletions

View File

@@ -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);
});
},
},
};
</script>

View File

@@ -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;
}

View File

@@ -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 <p> 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');
});
});