From fd6d5e4296357050367ffb6f528a7e6fc8c62f7f Mon Sep 17 00:00:00 2001 From: QingXiao <143726276+5xiao0qing5@users.noreply.github.com> Date: Fri, 23 Jan 2026 14:34:40 +0800 Subject: [PATCH 1/3] Implement HTML chat export --- electron/services/exportService.ts | 607 ++++++++++++++++++++++++++++- src/pages/ExportPage.tsx | 21 +- 2 files changed, 617 insertions(+), 11 deletions(-) diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 875c961..7840521 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -8,6 +8,7 @@ import { ConfigService } from './config' import { wcdbService } from './wcdbService' import { imageDecryptService } from './imageDecryptService' import { chatService } from './chatService' +import { videoService } from './videoService' // ChatLab 格式类型定义 interface ChatLabHeader { @@ -89,7 +90,8 @@ const TXT_COLUMN_DEFINITIONS: Array<{ id: string; label: string }> = [ interface MediaExportItem { relativePath: string - kind: 'image' | 'voice' | 'emoji' + kind: 'image' | 'voice' | 'emoji' | 'video' + posterDataUrl?: string } export interface ExportProgress { @@ -451,6 +453,28 @@ class ExportService { return value.replace(/\r?\n/g, ' ').replace(/\t/g, ' ').trim() } + private escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + } + + private escapeAttribute(value: string): string { + return this.escapeHtml(value).replace(/`/g, '`') + } + + private getAvatarFallback(name: string): string { + if (!name) return '?' + return [...name][0] || '?' + } + + private renderMultilineText(value: string): string { + return this.escapeHtml(value).replace(/\r?\n/g, '
') + } + /** * 导出媒体文件到指定目录 */ @@ -459,7 +483,14 @@ class ExportService { sessionId: string, mediaRootDir: string, mediaRelativePrefix: string, - options: { exportImages?: boolean; exportVoices?: boolean; exportEmojis?: boolean; exportVoiceAsText?: boolean } + options: { + exportImages?: boolean + exportVoices?: boolean + exportEmojis?: boolean + exportVoiceAsText?: boolean + includeVoiceWithTranscript?: boolean + exportVideos?: boolean + } ): Promise { const localType = msg.localType @@ -473,14 +504,13 @@ class ExportService { // 语音消息 if (localType === 34) { - // 如果开启了语音转文字,优先转文字(不导出语音文件) - if (options.exportVoiceAsText) { - return null // 转文字逻辑在消息内容处理中完成 - } - // 否则导出语音文件 - if (options.exportVoices) { + const shouldKeepVoiceFile = options.includeVoiceWithTranscript || !options.exportVoiceAsText + if (shouldKeepVoiceFile && options.exportVoices) { return this.exportVoice(msg, sessionId, mediaRootDir, mediaRelativePrefix) } + if (options.exportVoiceAsText) { + return null + } } // 动画表情 @@ -491,6 +521,10 @@ class ExportService { return result } + if (localType === 43 && options.exportVideos) { + return this.exportVideo(msg, sessionId, mediaRootDir, mediaRelativePrefix) + } + return null } @@ -701,6 +735,47 @@ class ExportService { } } + /** + * 导出视频文件 + */ + private async exportVideo( + msg: any, + sessionId: string, + mediaRootDir: string, + mediaRelativePrefix: string + ): Promise { + try { + const videoMd5 = msg.videoMd5 + if (!videoMd5) return null + + const videosDir = path.join(mediaRootDir, mediaRelativePrefix, 'videos') + if (!fs.existsSync(videosDir)) { + fs.mkdirSync(videosDir, { recursive: true }) + } + + const videoInfo = await videoService.getVideoInfo(videoMd5) + if (!videoInfo.exists || !videoInfo.videoUrl) { + return null + } + + const sourcePath = videoInfo.videoUrl + const fileName = path.basename(sourcePath) + const destPath = path.join(videosDir, fileName) + + if (!fs.existsSync(destPath)) { + fs.copyFileSync(sourcePath, destPath) + } + + return { + relativePath: path.posix.join(mediaRelativePrefix, 'videos', fileName), + kind: 'video', + posterDataUrl: videoInfo.coverUrl || videoInfo.thumbUrl + } + } catch (e) { + return null + } + } + /** * 从消息内容提取图片 MD5 */ @@ -759,6 +834,16 @@ class ExportService { return match?.[1] } + private extractVideoMd5(content: string): string | undefined { + if (!content) return undefined + const attrMatch = /]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) + if (attrMatch) { + return attrMatch[1].toLowerCase() + } + const tagMatch = /([^<]+)<\/md5>/i.exec(content) + return tagMatch?.[1]?.toLowerCase() + } + /** * 从 data URL 获取扩展名 */ @@ -881,6 +966,7 @@ class ExportService { let imageDatName: string | undefined let emojiCdnUrl: string | undefined let emojiMd5: string | undefined + let videoMd5: string | undefined if (localType === 3 && content) { // 图片消息 @@ -890,6 +976,9 @@ class ExportService { // 动画表情 emojiCdnUrl = this.extractEmojiUrl(content) emojiMd5 = this.extractEmojiMd5(content) + } else if (localType === 43 && content) { + // 视频消息 + videoMd5 = this.extractVideoMd5(content) } rows.push({ @@ -902,7 +991,8 @@ class ExportService { imageMd5, imageDatName, emojiCdnUrl, - emojiMd5 + emojiMd5, + videoMd5 }) if (firstTime === null || createTime < firstTime) firstTime = createTime @@ -2094,6 +2184,502 @@ class ExportService { } } + /** + * 导出单个会话为 HTML 格式 + */ + async exportSessionToHtml( + sessionId: string, + outputPath: string, + options: ExportOptions, + onProgress?: (progress: ExportProgress) => void + ): Promise<{ success: boolean; error?: string }> { + try { + const conn = await this.ensureConnected() + if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error } + + const cleanedMyWxid = conn.cleanedWxid + const isGroup = sessionId.includes('@chatroom') + const sessionInfo = await this.getContactInfo(sessionId) + const myInfo = await this.getContactInfo(cleanedMyWxid) + + onProgress?.({ + current: 0, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'preparing' + }) + + const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) + if (isGroup) { + await this.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true) + } + const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime) + + const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options) + const mediaMessages = exportMediaEnabled + ? sortedMessages.filter(msg => { + const t = msg.localType + return (t === 3 && options.exportImages) || + (t === 47 && options.exportEmojis) || + (t === 34 && options.exportVoices) || + t === 43 + }) + : [] + + const mediaCache = new Map() + + if (mediaMessages.length > 0) { + onProgress?.({ + current: 20, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting-media' + }) + + const MEDIA_CONCURRENCY = 6 + await parallelLimit(mediaMessages, MEDIA_CONCURRENCY, async (msg) => { + const mediaKey = `${msg.localType}_${msg.localId}` + if (!mediaCache.has(mediaKey)) { + const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, { + exportImages: options.exportImages, + exportVoices: options.exportVoices, + exportEmojis: options.exportEmojis, + exportVoiceAsText: options.exportVoiceAsText, + includeVoiceWithTranscript: true, + exportVideos: true + }) + mediaCache.set(mediaKey, mediaItem) + } + }) + } + + const useVoiceTranscript = options.exportVoiceAsText !== false + const voiceMessages = useVoiceTranscript + ? sortedMessages.filter(msg => msg.localType === 34) + : [] + const voiceTranscriptMap = new Map() + + if (voiceMessages.length > 0) { + onProgress?.({ + current: 40, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting-voice' + }) + + const VOICE_CONCURRENCY = 4 + await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { + const transcript = await this.transcribeVoice(sessionId, String(msg.localId)) + voiceTranscriptMap.set(msg.localId, transcript) + }) + } + + const avatarMap = options.exportAvatars + ? await this.exportAvatars( + [ + ...Array.from(collected.memberSet.entries()).map(([username, info]) => ({ + username, + avatarUrl: info.avatarUrl + })), + { username: sessionId, avatarUrl: sessionInfo.avatarUrl }, + { username: cleanedMyWxid, avatarUrl: myInfo.avatarUrl } + ] + ) + : new Map() + + const renderedMessages = sortedMessages.map((msg, index) => { + const mediaKey = `${msg.localType}_${msg.localId}` + const mediaItem = mediaCache.get(mediaKey) || null + + const isSenderMe = msg.isSend + const senderInfo = collected.memberSet.get(msg.senderUsername)?.member + const senderName = isSenderMe + ? (myInfo.displayName || '我') + : (isGroup + ? (senderInfo?.groupNickname || senderInfo?.accountName || msg.senderUsername) + : (sessionInfo.displayName || sessionId)) + const avatarData = avatarMap.get(isSenderMe ? cleanedMyWxid : msg.senderUsername) + const avatarHtml = avatarData + ? `${this.escapeAttribute(senderName)}` + : `${this.escapeHtml(this.getAvatarFallback(senderName))}` + + const timeText = this.formatTimestamp(msg.createTime) + const typeName = this.getMessageTypeName(msg.localType) + + let textContent = this.parseMessageContent(msg.content, msg.localType) || '' + if (msg.localType === 34 && useVoiceTranscript) { + textContent = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]' + } + + let mediaHtml = '' + if (mediaItem?.kind === 'image') { + mediaHtml = `${this.escapeAttribute(typeName)}` + } else if (mediaItem?.kind === 'emoji') { + mediaHtml = `${this.escapeAttribute(typeName)}` + } else if (mediaItem?.kind === 'voice') { + mediaHtml = `` + } else if (mediaItem?.kind === 'video') { + const posterAttr = mediaItem.posterDataUrl ? ` poster="${this.escapeAttribute(mediaItem.posterDataUrl)}"` : '' + mediaHtml = `` + } + + const textHtml = textContent + ? `
${this.renderMultilineText(textContent)}
` + : '' + const senderHtml = isGroup + ? `
${this.escapeHtml(senderName)}
` + : '' + const messageBody = ` + ${senderHtml} +
+ ${mediaHtml} + ${textHtml} +
+ ` + + return ` +
+
${this.escapeHtml(timeText)}
+
+
${avatarHtml}
+
+ ${messageBody} +
+
+
+ ` + }).join('\n') + + onProgress?.({ + current: 85, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'writing' + }) + + const exportMeta = this.getExportMeta(sessionId, sessionInfo, isGroup) + const html = ` + + + + + ${this.escapeHtml(sessionInfo.displayName)} - 聊天记录 + + + +
+
+

${this.escapeHtml(sessionInfo.displayName)} 的聊天记录

+
+ 导出时间:${this.escapeHtml(this.formatTimestamp(exportMeta.chatlab.exportedAt))} + 消息数量:${sortedMessages.length} + 会话类型:${isGroup ? '群聊' : '私聊'} +
+
+
+ + +
+
+ + +
+
+ + +
+
+ 共 ${sortedMessages.length} 条 +
+
+
+
+ ${renderedMessages || '
暂无消息
'} +
+
+ + +` + + fs.writeFileSync(outputPath, html, 'utf-8') + + onProgress?.({ + current: 100, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'complete' + }) + + return { success: true } + } catch (e) { + return { success: false, error: String(e) } + } + } + /** * 批量导出多个会话 */ @@ -2145,6 +2731,7 @@ class ExportService { if (options.format === 'chatlab-jsonl') ext = '.jsonl' else if (options.format === 'excel') ext = '.xlsx' else if (options.format === 'txt') ext = '.txt' + else if (options.format === 'html') ext = '.html' const outputPath = path.join(sessionDir, `${safeName}${ext}`) let result: { success: boolean; error?: string } @@ -2156,6 +2743,8 @@ class ExportService { result = await this.exportSessionToExcel(sessionId, outputPath, options) } else if (options.format === 'txt') { result = await this.exportSessionToTxt(sessionId, outputPath, options) + } else if (options.format === 'html') { + result = await this.exportSessionToHtml(sessionId, outputPath, options) } else { result = { success: false, error: `不支持的格式: ${options.format}` } } diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 87f3bfa..088c0ef 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -216,6 +216,23 @@ function ExportPage() { return date.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }) } + const handleFormatChange = (format: ExportOptions['format']) => { + setOptions((prev) => { + const next = { ...prev, format } + if (format === 'html') { + return { + ...next, + exportMedia: true, + exportImages: true, + exportVoices: true, + exportEmojis: true, + exportVoiceAsText: true + } + } + return next + }) + } + const openExportFolder = async () => { if (exportFolder) { await window.electronAPI.shell.openPath(exportFolder) @@ -249,7 +266,7 @@ function ExportPage() { } : null } - if (options.format === 'chatlab' || options.format === 'chatlab-jsonl' || options.format === 'json' || options.format === 'excel' || options.format === 'txt') { + if (options.format === 'chatlab' || options.format === 'chatlab-jsonl' || options.format === 'json' || options.format === 'excel' || options.format === 'txt' || options.format === 'html') { const result = await window.electronAPI.export.exportSessions( sessionList, exportFolder, @@ -455,7 +472,7 @@ function ExportPage() {
setOptions({ ...options, format: fmt.value as any })} + onClick={() => handleFormatChange(fmt.value as ExportOptions['format'])} > {fmt.label} From a61371c8ad0dc1c9d4da2e5feb9790d34f93b8e8 Mon Sep 17 00:00:00 2001 From: QingXiao <143726276+5xiao0qing5@users.noreply.github.com> Date: Fri, 23 Jan 2026 14:48:34 +0800 Subject: [PATCH 2/3] Refine HTML export layout and theming --- electron/services/exportService.ts | 153 ++++++++++++++++++++++++++++- 1 file changed, 148 insertions(+), 5 deletions(-) diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 7840521..17d4c5f 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -475,6 +475,33 @@ class ExportService { return this.escapeHtml(value).replace(/\r?\n/g, '
') } + private formatHtmlMessageText(content: string, localType: number): string { + if (!content) return '' + + if (localType === 49) { + const typeMatch = /(\d+)<\/type>/i.exec(content) + const subType = typeMatch ? parseInt(typeMatch[1], 10) : 0 + const title = this.extractXmlValue(content, 'title') || this.extractXmlValue(content, 'appname') + if (subType === 6) { + const fileName = this.extractXmlValue(content, 'filename') || title || '文件' + return `[文件] ${fileName}`.trim() + } + if (subType === 33 || subType === 36) { + const appName = this.extractXmlValue(content, 'appname') + const miniTitle = title || appName || '小程序' + return `[小程序] ${miniTitle}`.trim() + } + return title || '[链接]' + } + + if (localType === 42) { + const nickname = this.extractXmlValue(content, 'nickname') + return nickname ? `[名片] ${nickname}` : '[名片]' + } + + return this.parseMessageContent(content, localType) || '' + } + /** * 导出媒体文件到指定目录 */ @@ -2306,16 +2333,21 @@ class ExportService { const timeText = this.formatTimestamp(msg.createTime) const typeName = this.getMessageTypeName(msg.localType) - let textContent = this.parseMessageContent(msg.content, msg.localType) || '' + let textContent = this.formatHtmlMessageText(msg.content, msg.localType) if (msg.localType === 34 && useVoiceTranscript) { textContent = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]' } + if (mediaItem && (msg.localType === 3 || msg.localType === 43 || msg.localType === 47)) { + textContent = '' + } let mediaHtml = '' if (mediaItem?.kind === 'image') { - mediaHtml = `${this.escapeAttribute(typeName)}` + const mediaPath = this.escapeAttribute(encodeURI(mediaItem.relativePath)) + mediaHtml = `${this.escapeAttribute(typeName)}` } else if (mediaItem?.kind === 'emoji') { - mediaHtml = `${this.escapeAttribute(typeName)}` + const mediaPath = this.escapeAttribute(encodeURI(mediaItem.relativePath)) + mediaHtml = `${this.escapeAttribute(typeName)}` } else if (mediaItem?.kind === 'voice') { mediaHtml = `` } else if (mediaItem?.kind === 'video') { @@ -2329,7 +2361,9 @@ class ExportService { const senderHtml = isGroup ? `
${this.escapeHtml(senderName)}
` : '' + const timeHtml = `
${this.escapeHtml(timeText)}
` const messageBody = ` + ${timeHtml} ${senderHtml}
${mediaHtml} @@ -2339,7 +2373,6 @@ class ExportService { return `
-
${this.escapeHtml(timeText)}
${avatarHtml}
@@ -2437,6 +2470,7 @@ class ExportService { } .control input, + .control select, .control button { border-radius: 12px; border: 1px solid var(--border); @@ -2481,9 +2515,9 @@ class ExportService { } .message-time { - text-align: center; font-size: 12px; color: var(--muted); + margin-bottom: 6px; } .message-row { @@ -2575,6 +2609,72 @@ class ExportService { width: 260px; } + .image-preview { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.7); + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s ease; + z-index: 999; + } + + .image-preview.active { + opacity: 1; + pointer-events: auto; + } + + .image-preview img { + max-width: min(90vw, 1200px); + max-height: 90vh; + border-radius: 18px; + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.35); + background: #0f172a; + } + + body[data-theme="cloud-dancer"] { + --accent: #6b8cff; + --sent: #e0e7ff; + --received: #ffffff; + --border: #d8e0f7; + --bg: #f6f7fb; + } + + body[data-theme="corundum-blue"] { + --accent: #2563eb; + --sent: #dbeafe; + --received: #ffffff; + --border: #c7d2fe; + --bg: #eef2ff; + } + + body[data-theme="kiwi-green"] { + --accent: #16a34a; + --sent: #dcfce7; + --received: #ffffff; + --border: #bbf7d0; + --bg: #f0fdf4; + } + + body[data-theme="spicy-red"] { + --accent: #e11d48; + --sent: #ffe4e6; + --received: #ffffff; + --border: #fecdd3; + --bg: #fff1f2; + } + + body[data-theme="teal-water"] { + --accent: #0f766e; + --sent: #ccfbf1; + --received: #ffffff; + --border: #99f6e4; + --bg: #f0fdfa; + } + .highlight { outline: 2px solid var(--accent); outline-offset: 4px; @@ -2606,6 +2706,16 @@ class ExportService {
+
+ + +
@@ -2619,12 +2729,18 @@ class ExportService { ${renderedMessages || '
暂无消息
'}
+
+ 预览 +
From 54f3e0481f9495c447b92373c181f22478f2db21 Mon Sep 17 00:00:00 2001 From: QingXiao <143726276+5xiao0qing5@users.noreply.github.com> Date: Fri, 23 Jan 2026 15:00:43 +0800 Subject: [PATCH 3/3] Fix HTML export app messages and emoji rendering --- electron/services/exportService.ts | 105 +++++++++++++++++++++++++++-- 1 file changed, 98 insertions(+), 7 deletions(-) diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 17d4c5f..f723307 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -4,6 +4,7 @@ import * as http from 'http' import * as https from 'https' import { fileURLToPath } from 'url' import ExcelJS from 'exceljs' +import { getEmojiPath } from 'wechat-emojis' import { ConfigService } from './config' import { wcdbService } from './wcdbService' import { imageDecryptService } from './imageDecryptService' @@ -129,6 +130,7 @@ async function parallelLimit( class ExportService { private configService: ConfigService private contactCache: Map = new Map() + private inlineEmojiCache: Map = new Map() constructor() { this.configService = new ConfigService() @@ -218,6 +220,9 @@ class ExportService { if (!raw) return '' if (typeof raw === 'string') { if (raw.length === 0) return '' + if (/^[0-9]+$/.test(raw)) { + return raw + } if (this.looksLikeHex(raw)) { const bytes = Buffer.from(raw, 'hex') if (bytes.length > 0) return this.decodeBinaryContent(bytes) @@ -475,19 +480,70 @@ class ExportService { return this.escapeHtml(value).replace(/\r?\n/g, '
') } + private normalizeAppMessageContent(content: string): string { + if (!content) return '' + if (content.includes('<') && content.includes('>')) { + return content + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, "'") + } + return content + } + + private getInlineEmojiDataUrl(name: string): string | null { + if (!name) return null + const cached = this.inlineEmojiCache.get(name) + if (cached) return cached + const emojiPath = getEmojiPath(name as any) + if (!emojiPath) return null + const baseDir = path.dirname(require.resolve('wechat-emojis')) + const absolutePath = path.join(baseDir, emojiPath) + if (!fs.existsSync(absolutePath)) return null + try { + const buffer = fs.readFileSync(absolutePath) + const dataUrl = `data:image/png;base64,${buffer.toString('base64')}` + this.inlineEmojiCache.set(name, dataUrl) + return dataUrl + } catch { + return null + } + } + + private renderTextWithEmoji(text: string): string { + if (!text) return '' + const parts = text.split(/\[(.*?)\]/g) + const rendered = parts.map((part, index) => { + if (index % 2 === 1) { + const emojiDataUrl = this.getInlineEmojiDataUrl(part) + if (emojiDataUrl) { + return `[${this.escapeAttribute(part)}]` + } + return this.escapeHtml(`[${part}]`) + } + return this.escapeHtml(part) + }) + return rendered.join('') + } + private formatHtmlMessageText(content: string, localType: number): string { if (!content) return '' - if (localType === 49) { - const typeMatch = /(\d+)<\/type>/i.exec(content) + const normalized = this.normalizeAppMessageContent(content) + const isAppMessage = normalized.includes('') + + if (localType === 49 || isAppMessage) { + const typeMatch = /(\d+)<\/type>/i.exec(normalized) const subType = typeMatch ? parseInt(typeMatch[1], 10) : 0 - const title = this.extractXmlValue(content, 'title') || this.extractXmlValue(content, 'appname') + const title = this.extractXmlValue(normalized, 'title') || this.extractXmlValue(normalized, 'appname') if (subType === 6) { - const fileName = this.extractXmlValue(content, 'filename') || title || '文件' + const fileName = this.extractXmlValue(normalized, 'filename') || title || '文件' return `[文件] ${fileName}`.trim() } if (subType === 33 || subType === 36) { - const appName = this.extractXmlValue(content, 'appname') + const appName = this.extractXmlValue(normalized, 'appname') const miniTitle = title || appName || '小程序' return `[小程序] ${miniTitle}`.trim() } @@ -495,7 +551,7 @@ class ExportService { } if (localType === 42) { - const nickname = this.extractXmlValue(content, 'nickname') + const nickname = this.extractXmlValue(normalized, 'nickname') return nickname ? `[名片] ${nickname}` : '[名片]' } @@ -2356,7 +2412,7 @@ class ExportService { } const textHtml = textContent - ? `
${this.renderMultilineText(textContent)}
` + ? `
${this.renderTextWithEmoji(textContent).replace(/\r?\n/g, '
')}
` : '' const senderHtml = isGroup ? `
${this.escapeHtml(senderName)}
` @@ -2582,11 +2638,22 @@ class ExportService { word-break: break-word; } + .inline-emoji { + width: 22px; + height: 22px; + vertical-align: text-bottom; + margin: 0 2px; + } + .message-media { border-radius: 14px; max-width: 100%; } + .previewable { + cursor: zoom-in; + } + .message-media.image, .message-media.emoji { max-height: 260px; @@ -2633,6 +2700,8 @@ class ExportService { border-radius: 18px; box-shadow: 0 20px 40px rgba(0, 0, 0, 0.35); background: #0f172a; + transition: transform 0.1s ease; + cursor: zoom-out; } body[data-theme="cloud-dancer"] { @@ -2741,6 +2810,7 @@ class ExportService { const themeSelect = document.getElementById('themeSelect') const imagePreview = document.getElementById('imagePreview') const imagePreviewTarget = document.getElementById('imagePreviewTarget') + let imageZoom = 1 const updateCount = () => { const visible = messages.filter((msg) => !msg.classList.contains('hidden')) @@ -2794,13 +2864,34 @@ class ExportService { const full = img.getAttribute('data-full') if (!full) return imagePreviewTarget.src = full + imageZoom = 1 + imagePreviewTarget.style.transform = 'scale(1)' imagePreview.classList.add('active') }) }) + imagePreviewTarget.addEventListener('click', (event) => { + event.stopPropagation() + }) + + imagePreviewTarget.addEventListener('dblclick', (event) => { + event.stopPropagation() + imageZoom = 1 + imagePreviewTarget.style.transform = 'scale(1)' + }) + + imagePreviewTarget.addEventListener('wheel', (event) => { + event.preventDefault() + const delta = event.deltaY > 0 ? -0.1 : 0.1 + imageZoom = Math.min(3, Math.max(0.5, imageZoom + delta)) + imagePreviewTarget.style.transform = \`scale(\${imageZoom})\` + }, { passive: false }) + imagePreview.addEventListener('click', () => { imagePreview.classList.remove('active') imagePreviewTarget.src = '' + imageZoom = 1 + imagePreviewTarget.style.transform = 'scale(1)' }) updateCount()