diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2022-08-31 00:24:33 +0900 |
|---|---|---|
| committer | syuilo <Syuilotan@yahoo.co.jp> | 2022-08-31 00:24:33 +0900 |
| commit | 786b150ea75111b5f6102c256d5cfa42cb83d1fb (patch) | |
| tree | d552d0c371829d7ff027890d1036a80bb08517f7 /packages/client/src/components/MkAutocomplete.vue | |
| parent | update deps (diff) | |
| download | misskey-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.vue | 474 |
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> |