Files
clientsflow/research/chatwoot/app/javascript/dashboard/helper/editorHelper.js

607 lines
21 KiB
JavaScript

import {
messageSchema,
MessageMarkdownTransformer,
MessageMarkdownSerializer,
} from '@chatwoot/prosemirror-schema';
import { replaceVariablesInMessage } from '@chatwoot/utils';
import * as Sentry from '@sentry/vue';
import { FORMATTING, MARKDOWN_PATTERNS } from 'dashboard/constants/editor';
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox';
import camelcaseKeys from 'camelcase-keys';
/**
* Extract text from markdown, and remove all images, code blocks, links, headers, bold, italic, lists etc.
* Links will be converted to text, and not removed.
*
* @param {string} markdown - markdown text to be extracted
* @returns {string} - The extracted text.
*/
export function extractTextFromMarkdown(markdown) {
if (!markdown) return '';
return markdown
.replace(/```[\s\S]*?```/g, '') // Remove code blocks
.replace(/`.*?`/g, '') // Remove inline code
.replace(/!\[.*?\]\(.*?\)/g, '') // Remove images before removing links
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Remove links but keep the text
.replace(/#+\s*|[*_-]{1,3}/g, '') // Remove headers, bold, italic, lists etc.
.split('\n')
.map(line => line.trim())
.filter(Boolean)
.join('\n') // Trim each line & remove any lines only having spaces
.replace(/\n{2,}/g, '\n') // Remove multiple consecutive newlines (blank lines)
.trim(); // Trim any extra space
}
/**
* Strip unsupported markdown formatting based on channel capabilities.
* Uses MARKDOWN_PATTERNS from editor constants.
*
* @param {string} markdown - markdown text to process
* @param {string} channelType - The channel type to check supported formatting
* @param {boolean} cleanWhitespace - Whether to clean up extra whitespace and blank lines (default: true for signatures)
* @returns {string} - The markdown with unsupported formatting removed
*/
export function stripUnsupportedMarkdown(
markdown,
channelType,
cleanWhitespace = true
) {
if (!markdown) return '';
const { marks = [], nodes = [] } = FORMATTING[channelType] || {};
const supported = [...marks, ...nodes];
// Apply patterns from MARKDOWN_PATTERNS for unsupported types
const result = MARKDOWN_PATTERNS.reduce((text, { type, patterns }) => {
if (supported.includes(type)) return text;
return patterns.reduce(
(t, { pattern, replacement }) => t.replace(pattern, replacement),
text
);
}, markdown);
if (!cleanWhitespace) return result;
// Clean whitespace for signatures
return result
.split('\n')
.map(line => line.trim())
.filter(Boolean)
.join('\n')
.replace(/\n{2,}/g, '\n')
.trim();
}
/**
* The delimiter used to separate the signature from the rest of the body.
* @type {string}
*/
export const SIGNATURE_DELIMITER = '--';
/**
* Parse and Serialize the markdown text to remove any extra spaces or new lines
*/
export function cleanSignature(signature) {
try {
// remove any horizontal rule tokens
signature = signature
.replace(/^( *\* *){3,} *$/gm, '')
.replace(/^( *- *){3,} *$/gm, '')
.replace(/^( *_ *){3,} *$/gm, '');
const nodes = new MessageMarkdownTransformer(messageSchema).parse(
signature
);
return MessageMarkdownSerializer.serialize(nodes);
} catch (e) {
// eslint-disable-next-line no-console
console.warn(e);
Sentry.captureException(e);
// The parser can break on some cases
// for example, Token type `hr` not supported by Markdown parser
return signature;
}
}
/**
* Adds the signature delimiter to the beginning of the signature.
*
* @param {string} signature - The signature to add the delimiter to.
* @returns {string} - The signature with the delimiter added.
*/
function appendDelimiter(signature) {
return `${SIGNATURE_DELIMITER}\n\n${cleanSignature(signature)}`;
}
/**
* Check if there's an unedited signature at the end of the body
* If there is, return the index of the signature, If there isn't, return -1
*
* @param {string} body - The body to search for the signature.
* @param {string} signature - The signature to search for.
* @returns {number} - The index of the last occurrence of the signature in the body, or -1 if not found.
*/
export function findSignatureInBody(body, signature) {
const trimmedBody = body.trimEnd();
const cleanedSignature = cleanSignature(signature);
// check if body ends with signature
if (trimmedBody.endsWith(cleanedSignature)) {
return body.lastIndexOf(cleanedSignature);
}
return -1;
}
/**
* Gets the effective channel type for formatting purposes.
* For Twilio channels, returns WhatsApp or Twilio based on medium.
*
* @param {string} channelType - The channel type
* @param {string} medium - Optional. The medium for Twilio channels (sms/whatsapp)
* @returns {string} - The effective channel type for formatting
*/
export function getEffectiveChannelType(channelType, medium) {
if (channelType === INBOX_TYPES.TWILIO) {
return medium === TWILIO_CHANNEL_MEDIUM.WHATSAPP
? INBOX_TYPES.WHATSAPP
: INBOX_TYPES.TWILIO;
}
return channelType;
}
/**
* Appends the signature to the body, separated by the signature delimiter.
* Automatically strips unsupported formatting based on channel capabilities.
*
* @param {string} body - The body to append the signature to.
* @param {string} signature - The signature to append.
* @param {string} channelType - Optional. The effective channel type to determine supported formatting.
* For Twilio channels, pass the result of getEffectiveChannelType().
* @returns {string} - The body with the signature appended.
*/
export function appendSignature(body, signature, channelType) {
// Strip only unsupported formatting based on channel capabilities
const preparedSignature = channelType
? stripUnsupportedMarkdown(signature, channelType)
: signature;
const cleanedSignature = cleanSignature(preparedSignature);
// if signature is already present, return body
if (findSignatureInBody(body, cleanedSignature) > -1) {
return body;
}
return `${body.trimEnd()}\n\n${appendDelimiter(cleanedSignature)}`;
}
/**
* Removes the signature from the body, along with the signature delimiter.
* Tries multiple signature variants: original, channel-stripped, and fully stripped.
*
* @param {string} body - The body to remove the signature from.
* @param {string} signature - The signature to remove.
* @param {string} channelType - Optional. The effective channel type for channel-specific stripping.
* @returns {string} - The body with the signature removed.
*/
export function removeSignature(body, signature, channelType) {
// Build unique list of signature variants to try
const channelStripped = channelType
? cleanSignature(stripUnsupportedMarkdown(signature, channelType))
: null;
const signaturesToTry = [
cleanSignature(signature),
channelStripped,
cleanSignature(extractTextFromMarkdown(signature)),
].filter((sig, i, arr) => sig && arr.indexOf(sig) === i); // Remove nulls and duplicates
// Find the first matching signature
const signatureIndex = signaturesToTry.reduce(
(index, sig) => (index === -1 ? findSignatureInBody(body, sig) : index),
-1
);
// no need to trim the ends here, because it will simply be removed in the next method
let newBody = body;
// if signature is present, remove it and trim it
// trimming will ensure any spaces or new lines before the signature are removed
// This means we will have the delimiter at the end
if (signatureIndex > -1) {
newBody = newBody.substring(0, signatureIndex).trimEnd();
}
// Remove delimiter if it's at the end
if (newBody.endsWith(SIGNATURE_DELIMITER)) {
// if the delimiter is at the end, remove it
newBody = newBody.slice(0, -SIGNATURE_DELIMITER.length);
}
return newBody;
}
/**
* Replaces the old signature with the new signature.
* If the old signature is not present, it will append the new signature.
*
* @param {string} body - The body to replace the signature in.
* @param {string} oldSignature - The signature to replace.
* @param {string} newSignature - The signature to replace the old signature with.
* @returns {string} - The body with the old signature replaced with the new signature.
*
*/
export function replaceSignature(body, oldSignature, newSignature) {
const withoutSignature = removeSignature(body, oldSignature);
return appendSignature(withoutSignature, newSignature);
}
/**
* Scrolls the editor view into current cursor position
*
* @param {EditorView} view - The Prosemirror EditorView
*
*/
export const scrollCursorIntoView = view => {
// Get the current selection's head position (where the cursor is).
const pos = view.state.selection.head;
// Get the corresponding DOM node for that position.
const domAtPos = view.domAtPos(pos);
const node = domAtPos.node;
// Scroll the node into view.
if (node && node.scrollIntoView) {
node.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
};
/**
* Returns a transaction that inserts a node into editor at the given position
* Has an optional param 'content' to check if the
*
* @param {Node} node - The prosemirror node that needs to be inserted into the editor
* @param {number} from - Position in the editor where the node needs to be inserted
* @param {number} to - Position in the editor where the node needs to be replaced
*
*/
export function insertAtCursor(editorView, node, from, to) {
if (!editorView) {
return undefined;
}
// This is a workaround to prevent inserting content into new line rather than on the exiting line
// If the node is of type 'doc' and has only one child which is a paragraph,
// then extract its inline content to be inserted as inline.
const isWrappedInParagraph =
node.type.name === 'doc' &&
node.childCount === 1 &&
node.firstChild.type.name === 'paragraph';
if (isWrappedInParagraph) {
node = node.firstChild.content;
}
let tr;
if (to) {
tr = editorView.state.tr.replaceWith(from, to, node).insertText(` `);
} else {
tr = editorView.state.tr.insert(from, node);
}
const state = editorView.state.apply(tr);
editorView.updateState(state);
editorView.focus();
return state;
}
/**
* Determines the appropriate node and position to insert an image in the editor.
*
* Based on the current editor state and the provided image URL, this function finds out the correct node (either
* a standalone image node or an image wrapped in a paragraph) and its respective position in the editor.
*
* 1. If the current node is a paragraph and doesn't contain an image or text, the image is inserted directly into it.
* 2. If the current node isn't a paragraph or it's a paragraph containing text, the image will be wrapped
* in a new paragraph and then inserted.
* 3. If the current node is a paragraph containing an image, the new image will be inserted directly into it.
*
* @param {Object} editorState - The current state of the editor. It provides necessary details like selection, schema, etc.
* @param {string} fileUrl - The URL of the image to be inserted into the editor.
* @returns {Object|null} An object containing details about the node to be inserted and its position. It returns null if no image node can be created.
* @property {Node} node - The ProseMirror node to be inserted (either an image node or a paragraph containing the image).
* @property {number} pos - The position where the new node should be inserted in the editor.
*/
export const findNodeToInsertImage = (editorState, fileUrl) => {
const { selection, schema } = editorState;
const { nodes } = schema;
const currentNode = selection.$from.node();
const {
type: { name: typeName },
content: { size, content },
} = currentNode;
const imageNode = nodes.image.create({ src: fileUrl });
if (!imageNode) return null;
const isInParagraph = typeName === 'paragraph';
const needsNewLine =
!content.some(n => n.type.name === 'image') && size !== 0 ? 1 : 0;
return {
node: isInParagraph ? imageNode : nodes.paragraph.create({}, imageNode),
pos: selection.from + needsNewLine,
};
};
/**
* Set URL with query and size.
*
* @param {Object} selectedImageNode - The current selected node.
* @param {Object} size - The size to set.
* @param {Object} editorView - The editor view.
*/
export function setURLWithQueryAndSize(selectedImageNode, size, editorView) {
if (selectedImageNode) {
// Create and apply the transaction
const tr = editorView.state.tr.setNodeMarkup(
editorView.state.selection.from,
null,
{
src: selectedImageNode.src,
height: size.height,
}
);
if (tr.docChanged) {
editorView.dispatch(tr);
}
}
}
/**
* Strips unsupported markdown formatting from content based on the editor schema.
* This ensures canned responses with rich formatting can be inserted into channels
* that don't support certain formatting (e.g., API channels don't support bold).
*
* @param {string} content - The markdown content to sanitize
* @param {Object} schema - The ProseMirror schema with supported marks and nodes
* @returns {string} - Content with unsupported formatting stripped
*/
export function stripUnsupportedFormatting(content, schema) {
if (!content || typeof content !== 'string') return content;
if (!schema) return content;
let sanitizedContent = content;
// Get supported marks and nodes from the schema
// Note: ProseMirror uses snake_case internally (code_block, bullet_list, etc.)
// but our FORMATTING constant uses camelCase (codeBlock, bulletList, etc.)
// We use camelcase-keys to normalize node names for comparison
const supportedMarks = Object.keys(schema.marks || {});
const nodeKeys = Object.keys(schema.nodes || {});
const nodeKeysObj = Object.fromEntries(nodeKeys.map(k => [k, true]));
const supportedNodes = Object.keys(camelcaseKeys(nodeKeysObj));
// Process each formatting type in order (codeBlock before code is important!)
MARKDOWN_PATTERNS.forEach(({ type, patterns }) => {
// Check if this format type is supported by the schema
const isMarkSupported = supportedMarks.includes(type);
const isNodeSupported = supportedNodes.includes(type);
// If not supported, strip the formatting
if (!isMarkSupported && !isNodeSupported) {
patterns.forEach(({ pattern, replacement }) => {
sanitizedContent = sanitizedContent.replace(pattern, replacement);
});
}
});
return sanitizedContent;
}
/**
* Content Node Creation Helper Functions for
* - mention
* - canned response
* - variable
* - emoji
*/
/**
* Centralized node creation function that handles the creation of different types of nodes based on the specified type.
* @param {Object} editorView - The editor view instance.
* @param {string} nodeType - The type of node to create ('mention', 'cannedResponse', 'variable', 'emoji').
* @param {Object|string} content - The content needed to create the node, which varies based on node type.
* @returns {Object|null} - The created ProseMirror node or null if the type is not supported.
*/
const createNode = (editorView, nodeType, content) => {
const { state } = editorView;
switch (nodeType) {
case 'mention': {
const mentionType = content.type || 'user';
const displayName = content.displayName || content.name;
const mentionNode = state.schema.nodes.mention.create({
userId: content.id,
userFullName: displayName,
mentionType,
});
return mentionNode;
}
case 'cannedResponse': {
// Strip unsupported formatting before parsing to ensure content can be inserted
// into channels that don't support certain markdown features (e.g., API channels)
const sanitizedContent = stripUnsupportedFormatting(
content,
state.schema
);
return new MessageMarkdownTransformer(state.schema).parse(
sanitizedContent
);
}
case 'variable':
return state.schema.text(`{{${content}}}`);
case 'emoji':
return state.schema.text(content);
case 'tool': {
return state.schema.nodes.tools.create({
id: content.id,
name: content.title,
});
}
default:
return null;
}
};
/**
* Object mapping types to their respective node creation functions.
*/
const nodeCreators = {
mention: (editorView, content, from, to) => ({
node: createNode(editorView, 'mention', content),
from,
to,
}),
cannedResponse: (editorView, content, from, to, variables) => {
const updatedMessage = replaceVariablesInMessage({
message: content,
variables,
});
const node = createNode(editorView, 'cannedResponse', updatedMessage);
return {
node,
from: node.textContent === updatedMessage ? from : from - 1,
to,
};
},
variable: (editorView, content, from, to) => ({
node: createNode(editorView, 'variable', content),
from,
to,
}),
emoji: (editorView, content, from, to) => ({
node: createNode(editorView, 'emoji', content),
from,
to,
}),
tool: (editorView, content, from, to) => ({
node: createNode(editorView, 'tool', content),
from,
to,
}),
};
/**
* Retrieves a content node based on the specified type and content, using a functional approach to select the appropriate node creation function.
* @param {Object} editorView - The editor view instance.
* @param {string} type - The type of content node to create ('mention', 'cannedResponse', 'variable', 'emoji').
* @param {string|Object} content - The content to be transformed into a node.
* @param {Object} range - An object containing 'from' and 'to' properties indicating the range in the document where the node should be placed.
* @param {Object} variables - Optional. Variables to replace in the content, used for 'cannedResponse' type.
* @returns {Object} - An object containing the created node and the updated 'from' and 'to' positions.
*/
export const getContentNode = (
editorView,
type,
content,
{ from, to },
variables
) => {
const creator = nodeCreators[type];
return creator
? 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.
* TODO: We're hiding captain, enable it back when we add selection improvements
*
* @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, showCaptain = false) {
const formatting = FORMATTING[channelType] || FORMATTING['Context::Default'];
return {
...formatting,
menu: showCaptain
? formatting.menu
: formatting.menu.filter(item => item !== 'copilot'),
};
}
/**
* 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 */