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 (
);
}
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 (
+ <>
+
+
+ >
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/ui/hooks/useCombinedRefs.ts b/frontend/src/ui/hooks/useCombinedRefs.ts
new file mode 100644
index 0000000..8694050
--- /dev/null
+++ b/frontend/src/ui/hooks/useCombinedRefs.ts
@@ -0,0 +1,37 @@
+import { useRef, useCallback, type RefCallback, type Ref } from 'react';
+
+// Определяем тип для ref, который может быть либо функцией, либо объектом
+type PossibleRef = Ref | undefined;
+
+export default function useCombinedRefs(...refs: PossibleRef[]): [RefCallback, React.RefObject] {
+ const targetRef = useRef(null);
+
+ const setRefs = useCallback(
+ (node: T | null) => {
+ // Обновляем внутренний ref
+ targetRef.current = node;
+
+ // Обновляем все переданные refs
+ refs.forEach((ref) => {
+ if (!ref) {
+ return;
+ }
+
+ if (typeof ref === 'function') {
+ // Если ref - это функция, вызываем её
+ ref(node);
+ } else {
+ // Если ref - это объект, обновляем его свойство .current
+ // Используем проверку, чтобы убедиться, что это действительно MutableRefObject
+ // (хотя в реальном коде это почти всегда так)
+ ref.current = node;
+ }
+ });
+ },
+ // Убедитесь, что массив зависимостей всегда актуален
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [...refs]
+ );
+
+ return [setRefs, targetRef];
+}
\ No newline at end of file