diff --git a/client/src/lib/emojiUtils.ts b/client/src/lib/emojiUtils.ts new file mode 100644 index 0000000..9203c31 --- /dev/null +++ b/client/src/lib/emojiUtils.ts @@ -0,0 +1,81 @@ +import twemoji from 'twemoji'; + +export interface EmojiSegment { + type: 'text' | 'emoji'; + content: string; + url?: string; +} + +/** + * Parse text and identify emoji segments + * Returns an array of segments with text and emoji parts + */ +export function parseTextWithEmojis(text: string): EmojiSegment[] { + const segments: EmojiSegment[] = []; + let lastIndex = 0; + + // Use twemoji to parse and replace emojis + const parsed = twemoji.parse(text, { + callback: (icon: string, options: any) => { + return `https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/${icon}.png`; + } + }); + + // Extract emoji positions using a temporary div + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = parsed; + + const walker = document.createTreeWalker( + tempDiv, + NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, + null + ); + + let node; + let currentText = ''; + + while ((node = walker.nextNode())) { + if (node.nodeType === Node.TEXT_NODE) { + if (node.textContent) { + currentText += node.textContent; + } + } else if (node.nodeType === Node.ELEMENT_NODE && (node as Element).tagName === 'IMG') { + // Push any accumulated text before this emoji + if (currentText) { + segments.push({ type: 'text', content: currentText }); + currentText = ''; + } + + const img = node as HTMLImageElement; + segments.push({ + type: 'emoji', + content: img.alt || '', + url: img.src + }); + } + } + + // Push any remaining text + if (currentText) { + segments.push({ type: 'text', content: currentText }); + } + + // If no emojis were found, return the original text as a single segment + if (segments.length === 0) { + return [{ type: 'text', content: text }]; + } + + return segments; +} + +/** + * Convert text with emojis to HTML string with Twemoji images + */ +export function textToTwemojiHtml(text: string, className?: string): string { + return twemoji.parse(text, { + className: className || 'inline-block align-middle', + callback: (icon: string, options: any) => { + return `https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/${icon}.png`; + } + }); +} diff --git a/package-lock.json b/package-lock.json index 6500294..30474dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -86,6 +86,7 @@ "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", "tw-animate-css": "^1.2.5", + "twemoji": "^14.0.2", "twemoji-parser": "^14.0.0", "vaul": "^1.1.2", "wouter": "^3.3.5", @@ -5893,6 +5894,29 @@ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "license": "MIT" }, + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/fs-extra/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -6037,8 +6061,7 @@ "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" }, "node_modules/has-property-descriptors": { "version": "1.0.2", @@ -6325,6 +6348,18 @@ "node": ">=6" } }, + "node_modules/jsonfile": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-5.0.0.tgz", + "integrity": "sha512-NQRZ5CRo74MhMMC3/3r5g2k4fjodJ/wh8MxjFbCViWKFjxrnudWSY5vomh+23ZaXzAS7J3fBZIR2dV6WbmfM0w==", + "license": "MIT", + "dependencies": { + "universalify": "^0.1.2" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, "node_modules/lightningcss": { "version": "1.29.2", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.29.2.tgz", @@ -8715,6 +8750,18 @@ "url": "https://github.com/sponsors/Wombosvideo" } }, + "node_modules/twemoji": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/twemoji/-/twemoji-14.0.2.tgz", + "integrity": "sha512-BzOoXIe1QVdmsUmZ54xbEH+8AgtOKUiG53zO5vVP2iUu6h5u9lN15NcuS6te4OY96qx0H7JK9vjjl9WQbkTRuA==", + "license": "MIT", + "dependencies": { + "fs-extra": "^8.0.1", + "jsonfile": "^5.0.0", + "twemoji-parser": "14.0.0", + "universalify": "^0.1.2" + } + }, "node_modules/twemoji-parser": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/twemoji-parser/-/twemoji-parser-14.0.0.tgz", @@ -8772,6 +8819,15 @@ "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", "license": "MIT" }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", diff --git a/package.json b/package.json index 2ea2cc0..27c6803 100644 --- a/package.json +++ b/package.json @@ -88,6 +88,7 @@ "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", "tw-animate-css": "^1.2.5", + "twemoji": "^14.0.2", "twemoji-parser": "^14.0.0", "vaul": "^1.1.2", "wouter": "^3.3.5",