559 lines
11 KiB
Vue
559 lines
11 KiB
Vue
<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,
|
|
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>
|