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 {
|
||||
position: relative;
|
||||
margin: 0 20px 20px 20px;
|
||||
|
||||
&::before {
|
||||
$height: 20px;
|
||||
@@ -278,24 +279,32 @@
|
||||
);
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
margin: 0 20px 20px 20px;
|
||||
background-color: $color-dark-surface-container;
|
||||
border-radius: 40px;
|
||||
|
||||
.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;
|
||||
resize: none;
|
||||
font: inherit;
|
||||
font-size: 13pt;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
@@ -311,6 +320,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background-color 0.25s ease;
|
||||
align-self: flex-end;
|
||||
|
||||
@include hoverStateLayer($background: $color-dark-primary);
|
||||
}
|
||||
@@ -318,7 +328,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-profile-pic {
|
||||
|
||||
@@ -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"
|
||||
<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>
|
||||
</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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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