Auto-complete for emoji in compose

This commit is contained in:
AkiraFukushima 2023-02-20 22:46:19 +09:00
parent 3d84d41558
commit 4614fd1f88
No known key found for this signature in database
GPG Key ID: B6E51BAC4DE1A957
1 changed files with 198 additions and 4 deletions

View File

@ -3,8 +3,37 @@
<Quote v-if="inReplyTo" :message="inReplyTo" @close="clearReply" /> <Quote v-if="inReplyTo" :message="inReplyTo" @close="clearReply" />
<Quote v-if="quoteTo" :message="quoteTo" @close="clearQuote" /> <Quote v-if="quoteTo" :message="quoteTo" @close="clearQuote" />
<el-form :model="form" class="compose-form"> <el-form :model="form" class="compose-form">
<el-input v-model="form.spoiler" class="spoiler" :placeholder="$t('compose.cw')" v-if="cw" /> <el-popover
<el-input v-model="form.status" type="textarea" :autosize="{ minRows: 2 }" :placeholder="$t('compose.status')" ref="statusRef" /> placement="top-start"
width="300"
trigger="manual"
popper-class="suggest-popper"
:popper-options="popperOptions()"
ref="suggestRef"
v-model:visible="suggestOpened"
>
<ul class="suggest-list">
<li
v-for="(item, index) in filteredSuggestion"
:key="index"
@click="insertItem(item)"
@mouseover="suggestHighlight(index)"
:class="{ highlighted: highlightedIndex === index }"
>
<span v-if="item.image">
<img :src="item.image" class="icon" />
</span>
<span v-if="item.code">
{{ item.code }}
</span>
{{ item.name }}
</li>
</ul>
<template #reference>
<el-input v-model="form.spoiler" class="spoiler" :placeholder="$t('compose.cw')" v-if="cw" />
<el-input v-model="form.status" type="textarea" :autosize="{ minRows: 2 }" :placeholder="$t('compose.status')" ref="statusRef" />
</template>
</el-popover>
<div class="preview" ref="previewRef"> <div class="preview" ref="previewRef">
<div class="image-wrapper" v-for="media in attachments" :key="media.id"> <div class="image-wrapper" v-for="media in attachments" :key="media.id">
<img :src="media.preview_url" class="preview-image" /> <img :src="media.preview_url" class="preview-image" />
@ -101,7 +130,7 @@ import { defineComponent, reactive, computed, ref, onMounted, onBeforeUnmount, w
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import generator, { Entity, MegalodonInterface } from 'megalodon' import generator, { Entity, MegalodonInterface } from 'megalodon'
import emojiDefault from 'emoji-mart-vue-fast/data/all.json' import emojiDefault from 'emoji-mart-vue-fast/data/all.json'
import { Picker, EmojiIndex } from 'emoji-mart-vue-fast/src' import { Picker, EmojiIndex, EmojiData } from 'emoji-mart-vue-fast/src'
import { useI18next } from 'vue3-i18next' import { useI18next } from 'vue3-i18next'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { useStore } from '@/store' import { useStore } from '@/store'
@ -112,12 +141,19 @@ import visibilityList from '~/src/constants/visibility'
import { MUTATION_TYPES } from '@/store/TimelineSpace/Compose' import { MUTATION_TYPES } from '@/store/TimelineSpace/Compose'
import ReceiveDrop from './ReceiveDrop.vue' import ReceiveDrop from './ReceiveDrop.vue'
import Quote from './Compose/Quote.vue' import Quote from './Compose/Quote.vue'
import suggestText from '@/utils/suggestText'
type Expire = { type Expire = {
label: string label: string
value: number value: number
} }
type SuggestItem = {
name: string
image?: string
code?: string
}
export default defineComponent({ export default defineComponent({
name: 'Compose', name: 'Compose',
components: { Picker, ReceiveDrop, Quote }, components: { Picker, ReceiveDrop, Quote },
@ -203,6 +239,12 @@ export default defineComponent({
const maxStatusChars = ref<number>(500) const maxStatusChars = ref<number>(500)
const statusChars = computed(() => maxStatusChars.value - (form.status.length + form.spoiler.length)) const statusChars = computed(() => maxStatusChars.value - (form.status.length + form.spoiler.length))
const suggestOpened = ref<boolean>(false)
const filteredSuggestion = ref<Array<SuggestItem>>([])
const highlightedIndex = ref(0)
const startIndex = ref(0)
const matchWord = ref('')
onMounted(async () => { onMounted(async () => {
const [a, s]: [LocalAccount, LocalServer] = await win.ipcRenderer.invoke('get-local-account', id.value) const [a, s]: [LocalAccount, LocalServer] = await win.ipcRenderer.invoke('get-local-account', id.value)
const c = generator(s.sns, s.baseURL, a.accessToken, userAgent.value) const c = generator(s.sns, s.baseURL, a.accessToken, userAgent.value)
@ -258,6 +300,10 @@ export default defineComponent({
} }
}) })
watch(form, async current => {
await suggest(current.status)
})
const post = async () => { const post = async () => {
if (!client.value) { if (!client.value) {
return return
@ -471,6 +517,110 @@ export default defineComponent({
store.commit(`${space}/${MUTATION_TYPES.CLEAR_QUOTE_TO}`) store.commit(`${space}/${MUTATION_TYPES.CLEAR_QUOTE_TO}`)
} }
const popperOptions = () => {
const element = document.querySelector('#status_textarea')
return {
modifiers: [
{
name: 'preventOverflow',
options: {
boundary: element,
rootBoundary: 'viewport',
altBoundary: true
}
}
]
}
}
const suggestHighlight = (index: number) => {
if (index < 0) {
highlightedIndex.value = 0
} else if (index >= filteredSuggestion.value.length) {
highlightedIndex.value = filteredSuggestion.value.length - 1
} else {
highlightedIndex.value = index
}
}
const insertItem = (item: SuggestItem) => {
if (!item) return
if (item.code) {
const str = `${form.status.slice(0, startIndex.value - 1)}${item.code} ${form.status.slice(
startIndex.value + matchWord.value.length
)}`
form.status = str
} else {
const str = `${form.status.slice(0, startIndex.value - 1)}${item.name} ${form.status.slice(
startIndex.value + matchWord.value.length
)}`
form.status = str
}
closeSuggest()
}
const closeSuggest = () => {
highlightedIndex.value = 0
suggestOpened.value = false
filteredSuggestion.value = []
}
const suggestEmoji = async (start: number, word: string) => {
try {
const find: Array<EmojiData> = emojiData.value.search(word.replace(':', ''))
startIndex.value = start
matchWord.value = word
filteredSuggestion.value = find.map(e => {
if (e.native) {
return {
name: e.colons,
code: e.native
}
} else {
return {
name: e.id,
image: e.imageUrl
}
}
})
suggestOpened.value = true
return true
} catch (err) {
console.error(err)
return false
}
}
const suggest = async (current: string) => {
const target = statusRef.value.textarea as HTMLInputElement
// e.target.sectionStart: Cursor position
// e.target.value: current value of the textarea
if (current !== target.value) {
return
}
if (!target.selectionStart) {
return
}
const [start, word] = suggestText(target.value, target.selectionStart)
if (!start || !word) {
closeSuggest()
return false
}
switch (word.charAt(0)) {
case ':':
await suggestEmoji(start, word)
return true
case '@':
//await suggestAccount(start, word)
return true
case '#':
// await suggestHashtag(start, word)
return true
default:
return false
}
}
return { return {
form, form,
post, post,
@ -499,7 +649,14 @@ export default defineComponent({
quoteTo, quoteTo,
clearReply, clearReply,
clearQuote, clearQuote,
statusChars statusChars,
suggestOpened,
popperOptions,
filteredSuggestion,
suggestHighlight,
insertItem,
highlightedIndex,
suggest
} }
} }
}) })
@ -637,4 +794,41 @@ export default defineComponent({
display: none; display: none;
} }
} }
.suggest-popper {
// These color is not applied because popper append outside of app.
background-color: var(--theme-background-color);
border: 1px solid var(--theme-header-menu-color);
.suggest-list {
list-style: none;
padding: 6px 0;
margin: 0;
box-sizing: border-box;
li {
font-size: var(--base-font-size);
padding: 0 20px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
height: 34px;
line-height: 34px;
box-sizing: border-box;
cursor: pointer;
color: var(--theme-regular-color);
.icon {
display: inline-block;
vertical-align: middle;
width: 20px;
height: 20px;
}
}
.highlighted {
background-color: var(--theme-selected-background-color);
}
}
}
</style> </style>