From 82204553e2d1a100bffd4542f50be85c56869d8f Mon Sep 17 00:00:00 2001 From: Max Ronzhin Date: Thu, 30 Jul 2026 00:23:53 +0300 Subject: [PATCH] =?UTF-8?q?feat(chat):=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=20?= =?UTF-8?q?=D1=8D=D0=BC=D0=BE=D0=B4=D0=B7=D0=B8=20=D0=B2=20=D0=BF=D0=B0?= =?UTF-8?q?=D0=BD=D0=B5=D0=BB=D0=B8=20=D1=87=D0=B0=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Кнопка со смайликом слева от поля ввода открывает поповер с небольшим собственным набором популярных эмодзи (без библиотеки-пикера) — выбор вставляется в позицию курсора. Поповер открывается вверх от кнопки, поэтому на мобильном никогда не перекрывает textarea; закрытие — по клику вне и по Escape (переиспользован useModalDismiss). --- frontend/src/components/room/ChatPanel.tsx | 124 ++++++++++++++++++++- frontend/src/styles/room.css | 43 +++++++ 2 files changed, 161 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/room/ChatPanel.tsx b/frontend/src/components/room/ChatPanel.tsx index eac52bd..b6fe30f 100644 --- a/frontend/src/components/room/ChatPanel.tsx +++ b/frontend/src/components/room/ChatPanel.tsx @@ -1,7 +1,18 @@ import { useEffect, useRef, useState, type ChangeEvent, type KeyboardEvent } from 'react' -import { AlertCircle, Send, X } from 'lucide-react' +import { AlertCircle, Send, SmilePlus, X } from 'lucide-react' import type { ChatConnectionStatus, ChatMessageOut } from '@/hooks/useChat' import { formatLocalTime } from '@/lib/localTime' +import { useModalDismiss } from '@/hooks/useModalDismiss' + +/** + * Небольшой собственный набор популярных эмодзи — вместо библиотеки-пикера на + * сотни килобайт ради десятка кнопок в поповере. + */ +const EMOJI_OPTIONS = [ + '😀', '😂', '😊', '😉', '😍', '🤔', '😅', '😢', + '😮', '😎', '🙌', '👍', '👎', '👏', '🙏', '❤️', + '🔥', '🎉', '✅', '❌', '⚠️', '💡', '👀', '🤝', +] interface ChatPanelProps { messages: ChatMessageOut[] @@ -38,12 +49,21 @@ export function ChatPanel({ messages, status, statusMessage, onSend, onClose }: } }, [messages]) + // Авторасширение textarea до 4 строк, дальше — внутренний скролл. Эффектом, + // а не только в обработчике ввода, — так пересчёт срабатывает и после + // программной вставки эмодзи, и после очистки поля отправкой. + useEffect(() => { + const el = textareaRef.current + if (!el) return + el.style.height = 'auto' + el.style.height = `${Math.min(el.scrollHeight, 96)}px` + }, [draft]) + function handleSend() { const text = draft.trim() if (!text || status !== 'open') return onSend(text) setDraft('') - if (textareaRef.current) textareaRef.current.style.height = 'auto' } function handleKeyDown(event: KeyboardEvent) { @@ -55,10 +75,27 @@ export function ChatPanel({ messages, status, statusMessage, onSend, onClose }: function handleDraftChange(event: ChangeEvent) { setDraft(event.target.value) - // Авторасширение textarea до 4 строк, дальше — внутренний скролл. - const el = event.target - el.style.height = 'auto' - el.style.height = `${Math.min(el.scrollHeight, 96)}px` + } + + // Вставка эмодзи в позицию курсора, а не в конец поля, — ожидаемое поведение + // при редактировании текста в середине. + function insertEmoji(emoji: string) { + const el = textareaRef.current + if (!el) { + setDraft((current) => current + emoji) + return + } + const start = el.selectionStart ?? draft.length + const end = el.selectionEnd ?? draft.length + setDraft(draft.slice(0, start) + emoji + draft.slice(end)) + // value обновится реальным React-рендером только на следующий тик — + // курсор возвращаем после него, иначе setSelectionRange встанет на + // ещё старую (более короткую) строку. + requestAnimationFrame(() => { + const pos = start + emoji.length + el.focus() + el.setSelectionRange(pos, pos) + }) } return ( @@ -101,6 +138,7 @@ export function ChatPanel({ messages, status, statusMessage, onSend, onClose }: handleSend() }} > +