diff --git a/app/api/openai/route.ts b/app/api/openai/route.ts index 3477fc2..261c20a 100644 --- a/app/api/openai/route.ts +++ b/app/api/openai/route.ts @@ -17,7 +17,7 @@ async function makeRequest(req: NextRequest) { }, { status: 500, - } + }, ); } } diff --git a/app/calcTextareaHeight.ts b/app/calcTextareaHeight.ts new file mode 100644 index 0000000..555c8ce --- /dev/null +++ b/app/calcTextareaHeight.ts @@ -0,0 +1,118 @@ +/** + * fork from element-plus + * https://github.com/element-plus/element-plus/blob/dev/packages/components/input/src/utils.ts + */ + +import { isFirefox } from "./utils"; + +let hiddenTextarea: HTMLTextAreaElement | undefined = undefined; + +const HIDDEN_STYLE = ` + height:0 !important; + visibility:hidden !important; + ${isFirefox() ? "" : "overflow:hidden !important;"} + position:absolute !important; + z-index:-1000 !important; + top:0 !important; + right:0 !important; +`; + +const CONTEXT_STYLE = [ + "letter-spacing", + "line-height", + "padding-top", + "padding-bottom", + "font-family", + "font-weight", + "font-size", + "text-rendering", + "text-transform", + "width", + "text-indent", + "padding-left", + "padding-right", + "border-width", + "box-sizing", +]; + +type NodeStyle = { + contextStyle: string; + boxSizing: string; + paddingSize: number; + borderSize: number; +}; + +type TextAreaHeight = { + height: string; + minHeight?: string; +}; + +function calculateNodeStyling(targetElement: Element): NodeStyle { + const style = window.getComputedStyle(targetElement); + + const boxSizing = style.getPropertyValue("box-sizing"); + + const paddingSize = + Number.parseFloat(style.getPropertyValue("padding-bottom")) + + Number.parseFloat(style.getPropertyValue("padding-top")); + + const borderSize = + Number.parseFloat(style.getPropertyValue("border-bottom-width")) + + Number.parseFloat(style.getPropertyValue("border-top-width")); + + const contextStyle = CONTEXT_STYLE.map( + (name) => `${name}:${style.getPropertyValue(name)}`, + ).join(";"); + + return { contextStyle, paddingSize, borderSize, boxSizing }; +} + +export default function calcTextareaHeight( + targetElement: HTMLTextAreaElement, + minRows: number = 2, + maxRows?: number, +): TextAreaHeight { + if (!hiddenTextarea) { + hiddenTextarea = document.createElement("textarea"); + document.body.appendChild(hiddenTextarea); + } + + const { paddingSize, borderSize, boxSizing, contextStyle } = + calculateNodeStyling(targetElement); + + hiddenTextarea.setAttribute("style", `${contextStyle};${HIDDEN_STYLE}`); + hiddenTextarea.value = targetElement.value || targetElement.placeholder || ""; + + let height = hiddenTextarea.scrollHeight; + const result = {} as TextAreaHeight; + + if (boxSizing === "border-box") { + height = height + borderSize; + } else if (boxSizing === "content-box") { + height = height - paddingSize; + } + + hiddenTextarea.value = ""; + const singleRowHeight = hiddenTextarea.scrollHeight - paddingSize; + + if (minRows) { + let minHeight = singleRowHeight * minRows; + if (boxSizing === "border-box") { + minHeight = minHeight + paddingSize + borderSize; + } + height = Math.max(minHeight, height); + result.minHeight = `${minHeight}px`; + } + if (maxRows) { + let maxHeight = singleRowHeight * maxRows; + if (boxSizing === "border-box") { + maxHeight = maxHeight + paddingSize + borderSize; + } + height = Math.min(maxHeight, height); + } + result.height = `${height}px`; + hiddenTextarea.parentNode?.removeChild(hiddenTextarea); + hiddenTextarea = undefined; + + return result; +} diff --git a/app/components/chat.tsx b/app/components/chat.tsx index dabe103..0e719d8 100644 --- a/app/components/chat.tsx +++ b/app/components/chat.tsx @@ -41,6 +41,8 @@ import chatStyle from "./chat.module.scss"; import { Input, Modal, showModal, showToast } from "./ui-lib"; +import calcTextareaHeight from "../calcTextareaHeight"; + const Markdown = dynamic( async () => memo((await import("./markdown")).Markdown), { @@ -331,6 +333,10 @@ function useScrollToBottom() { export function Chat(props: { showSideBar?: () => void; sideBarShowing?: boolean; + autoSize: { + minRows: number; + maxRows?: number; + }; }) { type RenderMessage = Message & { preview?: boolean }; @@ -348,6 +354,7 @@ export function Chat(props: { const { submitKey, shouldSubmit } = useSubmitHandler(); const { scrollRef, setAutoScroll } = useScrollToBottom(); const [hitBottom, setHitBottom] = useState(false); + const [textareaStyle, setTextareaStyle] = useState({}); const onChatBodyScroll = (e: HTMLElement) => { const isTouchBottom = e.scrollTop + e.clientHeight >= e.scrollHeight - 20; @@ -381,6 +388,14 @@ export function Chat(props: { dom.scrollTop = dom.scrollHeight - dom.offsetHeight + paddingBottomNum; }; + // textarea has an adaptive height + const resizeTextarea = () => { + const dom = inputRef.current; + if (!dom) return; + const { minRows, maxRows } = props.autoSize; + setTextareaStyle(calcTextareaHeight(dom, minRows, maxRows)); + }; + // only search prompts when user input is short const SEARCH_TEXT_LIMIT = 30; const onInput = (text: string) => { @@ -512,6 +527,12 @@ export function Chat(props: { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // Textarea Adaptive height + useEffect(() => { + resizeTextarea(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [userInput]); + return (
@@ -667,8 +688,8 @@ export function Chat(props: {