mirror of
https://github.com/coaidev/coai.git
synced 2025-05-20 05:20:15 +09:00
feat: optimize scroll area and message acitons
This commit is contained in:
parent
39ea5f2051
commit
27dd2c80f6
@ -8,7 +8,7 @@
|
|||||||
},
|
},
|
||||||
"package": {
|
"package": {
|
||||||
"productName": "chatnio",
|
"productName": "chatnio",
|
||||||
"version": "3.10.2"
|
"version": "3.10.3"
|
||||||
},
|
},
|
||||||
"tauri": {
|
"tauri": {
|
||||||
"allowlist": {
|
"allowlist": {
|
||||||
|
@ -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));
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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}
|
||||||
|
@ -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)}>
|
||||||
|
@ -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 />
|
||||||
|
@ -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
|
||||||
|
@ -1,5 +0,0 @@
|
|||||||
import { EventCommitter } from "@/events/struct.ts";
|
|
||||||
|
|
||||||
export const chatEvent = new EventCommitter<void>({
|
|
||||||
name: "chat",
|
|
||||||
});
|
|
@ -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);
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user