From 27dd2c80f6defb7f28fa0ec04214e65795a1279b Mon Sep 17 00:00:00 2001 From: Zhang Minghan Date: Wed, 6 Mar 2024 18:40:15 +0800 Subject: [PATCH] feat: optimize scroll area and message acitons --- app/src-tauri/tauri.conf.json | 2 +- app/src/assets/pages/chat.less | 1 + app/src/assets/pages/home.less | 12 +- app/src/assets/ui.less | 16 +- app/src/components/Message.tsx | 263 +++++++++++------- app/src/components/home/ChatInterface.tsx | 59 ++-- app/src/components/home/ChatWrapper.tsx | 5 +- .../components/home/assemblies/ChatAction.tsx | 2 +- .../home/assemblies/ScrollAction.tsx | 37 ++- app/src/components/ui/scroll-area.tsx | 6 +- app/src/conf/bootstrap.ts | 2 +- app/src/events/chat.ts | 5 - app/src/utils/dom.ts | 36 +++ 13 files changed, 267 insertions(+), 179 deletions(-) delete mode 100644 app/src/events/chat.ts diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json index cac1353..db80faf 100644 --- a/app/src-tauri/tauri.conf.json +++ b/app/src-tauri/tauri.conf.json @@ -8,7 +8,7 @@ }, "package": { "productName": "chatnio", - "version": "3.10.2" + "version": "3.10.3" }, "tauri": { "allowlist": { diff --git a/app/src/assets/pages/chat.less b/app/src/assets/pages/chat.less index a4e52f4..99bc88b 100644 --- a/app/src/assets/pages/chat.less +++ b/app/src/assets/pages/chat.less @@ -135,6 +135,7 @@ border-radius: var(--radius); border: 1px solid hsl(var(--border-hover)); transition: 0.25s linear; + cursor: pointer; &:hover { border-color: hsl(var(--border-active)); diff --git a/app/src/assets/pages/home.less b/app/src/assets/pages/home.less index b5e4101..79661b1 100644 --- a/app/src/assets/pages/home.less +++ b/app/src/assets/pages/home.less @@ -644,20 +644,14 @@ overflow-y: auto; touch-action: pan-y; padding: 18px; - scrollbar-width: thin; - // using margin instead of gap to avoid browser compatibility issues - & > * { - margin-bottom: 8px; + .message { + margin-bottom: 0.75rem; &:last-child { margin-bottom: 0; } } - - &::-webkit-scrollbar { - width: 6px; - } } .chat-input { @@ -702,7 +696,7 @@ .text { font-size: 12px; white-space: nowrap; - transform: translateY(-1px); + transform: translateY(-0.5px); opacity: 0; transition: 0.3s; transition-delay: 0.3s; diff --git a/app/src/assets/ui.less b/app/src/assets/ui.less index fa832f0..f513da8 100644 --- a/app/src/assets/ui.less +++ b/app/src/assets/ui.less @@ -344,4 +344,18 @@ input[type="number"] { .border-input:focus { border-color: hsl(var(--border)); -} \ No newline at end of file +} + + +.animate-fade-in { + @keyframes fadeIn { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } + } + + animation: fadeIn 0.5s forwards; +} diff --git a/app/src/components/Message.tsx b/app/src/components/Message.tsx index f0b2a8f..591f490 100644 --- a/app/src/components/Message.tsx +++ b/app/src/components/Message.tsx @@ -1,4 +1,4 @@ -import { getRoleIcon, Message } from "@/api/types.tsx"; +import { Message } from "@/api/types.tsx"; import Markdown from "@/components/Markdown.tsx"; import { CalendarCheck2, @@ -8,7 +8,6 @@ import { Copy, File, Loader2, - MoreVertical, MousePointerSquare, PencilLine, Power, @@ -16,9 +15,14 @@ import { Trash, } from "lucide-react"; import { filterMessage } from "@/utils/processor.ts"; -import { copyClipboard, saveAsFile, useInputValue } from "@/utils/dom.ts"; +import { + copyClipboard, + isContainDom, + saveAsFile, + useInputValue, +} from "@/utils/dom.ts"; import { useTranslation } from "react-i18next"; -import { Ref, useMemo, useRef, useState } from "react"; +import React, { Ref, useMemo, useRef, useState } from "react"; import { DropdownMenu, DropdownMenuContent, @@ -32,8 +36,6 @@ import Avatar from "@/components/Avatar.tsx"; import { useSelector } from "react-redux"; import { selectUsername } from "@/store/auth.ts"; import { appLogo } from "@/conf/env.ts"; -import Icon from "@/components/utils/Icon.tsx"; -import { useMobile } from "@/utils/device.ts"; type MessageProps = { index: number; @@ -42,17 +44,34 @@ type MessageProps = { onEvent?: (event: string, index?: number, message?: string) => void; ref?: Ref; sharing?: boolean; + + selected?: boolean; + onFocus?: (event: React.MouseEvent) => void; + onFocusLeave?: (event: React.MouseEvent) => void; }; function MessageSegment(props: MessageProps) { const ref = useRef(null); - const mobile = useMobile(); const { message } = props; return ( -
+
{ + try { + if (isContainDom(ref.current, event.relatedTarget as HTMLElement)) + return; + props.onFocusLeave && props.onFocusLeave(event); + } catch (e) { + console.debug(e); + } + }} + > - {!mobile && } +
); } @@ -96,17 +115,113 @@ function MessageQuota({ message }: MessageQuotaProps) { ); } -function MessageContent({ message, end, index, onEvent }: MessageProps) { +type MessageMenuProps = { + children?: React.ReactNode; + message: Message; + end?: boolean; + index: number; + onEvent?: (event: string, index?: number, message?: string) => void; + editedMessage?: string; + setEditedMessage: (message: string) => void; + setOpen: (open: boolean) => void; + align?: "start" | "end"; +}; + +function MessageMenu({ + children, + align, + message, + end, + index, + onEvent, + editedMessage, + setEditedMessage, + setOpen, +}: MessageMenuProps) { const { t } = useTranslation(); - const mobile = useMobile(); const isAssistant = message.role === "assistant"; + + const [dropdown, setDropdown] = useState(false); + + return ( + + + {children} + + + {isAssistant && end && ( + { + onEvent && onEvent(message.end !== false ? "restart" : "stop"); + setDropdown(false); + }} + > + {message.end !== false ? ( + <> + + {t("message.restart")} + + ) : ( + <> + + {t("message.stop")} + + )} + + )} + copyClipboard(filterMessage(message.content))} + > + + {t("message.copy")} + + useInputValue("input", filterMessage(message.content))} + > + + {t("message.use")} + + { + editedMessage?.length === 0 && setEditedMessage(message.content); + setOpen(true); + }} + > + + {t("message.edit")} + + onEvent && onEvent("remove", index)}> + + {t("message.remove")} + + + saveAsFile( + `message-${message.role}.txt`, + filterMessage(message.content), + ) + } + > + + {t("message.save")} + + + + ); +} + +function MessageContent({ + message, + end, + index, + onEvent, + selected, +}: MessageProps) { const isUser = message.role === "user"; const username = useSelector(selectUsername); - const icon = getRoleIcon(message.role); const [open, setOpen] = useState(false); - const [dropdown, setDropdown] = useState(false); const [editedMessage, setEditedMessage] = useState(""); return ( @@ -120,19 +235,37 @@ function MessageContent({ message, end, index, onEvent }: MessageProps) { onChange={setEditedMessage} />
- - ) : ( - {``} - ) - } - > - - {message.role} - + {!selected ? ( + isUser ? ( + + ) : ( + {``} + ) + ) : ( + +
+ +
+
+ )}
{message.content.length ? ( @@ -143,84 +276,6 @@ function MessageContent({ message, end, index, onEvent }: MessageProps) { )}
-
- - - {mobile && } - {!mobile ? ( - - ) : ( - - )} - - - {isAssistant && end && ( - { - onEvent && - onEvent(message.end !== false ? "restart" : "stop"); - setDropdown(false); - }} - > - {message.end !== false ? ( - <> - - {t("message.restart")} - - ) : ( - <> - - {t("message.stop")} - - )} - - )} - copyClipboard(filterMessage(message.content))} - > - - {t("message.copy")} - - - useInputValue("input", filterMessage(message.content)) - } - > - - {t("message.use")} - - { - editedMessage?.length === 0 && - setEditedMessage(message.content); - setOpen(true); - }} - > - - {t("message.edit")} - - onEvent && onEvent("remove", index)} - > - - {t("message.remove")} - - - saveAsFile( - `message-${message.role}.txt`, - filterMessage(message.content), - ) - } - > - - {t("message.save")} - - - -
); } diff --git a/app/src/components/home/ChatInterface.tsx b/app/src/components/home/ChatInterface.tsx index d983d7b..ebbc7bf 100644 --- a/app/src/components/home/ChatInterface.tsx +++ b/app/src/components/home/ChatInterface.tsx @@ -7,69 +7,50 @@ import { useMessages, } from "@/store/chat.ts"; import MessageSegment from "@/components/Message.tsx"; -import { chatEvent } from "@/events/chat.ts"; -import { addEventListeners } from "@/utils/dom.ts"; +import { ScrollArea } from "@/components/ui/scroll-area.tsx"; type ChatInterfaceProps = { + scrollable: boolean; setTarget: (target: HTMLDivElement | null) => void; }; -function ChatInterface({ setTarget }: ChatInterfaceProps) { +function ChatInterface({ scrollable, setTarget }: ChatInterfaceProps) { const ref = React.useRef(null); const messages: Message[] = useMessages(); const process = listenMessageEvent(); const current: number = useSelector(selectCurrent); - const [scrollable, setScrollable] = React.useState(true); - const [position, setPosition] = React.useState(0); + const [selected, setSelected] = React.useState(-1); useEffect( function () { if (!ref.current || !scrollable) return; const el = ref.current as HTMLDivElement; el.scrollTop = el.scrollHeight; - chatEvent.emit(); }, [messages], ); - useEffect(() => { - if (!ref.current) return; - const el = ref.current as HTMLDivElement; - - const event = () => { - const offset = el.scrollTop - position; - setPosition(el.scrollTop); - if (offset < 0) setScrollable(false); - else - setScrollable(el.scrollTop + el.clientHeight + 20 >= el.scrollHeight); - }; - return addEventListeners( - el, - ["scroll", "scrollend", "resize", "touchend"], - event, - ); - }, [ref]); - useEffect(() => { setTarget(ref.current); }, [ref]); return ( - <> -
- {messages.map((message, i) => ( - { - process({ id: current, event, index, message }); - }} - key={i} - index={i} - /> - ))} -
- + + {messages.map((message, i) => ( + { + process({ id: current, event, index, message }); + }} + key={i} + index={i} + selected={selected === i} + onFocus={() => setSelected(i)} + onFocusLeave={() => setSelected(-1)} + /> + ))} + ); } diff --git a/app/src/components/home/ChatWrapper.tsx b/app/src/components/home/ChatWrapper.tsx index 0ac2ea5..d2ddb45 100644 --- a/app/src/components/home/ChatWrapper.tsx +++ b/app/src/components/home/ChatWrapper.tsx @@ -38,6 +38,7 @@ import { getModelFromId } from "@/conf/model.ts"; import { posterEvent } from "@/events/poster.ts"; type InterfaceProps = { + scrollable: boolean; setTarget: (instance: HTMLElement | null) => void; }; @@ -91,6 +92,8 @@ function ChatWrapper() { return false; } + if (working) return false; + const message: string = formatMessage(files, data); if (message.length > 0 && data.trim().length > 0) { if (await sendAction(message)) { @@ -155,7 +158,7 @@ function ChatWrapper() { return (
- +
( return (
{ + if (!target) return; + + const position = target.scrollTop + target.clientHeight; + const height = target.scrollHeight; + const diff = Math.abs(position - height); + setVisibility(diff > 20); + }; + + useEffect(() => { + if (!target) return; + return addEventListeners( + target, + ["scroll", "touchmove"], + scrollableHandler, + ); + }, [target]); + useEffect(() => { if (messages.length === 0) return setVisibility(false); }, [messages]); - useEffect(() => { - if (!target) return setVisibility(false); - addEventListeners(target, ["scroll", "resize"], listenScrollingAction); - }, [target]); - - function listenScrollingAction() { - if (!target) return; - const offset = target.scrollHeight - target.scrollTop - target.clientHeight; - setVisibility(offset > 100); - } - - chatEvent.addEventListener(listenScrollingAction); - return ( visible && ( scrollDown(target)}> diff --git a/app/src/components/ui/scroll-area.tsx b/app/src/components/ui/scroll-area.tsx index 13716fc..c51b9f3 100644 --- a/app/src/components/ui/scroll-area.tsx +++ b/app/src/components/ui/scroll-area.tsx @@ -8,11 +8,13 @@ const ScrollArea = React.forwardRef< React.ComponentPropsWithoutRef >(({ className, children, ...props }, ref) => ( - + {children} diff --git a/app/src/conf/bootstrap.ts b/app/src/conf/bootstrap.ts index 115bda3..581ad36 100644 --- a/app/src/conf/bootstrap.ts +++ b/app/src/conf/bootstrap.ts @@ -7,7 +7,7 @@ import { import { syncSiteInfo } from "@/admin/api/info.ts"; import { setAxiosConfig } from "@/conf/api.ts"; -export const version = "3.10.2"; // version of the current build +export const version = "3.10.3"; // version of the current build export const dev: boolean = getDev(); // is in development mode (for debugging, in localhost origin) export const deploy: boolean = true; // is production environment (for api endpoint) export const tokenField = getTokenField(deploy); // token field name for storing token diff --git a/app/src/events/chat.ts b/app/src/events/chat.ts deleted file mode 100644 index ec403cc..0000000 --- a/app/src/events/chat.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { EventCommitter } from "@/events/struct.ts"; - -export const chatEvent = new EventCommitter({ - name: "chat", -}); diff --git a/app/src/utils/dom.ts b/app/src/utils/dom.ts index 0312e94..7c071a3 100644 --- a/app/src/utils/dom.ts +++ b/app/src/utils/dom.ts @@ -304,3 +304,39 @@ export function getQuerySelector(query: string): HTMLElement | null { return document.body.querySelector(query); } + +export function isContainDom( + el: HTMLElement | undefined | null, + target: HTMLElement | undefined | null, + notIncludeSelf = false, +) { + /** + * Test if element contains target + * @param el Element + * @param target Target + * @example + * const el = document.getElementById("el"); + * const target = document.getElementById("target"); + * console.log(isContain(el, target)); + */ + + if (!el || !target) return false; + return el.contains(target) && (!notIncludeSelf || el !== target); +} + +export function isContainEventTarget( + el: HTMLElement | undefined | null, + e: Event, +) { + /** + * Test if element contains event target + * @param el Element + * @param e Event + * @example + * const el = document.getElementById("el"); + * const handler = (e: Event) => console.log(isContainEventTarget(el, e)); + * el.addEventListener("click", handler); + */ + + return isContainDom(el, e.target as HTMLElement); +}