<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> <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, nextTick, onBeforeUnmount, onMounted, onUpdated, ref, 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 { addSkinTone, emojilist } from "@/scripts/emojilist"; import { instance } from "@/instance"; import { i18n } from "@/i18n"; interface EmojiDef { emoji: string; name: string; aliasOf?: string; url?: string; isCustomEmoji?: boolean; } const lib = emojilist.filter((x) => x.category !== "flags"); for (const emoji of lib) { if (emoji.skin_tone_support) { emoji.emoji = addSkinTone(emoji.emoji); } } const emjdb: EmojiDef[] = lib.map((x) => ({ emoji: x.emoji, name: x.slug, url: char2filePath(x.emoji), })); for (const x of lib) { if (x.keywords) { for (const k of x.keywords) { emjdb.push({ emoji: x.emoji, name: k, aliasOf: x.slug, url: char2filePath(x.emoji), }); } } } 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); document.body.addEventListener("mousedown", onMousedown); nextTick(() => { exec(); watch( () => props.q, () => { nextTick(() => { exec(); }); }, ); }); }); onBeforeUnmount(() => { props.textarea.removeEventListener("keydown", onKeydown); document.body.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>