artifact.tsx 6.6 KB

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