diff --git a/frontend/src/resources/css/_chat.scss b/frontend/src/resources/css/_chat.scss index 65cadf0..03d3064 100644 --- a/frontend/src/resources/css/_chat.scss +++ b/frontend/src/resources/css/_chat.scss @@ -260,6 +260,7 @@ .chat-input-wrapper { position: relative; + margin: 0 20px 20px 20px; &::before { $height: 20px; @@ -278,43 +279,51 @@ ); } - .chat-input { - margin: 0 20px 20px 20px; - background-color: $color-dark-surface-container; - border-radius: 40px; - - .input-group { + .input-group { + display: flex; + + .chat-input { + background-color: $color-dark-surface-container; + border-radius: 30px; + flex: 1; display: flex; - + flex-direction: row; + .message-input { flex: 1; - padding: 10px 20px; + padding: 20px 20px; + padding-right: 0; border: none; border-radius: 25px; font-size: 1rem; outline: none; - background-color: inherit; caret-color: $color-dark-primary; color: $color-dark-on-surface; - } - - .send-btn { - margin: 10px; - width: 50px; - height: 50px; - border-radius: 50%; - background-color: $color-dark-primary; - color: $color-dark-on-primary; - border: none; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: background-color 0.25s ease; - - @include hoverStateLayer($background: $color-dark-primary); + resize: none; + font: inherit; + font-size: 13pt; + height: 100%; + width: 100%; } } + + .send-btn { + margin: 10px; + width: 50px; + height: 50px; + border-radius: 50%; + background-color: $color-dark-primary; + color: $color-dark-on-primary; + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background-color 0.25s ease; + align-self: flex-end; + + @include hoverStateLayer($background: $color-dark-primary); + } } } } diff --git a/frontend/src/resources/css/common/_components.scss b/frontend/src/resources/css/common/_components.scss index 8fd5ee7..cecb551 100644 --- a/frontend/src/resources/css/common/_components.scss +++ b/frontend/src/resources/css/common/_components.scss @@ -114,4 +114,13 @@ button, input { justify-content: flex-end; margin-top: 1rem; } +} + +.rich-text-area { + width: 100%; + resize: none; + transition: height 0.2s ease; + overflow-y: hidden; + background-color: transparent; + display: block; } \ No newline at end of file diff --git a/frontend/src/ui/components/chat/ChatInputWrapper.tsx b/frontend/src/ui/components/chat/ChatInputWrapper.tsx index 8101b87..26541b6 100644 --- a/frontend/src/ui/components/chat/ChatInputWrapper.tsx +++ b/frontend/src/ui/components/chat/ChatInputWrapper.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { useChat } from "../../hooks/useChat"; +import { RichTextArea } from "../core/RichTextArea"; interface ChatInputWrapperProps { onSendMessage?: (message: string) => void; @@ -23,22 +24,21 @@ export function ChatInputWrapper({ onSendMessage }: ChatInputWrapperProps) { return (
-
-
- +
+ setMessage(e.target.value)} - /> + text={message} + rows={1} + onTextChange={(value) => setMessage(value)} /> - -
+
+
); } diff --git a/frontend/src/ui/components/core/RichTextArea.tsx b/frontend/src/ui/components/core/RichTextArea.tsx new file mode 100644 index 0000000..75038cb --- /dev/null +++ b/frontend/src/ui/components/core/RichTextArea.tsx @@ -0,0 +1,213 @@ +import { useEffect, useRef, useCallback, useLayoutEffect } from "react"; + +interface RichTextAreaProps { + text: string; + onTextChange: (value: string) => void; + onEnter?: "newLine" | null | (() => void); + onCtrlEnter?: (() => void) | null; + placeholder?: string; + id?: string; + className?: string; + rows?: number; + autoComplete?: string; +} + +export function RichTextArea({ + text, + onTextChange, + onEnter = "newLine", + onCtrlEnter = null, + placeholder, + className, + rows = 1, + autoComplete = "off", +}: RichTextAreaProps) { + const textareaRef = useRef(null); + const hiddenTextareaRef = useRef(null); + const heightRef = useRef(null); + + const getStyleValue = (computedStyle: CSSStyleDeclaration, prop: keyof CSSStyleDeclaration): number => { + const raw = (computedStyle as any)[prop] as string | number | undefined; + if (raw == null) return 0; + const str = String(raw); + return str.endsWith("px") ? parseFloat(str) : parseFloat(str) || 0; + }; + + const calculateTextareaStyles = useCallback(() => { + const textarea = textareaRef.current; + const hidden = hiddenTextareaRef.current; + if (!textarea || !hidden) return undefined; + + const computedStyle = window.getComputedStyle(textarea); + if (computedStyle.width === "0px") { + return { outerHeightStyle: 0, overflowing: false }; + } + + // Ensure hidden textarea copies width but not percentage-based anomalies from parents + // Normalize hidden textarea to avoid inherited constraints and copy critical metrics + hidden.style.position = "fixed"; + hidden.style.top = "-9999px"; + hidden.style.left = "-9999px"; + hidden.style.visibility = "hidden"; + hidden.style.height = "auto"; + hidden.style.minHeight = "0"; + hidden.style.maxHeight = "none"; + hidden.style.overflow = "hidden"; + hidden.style.boxSizing = computedStyle.boxSizing; + // Avoid counting vertical padding twice: keep 0 for measurement + hidden.style.paddingTop = "0"; + hidden.style.paddingBottom = "0"; + hidden.style.paddingLeft = computedStyle.paddingLeft; + hidden.style.paddingRight = computedStyle.paddingRight; + // Do not include borders in the inner scrollHeight measurement + hidden.style.borderTopWidth = "0"; + hidden.style.borderBottomWidth = "0"; + hidden.style.borderLeftWidth = computedStyle.borderLeftWidth; + hidden.style.borderRightWidth = computedStyle.borderRightWidth; + hidden.style.fontFamily = computedStyle.fontFamily; + hidden.style.fontSize = computedStyle.fontSize; + hidden.style.fontWeight = computedStyle.fontWeight; + hidden.style.lineHeight = computedStyle.lineHeight; + hidden.style.letterSpacing = computedStyle.letterSpacing; + hidden.style.whiteSpace = computedStyle.whiteSpace; + hidden.style.wordSpacing = computedStyle.wordSpacing; + hidden.style.textIndent = computedStyle.textIndent; + hidden.style.textTransform = computedStyle.textTransform; + hidden.style.textDecoration = computedStyle.textDecoration; + hidden.style.width = computedStyle.width; + hidden.style.maxWidth = computedStyle.width; + hidden.value = textarea.value || placeholder || "x"; + if (hidden.value.slice(-1) === "\n") { + hidden.value += " "; + } + + const boxSizing = computedStyle.boxSizing; + const padding = getStyleValue(computedStyle, "paddingBottom") + getStyleValue(computedStyle, "paddingTop"); + const border = getStyleValue(computedStyle, "borderBottomWidth") + getStyleValue(computedStyle, "borderTopWidth"); + + const innerHeight = hidden.scrollHeight; + + hidden.value = "x"; + const singleRowHeight = hidden.scrollHeight; + + let outerHeight = innerHeight; + const minRows = Number(rows || 1); + if (minRows) { + outerHeight = Math.max(minRows * singleRowHeight, outerHeight); + } + outerHeight = Math.max(outerHeight, singleRowHeight); + + // Use ceil to avoid sub-pixel gaps and subtract a tiny epsilon to reduce visual gap + let outerHeightStyle = outerHeight + (boxSizing === "border-box" ? padding + border : 0); + outerHeightStyle = Math.round(outerHeightStyle); // snap to pixel to avoid half-line gaps + const overflowing = Math.abs(outerHeight - innerHeight) <= 1; + return { outerHeightStyle, overflowing }; + }, [rows, placeholder]); + + const syncHeight = useCallback(() => { + const textarea = textareaRef.current; + const styles = calculateTextareaStyles(); + if (!textarea || !styles) return; + const { outerHeightStyle, overflowing } = styles; + if (heightRef.current !== outerHeightStyle) { + heightRef.current = outerHeightStyle; + textarea.style.height = `${outerHeightStyle}px`; + } + textarea.style.overflowY = overflowing ? "hidden" : ""; + }, [calculateTextareaStyles]); + + const useEnhancedEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect; + + useEnhancedEffect(() => { + syncHeight(); + }, [syncHeight, text]); + + useEffect(() => { + const textarea = textareaRef.current; + if (!textarea) return; + const onResize = () => syncHeight(); + window.addEventListener("resize", onResize); + let ro: ResizeObserver | null = null; + if (typeof ResizeObserver !== "undefined") { + ro = new ResizeObserver(() => { + ro!.unobserve(textarea); + syncHeight(); + requestAnimationFrame(() => ro && textarea && ro.observe(textarea)); + }); + ro.observe(textarea); + } + return () => { + window.removeEventListener("resize", onResize); + if (ro) ro.disconnect(); + }; + }, [syncHeight]); + + const handleChange = (e: React.ChangeEvent) => { + // Keep height responsive during rapid uncontrolled input bursts + syncHeight(); + onTextChange(e.target.value); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + const isCtrlEnter = e.key === "Enter" && (e.ctrlKey || e.metaKey); + const isPlainEnter = e.key === "Enter" && !e.ctrlKey && !e.metaKey && !e.shiftKey && !e.altKey; + + if (isCtrlEnter) { + e.preventDefault(); + if (typeof onCtrlEnter === "function") { + onCtrlEnter(); + } + return; + } + + if (isPlainEnter) { + if (onEnter === "newLine") { + // allow default + return; + } + if (onEnter === null) { + e.preventDefault(); + return; + } + if (typeof onEnter === "function") { + e.preventDefault(); + onEnter(); + return; + } + } + }; + + return ( + <> +