diff --git a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue index e776ed913..c349468b8 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue @@ -51,6 +51,7 @@ import { import { appendSignature, + collapseSelection, findNodeToInsertImage, getContentNode, insertAtCursor, @@ -66,6 +67,7 @@ import { import { hasPressedEnterAndNotCmdOrShift, hasPressedCommandAndEnter, + isEscape, } from 'shared/helpers/KeyboardHelpers'; import { createTypingIndicator } from '@chatwoot/utils'; import { checkFileSizeLimit } from 'shared/helpers/FileHelper'; @@ -515,7 +517,9 @@ function setMenubarPosition({ selection } = {}) { function checkSelection(editorState) { showSelectionMenu.value = false; - const hasSelection = editorState.selection.from !== editorState.selection.to; + const { selection } = editorState; + // Skip NodeSelection (from Esc -> selectParentNode); only text ranges count. + const hasSelection = !selection.empty && !selection.node; if (hasSelection === isTextSelected.value) return; isTextSelected.value = hasSelection; @@ -711,12 +715,17 @@ function handleLineBreakWhenCmdAndEnterToSendEnabled(event) { } function onKeydown(event) { + if (isEscape(event)) { + collapseSelection(editorView); + return true; + } if (isEnterToSendEnabled()) { handleLineBreakWhenEnterToSendEnabled(event); } if (isCmdPlusEnterToSendEnabled()) { handleLineBreakWhenCmdAndEnterToSendEnabled(event); } + return false; } function createEditorView() { @@ -744,6 +753,9 @@ function createEditorView() { blur: () => { if (props.disabled) return; typingIndicator.stop(); + // PM keeps its selection on blur — clear the menu flags manually. + isTextSelected.value = false; + editorRoot.value?.classList.remove('has-selection'); emit('blur'); }, paste: (view, event) => { diff --git a/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue b/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue index 72104ff06..1e1ab9756 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue @@ -17,6 +17,8 @@ import { toggleMark } from 'prosemirror-commands'; import { wrapInList } from 'prosemirror-schema-list'; import { toggleBlockType } from '@chatwoot/prosemirror-schema/src/menu/common'; import { checkFileSizeLimit } from 'shared/helpers/FileHelper'; +import { isEscape } from 'shared/helpers/KeyboardHelpers'; +import { collapseSelection } from 'dashboard/helper/editorHelper'; import { useAlert } from 'dashboard/composables'; import { useUISettings } from 'dashboard/composables/useUISettings'; import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins'; @@ -362,19 +364,33 @@ export default { onKeyup() { this.$emit('keyup'); }, - onKeydown() { + onKeydown(view, event) { this.$emit('keydown'); + if (isEscape(event)) { + if (this.showSlashMenu) { + this.showSlashMenu = false; + this.slashSearchTerm = ''; + this.slashMenuPosition = null; + return true; + } + collapseSelection(editorView); + return true; + } + return false; }, onBlur() { + // ProseMirror keeps its selection on blur — clear the menu flag manually. + this.isTextSelected = false; + this.$refs.editor?.classList.remove('has-selection'); this.$emit('blur'); }, onFocus() { this.$emit('focus'); }, checkSelection(editorState) { - const { from, to } = editorState.selection; - // Check if there's a selection (from and to are different) - const hasSelection = from !== to; + const { selection } = editorState; + // Skip NodeSelection (from Esc -> selectParentNode); only text ranges count. + const hasSelection = !selection.empty && !selection.node; // If the selection state is the same as the previous state, do nothing if (hasSelection === this.isTextSelected) return; // Update the selection state diff --git a/app/javascript/dashboard/helper/editorHelper.js b/app/javascript/dashboard/helper/editorHelper.js index b3d071ccd..9839a04e0 100644 --- a/app/javascript/dashboard/helper/editorHelper.js +++ b/app/javascript/dashboard/helper/editorHelper.js @@ -2,6 +2,7 @@ import { messageSchema, MessageMarkdownTransformer, MessageMarkdownSerializer, + Selection, } from '@chatwoot/prosemirror-schema'; import { replaceVariablesInMessage } from '@chatwoot/utils'; import * as Sentry from '@sentry/vue'; @@ -273,6 +274,18 @@ export const scrollCursorIntoView = view => { } }; +/** + * Collapse the current selection to a cursor near its head. Used to override + * the default Escape -> selectParentNode behavior which would otherwise keep + * the text highlight visible. + * + * @param {EditorView} view - The ProseMirror EditorView + */ +export const collapseSelection = view => { + const { tr, selection } = view.state; + view.dispatch(tr.setSelection(Selection.near(selection.$head))); +}; + /** * Returns a transaction that inserts a node into editor at the given position * Has an optional param 'content' to check if the diff --git a/app/javascript/dashboard/helper/specs/editorHelper.spec.js b/app/javascript/dashboard/helper/specs/editorHelper.spec.js index f558dd213..b06c36d42 100644 --- a/app/javascript/dashboard/helper/specs/editorHelper.spec.js +++ b/app/javascript/dashboard/helper/specs/editorHelper.spec.js @@ -16,6 +16,7 @@ import { calculateMenuPosition, stripUnsupportedFormatting, stripInlineBase64Images, + collapseSelection, } from '../editorHelper'; import { FORMATTING } from 'dashboard/constants/editor'; import { EditorState } from '@chatwoot/prosemirror-schema'; @@ -454,6 +455,37 @@ describe('stripInlineBase64Images', () => { }); }); +describe('collapseSelection', () => { + it('collapses a text range to a cursor at its head', () => { + const editorView = new EditorView(document.body, { + state: createEditorState('Hello world'), + }); + + // Build a TextSelection via the initial selection's constructor (avoids + // importing prosemirror-state, which isn't a direct dep). + const { doc, selection } = editorView.state; + editorView.dispatch( + editorView.state.tr.setSelection(selection.constructor.create(doc, 1, 6)) + ); + expect(editorView.state.selection.empty).toBe(false); + + collapseSelection(editorView); + + expect(editorView.state.selection.empty).toBe(true); + expect(editorView.state.selection.head).toBe(6); + }); + + it('leaves an already-collapsed selection as a cursor', () => { + const editorView = new EditorView(document.body, { + state: createEditorState('Hi'), + }); + + collapseSelection(editorView); + + expect(editorView.state.selection.empty).toBe(true); + }); +}); + describe('insertAtCursor', () => { it('should return undefined if editorView is not provided', () => { const result = insertAtCursor(undefined, schema.text('Hello'), 0);