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()
+ }
}
]
}