From 399c91adaa58846db607036ff9e226b2288e755b Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Mon, 8 Dec 2025 14:43:45 +0530 Subject: [PATCH] feat: Standardize rich editor across all channels (#12600) # Pull Request Template ## Description This PR includes, 1. **Channel-specific formatting and menu options** for the rich reply editor. 2. **Removal of the plain reply editor** and full **standardization** on the rich reply editor across all channels. 3. **Fix for multiple canned responses insertion:** * **Before:** The plain editor only allowed inserting canned responses at the beginning of a message, making it impossible to combine multiple canned responses in a single reply. This caused inconsistent behavior across the app. * **Solution:** Replaced the plain reply editor with the rich (ProseMirror) editor to ensure a unified experience. Agents can now insert multiple canned responses at any cursor position. 4. **Floating editor menu** for the reply box to improve accessibility and overall user experience. 5. **New Strikethrough formatting option** added to the editor menu. --- **Editor repo PR**: https://github.com/chatwoot/prosemirror-schema/pull/36 Fixes https://github.com/chatwoot/chatwoot/issues/12517, [CW-5924](https://linear.app/chatwoot/issue/CW-5924/standardize-the-editor), [CW-5679](https://linear.app/chatwoot/issue/CW-5679/allow-inserting-multiple-canned-responses-in-a-single-message) ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? ### Screenshot **Dark** image **Light** image ## 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 Co-authored-by: Pranav Co-authored-by: Vinay Keerthi <11478411+stonecharioteer@users.noreply.github.com> --- Gemfile | 2 +- Gemfile.lock | 8 +- .../components-next/Editor/Editor.vue | 15 - .../Pages/ArticleEditorPage/ArticleEditor.vue | 2 +- .../components/widgets/WootWriter/Editor.vue | 160 ++++++-- .../widgets/WootWriter/ReplyBottomPanel.vue | 15 - .../widgets/conversation/ReplyBox.vue | 201 ++-------- app/javascript/dashboard/constants/editor.js | 167 ++++++-- .../dashboard/helper/editorHelper.js | 85 +++- .../helper/specs/editorContentHelper.spec.js | 33 +- .../helper/specs/editorHelper.spec.js | 188 ++++++++- .../i18n/locale/en/conversation.json | 1 - .../dashboard/settings/inbox/Settings.vue | 4 +- .../settings/inbox/WidgetBuilder.vue | 4 +- .../settings/inbox/channels/Website.vue | 4 +- .../settings/profile/MessageSignature.vue | 4 +- app/models/channel/telegram.rb | 33 +- app/presenters/message_content_presenter.rb | 16 +- .../messages/markdown_renderer_service.rb | 64 +++ .../base_markdown_renderer.rb | 39 ++ .../markdown_renderers/instagram_renderer.rb | 44 +++ .../markdown_renderers/line_renderer.rb | 36 ++ .../markdown_renderers/plain_text_renderer.rb | 58 +++ .../markdown_renderers/telegram_renderer.rb | 60 +++ .../markdown_renderers/whats_app_renderer.rb | 32 ++ .../email_reply.html.erb | 2 +- lib/chatwoot_markdown_renderer.rb | 2 +- lib/tasks/auto_annotate_models.rake | 9 +- package.json | 2 +- pnpm-lock.yaml | 10 +- spec/lib/chatwoot_markdown_renderer_spec.rb | 3 +- .../message_content_presenter_spec.rb | 16 +- .../markdown_renderer_service_spec.rb | 366 ++++++++++++++++++ 33 files changed, 1351 insertions(+), 334 deletions(-) create mode 100644 app/services/messages/markdown_renderer_service.rb create mode 100644 app/services/messages/markdown_renderers/base_markdown_renderer.rb create mode 100644 app/services/messages/markdown_renderers/instagram_renderer.rb create mode 100644 app/services/messages/markdown_renderers/line_renderer.rb create mode 100644 app/services/messages/markdown_renderers/plain_text_renderer.rb create mode 100644 app/services/messages/markdown_renderers/telegram_renderer.rb create mode 100644 app/services/messages/markdown_renderers/whats_app_renderer.rb create mode 100644 spec/services/messages/markdown_renderer_service_spec.rb diff --git a/Gemfile b/Gemfile index 46b11ef1d..1ae6cf093 100644 --- a/Gemfile +++ b/Gemfile @@ -215,7 +215,7 @@ group :production do end group :development do - gem 'annotate' + gem 'annotaterb' gem 'bullet' gem 'letter_opener' gem 'scss_lint', require: false diff --git a/Gemfile.lock b/Gemfile.lock index 55cfdef7e..b42472ef4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -128,9 +128,9 @@ GEM selectize-rails (~> 0.6) ai-agents (0.7.0) ruby_llm (~> 1.8.2) - annotate (3.2.0) - activerecord (>= 3.2, < 8.0) - rake (>= 10.4, < 14.0) + annotaterb (4.20.0) + activerecord (>= 6.0.0) + activesupport (>= 6.0.0) ast (2.4.3) attr_extras (7.1.0) audited (5.4.1) @@ -1018,7 +1018,7 @@ DEPENDENCIES administrate-field-active_storage (>= 1.0.3) administrate-field-belongs_to_search (>= 0.9.0) ai-agents (>= 0.7.0) - annotate + annotaterb attr_extras audited (~> 5.4, >= 5.4.1) aws-actionmailbox-ses (~> 0) diff --git a/app/javascript/dashboard/components-next/Editor/Editor.vue b/app/javascript/dashboard/components-next/Editor/Editor.vue index 67936fa59..ff1c616de 100644 --- a/app/javascript/dashboard/components-next/Editor/Editor.vue +++ b/app/javascript/dashboard/components-next/Editor/Editor.vue @@ -19,7 +19,6 @@ const props = defineProps({ }, enableVariables: { type: Boolean, default: false }, enableCannedResponses: { type: Boolean, default: true }, - enabledMenuOptions: { type: Array, default: () => [] }, enableCaptainTools: { type: Boolean, default: false }, signature: { type: String, default: '' }, allowSignature: { type: Boolean, default: false }, @@ -102,7 +101,6 @@ watch( :disabled="disabled" :enable-variables="enableVariables" :enable-canned-responses="enableCannedResponses" - :enabled-menu-options="enabledMenuOptions" :enable-captain-tools="enableCaptainTools" :signature="signature" :allow-signature="allowSignature" @@ -139,19 +137,6 @@ watch( .editor-wrapper { ::v-deep { .ProseMirror-menubar-wrapper { - @apply gap-2 !important; - - .ProseMirror-menubar { - @apply bg-transparent dark:bg-transparent w-fit left-1 pt-0 h-5 !top-0 !relative !important; - - .ProseMirror-menuitem { - @apply h-5 !important; - } - - .ProseMirror-icon { - @apply p-1 w-3 h-3 text-n-slate-12 dark:text-n-slate-12 !important; - } - } .ProseMirror.ProseMirror-woot-style { p { @apply first:mt-0 !important; diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue index 7beff200e..4c4d95f0c 100644 --- a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue +++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue @@ -172,7 +172,7 @@ const previewArticle = () => { @apply mr-0; .ProseMirror-icon { - @apply p-0 mt-1 !mr-0; + @apply p-0 mt-0 !mr-0; svg { width: 20px !important; diff --git a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue index 8d7a082b4..323b911ec 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue @@ -26,13 +26,11 @@ import { useAlert } from 'dashboard/composables'; import { BUS_EVENTS } from 'shared/constants/busEvents'; import { CONVERSATION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events'; -import { - MESSAGE_EDITOR_MENU_OPTIONS, - MESSAGE_EDITOR_IMAGE_RESIZES, -} from 'dashboard/constants/editor'; +import { MESSAGE_EDITOR_IMAGE_RESIZES } from 'dashboard/constants/editor'; import { messageSchema, + buildMessageSchema, buildEditor, EditorView, MessageMarkdownTransformer, @@ -53,6 +51,9 @@ import { removeSignature as removeSignatureHelper, scrollCursorIntoView, setURLWithQueryAndSize, + getFormattingForEditor, + getSelectionCoords, + calculateMenuPosition, } from 'dashboard/helper/editorHelper'; import { hasPressedEnterAndNotCmdOrShift, @@ -75,7 +76,6 @@ const props = defineProps({ enableCannedResponses: { type: Boolean, default: true }, enableCaptainTools: { type: Boolean, default: false }, variables: { type: Object, default: () => ({}) }, - enabledMenuOptions: { type: Array, default: () => [] }, signature: { type: String, default: '' }, // allowSignature is a kill switch, ensuring no signature methods // are triggered except when this flag is true @@ -103,22 +103,34 @@ const { t } = useI18n(); const TYPING_INDICATOR_IDLE_TIME = 4000; const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB +const DEFAULT_FORMATTING = 'Context::Default'; -const createState = ( - content, - placeholder, - plugins = [], - methods = {}, - enabledMenuOptions = [] -) => { +const editorSchema = computed(() => { + if (!props.channelType) return messageSchema; + + const formatType = props.isPrivate ? DEFAULT_FORMATTING : props.channelType; + const formatting = getFormattingForEditor(formatType); + return buildMessageSchema(formatting.marks, formatting.nodes); +}); + +const editorMenuOptions = computed(() => { + const formatType = props.isPrivate + ? DEFAULT_FORMATTING + : props.channelType || DEFAULT_FORMATTING; + const formatting = getFormattingForEditor(formatType); + return formatting.menu; +}); + +const createState = (content, placeholder, plugins = [], methods = {}) => { + const schema = editorSchema.value; return EditorState.create({ - doc: new MessageMarkdownTransformer(messageSchema).parse(content), + doc: new MessageMarkdownTransformer(schema).parse(content), plugins: buildEditor({ - schema: messageSchema, + schema, placeholder, methods, plugins, - enabledMenuOptions, + enabledMenuOptions: editorMenuOptions.value, }), }); }; @@ -153,6 +165,8 @@ const range = ref(null); const isImageNodeSelected = ref(false); const toolbarPosition = ref({ top: 0, left: 0 }); const selectedImageNode = ref(null); +const isTextSelected = ref(false); // Tracks text selection and prevents unnecessary re-renders on mouse selection +const showSelectionMenu = ref(false); const sizes = MESSAGE_EDITOR_IMAGE_RESIZES; // element ref @@ -174,12 +188,6 @@ const shouldShowCannedResponses = computed(() => { ); }); -const editorMenuOptions = computed(() => { - return props.enabledMenuOptions.length - ? props.enabledMenuOptions - : MESSAGE_EDITOR_MENU_OPTIONS; -}); - function createSuggestionPlugin({ trigger, minChars = 0, @@ -400,6 +408,38 @@ function setToolbarPosition() { }; } +function setMenubarPosition({ selection } = {}) { + const wrapper = editorRoot.value; + if (!selection || !wrapper) return; + + const rect = wrapper.getBoundingClientRect(); + const isRtl = getComputedStyle(wrapper).direction === 'rtl'; + + // Calculate coords and final position + const coords = getSelectionCoords(editorView, selection, rect); + const { left, top, width } = calculateMenuPosition(coords, rect, isRtl); + + wrapper.style.setProperty('--selection-left', `${left}px`); + wrapper.style.setProperty( + '--selection-right', + `${rect.width - left - width}px` + ); + wrapper.style.setProperty('--selection-top', `${top}px`); +} + +function checkSelection(editorState) { + showSelectionMenu.value = false; + const hasSelection = editorState.selection.from !== editorState.selection.to; + if (hasSelection === isTextSelected.value) return; + + isTextSelected.value = hasSelection; + const wrapper = editorRoot.value; + if (!wrapper) return; + + wrapper.classList.toggle('has-selection', hasSelection); + if (hasSelection) setMenubarPosition(editorState); +} + function setURLWithQueryAndImageSize(size) { if (!props.showImageResizeToolbar) { return; @@ -529,7 +569,9 @@ async function insertNodeIntoEditor(node, from = 0, to = 0) { function insertContentIntoEditor(content, defaultFrom = 0) { const from = defaultFrom || editorView.state.selection.from || 0; - let node = new MessageMarkdownTransformer(messageSchema).parse(content); + // Use the editor's current schema to ensure compatibility with buildMessageSchema + const currentSchema = editorView.state.schema; + let node = new MessageMarkdownTransformer(currentSchema).parse(content); insertNodeIntoEditor(node, from, undefined); } @@ -596,6 +638,7 @@ function createEditorView() { if (tx.docChanged) { emitOnChange(); } + checkSelection(state); }, handleDOMEvents: { keyup: () => { @@ -761,15 +804,33 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor); @import '@chatwoot/prosemirror-schema/src/styles/base.scss'; .ProseMirror-menubar-wrapper { - @apply flex flex-col; + @apply flex flex-col gap-3; .ProseMirror-menubar { min-height: 1.25rem !important; - @apply -ml-2.5 pb-0 bg-transparent text-n-slate-11; + @apply items-center gap-4 flex pb-0 bg-transparent text-n-slate-11 relative ltr:-left-[3px] rtl:-right-[3px]; .ProseMirror-menu-active { - @apply bg-n-slate-5 dark:bg-n-solid-3; + @apply bg-n-slate-5 dark:bg-n-solid-3 !important; } + + .ProseMirror-menuitem { + @apply mr-0 size-4 flex items-center justify-center; + + .ProseMirror-icon { + @apply size-4 flex items-center justify-center flex-shrink-0; + + svg { + @apply size-full; + } + } + } + } + + .ProseMirror-menubar:not(:has(*)) { + max-height: none !important; + min-height: 0 !important; + padding: 0 !important; } > .ProseMirror { @@ -860,4 +921,53 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor); .editor-warning__message { @apply text-n-ruby-9 dark:text-n-ruby-9 font-normal text-sm pt-1 pb-0 px-0; } + +// Float editor menu +.popover-prosemirror-menu { + position: relative; + + .ProseMirror p:last-child { + margin-bottom: 10px !important; + } + + .ProseMirror-menubar { + display: none; // Hide by default + } + + &.has-selection { + // Hide menu completely when it has no items + .ProseMirror-menubar:not(:has(*)) { + display: none !important; + } + + .ProseMirror-menubar { + @apply rounded-lg !px-3 !py-1.5 z-50 bg-n-background items-center gap-4 ml-0 mb-0 shadow-md outline outline-1 outline-n-weak; + display: flex; + width: fit-content !important; + position: absolute !important; + + // Default/LTR: position from left + top: var(--selection-top); + left: var(--selection-left); + + // RTL: position from right instead + [dir='rtl'] & { + left: auto; + right: var(--selection-right); + } + + .ProseMirror-menuitem { + @apply mr-0 size-4 flex items-center; + + .ProseMirror-icon { + @apply p-0.5 flex-shrink-0; + } + } + + .ProseMirror-menu-active { + @apply bg-n-slate-3; + } + } + } +} diff --git a/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue b/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue index bc43f4869..80737ac45 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue @@ -78,10 +78,6 @@ export default { type: Boolean, default: false, }, - showEditorToggle: { - type: Boolean, - default: false, - }, isOnPrivateNote: { type: Boolean, default: false, @@ -130,7 +126,6 @@ export default { emits: [ 'replaceText', 'toggleInsertArticle', - 'toggleEditor', 'selectWhatsappTemplate', 'selectContentTemplate', 'toggleQuotedReply', @@ -325,18 +320,8 @@ export default { sm @click="toggleAudioRecorder" /> - 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); }, - toggleRichContentEditor() { - this.updateUISettings({ - display_rich_content_editor: !this.showRichContentEditor, - }); - - const plainTextSignature = extractTextFromMarkdown(this.messageSignature); - - if (!this.showRichContentEditor && this.messageSignature) { - // remove the old signature -> extract text from markdown -> attach new signature - let message = removeSignature(this.message, this.messageSignature); - message = extractTextFromMarkdown(message); - message = appendSignature(message, plainTextSignature); - - this.message = message; - } else { - this.message = replaceSignature( - this.message, - plainTextSignature, - this.messageSignature - ); - } - }, toggleQuotedReply() { if (!this.isAnEmailChannel) { return; @@ -655,8 +565,8 @@ export default { } return this.sendWithSignature - ? appendSignature(message, this.signatureToApply) - : removeSignature(message, this.signatureToApply); + ? appendSignature(message, this.messageSignature) + : removeSignature(message, this.messageSignature); }, removeFromDraft() { if (this.conversationIdByRoute) { @@ -672,7 +582,6 @@ export default { Escape: { action: () => { this.hideEmojiPicker(); - this.hideMentions(); }, allowOnFocusedInput: true, }, @@ -715,9 +624,6 @@ export default { }, onPaste(e) { const data = e.clipboardData.files; - if (!this.showRichContentEditor && data.length !== 0) { - this.$refs.messageInput.$el.blur(); - } if (!data.length || !data[0]) { return; } @@ -851,7 +757,7 @@ 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 - message = appendSignature(message, this.signatureToApply); + message = appendSignature(message, this.messageSignature); } const updatedMessage = replaceVariablesInMessage({ @@ -875,40 +781,22 @@ 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 - this.message = appendSignature(this.message, this.signatureToApply); + this.message = appendSignature(this.message, this.messageSignature); } this.attachedFiles = []; this.isRecordingAudio = false; @@ -926,19 +814,15 @@ export default { }, toggleAudioRecorder() { this.isRecordingAudio = !this.isRecordingAudio; - this.isRecorderAudioStopped = !this.isRecordingAudio; if (!this.isRecordingAudio) { this.resetAudioRecorderInput(); } }, toggleAudioRecorderPlayPause() { - if (!this.isRecordingAudio) { - return; - } - if (!this.isRecorderAudioStopped) { - this.isRecorderAudioStopped = true; + if (!this.$refs.audioRecorderInput) return; + if (!this.recordingAudioState) { this.$refs.audioRecorderInput.stopRecording(); - } else if (this.isRecorderAudioStopped) { + } else { this.$refs.audioRecorderInput.playPause(); } }, @@ -947,9 +831,6 @@ export default { this.toggleEmojiPicker(); } }, - hideMentions() { - this.showMentions = false; - }, onTypingOn() { this.toggleTyping('on'); }, @@ -1196,13 +1077,6 @@ export default { :message="inReplyTo" @dismiss="resetReplyToMessage" /> - - diff --git a/app/javascript/dashboard/constants/editor.js b/app/javascript/dashboard/constants/editor.js index 9a99516e8..9633687ec 100644 --- a/app/javascript/dashboard/constants/editor.js +++ b/app/javascript/dashboard/constants/editor.js @@ -1,23 +1,143 @@ -export const MESSAGE_EDITOR_MENU_OPTIONS = [ - 'strong', - 'em', - 'link', - 'undo', - 'redo', - 'bulletList', - 'orderedList', - 'code', -]; - -export const MESSAGE_SIGNATURE_EDITOR_MENU_OPTIONS = [ - 'strong', - 'em', - 'link', - 'undo', - 'redo', - 'imageUpload', -]; +// Formatting rules for different contexts (channels and special contexts) +// marks: inline formatting (strong, em, code, link, strike) +// nodes: block structures (bulletList, orderedList, codeBlock, blockquote) +export const FORMATTING = { + // Channel formatting + 'Channel::Email': { + marks: ['strong', 'em', 'code', 'link'], + nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote'], + menu: [ + 'strong', + 'em', + 'code', + 'link', + 'bulletList', + 'orderedList', + 'undo', + 'redo', + ], + }, + 'Channel::WebWidget': { + marks: ['strong', 'em', 'code', 'link', 'strike'], + nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote'], + menu: [ + 'strong', + 'em', + 'code', + 'link', + 'strike', + 'bulletList', + 'orderedList', + 'undo', + 'redo', + ], + }, + 'Channel::Api': { + marks: [], + nodes: [], + menu: [], + }, + 'Channel::FacebookPage': { + marks: ['strong', 'em', 'code', 'strike'], + nodes: ['bulletList', 'orderedList', 'codeBlock'], + menu: [ + 'strong', + 'em', + 'code', + 'strike', + 'bulletList', + 'orderedList', + 'undo', + 'redo', + ], + }, + 'Channel::TwitterProfile': { + marks: [], + nodes: [], + menu: [], + }, + 'Channel::TwilioSms': { + marks: [], + nodes: [], + menu: [], + }, + 'Channel::Sms': { + marks: [], + nodes: [], + menu: [], + }, + 'Channel::Whatsapp': { + marks: ['strong', 'em', 'code', 'strike'], + nodes: ['bulletList', 'orderedList', 'codeBlock'], + menu: [ + 'strong', + 'em', + 'code', + 'strike', + 'bulletList', + 'orderedList', + 'undo', + 'redo', + ], + }, + 'Channel::Line': { + marks: ['strong', 'em', 'code', 'strike'], + nodes: ['codeBlock'], + menu: ['strong', 'em', 'code', 'strike', 'undo', 'redo'], + }, + 'Channel::Telegram': { + marks: ['strong', 'em', 'link', 'code'], + nodes: [], + menu: ['strong', 'em', 'link', 'code', 'undo', 'redo'], + }, + 'Channel::Instagram': { + marks: ['strong', 'em', 'code', 'strike'], + nodes: ['bulletList', 'orderedList'], + menu: [ + 'strong', + 'em', + 'code', + 'bulletList', + 'orderedList', + 'strike', + 'undo', + 'redo', + ], + }, + 'Channel::Voice': { + marks: [], + nodes: [], + menu: [], + }, + // Special contexts (not actual channels) + 'Context::Default': { + marks: ['strong', 'em', 'code', 'link', 'strike'], + nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote'], + menu: [ + 'strong', + 'em', + 'code', + 'link', + 'strike', + 'bulletList', + 'orderedList', + 'undo', + 'redo', + ], + }, + 'Context::MessageSignature': { + marks: ['strong', 'em', 'link'], + nodes: [], + menu: ['strong', 'em', 'link', 'undo', 'redo', 'imageUpload'], + }, + 'Context::InboxSettings': { + marks: ['strong', 'em', 'link'], + nodes: [], + menu: ['strong', 'em', 'link', 'undo', 'redo'], + }, +}; +// Editor menu options for Full Editor export const ARTICLE_EDITOR_MENU_OPTIONS = [ 'strong', 'em', @@ -33,14 +153,7 @@ export const ARTICLE_EDITOR_MENU_OPTIONS = [ 'code', ]; -export const WIDGET_BUILDER_EDITOR_MENU_OPTIONS = [ - 'strong', - 'em', - 'link', - 'undo', - 'redo', -]; - +// Editor image resize options for Message Editor export const MESSAGE_EDITOR_IMAGE_RESIZES = [ { name: 'Small', diff --git a/app/javascript/dashboard/helper/editorHelper.js b/app/javascript/dashboard/helper/editorHelper.js index ae4c4a8cc..3722bbeed 100644 --- a/app/javascript/dashboard/helper/editorHelper.js +++ b/app/javascript/dashboard/helper/editorHelper.js @@ -5,6 +5,7 @@ import { } from '@chatwoot/prosemirror-schema'; import { replaceVariablesInMessage } from '@chatwoot/utils'; import * as Sentry from '@sentry/vue'; +import { FORMATTING } from 'dashboard/constants/editor'; /** * The delimiter used to separate the signature from the rest of the body. @@ -314,7 +315,7 @@ const createNode = (editorView, nodeType, content) => { return mentionNode; } case 'cannedResponse': - return new MessageMarkdownTransformer(messageSchema).parse(content); + return new MessageMarkdownTransformer(state.schema).parse(content); case 'variable': return state.schema.text(`{{${content}}}`); case 'emoji': @@ -389,3 +390,85 @@ export const getContentNode = ( ? creator(editorView, content, from, to, variables) : { node: null, from, to }; }; + +/** + * Get the formatting configuration for a specific channel type. + * Returns the appropriate marks, nodes, and menu items for the editor. + * + * @param {string} channelType - The channel type (e.g., 'Channel::FacebookPage', 'Channel::WebWidget') + * @returns {Object} The formatting configuration with marks, nodes, and menu properties + */ +export function getFormattingForEditor(channelType) { + return FORMATTING[channelType] || FORMATTING['Context::Default']; +} + +/** + * Menu Positioning Helpers + * Handles floating menu bar positioning for text selection in the editor. + */ + +const MENU_CONFIG = { H: 46, W: 300, GAP: 10 }; + +/** + * Calculate selection coordinates with bias to handle line-wraps correctly. + * @param {EditorView} editorView - ProseMirror editor view + * @param {Selection} selection - Current text selection + * @param {DOMRect} rect - Container bounding rect + * @returns {{start: Object, end: Object, selTop: number, onTop: boolean}} + */ +export function getSelectionCoords(editorView, selection, rect) { + const start = editorView.coordsAtPos(selection.from, 1); + const end = editorView.coordsAtPos(selection.to, -1); + + const selTop = Math.min(start.top, end.top); + const spaceAbove = selTop - rect.top; + const onTop = + spaceAbove > MENU_CONFIG.H + MENU_CONFIG.GAP || end.bottom > rect.bottom; + + return { start, end, selTop, onTop }; +} + +/** + * Calculate anchor position based on selection visibility and RTL direction. + * @param {Object} coords - Selection coordinates from getSelectionCoords + * @param {DOMRect} rect - Container bounding rect + * @param {boolean} isRtl - Whether text direction is RTL + * @returns {number} Anchor x-position for menu + */ +export function getMenuAnchor(coords, rect, isRtl) { + const { start, end, onTop } = coords; + + if (!onTop) return end.left; + + // If start of selection is visible, align to text. Else stick to container edge. + if (start.top >= rect.top) return isRtl ? start.right : start.left; + + return isRtl ? rect.right - MENU_CONFIG.GAP : rect.left + MENU_CONFIG.GAP; +} + +/** + * Calculate final menu position (left, top) within container bounds. + * @param {Object} coords - Selection coordinates from getSelectionCoords + * @param {DOMRect} rect - Container bounding rect + * @param {boolean} isRtl - Whether text direction is RTL + * @returns {{left: number, top: number, width: number}} + */ +export function calculateMenuPosition(coords, rect, isRtl) { + const { start, end, selTop, onTop } = coords; + + const anchor = getMenuAnchor(coords, rect, isRtl); + + // Calculate Left: shift by width if RTL, then make relative to container + const rawLeft = (isRtl ? anchor - MENU_CONFIG.W : anchor) - rect.left; + + // Ensure menu stays within container bounds + const left = Math.min(Math.max(0, rawLeft), rect.width - MENU_CONFIG.W); + + // Calculate Top: align to selection or bottom of selection + const top = onTop + ? Math.max(-26, selTop - rect.top - MENU_CONFIG.H - MENU_CONFIG.GAP) + : Math.max(start.bottom, end.bottom) - rect.top + MENU_CONFIG.GAP; + return { left, top, width: MENU_CONFIG.W }; +} + +/* End Menu Positioning Helpers */ diff --git a/app/javascript/dashboard/helper/specs/editorContentHelper.spec.js b/app/javascript/dashboard/helper/specs/editorContentHelper.spec.js index 56b162d50..4efb4d1d9 100644 --- a/app/javascript/dashboard/helper/specs/editorContentHelper.spec.js +++ b/app/javascript/dashboard/helper/specs/editorContentHelper.spec.js @@ -1,15 +1,11 @@ // Moved from editorHelper.spec.js to editorContentHelper.spec.js // the mock of chatwoot/prosemirror-schema is getting conflicted with other specs import { getContentNode } from '../editorHelper'; -import { - MessageMarkdownTransformer, - messageSchema, -} from '@chatwoot/prosemirror-schema'; +import { MessageMarkdownTransformer } from '@chatwoot/prosemirror-schema'; import { replaceVariablesInMessage } from '@chatwoot/utils'; vi.mock('@chatwoot/prosemirror-schema', () => ({ MessageMarkdownTransformer: vi.fn(), - messageSchema: {}, })); vi.mock('@chatwoot/utils', () => ({ @@ -62,12 +58,18 @@ describe('getContentNode', () => { const to = 10; const updatedMessage = 'Hello John'; - replaceVariablesInMessage.mockReturnValue(updatedMessage); - MessageMarkdownTransformer.mockImplementation(() => ({ - parse: vi.fn().mockReturnValue({ textContent: updatedMessage }), - })); + // Mock the node that will be returned by parse + const mockNode = { textContent: updatedMessage }; - const { node } = getContentNode( + replaceVariablesInMessage.mockReturnValue(updatedMessage); + + // Mock MessageMarkdownTransformer instance with parse method + const mockTransformer = { + parse: vi.fn().mockReturnValue(mockNode), + }; + MessageMarkdownTransformer.mockImplementation(() => mockTransformer); + + const result = getContentNode( editorView, 'cannedResponse', content, @@ -79,8 +81,15 @@ describe('getContentNode', () => { message: content, variables, }); - expect(MessageMarkdownTransformer).toHaveBeenCalledWith(messageSchema); - expect(node.textContent).toBe(updatedMessage); + expect(MessageMarkdownTransformer).toHaveBeenCalledWith( + editorView.state.schema + ); + expect(mockTransformer.parse).toHaveBeenCalledWith(updatedMessage); + expect(result.node).toBe(mockNode); + expect(result.node.textContent).toBe(updatedMessage); + // When textContent matches updatedMessage, from should remain unchanged + expect(result.from).toBe(from); + expect(result.to).toBe(to); }); }); diff --git a/app/javascript/dashboard/helper/specs/editorHelper.spec.js b/app/javascript/dashboard/helper/specs/editorHelper.spec.js index 4ed9170c2..664a1a42f 100644 --- a/app/javascript/dashboard/helper/specs/editorHelper.spec.js +++ b/app/javascript/dashboard/helper/specs/editorHelper.spec.js @@ -9,7 +9,12 @@ import { findNodeToInsertImage, setURLWithQueryAndSize, getContentNode, + getFormattingForEditor, + getSelectionCoords, + getMenuAnchor, + calculateMenuPosition, } from '../editorHelper'; +import { FORMATTING } from 'dashboard/constants/editor'; import { EditorState } from '@chatwoot/prosemirror-schema'; import { EditorView } from '@chatwoot/prosemirror-schema'; import { Schema } from 'prosemirror-model'; @@ -258,15 +263,11 @@ describe('insertAtCursor', () => { 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')]), - ]); - + it('should insert text node at cursor position', () => { const editorState = createEditorState(); const editorView = new EditorView(document.body, { state: editorState }); - insertAtCursor(editorView, docNode, 0); + insertAtCursor(editorView, schema.text('Hello'), 0); // Check if node was unwrapped and inserted correctly expect(editorView.state.doc.firstChild.firstChild.text).toBe('Hello'); @@ -626,3 +627,178 @@ describe('getContentNode', () => { }); }); }); + +describe('getFormattingForEditor', () => { + describe('channel-specific formatting', () => { + it('returns full formatting for Email channel', () => { + const result = getFormattingForEditor('Channel::Email'); + + expect(result).toEqual(FORMATTING['Channel::Email']); + }); + + it('returns full formatting for WebWidget channel', () => { + const result = getFormattingForEditor('Channel::WebWidget'); + + expect(result).toEqual(FORMATTING['Channel::WebWidget']); + }); + + it('returns limited formatting for WhatsApp channel', () => { + const result = getFormattingForEditor('Channel::Whatsapp'); + + expect(result).toEqual(FORMATTING['Channel::Whatsapp']); + }); + + it('returns no formatting for API channel', () => { + const result = getFormattingForEditor('Channel::Api'); + + expect(result).toEqual(FORMATTING['Channel::Api']); + }); + + it('returns limited formatting for FacebookPage channel', () => { + const result = getFormattingForEditor('Channel::FacebookPage'); + + expect(result).toEqual(FORMATTING['Channel::FacebookPage']); + }); + + it('returns no formatting for TwitterProfile channel', () => { + const result = getFormattingForEditor('Channel::TwitterProfile'); + + expect(result).toEqual(FORMATTING['Channel::TwitterProfile']); + }); + + it('returns no formatting for SMS channel', () => { + const result = getFormattingForEditor('Channel::Sms'); + + expect(result).toEqual(FORMATTING['Channel::Sms']); + }); + + it('returns limited formatting for Telegram channel', () => { + const result = getFormattingForEditor('Channel::Telegram'); + + expect(result).toEqual(FORMATTING['Channel::Telegram']); + }); + + it('returns formatting for Instagram channel', () => { + const result = getFormattingForEditor('Channel::Instagram'); + + expect(result).toEqual(FORMATTING['Channel::Instagram']); + }); + }); + + describe('context-specific formatting', () => { + it('returns default formatting for Context::Default', () => { + const result = getFormattingForEditor('Context::Default'); + + expect(result).toEqual(FORMATTING['Context::Default']); + }); + + it('returns signature formatting for Context::MessageSignature', () => { + const result = getFormattingForEditor('Context::MessageSignature'); + + expect(result).toEqual(FORMATTING['Context::MessageSignature']); + }); + + it('returns widget builder formatting for Context::InboxSettings', () => { + const result = getFormattingForEditor('Context::InboxSettings'); + + expect(result).toEqual(FORMATTING['Context::InboxSettings']); + }); + }); + + describe('fallback behavior', () => { + it('returns default formatting for unknown channel type', () => { + const result = getFormattingForEditor('Channel::Unknown'); + + expect(result).toEqual(FORMATTING['Context::Default']); + }); + + it('returns default formatting for null channel type', () => { + const result = getFormattingForEditor(null); + + expect(result).toEqual(FORMATTING['Context::Default']); + }); + + it('returns default formatting for undefined channel type', () => { + const result = getFormattingForEditor(undefined); + + expect(result).toEqual(FORMATTING['Context::Default']); + }); + + it('returns default formatting for empty string', () => { + const result = getFormattingForEditor(''); + + expect(result).toEqual(FORMATTING['Context::Default']); + }); + }); + + describe('return value structure', () => { + it('always returns an object with marks, nodes, and menu properties', () => { + const result = getFormattingForEditor('Channel::Email'); + + expect(result).toHaveProperty('marks'); + expect(result).toHaveProperty('nodes'); + expect(result).toHaveProperty('menu'); + expect(Array.isArray(result.marks)).toBe(true); + expect(Array.isArray(result.nodes)).toBe(true); + expect(Array.isArray(result.menu)).toBe(true); + }); + }); +}); + +describe('Menu positioning helpers', () => { + const mockEditorView = { + coordsAtPos: vi.fn((pos, bias) => { + // Return different coords based on position + if (bias === 1) return { top: 100, bottom: 120, left: 50, right: 100 }; + return { top: 100, bottom: 120, left: 150, right: 200 }; + }), + }; + + const wrapperRect = { top: 50, bottom: 300, left: 0, right: 400, width: 400 }; + + describe('getSelectionCoords', () => { + it('returns selection coordinates with onTop flag', () => { + const selection = { from: 0, to: 10 }; + const result = getSelectionCoords(mockEditorView, selection, wrapperRect); + + expect(result).toHaveProperty('start'); + expect(result).toHaveProperty('end'); + expect(result).toHaveProperty('selTop'); + expect(result).toHaveProperty('onTop'); + }); + }); + + describe('getMenuAnchor', () => { + it('returns end.left when menu is below selection', () => { + const coords = { start: { left: 50 }, end: { left: 150 }, onTop: false }; + expect(getMenuAnchor(coords, wrapperRect, false)).toBe(150); + }); + + it('returns start.left for LTR when menu is above and visible', () => { + const coords = { start: { top: 100, left: 50 }, end: {}, onTop: true }; + expect(getMenuAnchor(coords, wrapperRect, false)).toBe(50); + }); + + it('returns start.right for RTL when menu is above and visible', () => { + const coords = { start: { top: 100, right: 100 }, end: {}, onTop: true }; + expect(getMenuAnchor(coords, wrapperRect, true)).toBe(100); + }); + }); + + describe('calculateMenuPosition', () => { + it('returns bounded left and top positions', () => { + const coords = { + start: { top: 100, bottom: 120, left: 50 }, + end: { top: 100, bottom: 120, left: 150 }, + selTop: 100, + onTop: false, + }; + const result = calculateMenuPosition(coords, wrapperRect, false); + + expect(result).toHaveProperty('left'); + expect(result).toHaveProperty('top'); + expect(result).toHaveProperty('width', 300); + expect(result.left).toBeGreaterThanOrEqual(0); + }); + }); +}); diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json index 79d5ebc66..cfb41615b 100644 --- a/app/javascript/dashboard/i18n/locale/en/conversation.json +++ b/app/javascript/dashboard/i18n/locale/en/conversation.json @@ -196,7 +196,6 @@ "INSERT_READ_MORE": "Read more", "DISMISS_REPLY": "Dismiss reply", "REPLYING_TO": "Replying to:", - "TIP_FORMAT_ICON": "Show rich text editor", "TIP_EMOJI_ICON": "Show emoji selector", "TIP_ATTACH_ICON": "Attach files", "TIP_AUDIORECORDER_ICON": "Record audio", diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue index bbd0d9000..6a10d0986 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue @@ -27,7 +27,6 @@ import { FEATURE_FLAGS } from '../../../../featureFlags'; import SenderNameExamplePreview from './components/SenderNameExamplePreview.vue'; import NextButton from 'dashboard/components-next/button/Button.vue'; import { INBOX_TYPES } from 'dashboard/helper/inbox'; -import { WIDGET_BUILDER_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor'; import { getInboxIconByType } from 'dashboard/helper/inbox'; import Editor from 'dashboard/components-next/Editor/Editor.vue'; @@ -81,7 +80,6 @@ export default { selectedTabIndex: 0, selectedPortalSlug: '', showBusinessNameInput: false, - welcomeTaglineEditorMenuOptions: WIDGET_BUILDER_EDITOR_MENU_OPTIONS, healthData: null, isLoadingHealth: false, healthError: null, @@ -626,7 +624,7 @@ export default { ) " :max-length="255" - :enabled-menu-options="welcomeTaglineEditorMenuOptions" + channel-type="Context::InboxSettings" />