mirror of
				https://github.com/Yidadaa/ChatGPT-Next-Web.git
				synced 2025-10-30 04:59:20 +08:00 
			
		
		
		
	
		
			
				
	
	
		
			232 lines
		
	
	
		
			6.5 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			232 lines
		
	
	
		
			6.5 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| import { useEffect, useState, useRef, useMemo } from "react";
 | |
| import { useParams } from "react-router";
 | |
| import { useWindowSize } from "@/app/utils";
 | |
| import { IconButton } from "./button";
 | |
| import { nanoid } from "nanoid";
 | |
| import ExportIcon from "../icons/share.svg";
 | |
| import CopyIcon from "../icons/copy.svg";
 | |
| import DownloadIcon from "../icons/download.svg";
 | |
| import GithubIcon from "../icons/github.svg";
 | |
| import LoadingButtonIcon from "../icons/loading.svg";
 | |
| import Locale from "../locales";
 | |
| import { Modal, showToast } from "./ui-lib";
 | |
| import { copyToClipboard, downloadAs } from "../utils";
 | |
| import { Path, ApiPath, REPO_URL } from "@/app/constant";
 | |
| import { Loading } from "./home";
 | |
| import styles from "./artifact.module.scss";
 | |
| 
 | |
| export function HTMLPreview(props: {
 | |
|   code: string;
 | |
|   autoHeight?: boolean;
 | |
|   height?: number | string;
 | |
|   onLoad?: (title?: string) => void;
 | |
| }) {
 | |
|   const ref = useRef<HTMLIFrameElement>(null);
 | |
|   const frameId = useRef<string>(nanoid());
 | |
|   const [iframeHeight, setIframeHeight] = useState(600);
 | |
|   const [title, setTitle] = useState("");
 | |
|   /*
 | |
|    * https://stackoverflow.com/questions/19739001/what-is-the-difference-between-srcdoc-and-src-datatext-html-in-an
 | |
|    * 1. using srcdoc
 | |
|    * 2. using src with dataurl:
 | |
|    *    easy to share
 | |
|    *    length limit (Data URIs cannot be larger than 32,768 characters.)
 | |
|    */
 | |
| 
 | |
|   useEffect(() => {
 | |
|     const handleMessage = (e: any) => {
 | |
|       const { id, height, title } = e.data;
 | |
|       setTitle(title);
 | |
|       if (id == frameId.current) {
 | |
|         setIframeHeight(height);
 | |
|       }
 | |
|     };
 | |
|     window.addEventListener("message", handleMessage);
 | |
|     return () => {
 | |
|       window.removeEventListener("message", handleMessage);
 | |
|     };
 | |
|   }, []);
 | |
| 
 | |
|   const height = useMemo(() => {
 | |
|     const parentHeight = props.height || 600;
 | |
|     if (props.autoHeight !== false) {
 | |
|       return iframeHeight + 40 > parentHeight
 | |
|         ? parentHeight
 | |
|         : iframeHeight + 40;
 | |
|     } else {
 | |
|       return parentHeight;
 | |
|     }
 | |
|   }, [props.autoHeight, props.height, iframeHeight]);
 | |
| 
 | |
|   const srcDoc = useMemo(() => {
 | |
|     const script = `<script>new ResizeObserver((entries) => parent.postMessage({id: '${frameId.current}', height: entries[0].target.clientHeight}, '*')).observe(document.body)</script>`;
 | |
|     if (props.code.includes("</head>")) {
 | |
|       props.code.replace("</head>", "</head>" + script);
 | |
|     }
 | |
|     return props.code + script;
 | |
|   }, [props.code]);
 | |
| 
 | |
|   const handleOnLoad = () => {
 | |
|     if (props?.onLoad) {
 | |
|       props.onLoad(title);
 | |
|     }
 | |
|   };
 | |
| 
 | |
|   return (
 | |
|     <iframe
 | |
|       className={styles["artifact-iframe"]}
 | |
|       id={frameId.current}
 | |
|       ref={ref}
 | |
|       sandbox="allow-forms allow-modals allow-scripts"
 | |
|       style={{ height }}
 | |
|       srcDoc={srcDoc}
 | |
|       onLoad={handleOnLoad}
 | |
|     />
 | |
|   );
 | |
| }
 | |
| 
 | |
| export function ArtifactShareButton({
 | |
|   getCode,
 | |
|   id,
 | |
|   style,
 | |
|   fileName,
 | |
| }: {
 | |
|   getCode: () => string;
 | |
|   id?: string;
 | |
|   style?: any;
 | |
|   fileName?: string;
 | |
| }) {
 | |
|   const [loading, setLoading] = useState(false);
 | |
|   const [name, setName] = useState(id);
 | |
|   const [show, setShow] = useState(false);
 | |
|   const shareUrl = useMemo(
 | |
|     () => [location.origin, "#", Path.Artifact, "/", name].join(""),
 | |
|     [name],
 | |
|   );
 | |
|   const upload = (code: string) =>
 | |
|     id
 | |
|       ? Promise.resolve({ id })
 | |
|       : fetch(ApiPath.Artifact, {
 | |
|           method: "POST",
 | |
|           body: code,
 | |
|         })
 | |
|           .then((res) => res.json())
 | |
|           .then(({ id }) => {
 | |
|             if (id) {
 | |
|               return { id };
 | |
|             }
 | |
|             throw Error();
 | |
|           })
 | |
|           .catch((e) => {
 | |
|             showToast(Locale.Export.Artifact.Error);
 | |
|           });
 | |
|   return (
 | |
|     <>
 | |
|       <div className="window-action-button" style={style}>
 | |
|         <IconButton
 | |
|           icon={loading ? <LoadingButtonIcon /> : <ExportIcon />}
 | |
|           bordered
 | |
|           title={Locale.Export.Artifact.Title}
 | |
|           onClick={() => {
 | |
|             setLoading(true);
 | |
|             upload(getCode())
 | |
|               .then((res) => {
 | |
|                 if (res?.id) {
 | |
|                   setShow(true);
 | |
|                   setName(res?.id);
 | |
|                 }
 | |
|               })
 | |
|               .finally(() => setLoading(false));
 | |
|           }}
 | |
|         />
 | |
|       </div>
 | |
|       {show && (
 | |
|         <div className="modal-mask">
 | |
|           <Modal
 | |
|             title={Locale.Export.Artifact.Title}
 | |
|             onClose={() => setShow(false)}
 | |
|             actions={[
 | |
|               <IconButton
 | |
|                 key="download"
 | |
|                 icon={<DownloadIcon />}
 | |
|                 bordered
 | |
|                 text={Locale.Export.Download}
 | |
|                 onClick={() => {
 | |
|                   downloadAs(getCode(), `${fileName || name}.html`).then(() =>
 | |
|                     setShow(false),
 | |
|                   );
 | |
|                 }}
 | |
|               />,
 | |
|               <IconButton
 | |
|                 key="copy"
 | |
|                 icon={<CopyIcon />}
 | |
|                 bordered
 | |
|                 text={Locale.Chat.Actions.Copy}
 | |
|                 onClick={() => {
 | |
|                   copyToClipboard(shareUrl).then(() => setShow(false));
 | |
|                 }}
 | |
|               />,
 | |
|             ]}
 | |
|           >
 | |
|             <div>
 | |
|               <a target="_blank" href={shareUrl}>
 | |
|                 {shareUrl}
 | |
|               </a>
 | |
|             </div>
 | |
|           </Modal>
 | |
|         </div>
 | |
|       )}
 | |
|     </>
 | |
|   );
 | |
| }
 | |
| 
 | |
| export function Artifact() {
 | |
|   const { id } = useParams();
 | |
|   const [code, setCode] = useState("");
 | |
|   const [loading, setLoading] = useState(true);
 | |
|   const [fileName, setFileName] = useState("");
 | |
| 
 | |
|   useEffect(() => {
 | |
|     if (id) {
 | |
|       fetch(`${ApiPath.Artifact}?id=${id}`)
 | |
|         .then((res) => {
 | |
|           if (res.status > 300) {
 | |
|             throw Error("can not get content");
 | |
|           }
 | |
|           return res;
 | |
|         })
 | |
|         .then((res) => res.text())
 | |
|         .then(setCode)
 | |
|         .catch((e) => {
 | |
|           showToast(Locale.Export.Artifact.Error);
 | |
|         });
 | |
|     }
 | |
|   }, [id]);
 | |
| 
 | |
|   return (
 | |
|     <div className={styles["artifact"]}>
 | |
|       <div className={styles["artifact-header"]}>
 | |
|         <a href={REPO_URL} target="_blank" rel="noopener noreferrer">
 | |
|           <IconButton bordered icon={<GithubIcon />} shadow />
 | |
|         </a>
 | |
|         <div className={styles["artifact-title"]}>NextChat Artifact</div>
 | |
|         <ArtifactShareButton id={id} getCode={() => code} fileName={fileName} />
 | |
|       </div>
 | |
|       <div className={styles["artifact-content"]}>
 | |
|         {loading && <Loading />}
 | |
|         {code && (
 | |
|           <HTMLPreview
 | |
|             code={code}
 | |
|             autoHeight={false}
 | |
|             height={"100%"}
 | |
|             onLoad={(title) => {
 | |
|               setFileName(title as string);
 | |
|               setLoading(false);
 | |
|             }}
 | |
|           />
 | |
|         )}
 | |
|       </div>
 | |
|     </div>
 | |
|   );
 | |
| }
 |