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:
@@ -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 */
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user