summaryrefslogtreecommitdiff
path: root/packages/frontend/src
diff options
context:
space:
mode:
authoranatawa12 <anatawa12@icloud.com>2025-04-16 11:25:21 +0900
committerGitHub <noreply@github.com>2025-04-16 11:25:21 +0900
commit5a9f54a07aeb5c0adbafd39089c893a24b366452 (patch)
treedb904e2e0e402c012c837684a7b9615603cdf1f1 /packages/frontend/src
parentfix: ノートの直後のノートを表示する機能で表示が逆順に... (diff)
downloadmisskey-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.vue77
-rw-r--r--packages/frontend/src/utility/autocomplete.ts37
-rw-r--r--packages/frontend/src/utility/search-emoji.ts30
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);
+}