mirror of
https://github.com/coaidev/coai.git
synced 2025-06-02 03:40:18 +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 {
|
.file-action {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
left: 6px;
|
left: 8px;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
background: hsl(var(--input)) !important;
|
background: hsl(var(--input)) !important;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
|
@ -282,6 +282,7 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: hsl(var(--text));
|
color: hsl(var(--text));
|
||||||
|
white-space: pre-wrap;
|
||||||
|
|
||||||
&::placeholder {
|
&::placeholder {
|
||||||
color: hsl(var(--text-secondary));
|
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";
|
import axios from "axios";
|
||||||
|
|
||||||
export const version: string = "3.2.1";
|
export const version: string = "3.2.2";
|
||||||
export const deploy: boolean = true;
|
export const deploy: boolean = true;
|
||||||
export let rest_api: string = "http://localhost:8094";
|
export let rest_api: string = "http://localhost:8094";
|
||||||
export let ws_api: string = "ws://localhost:8094";
|
export let ws_api: string = "ws://localhost:8094";
|
||||||
|
@ -28,6 +28,7 @@ const resources = {
|
|||||||
"request-failed":
|
"request-failed":
|
||||||
"Request failed. Please check your network and try again.",
|
"Request failed. Please check your network and try again.",
|
||||||
close: "Close",
|
close: "Close",
|
||||||
|
edit: "Edit",
|
||||||
conversation: {
|
conversation: {
|
||||||
title: "Conversation",
|
title: "Conversation",
|
||||||
empty: "Empty",
|
empty: "Empty",
|
||||||
@ -183,6 +184,7 @@ const resources = {
|
|||||||
"server-error-prompt": "登录出错,请重试。",
|
"server-error-prompt": "登录出错,请重试。",
|
||||||
"request-failed": "请求失败,请检查您的网络并重试。",
|
"request-failed": "请求失败,请检查您的网络并重试。",
|
||||||
close: "关闭",
|
close: "关闭",
|
||||||
|
edit: "编辑",
|
||||||
conversation: {
|
conversation: {
|
||||||
title: "会话",
|
title: "会话",
|
||||||
empty: "空空如也",
|
empty: "空空如也",
|
||||||
@ -333,6 +335,7 @@ const resources = {
|
|||||||
"request-failed":
|
"request-failed":
|
||||||
"Ошибка запроса. Пожалуйста, проверьте свою сеть и попробуйте еще раз.",
|
"Ошибка запроса. Пожалуйста, проверьте свою сеть и попробуйте еще раз.",
|
||||||
close: "Закрыть",
|
close: "Закрыть",
|
||||||
|
edit: "Редактировать",
|
||||||
conversation: {
|
conversation: {
|
||||||
title: "Разговор",
|
title: "Разговор",
|
||||||
empty: "Пусто",
|
empty: "Пусто",
|
||||||
|
@ -66,6 +66,7 @@ import { setMenu } from "../store/menu.ts";
|
|||||||
import FileProvider, { FileObject } from "../components/FileProvider.tsx";
|
import FileProvider, { FileObject } from "../components/FileProvider.tsx";
|
||||||
import router from "../router.ts";
|
import router from "../router.ts";
|
||||||
import SelectGroup from "../components/SelectGroup.tsx";
|
import SelectGroup from "../components/SelectGroup.tsx";
|
||||||
|
import RichEditor from "../components/RichEditor.tsx";
|
||||||
|
|
||||||
function SideBar() {
|
function SideBar() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -283,6 +284,7 @@ function ChatWrapper() {
|
|||||||
content: "",
|
content: "",
|
||||||
});
|
});
|
||||||
const [clearEvent, setClearEvent] = useState<() => void>(() => {});
|
const [clearEvent, setClearEvent] = useState<() => void>(() => {});
|
||||||
|
const [input, setInput] = useState("");
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const init = useSelector(selectInit);
|
const init = useSelector(selectInit);
|
||||||
const auth = useSelector(selectAuthenticated);
|
const auth = useSelector(selectAuthenticated);
|
||||||
@ -318,10 +320,8 @@ function ChatWrapper() {
|
|||||||
|
|
||||||
async function handleSend(auth: boolean, model: string, web: boolean) {
|
async function handleSend(auth: boolean, model: string, web: boolean) {
|
||||||
// because of the function wrapper, we need to update the selector state using props.
|
// because of the function wrapper, we need to update the selector state using props.
|
||||||
if (!target.current) return;
|
if (await processSend(input, auth, model, web)) {
|
||||||
const el = target.current as HTMLInputElement;
|
setInput("");
|
||||||
if (await processSend(el.value, auth, model, web)) {
|
|
||||||
el.value = "";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -377,15 +377,6 @@ function ChatWrapper() {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
<div className={`chat-box`}>
|
<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 && (
|
{auth && (
|
||||||
<FileProvider
|
<FileProvider
|
||||||
id={`file`}
|
id={`file`}
|
||||||
@ -395,6 +386,27 @@ function ChatWrapper() {
|
|||||||
setClearEvent={setClearEvent}
|
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>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
size={`icon`}
|
size={`icon`}
|
||||||
|
Loading…
Reference in New Issue
Block a user