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:
parent
0257d54add
commit
5a9f54a07a
|
@ -6,6 +6,7 @@
|
||||||
### Client
|
### Client
|
||||||
- Feat: チャットウィジェットを追加
|
- Feat: チャットウィジェットを追加
|
||||||
- Feat: デッキにチャットカラムを追加
|
- Feat: デッキにチャットカラムを追加
|
||||||
|
- Enhance: Unicode絵文字をslugから入力する際に`:ok:`のように最後の`:`を入力したあとにUnicode絵文字に変換できるように
|
||||||
- Enhance: テーマでページヘッダーの色を変更できるように
|
- Enhance: テーマでページヘッダーの色を変更できるように
|
||||||
- Enhance: デザインのブラッシュアップ
|
- Enhance: デザインのブラッシュアップ
|
||||||
- Fix: ログアウトした際に処理が終了しない問題を修正
|
- Fix: ログアウトした際に処理が終了しない問題を修正
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue