Browse Source

Merge pull request #2269 from Yidadaa/bugfix-0705

feat: improve image preview & model switcher model ux
Yifei Zhang 1 year ago
parent
commit
81771568be

+ 32 - 12
app/components/chat.tsx

@@ -61,7 +61,14 @@ import Locale from "../locales";
 import { IconButton } from "./button";
 import styles from "./chat.module.scss";
 
-import { ListItem, Modal, showConfirm, showPrompt, showToast } from "./ui-lib";
+import {
+  ListItem,
+  Modal,
+  Selector,
+  showConfirm,
+  showPrompt,
+  showToast,
+} from "./ui-lib";
 import { useLocation, useNavigate } from "react-router-dom";
 import { LAST_INPUT_KEY, Path, REQUEST_TIMEOUT_MS } from "../constant";
 import { Avatar } from "./emoji";
@@ -404,16 +411,11 @@ export function ChatActions(props: {
 
   // switch model
   const currentModel = chatStore.currentSession().mask.modelConfig.model;
-  function nextModel() {
-    const models = config.models.filter((m) => m.available).map((m) => m.name);
-    const modelIndex = models.indexOf(currentModel);
-    const nextIndex = (modelIndex + 1) % models.length;
-    const nextModel = models[nextIndex];
-    chatStore.updateCurrentSession((session) => {
-      session.mask.modelConfig.model = nextModel as ModelType;
-      session.mask.syncGlobalConfig = false;
-    });
-  }
+  const models = useMemo(
+    () => config.models.filter((m) => m.available).map((m) => m.name),
+    [config.models],
+  );
+  const [showModelSelector, setShowModelSelector] = useState(false);
 
   return (
     <div className={styles["chat-input-actions"]}>
@@ -485,10 +487,28 @@ export function ChatActions(props: {
       />
 
       <ChatAction
-        onClick={nextModel}
+        onClick={() => setShowModelSelector(true)}
         text={currentModel}
         icon={<RobotIcon />}
       />
+
+      {showModelSelector && (
+        <Selector
+          items={models.map((m) => ({
+            title: m,
+            value: m,
+          }))}
+          onClose={() => setShowModelSelector(false)}
+          onSelection={(s) => {
+            if (s.length === 0) return;
+            chatStore.updateCurrentSession((session) => {
+              session.mask.modelConfig.model = s[0] as ModelType;
+              session.mask.syncGlobalConfig = false;
+            });
+            showToast(s[0]);
+          }}
+        />
+      )}
     </div>
   );
 }

+ 1 - 1
app/components/exporter.module.scss

@@ -186,7 +186,7 @@
         box-shadow: var(--card-shadow);
         border: var(--border-in-light);
 
-        * {
+        *:not(li) {
           overflow: hidden;
         }
       }

+ 15 - 6
app/components/exporter.tsx

@@ -1,7 +1,16 @@
+/* eslint-disable @next/next/no-img-element */
 import { ChatMessage, useAppConfig, useChatStore } from "../store";
 import Locale from "../locales";
 import styles from "./exporter.module.scss";
-import { List, ListItem, Modal, Select, showToast } from "./ui-lib";
+import {
+  List,
+  ListItem,
+  Modal,
+  Select,
+  showImageModal,
+  showModal,
+  showToast,
+} from "./ui-lib";
 import { IconButton } from "./button";
 import { copyToClipboard, downloadAs, useMobileScreen } from "../utils";
 
@@ -23,6 +32,7 @@ import { DEFAULT_MASK_AVATAR } from "../store/mask";
 import { api } from "../client/api";
 import { prettyObject } from "../utils/format";
 import { EXPORT_MESSAGE_CLASS_NAME } from "../constant";
+import { getClientConfig } from "../config/client";
 
 const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
   loading: () => <LoadingIcon />,
@@ -369,6 +379,7 @@ export function ImagePreviewer(props: {
   const previewRef = useRef<HTMLDivElement>(null);
 
   const copy = () => {
+    showToast(Locale.Export.Image.Toast);
     const dom = previewRef.current;
     if (!dom) return;
     toBlob(dom).then((blob) => {
@@ -393,17 +404,15 @@ export function ImagePreviewer(props: {
   const isMobile = useMobileScreen();
 
   const download = () => {
+    showToast(Locale.Export.Image.Toast);
     const dom = previewRef.current;
     if (!dom) return;
     toPng(dom)
       .then((blob) => {
         if (!blob) return;
 
-        if (isMobile) {
-          const image = new Image();
-          image.src = blob;
-          const win = window.open("");
-          win?.document.write(image.outerHTML);
+        if (isMobile || getClientConfig()?.isApp) {
+          showImageModal(blob);
         } else {
           const link = document.createElement("a");
           link.download = `${props.topic}.png`;

+ 8 - 5
app/components/markdown.tsx

@@ -12,6 +12,7 @@ import mermaid from "mermaid";
 import LoadingIcon from "../icons/three-dots.svg";
 import React from "react";
 import { useDebouncedCallback, useThrottledCallback } from "use-debounce";
+import { showImageModal } from "./ui-lib";
 
 export function Mermaid(props: { code: string }) {
   const ref = useRef<HTMLDivElement>(null);
@@ -37,11 +38,13 @@ export function Mermaid(props: { code: string }) {
     if (!svg) return;
     const text = new XMLSerializer().serializeToString(svg);
     const blob = new Blob([text], { type: "image/svg+xml" });
-    const url = URL.createObjectURL(blob);
-    const win = window.open(url);
-    if (win) {
-      win.onload = () => URL.revokeObjectURL(url);
-    }
+    console.log(blob);
+    // const url = URL.createObjectURL(blob);
+    // const win = window.open(url);
+    // if (win) {
+    //   win.onload = () => URL.revokeObjectURL(url);
+    // }
+    showImageModal(URL.createObjectURL(blob));
   }
 
   if (hasError) {

+ 57 - 4
app/components/ui-lib.module.scss

@@ -62,6 +62,7 @@
   box-shadow: var(--card-shadow);
   margin-bottom: 20px;
   animation: slide-in ease 0.3s;
+  background: var(--white);
 }
 
 .list .list-item:last-child {
@@ -79,6 +80,19 @@
 
   --modal-padding: 20px;
 
+  &-max {
+    width: 95vw;
+    max-width: unset;
+    height: 95vh;
+    display: flex;
+    flex-direction: column;
+
+    .modal-content {
+      max-height: unset !important;
+      flex-grow: 1;
+    }
+  }
+
   .modal-header {
     padding: var(--modal-padding);
     display: flex;
@@ -91,11 +105,19 @@
       font-size: 16px;
     }
 
-    .modal-close-btn {
-      cursor: pointer;
+    .modal-header-actions {
+      display: flex;
 
-      &:hover {
-        filter: brightness(1.2);
+      .modal-header-action {
+        cursor: pointer;
+
+        &:not(:last-child) {
+          margin-right: 20px;
+        }
+
+        &:hover {
+          filter: brightness(1.2);
+        }
       }
     }
   }
@@ -249,3 +271,34 @@
     border: 1px solid var(--primary);
   }
 }
+
+.selector {
+  position: fixed;
+  top: 0;
+  left: 0;
+  height: 100vh;
+  width: 100vw;
+  background-color: rgba(0, 0, 0, 0.5);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+
+  &-content {
+    .list {
+      overflow: hidden;
+
+      .list-item {
+        cursor: pointer;
+        background-color: var(--white);
+
+        &:hover {
+          filter: brightness(0.95);
+        }
+
+        &:active {
+          filter: brightness(0.9);
+        }
+      }
+    }
+  }
+}

+ 80 - 4
app/components/ui-lib.tsx

@@ -1,3 +1,4 @@
+/* eslint-disable @next/next/no-img-element */
 import styles from "./ui-lib.module.scss";
 import LoadingIcon from "../icons/three-dots.svg";
 import CloseIcon from "../icons/close.svg";
@@ -6,6 +7,8 @@ import EyeOffIcon from "../icons/eye-off.svg";
 import DownIcon from "../icons/down.svg";
 import ConfirmIcon from "../icons/confirm.svg";
 import CancelIcon from "../icons/cancel.svg";
+import MaxIcon from "../icons/max.svg";
+import MinIcon from "../icons/min.svg";
 
 import Locale from "../locales";
 
@@ -44,9 +47,13 @@ export function ListItem(props: {
   children?: JSX.Element | JSX.Element[];
   icon?: JSX.Element;
   className?: string;
+  onClick?: () => void;
 }) {
   return (
-    <div className={styles["list-item"] + ` ${props.className || ""}`}>
+    <div
+      className={styles["list-item"] + ` ${props.className || ""}`}
+      onClick={props.onClick}
+    >
       <div className={styles["list-header"]}>
         {props.icon && <div className={styles["list-icon"]}>{props.icon}</div>}
         <div className={styles["list-item-title"]}>
@@ -93,6 +100,7 @@ interface ModalProps {
   title: string;
   children?: any;
   actions?: JSX.Element[];
+  defaultMax?: boolean;
   onClose?: () => void;
 }
 export function Modal(props: ModalProps) {
@@ -111,13 +119,30 @@ export function Modal(props: ModalProps) {
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, []);
 
+  const [isMax, setMax] = useState(!!props.defaultMax);
+
   return (
-    <div className={styles["modal-container"]}>
+    <div
+      className={
+        styles["modal-container"] + ` ${isMax && styles["modal-container-max"]}`
+      }
+    >
       <div className={styles["modal-header"]}>
         <div className={styles["modal-title"]}>{props.title}</div>
 
-        <div className={styles["modal-close-btn"]} onClick={props.onClose}>
-          <CloseIcon />
+        <div className={styles["modal-header-actions"]}>
+          <div
+            className={styles["modal-header-action"]}
+            onClick={() => setMax(!isMax)}
+          >
+            {isMax ? <MinIcon /> : <MaxIcon />}
+          </div>
+          <div
+            className={styles["modal-header-action"]}
+            onClick={props.onClose}
+          >
+            <CloseIcon />
+          </div>
         </div>
       </div>
 
@@ -394,3 +419,54 @@ export function showPrompt(content: any, value = "", rows = 3) {
     );
   });
 }
+
+export function showImageModal(img: string) {
+  showModal({
+    title: Locale.Export.Image.Modal,
+    children: (
+      <div>
+        <img
+          src={img}
+          alt="preview"
+          style={{
+            maxWidth: "100%",
+          }}
+        ></img>
+      </div>
+    ),
+  });
+}
+
+export function Selector<T>(props: {
+  items: Array<{
+    title: string;
+    subTitle?: string;
+    value: T;
+  }>;
+  onSelection?: (selection: T[]) => void;
+  onClose?: () => void;
+  multiple?: boolean;
+}) {
+  return (
+    <div className={styles["selector"]} onClick={() => props.onClose?.()}>
+      <div className={styles["selector-content"]}>
+        <List>
+          {props.items.map((item, i) => {
+            return (
+              <ListItem
+                className={styles["selector-item"]}
+                key={i}
+                title={item.title}
+                subTitle={item.subTitle}
+                onClick={() => {
+                  props.onSelection?.([item.value]);
+                  props.onClose?.();
+                }}
+              ></ListItem>
+            );
+          })}
+        </List>
+      </div>
+    </div>
+  );
+}

+ 4 - 0
app/locales/cn.ts

@@ -86,6 +86,10 @@ const cn = {
       Select: "选取",
       Preview: "预览",
     },
+    Image: {
+      Toast: "正在生成截图",
+      Modal: "长按或右键保存图片",
+    },
   },
   Select: {
     Search: "搜索消息",

+ 4 - 0
app/locales/en.ts

@@ -87,6 +87,10 @@ const en: LocaleType = {
       Select: "Select",
       Preview: "Preview",
     },
+    Image: {
+      Toast: "Capturing Image...",
+      Modal: "Long press or right click to save image",
+    },
   },
   Select: {
     Search: "Search",