artifacts.tsx 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  1. import {
  2. useEffect,
  3. useState,
  4. useRef,
  5. useMemo,
  6. forwardRef,
  7. useImperativeHandle,
  8. } from "react";
  9. import { useParams } from "react-router";
  10. import { IconButton } from "./button";
  11. import { nanoid } from "nanoid";
  12. import ExportIcon from "../icons/share.svg";
  13. import CopyIcon from "../icons/copy.svg";
  14. import DownloadIcon from "../icons/download.svg";
  15. import LoadingButtonIcon from "../icons/loading.svg";
  16. import ReloadButtonIcon from "../icons/reload.svg";
  17. import Locale from "../locales";
  18. import { Modal, showToast } from "./ui-lib";
  19. import { copyToClipboard, downloadAs } from "../utils";
  20. import { Path, ApiPath } from "@/app/constant";
  21. import { Loading } from "./home";
  22. import styles from "./artifacts.module.scss";
  23. type HTMLPreviewProps = {
  24. code: string;
  25. autoHeight?: boolean;
  26. height?: number | string;
  27. onLoad?: (title?: string) => void;
  28. };
  29. export type HTMLPreviewHandler = {
  30. reload: () => void;
  31. };
  32. export const HTMLPreview = forwardRef<HTMLPreviewHandler, HTMLPreviewProps>(
  33. function HTMLPreview(props, ref) {
  34. const iframeRef = useRef<HTMLIFrameElement>(null);
  35. const [frameId, setFrameId] = useState<string>(nanoid());
  36. const [iframeHeight, setIframeHeight] = useState(600);
  37. const [title, setTitle] = useState("");
  38. /*
  39. * https://stackoverflow.com/questions/19739001/what-is-the-difference-between-srcdoc-and-src-datatext-html-in-an
  40. * 1. using srcdoc
  41. * 2. using src with dataurl:
  42. * easy to share
  43. * length limit (Data URIs cannot be larger than 32,768 characters.)
  44. */
  45. useEffect(() => {
  46. const handleMessage = (e: any) => {
  47. const { id, height, title } = e.data;
  48. setTitle(title);
  49. if (id == frameId) {
  50. setIframeHeight(height);
  51. }
  52. };
  53. window.addEventListener("message", handleMessage);
  54. return () => {
  55. window.removeEventListener("message", handleMessage);
  56. };
  57. }, [frameId]);
  58. useImperativeHandle(ref, () => ({
  59. reload: () => {
  60. setFrameId(nanoid());
  61. },
  62. }));
  63. const height = useMemo(() => {
  64. if (!props.autoHeight) return props.height || 600;
  65. if (typeof props.height === "string") {
  66. return props.height;
  67. }
  68. const parentHeight = props.height || 600;
  69. return iframeHeight + 40 > parentHeight
  70. ? parentHeight
  71. : iframeHeight + 40;
  72. }, [props.autoHeight, props.height, iframeHeight]);
  73. const srcDoc = useMemo(() => {
  74. const script = `<script>window.addEventListener("DOMContentLoaded", () => new ResizeObserver((entries) => parent.postMessage({id: '${frameId}', height: entries[0].target.clientHeight}, '*')).observe(document.body))</script>`;
  75. if (props.code.includes("<!DOCTYPE html>")) {
  76. props.code.replace("<!DOCTYPE html>", "<!DOCTYPE html>" + script);
  77. }
  78. return script + props.code;
  79. }, [props.code, frameId]);
  80. const handleOnLoad = () => {
  81. if (props?.onLoad) {
  82. props.onLoad(title);
  83. }
  84. };
  85. return (
  86. <iframe
  87. className={styles["artifacts-iframe"]}
  88. key={frameId}
  89. ref={iframeRef}
  90. sandbox="allow-forms allow-modals allow-scripts"
  91. style={{ height }}
  92. srcDoc={srcDoc}
  93. onLoad={handleOnLoad}
  94. />
  95. );
  96. },
  97. );
  98. export function ArtifactsShareButton({
  99. getCode,
  100. id,
  101. style,
  102. fileName,
  103. }: {
  104. getCode: () => string;
  105. id?: string;
  106. style?: any;
  107. fileName?: string;
  108. }) {
  109. const [loading, setLoading] = useState(false);
  110. const [name, setName] = useState(id);
  111. const [show, setShow] = useState(false);
  112. const shareUrl = useMemo(
  113. () => [location.origin, "#", Path.Artifacts, "/", name].join(""),
  114. [name],
  115. );
  116. const upload = (code: string) =>
  117. id
  118. ? Promise.resolve({ id })
  119. : fetch(ApiPath.Artifacts, {
  120. method: "POST",
  121. body: code,
  122. })
  123. .then((res) => res.json())
  124. .then(({ id }) => {
  125. if (id) {
  126. return { id };
  127. }
  128. throw Error();
  129. })
  130. .catch((e) => {
  131. showToast(Locale.Export.Artifacts.Error);
  132. });
  133. return (
  134. <>
  135. <div className="window-action-button" style={style}>
  136. <IconButton
  137. icon={loading ? <LoadingButtonIcon /> : <ExportIcon />}
  138. bordered
  139. title={Locale.Export.Artifacts.Title}
  140. onClick={() => {
  141. if (loading) return;
  142. setLoading(true);
  143. upload(getCode())
  144. .then((res) => {
  145. if (res?.id) {
  146. setShow(true);
  147. setName(res?.id);
  148. }
  149. })
  150. .finally(() => setLoading(false));
  151. }}
  152. />
  153. </div>
  154. {show && (
  155. <div className="modal-mask">
  156. <Modal
  157. title={Locale.Export.Artifacts.Title}
  158. onClose={() => setShow(false)}
  159. actions={[
  160. <IconButton
  161. key="download"
  162. icon={<DownloadIcon />}
  163. bordered
  164. text={Locale.Export.Download}
  165. onClick={() => {
  166. downloadAs(getCode(), `${fileName || name}.html`).then(() =>
  167. setShow(false),
  168. );
  169. }}
  170. />,
  171. <IconButton
  172. key="copy"
  173. icon={<CopyIcon />}
  174. bordered
  175. text={Locale.Chat.Actions.Copy}
  176. onClick={() => {
  177. copyToClipboard(shareUrl).then(() => setShow(false));
  178. }}
  179. />,
  180. ]}
  181. >
  182. <div>
  183. <a target="_blank" href={shareUrl}>
  184. {shareUrl}
  185. </a>
  186. </div>
  187. </Modal>
  188. </div>
  189. )}
  190. </>
  191. );
  192. }
  193. export function Artifacts() {
  194. const { id } = useParams();
  195. const [code, setCode] = useState("");
  196. const [loading, setLoading] = useState(true);
  197. const [fileName, setFileName] = useState("");
  198. const previewRef = useRef<HTMLPreviewHandler>(null);
  199. useEffect(() => {
  200. if (id) {
  201. fetch(`${ApiPath.Artifacts}?id=${id}`)
  202. .then((res) => {
  203. if (res.status > 300) {
  204. throw Error("can not get content");
  205. }
  206. return res;
  207. })
  208. .then((res) => res.text())
  209. .then(setCode)
  210. .catch((e) => {
  211. showToast(Locale.Export.Artifacts.Error);
  212. });
  213. }
  214. }, [id]);
  215. return (
  216. <div className={styles["artifacts"]}>
  217. <div className={styles["artifacts-header"]}>
  218. <IconButton
  219. bordered
  220. icon={<ReloadButtonIcon />}
  221. shadow
  222. onClick={() => previewRef.current?.reload()}
  223. />
  224. <div className={styles["artifacts-title"]}>NextChat Artifacts</div>
  225. <ArtifactsShareButton
  226. id={id}
  227. getCode={() => code}
  228. fileName={fileName}
  229. />
  230. </div>
  231. <div className={styles["artifacts-content"]}>
  232. {loading && <Loading />}
  233. {code && (
  234. <HTMLPreview
  235. code={code}
  236. ref={previewRef}
  237. autoHeight={false}
  238. height={"100%"}
  239. onLoad={(title) => {
  240. setFileName(title as string);
  241. setLoading(false);
  242. }}
  243. />
  244. )}
  245. </div>
  246. </div>
  247. );
  248. }