Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions app/src/components/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -46,6 +47,34 @@ export const Composer = (props: Props) => {
const theme = useTheme()
const emojiPicker = useEmojiPicker()

// 手打ちショートコードの自動解決(emojiDictRefで無限ループ回避)
const emojiDictRef = useRef(emojiDict)
emojiDictRef.current = emojiDict

useEffect(() => {
const shortcodeRegex = /:([\w+-]+):/g
const matches = [...draft.matchAll(shortcodeRegex)]
if (matches.length === 0) return

const currentDict = emojiDictRef.current
const newEntries: Record<string, { imageURL: string }> = {}
for (const match of matches) {
const code = match[1]
if (currentDict[code]) continue
for (const pkg of emojiPicker.packages) {
const found = pkg.emojis.find((e) => e.shortcode === code)
if (found) {
newEntries[code] = { imageURL: found.imageURL }
break
}
}
}

if (Object.keys(newEntries).length > 0) {
setEmojiDict((prev) => ({ ...prev, ...newEntries }))
}
}, [draft, emojiPicker.packages])

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

enterで確定でいいので、ここはなくてもいいかも?

const [viewportHeight, setViewportHeight] = useLocalStorage<number>(
'composerViewportHeight',
visualViewport?.height ?? 0
Expand Down Expand Up @@ -412,6 +441,16 @@ export const Composer = (props: Props) => {
</div>
)}

{/* 絵文字サジェスト */}
{props.mode !== 'reroute' && (
<EmojiSuggestion
textareaRef={textareaRef}
text={draft}
setText={setDraft}
updateEmojiDict={setEmojiDict}
/>
)}

{/* テキストプレビュー(絵文字等のレンダリング確認用) */}
{props.mode !== 'reroute' && draft.length > 0 && (
<>
Expand Down
147 changes: 147 additions & 0 deletions app/src/components/EmojiSuggestion.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { useEffect, useMemo, useState } from 'react'
import { AnimatePresence, motion } from 'motion/react'
import { useEmojiPicker, Emoji } from '../contexts/EmojiPicker'
import { CssVar } from '../types/Theme'

interface Props {
textareaRef: React.RefObject<HTMLTextAreaElement | null>
text: string
setText: (text: string) => void
updateEmojiDict: React.Dispatch<React.SetStateAction<Record<string, { imageURL: string }>>>
}

export const EmojiSuggestion = (props: Props) => {
const emojiPicker = useEmojiPicker()

const [cursorPos, setCursorPos] = useState<number>(0)
const [forceOff, setForceOff] = useState(false)

// カーソル位置を追跡
useEffect(() => {
const ta = props.textareaRef.current
if (!ta) return

const updateCursor = () => {
setCursorPos(ta.selectionEnd ?? 0)
setForceOff(false)
}

ta.addEventListener('input', updateCursor)
ta.addEventListener('click', updateCursor)
ta.addEventListener('keyup', updateCursor)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

keyupで、Enterなら確定というロジックを入れたい
concrnt-worldの対象コード: https://github.com/concrnt/concrnt-world/blob/develop/app/src/components/Editor/EmojiSuggestion.tsx#L61-L81


return () => {
ta.removeEventListener('input', updateCursor)
ta.removeEventListener('click', updateCursor)
ta.removeEventListener('keyup', updateCursor)
}
}, [props.textareaRef])

// カーソル前のテキストから `:query` パターンを検出
const query = useMemo(() => {
const before = props.text.slice(0, cursorPos)
const match = /:(\w+)$/.exec(before)
return match?.[1] ?? null
}, [props.text, cursorPos])

// 検索結果
const suggestions = useMemo(() => {
if (!query) return []
return emojiPicker.search(query, 16)
}, [query, emojiPicker])

const showSuggestions = query !== null && suggestions.length > 0 && !forceOff

const onConfirm = (emoji: Emoji) => {
const before = props.text.slice(0, cursorPos)
const after = props.text.slice(cursorPos)
const colonPos = before.lastIndexOf(':')
if (colonPos === -1) return

const newText = before.slice(0, colonPos) + `:${emoji.shortcode}: ` + after
props.setText(newText)

props.updateEmojiDict((prev) => ({
...prev,
[emoji.shortcode]: { imageURL: emoji.imageURL }
}))

setForceOff(true)

// カーソルを挿入位置の後ろに移動
requestAnimationFrame(() => {
const ta = props.textareaRef.current
if (ta) {
const newPos = colonPos + emoji.shortcode.length + 3 // `:shortcode: ` の長さ
ta.setSelectionRange(newPos, newPos)
ta.focus()
}
})
}

return (
<AnimatePresence>
{showSuggestions && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.15 }}
style={{ overflow: 'hidden' }}
>
<div
style={{
display: 'flex',
overflowX: 'auto',
gap: CssVar.space(1),
padding: `${CssVar.space(1)} 0`,
WebkitOverflowScrolling: 'touch'
}}
>
{suggestions.map((emoji) => (
<button
key={emoji.shortcode}
onMouseDown={(e) => {
e.preventDefault() // textareaのblurを防ぐ
onConfirm(emoji)
}}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '2px',
padding: CssVar.space(1),
border: 'none',
background: `rgb(from ${CssVar.contentText} r g b / 0.06)`,
borderRadius: CssVar.round(0.5),
cursor: 'pointer',
flexShrink: 0,
WebkitTapHighlightColor: 'transparent',
color: CssVar.contentText
}}
>
<img
src={emoji.imageURL}
alt={emoji.shortcode}
style={{ width: '28px', height: '28px' }}
/>
<span
style={{
fontSize: '10px',
opacity: 0.6,
maxWidth: '56px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
>
{emoji.shortcode}
</span>
</button>
))}
</div>
</motion.div>
)}
</AnimatePresence>
)
}
39 changes: 39 additions & 0 deletions web/src/components/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -46,6 +47,34 @@ export const Composer = (props: Props) => {
const theme = useTheme()
const emojiPicker = useEmojiPicker()

// 手打ちショートコードの自動解決(emojiDictRefで無限ループ回避)
const emojiDictRef = useRef(emojiDict)
emojiDictRef.current = emojiDict

useEffect(() => {
const shortcodeRegex = /:([\w+-]+):/g
const matches = [...draft.matchAll(shortcodeRegex)]
if (matches.length === 0) return

const currentDict = emojiDictRef.current
const newEntries: Record<string, { imageURL: string }> = {}
for (const match of matches) {
const code = match[1]
if (currentDict[code]) continue
for (const pkg of emojiPicker.packages) {
const found = pkg.emojis.find((e) => e.shortcode === code)
if (found) {
newEntries[code] = { imageURL: found.imageURL }
break
}
}
}

if (Object.keys(newEntries).length > 0) {
setEmojiDict((prev) => ({ ...prev, ...newEntries }))
}
}, [draft, emojiPicker.packages])

const [viewportHeight, setViewportHeight] = useLocalStorage<number>(
'composerViewportHeight',
visualViewport?.height ?? 0
Expand Down Expand Up @@ -412,6 +441,16 @@ export const Composer = (props: Props) => {
</div>
)}

{/* 絵文字サジェスト */}
{props.mode !== 'reroute' && (
<EmojiSuggestion
textareaRef={textareaRef}
text={draft}
setText={setDraft}
updateEmojiDict={setEmojiDict}
/>
)}

{/* テキストプレビュー(絵文字等のレンダリング確認用) */}
{props.mode !== 'reroute' && draft.length > 0 && (
<>
Expand Down
Loading
Loading