Browse Source

feat: close #680 lazy rendering markdown

Yidadaa 1 year ago
parent
commit
8363cdd9fa

+ 24 - 0
app/components/chat.module.scss

@@ -1,5 +1,29 @@
 @import "../styles/animation.scss";
 
+.chat-input-actions {
+  display: flex;
+  flex-wrap: wrap;
+
+  .chat-input-action {
+    display: inline-flex;
+    border-radius: 20px;
+    font-size: 12px;
+    background-color: var(--white);
+    color: var(--black);
+    border: var(--border-in-light);
+    padding: 4px 10px;
+    animation: slide-in ease 0.3s;
+    box-shadow: var(--card-shadow);
+    transition: all ease 0.3s;
+    margin-bottom: 10px;
+    align-items: center;
+
+    &:not(:last-child) {
+      margin-right: 5px;
+    }
+  }
+}
+
 .prompt-toast {
   position: absolute;
   bottom: -50px;

+ 81 - 6
app/components/chat.tsx

@@ -14,6 +14,11 @@ import DeleteIcon from "../icons/delete.svg";
 import MaxIcon from "../icons/max.svg";
 import MinIcon from "../icons/min.svg";
 
+import LightIcon from "../icons/light.svg";
+import DarkIcon from "../icons/dark.svg";
+import AutoIcon from "../icons/auto.svg";
+import BottomIcon from "../icons/bottom.svg";
+
 import {
   Message,
   SubmitKey,
@@ -22,6 +27,7 @@ import {
   ROLES,
   createMessage,
   useAccessStore,
+  Theme,
 } from "../store";
 
 import {
@@ -31,6 +37,7 @@ import {
   isMobileScreen,
   selectOrCopy,
   autoGrowTextArea,
+  getCSSVar,
 } from "../utils";
 
 import dynamic from "next/dynamic";
@@ -60,7 +67,11 @@ export function Avatar(props: { role: Message["role"] }) {
   const config = useChatStore((state) => state.config);
 
   if (props.role !== "user") {
-    return <BotIcon className={styles["user-avtar"]} />;
+    return (
+      <div className="no-dark">
+        <BotIcon className={styles["user-avtar"]} />
+      </div>
+    );
   }
 
   return (
@@ -316,22 +327,78 @@ function useScrollToBottom() {
   // for auto-scroll
   const scrollRef = useRef<HTMLDivElement>(null);
   const [autoScroll, setAutoScroll] = useState(true);
-
-  // auto scroll
-  useLayoutEffect(() => {
+  const scrollToBottom = () => {
     const dom = scrollRef.current;
-    if (dom && autoScroll) {
+    if (dom) {
       setTimeout(() => (dom.scrollTop = dom.scrollHeight), 1);
     }
+  };
+
+  // auto scroll
+  useLayoutEffect(() => {
+    autoScroll && scrollToBottom();
   });
 
   return {
     scrollRef,
     autoScroll,
     setAutoScroll,
+    scrollToBottom,
   };
 }
 
+export function ChatActions(props: {
+  showPromptModal: () => void;
+  scrollToBottom: () => void;
+  hitBottom: boolean;
+}) {
+  const chatStore = useChatStore();
+
+  const theme = chatStore.config.theme;
+
+  function nextTheme() {
+    const themes = [Theme.Auto, Theme.Light, Theme.Dark];
+    const themeIndex = themes.indexOf(theme);
+    const nextIndex = (themeIndex + 1) % themes.length;
+    const nextTheme = themes[nextIndex];
+    chatStore.updateConfig((config) => (config.theme = nextTheme));
+  }
+
+  return (
+    <div className={chatStyle["chat-input-actions"]}>
+      {!props.hitBottom && (
+        <div
+          className={`${chatStyle["chat-input-action"]} clickable`}
+          onClick={props.scrollToBottom}
+        >
+          <BottomIcon />
+        </div>
+      )}
+      {props.hitBottom && (
+        <div
+          className={`${chatStyle["chat-input-action"]} clickable`}
+          onClick={props.showPromptModal}
+        >
+          <BrainIcon />
+        </div>
+      )}
+
+      <div
+        className={`${chatStyle["chat-input-action"]} clickable`}
+        onClick={nextTheme}
+      >
+        {theme === Theme.Auto ? (
+          <AutoIcon />
+        ) : theme === Theme.Light ? (
+          <LightIcon />
+        ) : theme === Theme.Dark ? (
+          <DarkIcon />
+        ) : null}
+      </div>
+    </div>
+  );
+}
+
 export function Chat(props: {
   showSideBar?: () => void;
   sideBarShowing?: boolean;
@@ -350,7 +417,7 @@ export function Chat(props: {
   const [beforeInput, setBeforeInput] = useState("");
   const [isLoading, setIsLoading] = useState(false);
   const { submitKey, shouldSubmit } = useSubmitHandler();
-  const { scrollRef, setAutoScroll } = useScrollToBottom();
+  const { scrollRef, setAutoScroll, scrollToBottom } = useScrollToBottom();
   const [hitBottom, setHitBottom] = useState(false);
 
   const onChatBodyScroll = (e: HTMLElement) => {
@@ -683,6 +750,8 @@ export function Chat(props: {
                       if (!isMobileScreen()) return;
                       setUserInput(message.content);
                     }}
+                    fontSize={fontSize}
+                    parentRef={scrollRef}
                   />
                 </div>
                 {!isUser && !message.preview && (
@@ -700,6 +769,12 @@ export function Chat(props: {
 
       <div className={styles["chat-input-panel"]}>
         <PromptHints prompts={promptHints} onPromptSelect={onPromptSelect} />
+
+        <ChatActions
+          showPromptModal={() => setShowPromptModal(true)}
+          scrollToBottom={scrollToBottom}
+          hitBottom={hitBottom}
+        />
         <div className={styles["chat-input-panel-inner"]}>
           <textarea
             ref={inputRef}

+ 8 - 1
app/components/home.module.scss

@@ -36,6 +36,7 @@
     max-height: var(--full-height);
 
     border-radius: 0;
+    border: 0;
   }
 }
 
@@ -230,6 +231,7 @@
   flex: 1;
   overflow: auto;
   padding: 20px;
+  padding-bottom: 40px;
   position: relative;
 }
 
@@ -354,11 +356,16 @@
 }
 
 .chat-input-panel {
+  position: relative;
   width: 100%;
   padding: 20px;
-  padding-top: 5px;
+  padding-top: 10px;
   box-sizing: border-box;
   flex-direction: column;
+  border-top-left-radius: 10px;
+  border-top-right-radius: 10px;
+  border-top: var(--border-in-light);
+  box-shadow: var(--card-shadow);
 }
 
 @mixin single-line {

+ 2 - 4
app/components/home.tsx

@@ -17,7 +17,7 @@ import LoadingIcon from "../icons/three-dots.svg";
 import CloseIcon from "../icons/close.svg";
 
 import { useChatStore } from "../store";
-import { isMobileScreen } from "../utils";
+import { getCSSVar, isMobileScreen } from "../utils";
 import Locale from "../locales";
 import { Chat } from "./chat";
 
@@ -66,9 +66,7 @@ function useSwitchTheme() {
       metaDescriptionDark?.setAttribute("content", "#151515");
       metaDescriptionLight?.setAttribute("content", "#fafafa");
     } else {
-      const themeColor = getComputedStyle(document.body)
-        .getPropertyValue("--theme-color")
-        .trim();
+      const themeColor = getCSSVar("--themeColor");
       metaDescriptionDark?.setAttribute("content", themeColor);
       metaDescriptionLight?.setAttribute("content", themeColor);
     }

+ 37 - 5
app/components/markdown.tsx

@@ -47,7 +47,8 @@ const useLazyLoad = (ref: RefObject<Element>): boolean => {
     return () => {
       observer.disconnect();
     };
-  }, [ref]);
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, []);
 
   return isIntersecting;
 };
@@ -57,18 +58,49 @@ export function Markdown(
     content: string;
     loading?: boolean;
     fontSize?: number;
+    parentRef: RefObject<HTMLDivElement>;
   } & React.DOMAttributes<HTMLDivElement>,
 ) {
-  const mdRef = useRef(null);
-  const shouldRender = useLazyLoad(mdRef);
-  const shouldLoading = props.loading || !shouldRender;
+  const mdRef = useRef<HTMLDivElement>(null);
+
+  const parent = props.parentRef.current;
+  const md = mdRef.current;
+  const rendered = useRef(false);
+  const [counter, setCounter] = useState(0);
+
+  useEffect(() => {
+    // to triggr rerender
+    setCounter(counter + 1);
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [props.loading]);
+
+  const inView =
+    rendered.current ||
+    (() => {
+      if (parent && md) {
+        const parentBounds = parent.getBoundingClientRect();
+        const mdBounds = md.getBoundingClientRect();
+        const isInRange = (x: number) =>
+          x <= parentBounds.bottom && x >= parentBounds.top;
+        const inView = isInRange(mdBounds.top) || isInRange(mdBounds.bottom);
+
+        if (inView) {
+          rendered.current = true;
+        }
+
+        return inView;
+      }
+    })();
+
+  const shouldLoading = props.loading || !inView;
 
   return (
     <div
       className="markdown-body"
       style={{ fontSize: `${props.fontSize ?? 14}px` }}
-      {...props}
       ref={mdRef}
+      onContextMenu={props.onContextMenu}
+      onDoubleClickCapture={props.onDoubleClickCapture}
     >
       {shouldLoading ? (
         <LoadingIcon />

+ 1 - 0
app/icons/auto.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" viewBox="0 0 16 16" fill="none"><defs><rect id="path_0" x="0" y="0" width="16" height="16" /></defs><g opacity="1" transform="translate(0 0)  rotate(0 8 8)"><mask id="bg-mask-0" fill="white"><use xlink:href="#path_0"></use></mask><g mask="url(#bg-mask-0)" ><path  id="分组 1" style="stroke:#333333; stroke-width:1; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(5.666666666666666 5.333333333333333)  rotate(0 2.333750009536743 2.6666666666666665)" d="M0 5.33667L0.73 3.66667 M4.6675 5.33667L3.9375 3.66667 M0.729167 3.67L2.32917 0L3.93917 3.67 M0.729167 3.66667L3.93917 3.66667 " /><path  id="路径 5" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(1.3333333333333333 1.3333333333333333)  rotate(0 6.533316666666666 2.6666666666666665)" d="M13.07,5.33C12.45,2.29 9.76,0 6.53,0C3.31,0 0.62,2.29 0,5.33L2,4.67 " /><path  id="路径 6" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(1.3333333333333333 9.333333333333332)  rotate(0 6.533316666666666 2.6666666666666665)" d="M0,0C0.62,3.04 3.31,5.33 6.53,5.33C9.76,5.33 12.45,3.04 13.07,0L11.33,0.67 " /></g></g></svg>

+ 1 - 0
app/icons/bottom.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" viewBox="0 0 16 16" fill="none"><defs><rect id="path_0" x="0" y="0" width="16" height="16" /></defs><g opacity="1" transform="translate(0 0)  rotate(0 8 8)"><mask id="bg-mask-0" fill="white"><use xlink:href="#path_0"></use></mask><g mask="url(#bg-mask-0)" ><path  id="路径 1" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(8.002766666666666 2)  rotate(0 0 4.649916666666667)" d="M0,9.3L0,0 " /><path  id="路径 2" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(4 7.333333333333333)  rotate(0 4 2)" d="M8,0L4,4L0,0 " /><path  id="路径 3" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(4 14)  rotate(0 4 0)" d="M8,0L0,0 " /></g></g></svg>

+ 1 - 0
app/icons/dark.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" viewBox="0 0 16 16" fill="none"><defs><rect id="path_0" x="0" y="0" width="16" height="16" /></defs><g opacity="1" transform="translate(0 0)  rotate(0 8 8)"><mask id="bg-mask-0" fill="white"><use xlink:href="#path_0"></use></mask><g mask="url(#bg-mask-0)" ><path  id="路径 1" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(1.3333333333333333 1.333333333333485)  rotate(0 6.666666666666666 6.666666666666666)" d="M6.67,0L4.91,1.76L1.76,1.76L1.76,4.91L0,6.67L1.76,8.42L1.76,11.58L4.91,11.58L6.67,13.33L8.42,11.58L11.58,11.58L11.58,8.42L13.33,6.67L11.58,4.91L11.58,1.76L8.42,1.76L6.67,0Z " /><path  id="路径 2" style="stroke:#333333; stroke-width:1.3333333333333333; stroke-opacity:1; stroke-dasharray:0 0" transform="translate(5.666666666666666 5.44771525016904)  rotate(0 2.4732087011352872 2.442809041582063)" d="M4,0.55C2.17,-0.78 0,0.55 0,1.89C1.67,1.89 3.33,2.22 3.33,4.89C4.67,4.89 5.83,1.89 4,0.55Z " /></g></g></svg>

File diff suppressed because it is too large
+ 0 - 0
app/icons/light.svg


+ 8 - 4
app/styles/globals.scss

@@ -1,4 +1,6 @@
 @mixin light {
+  --theme: light;
+
   /* color */
   --white: white;
   --black: rgb(48, 48, 48);
@@ -18,6 +20,8 @@
 }
 
 @mixin dark {
+  --theme: dark;
+
   /* color */
   --white: rgb(30, 30, 30);
   --black: rgb(187, 187, 187);
@@ -31,6 +35,10 @@
   --border-in-light: 1px solid rgba(255, 255, 255, 0.192);
 
   --theme-color: var(--gray);
+
+  div:not(.no-dark) > svg {
+    filter: invert(0.5);
+  }
 }
 
 .light {
@@ -282,10 +290,6 @@ pre {
 .clickable {
   cursor: pointer;
 
-  div:not(.no-dark) > svg {
-    filter: invert(0.5);
-  }
-
   &:hover {
     filter: brightness(0.9);
   }

+ 4 - 0
app/utils.ts

@@ -120,3 +120,7 @@ export function autoGrowTextArea(dom: HTMLTextAreaElement) {
 
   return rows;
 }
+
+export function getCSSVar(varName: string) {
+  return getComputedStyle(document.body).getPropertyValue(varName).trim();
+}

Some files were not shown because too many files changed in this diff