feat: support generate image and save image

This commit is contained in:
Zhang Minghan 2024-02-14 13:05:45 +08:00
parent 304e071718
commit b444748113
15 changed files with 1355 additions and 19 deletions

View File

@ -10,10 +10,6 @@
<meta name="author" content="Deeptrain Team">
<meta name="theme-color" content="#000000">
<meta itemprop="image" content="/favicon.ico">
<meta name="baidu-site-verification" content="codeva-TJkbi40ZBi" />
<link href="https://open.lightxi.com/fonts/Andika" rel="stylesheet">
<link href="https://open.lightxi.com/fonts/Jetbrains-Mono" rel="stylesheet">
<link href="https://cdn.bootcdn.net/ajax/libs/KaTeX/0.16.0/katex.min.css" rel="stylesheet">
<link rel="manifest" href="/site.webmanifest">
<script src="/workbox.js" defer></script>
</head>

View File

@ -39,6 +39,7 @@
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"cmdk": "^0.2.0",
"html-to-image": "^1.11.11",
"i18next": "^23.4.6",
"localforage": "^1.10.0",
"lucide-react": "^0.309.0",

7
app/pnpm-lock.yaml generated
View File

@ -86,6 +86,9 @@ dependencies:
cmdk:
specifier: ^0.2.0
version: 0.2.0(@types/react@18.2.33)(react-dom@18.2.0)(react@18.2.0)
html-to-image:
specifier: ^1.11.11
version: 1.11.11
i18next:
specifier: ^23.4.6
version: 23.6.0
@ -3683,6 +3686,10 @@ packages:
void-elements: 3.1.0
dev: false
/html-to-image@1.11.11:
resolution: {integrity: sha512-9gux8QhvjRO/erSnDPv28noDZcPZmYE7e1vFsBLKLlRlKDSqNJYebj6Qz1TGd5lsRV+X+xYyjCKjuZdABinWjA==}
dev: false
/html-void-elements@3.0.0:
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
dev: false

View File

@ -0,0 +1,2 @@
@import "common";
@import "katex";

View File

@ -0,0 +1,14 @@
/* Copyright Jetbrains Mono https://www.jetbrains.com/lp/mono/ */
@font-face {
font-family: "JetBrains Mono";
src: url(https://open.lightxi.com/gstatic/JetBrainsMono-Regular.woff2) format("woff2");
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'Andika';
font-style: normal;
font-weight: 400;
src: url(https://open.lightxi.com/gstatic/s/andika/v25/mem_Ya6iyW-LwqgwarYV.ttf) format('truetype');
}

File diff suppressed because it is too large Load Diff

View File

@ -42,6 +42,102 @@
}
}
.sharing-screenshot {
position: absolute;
z-index: -999;
height: max-content;
width: 100vw;
background: hsl(var(--background));
.shot-body {
width: 100%;
height: max-content;
padding: 1.5rem;
.shot-wrapper {
border: 1px solid hsl(var(--border));
border-radius: var(--radius);
overflow: hidden;
.shot-header {
display: flex;
flex-direction: row;
border-bottom: 1px solid hsl(var(--border));
width: 100%;
padding: 1rem;
font-family: var(--font-family) !important;
.shot-column {
width: max-content;
}
.shot-icon {
width: 1rem;
height: 1rem;
margin-right: 0.3rem;
transform: translateY(1px);
}
.shot-avatar {
width: 1.25rem !important;
height: 1.25rem !important;
border-radius: 4px !important;
transform: translateY(1px);
margin-right: 0.25rem;
p {
font-size: 0.6rem;
}
}
.shot-label {
font-size: 0.95rem;
color: hsl(var(--text-secondary));
margin-right: 0.25rem;
user-select: none;
white-space: nowrap;
font-family: var(--font-family) !important;
}
.shot-value {
font-size: 1rem;
font-weight: bold;
margin: 0 0.25rem;
white-space: nowrap;
font-family: var(--font-family) !important;
}
.shot-row {
display: flex;
flex-direction: row;
align-items: center;
width: 100%;
border: 1px solid hsl(var(--border));
border-radius: var(--radius);
padding: 0.35rem 0.75rem;
margin-bottom: 0.5rem;
&:last-child {
margin-bottom: 0;
}
}
}
.shot-content {
padding: 2rem;
& > * {
margin-bottom: 0.5rem;
&:last-child {
margin-bottom: 0;
}
}
}
}
}
}
.sharing-container {
position: relative;
display: flex;
@ -53,6 +149,7 @@
border-radius: var(--radius);
width: 80vw;
height: 70vh;
background: hsl(var(--background));
.header {
display: flex;

View File

@ -1,3 +1,5 @@
@import "fonts/all";
.gold-text {
color: hsl(var(--gold)) !important;
}

View File

@ -29,7 +29,7 @@ function Avatar({ username, ...props }: AvatarProps) {
return useDeeptrain ? (
<img {...props} src={`${deeptrainApiEndpoint}/avatar/${username}`} alt="" />
) : (
<div {...props} className={cn("avatar", background)}>
<div {...props} className={cn("avatar", background, props.className)}>
<p className={`text-white`}>{code}</p>
</div>
);

View File

@ -144,12 +144,24 @@
"message": {
"copy": "复制消息",
"save": "保存为文件",
"save-image": "保存图片",
"use": "使用消息",
"edit": "编辑消息",
"stop": "暂停回答",
"remove": "删除消息",
"restart": "重新回答",
"copy-area": "复制选中区域"
"copy-area": "复制选中区域",
"saving-image-prompt": "图片生成中",
"saving-image-prompt-desc": "正在生成图片中,请稍等...",
"saving-image-failed": "图片生成失败",
"saving-image-failed-prompt": "图片生成失败,原因:{{reason}}",
"saving-image-success": "图片生成成功",
"saving-image-success-prompt": "图片已成功保存。",
"sharing": {
"title": "标题",
"time": "时间",
"message": "消息"
}
},
"quota-description": "消息的点数支出",
"buy": {

View File

@ -97,7 +97,19 @@
"restart": "Restart Answer",
"copy-area": "Copy Selected Area",
"edit": "Edit messages",
"remove": "Delete a Message"
"remove": "Delete a Message",
"save-image": "Save image",
"saving-image-prompt": "Image Generation in Progress",
"saving-image-prompt-desc": "Generating image, please wait...",
"saving-image-failed": "Image generation failed",
"saving-image-failed-prompt": "Image generation failed for {{reason}}",
"saving-image-success": "Image generated successfully",
"saving-image-success-prompt": "Picture saved successfully.",
"sharing": {
"title": "Title",
"time": "Time",
"message": "NO NAME SPACE NO KEY VALUE!!"
}
},
"quota-description": "spending quota for the message",
"buy": {

View File

@ -97,7 +97,19 @@
"restart": "再回答",
"copy-area": "選択を複製",
"edit": "メッセージを編集",
"remove": "メッセージを削除"
"remove": "メッセージを削除",
"save-image": "写真を保存",
"saving-image-prompt": "画像生成中",
"saving-image-prompt-desc": "画像を生成しています。しばらくお待ちください...",
"saving-image-failed": "画像の生成に失敗しました",
"saving-image-failed-prompt": "{{reason}}のために画像生成に失敗しました",
"saving-image-success": "画像が正常に生成されました",
"saving-image-success-prompt": "画像が正常に保存されました。",
"sharing": {
"title": "タイトル",
"time": "期日",
"message": "メッセージ"
}
},
"quota-description": "メッセージのクレジット使用額",
"buy": {

View File

@ -97,7 +97,19 @@
"restart": "Перезапустить ответ",
"copy-area": "Копировать выбранную область",
"edit": "Редактировать сообщение",
"remove": "Удалить сообщение"
"remove": "Удалить сообщение",
"save-image": "Сохранить",
"saving-image-prompt": "Выполняется создание изображения",
"saving-image-prompt-desc": "Создание изображения, подождите...",
"saving-image-failed": "Не удалось создать изображение",
"saving-image-failed-prompt": "Не удалось создать изображение по {{reason}}",
"saving-image-success": "Изображение успешно сгенерировано",
"saving-image-success-prompt": "Изображение успешно сохранено.",
"sharing": {
"title": "заглавие",
"time": "Время",
"message": "Сообщения"
}
},
"quota-description": "квота расходов на сообщение",
"buy": {

View File

@ -1,10 +1,18 @@
import "@/assets/pages/sharing.less";
import { useParams } from "react-router-dom";
import { viewConversation, ViewData, ViewForm } from "@/api/sharing.ts";
import { copyClipboard, saveAsFile } from "@/utils/dom.ts";
import { copyClipboard, saveImageAsFile } from "@/utils/dom.ts";
import { useEffectAsync } from "@/utils/hook.ts";
import { useState } from "react";
import { Copy, File, HelpCircle, Loader2, MessagesSquare } from "lucide-react";
import { useRef, useState } from "react";
import {
Clock,
Copy,
HelpCircle,
Image,
Loader2,
MessagesSquare,
Newspaper,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import MessageSegment from "@/components/Message.tsx";
import { Button } from "@/components/ui/button.tsx";
@ -13,6 +21,9 @@ import { useToast } from "@/components/ui/use-toast.ts";
import { sharingEvent } from "@/events/sharing.ts";
import { Message } from "@/api/types.ts";
import Avatar from "@/components/Avatar.tsx";
import { toJpeg } from "html-to-image";
import { appLogo } from "@/conf/env.ts";
import { extractMessage } from "@/utils/processor.ts";
type SharingFormProps = {
refer?: string;
@ -21,16 +32,84 @@ type SharingFormProps = {
function SharingForm({ refer, data }: SharingFormProps) {
if (data === null) return null;
const { t } = useTranslation();
const { toast } = useToast();
const container = useRef<HTMLDivElement>(null);
const date = new Date(data.time);
const time = `${
date.getMonth() + 1
}-${date.getDate()} ${date.getHours()}:${date.getMinutes()}`;
const value = JSON.stringify(data, null, 2);
const { toast } = useToast();
const saveImage = async () => {
toast({
title: t("message.saving-image-prompt"),
description: t("message.saving-image-prompt-desc"),
});
setTimeout(() => {
if (!container.current) return;
toJpeg(container.current)
.then((blob) => {
saveImageAsFile(`${extractMessage(data.name, 12)}.png`, blob);
toast({
title: t("message.saving-image-success"),
description: t("message.saving-image-success-prompt"),
});
})
.catch((reason) => {
toast({
title: t("message.saving-image-failed"),
description: t("message.saving-image-failed-prompt", { reason }),
});
});
}, 10);
};
return (
<div className={`sharing-container`}>
<div className={`sharing-screenshot`}>
<div className={`shot-body`} ref={container}>
<div className={`shot-wrapper`}>
<div className={`shot-header`}>
<div className={`shot-column`}>
<div className={`shot-row`}>
<Newspaper className={`shot-icon`} />
<p className={`shot-label`}>{t("message.sharing.title")}</p>
<div className={`grow`} />
<p className={`shot-value`}>{data.name}</p>
</div>
<div className={`shot-row`}>
<Clock className={`shot-icon`} />
<p className={`shot-label`}>{t("message.sharing.time")}</p>
<div className={`grow`} />
<p className={`shot-value`}>{time}</p>
</div>
<div className={`shot-row`}>
<MessagesSquare className={`shot-icon`} />
<p className={`shot-label`}>{t("message.sharing.message")}</p>
<div className={`grow`} />
<p className={`shot-value`}>{data.messages.length}</p>
</div>
</div>
<div className={`grow`} />
<div className={`shot-column`}>
<img className={`w-12 h-12 m-4`} src={appLogo} alt={""} />
<div className={`shot-row`}>
<Avatar username={data.username} className={`shot-avatar`} />
<p className={`shot-value`}>{data.username}</p>
</div>
</div>
</div>
<div className={`shot-content`}>
{data.messages.map((message, i) => (
<MessageSegment message={message} key={i} index={i} />
))}
</div>
</div>
</div>
</div>
<div className={`header`}>
<div className={`user`}>
<Avatar username={data.username} />
@ -57,12 +136,9 @@ function SharingForm({ refer, data }: SharingFormProps) {
<Copy className={`h-4 w-4 mr-2`} />
{t("message.copy")}
</Button>
<Button
variant={`outline`}
onClick={() => saveAsFile("conversation.json", value)}
>
<File className={`h-4 w-4 mr-2`} />
{t("message.save")}
<Button variant={`outline`} onClick={saveImage}>
<Image className={`h-4 w-4 mr-2`} />
{t("message.save-image")}
</Button>
<Button
variant={`outline`}

View File

@ -60,6 +60,21 @@ export function saveBlobAsFile(filename: string, blob: Blob) {
a.click();
}
export function saveImageAsFile(filename: string, data_url: string) {
/**
* Save data url as image file
* @param filename Filename
* @param data_url Data url
* @example
* saveImageAsFile("hello.png", "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAABwElEQVRIS+2VwQ3CMBBF/4f7B0Qf4B9
*/
const a = document.createElement("a");
a.href = data_url;
a.download = filename;
a.click();
}
export function getSelectionText(): string {
/**
* Get selected text