diff --git a/package-lock.json b/package-lock.json index 844bf86..34493dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,8 @@ "@radix-ui/react-tooltip": "^1.1.8", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "dom-to-image": "^2.6.0", + "dom-to-image-more": "^3.5.0", "html2canvas": "^1.4.1", "katex": "^0.16.21", "lucide-react": "^0.263.1", @@ -26,6 +28,7 @@ "rehype-katex": "^7.0.1", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", + "sonner": "^2.0.0", "tailwind-merge": "^2.6.0", "tailwind-scrollbar-hide": "^2.0.0", "tailwindcss-animate": "^1.0.7" @@ -2615,6 +2618,16 @@ "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==" }, + "node_modules/dom-to-image": { + "version": "2.6.0", + "resolved": "https://registry.npmmirror.com/dom-to-image/-/dom-to-image-2.6.0.tgz", + "integrity": "sha512-Dt0QdaHmLpjURjU7Tnu3AgYSF2LuOmksSGsUcE6ItvJoCWTBEmiMXcqBdNSAm9+QbbwD7JMoVsuuKX6ZVQv1qA==" + }, + "node_modules/dom-to-image-more": { + "version": "3.5.0", + "resolved": "https://registry.npmmirror.com/dom-to-image-more/-/dom-to-image-more-3.5.0.tgz", + "integrity": "sha512-VF/vwfHsPNMHJb5W/5sAmco3UIlEWSEFLppInQwqwN4joUvBULDwE3CqVcUDkUWleke/nZ5KwIVSrrFlGw7WPA==" + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -5617,6 +5630,15 @@ "node": ">=8" } }, + "node_modules/sonner": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/sonner/-/sonner-2.0.0.tgz", + "integrity": "sha512-3WeSl3WrEdhmdiTniT8JsCiVRVDOdn7k+4MG2drqzSMOeqrExm03HIwDBPKuq52JBqL7wRLG17QBIiSleUbljw==", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/package.json b/package.json index 059eae2..66e8518 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,8 @@ "@radix-ui/react-tooltip": "^1.1.8", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "dom-to-image": "^2.6.0", + "dom-to-image-more": "^3.5.0", "html2canvas": "^1.4.1", "katex": "^0.16.21", "lucide-react": "^0.263.1", @@ -26,6 +28,7 @@ "rehype-katex": "^7.0.1", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", + "sonner": "^2.0.0", "tailwind-merge": "^2.6.0", "tailwind-scrollbar-hide": "^2.0.0", "tailwindcss-animate": "^1.0.7" diff --git a/src/components/ChatUI.tsx b/src/components/ChatUI.tsx index b41ac6d..4081d65 100644 --- a/src/components/ChatUI.tsx +++ b/src/components/ChatUI.tsx @@ -448,8 +448,8 @@ const ChatUI = () => { {/* Main Chat Area */}
- -
+ +
{messages.map((message) => (
diff --git a/src/components/SharePoster.tsx b/src/components/SharePoster.tsx index 8871b4d..1025a76 100644 --- a/src/components/SharePoster.tsx +++ b/src/components/SharePoster.tsx @@ -1,8 +1,9 @@ import React, { useRef, useEffect } from 'react'; -import html2canvas from 'html2canvas'; -import { Dialog, DialogContent } from "@/components/ui/dialog"; +import domtoimage from 'dom-to-image'; +import { Dialog, DialogContent, DialogClose } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Share2, Download } from 'lucide-react'; +import { toast } from 'sonner'; interface SharePosterProps { isOpen: boolean; @@ -22,132 +23,148 @@ export function SharePoster({ isOpen, onClose, chatAreaRef }: SharePosterProps) const generatePoster = async () => { if (!chatAreaRef.current) return; - + await document.fonts.ready; + try { - // 克隆原始聊天区域以保持样式 - const clonedChat = chatAreaRef.current.cloneNode(true) as HTMLElement; - - // 创建临时容器并设置与原始容器相同的样式 - const tempContainer = document.createElement('div'); - tempContainer.style.position = 'absolute'; - tempContainer.style.left = '-9999px'; - tempContainer.style.width = chatAreaRef.current.offsetWidth + 'px'; - document.body.appendChild(tempContainer); - tempContainer.appendChild(clonedChat); + const messageContainer = chatAreaRef.current.querySelector('.space-y-4'); + if (!messageContainer) return; - // 确保所有图片都已加载 - const images = Array.from(clonedChat.getElementsByTagName('img')); - await Promise.all(images.map(img => { - if (img.complete) return Promise.resolve(); - return new Promise((resolve) => { - img.onload = resolve; - img.onerror = resolve; - }); - })); + // 预处理所有图片 + const preloadImages = async () => { + const images = Array.from(messageContainer.getElementsByTagName('img')); + await Promise.all(images.map(async (img) => { + try { + const response = await fetch(img.src, { + mode: 'cors', + credentials: 'omit' + }); + const blob = await response.blob(); + const base64 = await new Promise((resolve) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result); + reader.readAsDataURL(blob); + }); + img.src = base64 as string; + } catch (error) { + console.error('图片预处理失败:', error); + } + })); + }; - const canvas = await html2canvas(clonedChat, { - backgroundColor: '#f3f4f6', - scale: 2, - useCORS: true, // 允许跨域图片 - logging: false, - onclone: (document) => { - // 确保所有样式都被正确应用 - const styles = Array.from(document.styleSheets); - styles.forEach(styleSheet => { - try { - if (styleSheet.cssRules) { - Array.from(styleSheet.cssRules).forEach(rule => { - clonedChat.style.cssText += rule.cssText; - }); - } - } catch (e) { - console.warn('无法访问样式表:', e); - } - }); - } + await preloadImages(); + + const originalScroll = chatAreaRef.current.scrollTop; + chatAreaRef.current.scrollTop = 0; + + const viewportWidth = Math.min(window.innerWidth, document.documentElement.clientWidth); + const extraSpace = 20; + const targetWidth = viewportWidth * 0.95 - (extraSpace * 2); + + const currentWidth = messageContainer.getBoundingClientRect().width; + const scale = targetWidth / currentWidth; + + const currentHeight = messageContainer.scrollHeight; + const adjustedHeight = currentHeight * scale; + + const dataUrl = await domtoimage.toSvg(messageContainer as HTMLElement, { + bgcolor: '#f3f4f6', + scale: 1, // 回到较安全的值 + width: targetWidth + (extraSpace * 2), + height: adjustedHeight + (extraSpace * 2), + style: { + padding: `${extraSpace}px`, + margin: '0', + width: '110%', + height: '110%', + transform: `scale(${scale})`, + transformOrigin: 'top left', + background: '#f3f4f6', + boxSizing: 'border-box' + }, + quality: 1.0 }); - - // 清理临时元素 - document.body.removeChild(tempContainer); - - setPosterImage(canvas.toDataURL('image/png')); + + chatAreaRef.current.scrollTop = originalScroll; + setPosterImage(dataUrl); } catch (error) { console.error('生成海报失败:', error); } }; - const handleShare = async () => { - if (!posterImage) return; - try { - const blob = await fetch(posterImage).then(r => r.blob()); - const filesArray = [ - new File([blob], 'chat-history.png', { type: 'image/png' }) - ]; - - if (navigator.share && navigator.canShare({ files: filesArray })) { - await navigator.share({ - files: filesArray, - title: '聊天记录', - }); - } else { - throw new Error('不支持系统分享'); - } - } catch (error) { - console.error('分享失败:', error); - alert('分享失败,请尝试保存图片'); - } - }; - - const handleDownload = () => { + const handleDownload = async () => { if (!posterImage) return; - const a = document.createElement('a'); - a.href = posterImage; - a.download = 'chat-history.png'; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); + try { + // 创建一个新的图片对象 + const img = new Image(); + img.crossOrigin = 'anonymous'; + + // 等待图片加载完成 + await new Promise((resolve, reject) => { + img.onload = resolve; + img.onerror = reject; + img.src = posterImage; + }); + + // 创建高分辨率canvas + const scale = 2; // 将分辨率提高2倍 + const canvas = document.createElement('canvas'); + canvas.width = img.width * scale; + canvas.height = img.height * scale; + + const ctx = canvas.getContext('2d'); + if (!ctx) { + throw new Error('无法创建canvas上下文'); + } + + // 设置更好的图像渲染质量 + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + + // 按比例缩放绘制 + ctx.scale(scale, scale); + ctx.drawImage(img, 0, 0); + + // 使用更高质量的PNG导出 + const pngUrl = canvas.toDataURL('image/png', 1.0); + const a = document.createElement('a'); + a.href = pngUrl; + a.download = 'chat-history.png'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + } catch (error) { + console.error('转换图片失败:', error); + toast.error('保存图片失败,请重试'); + } }; return ( - -
-
- {posterImage && ( + + {/* 图片容器 */} +
+ {posterImage && ( +
聊天记录 - )} -
- -
- - -
+
+ )} +
+ +
+