mirror of
https://github.com/chatwoot/chatwoot.git
synced 2026-06-04 21:02:35 +08:00
# 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 <[email protected]>
268 lines
6.5 KiB
JavaScript
268 lines
6.5 KiB
JavaScript
// 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', 'image'],
|
|
menu: [
|
|
'copilot',
|
|
'strong',
|
|
'em',
|
|
'code',
|
|
'link',
|
|
'bulletList',
|
|
'orderedList',
|
|
'imageUpload',
|
|
'undo',
|
|
'redo',
|
|
],
|
|
},
|
|
'Channel::WebWidget': {
|
|
marks: ['strong', 'em', 'code', 'link', 'strike'],
|
|
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote', 'image'],
|
|
menu: [
|
|
'copilot',
|
|
'strong',
|
|
'em',
|
|
'code',
|
|
'link',
|
|
'strike',
|
|
'bulletList',
|
|
'orderedList',
|
|
'imageUpload',
|
|
'undo',
|
|
'redo',
|
|
],
|
|
},
|
|
'Channel::Api': {
|
|
marks: ['strong', 'em'],
|
|
nodes: [],
|
|
menu: ['copilot', 'strong', 'em', 'undo', 'redo'],
|
|
},
|
|
'Channel::FacebookPage': {
|
|
marks: ['strong', 'em', 'code', 'strike'],
|
|
nodes: ['bulletList', 'orderedList', 'codeBlock'],
|
|
menu: [
|
|
'copilot',
|
|
'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: [
|
|
'copilot',
|
|
'strong',
|
|
'em',
|
|
'code',
|
|
'strike',
|
|
'bulletList',
|
|
'orderedList',
|
|
'undo',
|
|
'redo',
|
|
],
|
|
},
|
|
'Channel::Line': {
|
|
marks: ['strong', 'em', 'code', 'strike'],
|
|
nodes: ['codeBlock'],
|
|
menu: ['copilot', 'strong', 'em', 'code', 'strike', 'undo', 'redo'],
|
|
},
|
|
'Channel::Telegram': {
|
|
marks: ['strong', 'em', 'link', 'code'],
|
|
nodes: [],
|
|
menu: ['copilot', 'strong', 'em', 'link', 'code', 'undo', 'redo'],
|
|
},
|
|
'Channel::Instagram': {
|
|
marks: ['strong', 'em', 'code', 'strike'],
|
|
nodes: ['bulletList', 'orderedList'],
|
|
menu: [
|
|
'copilot',
|
|
'strong',
|
|
'em',
|
|
'code',
|
|
'bulletList',
|
|
'orderedList',
|
|
'strike',
|
|
'undo',
|
|
'redo',
|
|
],
|
|
},
|
|
'Channel::Tiktok': {
|
|
marks: [],
|
|
nodes: [],
|
|
menu: [],
|
|
},
|
|
// Special contexts (not actual channels)
|
|
'Context::PrivateNote': {
|
|
marks: ['strong', 'em', 'code', 'link', 'strike'],
|
|
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote'],
|
|
menu: [
|
|
'copilot',
|
|
'strong',
|
|
'em',
|
|
'code',
|
|
'link',
|
|
'strike',
|
|
'bulletList',
|
|
'orderedList',
|
|
'undo',
|
|
'redo',
|
|
],
|
|
},
|
|
'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: ['image'],
|
|
menu: ['strong', 'em', 'link', 'undo', 'redo', 'imageUpload'],
|
|
},
|
|
'Context::InboxSettings': {
|
|
marks: ['strong', 'em', 'link'],
|
|
nodes: [],
|
|
menu: ['strong', 'em', 'link', 'undo', 'redo'],
|
|
},
|
|
'Context::Plain': {
|
|
marks: [],
|
|
nodes: [],
|
|
menu: [],
|
|
},
|
|
};
|
|
|
|
// Editor menu options for Full Editor
|
|
export const ARTICLE_EDITOR_MENU_OPTIONS = [
|
|
'strong',
|
|
'em',
|
|
'strike',
|
|
'link',
|
|
'undo',
|
|
'redo',
|
|
'bulletList',
|
|
'orderedList',
|
|
'h1',
|
|
'h2',
|
|
'h3',
|
|
'imageUpload',
|
|
'code',
|
|
'insertTable',
|
|
];
|
|
|
|
/**
|
|
* Markdown formatting patterns for stripping unsupported formatting.
|
|
*
|
|
* Maps camelCase type names to ProseMirror snake_case schema names.
|
|
* Order matters: codeBlock before code to avoid partial matches.
|
|
*/
|
|
export const MARKDOWN_PATTERNS = [
|
|
// --- BLOCK NODES ---
|
|
{
|
|
type: 'codeBlock', // PM: code_block, eg: ```js\ncode\n```
|
|
patterns: [
|
|
{ pattern: /`{3}(?:\w+)?\n?([\s\S]*?)`{3}/g, replacement: '$1' },
|
|
],
|
|
},
|
|
{
|
|
type: 'blockquote', // PM: blockquote, eg: > quote
|
|
patterns: [{ pattern: /^> ?/gm, replacement: '' }],
|
|
},
|
|
{
|
|
type: 'bulletList', // PM: bullet_list, eg: - item
|
|
patterns: [{ pattern: /^[\t ]*[-*+]\s+/gm, replacement: '' }],
|
|
},
|
|
{
|
|
type: 'orderedList', // PM: ordered_list, eg: 1. item
|
|
patterns: [{ pattern: /^[\t ]*\d+\.\s+/gm, replacement: '' }],
|
|
},
|
|
{
|
|
type: 'heading', // PM: heading, eg: ## Heading
|
|
patterns: [{ pattern: /^#{1,6}\s+/gm, replacement: '' }],
|
|
},
|
|
{
|
|
type: 'horizontalRule', // PM: horizontal_rule, eg: ---
|
|
patterns: [{ pattern: /^(?:---|___|\*\*\*)\s*$/gm, replacement: '' }],
|
|
},
|
|
{
|
|
type: 'image', // PM: image, eg: 
|
|
patterns: [{ pattern: /!\[([^\]]*)\]\([^)]+\)/g, replacement: '$1' }],
|
|
},
|
|
{
|
|
type: 'hardBreak', // PM: hard_break, eg: line\\\n or line \n
|
|
patterns: [
|
|
{ pattern: /\\\n/g, replacement: '\n' },
|
|
{ pattern: / {2,}\n/g, replacement: '\n' },
|
|
],
|
|
},
|
|
// --- INLINE MARKS ---
|
|
{
|
|
type: 'strong', // PM: strong, eg: **bold** or __bold__
|
|
patterns: [
|
|
{ pattern: /\*\*(.+?)\*\*/g, replacement: '$1' },
|
|
{ pattern: /__(.+?)__/g, replacement: '$1' },
|
|
],
|
|
},
|
|
{
|
|
type: 'em', // PM: em, eg: *italic* or _italic_
|
|
patterns: [
|
|
{ pattern: /(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, replacement: '$1' },
|
|
// Match _text_ only at word boundaries (whitespace/string start/end)
|
|
// Preserves underscores in URLs (e.g., https://example.com/path_name) and variable names
|
|
{
|
|
pattern: /(?<=^|[\s])_([^_\s][^_]*[^_\s]|[^_\s])_(?=$|[\s])/g,
|
|
replacement: '$1',
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: 'strike', // PM: strike, eg: ~~strikethrough~~
|
|
patterns: [{ pattern: /~~(.+?)~~/g, replacement: '$1' }],
|
|
},
|
|
{
|
|
type: 'code', // PM: code, eg: `inline code`
|
|
patterns: [{ pattern: /`([^`]+)`/g, replacement: '$1' }],
|
|
},
|
|
{
|
|
type: 'link', // PM: link
|
|
patterns: [
|
|
{ pattern: /\[([^\]]+)\]\([^)]+\)/g, replacement: '$1' }, // [text](url) -> text
|
|
{ pattern: /<([a-zA-Z][a-zA-Z0-9+.-]*:[^\s>]+)>/g, replacement: '$1' }, // <https://...>, <mailto:...>, <tel:...>, <ftp://...>, etc
|
|
{ pattern: /<([^\s@]+@[^\s@>]+)>/g, replacement: '$1' }, // <[email protected]> -> [email protected]
|
|
],
|
|
},
|
|
];
|