diff --git a/app/src/components/Composer.tsx b/app/src/components/Composer.tsx index 43a2ab6..790a868 100644 --- a/app/src/components/Composer.tsx +++ b/app/src/components/Composer.tsx @@ -15,6 +15,7 @@ import { hapticSuccess } from '../utils/haptics' import { MdSend } from 'react-icons/md' import { MdEmojiEmotions } from 'react-icons/md' import { useEmojiPicker, Emoji } from '../contexts/EmojiPicker' +import { EmojiSuggestion } from './EmojiSuggestion' import { MdOutlineUploadFile } from 'react-icons/md' import { CDID } from '@concrnt/client' @@ -412,6 +413,16 @@ export const Composer = (props: Props) => { )} + {/* 絵文字サジェスト */} + {props.mode !== 'reroute' && ( + + )} + {/* テキストプレビュー(絵文字等のレンダリング確認用) */} {props.mode !== 'reroute' && draft.length > 0 && ( <> diff --git a/app/src/components/EmojiSuggestion.tsx b/app/src/components/EmojiSuggestion.tsx new file mode 100644 index 0000000..e87d3c1 --- /dev/null +++ b/app/src/components/EmojiSuggestion.tsx @@ -0,0 +1,206 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { AnimatePresence, motion } from 'motion/react' +import { useEmojiPicker } from '../contexts/EmojiPicker' +import { CssVar } from '../types/Theme' + +interface Props { + textareaRef: React.RefObject + text: string + setText: (text: string) => void + updateEmojiDict: React.Dispatch>> +} + +export const EmojiSuggestion = ({ textareaRef, text, setText, updateEmojiDict }: Props) => { + const emojiPicker = useEmojiPicker() + + const [cursorPos, setCursorPos] = useState(0) + const [forceOff, setForceOff] = useState(false) + const [selectedIndex, setSelectedIndex] = useState(0) + + // カーソル前のテキストから `:query` パターンを検出 + const query = useMemo(() => { + const before = text.slice(0, cursorPos) + const match = /:(\w+)$/.exec(before) + return match?.[1] ?? null + }, [text, cursorPos]) + + // 検索結果 + const suggestions = useMemo(() => { + if (!query) return [] + return emojiPicker.search(query, 16) + }, [query, emojiPicker]) + + const showSuggestions = query !== null && suggestions.length > 0 && !forceOff + + // query が変わったら選択をリセット(レンダー中のstate調整パターン) + const [prevQuery, setPrevQuery] = useState(query) + if (query !== prevQuery) { + setPrevQuery(query) + setSelectedIndex(0) + } + + const onConfirm = useCallback( + (index: number) => { + const before = text.slice(0, cursorPos) + const after = text.slice(cursorPos) + const colonPos = before.lastIndexOf(':') + if (colonPos === -1) return + + const emoji = suggestions[index] + if (!emoji) return + + const newText = before.slice(0, colonPos) + `:${emoji.shortcode}: ` + after + setText(newText) + setSelectedIndex(0) + + updateEmojiDict((prev) => ({ + ...prev, + [emoji.shortcode]: { imageURL: emoji.imageURL } + })) + + setForceOff(true) + + // カーソルを挿入位置の後ろに移動 + requestAnimationFrame(() => { + const ta = textareaRef.current + if (ta) { + const newPos = colonPos + emoji.shortcode.length + 3 + ta.setSelectionRange(newPos, newPos) + ta.focus() + } + }) + }, + [text, cursorPos, suggestions, textareaRef, setText, updateEmojiDict] + ) + + // カーソル位置の追跡 + useEffect(() => { + const ta = textareaRef.current + if (!ta) return + + const updateCursor = () => { + setCursorPos(ta.selectionEnd ?? 0) + setForceOff(false) + } + + ta.addEventListener('input', updateCursor) + ta.addEventListener('click', updateCursor) + + return () => { + ta.removeEventListener('input', updateCursor) + ta.removeEventListener('click', updateCursor) + } + }, [textareaRef]) + + // keydown: Enter確定 + 矢印キー移動(サジェスト表示中のみ) + const onKeyDown = useCallback( + (e: KeyboardEvent) => { + setForceOff(false) + if (!showSuggestions) return + + if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') { + e.preventDefault() + setSelectedIndex((prev) => (prev - 1 + suggestions.length) % suggestions.length) + return + } + if (e.key === 'ArrowDown' || e.key === 'ArrowRight') { + e.preventDefault() + setSelectedIndex((prev) => (prev + 1) % suggestions.length) + return + } + if (e.key === 'Enter') { + e.preventDefault() + onConfirm(selectedIndex) + } + }, + [showSuggestions, suggestions.length, selectedIndex, onConfirm] + ) + + const onBlur = useCallback(() => { + setTimeout(() => { + setForceOff(true) + }, 100) + }, []) + + useEffect(() => { + const ta = textareaRef.current + if (!ta) return + + ta.addEventListener('keydown', onKeyDown) + ta.addEventListener('blur', onBlur) + + return () => { + ta.removeEventListener('keydown', onKeyDown) + ta.removeEventListener('blur', onBlur) + } + }, [textareaRef, onKeyDown, onBlur]) + + return ( + + {showSuggestions && ( + + + {suggestions.map((emoji, index) => ( + { + e.preventDefault() + onConfirm(index) + }} + style={{ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: '2px', + padding: CssVar.space(1), + border: + index === selectedIndex + ? `2px solid ${CssVar.contentLink}` + : '2px solid transparent', + background: `rgb(from ${CssVar.contentText} r g b / 0.06)`, + borderRadius: CssVar.round(0.5), + cursor: 'pointer', + flexShrink: 0, + WebkitTapHighlightColor: 'transparent', + color: CssVar.contentText + }} + > + + + {emoji.shortcode} + + + ))} + + + )} + + ) +} diff --git a/web/src/components/Composer.tsx b/web/src/components/Composer.tsx index 731ff57..4cccddc 100644 --- a/web/src/components/Composer.tsx +++ b/web/src/components/Composer.tsx @@ -15,6 +15,7 @@ import { hapticSuccess } from '../utils/haptics' import { MdSend } from 'react-icons/md' import { MdEmojiEmotions } from 'react-icons/md' import { useEmojiPicker, Emoji } from '../contexts/EmojiPicker' +import { EmojiSuggestion } from './EmojiSuggestion' import { MdOutlineUploadFile } from 'react-icons/md' import { CDID } from '@concrnt/client' @@ -412,6 +413,16 @@ export const Composer = (props: Props) => { )} + {/* 絵文字サジェスト */} + {props.mode !== 'reroute' && ( + + )} + {/* テキストプレビュー(絵文字等のレンダリング確認用) */} {props.mode !== 'reroute' && draft.length > 0 && ( <> diff --git a/web/src/components/EmojiSuggestion.tsx b/web/src/components/EmojiSuggestion.tsx new file mode 100644 index 0000000..e87d3c1 --- /dev/null +++ b/web/src/components/EmojiSuggestion.tsx @@ -0,0 +1,206 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { AnimatePresence, motion } from 'motion/react' +import { useEmojiPicker } from '../contexts/EmojiPicker' +import { CssVar } from '../types/Theme' + +interface Props { + textareaRef: React.RefObject + text: string + setText: (text: string) => void + updateEmojiDict: React.Dispatch>> +} + +export const EmojiSuggestion = ({ textareaRef, text, setText, updateEmojiDict }: Props) => { + const emojiPicker = useEmojiPicker() + + const [cursorPos, setCursorPos] = useState(0) + const [forceOff, setForceOff] = useState(false) + const [selectedIndex, setSelectedIndex] = useState(0) + + // カーソル前のテキストから `:query` パターンを検出 + const query = useMemo(() => { + const before = text.slice(0, cursorPos) + const match = /:(\w+)$/.exec(before) + return match?.[1] ?? null + }, [text, cursorPos]) + + // 検索結果 + const suggestions = useMemo(() => { + if (!query) return [] + return emojiPicker.search(query, 16) + }, [query, emojiPicker]) + + const showSuggestions = query !== null && suggestions.length > 0 && !forceOff + + // query が変わったら選択をリセット(レンダー中のstate調整パターン) + const [prevQuery, setPrevQuery] = useState(query) + if (query !== prevQuery) { + setPrevQuery(query) + setSelectedIndex(0) + } + + const onConfirm = useCallback( + (index: number) => { + const before = text.slice(0, cursorPos) + const after = text.slice(cursorPos) + const colonPos = before.lastIndexOf(':') + if (colonPos === -1) return + + const emoji = suggestions[index] + if (!emoji) return + + const newText = before.slice(0, colonPos) + `:${emoji.shortcode}: ` + after + setText(newText) + setSelectedIndex(0) + + updateEmojiDict((prev) => ({ + ...prev, + [emoji.shortcode]: { imageURL: emoji.imageURL } + })) + + setForceOff(true) + + // カーソルを挿入位置の後ろに移動 + requestAnimationFrame(() => { + const ta = textareaRef.current + if (ta) { + const newPos = colonPos + emoji.shortcode.length + 3 + ta.setSelectionRange(newPos, newPos) + ta.focus() + } + }) + }, + [text, cursorPos, suggestions, textareaRef, setText, updateEmojiDict] + ) + + // カーソル位置の追跡 + useEffect(() => { + const ta = textareaRef.current + if (!ta) return + + const updateCursor = () => { + setCursorPos(ta.selectionEnd ?? 0) + setForceOff(false) + } + + ta.addEventListener('input', updateCursor) + ta.addEventListener('click', updateCursor) + + return () => { + ta.removeEventListener('input', updateCursor) + ta.removeEventListener('click', updateCursor) + } + }, [textareaRef]) + + // keydown: Enter確定 + 矢印キー移動(サジェスト表示中のみ) + const onKeyDown = useCallback( + (e: KeyboardEvent) => { + setForceOff(false) + if (!showSuggestions) return + + if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') { + e.preventDefault() + setSelectedIndex((prev) => (prev - 1 + suggestions.length) % suggestions.length) + return + } + if (e.key === 'ArrowDown' || e.key === 'ArrowRight') { + e.preventDefault() + setSelectedIndex((prev) => (prev + 1) % suggestions.length) + return + } + if (e.key === 'Enter') { + e.preventDefault() + onConfirm(selectedIndex) + } + }, + [showSuggestions, suggestions.length, selectedIndex, onConfirm] + ) + + const onBlur = useCallback(() => { + setTimeout(() => { + setForceOff(true) + }, 100) + }, []) + + useEffect(() => { + const ta = textareaRef.current + if (!ta) return + + ta.addEventListener('keydown', onKeyDown) + ta.addEventListener('blur', onBlur) + + return () => { + ta.removeEventListener('keydown', onKeyDown) + ta.removeEventListener('blur', onBlur) + } + }, [textareaRef, onKeyDown, onBlur]) + + return ( + + {showSuggestions && ( + + + {suggestions.map((emoji, index) => ( + { + e.preventDefault() + onConfirm(index) + }} + style={{ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: '2px', + padding: CssVar.space(1), + border: + index === selectedIndex + ? `2px solid ${CssVar.contentLink}` + : '2px solid transparent', + background: `rgb(from ${CssVar.contentText} r g b / 0.06)`, + borderRadius: CssVar.round(0.5), + cursor: 'pointer', + flexShrink: 0, + WebkitTapHighlightColor: 'transparent', + color: CssVar.contentText + }} + > + + + {emoji.shortcode} + + + ))} + + + )} + + ) +}