From aacade6c84d44bf9951fd69f3486e18528cf1849 Mon Sep 17 00:00:00 2001 From: n4ze3m Date: Sat, 18 Jan 2025 17:13:20 +0530 Subject: [PATCH] feat: Add option to download chat as an image --- src/assets/locale/en/option.json | 3 +- src/components/Layouts/MoreOptions.tsx | 181 ++++++++++++++++++++++++- 2 files changed, 177 insertions(+), 7 deletions(-) diff --git a/src/assets/locale/en/option.json b/src/assets/locale/en/option.json index debb1be..95b89a3 100644 --- a/src/assets/locale/en/option.json +++ b/src/assets/locale/en/option.json @@ -21,7 +21,8 @@ "group": "Download", "text": "Text File (.txt)", "markdown": "Markdown (.md)", - "json": "JSON File (.json)" + "json": "JSON File (.json)", + "image": "Image (.png)" }, "share": "Share" } diff --git a/src/components/Layouts/MoreOptions.tsx b/src/components/Layouts/MoreOptions.tsx index fb18df5..26fea23 100644 --- a/src/components/Layouts/MoreOptions.tsx +++ b/src/components/Layouts/MoreOptions.tsx @@ -3,7 +3,8 @@ import { FileText, Share2, FileJson, - FileCode + FileCode, + ImageIcon } from "lucide-react" import { Dropdown, MenuProps, message } from "antd" import { Message } from "@/types/message" @@ -55,6 +56,162 @@ const downloadFile = (content: string, filename: string) => { URL.revokeObjectURL(url) } +const generateChatImage = async (messages: Message[]) => { + const canvas = document.createElement("canvas") + const ctx = canvas.getContext("2d")! + + canvas.width = 1200 + const padding = 40 + let yPosition = padding + + const wrapText = (text: string, maxWidth: number) => { + const paragraphs = text.split("\n") + const lines = [] + + paragraphs.forEach((paragraph) => { + if (paragraph.length === 0) { + lines.push("") + return + } + + const words = paragraph.split(" ") + let currentLine = words[0] + + for (let i = 1; i < words.length; i++) { + const word = words[i] + const width = ctx.measureText(currentLine + " " + word).width + if (width < maxWidth) { + currentLine += " " + word + } else { + lines.push(currentLine) + currentLine = word + } + } + lines.push(currentLine) + }) + + return lines + } + + let totalHeight = padding + messages.forEach((msg) => { + totalHeight += 20 + const maxWidth = canvas.width - padding * 2 + + if (msg.message.includes("```")) { + const blocks = msg.message.split("```") + blocks.forEach((block, index) => { + if (index % 2 === 1) { + const codeLines = block.split("\n") + totalHeight += codeLines.length * 25 + 20 + } else { + const wrappedText = wrapText(block, maxWidth) + totalHeight += wrappedText.length * 25 + } + }) + } else { + const wrappedText = wrapText(msg.message, maxWidth) + totalHeight += wrappedText.length * 25 + } + + if (msg.images?.length) { + totalHeight += msg.images.length * 250 + } + + totalHeight += 30 + + }) + + canvas.height = totalHeight + console.log(totalHeight) + + ctx.fillStyle = "#ffffff" + ctx.fillRect(0, 0, canvas.width, canvas.height) + + const drawText = async () => { + for (const msg of messages) { + ctx.font = "bold 18px Inter, Arial" + ctx.fillStyle = msg.isBot ? "#1A202C" : "#1E4E8C" + ctx.fillText(`${msg.isBot ? msg.name : "You"}:`, padding, yPosition) + yPosition += 35 + + if (msg.message.includes("```")) { + const blocks = msg.message.split("```") + blocks.forEach((block, index) => { + if (index % 2 === 1) { + const codeLines = block.split("\n") + const codeHeight = codeLines.length * 25 + 20 + ctx.fillStyle = "#1a1a1a" + ctx.fillRect( + padding, + yPosition, + canvas.width - padding * 2, + codeHeight + ) + ctx.font = "15px Consolas, monospace" + ctx.fillStyle = "#e6e6e6" + codeLines.forEach((line, lineIndex) => { + ctx.fillText(line, padding + 15, yPosition + 25 + lineIndex * 25) + }) + yPosition += codeHeight + 20 + } else { + ctx.font = "16px Inter, Arial" + ctx.fillStyle = "#1A202C" + const wrappedText = wrapText(block, canvas.width - padding * 2) + wrappedText.forEach((line) => { + ctx.fillText(line, padding, yPosition) + yPosition += 30 + }) + } + }) + } else { + ctx.font = "16px Inter, Arial" + ctx.fillStyle = "#1A202C" + const wrappedText = wrapText(msg.message, canvas.width - padding * 2) + wrappedText.forEach((line) => { + ctx.fillText(line, padding, yPosition) + yPosition += 30 + }) + } + + if (msg.images?.length) { + for (const imgUrl of msg.images) { + if (imgUrl) { + try { + const img = new Image() + img.crossOrigin = "anonymous" + await new Promise((resolve, reject) => { + img.onload = resolve + img.onerror = reject + img.src = imgUrl + }) + + const maxWidth = canvas.width - padding * 2 + const maxHeight = 100 + const scale = Math.min( + maxWidth / img.width, + maxHeight / img.height, + 0.5 + ) + const drawWidth = img.width * scale + const drawHeight = img.height * scale + + ctx.drawImage(img, padding, yPosition + 10, drawWidth, drawHeight) + yPosition += drawHeight + 30 + } catch (e) { + console.warn("Failed to load image:", imgUrl) + } + } + } + } + yPosition += 30 + } + } + + await drawText() + return canvas.toDataURL("image/png") +} + export const MoreOptions = ({ shareModeEnabled = false, historyId, @@ -65,7 +222,7 @@ export const MoreOptions = ({ const baseItems: MenuProps["items"] = [ { type: "group", - label: t("more.copy.group"), + label: t("more.copy.group"), children: [ { key: "copy-text", @@ -73,12 +230,12 @@ export const MoreOptions = ({ icon: , onClick: () => { navigator.clipboard.writeText(formatAsText(messages)) - message.success(t("more.copy.success")) + message.success(t("more.copy.success")) } }, { - key: "copy-markdown", - label: t("more.copy.asMarkdown"), + key: "copy-markdown", + label: t("more.copy.asMarkdown"), icon: , onClick: () => { navigator.clipboard.writeText(formatAsMarkdown(messages)) @@ -92,7 +249,7 @@ export const MoreOptions = ({ }, { type: "group", - label: t("more.download.group"), + label: t("more.download.group"), children: [ { key: "download-txt", @@ -118,6 +275,18 @@ export const MoreOptions = ({ const jsonContent = JSON.stringify(messages, null, 2) downloadFile(jsonContent, "chat.json") } + }, + { + key: "download-image", + label: t("more.download.image"), + icon: , + onClick: async () => { + const dataUrl = await generateChatImage(messages) + const link = document.createElement("a") + link.download = "chat.png" + link.href = dataUrl + link.click() + } } ] }