misskey/packages/frontend/src/components/MkAutocomplete.vue

520 lines
13 KiB
Vue
Raw Normal View History

<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
2018-02-20 20:29:52 +09:00
<template>
2023-01-14 20:51:07 +09:00
<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">
2023-02-08 20:07:19 +09:00
<MkUserName :key="user.id" :user="user"/>
</span>
2023-01-14 20:51:07 +09:00
<span>@{{ acct(user) }}</span>
2018-02-20 20:29:52 +09:00
</li>
2023-01-14 20:51:07 +09:00
<li tabindex="-1" :class="$style.item" @click="chooseUser()" @keydown="onKeydown">{{ i18n.ts.selectUser }}</li>
2018-02-20 20:29:52 +09:00
</ol>
<ol v-else-if="hashtags.length > 0" ref="suggests" :class="$style.list">
2023-01-14 20:51:07 +09:00
<li v-for="hashtag in hashtags" tabindex="-1" :class="$style.item" @click="complete(type, hashtag)" @keydown="onKeydown">
2018-07-18 07:19:24 +09:00
<span class="name">{{ hashtag }}</span>
</li>
</ol>
2023-01-14 20:51:07 +09:00
<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"/>
<MkEmoji v-else :emoji="emoji.emoji" :class="$style.emoji"/>
<!-- eslint-disable-next-line vue/no-v-html -->
2023-01-14 20:51:07 +09:00
<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>
2023-01-14 20:51:07 +09:00
<span v-if="emoji.aliasOf" :class="$style.emojiAlias">({{ emoji.aliasOf }})</span>
2018-02-25 01:55:49 +09:00
</li>
</ol>
2023-01-14 20:51:07 +09:00
<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>
2018-02-20 20:29:52 +09:00
</div>
</template>
<script lang="ts">
2023-01-16 18:39:58 +09:00
import { markRaw, ref, shallowRef, computed, onUpdated, onMounted, onBeforeUnmount, nextTick, watch } from 'vue';
import sanitizeHtml from 'sanitize-html';
2023-09-19 16:37:43 +09:00
import contains from '@/scripts/contains.js';
import { char2twemojiFilePath, char2fluentEmojiFilePath } from '@/scripts/emoji-base.js';
import { acct } from '@/filters/user.js';
import * as os from '@/os.js';
import { defaultStore } from '@/store.js';
import { emojilist, getEmojiName } from '@/scripts/emojilist.js';
import { i18n } from '@/i18n.js';
import { miLocalStorage } from '@/local-storage.js';
import { customEmojis } from '@/custom-emojis.js';
import { MFM_TAGS } from '@/const.js';
2018-02-20 20:29:52 +09:00
2018-11-01 21:28:39 +09:00
type EmojiDef = {
emoji: string;
name: string;
url: string;
2018-11-01 21:28:39 +09:00
aliasOf?: string;
} | {
emoji: string;
name: string;
aliasOf?: string;
isCustomEmoji?: true;
2018-11-01 21:28:39 +09:00
};
const lib = emojilist.filter(x => x.category !== 'flags');
2018-04-09 18:52:29 +09:00
2023-01-16 18:39:58 +09:00
const emojiDb = computed(() => {
2023-01-16 19:56:43 +09:00
//#region Unicode Emoji
2023-01-16 18:39:58 +09:00
const char2path = defaultStore.reactiveState.emojiStyle.value === 'twemoji' ? char2twemojiFilePath : char2fluentEmojiFilePath;
const unicodeEmojiDB: EmojiDef[] = lib.map(x => ({
emoji: x.char,
name: x.name,
url: char2path(x.char),
}));
for (const index of Object.values(defaultStore.state.additionalUnicodeEmojiIndexes)) {
for (const [emoji, keywords] of Object.entries(index)) {
for (const k of keywords) {
unicodeEmojiDB.push({
emoji: emoji,
name: k,
aliasOf: getEmojiName(emoji)!,
url: char2path(emoji),
});
}
}
}
2023-01-16 18:39:58 +09:00
unicodeEmojiDB.sort((a, b) => a.name.length - b.name.length);
2023-01-16 19:56:43 +09:00
//#endregion
2023-01-16 18:39:58 +09:00
2023-01-16 19:56:43 +09:00
//#region Custom Emoji
2023-01-16 18:39:58 +09:00
const customEmojiDB: EmojiDef[] = [];
for (const x of customEmojis.value) {
customEmojiDB.push({
name: x.name,
emoji: `:${x.name}:`,
isCustomEmoji: true,
});
if (x.aliases) {
for (const alias of x.aliases) {
customEmojiDB.push({
name: alias,
aliasOf: x.name,
emoji: `:${x.name}:`,
isCustomEmoji: true,
});
}
}
}
2023-01-16 18:39:58 +09:00
customEmojiDB.sort((a, b) => a.name.length - b.name.length);
2023-01-16 19:56:43 +09:00
//#endregion
return markRaw([...customEmojiDB, ...unicodeEmojiDB]);
2023-01-16 18:39:58 +09:00
});
export default {
emojiDb,
emojilist,
};
</script>
2018-07-18 07:19:24 +09:00
<script lang="ts" setup>
const props = defineProps<{
type: string;
q: string | null;
textarea: HTMLTextAreaElement;
close: () => void;
x: number;
y: number;
}>();
const emit = defineEmits<{
(event: 'done', value: { type: string; value: any }): void;
(event: 'closed'): void;
}>();
const suggests = ref<Element>();
const rootEl = shallowRef<HTMLDivElement>();
const fetching = ref(true);
const users = ref<any[]>([]);
const hashtags = ref<any[]>([]);
const emojis = ref<(EmojiDef)[]>([]);
const items = ref<Element[] | HTMLCollection>([]);
const mfmTags = ref<string[]>([]);
const select = ref(-1);
const zIndex = os.claimZIndex('high');
function complete(type: string, value: any) {
emit('done', { type, value });
emit('closed');
if (type === 'emoji') {
let recents = defaultStore.state.recentlyUsedEmojis;
recents = recents.filter((emoji: any) => emoji !== value);
recents.unshift(value);
defaultStore.set('recentlyUsedEmojis', recents.splice(0, 32));
}
}
2018-07-18 07:19:24 +09:00
function setPosition() {
if (!rootEl.value) return;
if (props.x + rootEl.value.offsetWidth > window.innerWidth) {
rootEl.value.style.left = (window.innerWidth - rootEl.value.offsetWidth) + 'px';
} else {
rootEl.value.style.left = `${props.x}px`;
}
if (props.y + rootEl.value.offsetHeight > window.innerHeight) {
rootEl.value.style.top = (props.y - rootEl.value.offsetHeight) + 'px';
rootEl.value.style.marginTop = '0';
} else {
rootEl.value.style.top = props.y + 'px';
rootEl.value.style.marginTop = 'calc(1em + 8px)';
}
}
2020-02-01 07:38:40 +09:00
function exec() {
select.value = -1;
if (suggests.value) {
for (const el of Array.from(items.value)) {
el.removeAttribute('data-selected');
}
}
if (props.type === 'user') {
if (!props.q) {
users.value = [];
fetching.value = false;
return;
}
2018-02-20 20:29:52 +09:00
const cacheKey = `autocomplete:user:${props.q}`;
const cache = sessionStorage.getItem(cacheKey);
if (cache) {
users.value = JSON.parse(cache);
fetching.value = false;
} else {
os.api('users/search-by-username-and-host', {
username: props.q,
limit: 10,
detail: false,
}).then(searchedUsers => {
users.value = searchedUsers as any[];
fetching.value = false;
// キャッシュ
sessionStorage.setItem(cacheKey, JSON.stringify(searchedUsers));
});
}
} else if (props.type === 'hashtag') {
2022-03-01 21:36:20 +09:00
if (!props.q || props.q === '') {
2023-02-22 15:28:17 +09:00
hashtags.value = JSON.parse(miLocalStorage.getItem('hashtags') ?? '[]');
fetching.value = false;
} else {
const cacheKey = `autocomplete:hashtag:${props.q}`;
const cache = sessionStorage.getItem(cacheKey);
if (cache) {
const hashtags = JSON.parse(cache);
hashtags.value = hashtags;
fetching.value = false;
} else {
os.api('hashtags/search', {
query: props.q,
limit: 30,
}).then(searchedHashtags => {
hashtags.value = searchedHashtags as any[];
fetching.value = false;
// キャッシュ
sessionStorage.setItem(cacheKey, JSON.stringify(searchedHashtags));
});
}
}
} else if (props.type === 'emoji') {
2022-03-01 21:36:20 +09:00
if (!props.q || props.q === '') {
// 最近使った絵文字をサジェスト
2023-01-16 18:39:58 +09:00
emojis.value = defaultStore.state.recentlyUsedEmojis.map(emoji => emojiDb.value.find(dbEmoji => dbEmoji.emoji === emoji)).filter(x => x) as EmojiDef[];
return;
}
2018-02-20 20:29:52 +09:00
emojis.value = emojiAutoComplete(props.q, emojiDb.value);
} else if (props.type === 'mfmTag') {
2022-03-01 21:36:20 +09:00
if (!props.q || props.q === '') {
mfmTags.value = MFM_TAGS;
return;
}
2022-03-01 21:36:20 +09:00
mfmTags.value = MFM_TAGS.filter(tag => tag.startsWith(props.q ?? ''));
}
}
type EmojiScore = { emoji: EmojiDef, score: number };
function emojiAutoComplete(query: string | null, emojiDb: EmojiDef[], max = 30): EmojiDef[] {
if (!query) {
return [];
}
const matched = new Map<string, EmojiScore>();
// 前方一致(エイリアスなし)
emojiDb.some(x => {
if (x.name.startsWith(query) && !x.aliasOf) {
matched.set(x.name, { emoji: x, score: query.length + 1 });
}
return matched.size === max;
});
// 前方一致(エイリアス込み)
if (matched.size < max) {
emojiDb.some(x => {
if (x.name.startsWith(query) && !matched.has(x.aliasOf ?? x.name)) {
matched.set(x.aliasOf ?? x.name, { emoji: x, score: query.length });
}
return matched.size === max;
});
}
// 部分一致(エイリアス込み)
if (matched.size < max) {
emojiDb.some(x => {
if (x.name.includes(query) && !matched.has(x.aliasOf ?? x.name)) {
matched.set(x.aliasOf ?? x.name, { emoji: x, score: query.length - 1 });
}
return matched.size === max;
});
}
// 簡易あいまい検索3文字以上
if (matched.size < max && query.length > 3) {
const queryChars = [...query];
const hitEmojis = new Map<string, EmojiScore>();
for (const x of emojiDb) {
// 文字列の位置を進めながら、クエリの文字を順番に探す
let pos = 0;
let hit = 0;
for (const c of queryChars) {
pos = x.name.indexOf(c, pos);
if (pos <= -1) break;
hit++;
}
// 半分以上の文字が含まれていればヒットとする
if (hit > Math.ceil(queryChars.length / 2) && hit - 2 > (matched.get(x.aliasOf ?? x.name)?.score ?? 0)) {
hitEmojis.set(x.aliasOf ?? x.name, { emoji: x, score: hit - 2 });
}
}
// ヒットしたものを全部追加すると雑多になるので、先頭の6件程度だけにしておく6件オートコンプリートのポップアップのサイズ分
[...hitEmojis.values()]
.sort((x, y) => y.score - x.score)
.slice(0, 6)
.forEach(it => matched.set(it.emoji.name, it));
}
return [...matched.values()]
.sort((x, y) => y.score - x.score)
.slice(0, max)
.map(it => it.emoji);
}
function onMousedown(event: Event) {
if (!contains(rootEl.value, event.target) && (rootEl.value !== event.target)) props.close();
}
function onKeydown(event: KeyboardEvent) {
const cancel = () => {
event.preventDefault();
event.stopPropagation();
};
switch (event.key) {
case 'Enter':
if (select.value !== -1) {
cancel();
(items.value[select.value] as any).click();
2020-02-01 07:38:40 +09:00
} else {
props.close();
2020-02-01 07:38:40 +09:00
}
break;
case 'Escape':
cancel();
props.close();
break;
2020-02-01 07:38:40 +09:00
case 'ArrowUp':
if (select.value !== -1) {
cancel();
selectPrev();
2020-02-01 07:38:40 +09:00
} else {
props.close();
2018-02-25 18:40:39 +09:00
}
break;
2018-02-25 18:40:39 +09:00
case 'Tab':
case 'ArrowDown':
cancel();
selectNext();
break;
2018-02-20 20:29:52 +09:00
default:
event.stopPropagation();
props.textarea.focus();
}
}
function selectNext() {
if (++select.value >= items.value.length) select.value = 0;
if (items.value.length === 0) select.value = -1;
applySelect();
}
function selectPrev() {
if (--select.value < 0) select.value = items.value.length - 1;
applySelect();
}
function applySelect() {
for (const el of Array.from(items.value)) {
el.removeAttribute('data-selected');
}
2020-02-01 07:38:40 +09:00
if (select.value !== -1) {
items.value[select.value].setAttribute('data-selected', 'true');
(items.value[select.value] as any).focus();
}
}
function chooseUser() {
props.close();
os.selectUser().then(user => {
complete('user', user);
props.textarea.focus();
});
}
onUpdated(() => {
setPosition();
2022-03-01 21:36:20 +09:00
items.value = suggests.value?.children ?? [];
});
onMounted(() => {
setPosition();
props.textarea.addEventListener('keydown', onKeydown);
document.body.addEventListener('mousedown', onMousedown);
nextTick(() => {
exec();
watch(() => props.q, () => {
nextTick(() => {
exec();
2020-02-01 07:42:21 +09:00
});
});
});
});
onBeforeUnmount(() => {
props.textarea.removeEventListener('keydown', onKeydown);
Migrate to Vue3 (#6587) * Update reaction.vue * fix bug * wip * wip * wjio * wip * Revert "wip" This reverts commit e427f2160adf4e8a4147006e25a89854edab0033. * wip * wip * wip * Update init.ts * Update drive-window.vue * wip * wip * Use PascalCase for components * Use PascalCase for components * update dep * wip * wip * wip * Update init.ts * wip * Update paging.ts * Update test.vue * watch deep * wip * lint * wip * wip * wip * wip * wiop * wip * Update webpack.config.ts * alllow null poll * wip * wip * wip * wiop * UI redesign & refactor (#6714) * wip * wip * wip * wip * wip * Update drive.vue * Update word-mute.vue * wip * wip * wip * clean up * wip * Update default.vue * wip * Update notes.vue * Update mfm.ts * Update index.home.vue * Update post-form.vue * Update post-form-attaches.vue * wip * Update post-form.vue * Update sidebar.vue * wip * wip * Update index.vue * wip * Update default.vue * Update index.vue * Update index.vue * wip * Update post-form-attaches.vue * Update note.vue * wip * clean up * Update notes.vue * wip * wip * Update ja-JP.yml * wip * wip * Update index.vue * wip * wip * wip * wip * wip * wip * wip * wip * Update default.vue * wip * Update _dark.json5 * wip * wip * wip * clean up * wip * wip * Update index.vue * Update test.vue * wip * wip * fix * wip * wip * wip * wip * clena yop * wip * wip * Update store.ts * Update messaging-room.vue * Update default.widgets.vue * fix * wip * wip * Update modal.vue * wip * Update os.ts * Update os.ts * Update deck.vue * Update init.ts * wip * Update ja-JP.yml * v-sizeは単にwindowのresizeを監視するだけで良いかもしれない * Update modal.vue * wip * Update tooltip.ts * wip * wip * wip * wip * wip * Update image-viewer.vue * wip * wip * Update style.scss * Update style.scss * Update visitor.vue * wip * Update init.ts * Update init.ts * wip * wip * Update visitor.vue * Update visitor.vue * Update visitor.vue * Update visitor.vue * wip * wip * Update modal.vue * Update header.vue * Update menu.vue * Update about.vue * Update about-misskey.vue * wip * wip * Update visitor.vue * Update tooltip.ts * wip * Update drive.vue * wip * Update style.scss * Update header.vue * wip * wip * Update users.user.vue * Update announcements.vue * wip * wip * wip * Update emojis.vue * wip * Update emojis.vue * Update style.scss * Update users.vue * wip * Update style.scss * wip * Update welcome.entrance.vue * Update radio.vue * Update size.ts * Update emoji-edit-dialog.vue * wip * Update emojis.vue * wip * Update emojis.vue * Update emojis.vue * Update emojis.vue * wip * wip * wip * wip * Update file-dialog.vue * wip * wip * Update token-generate-window.vue * Update notification-setting-window.vue * wip * wip * Update _error_.vue * Update ja-JP.yml * wip * wip * Update store.ts * Update emojis.vue * Update emojis.vue * Update emojis.vue * Update announcements.vue * Update store.ts * wip * Update page-editor.vue * wip * wip * Update modal.vue * wip * Update select-file.ts * Update timeline.vue * Update emojis.vue * Update os.ts * wip * Update user-select.vue * Update mfm.ts * Update get-file-info.ts * Update drive.vue * Update init.ts * Update mfm.ts * wip * wip * Update window.vue * Update note.vue * wip * wip * Update user-info.vue * wip * wip * wip * wip * wip * Update header.vue * Update header.vue * wip * Update explore.vue * wip * wip * wip * Update webpack.config.ts * wip * wip * wip * wip * wip * wip * Update autocomplete.ts * wip * wip * wip * Update toast.vue * wip * Update post-form-dialog.vue * wip * wip * wip * wip * wip * Update users.vue * wip * Update explore.vue * wip * wip * wip * Update package.json * wip * Update icon-dialog.vue * wip * wip * Update user-preview.ts * wip * wip * wip * wip * wip * Update instance.vue * Update user-name.vue * Update federation.vue * Update instance.vue * wip * wip * Update tag.vue * wip * wip * wip * wip * wip * Update instance.vue * wip * Update os.ts * Update os.ts * wip * wip * wip * Update router.ts * wip * Update init.ts * Update note.vue * Update messages.vue * wip * wip * wip * wip * wip * google * wip * wip * wip * wip * Update theme-editor.vue * wip * wip * Update room.vue * Update channel-editor.vue * wip * Update window.vue * Update window.vue * wip * Update window.vue * Update window.vue * wip * Update menu.vue * wip * wip * wip * wip * Update messaging-room.vue * wip * Update post-form.vue * Update default.widgets.vue * Update window.vue * wip
2020-10-17 20:12:00 +09:00
document.body.removeEventListener('mousedown', onMousedown);
2018-02-20 20:29:52 +09:00
});
</script>
2023-01-14 20:51:07 +09:00
<style lang="scss" module>
.root {
position: fixed;
max-width: 100%;
margin-top: calc(1em + 8px);
overflow: clip;
transition: top 0.1s ease, left 0.1s ease;
2023-01-14 20:51:07 +09:00
}
2023-01-14 20:51:07 +09:00
.list {
display: block;
margin: 0;
padding: 4px 0;
max-height: 190px;
max-width: 500px;
overflow: auto;
list-style: none;
}
2023-01-14 20:51:07 +09:00
.item {
display: flex;
align-items: center;
padding: 4px 12px;
white-space: nowrap;
overflow: clip;
font-size: 0.9em;
cursor: default;
user-select: none;
overflow: hidden;
text-overflow: ellipsis;
&:hover {
background: var(--X3);
}
2023-01-14 20:51:07 +09:00
&[data-selected='true'] {
background: var(--accent);
color: #fff !important;
}
2023-01-14 20:51:07 +09:00
&:active {
background: var(--accentDarken);
color: #fff !important;
}
2023-01-14 20:51:07 +09:00
}
2023-01-14 20:51:07 +09:00
.avatar {
min-width: 28px;
min-height: 28px;
max-width: 28px;
max-height: 28px;
margin: 0 8px 0 0;
border-radius: 100%;
}
2023-01-14 20:51:07 +09:00
.userName {
margin: 0 8px 0 0;
}
2023-01-14 20:51:07 +09:00
.emoji {
flex-shrink: 0 !important;
display: flex !important;
margin: 0 4px 0 0 !important;
height: 24px !important;
width: 24px !important;
justify-content: center !important;
align-items: center !important;
font-size: 20px !important;
pointer-events: none !important;
2023-01-14 20:51:07 +09:00
}
2023-01-14 20:51:07 +09:00
.emojiImg {
height: 24px;
width: 24px;
object-fit: scale-down;
}
2023-01-14 20:51:07 +09:00
.emojiName {
flex-shrink: 1;
}
2023-01-14 20:51:07 +09:00
.emojiAlias {
flex-shrink: 9999999;
margin: 0 0 0 8px;
}
2018-02-20 20:29:52 +09:00
</style>