summaryrefslogtreecommitdiff
path: root/packages/client/src/components/MkAutocomplete.vue
diff options
context:
space:
mode:
authorsyuilo <Syuilotan@yahoo.co.jp>2022-08-31 00:24:33 +0900
committersyuilo <Syuilotan@yahoo.co.jp>2022-08-31 00:24:33 +0900
commit786b150ea75111b5f6102c256d5cfa42cb83d1fb (patch)
treed552d0c371829d7ff027890d1036a80bb08517f7 /packages/client/src/components/MkAutocomplete.vue
parentupdate deps (diff)
downloadmisskey-786b150ea75111b5f6102c256d5cfa42cb83d1fb.tar.gz
misskey-786b150ea75111b5f6102c256d5cfa42cb83d1fb.tar.bz2
misskey-786b150ea75111b5f6102c256d5cfa42cb83d1fb.zip
refactor(client): align filename to component name
Diffstat (limited to 'packages/client/src/components/MkAutocomplete.vue')
-rw-r--r--packages/client/src/components/MkAutocomplete.vue474
1 files changed, 474 insertions, 0 deletions
diff --git a/packages/client/src/components/MkAutocomplete.vue b/packages/client/src/components/MkAutocomplete.vue
new file mode 100644
index 0000000000..144281e3c3
--- /dev/null
+++ b/packages/client/src/components/MkAutocomplete.vue
@@ -0,0 +1,474 @@
+<template>
+<div ref="rootEl" class="swhvrteh _popup _shadow" :style="{ zIndex }" @contextmenu.prevent="() => {}">
+ <ol v-if="type === 'user'" ref="suggests" class="users">
+ <li v-for="user in users" tabindex="-1" class="user" @click="complete(type, user)" @keydown="onKeydown">
+ <img class="avatar" :src="user.avatarUrl"/>
+ <span class="name">
+ <MkUserName :key="user.id" :user="user"/>
+ </span>
+ <span class="username">@{{ acct(user) }}</span>
+ </li>
+ <li tabindex="-1" class="choose" @click="chooseUser()" @keydown="onKeydown">{{ i18n.ts.selectUser }}</li>
+ </ol>
+ <ol v-else-if="hashtags.length > 0" ref="suggests" class="hashtags">
+ <li v-for="hashtag in hashtags" tabindex="-1" @click="complete(type, hashtag)" @keydown="onKeydown">
+ <span class="name">{{ hashtag }}</span>
+ </li>
+ </ol>
+ <ol v-else-if="emojis.length > 0" ref="suggests" class="emojis">
+ <li v-for="emoji in emojis" tabindex="-1" @click="complete(type, emoji.emoji)" @keydown="onKeydown">
+ <span v-if="emoji.isCustomEmoji" class="emoji"><img :src="defaultStore.state.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url" :alt="emoji.emoji"/></span>
+ <span v-else-if="!defaultStore.state.useOsNativeEmojis" class="emoji"><img :src="emoji.url" :alt="emoji.emoji"/></span>
+ <span v-else class="emoji">{{ emoji.emoji }}</span>
+ <!-- eslint-disable-next-line vue/no-v-html -->
+ <span class="name" v-html="emoji.name.replace(q, `<b>${q}</b>`)"></span>
+ <span v-if="emoji.aliasOf" class="alias">({{ emoji.aliasOf }})</span>
+ </li>
+ </ol>
+ <ol v-else-if="mfmTags.length > 0" ref="suggests" class="mfmTags">
+ <li v-for="tag in mfmTags" tabindex="-1" @click="complete(type, tag)" @keydown="onKeydown">
+ <span class="tag">{{ tag }}</span>
+ </li>
+ </ol>
+</div>
+</template>
+
+<script lang="ts">
+import { markRaw, ref, onUpdated, onMounted, onBeforeUnmount, nextTick, watch } from 'vue';
+import contains from '@/scripts/contains';
+import { char2filePath } from '@/scripts/twemoji-base';
+import { getStaticImageUrl } from '@/scripts/get-static-image-url';
+import { acct } from '@/filters/user';
+import * as os from '@/os';
+import { MFM_TAGS } from '@/scripts/mfm-tags';
+import { defaultStore } from '@/store';
+import { emojilist } from '@/scripts/emojilist';
+import { instance } from '@/instance';
+import { i18n } from '@/i18n';
+
+type EmojiDef = {
+ emoji: string;
+ name: string;
+ aliasOf?: string;
+ url?: string;
+ isCustomEmoji?: boolean;
+};
+
+const lib = emojilist.filter(x => x.category !== 'flags');
+
+const emjdb: EmojiDef[] = lib.map(x => ({
+ emoji: x.char,
+ name: x.name,
+ url: char2filePath(x.char),
+}));
+
+for (const x of lib) {
+ if (x.keywords) {
+ for (const k of x.keywords) {
+ emjdb.push({
+ emoji: x.char,
+ name: k,
+ aliasOf: x.name,
+ url: char2filePath(x.char),
+ });
+ }
+ }
+}
+
+emjdb.sort((a, b) => a.name.length - b.name.length);
+
+//#region Construct Emoji DB
+const customEmojis = instance.emojis;
+const emojiDefinitions: EmojiDef[] = [];
+
+for (const x of customEmojis) {
+ emojiDefinitions.push({
+ name: x.name,
+ emoji: `:${x.name}:`,
+ url: x.url,
+ isCustomEmoji: true
+ });
+
+ if (x.aliases) {
+ for (const alias of x.aliases) {
+ emojiDefinitions.push({
+ name: alias,
+ aliasOf: x.name,
+ emoji: `:${x.name}:`,
+ url: x.url,
+ isCustomEmoji: true
+ });
+ }
+ }
+}
+
+emojiDefinitions.sort((a, b) => a.name.length - b.name.length);
+
+const emojiDb = markRaw(emojiDefinitions.concat(emjdb));
+//#endregion
+
+export default {
+ emojiDb,
+ emojiDefinitions,
+ emojilist,
+ customEmojis,
+};
+</script>
+
+<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 = ref<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));
+ }
+}
+
+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)';
+ }
+}
+
+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;
+ }
+
+ 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') {
+ if (!props.q || props.q === '') {
+ hashtags.value = JSON.parse(localStorage.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') {
+ if (!props.q || props.q === '') {
+ // 最近使った絵文字をサジェスト
+ emojis.value = defaultStore.state.recentlyUsedEmojis.map(emoji => emojiDb.find(dbEmoji => dbEmoji.emoji === emoji)).filter(x => x) as EmojiDef[];
+ return;
+ }
+
+ const matched: EmojiDef[] = [];
+ const max = 30;
+
+ emojiDb.some(x => {
+ if (x.name.startsWith(props.q ?? '') && !x.aliasOf && !matched.some(y => y.emoji === x.emoji)) matched.push(x);
+ return matched.length === max;
+ });
+
+ if (matched.length < max) {
+ emojiDb.some(x => {
+ if (x.name.startsWith(props.q ?? '') && !matched.some(y => y.emoji === x.emoji)) matched.push(x);
+ return matched.length === max;
+ });
+ }
+
+ if (matched.length < max) {
+ emojiDb.some(x => {
+ if (x.name.includes(props.q ?? '') && !matched.some(y => y.emoji === x.emoji)) matched.push(x);
+ return matched.length === max;
+ });
+ }
+
+ emojis.value = matched;
+ } else if (props.type === 'mfmTag') {
+ if (!props.q || props.q === '') {
+ mfmTags.value = MFM_TAGS;
+ return;
+ }
+
+ mfmTags.value = MFM_TAGS.filter(tag => tag.startsWith(props.q ?? ''));
+ }
+}
+
+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();
+ } else {
+ props.close();
+ }
+ break;
+
+ case 'Escape':
+ cancel();
+ props.close();
+ break;
+
+ case 'ArrowUp':
+ if (select.value !== -1) {
+ cancel();
+ selectPrev();
+ } else {
+ props.close();
+ }
+ break;
+
+ case 'Tab':
+ case 'ArrowDown':
+ cancel();
+ selectNext();
+ break;
+
+ 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');
+ }
+
+ 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();
+ items.value = suggests.value?.children ?? [];
+});
+
+onMounted(() => {
+ setPosition();
+
+ props.textarea.addEventListener('keydown', onKeydown);
+
+ for (const el of Array.from(document.querySelectorAll('body *'))) {
+ el.addEventListener('mousedown', onMousedown);
+ }
+
+ nextTick(() => {
+ exec();
+
+ watch(() => props.q, () => {
+ nextTick(() => {
+ exec();
+ });
+ });
+ });
+});
+
+onBeforeUnmount(() => {
+ props.textarea.removeEventListener('keydown', onKeydown);
+
+ for (const el of Array.from(document.querySelectorAll('body *'))) {
+ el.removeEventListener('mousedown', onMousedown);
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.swhvrteh {
+ position: fixed;
+ max-width: 100%;
+ margin-top: calc(1em + 8px);
+ overflow: hidden;
+ transition: top 0.1s ease, left 0.1s ease;
+
+ > ol {
+ display: block;
+ margin: 0;
+ padding: 4px 0;
+ max-height: 190px;
+ max-width: 500px;
+ overflow: auto;
+ list-style: none;
+
+ > li {
+ display: flex;
+ align-items: center;
+ padding: 4px 12px;
+ white-space: nowrap;
+ overflow: hidden;
+ font-size: 0.9em;
+ cursor: default;
+
+ &, * {
+ user-select: none;
+ }
+
+ * {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ &:hover {
+ background: var(--X3);
+ }
+
+ &[data-selected='true'] {
+ background: var(--accent);
+
+ &, * {
+ color: #fff !important;
+ }
+ }
+
+ &:active {
+ background: var(--accentDarken);
+
+ &, * {
+ color: #fff !important;
+ }
+ }
+ }
+ }
+
+ > .users > li {
+
+ .avatar {
+ min-width: 28px;
+ min-height: 28px;
+ max-width: 28px;
+ max-height: 28px;
+ margin: 0 8px 0 0;
+ border-radius: 100%;
+ }
+
+ .name {
+ margin: 0 8px 0 0;
+ }
+ }
+
+ > .emojis > li {
+
+ .emoji {
+ display: inline-block;
+ margin: 0 4px 0 0;
+ width: 24px;
+
+ > img {
+ width: 24px;
+ vertical-align: bottom;
+ }
+ }
+
+ .alias {
+ margin: 0 0 0 8px;
+ }
+ }
+
+ > .mfmTags > li {
+
+ .name {
+ }
+ }
+}
+</style>