artifact.tsx 5.2 KB

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