Complete unicode emoji after last colon (misskey-dev#13384) (MisskeyIO#523)

Co-authored-by: anatawa12 <anatawa12@icloud.com>
This commit is contained in:
まっちゃとーにゅ 2024-03-15 14:17:24 +09:00 committed by GitHub
parent cd7ab5d0f9
commit 6bd7e85976
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 96 additions and 20 deletions

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"/> <MkCustomEmoji v-if="'isCustomEmoji' in emoji && emoji.isCustomEmoji" :name="emoji.emoji" :class="$style.emoji"/>
<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>
@ -59,9 +59,41 @@ import { customEmojis } from '@/custom-emojis.js';
import { MFM_TAGS, MFM_PARAMS } from '@/const.js'; import { MFM_TAGS, MFM_PARAMS } from '@/const.js';
import { searchEmoji, EmojiDef } from '@/scripts/search-emoji.js'; import { searchEmoji, EmojiDef } from '@/scripts/search-emoji.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 = defaultStore.reactiveState.emojiStyle.value === 'twemoji' ? char2twemojiFilePath : char2fluentEmojiFilePath; const char2path = defaultStore.reactiveState.emojiStyle.value === 'twemoji' ? char2twemojiFilePath : char2fluentEmojiFilePath;
@ -87,6 +119,10 @@ const emojiDb = computed(() => {
unicodeEmojiDB.sort((a, b) => a.name.length - b.name.length); unicodeEmojiDB.sort((a, b) => a.name.length - b.name.length);
//#endregion //#endregion
return unicodeEmojiDB;
});
const emojiDb = computed(() => {
//#region Custom Emoji //#region Custom Emoji
const customEmojiDB: EmojiDef[] = []; const customEmojiDB: EmojiDef[] = [];
@ -112,7 +148,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 {
@ -121,18 +157,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;
}>(); }>();
@ -149,10 +190,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 = defaultStore.state.recentlyUsedEmojis; let recents = defaultStore.state.recentlyUsedEmojis;
recents = recents.filter((emoji: any) => emoji !== value); recents = recents.filter((emoji: any) => emoji !== value);
recents.unshift(value); recents.unshift(value);
@ -239,6 +280,14 @@ function exec() {
} }
emojis.value = searchEmoji(props.q, emojiDb.value); emojis.value = searchEmoji(props.q, emojiDb.value);
} else if (props.type === 'emojiComplete') {
if (!props.q || props.q === '') {
// 使
emojis.value = defaultStore.state.recentlyUsedEmojis.map(emoji => unicodeEmojiDB.value.find(dbEmoji => dbEmoji.emoji === emoji)).filter(x => x) as EmojiDef[];
return;
}
emojis.value = searchEmoji(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

@ -6,6 +6,7 @@
import { nextTick, Ref, ref, defineAsyncComponent } from 'vue'; import { nextTick, Ref, ref, defineAsyncComponent } from 'vue';
import getCaretCoordinates from 'textarea-caret'; import getCaretCoordinates from 'textarea-caret';
import { toASCII } from 'punycode/'; import { toASCII } from 'punycode/';
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';
@ -18,7 +19,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[];
@ -73,7 +74,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('@');
@ -100,6 +101,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;
@ -130,6 +133,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(' ')) {
@ -157,7 +168,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();
} }
@ -224,10 +235,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;
@ -273,6 +284,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();