feat: close #580 export messages as image

This commit is contained in:
Yidadaa 2023-05-22 00:59:36 +08:00
parent 05b1b8b240
commit 4dad7f2ab6
11 changed files with 934 additions and 54 deletions

View File

@ -7,7 +7,6 @@ import RenameIcon from "../icons/rename.svg";
import ExportIcon from "../icons/share.svg";
import ReturnIcon from "../icons/return.svg";
import CopyIcon from "../icons/copy.svg";
import DownloadIcon from "../icons/download.svg";
import LoadingIcon from "../icons/three-dots.svg";
import PromptIcon from "../icons/prompt.svg";
import MaskIcon from "../icons/mask.svg";
@ -53,7 +52,7 @@ import { IconButton } from "./button";
import styles from "./home.module.scss";
import chatStyle from "./chat.module.scss";
import { ListItem, Modal, showModal, showToast } from "./ui-lib";
import { ListItem, Modal } from "./ui-lib";
import { useLocation, useNavigate } from "react-router-dom";
import { LAST_INPUT_KEY, Path, REQUEST_TIMEOUT_MS } from "../constant";
import { Avatar } from "./emoji";
@ -61,49 +60,12 @@ import { MaskAvatar, MaskConfig } from "./mask";
import { useMaskStore } from "../store/mask";
import { useCommand } from "../command";
import { prettyObject } from "../utils/format";
import { ExportMessageModal } from "./exporter";
const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
loading: () => <LoadingIcon />,
});
function exportMessages(messages: ChatMessage[], topic: string) {
const mdText =
`# ${topic}\n\n` +
messages
.map((m) => {
return m.role === "user"
? `## ${Locale.Export.MessageFromYou}:\n${m.content}`
: `## ${Locale.Export.MessageFromChatGPT}:\n${m.content.trim()}`;
})
.join("\n\n");
const filename = `${topic}.md`;
showModal({
title: Locale.Export.Title,
children: (
<div className="markdown-body">
<pre className={styles["export-content"]}>{mdText}</pre>
</div>
),
actions: [
<IconButton
key="copy"
icon={<CopyIcon />}
bordered
text={Locale.Export.Copy}
onClick={() => copyToClipboard(mdText)}
/>,
<IconButton
key="download"
icon={<DownloadIcon />}
bordered
text={Locale.Export.Download}
onClick={() => downloadAs(mdText, filename)}
/>,
],
});
}
export function SessionConfigModel(props: { onClose: () => void }) {
const chatStore = useChatStore();
const session = chatStore.currentSession();
@ -451,6 +413,8 @@ export function Chat() {
const config = useAppConfig();
const fontSize = config.fontSize;
const [showExport, setShowExport] = useState(false);
const inputRef = useRef<HTMLTextAreaElement>(null);
const [userInput, setUserInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
@ -739,10 +703,7 @@ export function Chat() {
bordered
title={Locale.Chat.Actions.Export}
onClick={() => {
exportMessages(
session.messages.filter((msg) => !msg.isError),
session.topic,
);
setShowExport(true);
}}
/>
</div>
@ -917,6 +878,10 @@ export function Chat() {
/>
</div>
</div>
{showExport && (
<ExportMessageModal onClose={() => setShowExport(false)} />
)}
</div>
);
}

View File

@ -0,0 +1,212 @@
.message-exporter {
&-body {
margin-top: 20px;
}
}
.export-content {
white-space: break-spaces;
padding: 10px !important;
}
.steps {
background-color: var(--gray);
border-radius: 10px;
overflow: hidden;
padding: 5px;
position: relative;
box-shadow: var(--card-shadow) inset;
.steps-progress {
$padding: 5px;
height: calc(100% - 2 * $padding);
width: calc(100% - 2 * $padding);
position: absolute;
top: $padding;
left: $padding;
&-inner {
box-sizing: border-box;
box-shadow: var(--card-shadow);
border: var(--border-in-light);
content: "";
display: inline-block;
width: 0%;
height: 100%;
background-color: var(--white);
transition: all ease 0.3s;
border-radius: 8px;
}
}
.steps-inner {
display: flex;
transform: scale(1);
.step {
flex-grow: 1;
padding: 5px 10px;
font-size: 14px;
color: var(--black);
opacity: 0.5;
transition: all ease 0.3s;
display: flex;
align-items: center;
justify-content: center;
$radius: 8px;
&-finished {
opacity: 0.9;
}
&:hover {
opacity: 0.8;
}
&-current {
color: var(--primary);
}
.step-index {
background-color: var(--gray);
border: var(--border-in-light);
border-radius: 6px;
display: inline-block;
padding: 0px 5px;
font-size: 12px;
margin-right: 8px;
opacity: 0.8;
}
.step-name {
font-size: 12px;
}
}
}
}
.preview-actions {
margin-bottom: 20px;
display: flex;
justify-content: space-between;
button {
flex-grow: 1;
&:not(:last-child) {
margin-right: 10px;
}
}
}
.image-previewer {
.preview-body {
border-radius: 10px;
padding: 20px;
box-shadow: var(--card-shadow) inset;
background-color: var(--gray);
.chat-info {
background-color: var(--second);
padding: 20px;
border-radius: 10px;
margin-bottom: 20px;
display: flex;
justify-content: space-between;
align-items: flex-end;
position: relative;
overflow: hidden;
@media screen and (max-width: 600px) {
flex-direction: column;
align-items: flex-start;
.icons {
margin-bottom: 20px;
}
}
.logo {
position: absolute;
top: 0px;
left: 0px;
transform: scale(2);
}
.main-title {
font-size: 20px;
font-weight: bolder;
}
.sub-title {
font-size: 12px;
}
.icons {
margin-top: 10px;
display: flex;
align-items: center;
.icon-space {
font-size: 12px;
margin: 0 10px;
font-weight: bolder;
color: var(--primary);
}
}
.chat-info-item {
font-size: 12px;
color: var(--primary);
padding: 2px 15px;
border-radius: 10px;
background-color: var(--white);
box-shadow: var(--card-shadow);
&:not(:last-child) {
margin-bottom: 5px;
}
}
}
.message {
margin-bottom: 20px;
display: flex;
.avatar {
margin-right: 10px;
}
.body {
border-radius: 10px;
padding: 8px 10px;
max-width: calc(100% - 104px);
box-shadow: var(--card-shadow);
border: var(--border-in-light);
}
&-assistant {
.body {
background-color: var(--white);
}
}
&-user {
flex-direction: row-reverse;
.avatar {
margin-right: 0;
}
.body {
background-color: var(--second);
margin-right: 10px;
}
}
}
}
.default-theme {
}
}

398
app/components/exporter.tsx Normal file
View File

@ -0,0 +1,398 @@
import { ChatMessage, useAppConfig, useChatStore } from "../store";
import Locale from "../locales";
import styles from "./exporter.module.scss";
import { List, ListItem, Modal, showToast } from "./ui-lib";
import { IconButton } from "./button";
import { copyToClipboard, downloadAs } from "../utils";
import CopyIcon from "../icons/copy.svg";
import LoadingIcon from "../icons/three-dots.svg";
import ChatGptIcon from "../icons/chatgpt.svg";
import ShareIcon from "../icons/share.svg";
import DownloadIcon from "../icons/download.svg";
import { useMemo, useRef, useState } from "react";
import { MessageSelector, useMessageSelector } from "./message-selector";
import { Avatar } from "./emoji";
import { MaskAvatar } from "./mask";
import dynamic from "next/dynamic";
import { toBlob, toPng } from "html-to-image";
const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
loading: () => <LoadingIcon />,
});
export function ExportMessageModal(props: { onClose: () => void }) {
return (
<div className="modal-mask">
<Modal title={Locale.Export.Title} onClose={props.onClose}>
<div style={{ minHeight: "40vh" }}>
<MessageExporter />
</div>
</Modal>
</div>
);
}
function useSteps(
steps: Array<{
name: string;
value: string;
}>,
) {
const stepCount = steps.length;
const [currentStepIndex, setCurrentStepIndex] = useState(0);
const nextStep = () =>
setCurrentStepIndex((currentStepIndex + 1) % stepCount);
const prevStep = () =>
setCurrentStepIndex((currentStepIndex - 1 + stepCount) % stepCount);
return {
currentStepIndex,
setCurrentStepIndex,
nextStep,
prevStep,
currentStep: steps[currentStepIndex],
};
}
function Steps<
T extends {
name: string;
value: string;
}[],
>(props: { steps: T; onStepChange?: (index: number) => void; index: number }) {
const steps = props.steps;
const stepCount = steps.length;
return (
<div className={styles["steps"]}>
<div className={styles["steps-progress"]}>
<div
className={styles["steps-progress-inner"]}
style={{
width: `${((props.index + 1) / stepCount) * 100}%`,
}}
></div>
</div>
<div className={styles["steps-inner"]}>
{steps.map((step, i) => {
return (
<div
key={i}
className={`${styles["step"]} ${
styles[i <= props.index ? "step-finished" : ""]
} ${i === props.index && styles["step-current"]} clickable`}
onClick={() => {
props.onStepChange?.(i);
}}
role="button"
>
<span className={styles["step-index"]}>{i + 1}</span>
<span className={styles["step-name"]}>{step.name}</span>
</div>
);
})}
</div>
</div>
);
}
export function MessageExporter() {
const steps = [
{
name: Locale.Export.Steps.Select,
value: "select",
},
{
name: Locale.Export.Steps.Preview,
value: "preview",
},
];
const { currentStep, setCurrentStepIndex, currentStepIndex } =
useSteps(steps);
const formats = ["text", "image"] as const;
type ExportFormat = (typeof formats)[number];
const [exportConfig, setExportConfig] = useState({
format: "image" as ExportFormat,
includeContext: true,
});
function updateExportConfig(updater: (config: typeof exportConfig) => void) {
const config = { ...exportConfig };
updater(config);
setExportConfig(config);
}
const chatStore = useChatStore();
const session = chatStore.currentSession();
const { selection, updateSelection } = useMessageSelector();
const selectedMessages = useMemo(() => {
const ret: ChatMessage[] = [];
if (exportConfig.includeContext) {
ret.push(...session.mask.context);
}
ret.push(...session.messages.filter((m, i) => selection.has(m.id ?? i)));
return ret;
}, [
exportConfig.includeContext,
session.messages,
session.mask.context,
selection,
]);
return (
<>
<Steps
steps={steps}
index={currentStepIndex}
onStepChange={setCurrentStepIndex}
/>
<div className={styles["message-exporter-body"]}>
{currentStep.value === "select" && (
<>
<List>
<ListItem
title={Locale.Export.Format.Title}
subTitle={Locale.Export.Format.SubTitle}
>
<select
value={exportConfig.format}
onChange={(e) =>
updateExportConfig(
(config) =>
(config.format = e.currentTarget.value as ExportFormat),
)
}
>
{formats.map((f) => (
<option key={f} value={f}>
{f}
</option>
))}
</select>
</ListItem>
<ListItem
title={Locale.Export.IncludeContext.Title}
subTitle={Locale.Export.IncludeContext.SubTitle}
>
<input
type="checkbox"
checked={exportConfig.includeContext}
onChange={(e) => {
updateExportConfig(
(config) =>
(config.includeContext = e.currentTarget.checked),
);
}}
></input>
</ListItem>
</List>
<MessageSelector
selection={selection}
updateSelection={updateSelection}
defaultSelectAll
/>
</>
)}
{currentStep.value === "preview" && (
<>
{exportConfig.format === "text" ? (
<MarkdownPreviewer
messages={selectedMessages}
topic={session.topic}
/>
) : (
<ImagePreviewer
messages={selectedMessages}
topic={session.topic}
/>
)}
</>
)}
</div>
</>
);
}
export function PreviewActions(props: {
download: () => void;
copy: () => void;
}) {
return (
<div className={styles["preview-actions"]}>
<IconButton
text={Locale.Export.Copy}
bordered
shadow
icon={<CopyIcon />}
onClick={props.copy}
></IconButton>
<IconButton
text={Locale.Export.Download}
bordered
shadow
icon={<DownloadIcon />}
onClick={props.download}
></IconButton>
<IconButton
text={Locale.Export.Share}
bordered
shadow
icon={<ShareIcon />}
onClick={() => showToast(Locale.WIP)}
></IconButton>
</div>
);
}
export function ImagePreviewer(props: {
messages: ChatMessage[];
topic: string;
}) {
const chatStore = useChatStore();
const session = chatStore.currentSession();
const mask = session.mask;
const config = useAppConfig();
const previewRef = useRef<HTMLDivElement>(null);
const copy = () => {
const dom = previewRef.current;
if (!dom) return;
toBlob(dom).then((blob) => {
if (!blob) return;
try {
navigator.clipboard
.write([
new ClipboardItem({
"image/png": blob,
}),
])
.then(() => {
showToast(Locale.Copy.Success);
});
} catch (e) {
console.error("[Copy Image] ", e);
showToast(Locale.Copy.Failed);
}
});
};
const download = () => {
const dom = previewRef.current;
if (!dom) return;
toPng(dom)
.then((blob) => {
if (!blob) return;
const link = document.createElement("a");
link.download = `${props.topic}.png`;
link.href = blob;
link.click();
})
.catch((e) => console.log("[Export Image] ", e));
};
return (
<div className={styles["image-previewer"]}>
<PreviewActions copy={copy} download={download} />
<div
className={`${styles["preview-body"]} ${styles["default-theme"]}`}
ref={previewRef}
>
<div className={styles["chat-info"]}>
<div className={styles["logo"] + " no-dark"}>
<ChatGptIcon />
</div>
<div>
<div className={styles["main-title"]}>ChatGPT Next Web</div>
<div className={styles["sub-title"]}>
github.com/Yidadaa/ChatGPT-Next-Web
</div>
<div className={styles["icons"]}>
<Avatar avatar={config.avatar}></Avatar>
<span className={styles["icon-space"]}>&</span>
<MaskAvatar mask={session.mask} />
</div>
</div>
<div>
<div className={styles["chat-info-item"]}>
Model: {mask.modelConfig.model}
</div>
<div className={styles["chat-info-item"]}>
Messages: {props.messages.length}
</div>
<div className={styles["chat-info-item"]}>
Topic: {session.topic}
</div>
<div className={styles["chat-info-item"]}>
Time:{" "}
{new Date(
props.messages.at(-1)?.date ?? Date.now(),
).toLocaleString()}
</div>
</div>
</div>
{props.messages.map((m, i) => {
return (
<div
className={styles["message"] + " " + styles["message-" + m.role]}
key={i}
>
<div className={styles["avatar"]}>
{m.role === "user" ? (
<Avatar avatar={config.avatar}></Avatar>
) : (
<MaskAvatar mask={session.mask} />
)}
</div>
<div className={`${styles["body"]} `}>
<Markdown
content={m.content}
fontSize={config.fontSize}
defaultShow
/>
</div>
</div>
);
})}
</div>
</div>
);
}
export function MarkdownPreviewer(props: {
messages: ChatMessage[];
topic: string;
}) {
const mdText =
`# ${props.topic}\n\n` +
props.messages
.map((m) => {
return m.role === "user"
? `## ${Locale.Export.MessageFromYou}:\n${m.content}`
: `## ${Locale.Export.MessageFromChatGPT}:\n${m.content.trim()}`;
})
.join("\n\n");
const copy = () => {
copyToClipboard(mdText);
};
const download = () => {
downloadAs(mdText, `${props.topic}.md`);
};
return (
<>
<PreviewActions copy={copy} download={download} />
<div className="markdown-body">
<pre className={styles["export-content"]}>{mdText}</pre>
</div>
</>
);
}

View File

@ -558,11 +558,6 @@
}
}
.export-content {
white-space: break-spaces;
padding: 10px !important;
}
.loading-content {
display: flex;
flex-direction: column;

View File

@ -121,7 +121,7 @@ export function Markdown(
content: string;
loading?: boolean;
fontSize?: number;
parentRef: RefObject<HTMLDivElement>;
parentRef?: RefObject<HTMLDivElement>;
defaultShow?: boolean;
} & React.DOMAttributes<HTMLDivElement>,
) {
@ -129,7 +129,7 @@ export function Markdown(
const renderedHeight = useRef(0);
const inView = useRef(!!props.defaultShow);
const parent = props.parentRef.current;
const parent = props.parentRef?.current;
const md = mdRef.current;
const checkInView = () => {

View File

@ -0,0 +1,55 @@
.message-selector {
.message-filter {
display: flex;
.search-bar {
max-width: unset;
flex-grow: 1;
}
.filter-item:not(:last-child) {
margin-right: 10px;
}
}
.messages {
margin-top: 20px;
border-radius: 10px;
border: var(--border-in-light);
overflow: hidden;
.message {
display: flex;
align-items: center;
padding: 8px 10px;
cursor: pointer;
&-selected {
background-color: var(--second);
}
&:not(:last-child) {
border-bottom: var(--border-in-light);
}
.avatar {
margin-right: 10px;
}
.body {
flex-grow: 1;
max-width: calc(100% - 40px);
.date {
font-size: 12px;
line-height: 1.2;
opacity: 0.5;
}
.content {
font-size: 12px;
}
}
}
}
}

View File

@ -0,0 +1,211 @@
import { useEffect, useState } from "react";
import { ChatMessage, useAppConfig, useChatStore } from "../store";
import { Updater } from "../typing";
import { IconButton } from "./button";
import { Avatar } from "./emoji";
import { MaskAvatar } from "./mask";
import Locale from "../locales";
import styles from "./message-selector.module.scss";
function useShiftRange() {
const [startIndex, setStartIndex] = useState<number>();
const [endIndex, setEndIndex] = useState<number>();
const [shiftDown, setShiftDown] = useState(false);
const onClickIndex = (index: number) => {
if (shiftDown && startIndex !== undefined) {
setEndIndex(index);
} else {
setStartIndex(index);
setEndIndex(undefined);
}
};
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== "Shift") return;
setShiftDown(true);
};
const onKeyUp = (e: KeyboardEvent) => {
if (e.key !== "Shift") return;
setShiftDown(false);
setStartIndex(undefined);
setEndIndex(undefined);
};
window.addEventListener("keyup", onKeyUp);
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("keyup", onKeyUp);
window.removeEventListener("keydown", onKeyDown);
};
}, []);
return {
onClickIndex,
startIndex,
endIndex,
};
}
export function useMessageSelector() {
const [selection, setSelection] = useState(new Set<number>());
const updateSelection: Updater<Set<number>> = (updater) => {
const newSelection = new Set<number>(selection);
updater(newSelection);
setSelection(newSelection);
};
return {
selection,
updateSelection,
};
}
export function MessageSelector(props: {
selection: Set<number>;
updateSelection: Updater<Set<number>>;
defaultSelectAll?: boolean;
onSelected?: (messages: ChatMessage[]) => void;
}) {
const chatStore = useChatStore();
const session = chatStore.currentSession();
const isValid = (m: ChatMessage) => m.content && !m.isError && !m.streaming;
const messages = session.messages.filter(
(m, i) =>
m.id && // messsage must has id
isValid(m) &&
(i >= session.messages.length - 1 || isValid(session.messages[i + 1])),
);
const messageCount = messages.length;
const config = useAppConfig();
const [searchInput, setSearchInput] = useState("");
const [searchIds, setSearchIds] = useState(new Set<number>());
const isInSearchResult = (id: number) => {
return searchInput.length === 0 || searchIds.has(id);
};
const doSearch = (text: string) => {
const searchResuts = new Set<number>();
if (text.length > 0) {
messages.forEach((m) =>
m.content.includes(text) ? searchResuts.add(m.id!) : null,
);
}
setSearchIds(searchResuts);
};
// for range selection
const { startIndex, endIndex, onClickIndex } = useShiftRange();
const selectAll = () => {
props.updateSelection((selection) =>
messages.forEach((m) => selection.add(m.id!)),
);
};
useEffect(() => {
if (props.defaultSelectAll) {
selectAll();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (startIndex === undefined || endIndex === undefined) {
return;
}
const [start, end] = [startIndex, endIndex].sort((a, b) => a - b);
props.updateSelection((selection) => {
for (let i = start; i <= end; i += 1) {
selection.add(messages[i].id ?? i);
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [startIndex, endIndex]);
return (
<div className={styles["message-selector"]}>
<div className={styles["message-filter"]}>
<input
type="text"
placeholder={Locale.Select.Search}
className={styles["filter-item"] + " " + styles["search-bar"]}
value={searchInput}
onInput={(e) => {
setSearchInput(e.currentTarget.value);
doSearch(e.currentTarget.value);
}}
></input>
<IconButton
text={Locale.Select.All}
bordered
className={styles["filter-item"]}
onClick={selectAll}
/>
<IconButton
text={Locale.Select.Latest}
bordered
className={styles["filter-item"]}
onClick={() =>
props.updateSelection((selection) => {
selection.clear();
messages
.slice(messageCount - 10)
.forEach((m) => selection.add(m.id!));
})
}
/>
<IconButton
text={Locale.Select.Clear}
bordered
className={styles["filter-item"]}
onClick={() =>
props.updateSelection((selection) => selection.clear())
}
/>
</div>
<div className={styles["messages"]}>
{messages.map((m, i) => {
if (!isInSearchResult(m.id!)) return null;
return (
<div
className={`${styles["message"]} ${
props.selection.has(m.id!) && styles["message-selected"]
}`}
key={i}
onClick={() => {
props.updateSelection((selection) => {
const id = m.id ?? i;
selection.has(id) ? selection.delete(id) : selection.add(id);
});
onClickIndex(i);
}}
>
<div className={styles["avatar"]}>
{m.role === "user" ? (
<Avatar avatar={config.avatar}></Avatar>
) : (
<MaskAvatar mask={session.mask} />
)}
</div>
<div className={styles["body"]}>
<div className={styles["date"]}>
{new Date(m.date).toLocaleString()}
</div>
<div className={`${styles["content"]} one-line`}>
{m.content}
</div>
</div>
</div>
);
})}
</div>
</div>
);
}

View File

@ -36,11 +36,30 @@ const cn = {
},
},
Export: {
Title: "导出聊天记录为 Markdown",
Title: "分享聊天记录",
Copy: "全部复制",
Download: "下载文件",
Share: "分享到 ShareGPT",
MessageFromYou: "来自你的消息",
MessageFromChatGPT: "来自 ChatGPT 的消息",
Format: {
Title: "导出格式",
SubTitle: "可以导出 Markdown 文本或者 PNG 图片",
},
IncludeContext: {
Title: "包含面具上下文",
SubTitle: "是否在消息中展示面具上下文",
},
Steps: {
Select: "选取",
Preview: "预览",
},
},
Select: {
Search: "搜索消息",
All: "选取全部",
Latest: "最近十条",
Clear: "清除选中",
},
Memory: {
Title: "历史摘要",

View File

@ -37,11 +37,30 @@ const en: RequiredLocaleType = {
},
},
Export: {
Title: "All Messages",
Title: "Export Messages",
Copy: "Copy All",
Download: "Download",
MessageFromYou: "Message From You",
MessageFromChatGPT: "Message From ChatGPT",
Share: "Share to ShareGPT",
Format: {
Title: "Export Format",
SubTitle: "Markdown or PNG Image",
},
IncludeContext: {
Title: "Including Context",
SubTitle: "Export context prompts in mask or not",
},
Steps: {
Select: "Select",
Preview: "Preview",
},
},
Select: {
Search: "Search",
All: "Select All",
Latest: "Select Latest",
Clear: "Clear",
},
Memory: {
Title: "Memory Prompt",

View File

@ -13,12 +13,13 @@
"proxy-dev": "sh ./scripts/init-proxy.sh && proxychains -f ./scripts/proxychains.conf yarn dev"
},
"dependencies": {
"@hello-pangea/dnd": "^16.2.0",
"@fortaine/fetch-event-source": "^3.0.6",
"@hello-pangea/dnd": "^16.2.0",
"@svgr/webpack": "^6.5.1",
"@vercel/analytics": "^0.1.11",
"emoji-picker-react": "^4.4.7",
"fuse.js": "^6.6.2",
"html-to-image": "^1.11.11",
"mermaid": "^10.1.0",
"next": "^13.4.3",
"node-fetch": "^3.3.1",

View File

@ -3215,6 +3215,11 @@ hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2:
dependencies:
react-is "^16.7.0"
html-to-image@^1.11.11:
version "1.11.11"
resolved "https://registry.npmmirror.com/html-to-image/-/html-to-image-1.11.11.tgz#c0f8a34dc9e4b97b93ff7ea286eb8562642ebbea"
integrity sha512-9gux8QhvjRO/erSnDPv28noDZcPZmYE7e1vFsBLKLlRlKDSqNJYebj6Qz1TGd5lsRV+X+xYyjCKjuZdABinWjA==
human-signals@^4.3.0:
version "4.3.1"
resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-4.3.1.tgz#ab7f811e851fca97ffbd2c1fe9a958964de321b2"