From 92422f979d15f994af18fe16f1ff36386bfff95b Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Thu, 8 Jan 2026 15:17:54 +0530 Subject: [PATCH] chore: Replace plain editor with advanced editor (#13071) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Pull Request Template ## Description This PR reverts the plain text editor back to the **advanced editor**, which was previously removed in [https://github.com/chatwoot/chatwoot/pull/13058](https://github.com/chatwoot/chatwoot/pull/13058). All channels now use the **ProseMirror editor**, with formatting applied based on each channel’s configuration. This PR also fixes issues where **new lines were not properly preserved during Markdown serialization**, for both: * `Enter or CMD/Ctrl+enter` (new paragraph) * `Shift+Enter` (`hard_break`) Additionally, it resolves related **[Sentry issue](https://chatwoot-p3.sentry.io/issues/?environment=production&project=4507182691975168&query=is%3Aunresolved%20markdown&referrer=issue-list&statsPeriod=7d)**. With these changes: * Line breaks and spacing are now preserved correctly when saving canned responses. * When editing a canned response, the content retains the exact spacing and formatting as saved in editor. * Canned responses are now correctly converted to plain text where required and displayed consistently in the canned response list. ### https://github.com/chatwoot/prosemirror-schema/pull/38 --- ## Type of change - [x] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? ## 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 - [ ] 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 - [ ] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Muhsin Keloth --- .../components/widgets/WootWriter/Editor.vue | 5 +- .../widgets/conversation/ReplyBox.vue | 202 ++++-------------- .../widgets/mentions/MentionBox.vue | 5 +- .../dashboard/helper/editorHelper.js | 62 ++---- .../dashboard/settings/canned/Index.vue | 5 +- package.json | 2 +- pnpm-lock.yaml | 10 +- 7 files changed, 73 insertions(+), 218 deletions(-) diff --git a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue index d26192bcd..850ca5f4b 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue @@ -55,6 +55,7 @@ import { getSelectionCoords, calculateMenuPosition, getEffectiveChannelType, + stripUnsupportedFormatting, } from 'dashboard/helper/editorHelper'; import { hasPressedEnterAndNotCmdOrShift, @@ -131,8 +132,10 @@ const editorMenuOptions = computed(() => { const createState = (content, placeholder, plugins = [], methods = {}) => { const schema = editorSchema.value; + // Strip unsupported formatting before parsing to prevent "Token type not supported" errors + const sanitizedContent = stripUnsupportedFormatting(content, schema); return EditorState.create({ - doc: new MessageMarkdownTransformer(schema).parse(content), + doc: new MessageMarkdownTransformer(schema).parse(sanitizedContent), plugins: buildEditor({ schema, placeholder, diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue index aa67cda0b..a2e7fe103 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue @@ -7,9 +7,7 @@ import { useTrack } from 'dashboard/composables'; import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins'; import { FEATURE_FLAGS } from 'dashboard/featureFlags'; -import CannedResponse from './CannedResponse.vue'; import ReplyToMessage from './ReplyToMessage.vue'; -import ResizableTextArea from 'shared/components/ResizableTextArea.vue'; import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview.vue'; import ReplyTopPanel from 'dashboard/components/widgets/WootWriter/ReplyTopPanel.vue'; import ReplyEmailHead from './ReplyEmailHead.vue'; @@ -46,7 +44,6 @@ import { appendSignature, removeSignature, getEffectiveChannelType, - extractTextFromMarkdown, } from 'dashboard/helper/editorHelper'; import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage'; @@ -72,8 +69,6 @@ export default { WhatsappTemplates, WootMessageEditor, QuotedEmailPreview, - ResizableTextArea, - CannedResponse, }, mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins], props: { @@ -114,8 +109,6 @@ export default { recordingAudioState: '', recordingAudioDurationText: '', replyType: REPLY_EDITOR_MODES.REPLY, - mentionSearchKey: '', - hasSlashCommand: false, bccEmails: '', ccEmails: '', toEmails: '', @@ -148,9 +141,6 @@ export default { if (!senderId) return {}; return this.$store.getters['contacts/getContact'](senderId); }, - isRichEditorEnabled() { - return this.isAWebWidgetInbox || this.isAnEmailChannel || this.isAPIInbox; - }, shouldShowReplyToMessage() { return ( this.inReplyTo?.id && @@ -409,19 +399,6 @@ export default { !!this.quotedEmailText ); }, - showRichContentEditor() { - if (this.isOnPrivateNote || this.isRichEditorEnabled) { - return true; - } - - return false; - }, - // ensure that the signature is plain text depending on `showRichContentEditor` - signatureToApply() { - return this.showRichContentEditor - ? this.messageSignature - : extractTextFromMarkdown(this.messageSignature); - }, }, watch: { currentChat(conversation, oldConversation) { @@ -464,25 +441,7 @@ export default { this.resetRecorderAndClearAttachments(); } }, - message(updatedMessage) { - // Check if the message starts with a slash. - const bodyWithoutSignature = removeSignature( - updatedMessage, - this.signatureToApply - ); - const startsWithSlash = bodyWithoutSignature.startsWith('/'); - - // Determine if the user is potentially typing a slash command. - // This is true if the message starts with a slash and the rich content editor is not active. - this.hasSlashCommand = startsWithSlash && !this.showRichContentEditor; - this.showMentions = this.hasSlashCommand; - - // If a slash command is active, extract the command text after the slash. - // If not, reset the mentionSearchKey. - this.mentionSearchKey = this.hasSlashCommand - ? bodyWithoutSignature.substring(1) - : ''; - + message() { // Autosave the current message draft. this.doAutoSaveDraft(); }, @@ -532,20 +491,14 @@ export default { methods: { handleInsert(article) { const { url, title } = article; - if (this.isRichEditorEnabled) { - // Removing empty lines from the title - const lines = title.split('\n'); - const nonEmptyLines = lines.filter(line => line.trim() !== ''); - const filteredMarkdown = nonEmptyLines.join(' '); - emitter.emit( - BUS_EVENTS.INSERT_INTO_RICH_EDITOR, - `[${filteredMarkdown}](${url})` - ); - } else { - this.addIntoEditor( - `${this.$t('CONVERSATION.REPLYBOX.INSERT_READ_MORE')} ${url}` - ); - } + // Removing empty lines from the title + const lines = title.split('\n'); + const nonEmptyLines = lines.filter(line => line.trim() !== ''); + const filteredMarkdown = nonEmptyLines.join(' '); + emitter.emit( + BUS_EVENTS.INSERT_INTO_RICH_EDITOR, + `[${filteredMarkdown}](${url})` + ); useTrack(CONVERSATION_EVENTS.INSERT_ARTICLE_LINK); }, @@ -614,26 +567,14 @@ export default { if (this.isPrivate) { return message; } - if (this.showRichContentEditor) { - const effectiveChannelType = getEffectiveChannelType( - this.channelType, - this.inbox?.medium || '' - ); - return this.sendWithSignature - ? appendSignature( - message, - this.messageSignature, - effectiveChannelType - ) - : removeSignature( - message, - this.messageSignature, - effectiveChannelType - ); - } + + const effectiveChannelType = getEffectiveChannelType( + this.channelType, + this.inbox?.medium || '' + ); return this.sendWithSignature - ? appendSignature(message, this.signatureToApply) - : removeSignature(message, this.signatureToApply); + ? appendSignature(message, this.messageSignature, effectiveChannelType) + : removeSignature(message, this.messageSignature, effectiveChannelType); }, removeFromDraft() { if (this.conversationIdByRoute) { @@ -649,7 +590,6 @@ export default { Escape: { action: () => { this.hideEmojiPicker(); - this.hideMentions(); }, allowOnFocusedInput: true, }, @@ -694,11 +634,6 @@ export default { // Don't handle paste if compose new conversation modal is open if (this.newConversationModalActive) return; - const data = e.clipboardData.files; - if (!this.showRichContentEditor && data.length !== 0) { - this.$refs.messageInput?.$el?.blur(); - } - // Filter valid files (non-zero size) Array.from(e.clipboardData.files) .filter(file => file.size > 0) @@ -832,19 +767,15 @@ export default { // if signature is enabled, append it to the message // appendSignature ensures that the signature is not duplicated // so we don't need to check if the signature is already present - if (this.showRichContentEditor) { - const effectiveChannelType = getEffectiveChannelType( - this.channelType, - this.inbox?.medium || '' - ); - message = appendSignature( - message, - this.messageSignature, - effectiveChannelType - ); - } else { - message = appendSignature(message, this.signatureToApply); - } + const effectiveChannelType = getEffectiveChannelType( + this.channelType, + this.inbox?.medium || '' + ); + message = appendSignature( + message, + this.messageSignature, + effectiveChannelType + ); } const updatedMessage = replaceVariablesInMessage({ @@ -868,52 +799,30 @@ export default { }); if (canReply || this.isAWhatsAppChannel || this.isAPIInbox) this.replyType = mode; - if (this.showRichContentEditor) { - if (this.isRecordingAudio) { - this.toggleAudioRecorder(); - } - return; + if (this.isRecordingAudio) { + this.toggleAudioRecorder(); } - this.$nextTick(() => this.$refs.messageInput.focus()); }, clearEditorSelection() { this.updateEditorSelectionWith = ''; }, - insertIntoTextEditor(text, selectionStart, selectionEnd) { - const { message } = this; - const newMessage = - message.slice(0, selectionStart) + - text + - message.slice(selectionEnd, message.length); - this.message = newMessage; - }, addIntoEditor(content) { - if (this.showRichContentEditor) { - this.updateEditorSelectionWith = content; - this.onFocus(); - } - if (!this.showRichContentEditor) { - const { selectionStart, selectionEnd } = this.$refs.messageInput.$el; - this.insertIntoTextEditor(content, selectionStart, selectionEnd); - } + this.updateEditorSelectionWith = content; + this.onFocus(); }, clearMessage() { this.message = ''; if (this.sendWithSignature && !this.isPrivate) { // if signature is enabled, append it to the message - if (this.showRichContentEditor) { - const effectiveChannelType = getEffectiveChannelType( - this.channelType, - this.inbox?.medium || '' - ); - this.message = appendSignature( - this.message, - this.messageSignature, - effectiveChannelType - ); - } else { - this.message = appendSignature(this.message, this.signatureToApply); - } + const effectiveChannelType = getEffectiveChannelType( + this.channelType, + this.inbox?.medium || '' + ); + this.message = appendSignature( + this.message, + this.messageSignature, + effectiveChannelType + ); } this.attachedFiles = []; this.isRecordingAudio = false; @@ -948,9 +857,6 @@ export default { this.toggleEmojiPicker(); } }, - hideMentions() { - this.showMentions = false; - }, onTypingOn() { this.toggleTyping('on'); }, @@ -1197,13 +1103,6 @@ export default { :message="inReplyTo" @dismiss="resetReplyToMessage" /> - - diff --git a/app/javascript/dashboard/components/widgets/mentions/MentionBox.vue b/app/javascript/dashboard/components/widgets/mentions/MentionBox.vue index 999ec758d..dc00bdb4c 100644 --- a/app/javascript/dashboard/components/widgets/mentions/MentionBox.vue +++ b/app/javascript/dashboard/components/widgets/mentions/MentionBox.vue @@ -1,6 +1,7 @@