summaryrefslogtreecommitdiff
path: root/src/client/components/autocomplete.vue
diff options
context:
space:
mode:
Diffstat (limited to 'src/client/components/autocomplete.vue')
-rw-r--r--src/client/components/autocomplete.vue443
1 files changed, 443 insertions, 0 deletions
diff --git a/src/client/components/autocomplete.vue b/src/client/components/autocomplete.vue
new file mode 100644
index 0000000000..232b25dd61
--- /dev/null
+++ b/src/client/components/autocomplete.vue
@@ -0,0 +1,443 @@
+<template>
+<div class="mk-autocomplete" @contextmenu.prevent="() => {}">
+ <ol class="users" ref="suggests" v-if="users.length > 0">
+ <li v-for="user in users" @click="complete(type, user)" @keydown="onKeydown" tabindex="-1">
+ <img class="avatar" :src="user.avatarUrl" alt=""/>
+ <span class="name">
+ <mk-user-name :user="user" :key="user.id"/>
+ </span>
+ <span class="username">@{{ user | acct }}</span>
+ </li>
+ </ol>
+ <ol class="hashtags" ref="suggests" v-if="hashtags.length > 0">
+ <li v-for="hashtag in hashtags" @click="complete(type, hashtag)" @keydown="onKeydown" tabindex="-1">
+ <span class="name">{{ hashtag }}</span>
+ </li>
+ </ol>
+ <ol class="emojis" ref="suggests" v-if="emojis.length > 0">
+ <li v-for="emoji in emojis" @click="complete(type, emoji.emoji)" @keydown="onKeydown" tabindex="-1">
+ <span class="emoji" v-if="emoji.isCustomEmoji"><img :src="$store.state.device.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url" :alt="emoji.emoji"/></span>
+ <span class="emoji" v-else-if="!useOsDefaultEmojis"><img :src="emoji.url" :alt="emoji.emoji"/></span>
+ <span class="emoji" v-else>{{ emoji.emoji }}</span>
+ <span class="name" v-html="emoji.name.replace(q, `<b>${q}</b>`)"></span>
+ <span class="alias" v-if="emoji.aliasOf">({{ emoji.aliasOf }})</span>
+ </li>
+ </ol>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { emojilist } from '../../misc/emojilist';
+import contains from '../scripts/contains';
+import { twemojiSvgBase } from '../../misc/twemoji-base';
+import { getStaticImageUrl } from '../scripts/get-static-image-url';
+
+type EmojiDef = {
+ emoji: string;
+ name: string;
+ aliasOf?: string;
+ url?: string;
+ isCustomEmoji?: boolean;
+};
+
+const lib = emojilist.filter(x => x.category !== 'flags');
+
+const char2file = (char: string) => {
+ let codes = Array.from(char).map(x => x.codePointAt(0).toString(16));
+ if (!codes.includes('200d')) codes = codes.filter(x => x != 'fe0f');
+ codes = codes.filter(x => x && x.length);
+ return codes.join('-');
+};
+
+const emjdb: EmojiDef[] = lib.map(x => ({
+ emoji: x.char,
+ name: x.name,
+ aliasOf: null,
+ url: `${twemojiSvgBase}/${char2file(x.char)}.svg`
+}));
+
+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: `${twemojiSvgBase}/${char2file(x.char)}.svg`
+ });
+ }
+ }
+}
+
+emjdb.sort((a, b) => a.name.length - b.name.length);
+
+export default Vue.extend({
+ props: ['type', 'q', 'textarea', 'complete', 'close', 'x', 'y'],
+
+ data() {
+ return {
+ getStaticImageUrl,
+ fetching: true,
+ users: [],
+ hashtags: [],
+ emojis: [],
+ select: -1,
+ emojilist,
+ emojiDb: [] as EmojiDef[]
+ }
+ },
+
+ computed: {
+ items(): HTMLCollection {
+ return (this.$refs.suggests as Element).children;
+ },
+
+ useOsDefaultEmojis(): boolean {
+ return this.$store.state.device.useOsDefaultEmojis;
+ }
+ },
+
+ updated() {
+ //#region 位置調整
+ if (this.x + this.$el.offsetWidth > window.innerWidth) {
+ this.$el.style.left = (window.innerWidth - this.$el.offsetWidth) + 'px';
+ } else {
+ this.$el.style.left = this.x + 'px';
+ }
+
+ if (this.y + this.$el.offsetHeight > window.innerHeight) {
+ this.$el.style.top = (this.y - this.$el.offsetHeight) + 'px';
+ this.$el.style.marginTop = '0';
+ } else {
+ this.$el.style.top = this.y + 'px';
+ this.$el.style.marginTop = 'calc(1em + 8px)';
+ }
+ //#endregion
+ },
+
+ mounted() {
+ //#region Construct Emoji DB
+ const customEmojis = (this.$root.getMetaSync() || { emojis: [] }).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);
+
+ this.emojiDb = emojiDefinitions.concat(emjdb);
+ //#endregion
+
+ this.textarea.addEventListener('keydown', this.onKeydown);
+
+ for (const el of Array.from(document.querySelectorAll('*'))) {
+ el.addEventListener('mousedown', this.onMousedown);
+ }
+
+ this.$nextTick(() => {
+ this.exec();
+
+ this.$watch('q', () => {
+ this.$nextTick(() => {
+ this.exec();
+ });
+ });
+ });
+ },
+
+ beforeDestroy() {
+ this.textarea.removeEventListener('keydown', this.onKeydown);
+
+ for (const el of Array.from(document.querySelectorAll('*'))) {
+ el.removeEventListener('mousedown', this.onMousedown);
+ }
+ },
+
+ methods: {
+ exec() {
+ this.select = -1;
+ if (this.$refs.suggests) {
+ for (const el of Array.from(this.items)) {
+ el.removeAttribute('data-selected');
+ }
+ }
+
+ if (this.type == 'user') {
+ const cacheKey = `autocomplete:user:${this.q}`;
+ const cache = sessionStorage.getItem(cacheKey);
+ if (cache) {
+ const users = JSON.parse(cache);
+ this.users = users;
+ this.fetching = false;
+ } else {
+ this.$root.api('users/search', {
+ query: this.q,
+ limit: 10,
+ detail: false
+ }).then(users => {
+ this.users = users;
+ this.fetching = false;
+
+ // キャッシュ
+ sessionStorage.setItem(cacheKey, JSON.stringify(users));
+ });
+ }
+ } else if (this.type == 'hashtag') {
+ if (this.q == null || this.q == '') {
+ this.hashtags = JSON.parse(localStorage.getItem('hashtags') || '[]');
+ this.fetching = false;
+ } else {
+ const cacheKey = `autocomplete:hashtag:${this.q}`;
+ const cache = sessionStorage.getItem(cacheKey);
+ if (cache) {
+ const hashtags = JSON.parse(cache);
+ this.hashtags = hashtags;
+ this.fetching = false;
+ } else {
+ this.$root.api('hashtags/search', {
+ query: this.q,
+ limit: 30
+ }).then(hashtags => {
+ this.hashtags = hashtags;
+ this.fetching = false;
+
+ // キャッシュ
+ sessionStorage.setItem(cacheKey, JSON.stringify(hashtags));
+ });
+ }
+ }
+ } else if (this.type == 'emoji') {
+ if (this.q == null || this.q == '') {
+ this.emojis = this.emojiDb.filter(x => x.isCustomEmoji && !x.aliasOf).sort((a, b) => {
+ var textA = a.name.toUpperCase();
+ var textB = b.name.toUpperCase();
+ return (textA < textB) ? -1 : (textA > textB) ? 1 : 0;
+ });
+ return;
+ }
+
+ const matched = [];
+ const max = 30;
+
+ this.emojiDb.some(x => {
+ if (x.name.startsWith(this.q) && !x.aliasOf && !matched.some(y => y.emoji == x.emoji)) matched.push(x);
+ return matched.length == max;
+ });
+ if (matched.length < max) {
+ this.emojiDb.some(x => {
+ if (x.name.startsWith(this.q) && !matched.some(y => y.emoji == x.emoji)) matched.push(x);
+ return matched.length == max;
+ });
+ }
+ if (matched.length < max) {
+ this.emojiDb.some(x => {
+ if (x.name.includes(this.q) && !matched.some(y => y.emoji == x.emoji)) matched.push(x);
+ return matched.length == max;
+ });
+ }
+
+ this.emojis = matched;
+ }
+ },
+
+ onMousedown(e) {
+ if (!contains(this.$el, e.target) && (this.$el != e.target)) this.close();
+ },
+
+ onKeydown(e) {
+ const cancel = () => {
+ e.preventDefault();
+ e.stopPropagation();
+ };
+
+ switch (e.which) {
+ case 10: // [ENTER]
+ case 13: // [ENTER]
+ if (this.select !== -1) {
+ cancel();
+ (this.items[this.select] as any).click();
+ } else {
+ this.close();
+ }
+ break;
+
+ case 27: // [ESC]
+ cancel();
+ this.close();
+ break;
+
+ case 38: // [↑]
+ if (this.select !== -1) {
+ cancel();
+ this.selectPrev();
+ } else {
+ this.close();
+ }
+ break;
+
+ case 9: // [TAB]
+ case 40: // [↓]
+ cancel();
+ this.selectNext();
+ break;
+
+ default:
+ e.stopPropagation();
+ this.textarea.focus();
+ }
+ },
+
+ selectNext() {
+ if (++this.select >= this.items.length) this.select = 0;
+ this.applySelect();
+ },
+
+ selectPrev() {
+ if (--this.select < 0) this.select = this.items.length - 1;
+ this.applySelect();
+ },
+
+ applySelect() {
+ for (const el of Array.from(this.items)) {
+ el.removeAttribute('data-selected');
+ }
+
+ this.items[this.select].setAttribute('data-selected', 'true');
+ (this.items[this.select] as any).focus();
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-autocomplete {
+ position: fixed;
+ z-index: 65535;
+ max-width: 100%;
+ margin-top: calc(1em + 8px);
+ overflow: hidden;
+ background: var(--panel);
+ border: solid 1px rgba(#000, 0.1);
+ border-radius: 4px;
+ 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(--yrnqrguo);
+ }
+
+ &[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;
+ color: var(--autocompleteItemText);
+ }
+
+ .username {
+ color: var(--autocompleteItemTextSub);
+ }
+ }
+
+ > .hashtags > li {
+
+ .name {
+ color: var(--autocompleteItemText);
+ }
+ }
+
+ > .emojis > li {
+
+ .emoji {
+ display: inline-block;
+ margin: 0 4px 0 0;
+ width: 24px;
+
+ > img {
+ width: 24px;
+ vertical-align: bottom;
+ }
+ }
+
+ .name {
+ color: var(--autocompleteItemText);
+ }
+
+ .alias {
+ margin: 0 0 0 8px;
+ color: var(--autocompleteItemTextSub);
+ }
+ }
+}
+</style>