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から入力する際に`🆗`のように最後の`:`を入力したあとに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>
This commit is contained in:
anatawa12 2025-04-16 11:25:21 +09:00 committed by GitHub
parent 0257d54add
commit 5a9f54a07a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 124 additions and 21 deletions

View File

@ -6,6 +6,7 @@
### Client ### Client
- Feat: チャットウィジェットを追加 - Feat: チャットウィジェットを追加
- Feat: デッキにチャットカラムを追加 - Feat: デッキにチャットカラムを追加
- Enhance: Unicode絵文字をslugから入力する際に`:ok:`のように最後の`:`を入力したあとにUnicode絵文字に変換できるように
- Enhance: テーマでページヘッダーの色を変更できるように - Enhance: テーマでページヘッダーの色を変更できるように
- Enhance: デザインのブラッシュアップ - Enhance: デザインのブラッシュアップ
- Fix: ログアウトした際に処理が終了しない問題を修正 - Fix: ログアウトした際に処理が終了しない問題を修正

View File

@ -15,12 +15,12 @@ SPDX-License-Identifier: AGPL-3.0-only
</li> </li>
<li tabindex="-1" :class="$style.item" @click="chooseUser()" @keydown="onKeydown">{{ i18n.ts.selectUser }}</li> <li tabindex="-1" :class="$style.item" @click="chooseUser()" @keydown="onKeydown">{{ i18n.ts.selectUser }}</li>
</ol> </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"> <li v-for="hashtag in hashtags" tabindex="-1" :class="$style.item" @click="complete(type, hashtag)" @keydown="onKeydown">
<span class="name">{{ hashtag }}</span> <span class="name">{{ hashtag }}</span>
</li> </li>
</ol> </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"> <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"/> <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"/> <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> <span v-if="emoji.aliasOf" :class="$style.emojiAlias">({{ emoji.aliasOf }})</span>
</li> </li>
</ol> </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"> <li v-for="tag in mfmTags" tabindex="-1" :class="$style.item" @click="complete(type, tag)" @keydown="onKeydown">
<span>{{ tag }}</span> <span>{{ tag }}</span>
</li> </li>
</ol> </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"> <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> <span>{{ param }}</span>
</li> </li>
@ -58,12 +58,44 @@ import { store } from '@/store.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { miLocalStorage } from '@/local-storage.js'; import { miLocalStorage } from '@/local-storage.js';
import { customEmojis } from '@/custom-emojis.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'; 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 lib = emojilist.filter(x => x.category !== 'flags');
const emojiDb = computed(() => { const unicodeEmojiDB = computed(() => {
//#region Unicode Emoji //#region Unicode Emoji
const char2path = prefer.r.emojiStyle.value === 'twemoji' ? char2twemojiFilePath : char2fluentEmojiFilePath; 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); unicodeEmojiDB.sort((a, b) => a.name.length - b.name.length);
return unicodeEmojiDB;
});
const emojiDb = computed(() => {
//#region Unicode Emoji
//#endregion //#endregion
//#region Custom Emoji //#region Custom Emoji
@ -114,7 +152,7 @@ const emojiDb = computed(() => {
customEmojiDB.sort((a, b) => a.name.length - b.name.length); customEmojiDB.sort((a, b) => a.name.length - b.name.length);
//#endregion //#endregion
return markRaw([...customEmojiDB, ...unicodeEmojiDB]); return markRaw([...customEmojiDB, ...unicodeEmojiDB.value]);
}); });
export default { export default {
@ -123,18 +161,23 @@ export default {
}; };
</script> </script>
<script lang="ts" setup> <script lang="ts" setup generic="T extends keyof CompleteInfo">
const props = defineProps<{ type PropsType<T extends keyof CompleteInfo> = {
type: string; type: T;
q: any; q: CompleteInfo[T]['query'];
textarea: HTMLTextAreaElement; // HTMLTextAreaElement | HTMLInputElement addEventListener/removeEventListener
textarea: (HTMLTextAreaElement | HTMLInputElement) & HTMLElement;
close: () => void; close: () => void;
x: number; x: number;
y: 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<{ 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; (event: 'closed'): void;
}>(); }>();
@ -151,10 +194,10 @@ const mfmParams = ref<string[]>([]);
const select = ref(-1); const select = ref(-1);
const zIndex = os.claimZIndex('high'); 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('done', { type, value });
emit('closed'); emit('closed');
if (type === 'emoji') { if (type === 'emoji' || type === 'emojiComplete') {
let recents = store.s.recentlyUsedEmojis; let recents = store.s.recentlyUsedEmojis;
recents = recents.filter((emoji: any) => emoji !== value); recents = recents.filter((emoji: any) => emoji !== value);
recents.unshift(value); recents.unshift(value);
@ -243,6 +286,8 @@ function exec() {
} }
emojis.value = searchEmoji(props.q, emojiDb.value); emojis.value = searchEmoji(props.q, emojiDb.value);
} else if (props.type === 'emojiComplete') {
emojis.value = searchEmojiExact(props.q, unicodeEmojiDB.value);
} else if (props.type === 'mfmTag') { } else if (props.type === 'mfmTag') {
if (!props.q || props.q === '') { if (!props.q || props.q === '') {
mfmTags.value = MFM_TAGS; mfmTags.value = MFM_TAGS;

View File

@ -7,6 +7,7 @@ import { nextTick, ref, defineAsyncComponent } from 'vue';
import getCaretCoordinates from 'textarea-caret'; import getCaretCoordinates from 'textarea-caret';
import { toASCII } from 'punycode.js'; import { toASCII } from 'punycode.js';
import type { Ref } from 'vue'; import type { Ref } from 'vue';
import type { CompleteInfo } from '@/components/MkAutocomplete.vue';
import { popup } from '@/os.js'; import { popup } from '@/os.js';
export type SuggestionType = 'user' | 'hashtag' | 'emoji' | 'mfmTag' | 'mfmParam'; export type SuggestionType = 'user' | 'hashtag' | 'emoji' | 'mfmTag' | 'mfmParam';
@ -19,7 +20,7 @@ export class Autocomplete {
close: () => void; close: () => void;
} | null; } | null;
private textarea: HTMLInputElement | HTMLTextAreaElement; private textarea: HTMLInputElement | HTMLTextAreaElement;
private currentType: string; private currentType: keyof CompleteInfo | undefined;
private textRef: Ref<string | number | null>; private textRef: Ref<string | number | null>;
private opening: boolean; private opening: boolean;
private onlyType: SuggestionType[]; private onlyType: SuggestionType[];
@ -74,7 +75,7 @@ export class Autocomplete {
* *
*/ */
private onInput() { private onInput() {
const caretPos = this.textarea.selectionStart; const caretPos = Number(this.textarea.selectionStart);
const text = this.text.substring(0, caretPos).split('\n').pop()!; const text = this.text.substring(0, caretPos).split('\n').pop()!;
const mentionIndex = text.lastIndexOf('@'); const mentionIndex = text.lastIndexOf('@');
@ -101,6 +102,8 @@ export class Autocomplete {
const isMfmParam = mfmParamIndex !== -1 && afterLastMfmParam?.includes('.') && !afterLastMfmParam.includes(' '); const isMfmParam = mfmParamIndex !== -1 && afterLastMfmParam?.includes('.') && !afterLastMfmParam.includes(' ');
const isMfmTag = mfmTagIndex !== -1 && !isMfmParam; const isMfmTag = mfmTagIndex !== -1 && !isMfmParam;
const isEmoji = emojiIndex !== -1 && text.split(/:[a-z0-9_+\-]+:/).pop()!.includes(':'); const isEmoji = emojiIndex !== -1 && text.split(/:[a-z0-9_+\-]+:/).pop()!.includes(':');
// :ok:などを🆗にするたいおぷ
const isEmojiCompleteToUnicode = !isEmoji && emojiIndex === text.length - 1;
let opened = false; 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')) { if (isMfmTag && !opened && this.onlyType.includes('mfmTag')) {
const mfmTag = text.substring(mfmTagIndex + 1); const mfmTag = text.substring(mfmTagIndex + 1);
if (!mfmTag.includes(' ')) { 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) { if (type !== this.currentType) {
this.close(); 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(); this.close();
const caret = this.textarea.selectionStart; const caret = Number(this.textarea.selectionStart);
if (type === 'user') { if (type === 'user') {
const source = this.text; const source = this.text;
@ -280,6 +291,22 @@ export class Autocomplete {
// 挿入 // 挿入
this.text = trimmedBefore + value + after; this.text = trimmedBefore + value + after;
// キャレットを戻す
nextTick(() => {
this.textarea.focus();
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(() => { nextTick(() => {
this.textarea.focus(); this.textarea.focus();

View File

@ -104,3 +104,33 @@ export function searchEmoji(query: string | null, emojiDb: EmojiDef[], max = 30)
.slice(0, max) .slice(0, max)
.map(it => it.emoji); .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);
}