UNPKG

@cometchat/chat-uikit-react

Version:

Ready-to-use Chat UI Components for React

368 lines (363 loc) 12.7 kB
'use strict'; var chunkNOCEWAE7_cjs = require('./chunk-NOCEWAE7.cjs'); require('./index.css'); // src/formatters/CometChatRichTextFormatter.ts var CometChatRichTextFormatter = class extends chunkNOCEWAE7_cjs.CometChatTextFormatter { constructor() { super(...arguments); this.id = "rich-text-formatter"; this.priority = 200; } getRegex() { return /<(b|strong|i|em|u|s|strike|del|code|pre|blockquote|a|ol|ul|li|br|div|p)[^>]*>/i; } /** * Only format if the input contains HTML tags that need conversion. */ shouldFormat(text) { return this.getRegex().test(text); } format(text) { if (!text) { this.originalText = ""; this.formattedText = ""; return ""; } this.originalText = text; if (!this.shouldFormat(text)) { this.formattedText = text; return text; } const markdown = this.htmlToMarkdown(text); this.formattedText = this.cleanMarkdown(markdown); return this.formattedText; } // ─── HTML → Markdown Conversion ──────────────────────────────────────── htmlToMarkdown(html) { if (typeof document === "undefined") { return this.fallbackHtmlToMarkdown(html); } const container = document.createElement("div"); container.innerHTML = html; return this.processNode(container); } processNode(node, depth = 0) { let result = ""; for (const child of Array.from(node.childNodes)) { result += this.processChildNode(child, depth); } return result; } processChildNode(node, depth = 0) { if (node.nodeType === Node.TEXT_NODE) { return node.textContent ?? ""; } if (node.nodeType !== Node.ELEMENT_NODE) { return ""; } const element = node; const tagName = element.tagName.toUpperCase(); const innerContent = this.processNode(element, depth); switch (tagName) { case "B": case "STRONG": if (element.querySelector("pre")) return innerContent; return this.wrapPerLine(innerContent, "**", "**"); case "I": case "EM": if (element.querySelector("pre")) return innerContent; return this.wrapPerLine(innerContent, "_", "_"); case "U": if (element.querySelector("pre")) return innerContent; return this.wrapPerLine(innerContent, "<u>", "</u>"); case "S": case "STRIKE": case "DEL": if (element.querySelector("pre")) return innerContent; return this.wrapPerLine(innerContent, "~~", "~~"); case "PRE": { const codeEl = element.querySelector("code") ?? element; const codeContent = this.extractCodeContent(codeEl, false); return `\`\`\` ${codeContent} \`\`\``; } case "CODE": { if (element.parentElement?.tagName === "PRE") return innerContent; const hasLink = element.querySelector("a") !== null; if (hasLink) { const linkEl = element.querySelector("a"); if (linkEl) { const href = linkEl.getAttribute("href") ?? ""; const linkText = linkEl.textContent ?? href; return `[${linkText}](${href})`; } } const codeText = this.extractCodeContent(element, true).replace(/\u200B/g, "").trim(); if (!codeText) return ""; if (codeText.includes("\n")) { return codeText.split("\n").map((line) => line.trim() ? `\`${line}\`` : "").join("\n"); } return `\`${codeText}\``; } case "BLOCKQUOTE": { const lines = innerContent.split("\n"); while (lines.length > 0 && (lines[lines.length - 1] ?? "").trim() === "") { lines.pop(); } return lines.map((line) => `> ${line}`).join("\n"); } case "A": { const href = element.getAttribute("href") ?? ""; const linkText = innerContent || href; return `[${linkText}](${href})`; } case "OL": { const startAttr = element.getAttribute("start"); let index = startAttr ? parseInt(startAttr, 10) : 1; if (isNaN(index)) index = 1; let listResult = ""; const indent = " ".repeat(depth); for (const child of Array.from(element.childNodes)) { if (child.nodeType === Node.ELEMENT_NODE && child.tagName === "LI") { const { text: textContent, nested } = this.extractListItemContent( child, depth ); const trimmed = textContent.replace(/\n/g, "").trim(); if (trimmed) { const marker = this.getOrderedListMarker(index, depth); listResult += `${indent}${marker} ${textContent} `; index++; } if (nested) listResult += nested; } } return listResult; } case "UL": { let listResult = ""; const indent = " ".repeat(depth); for (const child of Array.from(element.childNodes)) { if (child.nodeType === Node.ELEMENT_NODE && child.tagName === "LI") { const { text: textContent, nested } = this.extractListItemContent( child, depth ); const trimmed = textContent.replace(/\n/g, "").trim(); if (trimmed) { listResult += `${indent}\u2022 ${textContent} `; } if (nested) listResult += nested; } } return listResult; } case "LI": return innerContent; case "BR": return "\n"; case "P": case "DIV": return innerContent + "\n"; case "SPAN": { const className = element.className || ""; if (className.includes("cometchat-mentions")) { const uid = element.getAttribute("data-uid") ?? ""; const mentionType = element.getAttribute("data-mention-type") ?? ""; if (uid === "all" || mentionType === "channel") { return `<@all:${uid}>`; } if (uid) { return `<@uid:${uid}>`; } return element.textContent ?? innerContent; } return innerContent; } default: return innerContent; } } // ─── Helpers ─────────────────────────────────────────────────────────── /** * Get the ordered list marker based on nesting depth. * depth 0: 1. 2. 3. (decimal) * depth 1: a. b. c. (lower-alpha) * depth 2: i. ii. iii. (lower-roman) * depth 3+: cycles back through the pattern */ getOrderedListMarker(index, depth) { const level = depth % 3; switch (level) { case 0: return `${String(index)}.`; case 1: return `${this.numberToLowerAlpha(index)}.`; case 2: return `${this.numberToLowerRoman(index)}.`; default: return `${String(index)}.`; } } numberToLowerAlpha(n) { let result = ""; let num = n; while (num > 0) { num--; result = String.fromCharCode(97 + num % 26) + result; num = Math.floor(num / 26); } return result; } numberToLowerRoman(n) { const values = [1e3, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1]; const symbols = ["m", "cm", "d", "cd", "c", "xc", "l", "xl", "x", "ix", "v", "iv", "i"]; let result = ""; let num = n; for (let i = 0; i < values.length; i++) { while (num >= (values[i] ?? 0)) { result += symbols[i] ?? ""; num -= values[i] ?? 0; } } return result; } /** * Wrap inline markers per-line to handle block elements nested inside inline tags. */ wrapPerLine(content, open, close) { if (!content.includes("\n")) { return `${open}${content}${close}`; } return content.split("\n").map((line) => line.trim() === "" ? line : `${open}${line}${close}`).join("\n"); } /** * Extract text content from a code element, preserving structure. * For inline code: preserves inline formatting (bold/italic/underline/strikethrough) * by converting them to markdown markers. * For code blocks (pre): strips all formatting to plain text. */ extractCodeContent(node, preserveFormatting = false) { let out = ""; for (const child of Array.from(node.childNodes)) { if (child.nodeType === Node.TEXT_NODE) { out += child.textContent ?? ""; } else if (child.nodeType === Node.ELEMENT_NODE) { const el = child; const tag = el.tagName.toUpperCase(); if (el.classList.contains("cometchat-mentions")) { if (preserveFormatting) { out += el.textContent ?? ""; } else { const uid = el.getAttribute("data-uid") ?? ""; const mentionType = el.getAttribute("data-mention-type") ?? ""; if (uid === "all" || mentionType === "channel") { out += `<@all:${uid}>`; } else if (uid) { out += `<@uid:${uid}>`; } else { out += el.textContent ?? ""; } } } else if (tag === "BR") { out += "\n"; } else if (tag === "DIV" || tag === "P") { if (out.length > 0 && !out.endsWith("\n")) out += "\n"; out += this.extractCodeContent(el, preserveFormatting); } else if (preserveFormatting) { const innerText = this.extractCodeContent(el, true); switch (tag) { case "B": case "STRONG": out += `**${innerText}**`; break; case "I": case "EM": out += `_${innerText}_`; break; case "U": out += `<u>${innerText}</u>`; break; case "S": case "STRIKE": case "DEL": out += `~~${innerText}~~`; break; case "A": { const href = el.getAttribute("href") ?? ""; out += `[${innerText}](${href})`; break; } default: out += innerText; break; } } else { out += this.extractCodeContent(el, false); } } } return out; } /** * Extract text and nested list content from a <li> element. * Treats <div>/<p>/<br> inside list items as inline (space separator) rather than * block (newline) since browsers wrap inline content in divs inside <li>. */ extractListItemContent(li, depth) { let text = ""; let nested = ""; for (const child of Array.from(li.childNodes)) { if (child.nodeType === Node.ELEMENT_NODE) { const tag = child.tagName.toUpperCase(); if (tag === "OL" || tag === "UL") { nested += this.processChildNode(child, depth + 1); } else if (tag === "DIV" || tag === "P") { const innerContent = this.processNode(child, depth); if (text && innerContent.trim()) { text += " "; } text += innerContent.replace(/\n$/, ""); } else if (tag === "BR") { if (text && !text.endsWith(" ")) { text += " "; } } else { text += this.processChildNode(child, depth); } } else { text += child.textContent ?? ""; } } return { text, nested }; } /** * Clean up markdown output. */ cleanMarkdown(markdown) { return markdown.replace(/```\n?(?:\u200B|\u200C|\u200D|\uFEFF|\s)*\n?```/g, "").replace(/(?<!`)`(?!`)(?:[^\S\n]|\u200B|\u200C|\u200D|\uFEFF)*`(?!`)/g, "").replace(/\n{3,}/g, "\n\n").trim(); } /** * Fallback for SSR environments where document is not available. * Does basic regex-based HTML stripping. */ fallbackHtmlToMarkdown(html) { let result = html; result = result.replace(/<br\s*\/?>/gi, "\n"); result = result.replace(/<\/?(div|p)>/gi, "\n"); result = result.replace(/<b>(.*?)<\/b>/gi, "**$1**"); result = result.replace(/<strong>(.*?)<\/strong>/gi, "**$1**"); result = result.replace(/<i>(.*?)<\/i>/gi, "_$1_"); result = result.replace(/<em>(.*?)<\/em>/gi, "_$1_"); result = result.replace(/<s>(.*?)<\/s>/gi, "~~$1~~"); result = result.replace(/<del>(.*?)<\/del>/gi, "~~$1~~"); result = result.replace(/<code>(.*?)<\/code>/gi, "`$1`"); result = result.replace(/<a\s+href="([^"]*)"[^>]*>(.*?)<\/a>/gi, "[$2]($1)"); result = result.replace(/<[^>]+>/g, ""); return result; } }; exports.CometChatRichTextFormatter = CometChatRichTextFormatter;