Skip to content

Commit a71be43

Browse files
authored
Merge pull request #76 from concrnt/emojiPreview
絵文字のショートコードのサジェストを実装しました
2 parents 0ce9fef + bcef61c commit a71be43

4 files changed

Lines changed: 434 additions & 0 deletions

File tree

app/src/components/Composer.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { hapticSuccess } from '../utils/haptics'
1515
import { MdSend } from 'react-icons/md'
1616
import { MdEmojiEmotions } from 'react-icons/md'
1717
import { useEmojiPicker, Emoji } from '../contexts/EmojiPicker'
18+
import { EmojiSuggestion } from './EmojiSuggestion'
1819
import { MdOutlineUploadFile } from 'react-icons/md'
1920
import { CDID } from '@concrnt/client'
2021

@@ -412,6 +413,16 @@ export const Composer = (props: Props) => {
412413
</div>
413414
)}
414415

416+
{/* 絵文字サジェスト */}
417+
{props.mode !== 'reroute' && (
418+
<EmojiSuggestion
419+
textareaRef={textareaRef}
420+
text={draft}
421+
setText={setDraft}
422+
updateEmojiDict={setEmojiDict}
423+
/>
424+
)}
425+
415426
{/* テキストプレビュー(絵文字等のレンダリング確認用) */}
416427
{props.mode !== 'reroute' && draft.length > 0 && (
417428
<>
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
import { useCallback, useEffect, useMemo, useState } from 'react'
2+
import { AnimatePresence, motion } from 'motion/react'
3+
import { useEmojiPicker } from '../contexts/EmojiPicker'
4+
import { CssVar } from '../types/Theme'
5+
6+
interface Props {
7+
textareaRef: React.RefObject<HTMLTextAreaElement | null>
8+
text: string
9+
setText: (text: string) => void
10+
updateEmojiDict: React.Dispatch<React.SetStateAction<Record<string, { imageURL: string }>>>
11+
}
12+
13+
export const EmojiSuggestion = ({ textareaRef, text, setText, updateEmojiDict }: Props) => {
14+
const emojiPicker = useEmojiPicker()
15+
16+
const [cursorPos, setCursorPos] = useState<number>(0)
17+
const [forceOff, setForceOff] = useState(false)
18+
const [selectedIndex, setSelectedIndex] = useState<number>(0)
19+
20+
// カーソル前のテキストから `:query` パターンを検出
21+
const query = useMemo(() => {
22+
const before = text.slice(0, cursorPos)
23+
const match = /:(\w+)$/.exec(before)
24+
return match?.[1] ?? null
25+
}, [text, cursorPos])
26+
27+
// 検索結果
28+
const suggestions = useMemo(() => {
29+
if (!query) return []
30+
return emojiPicker.search(query, 16)
31+
}, [query, emojiPicker])
32+
33+
const showSuggestions = query !== null && suggestions.length > 0 && !forceOff
34+
35+
// query が変わったら選択をリセット(レンダー中のstate調整パターン)
36+
const [prevQuery, setPrevQuery] = useState(query)
37+
if (query !== prevQuery) {
38+
setPrevQuery(query)
39+
setSelectedIndex(0)
40+
}
41+
42+
const onConfirm = useCallback(
43+
(index: number) => {
44+
const before = text.slice(0, cursorPos)
45+
const after = text.slice(cursorPos)
46+
const colonPos = before.lastIndexOf(':')
47+
if (colonPos === -1) return
48+
49+
const emoji = suggestions[index]
50+
if (!emoji) return
51+
52+
const newText = before.slice(0, colonPos) + `:${emoji.shortcode}: ` + after
53+
setText(newText)
54+
setSelectedIndex(0)
55+
56+
updateEmojiDict((prev) => ({
57+
...prev,
58+
[emoji.shortcode]: { imageURL: emoji.imageURL }
59+
}))
60+
61+
setForceOff(true)
62+
63+
// カーソルを挿入位置の後ろに移動
64+
requestAnimationFrame(() => {
65+
const ta = textareaRef.current
66+
if (ta) {
67+
const newPos = colonPos + emoji.shortcode.length + 3
68+
ta.setSelectionRange(newPos, newPos)
69+
ta.focus()
70+
}
71+
})
72+
},
73+
[text, cursorPos, suggestions, textareaRef, setText, updateEmojiDict]
74+
)
75+
76+
// カーソル位置の追跡
77+
useEffect(() => {
78+
const ta = textareaRef.current
79+
if (!ta) return
80+
81+
const updateCursor = () => {
82+
setCursorPos(ta.selectionEnd ?? 0)
83+
setForceOff(false)
84+
}
85+
86+
ta.addEventListener('input', updateCursor)
87+
ta.addEventListener('click', updateCursor)
88+
89+
return () => {
90+
ta.removeEventListener('input', updateCursor)
91+
ta.removeEventListener('click', updateCursor)
92+
}
93+
}, [textareaRef])
94+
95+
// keydown: Enter確定 + 矢印キー移動(サジェスト表示中のみ)
96+
const onKeyDown = useCallback(
97+
(e: KeyboardEvent) => {
98+
setForceOff(false)
99+
if (!showSuggestions) return
100+
101+
if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') {
102+
e.preventDefault()
103+
setSelectedIndex((prev) => (prev - 1 + suggestions.length) % suggestions.length)
104+
return
105+
}
106+
if (e.key === 'ArrowDown' || e.key === 'ArrowRight') {
107+
e.preventDefault()
108+
setSelectedIndex((prev) => (prev + 1) % suggestions.length)
109+
return
110+
}
111+
if (e.key === 'Enter') {
112+
e.preventDefault()
113+
onConfirm(selectedIndex)
114+
}
115+
},
116+
[showSuggestions, suggestions.length, selectedIndex, onConfirm]
117+
)
118+
119+
const onBlur = useCallback(() => {
120+
setTimeout(() => {
121+
setForceOff(true)
122+
}, 100)
123+
}, [])
124+
125+
useEffect(() => {
126+
const ta = textareaRef.current
127+
if (!ta) return
128+
129+
ta.addEventListener('keydown', onKeyDown)
130+
ta.addEventListener('blur', onBlur)
131+
132+
return () => {
133+
ta.removeEventListener('keydown', onKeyDown)
134+
ta.removeEventListener('blur', onBlur)
135+
}
136+
}, [textareaRef, onKeyDown, onBlur])
137+
138+
return (
139+
<AnimatePresence>
140+
{showSuggestions && (
141+
<motion.div
142+
initial={{ opacity: 0, height: 0 }}
143+
animate={{ opacity: 1, height: 'auto' }}
144+
exit={{ opacity: 0, height: 0 }}
145+
transition={{ duration: 0.15 }}
146+
style={{ overflow: 'hidden' }}
147+
>
148+
<div
149+
style={{
150+
display: 'flex',
151+
overflowX: 'auto',
152+
gap: CssVar.space(1),
153+
padding: `${CssVar.space(1)} 0`,
154+
WebkitOverflowScrolling: 'touch'
155+
}}
156+
>
157+
{suggestions.map((emoji, index) => (
158+
<button
159+
key={emoji.shortcode}
160+
onMouseDown={(e) => {
161+
e.preventDefault()
162+
onConfirm(index)
163+
}}
164+
style={{
165+
display: 'flex',
166+
flexDirection: 'column',
167+
alignItems: 'center',
168+
gap: '2px',
169+
padding: CssVar.space(1),
170+
border:
171+
index === selectedIndex
172+
? `2px solid ${CssVar.contentLink}`
173+
: '2px solid transparent',
174+
background: `rgb(from ${CssVar.contentText} r g b / 0.06)`,
175+
borderRadius: CssVar.round(0.5),
176+
cursor: 'pointer',
177+
flexShrink: 0,
178+
WebkitTapHighlightColor: 'transparent',
179+
color: CssVar.contentText
180+
}}
181+
>
182+
<img
183+
src={emoji.imageURL}
184+
alt={emoji.shortcode}
185+
style={{ width: '28px', height: '28px' }}
186+
/>
187+
<span
188+
style={{
189+
fontSize: '10px',
190+
opacity: 0.6,
191+
maxWidth: '56px',
192+
overflow: 'hidden',
193+
textOverflow: 'ellipsis',
194+
whiteSpace: 'nowrap'
195+
}}
196+
>
197+
{emoji.shortcode}
198+
</span>
199+
</button>
200+
))}
201+
</div>
202+
</motion.div>
203+
)}
204+
</AnimatePresence>
205+
)
206+
}

web/src/components/Composer.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { hapticSuccess } from '../utils/haptics'
1515
import { MdSend } from 'react-icons/md'
1616
import { MdEmojiEmotions } from 'react-icons/md'
1717
import { useEmojiPicker, Emoji } from '../contexts/EmojiPicker'
18+
import { EmojiSuggestion } from './EmojiSuggestion'
1819
import { MdOutlineUploadFile } from 'react-icons/md'
1920
import { CDID } from '@concrnt/client'
2021

@@ -412,6 +413,16 @@ export const Composer = (props: Props) => {
412413
</div>
413414
)}
414415

416+
{/* 絵文字サジェスト */}
417+
{props.mode !== 'reroute' && (
418+
<EmojiSuggestion
419+
textareaRef={textareaRef}
420+
text={draft}
421+
setText={setDraft}
422+
updateEmojiDict={setEmojiDict}
423+
/>
424+
)}
425+
415426
{/* テキストプレビュー(絵文字等のレンダリング確認用) */}
416427
{props.mode !== 'reroute' && draft.length > 0 && (
417428
<>

0 commit comments

Comments
 (0)