浏览代码

Merge branch 'main' into Modifylang

mayfwl 1 年之前
父节点
当前提交
95332e50ed
共有 3 个文件被更改,包括 121 次插入76 次删除
  1. 92 59
      app/components/artifacts.tsx
  2. 26 16
      app/components/markdown.tsx
  3. 3 1
      app/styles/globals.scss

+ 92 - 59
app/components/artifacts.tsx

@@ -1,4 +1,11 @@
-import { useEffect, useState, useRef, useMemo } from "react";
+import {
+  useEffect,
+  useState,
+  useRef,
+  useMemo,
+  forwardRef,
+  useImperativeHandle,
+} from "react";
 import { useParams } from "react-router";
 import { useWindowSize } from "@/app/utils";
 import { IconButton } from "./button";
@@ -8,6 +15,7 @@ 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 ReloadButtonIcon from "../icons/reload.svg";
 import Locale from "../locales";
 import { Modal, showToast } from "./ui-lib";
 import { copyToClipboard, downloadAs } from "../utils";
@@ -15,73 +23,89 @@ import { Path, ApiPath, REPO_URL } from "@/app/constant";
 import { Loading } from "./home";
 import styles from "./artifacts.module.scss";
 
-export function HTMLPreview(props: {
+type HTMLPreviewProps = {
   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);
-    };
-  }, []);
+export type HTMLPreviewHander = {
+  reload: () => void;
+};
 
-  const height = useMemo(() => {
-    if (!props.autoHeight) return props.height || 600;
-    if (typeof props.height === "string") {
-      return props.height;
-    }
-    const parentHeight = props.height || 600;
-    return iframeHeight + 40 > parentHeight ? parentHeight : iframeHeight + 40;
-  }, [props.autoHeight, props.height, iframeHeight]);
+export const HTMLPreview = forwardRef<HTMLPreviewHander, HTMLPreviewProps>(
+  function HTMLPreview(props, ref) {
+    const iframeRef = useRef<HTMLIFrameElement>(null);
+    const [frameId, setFrameId] = useState<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.)
+     */
 
-  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]);
+    useEffect(() => {
+      const handleMessage = (e: any) => {
+        const { id, height, title } = e.data;
+        setTitle(title);
+        if (id == frameId) {
+          setIframeHeight(height);
+        }
+      };
+      window.addEventListener("message", handleMessage);
+      return () => {
+        window.removeEventListener("message", handleMessage);
+      };
+    }, [frameId]);
 
-  const handleOnLoad = () => {
-    if (props?.onLoad) {
-      props.onLoad(title);
-    }
-  };
+    useImperativeHandle(ref, () => ({
+      reload: () => {
+        setFrameId(nanoid());
+      },
+    }));
 
-  return (
-    <iframe
-      className={styles["artifacts-iframe"]}
-      id={frameId.current}
-      ref={ref}
-      sandbox="allow-forms allow-modals allow-scripts"
-      style={{ height }}
-      srcDoc={srcDoc}
-      onLoad={handleOnLoad}
-    />
-  );
-}
+    const height = useMemo(() => {
+      if (!props.autoHeight) return props.height || 600;
+      if (typeof props.height === "string") {
+        return props.height;
+      }
+      const parentHeight = props.height || 600;
+      return iframeHeight + 40 > parentHeight
+        ? parentHeight
+        : iframeHeight + 40;
+    }, [props.autoHeight, props.height, iframeHeight]);
+
+    const srcDoc = useMemo(() => {
+      const script = `<script>new ResizeObserver((entries) => parent.postMessage({id: '${frameId}', 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, frameId]);
+
+    const handleOnLoad = () => {
+      if (props?.onLoad) {
+        props.onLoad(title);
+      }
+    };
+
+    return (
+      <iframe
+        className={styles["artifacts-iframe"]}
+        key={frameId}
+        ref={iframeRef}
+        sandbox="allow-forms allow-modals allow-scripts"
+        style={{ height }}
+        srcDoc={srcDoc}
+        onLoad={handleOnLoad}
+      />
+    );
+  },
+);
 
 export function ArtifactsShareButton({
   getCode,
@@ -184,6 +208,7 @@ export function Artifacts() {
   const [code, setCode] = useState("");
   const [loading, setLoading] = useState(true);
   const [fileName, setFileName] = useState("");
+  const previewRef = useRef<HTMLPreviewHander>(null);
 
   useEffect(() => {
     if (id) {
@@ -208,6 +233,13 @@ export function Artifacts() {
         <a href={REPO_URL} target="_blank" rel="noopener noreferrer">
           <IconButton bordered icon={<GithubIcon />} shadow />
         </a>
+        <IconButton
+          bordered
+          style={{ marginLeft: 20 }}
+          icon={<ReloadButtonIcon />}
+          shadow
+          onClick={() => previewRef.current?.reload()}
+        />
         <div className={styles["artifacts-title"]}>NextChat Artifacts</div>
         <ArtifactsShareButton
           id={id}
@@ -220,6 +252,7 @@ export function Artifacts() {
         {code && (
           <HTMLPreview
             code={code}
+            ref={previewRef}
             autoHeight={false}
             height={"100%"}
             onLoad={(title) => {

+ 26 - 16
app/components/markdown.tsx

@@ -10,12 +10,19 @@ import { copyToClipboard, useWindowSize } from "../utils";
 import mermaid from "mermaid";
 import Locale from "../locales";
 import LoadingIcon from "../icons/three-dots.svg";
+import ReloadButtonIcon from "../icons/reload.svg";
 import React from "react";
 import { useDebouncedCallback } from "use-debounce";
 import { showImageModal, FullScreen } from "./ui-lib";
-import { ArtifactsShareButton, HTMLPreview } from "./artifacts";
+import {
+  ArtifactsShareButton,
+  HTMLPreview,
+  HTMLPreviewHander,
+} from "./artifacts";
 import { Plugin } from "../constant";
 import { useChatStore } from "../store";
+import { IconButton } from "./button";
+
 export function Mermaid(props: { code: string }) {
   const ref = useRef<HTMLDivElement>(null);
   const [hasError, setHasError] = useState(false);
@@ -64,7 +71,7 @@ export function Mermaid(props: { code: string }) {
 
 export function PreCode(props: { children: any }) {
   const ref = useRef<HTMLPreElement>(null);
-  const refText = ref.current?.innerText;
+  const previewRef = useRef<HTMLPreviewHander>(null);
   const [mermaidCode, setMermaidCode] = useState("");
   const [htmlCode, setHtmlCode] = useState("");
   const { height } = useWindowSize();
@@ -79,6 +86,7 @@ export function PreCode(props: { children: any }) {
       setMermaidCode((mermaidDom as HTMLElement).innerText);
     }
     const htmlDom = ref.current.querySelector("code.language-html");
+    const refText = ref.current.querySelector("code")?.innerText;
     if (htmlDom) {
       setHtmlCode((htmlDom as HTMLElement).innerText);
     } else if (refText?.startsWith("<!DOCTYPE")) {
@@ -86,11 +94,6 @@ export function PreCode(props: { children: any }) {
     }
   }, 600);
 
-  useEffect(() => {
-    setTimeout(renderArtifacts, 1);
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [refText]);
-
   const enableArtifacts = useMemo(
     () => plugins?.includes(Plugin.Artifacts),
     [plugins],
@@ -119,6 +122,7 @@ export function PreCode(props: { children: any }) {
           codeElement.style.whiteSpace = "pre-wrap";
         }
       });
+      setTimeout(renderArtifacts, 1);
     }
   }, []);
 
@@ -145,7 +149,15 @@ export function PreCode(props: { children: any }) {
             style={{ position: "absolute", right: 20, top: 10 }}
             getCode={() => htmlCode}
           />
+          <IconButton
+            style={{ position: "absolute", right: 120, top: 10 }}
+            bordered
+            icon={<ReloadButtonIcon />}
+            shadow
+            onClick={() => previewRef.current?.reload()}
+          />
           <HTMLPreview
+            ref={previewRef}
             code={htmlCode}
             autoHeight={!document.fullscreenElement}
             height={!document.fullscreenElement ? 600 : height}
@@ -182,16 +194,14 @@ function CustomCode(props: { children: any }) {
         }}
       >
         {props.children}
-        {showToggle && collapsed && (
-          <div
-            className={`show-hide-button ${
-              collapsed ? "collapsed" : "expanded"
-            }`}
-          >
-            <button onClick={toggleCollapsed}>{Locale.NewChat.More}</button>
-          </div>
-        )}
       </code>
+      {showToggle && collapsed && (
+        <div
+          className={`show-hide-button ${collapsed ? "collapsed" : "expanded"}`}
+        >
+          <button onClick={toggleCollapsed}>{Locale.NewChat.More}</button>
+        </div>
+      )}
     </>
   );
 }

+ 3 - 1
app/styles/globals.scss

@@ -304,7 +304,7 @@ pre {
   }
 }
 
-code{
+pre {
   .show-hide-button {
     border-radius: 10px;
     position: absolute;
@@ -314,7 +314,9 @@ code{
     height: fit-content;
     display: inline-flex;
     justify-content: center;
+    pointer-events: none;
     button{
+      pointer-events: auto;
       margin-top: 3em;
       margin-bottom: 4em;
       padding: 5px 16px;