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

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