artifacts.tsx 7.5 KB

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