mirror of
https://github.com/coaidev/coai.git
synced 2025-05-29 18:00:14 +09:00
update markdown editor
This commit is contained in:
parent
1c4e613d81
commit
b9d268ab0d
93
app/src/assets/editor.less
Normal file
93
app/src/assets/editor.less
Normal file
@ -0,0 +1,93 @@
|
||||
.editor-action {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 8px;
|
||||
transform: translateY(-50%);
|
||||
background: hsl(var(--input)) !important;
|
||||
padding: 6px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: 0.1s;
|
||||
outline: 0;
|
||||
opacity: 0;
|
||||
|
||||
&.active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: hsl(var(--border-hover)) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.editor-dialog {
|
||||
max-width: min(90vw, 920px) !important;
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
padding: 2px 4px 0;
|
||||
}
|
||||
|
||||
.editor-wrapper {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.editor-object {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-gap: 12px;
|
||||
height: 100%;
|
||||
|
||||
&.show-editor {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 1fr;
|
||||
grid-template-areas: 'editor';
|
||||
}
|
||||
|
||||
&.show-preview {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 1fr;
|
||||
grid-template-areas: 'markdown';
|
||||
}
|
||||
|
||||
&.show-editor.show-preview {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: 1fr;
|
||||
grid-template-areas: 'editor markdown';
|
||||
}
|
||||
}
|
||||
|
||||
.editor-input {
|
||||
grid-area: editor;
|
||||
scrollbar-width: thin;
|
||||
height: max-content;
|
||||
transition: .1s;
|
||||
min-height: 20vh !important;
|
||||
color: hsl(var(--text));
|
||||
font-size: 16px !important;
|
||||
padding: 14px 12px !important;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
|
||||
}
|
||||
|
||||
.editor-preview {
|
||||
height: 100%;
|
||||
grid-area: markdown;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
background: hsl(var(--input));
|
||||
color: hsl(var(--text));
|
||||
border: 1px solid hsl(var(--border));
|
||||
scrollbar-width: thin;
|
||||
transition: 0.1s;
|
||||
min-height: 20vh !important;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.editor-toolbar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
margin: 4px 0;
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
.file-action {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 6px;
|
||||
left: 8px;
|
||||
transform: translateY(-50%);
|
||||
background: hsl(var(--input)) !important;
|
||||
padding: 6px;
|
||||
|
@ -282,6 +282,7 @@
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
color: hsl(var(--text));
|
||||
white-space: pre-wrap;
|
||||
|
||||
&::placeholder {
|
||||
color: hsl(var(--text-secondary));
|
||||
|
148
app/src/components/RichEditor.tsx
Normal file
148
app/src/components/RichEditor.tsx
Normal file
@ -0,0 +1,148 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "./ui/dialog.tsx";
|
||||
import { Edit, Image, MenuSquare, PanelRight } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import "../assets/editor.less";
|
||||
import { Textarea } from "./ui/textarea.tsx";
|
||||
import Markdown from "./Markdown.tsx";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Toggle } from "./ui/toggle.tsx";
|
||||
import {mobile} from "../utils.ts";
|
||||
|
||||
type RichEditorProps = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
id?: string;
|
||||
placeholder?: string;
|
||||
maxLength?: number;
|
||||
};
|
||||
|
||||
function RichEditor({
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
id,
|
||||
placeholder,
|
||||
maxLength,
|
||||
}: RichEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const input = useRef(null);
|
||||
const [openPreview, setOpenPreview] = useState(!mobile);
|
||||
const [openInput, setOpenInput] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!input.current) return;
|
||||
const target = input.current as HTMLElement;
|
||||
const preview = target.parentElement?.querySelector(
|
||||
".editor-preview",
|
||||
) as HTMLElement;
|
||||
|
||||
const listener = () => {
|
||||
preview.style.height = `${target.clientHeight}px`;
|
||||
};
|
||||
target.addEventListener("transitionstart", listener);
|
||||
const task = setInterval(listener, 250);
|
||||
target.addEventListener("scroll", () => {
|
||||
preview.scrollTop = target.scrollTop;
|
||||
});
|
||||
|
||||
preview.style.height = `${target.clientHeight}px`;
|
||||
|
||||
return () => {
|
||||
target.removeEventListener("transitionstart", listener);
|
||||
clearInterval(task);
|
||||
};
|
||||
}, [input]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<div
|
||||
className={`editor-action active ${className}`}
|
||||
>
|
||||
<Edit className={`h-3.5 w-3.5`} />
|
||||
</div>
|
||||
</DialogTrigger>
|
||||
<DialogContent className={`editor-dialog flex-dialog`}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("edit")}</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div className={`editor-container`}>
|
||||
<div className={`editor-toolbar`}>
|
||||
<div className={`grow`} />
|
||||
<Toggle
|
||||
variant={`outline`}
|
||||
className={`h-8 w-8 p-0`}
|
||||
pressed={openInput && !openPreview}
|
||||
onClick={() => {
|
||||
setOpenPreview(false);
|
||||
setOpenInput(true);
|
||||
}}
|
||||
>
|
||||
<MenuSquare className={`h-3.5 w-3.5`} />
|
||||
</Toggle>
|
||||
|
||||
<Toggle
|
||||
variant={`outline`}
|
||||
className={`h-8 w-8 p-0`}
|
||||
pressed={openInput && openPreview}
|
||||
onClick={() => {
|
||||
setOpenPreview(true);
|
||||
setOpenInput(true);
|
||||
}}
|
||||
>
|
||||
<PanelRight className={`h-3.5 w-3.5`} />
|
||||
</Toggle>
|
||||
|
||||
<Toggle
|
||||
variant={`outline`}
|
||||
className={`h-8 w-8 p-0`}
|
||||
pressed={!openInput && openPreview}
|
||||
onClick={() => {
|
||||
setOpenPreview(true);
|
||||
setOpenInput(false);
|
||||
}}
|
||||
>
|
||||
<Image className={`h-3.5 w-3.5`} />
|
||||
</Toggle>
|
||||
</div>
|
||||
<div className={`editor-wrapper`}>
|
||||
<div
|
||||
className={`editor-object ${
|
||||
openInput ? "show-editor" : ""
|
||||
} ${openPreview ? "show-preview" : ""}`}
|
||||
>
|
||||
{openInput && (
|
||||
<Textarea
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
className={`editor-input`}
|
||||
id={id}
|
||||
maxLength={maxLength}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
ref={input}
|
||||
/>
|
||||
)}
|
||||
{openPreview && (
|
||||
<Markdown className={`editor-preview`} children={value} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default RichEditor;
|
@ -1,6 +1,6 @@
|
||||
import axios from "axios";
|
||||
|
||||
export const version: string = "3.2.1";
|
||||
export const version: string = "3.2.2";
|
||||
export const deploy: boolean = true;
|
||||
export let rest_api: string = "http://localhost:8094";
|
||||
export let ws_api: string = "ws://localhost:8094";
|
||||
|
@ -28,6 +28,7 @@ const resources = {
|
||||
"request-failed":
|
||||
"Request failed. Please check your network and try again.",
|
||||
close: "Close",
|
||||
edit: "Edit",
|
||||
conversation: {
|
||||
title: "Conversation",
|
||||
empty: "Empty",
|
||||
@ -183,6 +184,7 @@ const resources = {
|
||||
"server-error-prompt": "登录出错,请重试。",
|
||||
"request-failed": "请求失败,请检查您的网络并重试。",
|
||||
close: "关闭",
|
||||
edit: "编辑",
|
||||
conversation: {
|
||||
title: "会话",
|
||||
empty: "空空如也",
|
||||
@ -333,6 +335,7 @@ const resources = {
|
||||
"request-failed":
|
||||
"Ошибка запроса. Пожалуйста, проверьте свою сеть и попробуйте еще раз.",
|
||||
close: "Закрыть",
|
||||
edit: "Редактировать",
|
||||
conversation: {
|
||||
title: "Разговор",
|
||||
empty: "Пусто",
|
||||
|
@ -66,6 +66,7 @@ import { setMenu } from "../store/menu.ts";
|
||||
import FileProvider, { FileObject } from "../components/FileProvider.tsx";
|
||||
import router from "../router.ts";
|
||||
import SelectGroup from "../components/SelectGroup.tsx";
|
||||
import RichEditor from "../components/RichEditor.tsx";
|
||||
|
||||
function SideBar() {
|
||||
const { t } = useTranslation();
|
||||
@ -283,6 +284,7 @@ function ChatWrapper() {
|
||||
content: "",
|
||||
});
|
||||
const [clearEvent, setClearEvent] = useState<() => void>(() => {});
|
||||
const [input, setInput] = useState("");
|
||||
const dispatch = useDispatch();
|
||||
const init = useSelector(selectInit);
|
||||
const auth = useSelector(selectAuthenticated);
|
||||
@ -318,10 +320,8 @@ function ChatWrapper() {
|
||||
|
||||
async function handleSend(auth: boolean, model: string, web: boolean) {
|
||||
// because of the function wrapper, we need to update the selector state using props.
|
||||
if (!target.current) return;
|
||||
const el = target.current as HTMLInputElement;
|
||||
if (await processSend(el.value, auth, model, web)) {
|
||||
el.value = "";
|
||||
if (await processSend(input, auth, model, web)) {
|
||||
setInput("");
|
||||
}
|
||||
}
|
||||
|
||||
@ -377,15 +377,6 @@ function ChatWrapper() {
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<div className={`chat-box`}>
|
||||
<Input
|
||||
id={`input`}
|
||||
className={`input-box`}
|
||||
ref={target}
|
||||
placeholder={t("chat.placeholder")}
|
||||
onKeyDown={async (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") await handleSend(auth, model, web);
|
||||
}}
|
||||
/>
|
||||
{auth && (
|
||||
<FileProvider
|
||||
id={`file`}
|
||||
@ -395,6 +386,27 @@ function ChatWrapper() {
|
||||
setClearEvent={setClearEvent}
|
||||
/>
|
||||
)}
|
||||
<Input
|
||||
id={`input`}
|
||||
className={`input-box`}
|
||||
ref={target}
|
||||
value={input}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setInput(e.target.value)
|
||||
}
|
||||
placeholder={t("chat.placeholder")}
|
||||
onKeyDown={async (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") await handleSend(auth, model, web);
|
||||
}}
|
||||
/>
|
||||
<RichEditor
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
className={`editor`}
|
||||
id={`editor`}
|
||||
placeholder={t("chat.placeholder")}
|
||||
maxLength={8000}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size={`icon`}
|
||||
|
Loading…
Reference in New Issue
Block a user