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 16 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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 @@ -16,6 +16,7 @@
- Fix: 埋め込みプレイヤーから外部ページに移動できない問題を修正
- Fix: Play の再読込時に UI が以前の状態を引き継いでしまう問題を修正 `#14378`
- Fix: カスタム絵文字管理画面(beta)にてisSensitive/localOnlyの絞り込みが上手くいかない問題の修正 ( #15445 )
- Enhance: Unicode絵文字をslugから入力する際に`:ok:`のように最後の`:`を入力したあとにUnicode絵文字に変換できるように

### Server
- Fix: `following/invalidate`でフォロワーを解除しようとしているユーザーの情報を返すように
Expand Down
141 changes: 93 additions & 48 deletions packages/frontend/src/components/MkAutocomplete.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,43 +4,43 @@
-->

<template>
<div ref="rootEl" :class="$style.root" class="_popup _shadow" :style="{ zIndex }" @contextmenu.prevent="() => {}">
<ol v-if="type === 'user'" ref="suggests" :class="$style.list">
<li v-for="user in users" tabindex="-1" :class="$style.item" @click="complete(type, user)" @keydown="onKeydown">
<img :class="$style.avatar" :src="user.avatarUrl"/>
<span :class="$style.userName">
<div ref="rootEl" :class="$style.root" class="_popup _shadow" :style="{ zIndex }" @contextmenu.prevent="() => {}">
<ol v-if="type === 'user'" ref="suggests" :class="$style.list">
<li v-for="user in users" tabindex="-1" :class="$style.item" @click="complete(type, user)" @keydown="onKeydown">
<img :class="$style.avatar" :src="user.avatarUrl"/>
<span :class="$style.userName">
<MkUserName :key="user.id" :user="user"/>
</span>
<span>@{{ acct(user) }}</span>
</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">
<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">
<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"/>
<!-- eslint-disable-next-line vue/no-v-html -->
<span v-if="q" :class="$style.emojiName" v-html="sanitizeHtml(emoji.name.replace(q, `<b>${q}</b>`))"></span>
<span v-else v-text="emoji.name"></span>
<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">
<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">
<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>
</ol>
</div>
<span>@{{ acct(user) }}</span>
</li>
<li tabindex="-1" :class="$style.item" @click="chooseUser()" @keydown="onKeydown">{{ i18n.ts.selectUser }}</li>
</ol>
<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="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"/>
<!-- eslint-disable-next-line vue/no-v-html -->
<span v-if="q" :class="$style.emojiName" v-html="sanitizeHtml(emoji.name.replace(q, `<b>${q}</b>`))"></span>
<span v-else v-text="emoji.name"></span>
<span v-if="emoji.aliasOf" :class="$style.emojiAlias">({{ emoji.aliasOf }})</span>
</li>
</ol>
<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="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>
</ol>
</div>
</template>

<script lang="ts">
Expand All @@ -57,13 +57,45 @@
import { i18n } from '@/i18n.js';
import { miLocalStorage } from '@/local-storage.js';
import { customEmojis } from '@/custom-emojis.js';
import { searchEmoji } from '@/scripts/search-emoji.js';
import { MFM_TAGS, MFM_PARAMS } from '@@/js/const.js';

Check failure on line 60 in packages/frontend/src/components/MkAutocomplete.vue

View workflow job for this annotation

GitHub Actions / lint (frontend)

'MFM_TAGS' is already defined

Check failure on line 60 in packages/frontend/src/components/MkAutocomplete.vue

View workflow job for this annotation

GitHub Actions / lint (frontend)

'MFM_PARAMS' is already defined
import { searchEmojisearchEmojiExact } from '@/scripts/search-emoji.js';
import type { 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[];
};
},
}

Check failure on line 94 in packages/frontend/src/components/MkAutocomplete.vue

View workflow job for this annotation

GitHub Actions / lint (frontend)

Missing semicolon

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 @@ -86,6 +118,12 @@
}

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 @@ -113,7 +151,7 @@
customEmojiDB.sort((a, b) => a.name.length - b.name.length);
//#endregion

return markRaw([...customEmojiDB, ...unicodeEmojiDB]);
return markRaw([...customEmojiDB, ...unicodeEmojiDB.value]);
});

export default {
Expand All @@ -122,18 +160,23 @@
};
</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;
}>();
}

Check failure on line 172 in packages/frontend/src/components/MkAutocomplete.vue

View workflow job for this annotation

GitHub Actions / lint (frontend)

Missing semicolon
//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 @@ -150,10 +193,10 @@
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,7 +282,9 @@
return;
}

emojis.value = searchEmoji(props.q, emojiDb.value);

Check failure on line 285 in packages/frontend/src/components/MkAutocomplete.vue

View workflow job for this annotation

GitHub Actions / lint (frontend)

'searchEmoji' is not defined
} else if (props.type === 'emojiComplete') {
emojis.value = searchEmojiExact(props.q, unicodeEmojiDB.value);

Check failure on line 287 in packages/frontend/src/components/MkAutocomplete.vue

View workflow job for this annotation

GitHub Actions / lint (frontend)

'searchEmojiExact' is not defined
} 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 @@ -7,6 +7,7 @@ import { nextTick, ref, defineAsyncComponent } from 'vue';
import type { Ref } from 'vue';
import getCaretCoordinates from 'textarea-caret';
import { toASCII } from 'punycode.js';
import type { CompleteInfo } from '@/components/MkAutocomplete.vue';
import { popup } from '@/os.js';

export type SuggestionType = 'user' | 'hashtag' | 'emoji' | 'mfmTag' | 'mfmParam';
Expand All @@ -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[];
Expand Down Expand Up @@ -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('@');
Expand All @@ -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;

Expand Down Expand Up @@ -131,6 +134,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 @@ -158,7 +169,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 @@ -225,10 +236,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 @@ -274,6 +285,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