artifacts.tsx 7.4 KB

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