feat(chat): выбор эмодзи в панели чата
Кнопка со смайликом слева от поля ввода открывает поповер с небольшим собственным набором популярных эмодзи (без библиотеки-пикера) — выбор вставляется в позицию курсора. Поповер открывается вверх от кнопки, поэтому на мобильном никогда не перекрывает textarea; закрытие — по клику вне и по Escape (переиспользован useModalDismiss).
This commit is contained in:
@@ -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<HTMLTextAreaElement>) {
|
||||
@@ -55,10 +75,27 @@ export function ChatPanel({ messages, status, statusMessage, onSend, onClose }:
|
||||
|
||||
function handleDraftChange(event: ChangeEvent<HTMLTextAreaElement>) {
|
||||
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()
|
||||
}}
|
||||
>
|
||||
<EmojiPickerButton onSelect={insertEmoji} disabled={status !== 'open'} />
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
@@ -118,3 +156,77 @@ export function ChatPanel({ messages, status, statusMessage, onSend, onClose }:
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
interface EmojiPickerButtonProps {
|
||||
onSelect: (emoji: string) => void
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Кнопка с поповером выбора эмодзи слева от поля ввода. Поповер открывается
|
||||
* ВВЕРХ от кнопки (как `.tb-menu` тулбара) — на мобильном чат-панель занята
|
||||
* полем ввода снизу, а место над тулбаром свободно, так поповер никогда не
|
||||
* перекрывает textarea.
|
||||
*/
|
||||
function EmojiPickerButton({ onSelect, disabled }: EmojiPickerButtonProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const wrapRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Закрытие по клику вне — тем же паттерном, что меню «Вид» (StageViewMenu):
|
||||
// слушатель на document, пока поповер открыт.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
function handlePointerDown(event: MouseEvent) {
|
||||
if (wrapRef.current && !wrapRef.current.contains(event.target as Node)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handlePointerDown)
|
||||
return () => document.removeEventListener('mousedown', handlePointerDown)
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<div className="chat-emoji-wrap" ref={wrapRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`chat-emoji-trigger${open ? ' is-open' : ''}`}
|
||||
aria-label="Вставить эмодзи"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="dialog"
|
||||
disabled={disabled}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<SmilePlus className="lucide" aria-hidden="true" />
|
||||
</button>
|
||||
{open && (
|
||||
<EmojiPopover
|
||||
onSelect={(emoji) => {
|
||||
onSelect(emoji)
|
||||
setOpen(false)
|
||||
}}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface EmojiPopoverProps {
|
||||
onSelect: (emoji: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/** Отдельный компонент — чтобы `useModalDismiss` (Escape + возврат фокуса) жил ровно то время, пока поповер открыт. */
|
||||
function EmojiPopover({ onSelect, onClose }: EmojiPopoverProps) {
|
||||
useModalDismiss(onClose)
|
||||
|
||||
return (
|
||||
<div className="chat-emoji-popover" role="dialog" aria-label="Выбор эмодзи">
|
||||
{EMOJI_OPTIONS.map((emoji) => (
|
||||
<button key={emoji} type="button" className="chat-emoji-option" onClick={() => onSelect(emoji)}>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user