diff options
| author | anatawa12 <anatawa12@icloud.com> | 2025-04-16 11:25:21 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-04-16 11:25:21 +0900 |
| commit | 5a9f54a07aeb5c0adbafd39089c893a24b366452 (patch) | |
| tree | db904e2e0e402c012c837684a7b9615603cdf1f1 /packages/frontend/src | |
| parent | fix: ノートの直後のノートを表示する機能で表示が逆順に... (diff) | |
| download | misskey-5a9f54a07aeb5c0adbafd39089c893a24b366452.tar.gz misskey-5a9f54a07aeb5c0adbafd39089c893a24b366452.tar.bz2 misskey-5a9f54a07aeb5c0adbafd39089c893a24b366452.zip | |
Complete unicode emoji after last colon (#13384)
* chore: add types on MkAutocomplete
* feat: complete `:emoji:` to unicode emoji
* chore: do not use fuzzy match for emojiComplete
* docs(changelog): Unicode絵文字をslugから入力する際に`:ok:`のように最後の`:`を入力したあとにUnicode絵文字に変換できるように
* docs(changelog): update changelog insertion position
* chore: improve indent
---------
Co-authored-by: Sayamame-beans <61457993+Sayamame-beans@users.noreply.github.com>
Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
Diffstat (limited to 'packages/frontend/src')
| -rw-r--r-- | packages/frontend/src/components/MkAutocomplete.vue | 77 | ||||
| -rw-r--r-- | packages/frontend/src/utility/autocomplete.ts | 37 | ||||
| -rw-r--r-- | packages/frontend/src/utility/search-emoji.ts | 30 |
3 files changed, 123 insertions, 21 deletions
diff --git a/packages/frontend/src/components/MkAutocomplete.vue b/packages/frontend/src/components/MkAutocomplete.vue index e51a56fa7b..e5b9533cd7 100644 --- a/packages/frontend/src/components/MkAutocomplete.vue +++ b/packages/frontend/src/components/MkAutocomplete.vue @@ -15,12 +15,12 @@ SPDX-License-Identifier: AGPL-3.0-only </li> <li tabindex="-1" :class="$style.item" @click="chooseUser()" @keydown="onKeydown">{{ i18n.ts.selectUser }}</li> </ol> - <ol v-else-if="hashtags.length > 0" ref="suggests" :class="$style.list"> + <ol v-else-if="type === 'hashtag' && hashtags.length > 0" ref="suggests" :class="$style.list"> <li v-for="hashtag in hashtags" tabindex="-1" :class="$style.item" @click="complete(type, hashtag)" @keydown="onKeydown"> <span class="name">{{ hashtag }}</span> </li> </ol> - <ol v-else-if="emojis.length > 0" ref="suggests" :class="$style.list"> + <ol v-else-if="type === 'emoji' || type === 'emojiComplete' && emojis.length > 0" ref="suggests" :class="$style.list"> <li v-for="emoji in emojis" :key="emoji.emoji" :class="$style.item" tabindex="-1" @click="complete(type, emoji.emoji)" @keydown="onKeydown"> <MkCustomEmoji v-if="'isCustomEmoji' in emoji && emoji.isCustomEmoji" :name="emoji.emoji" :class="$style.emoji" :fallbackToImage="true"/> <MkEmoji v-else :emoji="emoji.emoji" :class="$style.emoji"/> @@ -30,12 +30,12 @@ SPDX-License-Identifier: AGPL-3.0-only <span v-if="emoji.aliasOf" :class="$style.emojiAlias">({{ emoji.aliasOf }})</span> </li> </ol> - <ol v-else-if="mfmTags.length > 0" ref="suggests" :class="$style.list"> + <ol v-else-if="type === 'mfmTag' && mfmTags.length > 0" ref="suggests" :class="$style.list"> <li v-for="tag in mfmTags" tabindex="-1" :class="$style.item" @click="complete(type, tag)" @keydown="onKeydown"> <span>{{ tag }}</span> </li> </ol> - <ol v-else-if="mfmParams.length > 0" ref="suggests" :class="$style.list"> + <ol v-else-if="type === 'mfmParam' && mfmParams.length > 0" ref="suggests" :class="$style.list"> <li v-for="param in mfmParams" tabindex="-1" :class="$style.item" @click="complete(type, q.params.toSpliced(-1, 1, param).join(','))" @keydown="onKeydown"> <span>{{ param }}</span> </li> @@ -58,12 +58,44 @@ import { store } from '@/store.js'; import { i18n } from '@/i18n.js'; import { miLocalStorage } from '@/local-storage.js'; import { customEmojis } from '@/custom-emojis.js'; -import { searchEmoji } from '@/utility/search-emoji.js'; +import { searchEmoji, searchEmojiExact } from '@/utility/search-emoji.js'; import { prefer } from '@/preferences.js'; +export type CompleteInfo = { + user: { + payload: any; + query: string | null; + }, + hashtag: { + payload: string; + query: string; + }, + // `:emo` -> `:emoji:` or some unicode emoji + emoji: { + payload: string; + query: string; + }, + // like emoji but for `:emoji:` -> unicode emoji + emojiComplete: { + payload: string; + query: string; + }, + mfmTag: { + payload: string; + query: string; + }, + mfmParam: { + payload: string; + query: { + tag: string; + params: string[]; + }; + }, +}; + const lib = emojilist.filter(x => x.category !== 'flags'); -const emojiDb = computed(() => { +const unicodeEmojiDB = computed(() => { //#region Unicode Emoji const char2path = prefer.r.emojiStyle.value === 'twemoji' ? char2twemojiFilePath : char2fluentEmojiFilePath; @@ -87,6 +119,12 @@ const emojiDb = computed(() => { } unicodeEmojiDB.sort((a, b) => a.name.length - b.name.length); + + return unicodeEmojiDB; +}); + +const emojiDb = computed(() => { + //#region Unicode Emoji //#endregion //#region Custom Emoji @@ -114,7 +152,7 @@ const emojiDb = computed(() => { customEmojiDB.sort((a, b) => a.name.length - b.name.length); //#endregion - return markRaw([...customEmojiDB, ...unicodeEmojiDB]); + return markRaw([...customEmojiDB, ...unicodeEmojiDB.value]); }); export default { @@ -123,18 +161,23 @@ export default { }; </script> -<script lang="ts" setup> -const props = defineProps<{ - type: string; - q: any; - textarea: HTMLTextAreaElement; +<script lang="ts" setup generic="T extends keyof CompleteInfo"> +type PropsType<T extends keyof CompleteInfo> = { + type: T; + q: CompleteInfo[T]['query']; + // なぜかわからないけど HTMLTextAreaElement | HTMLInputElement だと addEventListener/removeEventListenerがエラー + textarea: (HTMLTextAreaElement | HTMLInputElement) & HTMLElement; close: () => void; x: number; y: number; -}>(); +}; +//const props = defineProps<PropsType<keyof CompleteInfo>>(); +// ↑と同じだけど↓にしないとdiscriminated unionにならない。 +// https://www.typescriptlang.org/docs/handbook/typescript-in-5-minutes-func.html#discriminated-unions +const props = defineProps<PropsType<'user'> | PropsType<'hashtag'> | PropsType<'emoji'> | PropsType<'emojiComplete'> | PropsType<'mfmTag'> | PropsType<'mfmParam'>>(); const emit = defineEmits<{ - (event: 'done', value: { type: string; value: any }): void; + <T extends keyof CompleteInfo>(event: 'done', value: { type: T; value: CompleteInfo[T]['payload'] }): void; (event: 'closed'): void; }>(); @@ -151,10 +194,10 @@ const mfmParams = ref<string[]>([]); const select = ref(-1); const zIndex = os.claimZIndex('high'); -function complete(type: string, value: any) { +function complete<T extends keyof CompleteInfo>(type: T, value: CompleteInfo[T]['payload']) { emit('done', { type, value }); emit('closed'); - if (type === 'emoji') { + if (type === 'emoji' || type === 'emojiComplete') { let recents = store.s.recentlyUsedEmojis; recents = recents.filter((emoji: any) => emoji !== value); recents.unshift(value); @@ -243,6 +286,8 @@ function exec() { } emojis.value = searchEmoji(props.q, emojiDb.value); + } else if (props.type === 'emojiComplete') { + emojis.value = searchEmojiExact(props.q, unicodeEmojiDB.value); } else if (props.type === 'mfmTag') { if (!props.q || props.q === '') { mfmTags.value = MFM_TAGS; diff --git a/packages/frontend/src/utility/autocomplete.ts b/packages/frontend/src/utility/autocomplete.ts index 9a603b848c..1246c32554 100644 --- a/packages/frontend/src/utility/autocomplete.ts +++ b/packages/frontend/src/utility/autocomplete.ts @@ -7,6 +7,7 @@ import { nextTick, ref, defineAsyncComponent } from 'vue'; import getCaretCoordinates from 'textarea-caret'; import { toASCII } from 'punycode.js'; import type { Ref } from 'vue'; +import type { CompleteInfo } from '@/components/MkAutocomplete.vue'; import { popup } from '@/os.js'; export type SuggestionType = 'user' | 'hashtag' | 'emoji' | 'mfmTag' | 'mfmParam'; @@ -19,7 +20,7 @@ export class Autocomplete { close: () => void; } | null; private textarea: HTMLInputElement | HTMLTextAreaElement; - private currentType: string; + private currentType: keyof CompleteInfo | undefined; private textRef: Ref<string | number | null>; private opening: boolean; private onlyType: SuggestionType[]; @@ -74,7 +75,7 @@ export class Autocomplete { * テキスト入力時 */ private onInput() { - const caretPos = this.textarea.selectionStart; + const caretPos = Number(this.textarea.selectionStart); const text = this.text.substring(0, caretPos).split('\n').pop()!; const mentionIndex = text.lastIndexOf('@'); @@ -101,6 +102,8 @@ export class Autocomplete { const isMfmParam = mfmParamIndex !== -1 && afterLastMfmParam?.includes('.') && !afterLastMfmParam.includes(' '); const isMfmTag = mfmTagIndex !== -1 && !isMfmParam; const isEmoji = emojiIndex !== -1 && text.split(/:[a-z0-9_+\-]+:/).pop()!.includes(':'); + // :ok:などを🆗にするたいおぷ + const isEmojiCompleteToUnicode = !isEmoji && emojiIndex === text.length - 1; let opened = false; @@ -137,6 +140,14 @@ export class Autocomplete { } } + if (isEmojiCompleteToUnicode && !opened && this.onlyType.includes('emoji')) { + const emoji = text.substring(text.lastIndexOf(':', text.length - 2) + 1, text.length - 1); + if (!emoji.includes(' ')) { + this.open('emojiComplete', emoji); + opened = true; + } + } + if (isMfmTag && !opened && this.onlyType.includes('mfmTag')) { const mfmTag = text.substring(mfmTagIndex + 1); if (!mfmTag.includes(' ')) { @@ -164,7 +175,7 @@ export class Autocomplete { /** * サジェストを提示します。 */ - private async open(type: string, q: any) { + private async open<T extends keyof CompleteInfo>(type: T, q: CompleteInfo[T]['query']) { if (type !== this.currentType) { this.close(); } @@ -231,10 +242,10 @@ export class Autocomplete { /** * オートコンプリートする */ - private complete({ type, value }) { + private complete<T extends keyof CompleteInfo>({ type, value }: { type: T; value: CompleteInfo[T]['payload'] }) { this.close(); - const caret = this.textarea.selectionStart; + const caret = Number(this.textarea.selectionStart); if (type === 'user') { const source = this.text; @@ -286,6 +297,22 @@ export class Autocomplete { const pos = trimmedBefore.length + value.length; this.textarea.setSelectionRange(pos, pos); }); + } else if (type === 'emojiComplete') { + const source = this.text; + + const before = source.substring(0, caret); + const trimmedBefore = before.substring(0, before.lastIndexOf(':', before.length - 2)); + const after = source.substring(caret); + + // 挿入 + this.text = trimmedBefore + value + after; + + // キャレットを戻す + nextTick(() => { + this.textarea.focus(); + const pos = trimmedBefore.length + value.length; + this.textarea.setSelectionRange(pos, pos); + }); } else if (type === 'mfmTag') { const source = this.text; diff --git a/packages/frontend/src/utility/search-emoji.ts b/packages/frontend/src/utility/search-emoji.ts index 371f69b9a7..4cda880bff 100644 --- a/packages/frontend/src/utility/search-emoji.ts +++ b/packages/frontend/src/utility/search-emoji.ts @@ -104,3 +104,33 @@ export function searchEmoji(query: string | null, emojiDb: EmojiDef[], max = 30) .slice(0, max) .map(it => it.emoji); } + +export function searchEmojiExact(query: string | null, emojiDb: EmojiDef[], max = 30): EmojiDef[] { + if (!query) { + return []; + } + + const matched = new Map<string, EmojiScore>(); + // 完全一致(エイリアスなし) + emojiDb.some(x => { + if (x.name === query && !x.aliasOf) { + matched.set(x.name, { emoji: x, score: query.length + 3 }); + } + return matched.size === max; + }); + + // 完全一致(エイリアス込み) + if (matched.size < max) { + emojiDb.some(x => { + if (x.name === query && !matched.has(x.aliasOf ?? x.name)) { + matched.set(x.aliasOf ?? x.name, { emoji: x, score: query.length + 2 }); + } + return matched.size === max; + }); + } + + return [...matched.values()] + .sort((x, y) => y.score - x.score) + .slice(0, max) + .map(it => it.emoji); +} |