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 { 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 type { ChatConnectionStatus, ChatMessageOut } from '@/hooks/useChat'
|
||||||
import { formatLocalTime } from '@/lib/localTime'
|
import { formatLocalTime } from '@/lib/localTime'
|
||||||
|
import { useModalDismiss } from '@/hooks/useModalDismiss'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Небольшой собственный набор популярных эмодзи — вместо библиотеки-пикера на
|
||||||
|
* сотни килобайт ради десятка кнопок в поповере.
|
||||||
|
*/
|
||||||
|
const EMOJI_OPTIONS = [
|
||||||
|
'😀', '😂', '😊', '😉', '😍', '🤔', '😅', '😢',
|
||||||
|
'😮', '😎', '🙌', '👍', '👎', '👏', '🙏', '❤️',
|
||||||
|
'🔥', '🎉', '✅', '❌', '⚠️', '💡', '👀', '🤝',
|
||||||
|
]
|
||||||
|
|
||||||
interface ChatPanelProps {
|
interface ChatPanelProps {
|
||||||
messages: ChatMessageOut[]
|
messages: ChatMessageOut[]
|
||||||
@@ -38,12 +49,21 @@ export function ChatPanel({ messages, status, statusMessage, onSend, onClose }:
|
|||||||
}
|
}
|
||||||
}, [messages])
|
}, [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() {
|
function handleSend() {
|
||||||
const text = draft.trim()
|
const text = draft.trim()
|
||||||
if (!text || status !== 'open') return
|
if (!text || status !== 'open') return
|
||||||
onSend(text)
|
onSend(text)
|
||||||
setDraft('')
|
setDraft('')
|
||||||
if (textareaRef.current) textareaRef.current.style.height = 'auto'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
|
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
|
||||||
@@ -55,10 +75,27 @@ export function ChatPanel({ messages, status, statusMessage, onSend, onClose }:
|
|||||||
|
|
||||||
function handleDraftChange(event: ChangeEvent<HTMLTextAreaElement>) {
|
function handleDraftChange(event: ChangeEvent<HTMLTextAreaElement>) {
|
||||||
setDraft(event.target.value)
|
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 (
|
return (
|
||||||
@@ -101,6 +138,7 @@ export function ChatPanel({ messages, status, statusMessage, onSend, onClose }:
|
|||||||
handleSend()
|
handleSend()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<EmojiPickerButton onSelect={insertEmoji} disabled={status !== 'open'} />
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
rows={1}
|
rows={1}
|
||||||
@@ -118,3 +156,77 @@ export function ChatPanel({ messages, status, statusMessage, onSend, onClose }:
|
|||||||
</aside>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -497,6 +497,49 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
|
|||||||
}
|
}
|
||||||
.chat-input-row button:disabled { opacity: 0.5; cursor: default; }
|
.chat-input-row button:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
|
||||||
|
.chat-emoji-wrap { position: relative; display: flex; flex-shrink: 0; }
|
||||||
|
.chat-emoji-trigger {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-room-tile);
|
||||||
|
border: 1px solid var(--color-room-tile-border);
|
||||||
|
color: var(--color-room-text-secondary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.chat-emoji-trigger:hover { color: var(--color-room-text-primary); }
|
||||||
|
.chat-emoji-trigger.is-open { color: var(--color-room-mic-on); border-color: var(--color-room-speaker-ring); }
|
||||||
|
.chat-emoji-trigger:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
|
||||||
|
.chat-emoji-popover {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + var(--space-2));
|
||||||
|
left: 0;
|
||||||
|
z-index: 50;
|
||||||
|
width: 224px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, 1fr);
|
||||||
|
gap: 2px;
|
||||||
|
padding: var(--space-3);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--color-room-tile-border);
|
||||||
|
background: var(--color-room-surface-raised);
|
||||||
|
box-shadow: var(--shadow-room-panel);
|
||||||
|
}
|
||||||
|
.chat-emoji-option {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.chat-emoji-option:hover { background: var(--color-room-tile); }
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.chat-panel {
|
.chat-panel {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
Reference in New Issue
Block a user