feat: optimize scroll area and message acitons

This commit is contained in:
Zhang Minghan 2024-03-06 18:40:15 +08:00
parent 39ea5f2051
commit 27dd2c80f6
13 changed files with 267 additions and 179 deletions

View File

@ -8,7 +8,7 @@
}, },
"package": { "package": {
"productName": "chatnio", "productName": "chatnio",
"version": "3.10.2" "version": "3.10.3"
}, },
"tauri": { "tauri": {
"allowlist": { "allowlist": {

View File

@ -135,6 +135,7 @@
border-radius: var(--radius); border-radius: var(--radius);
border: 1px solid hsl(var(--border-hover)); border: 1px solid hsl(var(--border-hover));
transition: 0.25s linear; transition: 0.25s linear;
cursor: pointer;
&:hover { &:hover {
border-color: hsl(var(--border-active)); border-color: hsl(var(--border-active));

View File

@ -644,20 +644,14 @@
overflow-y: auto; overflow-y: auto;
touch-action: pan-y; touch-action: pan-y;
padding: 18px; padding: 18px;
scrollbar-width: thin;
// using margin instead of gap to avoid browser compatibility issues .message {
& > * { margin-bottom: 0.75rem;
margin-bottom: 8px;
&:last-child { &:last-child {
margin-bottom: 0; margin-bottom: 0;
} }
} }
&::-webkit-scrollbar {
width: 6px;
}
} }
.chat-input { .chat-input {
@ -702,7 +696,7 @@
.text { .text {
font-size: 12px; font-size: 12px;
white-space: nowrap; white-space: nowrap;
transform: translateY(-1px); transform: translateY(-0.5px);
opacity: 0; opacity: 0;
transition: 0.3s; transition: 0.3s;
transition-delay: 0.3s; transition-delay: 0.3s;

View File

@ -344,4 +344,18 @@ input[type="number"] {
.border-input:focus { .border-input:focus {
border-color: hsl(var(--border)); border-color: hsl(var(--border));
} }
.animate-fade-in {
@keyframes fadeIn {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
animation: fadeIn 0.5s forwards;
}

View File

@ -1,4 +1,4 @@
import { getRoleIcon, Message } from "@/api/types.tsx"; import { Message } from "@/api/types.tsx";
import Markdown from "@/components/Markdown.tsx"; import Markdown from "@/components/Markdown.tsx";
import { import {
CalendarCheck2, CalendarCheck2,
@ -8,7 +8,6 @@ import {
Copy, Copy,
File, File,
Loader2, Loader2,
MoreVertical,
MousePointerSquare, MousePointerSquare,
PencilLine, PencilLine,
Power, Power,
@ -16,9 +15,14 @@ import {
Trash, Trash,
} from "lucide-react"; } from "lucide-react";
import { filterMessage } from "@/utils/processor.ts"; 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 { useTranslation } from "react-i18next";
import { Ref, useMemo, useRef, useState } from "react"; import React, { Ref, useMemo, useRef, useState } from "react";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@ -32,8 +36,6 @@ import Avatar from "@/components/Avatar.tsx";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
import { selectUsername } from "@/store/auth.ts"; import { selectUsername } from "@/store/auth.ts";
import { appLogo } from "@/conf/env.ts"; import { appLogo } from "@/conf/env.ts";
import Icon from "@/components/utils/Icon.tsx";
import { useMobile } from "@/utils/device.ts";
type MessageProps = { type MessageProps = {
index: number; index: number;
@ -42,17 +44,34 @@ type MessageProps = {
onEvent?: (event: string, index?: number, message?: string) => void; onEvent?: (event: string, index?: number, message?: string) => void;
ref?: Ref<HTMLElement>; ref?: Ref<HTMLElement>;
sharing?: boolean; sharing?: boolean;
selected?: boolean;
onFocus?: (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
onFocusLeave?: (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
}; };
function MessageSegment(props: MessageProps) { function MessageSegment(props: MessageProps) {
const ref = useRef(null); const ref = useRef(null);
const mobile = useMobile();
const { message } = props; const { message } = props;
return ( return (
<div className={`message ${message.role}`} ref={ref}> <div
className={`message ${message.role}`}
ref={ref}
onClick={props.onFocus}
onMouseEnter={props.onFocus}
onMouseLeave={(event) => {
try {
if (isContainDom(ref.current, event.relatedTarget as HTMLElement))
return;
props.onFocusLeave && props.onFocusLeave(event);
} catch (e) {
console.debug(e);
}
}}
>
<MessageContent {...props} /> <MessageContent {...props} />
{!mobile && <MessageQuota message={message} />} <MessageQuota message={message} />
</div> </div>
); );
} }
@ -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 { t } = useTranslation();
const mobile = useMobile();
const isAssistant = message.role === "assistant"; const isAssistant = message.role === "assistant";
const [dropdown, setDropdown] = useState(false);
return (
<DropdownMenu open={dropdown} onOpenChange={setDropdown}>
<DropdownMenuTrigger className={cn(`flex flex-row outline-none`)}>
{children}
</DropdownMenuTrigger>
<DropdownMenuContent align={align}>
{isAssistant && end && (
<DropdownMenuItem
onClick={() => {
onEvent && onEvent(message.end !== false ? "restart" : "stop");
setDropdown(false);
}}
>
{message.end !== false ? (
<>
<RotateCcw className={`h-4 w-4 mr-1.5`} />
{t("message.restart")}
</>
) : (
<>
<Power className={`h-4 w-4 mr-1.5`} />
{t("message.stop")}
</>
)}
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => copyClipboard(filterMessage(message.content))}
>
<Copy className={`h-4 w-4 mr-1.5`} />
{t("message.copy")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => useInputValue("input", filterMessage(message.content))}
>
<MousePointerSquare className={`h-4 w-4 mr-1.5`} />
{t("message.use")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
editedMessage?.length === 0 && setEditedMessage(message.content);
setOpen(true);
}}
>
<PencilLine className={`h-4 w-4 mr-1.5`} />
{t("message.edit")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEvent && onEvent("remove", index)}>
<Trash className={`h-4 w-4 mr-1.5`} />
{t("message.remove")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
saveAsFile(
`message-${message.role}.txt`,
filterMessage(message.content),
)
}
>
<File className={`h-4 w-4 mr-1.5`} />
{t("message.save")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
function MessageContent({
message,
end,
index,
onEvent,
selected,
}: MessageProps) {
const isUser = message.role === "user"; const isUser = message.role === "user";
const username = useSelector(selectUsername); const username = useSelector(selectUsername);
const icon = getRoleIcon(message.role);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [dropdown, setDropdown] = useState(false);
const [editedMessage, setEditedMessage] = useState<string | undefined>(""); const [editedMessage, setEditedMessage] = useState<string | undefined>("");
return ( return (
@ -120,19 +235,37 @@ function MessageContent({ message, end, index, onEvent }: MessageProps) {
onChange={setEditedMessage} onChange={setEditedMessage}
/> />
<div className={`message-avatar-wrapper`}> <div className={`message-avatar-wrapper`}>
<Tips {!selected ? (
classNamePopup={`flex flex-row items-center`} isUser ? (
trigger={ <Avatar
isUser ? ( className={`message-avatar animate-fade-in`}
<Avatar className={`message-avatar`} username={username} /> username={username}
) : ( />
<img src={appLogo} alt={``} className={`message-avatar`} /> ) : (
) <img
} src={appLogo}
> alt={``}
<Icon icon={icon} className={`h-4 w-4 mr-1`} /> className={`message-avatar animate-fade-in`}
{message.role} />
</Tips> )
) : (
<MessageMenu
message={message}
end={end}
index={index}
onEvent={onEvent}
editedMessage={editedMessage}
setEditedMessage={setEditedMessage}
setOpen={setOpen}
align={isUser ? "end" : "start"}
>
<div
className={`message-avatar flex flex-row items-center justify-center cursor-pointer select-none opacity-0 animate-fade-in`}
>
<PencilLine className={`h-4 w-4`} />
</div>
</MessageMenu>
)}
</div> </div>
<div className={`message-content`}> <div className={`message-content`}>
{message.content.length ? ( {message.content.length ? (
@ -143,84 +276,6 @@ function MessageContent({ message, end, index, onEvent }: MessageProps) {
<Loader2 className={`h-5 w-5 m-1 animate-spin`} /> <Loader2 className={`h-5 w-5 m-1 animate-spin`} />
)} )}
</div> </div>
<div className={cn(`message-toolbar`, mobile && "w-full")}>
<DropdownMenu open={dropdown} onOpenChange={setDropdown}>
<DropdownMenuTrigger
className={cn(`flex flex-row outline-none`, mobile && "my-1.5")}
>
{mobile && <MessageQuota message={message} />}
{!mobile ? (
<MoreVertical className={`h-4 w-4 m-0.5`} />
) : (
<PencilLine className={cn(`h-6 w-6 p-1`, "ml-auto")} />
)}
</DropdownMenuTrigger>
<DropdownMenuContent align={`end`}>
{isAssistant && end && (
<DropdownMenuItem
onClick={() => {
onEvent &&
onEvent(message.end !== false ? "restart" : "stop");
setDropdown(false);
}}
>
{message.end !== false ? (
<>
<RotateCcw className={`h-4 w-4 mr-1.5`} />
{t("message.restart")}
</>
) : (
<>
<Power className={`h-4 w-4 mr-1.5`} />
{t("message.stop")}
</>
)}
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => copyClipboard(filterMessage(message.content))}
>
<Copy className={`h-4 w-4 mr-1.5`} />
{t("message.copy")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
useInputValue("input", filterMessage(message.content))
}
>
<MousePointerSquare className={`h-4 w-4 mr-1.5`} />
{t("message.use")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
editedMessage?.length === 0 &&
setEditedMessage(message.content);
setOpen(true);
}}
>
<PencilLine className={`h-4 w-4 mr-1.5`} />
{t("message.edit")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onEvent && onEvent("remove", index)}
>
<Trash className={`h-4 w-4 mr-1.5`} />
{t("message.remove")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
saveAsFile(
`message-${message.role}.txt`,
filterMessage(message.content),
)
}
>
<File className={`h-4 w-4 mr-1.5`} />
{t("message.save")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div> </div>
); );
} }

View File

@ -7,69 +7,50 @@ import {
useMessages, useMessages,
} from "@/store/chat.ts"; } from "@/store/chat.ts";
import MessageSegment from "@/components/Message.tsx"; import MessageSegment from "@/components/Message.tsx";
import { chatEvent } from "@/events/chat.ts"; import { ScrollArea } from "@/components/ui/scroll-area.tsx";
import { addEventListeners } from "@/utils/dom.ts";
type ChatInterfaceProps = { type ChatInterfaceProps = {
scrollable: boolean;
setTarget: (target: HTMLDivElement | null) => void; setTarget: (target: HTMLDivElement | null) => void;
}; };
function ChatInterface({ setTarget }: ChatInterfaceProps) { function ChatInterface({ scrollable, setTarget }: ChatInterfaceProps) {
const ref = React.useRef(null); const ref = React.useRef(null);
const messages: Message[] = useMessages(); const messages: Message[] = useMessages();
const process = listenMessageEvent(); const process = listenMessageEvent();
const current: number = useSelector(selectCurrent); const current: number = useSelector(selectCurrent);
const [scrollable, setScrollable] = React.useState(true); const [selected, setSelected] = React.useState(-1);
const [position, setPosition] = React.useState(0);
useEffect( useEffect(
function () { function () {
if (!ref.current || !scrollable) return; if (!ref.current || !scrollable) return;
const el = ref.current as HTMLDivElement; const el = ref.current as HTMLDivElement;
el.scrollTop = el.scrollHeight; el.scrollTop = el.scrollHeight;
chatEvent.emit();
}, },
[messages], [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(() => { useEffect(() => {
setTarget(ref.current); setTarget(ref.current);
}, [ref]); }, [ref]);
return ( return (
<> <ScrollArea className={`chat-content`} ref={ref}>
<div className={`chat-content`} ref={ref}> {messages.map((message, i) => (
{messages.map((message, i) => ( <MessageSegment
<MessageSegment message={message}
message={message} end={i === messages.length - 1}
end={i === messages.length - 1} onEvent={(event: string, index?: number, message?: string) => {
onEvent={(event: string, index?: number, message?: string) => { process({ id: current, event, index, message });
process({ id: current, event, index, message }); }}
}} key={i}
key={i} index={i}
index={i} selected={selected === i}
/> onFocus={() => setSelected(i)}
))} onFocusLeave={() => setSelected(-1)}
</div> />
</> ))}
</ScrollArea>
); );
} }

View File

@ -38,6 +38,7 @@ import { getModelFromId } from "@/conf/model.ts";
import { posterEvent } from "@/events/poster.ts"; import { posterEvent } from "@/events/poster.ts";
type InterfaceProps = { type InterfaceProps = {
scrollable: boolean;
setTarget: (instance: HTMLElement | null) => void; setTarget: (instance: HTMLElement | null) => void;
}; };
@ -91,6 +92,8 @@ function ChatWrapper() {
return false; return false;
} }
if (working) return false;
const message: string = formatMessage(files, data); const message: string = formatMessage(files, data);
if (message.length > 0 && data.trim().length > 0) { if (message.length > 0 && data.trim().length > 0) {
if (await sendAction(message)) { if (await sendAction(message)) {
@ -155,7 +158,7 @@ function ChatWrapper() {
return ( return (
<div className={`chat-container`}> <div className={`chat-container`}>
<div className={`chat-wrapper`}> <div className={`chat-wrapper`}>
<Interface setTarget={setInstance} /> <Interface setTarget={setInstance} scrollable={!visible} />
<div className={`chat-input`}> <div className={`chat-input`}>
<div className={`input-action`}> <div className={`input-action`}>
<ScrollAction <ScrollAction

View File

@ -28,7 +28,7 @@ export const ChatAction = React.forwardRef<HTMLDivElement, ChatActionProps>(
return ( return (
<div <div
className={`action chat-action ${className}`} className={cn("action chat-action", className)}
onClick={onClick} onClick={onClick}
ref={ref} ref={ref}
style={{ "--width": `${labelWidth}px` } as React.CSSProperties} style={{ "--width": `${labelWidth}px` } as React.CSSProperties}

View File

@ -1,6 +1,5 @@
import { ChevronsDown } from "lucide-react"; import { ChevronsDown } from "lucide-react";
import { useEffect } from "react"; import { useEffect } from "react";
import { chatEvent } from "@/events/chat.ts";
import { addEventListeners, scrollDown } from "@/utils/dom.ts"; import { addEventListeners, scrollDown } from "@/utils/dom.ts";
import { ChatAction } from "@/components/home/assemblies/ChatAction.tsx"; import { ChatAction } from "@/components/home/assemblies/ChatAction.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -13,27 +12,35 @@ type ScrollActionProps = {
target: HTMLElement | null; target: HTMLElement | null;
}; };
function ScrollAction({ visible, target, setVisibility }: ScrollActionProps) { function ScrollAction(
this: any,
{ target, visible, setVisibility }: ScrollActionProps,
) {
const { t } = useTranslation(); const { t } = useTranslation();
const messages: Message[] = useMessages(); const messages: Message[] = useMessages();
const scrollableHandler = () => {
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(() => { useEffect(() => {
if (messages.length === 0) return setVisibility(false); if (messages.length === 0) return setVisibility(false);
}, [messages]); }, [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 ( return (
visible && ( visible && (
<ChatAction text={t("scroll-down")} onClick={() => scrollDown(target)}> <ChatAction text={t("scroll-down")} onClick={() => scrollDown(target)}>

View File

@ -8,11 +8,13 @@ const ScrollArea = React.forwardRef<
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => ( >(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root <ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)} className={cn("relative overflow-hidden", className)}
{...props} {...props}
> >
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]"> <ScrollAreaPrimitive.Viewport
ref={ref}
className="h-full w-full rounded-[inherit]"
>
{children} {children}
</ScrollAreaPrimitive.Viewport> </ScrollAreaPrimitive.Viewport>
<ScrollBar /> <ScrollBar />

View File

@ -7,7 +7,7 @@ import {
import { syncSiteInfo } from "@/admin/api/info.ts"; import { syncSiteInfo } from "@/admin/api/info.ts";
import { setAxiosConfig } from "@/conf/api.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 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 deploy: boolean = true; // is production environment (for api endpoint)
export const tokenField = getTokenField(deploy); // token field name for storing token export const tokenField = getTokenField(deploy); // token field name for storing token

View File

@ -1,5 +0,0 @@
import { EventCommitter } from "@/events/struct.ts";
export const chatEvent = new EventCommitter<void>({
name: "chat",
});

View File

@ -304,3 +304,39 @@ export function getQuerySelector(query: string): HTMLElement | null {
return document.body.querySelector(query); 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);
}