Skip to content

Commit

Permalink
Merge pull request #434 from h3poteto/iss-274
Browse files Browse the repository at this point in the history
refs #274 Auto-complete for emoji
  • Loading branch information
h3poteto authored Feb 22, 2023
2 parents fcb07b6 + 4577a8a commit 6e5d9df
Show file tree
Hide file tree
Showing 3 changed files with 220 additions and 4 deletions.
208 changes: 208 additions & 0 deletions src/components/compose/AutoCompleteTextarea.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import { useEffect, useRef, useState, forwardRef, KeyboardEventHandler, Dispatch, SetStateAction } from 'react'
import { Input, Popover, Whisper } from 'rsuite'
import { PrependParameters } from 'rsuite/esm/@types/utils'
import { init, SearchIndex } from 'emoji-mart'
import { data } from 'src/utils/emojiData'
import { CustomEmojiCategory } from 'src/entities/emoji'

export type ArgProps = {
emojis: Array<CustomEmojiCategory>
onChange: (value: string) => void
}

type SuggestItem = {
name: string
code?: string
icon?: string
}

const AutoCompleteTextarea: React.ForwardRefRenderFunction<HTMLTextAreaElement, ArgProps> = (props, ref) => {
const [suggestList, setSuggestList] = useState<Array<SuggestItem>>([])
const [opened, setOpened] = useState(false)
const [highlight, setHighlight] = useState(0)
const [currentValue, setCurrentValue] = useState<string>('')
const [startIndex, setStartIndex] = useState(0)
const [matchWord, setMatchWord] = useState('')

const triggerRef = useRef<any>()

useEffect(() => {
init(data)
}, [])

const onChange: PrependParameters<React.ChangeEventHandler<HTMLInputElement>, [value: string]> = (value, event) => {
const [start, token] = textAtCursorMatchesToken(value, event.target.selectionStart)
if (!start || !token) {
closeSuggestion()
} else {
openSuggestion(start, token)
}
setCurrentValue(value)
props.onChange(value)
}

const closeSuggestion = () => {
setSuggestList([])
setHighlight(0)
setStartIndex(0)
setMatchWord('')
triggerRef.current.close()
}
const openSuggestion = async (start: number, token: string) => {
switch (token.charAt(0)) {
case ':': {
const res = await SearchIndex.search(token.replace(':', ''))
const emojis = res
.map(emoji =>
emoji.skins.map(skin => ({
name: skin.shortcodes,
code: skin.native
}))
)
.flatMap(e => e)
const custom = props.emojis
.map(d => d.emojis.filter(e => e.name.includes(token.replace(':', ''))))
.flatMap(e => e)
.map(emoji =>
emoji.skins.map(skin => ({
name: skin.shortcodes,
icon: skin.src
}))
)
.flatMap(e => e)
setSuggestList([...emojis, ...custom])
setStartIndex(start)
setMatchWord(token)
triggerRef.current.open()
}
}
}

const insertItem = (item: SuggestItem) => {
if (item.code) {
const str = `${currentValue.slice(0, startIndex - 1)}${item.code} ${currentValue.slice(startIndex + matchWord.length)}`
props.onChange(str)
} else {
const str = `${currentValue.slice(0, startIndex - 1)}${item.name} ${currentValue.slice(startIndex + matchWord.length)}`
props.onChange(str)
}
closeSuggestion()
}

const onKeyDown: KeyboardEventHandler<HTMLInputElement> = e => {
if (opened) {
if (e.code === 'ArrowDown') {
setHighlight(prev => (prev + 1 < suggestList.length ? prev + 1 : prev))
e.preventDefault()
}
if (e.code === 'ArrowUp') {
setHighlight(prev => (prev > 0 ? prev - 1 : 0))
e.preventDefault()
}
if (e.code === 'Enter') {
insertItem(suggestList[highlight])
e.preventDefault()
}
if (e.code === 'Escape') {
closeSuggestion()
e.preventDefault()
}
}
}

return (
<>
<Input {...props} as="textarea" ref={ref} onChange={onChange} onKeyDown={onKeyDown} />
<Whisper
placement="bottomStart"
speaker={({ className, left, top }, ref) => (
<AutoCompleteList
className={className}
left={left}
top={top}
data={suggestList}
onSelect={insertItem}
highlight={highlight}
setHighlight={setHighlight}
ref={ref}
/>
)}
ref={triggerRef}
trigger="click"
onOpen={() => setOpened(true)}
onClose={() => setOpened(false)}
>
<div></div>
</Whisper>
</>
)
}

type AutoCompleteListProps = {
className: string
left?: number
top?: number
data: Array<SuggestItem>
onSelect: (item: SuggestItem) => void
highlight: number
setHighlight: Dispatch<SetStateAction<number>>
}

const AutoCompleteList = forwardRef<HTMLDivElement, AutoCompleteListProps>((props, ref) => {
const { left, top, className, data, highlight } = props

const select = (index: number) => {
props.onSelect(data[index])
}

return (
<Popover ref={ref} className={className} style={{ left, top }}>
<ul style={{ listStyle: 'none', padding: 0, fontSize: '1.2em' }}>
{data.map((d, index) => (
<li
key={index}
onMouseOver={() => props.setHighlight(index)}
onClick={() => select(index)}
style={{ padding: '4px', backgroundColor: highlight === index ? 'var(--rs-primary-900)' : 'inherit' }}
>
{d.code && <span style={{ paddingRight: '4px' }}>{d.code}</span>}
{d.icon && (
<span style={{ paddingRight: '4px' }}>
<img src={d.icon} style={{ width: '1.2em' }} />
</span>
)}
<span>{d.name}</span>
</li>
))}
</ul>
</Popover>
)
})

// Refs: https://github.com/mastodon/mastodon/blob/7ecf783dd3bfc07f80aab495273b6d01ba972c40/app/javascript/mastodon/components/autosuggest_textarea.jsx#L11
const textAtCursorMatchesToken = (str: string, caretPosition: number): [number | null, string | null] => {
let word: string

const left = str.slice(0, caretPosition).search(/\S+$/)
const right = str.slice(caretPosition).search(/\s/)

if (right < 0) {
word = str.slice(left)
} else {
word = str.slice(left, right + caretPosition)
}

if (!word || word.trim().length < 3 || ['@', ':', '#'].indexOf(word[0]) === -1) {
return [null, null]
}

word = word.trim().toLowerCase()

if (word.length > 0) {
return [left + 1, word]
} else {
return [null, null]
}
}

export default AutoCompleteTextarea
14 changes: 11 additions & 3 deletions src/components/compose/Status.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { CustomEmojiCategory } from 'src/entities/emoji'
import alert from 'src/components/utils/alert'
import { Account } from 'src/entities/account'
import { useTranslation } from 'react-i18next'
import AutoCompleteTextarea, { ArgProps as AutoCompleteTextareaProps } from './AutoCompleteTextarea'

type Props = {
server: Server
Expand Down Expand Up @@ -102,7 +103,7 @@ const Status: React.FC<Props> = props => {
id: e.name,
name: e.name,
keywords: [e.name],
skins: [{ src: e.image }]
skins: [{ src: e.image, shortcodes: `:${e.name}:` }]
}))
}
])
Expand Down Expand Up @@ -342,7 +343,14 @@ const Status: React.FC<Props> = props => {

<Form.Group controlId="status" style={{ position: 'relative', marginBottom: '4px' }}>
{/** @ts-ignore **/}
<Form.Control rows={5} name="status" accepter={Textarea} ref={statusRef} placeholder={t('compose.status.placeholder')} />
<Form.Control
rows={5}
name="status"
accepter={Textarea}
ref={statusRef}
placeholder={t('compose.status.placeholder')}
emojis={customEmojis}
/>
<Whisper trigger="click" placement="bottomStart" ref={emojiPickerRef} speaker={<EmojiPicker />}>
<Button appearance="link" style={{ position: 'absolute', top: '4px', right: '8px', padding: 0 }}>
<Icon as={BsEmojiLaughing} style={{ fontSize: '1.2em' }} />
Expand Down Expand Up @@ -435,7 +443,7 @@ const privacyIcon = (visibility: 'public' | 'unlisted' | 'private' | 'direct') =
}
}

const Textarea = forwardRef<HTMLTextAreaElement>((props, ref) => <Input {...props} as="textarea" ref={ref} />)
const Textarea = forwardRef<HTMLTextAreaElement, AutoCompleteTextareaProps>(AutoCompleteTextarea)

const defaultPoll = () => ({
options: ['', ''],
Expand Down
2 changes: 1 addition & 1 deletion src/entities/emoji.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@ export type CustomEmoji = {
id: string
name: string
keywords: Array<string>
skins: Array<{ src: string }>
skins: Array<{ src: string; shortcodes: string }>
}

0 comments on commit 6e5d9df

Please sign in to comment.