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**
<img width="850" height="345" alt="image"
src="https://github.com/user-attachments/assets/47748e6c-380f-44a3-9e3b-c27e0c830bd0"
/>

**Light**
<img width="850" height="345" alt="image"
src="https://github.com/user-attachments/assets/6746cf32-bf63-4280-a5bd-bbd42c3cbe84"
/>


## 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 <muhsinkeramam@gmail.com>
Co-authored-by: Pranav <pranav@chatwoot.com>
Co-authored-by: Vinay Keerthi <11478411+stonecharioteer@users.noreply.github.com>
This commit is contained in:
Sivin Varghese
2025-12-08 14:43:45 +05:30
committed by GitHub
parent eb759255d8
commit 399c91adaa
33 changed files with 1351 additions and 334 deletions

View File

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

View File

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

View File

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

View File

@@ -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"
/>
<NextButton
v-if="showEditorToggle"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_FORMAT_ICON')"
icon="i-ph-quotes"
slate
faded
sm
@click="$emit('toggleEditor')"
/>
<NextButton
v-if="showAudioPlayStopButton"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_FORMAT_ICON')"
:icon="audioRecorderPlayStopIcon"
slate
faded

View File

@@ -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';
@@ -45,8 +43,6 @@ import fileUploadMixin from 'dashboard/mixins/fileUploadMixin';
import {
appendSignature,
removeSignature,
replaceSignature,
extractTextFromMarkdown,
} from 'dashboard/helper/editorHelper';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
@@ -61,7 +57,6 @@ export default {
ArticleSearchPopover,
AttachmentPreview,
AudioRecorder,
CannedResponse,
ReplyBoxBanner,
EmojiInput,
MessageSignatureMissingAlert,
@@ -69,7 +64,6 @@ export default {
ReplyEmailHead,
ReplyToMessage,
ReplyTopPanel,
ResizableTextArea,
ContentTemplates,
WhatsappTemplates,
WootMessageEditor,
@@ -86,7 +80,6 @@ export default {
setup() {
const {
uiSettings,
updateUISettings,
isEditorHotKeyEnabled,
fetchSignatureFlagFromUISettings,
setQuotedReplyFlagForInbox,
@@ -97,7 +90,6 @@ export default {
return {
uiSettings,
updateUISettings,
isEditorHotKeyEnabled,
fetchSignatureFlagFromUISettings,
setQuotedReplyFlagForInbox,
@@ -115,10 +107,7 @@ export default {
isRecordingAudio: false,
recordingAudioState: '',
recordingAudioDurationText: '',
isUploading: false,
replyType: REPLY_EDITOR_MODES.REPLY,
mentionSearchKey: '',
hasSlashCommand: false,
bccEmails: '',
ccEmails: '',
toEmails: '',
@@ -159,20 +148,6 @@ export default {
!this.is360DialogWhatsAppChannel
);
},
showRichContentEditor() {
if (this.isOnPrivateNote || this.isRichEditorEnabled) {
return true;
}
if (this.isAPIInbox) {
const {
display_rich_content_editor: displayRichContentEditor = false,
} = this.uiSettings;
return displayRichContentEditor;
}
return false;
},
showWhatsappTemplates() {
// We support templates for API channels if someone updates templates manually via API
// That's why we don't explicitly check for channel type here
@@ -300,9 +275,6 @@ export default {
hasAttachments() {
return this.attachedFiles.length;
},
isRichEditorEnabled() {
return this.isAWebWidgetInbox || this.isAnEmailChannel;
},
showAudioRecorder() {
return !this.isOnPrivateNote && this.showFileUpload;
},
@@ -342,21 +314,11 @@ export default {
return !this.isPrivate && this.sendWithSignature;
},
isSignatureAvailable() {
return !!this.signatureToApply;
return !!this.messageSignature;
},
sendWithSignature() {
return this.fetchSignatureFlagFromUISettings(this.channelType);
},
editorMessageKey() {
const { editor_message_key: isEnabled } = this.uiSettings;
return isEnabled;
},
commandPlusEnterToSendEnabled() {
return this.editorMessageKey === 'cmd_enter';
},
enterToSendEnabled() {
return this.editorMessageKey === 'enter';
},
conversationId() {
return this.currentChat.id;
},
@@ -383,12 +345,6 @@ export default {
});
return variables;
},
// ensure that the signature is plain text depending on `showRichContentEditor`
signatureToApply() {
return this.showRichContentEditor
? this.messageSignature
: extractTextFromMarkdown(this.messageSignature);
},
connectedPortalSlug() {
const { help_center: portal = {} } = this.inbox;
const { slug = '' } = portal;
@@ -481,25 +437,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();
},
@@ -512,7 +450,7 @@ export default {
mounted() {
this.getFromDraft();
// Don't use the keyboard listener mixin here as the events here are supposed to be
// working even if input/textarea is focussed.
// working even if the editor is focussed.
document.addEventListener('paste', this.onPaste);
document.addEventListener('keydown', this.handleKeyEvents);
this.setCCAndToEmailsFromLastChat();
@@ -549,45 +487,17 @@ 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);
},
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"
/>
<CannedResponse
v-if="showMentions && hasSlashCommand"
v-on-clickaway="hideMentions"
class="normal-editor__canned-box"
:search-key="mentionSearchKey"
@replace="replaceText"
/>
<EmojiInput
v-if="showEmojiPicker"
v-on-clickaway="hideEmojiPicker"
@@ -1226,33 +1100,17 @@ export default {
@play="recordingAudioState = 'playing'"
@pause="recordingAudioState = 'paused'"
/>
<ResizableTextArea
v-else-if="!showRichContentEditor"
ref="messageInput"
v-model="message"
class="rounded-none input"
:placeholder="messagePlaceHolder"
:min-height="4"
:signature="signatureToApply"
allow-signature
:send-with-signature="sendWithSignature"
@typing-off="onTypingOff"
@typing-on="onTypingOn"
@focus="onFocus"
@blur="onBlur"
/>
<WootMessageEditor
v-else
v-model="message"
:editor-id="editorStateId"
class="input"
class="input popover-prosemirror-menu"
:is-private="isOnPrivateNote"
:placeholder="messagePlaceHolder"
:update-selection-with="updateEditorSelectionWith"
:min-height="4"
enable-variables
:variables="messageVariables"
:signature="signatureToApply"
:signature="messageSignature"
allow-signature
:channel-type="channelType"
@typing-off="onTypingOff"
@@ -1302,7 +1160,6 @@ export default {
:recording-audio-state="recordingAudioState"
:send-button-text="replyButtonLabel"
:show-audio-recorder="showAudioRecorder"
:show-editor-toggle="isAPIInbox && !isOnPrivateNote"
:show-emoji-picker="showEmojiPicker"
:show-file-upload="showFileUpload"
:show-quoted-reply-toggle="shouldShowQuotedReplyToggle"
@@ -1315,7 +1172,6 @@ export default {
:new-conversation-modal-active="newConversationModalActive"
@select-whatsapp-template="openWhatsappTemplateModal"
@select-content-template="openContentTemplateModal"
@toggle-editor="toggleRichContentEditor"
@replace-text="replaceText"
@toggle-insert-article="toggleInsertArticle"
@toggle-quoted-reply="toggleQuotedReply"
@@ -1369,10 +1225,6 @@ export default {
.reply-box__top {
@apply relative py-0 px-4 -mt-px;
textarea {
@apply shadow-none outline-none border-transparent bg-transparent m-0 max-h-60 min-h-[3rem] pt-4 pb-0 px-0 resize-none;
}
}
.emoji-dialog {
@@ -1392,9 +1244,4 @@ export default {
@apply ltr:left-1 rtl:right-1 -bottom-2;
}
}
.normal-editor__canned-box {
width: calc(100% - 2 * 1rem);
left: 1rem;
}
</style>

View File

@@ -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',

View File

@@ -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 */

View File

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

View File

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

View File

@@ -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",

View File

@@ -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"
/>
<label v-if="isAWebWidgetInbox" class="pb-4">

View File

@@ -7,7 +7,6 @@ import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage';
import { WIDGET_BUILDER_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Avatar from 'next/avatar/Avatar.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
@@ -76,7 +75,6 @@ export default {
checked: false,
},
],
welcomeTaglineEditorMenuOptions: WIDGET_BUILDER_EDITOR_MENU_OPTIONS,
};
},
computed: {
@@ -337,7 +335,7 @@ export default {
)
"
:max-length="255"
:enabled-menu-options="welcomeTaglineEditorMenuOptions"
channel-type="Context::InboxSettings"
class="mb-4"
/>
<label>

View File

@@ -5,7 +5,6 @@ import router from '../../../../index';
import NextButton from 'dashboard/components-next/button/Button.vue';
import PageHeader from '../../SettingsSubPageHeader.vue';
import GreetingsEditor from 'shared/components/GreetingsEditor.vue';
import { WIDGET_BUILDER_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
export default {
@@ -24,7 +23,6 @@ export default {
channelWelcomeTagline: '',
greetingEnabled: false,
greetingMessage: '',
welcomeTaglineEditorMenuOptions: WIDGET_BUILDER_EDITOR_MENU_OPTIONS,
};
},
computed: {
@@ -147,7 +145,7 @@ export default {
)
"
:max-length="255"
:enabled-menu-options="welcomeTaglineEditorMenuOptions"
channel-type="Context::InboxSettings"
class="mb-4"
/>

View File

@@ -1,7 +1,6 @@
<script setup>
import { ref, watch } from 'vue';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
import { MESSAGE_SIGNATURE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
@@ -12,7 +11,6 @@ const props = defineProps({
});
const emit = defineEmits(['updateSignature']);
const customEditorMenuList = MESSAGE_SIGNATURE_EDITOR_MENU_OPTIONS;
const signature = ref(props.messageSignature);
watch(
() => props.messageSignature ?? '',
@@ -34,7 +32,7 @@ const updateSignature = () => {
class="message-editor h-[10rem] !px-3"
is-format-mode
:placeholder="$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE.PLACEHOLDER')"
:enabled-menu-options="customEditorMenuList"
channel-type="Context::MessageSignature"
:enable-suggestions="false"
show-image-resize-toolbar
/>