artifact.tsx 6.0 KB

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