artifact.tsx 6.2 KB

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