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": {
"productName": "chatnio",
"version": "3.10.2"
"version": "3.10.3"
},
"tauri": {
"allowlist": {

View File

@ -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));

View File

@ -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;

View File

@ -345,3 +345,17 @@ input[type="number"] {
.border-input:focus {
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 {
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<HTMLElement>;
sharing?: boolean;
selected?: boolean;
onFocus?: (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
onFocusLeave?: (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
};
function MessageSegment(props: MessageProps) {
const ref = useRef(null);
const mobile = useMobile();
const { message } = props;
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} />
{!mobile && <MessageQuota message={message} />}
<MessageQuota message={message} />
</div>
);
}
@ -96,71 +115,44 @@ 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 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<string | undefined>("");
return (
<div className={"content-wrapper"}>
<EditorProvider
submittable={true}
onSubmit={(value) => onEvent && onEvent("edit", index, value)}
open={open}
setOpen={setOpen}
value={editedMessage ?? ""}
onChange={setEditedMessage}
/>
<div className={`message-avatar-wrapper`}>
<Tips
classNamePopup={`flex flex-row items-center`}
trigger={
isUser ? (
<Avatar className={`message-avatar`} username={username} />
) : (
<img src={appLogo} alt={``} className={`message-avatar`} />
)
}
>
<Icon icon={icon} className={`h-4 w-4 mr-1`} />
{message.role}
</Tips>
</div>
<div className={`message-content`}>
{message.content.length ? (
<Markdown children={message.content} />
) : message.end === true ? (
<CircleSlash className={`h-5 w-5 m-1`} />
) : (
<Loader2 className={`h-5 w-5 m-1 animate-spin`} />
)}
</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 className={cn(`flex flex-row outline-none`)}>
{children}
</DropdownMenuTrigger>
<DropdownMenuContent align={`end`}>
<DropdownMenuContent align={align}>
{isAssistant && end && (
<DropdownMenuItem
onClick={() => {
onEvent &&
onEvent(message.end !== false ? "restart" : "stop");
onEvent && onEvent(message.end !== false ? "restart" : "stop");
setDropdown(false);
}}
>
@ -184,26 +176,21 @@ function MessageContent({ message, end, index, onEvent }: MessageProps) {
{t("message.copy")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
useInputValue("input", filterMessage(message.content))
}
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);
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)}
>
<DropdownMenuItem onClick={() => onEvent && onEvent("remove", index)}>
<Trash className={`h-4 w-4 mr-1.5`} />
{t("message.remove")}
</DropdownMenuItem>
@ -220,6 +207,74 @@ function MessageContent({ message, end, index, onEvent }: MessageProps) {
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
function MessageContent({
message,
end,
index,
onEvent,
selected,
}: MessageProps) {
const isUser = message.role === "user";
const username = useSelector(selectUsername);
const [open, setOpen] = useState(false);
const [editedMessage, setEditedMessage] = useState<string | undefined>("");
return (
<div className={"content-wrapper"}>
<EditorProvider
submittable={true}
onSubmit={(value) => onEvent && onEvent("edit", index, value)}
open={open}
setOpen={setOpen}
value={editedMessage ?? ""}
onChange={setEditedMessage}
/>
<div className={`message-avatar-wrapper`}>
{!selected ? (
isUser ? (
<Avatar
className={`message-avatar animate-fade-in`}
username={username}
/>
) : (
<img
src={appLogo}
alt={``}
className={`message-avatar animate-fade-in`}
/>
)
) : (
<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 className={`message-content`}>
{message.content.length ? (
<Markdown children={message.content} />
) : message.end === true ? (
<CircleSlash className={`h-5 w-5 m-1`} />
) : (
<Loader2 className={`h-5 w-5 m-1 animate-spin`} />
)}
</div>
</div>
);

View File

@ -7,56 +7,35 @@ 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 (
<>
<div className={`chat-content`} ref={ref}>
<ScrollArea className={`chat-content`} ref={ref}>
{messages.map((message, i) => (
<MessageSegment
message={message}
@ -66,10 +45,12 @@ function ChatInterface({ setTarget }: ChatInterfaceProps) {
}}
key={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";
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 (
<div className={`chat-container`}>
<div className={`chat-wrapper`}>
<Interface setTarget={setInstance} />
<Interface setTarget={setInstance} scrollable={!visible} />
<div className={`chat-input`}>
<div className={`input-action`}>
<ScrollAction

View File

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

View File

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

View File

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

View File

@ -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

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);
}
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);
}