diff --git a/attached_assets/image_1763472701076.png b/attached_assets/image_1763472701076.png new file mode 100644 index 0000000..0a3ad3e Binary files /dev/null and b/attached_assets/image_1763472701076.png differ diff --git a/client/src/components/preview-modal.tsx b/client/src/components/preview-modal.tsx index 0b5ed15..21e82ef 100644 --- a/client/src/components/preview-modal.tsx +++ b/client/src/components/preview-modal.tsx @@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { ChevronLeft, ChevronRight, Send, Facebook, Instagram, Heart, MessageCircle, Share2, Bookmark } from 'lucide-react'; import type { Media } from '@shared/schema'; +import { removeEmojis } from '@shared/emoji'; interface PreviewModalProps { open: boolean; @@ -227,8 +228,10 @@ export function PreviewModal({ const ctx = canvas.getContext('2d'); if (!ctx) return [text]; + // Remove emojis for better text rendering + const cleanText = removeEmojis(text); ctx.font = `bold ${fontSize}px Arial, sans-serif`; - const words = text.split(' '); + const words = cleanText.split(' '); const lines: string[] = []; let currentLine = ''; @@ -237,8 +240,27 @@ export function PreviewModal({ const metrics = ctx.measureText(testLine); if (metrics.width > maxWidth && currentLine) { + // Current line would exceed width, push it and start new line lines.push(currentLine); currentLine = word; + + // Check if the word itself is too long for a single line + const wordMetrics = ctx.measureText(word); + if (wordMetrics.width > maxWidth) { + // Word is too long, need to break it into chunks + const chunks = breakLongWord(ctx, word, maxWidth); + for (let i = 0; i < chunks.length - 1; i++) { + lines.push(chunks[i]); + } + currentLine = chunks[chunks.length - 1]; + } + } else if (metrics.width > maxWidth && !currentLine) { + // First word is too long, need to break it + const chunks = breakLongWord(ctx, word, maxWidth); + for (let i = 0; i < chunks.length - 1; i++) { + lines.push(chunks[i]); + } + currentLine = chunks[chunks.length - 1]; } else { currentLine = testLine; } @@ -251,6 +273,33 @@ export function PreviewModal({ return lines; }; + /** + * Breaks a long word into chunks that fit within maxWidth + */ + const breakLongWord = (ctx: CanvasRenderingContext2D, word: string, maxWidth: number): string[] => { + const chunks: string[] = []; + let currentChunk = ''; + + for (let i = 0; i < word.length; i++) { + const char = word[i]; + const testChunk = currentChunk + char; + const metrics = ctx.measureText(testChunk); + + if (metrics.width > maxWidth && currentChunk) { + chunks.push(currentChunk); + currentChunk = char; + } else { + currentChunk = testChunk; + } + } + + if (currentChunk) { + chunks.push(currentChunk); + } + + return chunks.length > 0 ? chunks : [word]; + }; + const calculateAdaptiveFontSize = (text: string, containerWidth: number, containerHeight: number): { fontSize: number; lines: string[]; isTruncated: boolean } => { const MIN_FONT_SIZE = 12; const MAX_FONT_SIZE = 72; diff --git a/package-lock.json b/package-lock.json index 45fd703..1a39110 100644 --- a/package-lock.json +++ b/package-lock.json @@ -58,6 +58,7 @@ "drizzle-orm": "^0.39.1", "drizzle-zod": "^0.7.0", "embla-carousel-react": "^8.6.0", + "emoji-regex": "^10.6.0", "express": "^4.21.2", "express-session": "^1.18.1", "framer-motion": "^11.13.1", @@ -5341,9 +5342,9 @@ } }, "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "license": "MIT" }, "node_modules/encodeurl": { @@ -8293,6 +8294,12 @@ "node": ">=8" } }, + "node_modules/string-width/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, "node_modules/strip-ansi": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", diff --git a/package.json b/package.json index e688d05..c1437bd 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "drizzle-orm": "^0.39.1", "drizzle-zod": "^0.7.0", "embla-carousel-react": "^8.6.0", + "emoji-regex": "^10.6.0", "express": "^4.21.2", "express-session": "^1.18.1", "framer-motion": "^11.13.1", diff --git a/server/routes.ts b/server/routes.ts index a48291d..4fcf790 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -1247,9 +1247,9 @@ export async function registerRoutes(app: Express): Promise { const user = req.user as User; const userId = user.id; - // Calculate token expiration date (90 days from now) + // Calculate token expiration date (60 days from now) const tokenExpiresAt = new Date(); - tokenExpiresAt.setDate(tokenExpiresAt.getDate() + 90); + tokenExpiresAt.setDate(tokenExpiresAt.getDate() + 60); const pageData = insertSocialPageSchema.parse({ ...req.body, @@ -1277,10 +1277,10 @@ export async function registerRoutes(app: Express): Promise { let pageData = insertSocialPageSchema.partial().parse(req.body); - // If accessToken is being updated, recalculate expiration date (90 days from now) + // If accessToken is being updated, recalculate expiration date (60 days from now) if (pageData.accessToken) { const tokenExpiresAt = new Date(); - tokenExpiresAt.setDate(tokenExpiresAt.getDate() + 90); + tokenExpiresAt.setDate(tokenExpiresAt.getDate() + 60); pageData = { ...pageData, tokenExpiresAt }; } diff --git a/server/services/imageProcessor.ts b/server/services/imageProcessor.ts index e43211f..e63bcba 100644 --- a/server/services/imageProcessor.ts +++ b/server/services/imageProcessor.ts @@ -3,6 +3,7 @@ import { randomUUID } from "crypto"; import path from "path"; import fs from "fs/promises"; import { createCanvas, loadImage, registerFont } from "canvas"; +import { removeEmojis } from "@shared/emoji"; interface CropDimensions { width: number; @@ -116,6 +117,9 @@ export class ImageProcessor { const MAX_FONT_SIZE = 72; const PADDING = 40; const LINE_HEIGHT_MULTIPLIER = 1.3; + + // Remove emojis from text as canvas doesn't support them + const cleanText = removeEmojis(text); const canvas = createCanvas(STORY_WIDTH, STORY_HEIGHT); const ctx = canvas.getContext('2d'); @@ -161,7 +165,7 @@ export class ImageProcessor { while (fontSize >= MIN_FONT_SIZE) { ctx.font = `bold ${fontSize}px "DejaVu Sans", Arial, sans-serif`; - lines = this.wrapText(ctx, text, maxTextWidth); + lines = this.wrapText(ctx, cleanText, maxTextWidth); const lineHeight = fontSize * LINE_HEIGHT_MULTIPLIER; totalTextHeight = lines.length * lineHeight; @@ -175,7 +179,7 @@ export class ImageProcessor { if (fontSize < MIN_FONT_SIZE) { fontSize = MIN_FONT_SIZE; ctx.font = `bold ${fontSize}px "DejaVu Sans", Arial, sans-serif`; - lines = this.wrapText(ctx, text, maxTextWidth); + lines = this.wrapText(ctx, cleanText, maxTextWidth); const lineHeight = fontSize * LINE_HEIGHT_MULTIPLIER; totalTextHeight = lines.length * lineHeight; @@ -214,8 +218,27 @@ export class ImageProcessor { const metrics = ctx.measureText(testLine); if (metrics.width > maxWidth && currentLine) { + // Current line would exceed width, push it and start new line lines.push(currentLine); currentLine = word; + + // Check if the word itself is too long for a single line + const wordMetrics = ctx.measureText(word); + if (wordMetrics.width > maxWidth) { + // Word is too long, need to break it into chunks + const chunks = this.breakLongWord(ctx, word, maxWidth); + for (let i = 0; i < chunks.length - 1; i++) { + lines.push(chunks[i]); + } + currentLine = chunks[chunks.length - 1]; + } + } else if (metrics.width > maxWidth && !currentLine) { + // First word is too long, need to break it + const chunks = this.breakLongWord(ctx, word, maxWidth); + for (let i = 0; i < chunks.length - 1; i++) { + lines.push(chunks[i]); + } + currentLine = chunks[chunks.length - 1]; } else { currentLine = testLine; } @@ -227,6 +250,33 @@ export class ImageProcessor { return lines; } + + /** + * Breaks a long word into chunks that fit within maxWidth + */ + private breakLongWord(ctx: any, word: string, maxWidth: number): string[] { + const chunks: string[] = []; + let currentChunk = ''; + + for (let i = 0; i < word.length; i++) { + const char = word[i]; + const testChunk = currentChunk + char; + const metrics = ctx.measureText(testChunk); + + if (metrics.width > maxWidth && currentChunk) { + chunks.push(currentChunk); + currentChunk = char; + } else { + currentChunk = testChunk; + } + } + + if (currentChunk) { + chunks.push(currentChunk); + } + + return chunks.length > 0 ? chunks : [word]; + } } export const imageProcessor = new ImageProcessor(); diff --git a/shared/emoji.ts b/shared/emoji.ts new file mode 100644 index 0000000..41e6231 --- /dev/null +++ b/shared/emoji.ts @@ -0,0 +1,16 @@ +import emojiRegex from 'emoji-regex'; + +// Cached regex instance for performance +const emojiPattern = emojiRegex(); + +/** + * Removes emojis from text while preserving other Unicode characters + * Uses the emoji-regex package which correctly identifies emojis without + * removing legitimate astral-plane characters (CJK Extension B, etc.) + */ +export function removeEmojis(text: string): string { + return text + .replace(emojiPattern, '') + .replace(/\s+/g, ' ') // Normalize whitespace + .trim(); +}