Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Complete unicode emoji after last colon #13384

Open
wants to merge 13 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
(Cherry-picked from https://github.com/taiyme/misskey/pull/238)
- Enhance: AiScriptを0.19.0にアップデート
- Enhance: Allow negative delay for MFM animation elements (`tada`, `jelly`, `twitch`, `shake`, `spin`, `jump`, `bounce`, `rainbow`)
- Enhance: Unicode絵文字をslugから入力する際に`:ok:`のように最後の`:`を入力したあとにUnicode絵文字に変換できるように
- Fix: `/about#federation` ページなどで各インスタンスのチャートが表示されなくなっていた問題を修正
- Fix: ユーザーページの追加情報のラベルを投稿者のサーバーの絵文字で表示する (#13968)
- Fix: リバーシの対局を正しく共有できないことがある問題を修正
Expand Down
78 changes: 61 additions & 17 deletions packages/frontend/src/components/MkAutocomplete.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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"/>
Expand All @@ -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>
Expand All @@ -57,12 +57,43 @@ import { i18n } from '@/i18n.js';
import { miLocalStorage } from '@/local-storage.js';
import { customEmojis } from '@/custom-emojis.js';
import { MFM_TAGS, MFM_PARAMS } from '@/const.js';
import { searchEmoji, EmojiDef } from '@/scripts/search-emoji.js';
import { searchEmoji, searchEmojiExact, 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 emojiDb = computed(() => {
//#region Unicode Emoji
const unicodeEmojiDB = computed(() => {
const char2path = defaultStore.reactiveState.emojiStyle.value === 'twemoji' ? char2twemojiFilePath : char2fluentEmojiFilePath;

const unicodeEmojiDB: EmojiDef[] = lib.map(x => ({
Expand All @@ -85,6 +116,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
Expand Down Expand Up @@ -112,7 +149,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 {
Expand All @@ -121,18 +158,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;
}>();

Expand All @@ -149,10 +191,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 = defaultStore.state.recentlyUsedEmojis;
recents = recents.filter((emoji: any) => emoji !== value);
recents.unshift(value);
Expand Down Expand Up @@ -239,6 +281,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;
Expand Down
37 changes: 32 additions & 5 deletions packages/frontend/src/scripts/autocomplete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import { nextTick, Ref, ref, defineAsyncComponent } from 'vue';
import getCaretCoordinates from 'textarea-caret';
import { toASCII } from 'punycode/';
import type { CompleteInfo } from '@/components/MkAutocomplete.vue';
import { popup } from '@/os.js';

export type SuggestionType = 'user' | 'hashtag' | 'emoji' | 'mfmTag' | 'mfmParam';
Expand All @@ -18,7 +19,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[];
Expand Down Expand Up @@ -73,7 +74,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('@');
Expand All @@ -100,6 +101,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;

Expand Down Expand Up @@ -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')) {
const mfmTag = text.substring(mfmTagIndex + 1);
if (!mfmTag.includes(' ')) {
Expand Down Expand Up @@ -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) {
this.close();
}
Expand Down Expand Up @@ -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();

const caret = this.textarea.selectionStart;
const caret = Number(this.textarea.selectionStart);

if (type === 'user') {
const source = this.text;
Expand Down Expand Up @@ -273,6 +284,22 @@ export class Autocomplete {
// 挿入
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(() => {
this.textarea.focus();
Expand Down
30 changes: 30 additions & 0 deletions packages/frontend/src/scripts/search-emoji.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Loading