diff --git a/app/src/assets/editor.less b/app/src/assets/editor.less new file mode 100644 index 0000000..ab0630f --- /dev/null +++ b/app/src/assets/editor.less @@ -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; +} diff --git a/app/src/assets/file.less b/app/src/assets/file.less index d5db50e..f4d6cec 100644 --- a/app/src/assets/file.less +++ b/app/src/assets/file.less @@ -1,7 +1,7 @@ .file-action { position: absolute; top: 50%; - left: 6px; + left: 8px; transform: translateY(-50%); background: hsl(var(--input)) !important; padding: 6px; diff --git a/app/src/assets/home.less b/app/src/assets/home.less index e43f26e..c0f69b0 100644 --- a/app/src/assets/home.less +++ b/app/src/assets/home.less @@ -282,6 +282,7 @@ width: 100%; text-align: center; color: hsl(var(--text)); + white-space: pre-wrap; &::placeholder { color: hsl(var(--text-secondary)); diff --git a/app/src/components/RichEditor.tsx b/app/src/components/RichEditor.tsx new file mode 100644 index 0000000..6bbfe31 --- /dev/null +++ b/app/src/components/RichEditor.tsx @@ -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 ( + <> + + > + ); +} + +export default RichEditor; diff --git a/app/src/conf.ts b/app/src/conf.ts index 5552116..cd56509 100644 --- a/app/src/conf.ts +++ b/app/src/conf.ts @@ -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"; diff --git a/app/src/i18n.ts b/app/src/i18n.ts index d014878..d48675a 100644 --- a/app/src/i18n.ts +++ b/app/src/i18n.ts @@ -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: "Пусто", diff --git a/app/src/routes/Home.tsx b/app/src/routes/Home.tsx index 11cc61c..6aecfc5 100644 --- a/app/src/routes/Home.tsx +++ b/app/src/routes/Home.tsx @@ -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() {