diff --git a/attached_assets/image_1763735384881.png b/attached_assets/image_1763735384881.png new file mode 100644 index 0000000..0d6f5d6 Binary files /dev/null and b/attached_assets/image_1763735384881.png differ diff --git a/client/src/components/emoji-text.tsx b/client/src/components/emoji-text.tsx new file mode 100644 index 0000000..bc84a62 --- /dev/null +++ b/client/src/components/emoji-text.tsx @@ -0,0 +1,42 @@ +import { useMemo } from 'react'; +import { parseTextWithEmojis } from '@/lib/emojiUtils'; + +interface EmojiTextProps { + text: string; + className?: string; + emojiSize?: number; +} + +/** + * Component that renders text with emojis as Twemoji images + * Matches the backend Twemoji rendering for consistent preview + */ +export function EmojiText({ text, className = '', emojiSize = 20 }: EmojiTextProps) { + const segments = useMemo(() => parseTextWithEmojis(text), [text]); + + return ( + + {segments.map((segment, idx) => { + if (segment.type === 'text') { + return {segment.content}; + } else { + return ( + {segment.content} + ); + } + })} + + ); +} diff --git a/client/src/components/preview-modal.tsx b/client/src/components/preview-modal.tsx index 7fcd3e9..1f4b1be 100644 --- a/client/src/components/preview-modal.tsx +++ b/client/src/components/preview-modal.tsx @@ -5,6 +5,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { ChevronLeft, ChevronRight, Send, Facebook, Instagram, Heart, MessageCircle, Share2, Bookmark } from 'lucide-react'; import type { Media } from '@shared/schema'; import { removeHashtags } from '@shared/emoji'; +import { EmojiText } from '@/components/emoji-text'; interface PreviewModalProps { open: boolean; @@ -425,7 +426,9 @@ export function PreviewModal({ }} > {lines.map((line, idx) => ( -
{line}
+
+ +
))} 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", diff --git a/replit.md b/replit.md index 5f7ef7a..edad4b8 100644 --- a/replit.md +++ b/replit.md @@ -9,13 +9,19 @@ Preferred communication style: Simple, everyday language. ## Recent Changes ### November 21, 2025 -- **Story Text Rendering with Emoji Support**: Implemented robust emoji rendering in Instagram/Facebook stories using Twemoji (Twitter's emoji library). Previous implementation removed all emojis due to canvas rendering limitations. New solution: +- **Story Text Rendering with Emoji Support (Backend & Frontend)**: Implemented complete emoji rendering pipeline for Instagram/Facebook stories using Twemoji: + - **Backend Implementation** (`server/services/emojiRenderer.ts`, `server/services/imageProcessor.ts`): + - Created `emojiRenderer` service that converts text emojis to high-quality PNG images from Twemoji CDN + - Smart text wrapping handles long emoji sequences (e.g., "🎉🎉🎉🎉🎉") by breaking them into chunks that fit within canvas bounds + - In-memory caching of emoji images reduces CDN requests and improves performance + - Grapheme-aware breaking uses `Array.from()` for proper Unicode grapheme splitting + - **Frontend Preview Implementation** (`client/src/lib/emojiUtils.ts`, `client/src/components/emoji-text.tsx`): + - Added `twemoji` package for client-side emoji parsing + - Created `EmojiText` React component that renders emojis as Twemoji images in preview modal + - Ensures preview modal matches actual published story appearance (both use Twemoji CDN images) + - Emoji size dynamically scales with text font size - **Hashtag Removal**: Changed from removing emojis to removing hashtags (`#tag`) for cleaner story text while preserving emojis for engagement - - **Twemoji Integration**: Created `emojiRenderer` service that converts text emojis to high-quality PNG images from Twemoji CDN - - **Smart Text Wrapping**: Enhanced text wrapping to handle long emoji sequences (e.g., "🎉🎉🎉🎉🎉") by breaking them into chunks that fit within canvas bounds - - **Emoji Caching**: Implements in-memory caching of emoji images to reduce CDN requests and improve performance - - **Grapheme-Aware Breaking**: Uses `Array.from()` for proper Unicode grapheme splitting, ensuring multi-codepoint emojis aren't broken incorrectly - - Files modified: `shared/emoji.ts`, `server/services/emojiRenderer.ts`, `server/services/imageProcessor.ts`, `client/src/components/preview-modal.tsx` + - Files modified: `shared/emoji.ts`, `server/services/emojiRenderer.ts`, `server/services/imageProcessor.ts`, `client/src/lib/emojiUtils.ts`, `client/src/components/emoji-text.tsx`, `client/src/components/preview-modal.tsx` ### November 18, 2025 - **Facebook Video Codec Compatibility Fix**: Fixed issue where MP4 videos uploaded to Facebook would not play correctly due to codec incompatibility. Facebook requires strict video format: H.264 video codec, AAC audio codec, and MP4 container. Solution: Added automatic video transcoding via Cloudinary transformations. When publishing a video to Facebook, the system now transforms the Cloudinary URL to include `vc_h264,ac_aac,f_mp4,q_auto` parameters, ensuring all videos are automatically re-encoded to Facebook-compatible format before publication. The getFacebookCompatibleVideoUrl() method detects Cloudinary video URLs and applies the necessary transformations transparently. Non-Cloudinary URLs are passed through unchanged with a warning. This ensures reliable video playback on Facebook without requiring users to manually re-encode their videos. Files modified: `server/services/facebook.ts`.