Implement rich text area

This commit is contained in:
2025-09-17 00:07:55 +03:00
Unverified
parent a22c53d225
commit a2ba33edb6
5 changed files with 303 additions and 35 deletions
+31 -22
View File
@@ -260,6 +260,7 @@
.chat-input-wrapper {
position: relative;
margin: 0 20px 20px 20px;
&::before {
$height: 20px;
@@ -278,42 +279,50 @@
);
}
.chat-input {
margin: 0 20px 20px 20px;
background-color: $color-dark-surface-container;
border-radius: 40px;
.input-group {
display: flex;
.input-group {
.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;
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;
.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);
}
@include hoverStateLayer($background: $color-dark-primary);
}
}
}
@@ -115,3 +115,12 @@ button, input {
margin-top: 1rem;
}
}
.rich-text-area {
width: 100%;
resize: none;
transition: height 0.2s ease;
overflow-y: hidden;
background-color: transparent;
display: block;
}
@@ -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 (
<div className="chat-input-wrapper">
<div className="chat-input">
<form className="input-group" id="message-form" onSubmit={handleSubmit}>
<input
type="text"
<form className="input-group" id="message-form" onSubmit={handleSubmit}>
<div className="chat-input">
<RichTextArea
className="message-input"
id="message-input"
placeholder="Напишите сообщение..."
autoComplete="off"
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
text={message}
rows={1}
onTextChange={(value) => setMessage(value)} />
<button type="submit" className="send-btn">
<span className="material-symbols filled">send</span>
</button>
</form>
</div>
</div>
</form>
</div>
);
}
@@ -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<HTMLTextAreaElement | null>(null);
const hiddenTextareaRef = useRef<HTMLTextAreaElement | null>(null);
const heightRef = useRef<number | null>(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<HTMLTextAreaElement>) => {
// Keep height responsive during rapid uncontrolled input bursts
syncHeight();
onTextChange(e.target.value);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
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 (
<>
<textarea
className={`rich-text-area ${className}`}
ref={textareaRef}
value={text}
placeholder={placeholder}
rows={rows}
autoComplete={autoComplete}
onChange={handleChange}
onKeyDown={handleKeyDown}
/>
<textarea
aria-hidden
readOnly
tabIndex={-1}
ref={hiddenTextareaRef}
style={{
position: "fixed",
top: "-9999px",
left: "-9999px",
visibility: "hidden",
paddingTop: 0,
paddingBottom: 0,
height: "auto",
minHeight: 0,
maxHeight: "none",
overflow: "hidden",
}}
rows={1}
/>
</>
);
}
+37
View File
@@ -0,0 +1,37 @@
import { useRef, useCallback, type RefCallback, type Ref } from 'react';
// Определяем тип для ref, который может быть либо функцией, либо объектом
type PossibleRef<T> = Ref<T> | undefined;
export default function useCombinedRefs<T>(...refs: PossibleRef<T>[]): [RefCallback<T>, React.RefObject<T | null>] {
const targetRef = useRef<T | null>(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];
}