Files
chatwoot/app/javascript/dashboard/helper/specs/editorHelper.spec.js
T
1beaa284c6 feat: inline images in website and email channels (#14516)
# Pull Request Template

## Description

This PR adds support for inline image uploads in the reply editor for
Email and Website (chat widget) channels.

Agents can now insert images inline between text and resize them
directly in the editor by dragging the bottom corner, similar to the
help center editor experience.

Image sizes are preserved through markdown using the `cw_image_width`
URL param and render correctly in both outgoing emails and chat widget
messages.

Agents can also paste copied images directly into Email or Website
replies using **Shift+Cmd+V** (Shift+Ctrl+V on Windows/Linux). The image
gets inserted inline at the cursor position and supports resizing just
like uploaded images. Regular **Cmd+V / Ctrl+V** behavior remains
unchanged and continues to add images as attachments, so both inline and
attachment flows are supported.


### Prosemirror repo PR:
https://github.com/chatwoot/prosemirror-schema/pull/48

Fixes
https://linear.app/chatwoot/issue/CW-7133/inline-images-in-live-chat-and-email

https://linear.app/chatwoot/issue/CW-7225/ghsa-8j9w-jppp-xcfc-html-attribute-injection-via-unvalidated-cw-image

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

### Screencast



https://github.com/user-attachments/assets/a928f852-ab15-413a-9d35-6ea69b718ecf

<img width="414" height="654" alt="image"
src="https://github.com/user-attachments/assets/205e0729-8f2d-4cc5-9c55-7696f032eca4"
/>



## 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
- [ ] 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>
2026-06-03 15:05:17 +05:30

1162 lines
37 KiB
JavaScript

import { EditorState, EditorView } from '@chatwoot/prosemirror-schema';
import { FORMATTING } from 'dashboard/constants/editor';
import { Schema } from 'prosemirror-model';
import {
appendSignature,
calculateMenuPosition,
cleanSignature,
collapseSelection,
extractTextFromMarkdown,
findNodeToInsertImage,
findSignatureInBody,
getContentNode,
getFormattingForEditor,
getMenuAnchor,
getSelectionCoords,
insertAtCursor,
removeSignature,
replaceSignature,
stripInlineBase64Images,
stripUnsupportedFormatting,
stripUnsupportedMarkdown,
} from '../editorHelper';
// Define a basic ProseMirror schema
const schema = new Schema({
nodes: {
doc: { content: 'paragraph+' },
paragraph: {
content: 'inline*',
group: 'block',
toDOM: () => ['p', 0], // Represents a paragraph as a <p> tag in the DOM.
},
text: {
group: 'inline',
toDOM: node => node.text, // Represents text as its actual string value.
},
mention: {
attrs: {
userId: { default: '' },
userFullName: { default: '' },
mentionType: { default: 'user' },
},
inline: true,
group: 'inline',
toDOM: node => [
'span',
{ class: 'mention' },
`@${node.attrs.userFullName}`,
],
},
},
});
// Initialize a basic EditorState for testing
const createEditorState = (content = '') => {
if (!content) {
return EditorState.create({
schema,
doc: schema.node('doc', null, [schema.node('paragraph')]),
});
}
return EditorState.create({
schema,
doc: schema.node('doc', null, [
schema.node('paragraph', null, [schema.text(content)]),
]),
});
};
const NEW_SIGNATURE = 'This is a new signature';
const DOES_NOT_HAVE_SIGNATURE = {
'no signature': {
body: 'This is a test',
signature: 'This is a signature',
},
'text after signature': {
body: 'This is a test\n\n--\n\nThis is a signature\n\nThis is more text',
signature: 'This is a signature',
},
'signature has images': {
body: 'This is a test',
signature:
'Testing\n![](http://localhost:3000/rails/active_storage/blobs/redirect/some-hash/image.png)',
},
'signature has non commonmark syntax': {
body: 'This is a test',
signature: '- Signature',
},
'signature has trailing spaces': {
body: 'This is a test',
signature: '**hello** \n**world**',
},
};
const HAS_SIGNATURE = {
'signature at end': {
body: 'This is a test\n\n--\n\nThis is a signature',
signature: 'This is a signature',
},
'signature at end with spaces and new lines': {
body: 'This is a test\n\n--\n\nThis is a signature \n\n',
signature: 'This is a signature ',
},
'no text before signature': {
body: '\n\n--\n\nThis is a signature',
signature: 'This is a signature',
},
'signature has non-commonmark syntax': {
body: '\n\n--\n\n* Signature',
signature: '- Signature',
},
};
describe('findSignatureInBody', () => {
it('returns -1 if there is no signature', () => {
Object.keys(DOES_NOT_HAVE_SIGNATURE).forEach(key => {
const { body, signature } = DOES_NOT_HAVE_SIGNATURE[key];
expect(findSignatureInBody(body, signature)).toBe(-1);
});
});
it('returns the index of the signature if there is one', () => {
Object.keys(HAS_SIGNATURE).forEach(key => {
const { body, signature } = HAS_SIGNATURE[key];
expect(findSignatureInBody(body, signature)).toBeGreaterThan(0);
});
});
});
describe('appendSignature', () => {
it('appends the signature if it is not present', () => {
Object.keys(DOES_NOT_HAVE_SIGNATURE).forEach(key => {
const { body, signature } = DOES_NOT_HAVE_SIGNATURE[key];
const cleanedSignature = cleanSignature(signature);
expect(
appendSignature(body, signature).includes(cleanedSignature)
).toBeTruthy();
});
});
it('does not append signature if already present', () => {
Object.keys(HAS_SIGNATURE).forEach(key => {
const { body, signature } = HAS_SIGNATURE[key];
expect(appendSignature(body, signature)).toBe(body);
});
});
});
describe('stripUnsupportedMarkdown', () => {
const richSignature =
'**Bold** _italic_ [link](http://example.com) ![](http://localhost:3000/image.png)';
it('keeps all formatting for Email channel (supports image, link, strong, em)', () => {
const result = stripUnsupportedMarkdown(richSignature, 'Channel::Email');
expect(result).toContain('**Bold**');
expect(result).toContain('_italic_');
expect(result).toContain('[link](http://example.com)');
expect(result).toContain('![](http://localhost:3000/image.png)');
});
it('strips images but keeps bold/italic for Api channel', () => {
const result = stripUnsupportedMarkdown(richSignature, 'Channel::Api');
expect(result).toContain('**Bold**');
expect(result).toContain('_italic_');
expect(result).toContain('link'); // link text kept
expect(result).not.toContain('[link]('); // link syntax removed
expect(result).not.toContain('![]('); // image removed
});
it('strips images but keeps bold/italic/link for Telegram channel', () => {
const result = stripUnsupportedMarkdown(richSignature, 'Channel::Telegram');
expect(result).toContain('**Bold**');
expect(result).toContain('_italic_');
expect(result).toContain('[link](http://example.com)');
expect(result).not.toContain('![](');
});
it('strips all formatting for SMS channel', () => {
const result = stripUnsupportedMarkdown(richSignature, 'Channel::Sms');
expect(result).toContain('Bold');
expect(result).toContain('italic');
expect(result).toContain('link');
expect(result).not.toContain('**');
expect(result).not.toContain('_');
expect(result).not.toContain('[');
expect(result).not.toContain('![](');
});
it('returns empty string for empty input', () => {
expect(stripUnsupportedMarkdown('', 'Channel::Api')).toBe('');
expect(stripUnsupportedMarkdown(null, 'Channel::Api')).toBe('');
});
describe('with cleanWhitespace parameter', () => {
const textWithWhitespace =
'**Bold** text\n\nWith multiple\n\nLine breaks\n\n And spaces ';
it('cleans whitespace when cleanWhitespace=true (default)', () => {
const result = stripUnsupportedMarkdown(
textWithWhitespace,
'Channel::Api',
true
);
expect(result).toBe(
'**Bold** text\nWith multiple\nLine breaks\nAnd spaces'
);
expect(result).not.toContain('\n\n');
expect(result).not.toContain(' ');
});
it('preserves whitespace when cleanWhitespace=false', () => {
const result = stripUnsupportedMarkdown(
textWithWhitespace,
'Channel::Api',
false
);
expect(result).toContain('\n\n');
expect(result).toContain(' And spaces ');
expect(result).toBe(
'**Bold** text\n\nWith multiple\n\nLine breaks\n\n And spaces '
);
});
it('strips formatting but preserves whitespace for messages', () => {
const messageWithFormatting = '**Bold**\n\n`code`\n\nNormal text';
const result = stripUnsupportedMarkdown(
messageWithFormatting,
'Channel::Sms',
false
);
expect(result).toBe('Bold\n\ncode\n\nNormal text');
expect(result).toContain('\n\n');
expect(result).not.toContain('**');
expect(result).not.toContain('`');
});
});
});
describe('appendSignature with channelType', () => {
const signatureWithImage =
'Thanks\n![](http://localhost:3000/image.png?cw_image_height=24px)';
it('keeps images for Email channel', () => {
const result = appendSignature(
'Hello',
signatureWithImage,
'Channel::Email'
);
expect(result).toContain('![](http://localhost:3000/image.png');
});
it('keeps images for WebWidget channel', () => {
const result = appendSignature(
'Hello',
signatureWithImage,
'Channel::WebWidget'
);
expect(result).toContain('![](http://localhost:3000/image.png');
});
it('strips images but keeps text for Api channel', () => {
const result = appendSignature('Hello', signatureWithImage, 'Channel::Api');
expect(result).not.toContain('![](');
expect(result).toContain('Thanks');
});
it('strips images but keeps text for WhatsApp channel', () => {
const result = appendSignature(
'Hello',
signatureWithImage,
'Channel::Whatsapp'
);
expect(result).not.toContain('![](');
expect(result).toContain('Thanks');
});
it('keeps images when channelType is not provided', () => {
const result = appendSignature('Hello', signatureWithImage);
expect(result).toContain('![](http://localhost:3000/image.png');
});
it('keeps bold/italic for channels that support them', () => {
const boldSignature = '**Bold** *italic* Thanks';
const result = appendSignature('Hello', boldSignature, 'Channel::Api');
// Api supports strong and em
expect(result).toContain('**Bold**');
expect(result).toContain('*italic*');
});
});
describe('cleanSignature', () => {
it('removes any instance of horizontal rule', () => {
const options = [
'---',
'***',
'___',
'- - -',
'* * *',
'_ _ _',
' ---',
'--- ',
' --- ',
'-----',
'*****',
'_____',
'- - - -',
'* * * * *',
'_ _ _ _ _ _',
' - - - - ',
' * * * * * ',
' _ _ _ _ _ _',
'- - - - -',
'* * * * * *',
'_ _ _ _ _ _ _',
];
options.forEach(option => {
expect(cleanSignature(option)).toBe('');
});
});
});
describe('removeSignature', () => {
it('does not remove signature if not present', () => {
Object.keys(DOES_NOT_HAVE_SIGNATURE).forEach(key => {
const { body, signature } = DOES_NOT_HAVE_SIGNATURE[key];
expect(removeSignature(body, signature)).toBe(body);
});
});
it('removes signature if present at the end', () => {
const { body, signature } = HAS_SIGNATURE['signature at end'];
expect(removeSignature(body, signature)).toBe('This is a test\n\n');
});
it('removes signature if present with spaces and new lines', () => {
const { body, signature } =
HAS_SIGNATURE['signature at end with spaces and new lines'];
expect(removeSignature(body, signature)).toBe('This is a test\n\n');
});
it('removes signature if present without text before it', () => {
const { body, signature } = HAS_SIGNATURE['no text before signature'];
expect(removeSignature(body, signature)).toBe('\n\n');
});
it('removes just the delimiter if no signature is present', () => {
expect(removeSignature('This is a test\n\n--', 'This is a signature')).toBe(
'This is a test\n\n'
);
});
it('strips blank-paragraph marker before the delimiter', () => {
expect(removeSignature('hey\n\n\\\n--\n\nHello there', 'Hello there')).toBe(
'hey'
);
});
it('strips multiple consecutive blank-paragraph markers before the delimiter', () => {
expect(
removeSignature('wewe\n\n\\\n\\\n\\\n--\n\nHello there', 'Hello there')
).toBe('wewe');
});
it('strips dangling hardbreak when signature shared a paragraph with "--"', () => {
expect(removeSignature('hey\n\n--\\\nHello there', 'Hello there')).toBe(
'hey\n\n'
);
});
it('preserves trailing backslash in user text when appending', () => {
expect(appendSignature('The path is C:\\', 'Best\nAgent')).toContain(
'C:\\'
);
expect(appendSignature('C:\\\n', 'Best\nAgent')).toContain('C:\\');
expect(appendSignature('C:\\\n\n', 'Best\nAgent')).toContain('C:\\');
});
it('preserves trailing backslash in user text when removing', () => {
expect(removeSignature('C:\\\n--\n\nBest\nAgent', 'Best\nAgent')).toContain(
'C:\\'
);
expect(removeSignature('C:\\\n--', 'no matching sig')).toContain('C:\\');
expect(removeSignature('C:\\\nBest\\\nAgent', 'Best\nAgent')).toContain(
'C:\\'
);
expect(removeSignature('notes\n\\\n--', 'no matching sig')).toContain('\\');
});
});
describe('removeSignature with stripped signature', () => {
const signatureWithImage =
'Thanks\n![](http://localhost:3000/image.png?cw_image_height=24px)';
it('removes stripped signature from body', () => {
// Simulate a body where signature was added with images stripped
const bodyWithStrippedSignature = 'Hello\n\n--\n\nThanks';
const result = removeSignature(
bodyWithStrippedSignature,
signatureWithImage
);
expect(result).toBe('Hello\n\n');
});
it('removes original signature from body', () => {
// Simulate a body where signature was added with images (using cleanSignature format)
const cleanedSig = cleanSignature(signatureWithImage);
const bodyWithOriginalSignature = `Hello\n\n--\n\n${cleanedSig}`;
const result = removeSignature(
bodyWithOriginalSignature,
signatureWithImage
);
expect(result).toBe('Hello\n\n');
});
it('handles signature without images', () => {
const simpleSignature = 'Best regards';
const body = 'Hello\n\n--\n\nBest regards';
const result = removeSignature(body, simpleSignature);
expect(result).toBe('Hello\n\n');
});
});
describe('replaceSignature', () => {
it('appends the new signature if not present', () => {
Object.keys(DOES_NOT_HAVE_SIGNATURE).forEach(key => {
const { body, signature } = DOES_NOT_HAVE_SIGNATURE[key];
expect(replaceSignature(body, signature, NEW_SIGNATURE)).toBe(
`${body}\n\n--\n\n${NEW_SIGNATURE}`
);
});
});
it('removes signature if present at the end', () => {
const { body, signature } = HAS_SIGNATURE['signature at end'];
expect(replaceSignature(body, signature, NEW_SIGNATURE)).toBe(
`This is a test\n\n--\n\n${NEW_SIGNATURE}`
);
});
it('removes signature if present with spaces and new lines', () => {
const { body, signature } =
HAS_SIGNATURE['signature at end with spaces and new lines'];
expect(replaceSignature(body, signature, NEW_SIGNATURE)).toBe(
`This is a test\n\n--\n\n${NEW_SIGNATURE}`
);
});
it('removes signature if present without text before it', () => {
const { body, signature } = HAS_SIGNATURE['no text before signature'];
expect(replaceSignature(body, signature, NEW_SIGNATURE)).toBe(
`\n\n--\n\n${NEW_SIGNATURE}`
);
});
});
describe('extractTextFromMarkdown', () => {
it('should extract text from markdown and remove all images, code blocks, links, headers, bold, italic, lists etc.', () => {
const markdown = `
# Hello World
This is a **bold** text with a [link](https://example.com).
\`\`\`javascript
const foo = 'bar';
console.log(foo);
\`\`\`
Here's an image: ![alt text](https://example.com/image.png)
- List item 1
- List item 2
*Italic text*
`;
const expected =
"Hello World\nThis is a bold text with a link.\nHere's an image:\nList item 1\nList item 2\nItalic text";
expect(extractTextFromMarkdown(markdown)).toEqual(expected);
});
});
describe('stripInlineBase64Images', () => {
it('removes markdown data:image base64 images and sets hasInlineImages', () => {
const content =
'Hello\n![x](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAE)\nWorld';
const { sanitizedContent, hasInlineImages } =
stripInlineBase64Images(content);
expect(hasInlineImages).toBe(true);
expect(sanitizedContent).not.toContain('data:image/png;base64');
expect(sanitizedContent).toContain('Hello');
expect(sanitizedContent).toContain('World');
});
it('leaves hosted image markdown unchanged', () => {
const content = '![](https://example.com/logo.png)';
const { sanitizedContent, hasInlineImages } =
stripInlineBase64Images(content);
expect(hasInlineImages).toBe(false);
expect(sanitizedContent).toBe(content);
});
it('returns empty hasInlineImages for empty input', () => {
expect(stripInlineBase64Images('')).toEqual({
sanitizedContent: '',
hasInlineImages: false,
});
});
});
describe('collapseSelection', () => {
it('collapses a text range to a cursor at its head', () => {
const editorView = new EditorView(document.body, {
state: createEditorState('Hello world'),
});
// Build a TextSelection via the initial selection's constructor (avoids
// importing prosemirror-state, which isn't a direct dep).
const { doc, selection } = editorView.state;
editorView.dispatch(
editorView.state.tr.setSelection(selection.constructor.create(doc, 1, 6))
);
expect(editorView.state.selection.empty).toBe(false);
collapseSelection(editorView);
expect(editorView.state.selection.empty).toBe(true);
expect(editorView.state.selection.head).toBe(6);
});
it('leaves an already-collapsed selection as a cursor', () => {
const editorView = new EditorView(document.body, {
state: createEditorState('Hi'),
});
collapseSelection(editorView);
expect(editorView.state.selection.empty).toBe(true);
});
});
describe('insertAtCursor', () => {
it('should return undefined if editorView is not provided', () => {
const result = insertAtCursor(undefined, schema.text('Hello'), 0);
expect(result).toBeUndefined();
});
it('should insert text node at cursor position', () => {
const editorState = createEditorState();
const editorView = new EditorView(document.body, { state: editorState });
insertAtCursor(editorView, schema.text('Hello'), 0);
// Check if node was unwrapped and inserted correctly
expect(editorView.state.doc.firstChild.firstChild.text).toBe('Hello');
});
it('should insert node without replacing any content if "to" is not provided', () => {
const editorState = createEditorState();
const editorView = new EditorView(document.body, { state: editorState });
insertAtCursor(editorView, schema.text('Hello'), 0);
// Check if node was inserted correctly
expect(editorView.state.doc.firstChild.firstChild.text).toBe('Hello');
});
it('should replace content between "from" and "to" with the provided node', () => {
const editorState = createEditorState('ReplaceMe');
const editorView = new EditorView(document.body, { state: editorState });
insertAtCursor(editorView, schema.text('Hello'), 0, 8);
// Check if content was replaced correctly
expect(editorView.state.doc.firstChild.firstChild.text).toBe('Hello Me');
});
});
describe('findNodeToInsertImage', () => {
let mockEditorState;
beforeEach(() => {
mockEditorState = {
selection: {
$from: {
node: vi.fn(() => ({})),
},
from: 0,
},
schema: {
nodes: {
image: {
create: vi.fn(attrs => ({ type: { name: 'image' }, attrs })),
},
paragraph: {
create: vi.fn((_, node) => ({
type: { name: 'paragraph' },
content: [node],
})),
},
},
},
};
});
it('should insert image directly into an empty paragraph', () => {
const mockNode = {
type: { name: 'paragraph' },
content: { size: 0, content: [] },
};
mockEditorState.selection.$from.node.mockReturnValueOnce(mockNode);
const result = findNodeToInsertImage(mockEditorState, 'image-url');
expect(result).toEqual({
node: { type: { name: 'image' }, attrs: { src: 'image-url' } },
pos: 0,
});
});
it('should insert image directly into a paragraph without an image but with other content', () => {
const mockNode = {
type: { name: 'paragraph' },
content: {
size: 1,
content: [
{
type: { name: 'text' },
},
],
},
};
mockEditorState.selection.$from.node.mockReturnValueOnce(mockNode);
mockEditorState.selection.from = 1;
const result = findNodeToInsertImage(mockEditorState, 'image-url');
expect(result).toEqual({
node: { type: { name: 'image' }, attrs: { src: 'image-url' } },
pos: 2, // Because it should insert after the text, on a new line.
});
});
it("should wrap image in a new paragraph when the current node isn't a paragraph", () => {
const mockNode = {
type: { name: 'not-a-paragraph' },
content: { size: 0, content: [] },
};
mockEditorState.selection.$from.node.mockReturnValueOnce(mockNode);
const result = findNodeToInsertImage(mockEditorState, 'image-url');
expect(result.node.type.name).toBe('paragraph');
expect(result.node.content[0].type.name).toBe('image');
expect(result.node.content[0].attrs.src).toBe('image-url');
expect(result.pos).toBe(0);
});
it('should insert a new image directly into the paragraph that already contains an image', () => {
const mockNode = {
type: { name: 'paragraph' },
content: {
size: 1,
content: [
{
type: { name: 'image', attrs: { src: 'existing-image-url' } },
},
],
},
};
mockEditorState.selection.$from.node.mockReturnValueOnce(mockNode);
mockEditorState.selection.from = 1;
const result = findNodeToInsertImage(mockEditorState, 'image-url');
expect(result.node.type.name).toBe('image');
expect(result.node.attrs.src).toBe('image-url');
expect(result.pos).toBe(1);
});
});
describe('getContentNode', () => {
let mockEditorView;
beforeEach(() => {
mockEditorView = {
state: {
schema: {
nodes: {
mention: {
create: vi.fn(attrs => ({
type: { name: 'mention' },
attrs,
})),
},
},
text: vi.fn(content => ({ type: { name: 'text' }, text: content })),
},
},
};
});
describe('mention node creation', () => {
it('creates a user mention node with correct attributes', () => {
const userContent = {
id: '123',
name: 'John Doe',
type: 'user',
};
const result = getContentNode(mockEditorView, 'mention', userContent, {
from: 0,
to: 5,
});
expect(
mockEditorView.state.schema.nodes.mention.create
).toHaveBeenCalledWith({
userId: '123',
userFullName: 'John Doe',
mentionType: 'user',
});
expect(result).toEqual({
node: {
type: { name: 'mention' },
attrs: {
userId: '123',
userFullName: 'John Doe',
mentionType: 'user',
},
},
from: 0,
to: 5,
});
});
it('creates a team mention node with correct attributes', () => {
const teamContent = {
id: '456',
name: 'Support Team',
type: 'team',
};
const result = getContentNode(mockEditorView, 'mention', teamContent, {
from: 0,
to: 5,
});
expect(
mockEditorView.state.schema.nodes.mention.create
).toHaveBeenCalledWith({
userId: '456',
userFullName: 'Support Team',
mentionType: 'team',
});
expect(result).toEqual({
node: {
type: { name: 'mention' },
attrs: {
userId: '456',
userFullName: 'Support Team',
mentionType: 'team',
},
},
from: 0,
to: 5,
});
});
it('defaults to user mention type when type is not specified', () => {
const contentWithoutType = {
id: '789',
name: 'Jane Smith',
};
getContentNode(mockEditorView, 'mention', contentWithoutType, {
from: 0,
to: 5,
});
expect(
mockEditorView.state.schema.nodes.mention.create
).toHaveBeenCalledWith({
userId: '789',
userFullName: 'Jane Smith',
mentionType: 'user',
});
});
it('uses displayName over name when both are provided', () => {
const contentWithDisplayName = {
id: '101',
name: 'john_doe',
displayName: 'John Doe (Admin)',
type: 'user',
};
getContentNode(mockEditorView, 'mention', contentWithDisplayName, {
from: 0,
to: 5,
});
expect(
mockEditorView.state.schema.nodes.mention.create
).toHaveBeenCalledWith({
userId: '101',
userFullName: 'John Doe (Admin)',
mentionType: 'user',
});
});
it('handles missing displayName by falling back to name', () => {
const contentWithoutDisplayName = {
id: '102',
name: 'jane_smith',
type: 'user',
};
getContentNode(mockEditorView, 'mention', contentWithoutDisplayName, {
from: 0,
to: 5,
});
expect(
mockEditorView.state.schema.nodes.mention.create
).toHaveBeenCalledWith({
userId: '102',
userFullName: 'jane_smith',
mentionType: 'user',
});
});
});
describe('unsupported node types', () => {
it('returns null node for unsupported type', () => {
const result = getContentNode(mockEditorView, 'unsupported', 'content', {
from: 0,
to: 5,
});
expect(result).toEqual({
node: null,
from: 0,
to: 5,
});
});
});
});
describe('getFormattingForEditor', () => {
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('stripUnsupportedFormatting', () => {
describe('when schema supports all formatting', () => {
const fullSchema = {
marks: { strong: {}, em: {}, code: {}, strike: {}, link: {} },
nodes: { bulletList: {}, orderedList: {}, codeBlock: {}, blockquote: {} },
};
it('preserves all formatting when schema supports it', () => {
const content = '**bold** and *italic* and `code`';
expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content);
});
it('preserves links when schema supports them', () => {
const content = 'Check [this link](https://example.com)';
expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content);
});
it('preserves autolinks when schema supports links', () => {
const content = 'Check out <https://cegrafic.com/catalogo/>';
expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content);
});
it('preserves various URI scheme autolinks', () => {
const content =
'Email <mailto:user@example.com> or call <tel:+1234567890>';
expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content);
});
it('preserves email autolinks', () => {
const content = 'Contact us at <support@chatwoot.com>';
expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content);
});
it('preserves lists when schema supports them', () => {
const content = '- item 1\n- item 2\n1. first\n2. second';
expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content);
});
});
describe('when schema has no formatting support (eg:SMS channel)', () => {
const emptySchema = {
marks: {},
nodes: {},
};
it('strips bold formatting', () => {
expect(stripUnsupportedFormatting('**bold text**', emptySchema)).toBe(
'bold text'
);
expect(stripUnsupportedFormatting('__bold text__', emptySchema)).toBe(
'bold text'
);
});
it('strips italic formatting', () => {
expect(stripUnsupportedFormatting('*italic text*', emptySchema)).toBe(
'italic text'
);
expect(stripUnsupportedFormatting('_italic text_', emptySchema)).toBe(
'italic text'
);
});
it('preserves underscores in URLs and mid-word positions', () => {
// Underscores in URLs should not be stripped as italic formatting
expect(
stripUnsupportedFormatting(
'https://www.chatwoot.com/new_first_second-third/ssd',
emptySchema
)
).toBe('https://www.chatwoot.com/new_first_second-third/ssd');
// Underscores in variable names should not be stripped
expect(
stripUnsupportedFormatting('some_variable_name', emptySchema)
).toBe('some_variable_name');
// But actual italic formatting with spaces should still be stripped
expect(
stripUnsupportedFormatting('hello _world_ there', emptySchema)
).toBe('hello world there');
});
it('strips inline code formatting', () => {
expect(stripUnsupportedFormatting('`inline code`', emptySchema)).toBe(
'inline code'
);
});
it('strips strikethrough formatting', () => {
expect(stripUnsupportedFormatting('~~strikethrough~~', emptySchema)).toBe(
'strikethrough'
);
});
it('strips links but keeps text', () => {
expect(
stripUnsupportedFormatting(
'Check [this link](https://example.com)',
emptySchema
)
).toBe('Check this link');
});
it('converts autolinks to plain URLs when schema does not support links', () => {
const content = 'Visit <https://cegrafic.com/catalogo/> for more info';
const expected = 'Visit https://cegrafic.com/catalogo/ for more info';
expect(stripUnsupportedFormatting(content, emptySchema)).toBe(expected);
});
it('handles multiple autolinks in content', () => {
const content = 'Check <https://example.com> and <https://test.com>';
const expected = 'Check https://example.com and https://test.com';
expect(stripUnsupportedFormatting(content, emptySchema)).toBe(expected);
});
it('converts URI scheme autolinks to plain text', () => {
const content =
'Email <mailto:support@example.com> or call <tel:+1234567890>';
const expected =
'Email mailto:support@example.com or call tel:+1234567890';
expect(stripUnsupportedFormatting(content, emptySchema)).toBe(expected);
});
it('converts email autolinks to plain text', () => {
const content = 'Reach us at <admin@chatwoot.com> for help';
const expected = 'Reach us at admin@chatwoot.com for help';
expect(stripUnsupportedFormatting(content, emptySchema)).toBe(expected);
});
it('handles mixed autolink types', () => {
const content = 'Visit <https://example.com> or email <info@example.com>';
const expected = 'Visit https://example.com or email info@example.com';
expect(stripUnsupportedFormatting(content, emptySchema)).toBe(expected);
});
it('strips bullet list markers', () => {
expect(
stripUnsupportedFormatting('- item 1\n- item 2', emptySchema)
).toBe('item 1\nitem 2');
expect(
stripUnsupportedFormatting('* item 1\n* item 2', emptySchema)
).toBe('item 1\nitem 2');
});
it('strips ordered list markers', () => {
expect(
stripUnsupportedFormatting('1. first\n2. second', emptySchema)
).toBe('first\nsecond');
});
it('strips code block markers', () => {
expect(
stripUnsupportedFormatting('```javascript\ncode here\n```', emptySchema)
).toBe('code here\n');
});
it('strips blockquote markers', () => {
expect(stripUnsupportedFormatting('> quoted text', emptySchema)).toBe(
'quoted text'
);
});
it('handles complex content with multiple formatting types', () => {
const content =
'**Bold** and *italic* with `code` and [link](url)\n- list item';
const expected = 'Bold and italic with code and link\nlist item';
expect(stripUnsupportedFormatting(content, emptySchema)).toBe(expected);
});
});
describe('when schema has partial support', () => {
const partialSchema = {
marks: { strong: {}, em: {} },
nodes: {},
};
it('preserves supported marks and strips unsupported ones', () => {
const content = '**bold** and `code`';
expect(stripUnsupportedFormatting(content, partialSchema)).toBe(
'**bold** and code'
);
});
it('strips unsupported nodes but keeps supported marks', () => {
const content = '**bold** text\n- list item';
expect(stripUnsupportedFormatting(content, partialSchema)).toBe(
'**bold** text\nlist item'
);
});
});
describe('edge cases', () => {
it('returns content unchanged if content is empty', () => {
expect(stripUnsupportedFormatting('', {})).toBe('');
});
it('returns content unchanged if content is null', () => {
expect(stripUnsupportedFormatting(null, {})).toBe(null);
});
it('returns content unchanged if content is undefined', () => {
expect(stripUnsupportedFormatting(undefined, {})).toBe(undefined);
});
it('returns content unchanged if schema is null', () => {
expect(stripUnsupportedFormatting('**bold**', null)).toBe('**bold**');
});
it('handles nested formatting correctly', () => {
const emptySchema = { marks: {}, nodes: {} };
// After stripping bold (**), the remaining *and italic* becomes italic and is stripped too
expect(
stripUnsupportedFormatting('**bold *and italic***', emptySchema)
).toBe('bold and italic');
});
});
});
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);
});
});
});