mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Implement rich text area
This commit is contained in:
@@ -260,6 +260,7 @@
|
|||||||
|
|
||||||
.chat-input-wrapper {
|
.chat-input-wrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
margin: 0 20px 20px 20px;
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
$height: 20px;
|
$height: 20px;
|
||||||
@@ -278,42 +279,50 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-input {
|
.input-group {
|
||||||
margin: 0 20px 20px 20px;
|
display: flex;
|
||||||
background-color: $color-dark-surface-container;
|
|
||||||
border-radius: 40px;
|
|
||||||
|
|
||||||
.input-group {
|
.chat-input {
|
||||||
|
background-color: $color-dark-surface-container;
|
||||||
|
border-radius: 30px;
|
||||||
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
.message-input {
|
.message-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 10px 20px;
|
padding: 20px 20px;
|
||||||
|
padding-right: 0;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 25px;
|
border-radius: 25px;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
outline: none;
|
outline: none;
|
||||||
background-color: inherit;
|
|
||||||
caret-color: $color-dark-primary;
|
caret-color: $color-dark-primary;
|
||||||
color: $color-dark-on-surface;
|
color: $color-dark-on-surface;
|
||||||
|
resize: none;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13pt;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.send-btn {
|
.send-btn {
|
||||||
margin: 10px;
|
margin: 10px;
|
||||||
width: 50px;
|
width: 50px;
|
||||||
height: 50px;
|
height: 50px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background-color: $color-dark-primary;
|
background-color: $color-dark-primary;
|
||||||
color: $color-dark-on-primary;
|
color: $color-dark-on-primary;
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
transition: background-color 0.25s ease;
|
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;
|
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 { useState } from "react";
|
||||||
import { useChat } from "../../hooks/useChat";
|
import { useChat } from "../../hooks/useChat";
|
||||||
|
import { RichTextArea } from "../core/RichTextArea";
|
||||||
|
|
||||||
interface ChatInputWrapperProps {
|
interface ChatInputWrapperProps {
|
||||||
onSendMessage?: (message: string) => void;
|
onSendMessage?: (message: string) => void;
|
||||||
@@ -23,22 +24,21 @@ export function ChatInputWrapper({ onSendMessage }: ChatInputWrapperProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="chat-input-wrapper">
|
<div className="chat-input-wrapper">
|
||||||
<div className="chat-input">
|
<form className="input-group" id="message-form" onSubmit={handleSubmit}>
|
||||||
<form className="input-group" id="message-form" onSubmit={handleSubmit}>
|
<div className="chat-input">
|
||||||
<input
|
<RichTextArea
|
||||||
type="text"
|
|
||||||
className="message-input"
|
className="message-input"
|
||||||
id="message-input"
|
id="message-input"
|
||||||
placeholder="Напишите сообщение..."
|
placeholder="Напишите сообщение..."
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
value={message}
|
text={message}
|
||||||
onChange={(e) => setMessage(e.target.value)}
|
rows={1}
|
||||||
/>
|
onTextChange={(value) => setMessage(value)} />
|
||||||
<button type="submit" className="send-btn">
|
<button type="submit" className="send-btn">
|
||||||
<span className="material-symbols filled">send</span>
|
<span className="material-symbols filled">send</span>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
</div>
|
</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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user