Sharkey/src/client/components/autocomplete.vue

485 lines
10 KiB
Vue
Raw Normal View History

2018-02-20 13:29:52 +02:00
<template>
2020-02-01 00:38:40 +02:00
<div class="swhvrteh" @contextmenu.prevent="() => {}">
<ol class="users" ref="suggests" v-if="type === 'user'">
<li v-for="user in users" @click="complete(type, user)" @keydown="onKeydown" tabindex="-1" class="user">
2020-02-16 15:46:51 +02:00
<img class="avatar" :src="user.avatarUrl"/>
<span class="name">
2019-03-05 01:24:19 +02:00
<mk-user-name :user="user" :key="user.id"/>
</span>
2018-04-09 12:52:29 +03:00
<span class="username">@{{ user | acct }}</span>
2018-02-20 13:29:52 +02:00
</li>
2020-02-01 00:38:40 +02:00
<li @click="chooseUser()" @keydown="onKeydown" tabindex="-1" class="choose">{{ $t('selectUser') }}</li>
2018-02-20 13:29:52 +02:00
</ol>
2018-07-18 01:19:24 +03:00
<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>
2018-02-24 18:55:49 +02:00
<ol class="emojis" ref="suggests" v-if="emojis.length > 0">
2018-02-25 10:03:39 +02:00
<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="!useOsNativeEmojis"><img :src="emoji.url" :alt="emoji.emoji"/></span>
2018-11-01 04:51:49 +02:00
<span class="emoji" v-else>{{ emoji.emoji }}</span>
2019-06-10 17:09:36 +03:00
<span class="name" v-html="emoji.name.replace(q, `<b>${q}</b>`)"></span>
2018-11-01 14:28:39 +02:00
<span class="alias" v-if="emoji.aliasOf">({{ emoji.aliasOf }})</span>
2018-02-24 18:55:49 +02:00
</li>
</ol>
2018-02-20 13:29:52 +02:00
</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';
2020-02-01 00:38:40 +02:00
import MkUserSelect from './user-select.vue';
2018-02-20 13:29:52 +02:00
2018-11-01 14:28:39 +02:00
type EmojiDef = {
emoji: string;
name: string;
aliasOf?: string;
url?: string;
isCustomEmoji?: boolean;
2018-11-01 14:28:39 +02:00
};
const lib = emojilist.filter(x => x.category !== 'flags');
2018-04-09 12:52:29 +03:00
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,
2018-11-05 04:19:40 +02:00
aliasOf: null,
url: `${twemojiSvgBase}/${char2file(x.char)}.svg`
2018-02-25 10:03:39 +02:00
}));
2018-04-09 12:52:29 +03:00
for (const x of lib) {
if (x.keywords) {
for (const k of x.keywords) {
2018-02-25 10:03:39 +02:00
emjdb.push({
emoji: x.char,
2018-02-25 10:03:39 +02:00
name: k,
aliasOf: x.name,
url: `${twemojiSvgBase}/${char2file(x.char)}.svg`
2018-02-25 10:03:39 +02:00
});
}
2018-02-25 10:03:39 +02:00
}
}
2018-04-09 12:52:29 +03:00
2018-02-25 10:03:39 +02:00
emjdb.sort((a, b) => a.name.length - b.name.length);
2018-02-20 13:29:52 +02:00
export default Vue.extend({
2020-02-01 00:38:40 +02:00
props: {
type: {
type: String,
required: true,
},
q: {
type: String,
required: false,
},
textarea: {
type: HTMLTextAreaElement,
required: true,
},
complete: {
type: Function,
required: true,
},
close: {
type: Function,
required: true,
},
x: {
type: Number,
required: true,
},
y: {
type: Number,
required: true,
},
},
2018-07-18 01:19:24 +03:00
2018-02-20 13:29:52 +02:00
data() {
return {
getStaticImageUrl,
2018-02-20 13:29:52 +02:00
fetching: true,
users: [],
2018-07-18 01:19:24 +03:00
hashtags: [],
2018-02-24 18:55:49 +02:00
emojis: [],
select: -1,
emojilist,
2018-11-01 14:28:39 +02:00
emojiDb: [] as EmojiDef[]
2018-02-24 18:55:49 +02:00
}
},
2018-07-18 01:19:24 +03:00
2018-02-24 18:55:49 +02:00
computed: {
items(): HTMLCollection {
return (this.$refs.suggests as Element).children;
},
useOsNativeEmojis(): boolean {
return this.$store.state.device.useOsNativeEmojis;
2018-02-20 13:29:52 +02:00
}
},
2018-07-18 01:19:24 +03:00
2018-02-24 19:57:19 +02:00
updated() {
2020-02-01 00:38:40 +02:00
this.setPosition();
2018-02-24 19:57:19 +02:00
},
2018-07-18 01:19:24 +03:00
2018-02-20 13:29:52 +02:00
mounted() {
2020-02-01 00:38:40 +02:00
this.setPosition();
2018-11-01 14:28:39 +02:00
//#region Construct Emoji DB
const customEmojis = this.$store.state.instance.meta.emojis;
2018-11-01 14:28:39 +02:00
const emojiDefinitions: EmojiDef[] = [];
for (const x of customEmojis) {
2018-11-01 14:28:39 +02:00
emojiDefinitions.push({
name: x.name,
emoji: `:${x.name}:`,
url: x.url,
isCustomEmoji: true
2018-11-01 14:28:39 +02:00
});
if (x.aliases) {
for (const alias of x.aliases) {
2018-11-01 14:28:39 +02:00
emojiDefinitions.push({
name: alias,
aliasOf: x.name,
emoji: `:${x.name}:`,
url: x.url,
isCustomEmoji: true
2018-11-01 14:28:39 +02:00
});
}
2018-11-01 14:28:39 +02:00
}
}
2018-11-01 14:28:39 +02:00
emojiDefinitions.sort((a, b) => a.name.length - b.name.length);
this.emojiDb = emojiDefinitions.concat(emjdb);
//#endregion
2018-02-20 13:29:52 +02:00
this.textarea.addEventListener('keydown', this.onKeydown);
2020-02-01 00:38:40 +02:00
for (const el of Array.from(document.querySelectorAll('body *'))) {
2018-02-20 13:29:52 +02:00
el.addEventListener('mousedown', this.onMousedown);
}
2018-02-20 13:29:52 +02:00
2018-02-25 11:40:39 +02:00
this.$nextTick(() => {
this.exec();
2018-02-25 10:38:16 +02:00
2018-02-25 11:40:39 +02:00
this.$watch('q', () => {
this.$nextTick(() => {
this.exec();
});
});
});
2018-02-20 13:29:52 +02:00
},
2018-07-18 01:19:24 +03:00
2018-02-20 13:29:52 +02:00
beforeDestroy() {
this.textarea.removeEventListener('keydown', this.onKeydown);
2020-02-01 00:38:40 +02:00
for (const el of Array.from(document.querySelectorAll('body *'))) {
2018-02-20 13:29:52 +02:00
el.removeEventListener('mousedown', this.onMousedown);
}
2018-02-20 13:29:52 +02:00
},
2018-07-18 01:19:24 +03:00
2018-02-20 13:29:52 +02:00
methods: {
2020-02-01 00:38:40 +02:00
setPosition() {
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)';
}
},
2018-02-25 10:38:16 +02:00
exec() {
2018-02-25 11:40:39 +02:00
this.select = -1;
if (this.$refs.suggests) {
for (const el of Array.from(this.items)) {
2018-02-25 11:40:39 +02:00
el.removeAttribute('data-selected');
}
2018-02-25 11:40:39 +02:00
}
2018-02-25 10:38:16 +02:00
if (this.type == 'user') {
2020-02-01 00:38:40 +02:00
if (this.q == null) {
this.users = [];
this.fetching = false;
return;
}
2018-09-01 17:12:51 +03:00
const cacheKey = `autocomplete:user:${this.q}`;
2018-07-18 01:19:24 +03:00
const cache = sessionStorage.getItem(cacheKey);
2018-02-25 10:38:16 +02:00
if (cache) {
const users = JSON.parse(cache);
this.users = users;
this.fetching = false;
} else {
2018-11-09 01:13:34 +02:00
this.$root.api('users/search', {
2018-02-25 10:38:16 +02:00
query: this.q,
2018-12-01 23:44:18 +02:00
limit: 10,
detail: false
2018-02-25 10:38:16 +02:00
}).then(users => {
this.users = users;
this.fetching = false;
// キャッシュ
2018-07-18 01:19:24 +03:00
sessionStorage.setItem(cacheKey, JSON.stringify(users));
});
}
} else if (this.type == 'hashtag') {
2018-07-20 21:15:31 +03:00
if (this.q == null || this.q == '') {
this.hashtags = JSON.parse(localStorage.getItem('hashtags') || '[]');
2018-07-18 01:19:24 +03:00
this.fetching = false;
} else {
2018-09-01 17:12:51 +03:00
const cacheKey = `autocomplete:hashtag:${this.q}`;
2018-07-20 21:15:31 +03:00
const cache = sessionStorage.getItem(cacheKey);
if (cache) {
const hashtags = JSON.parse(cache);
2018-07-18 01:19:24 +03:00
this.hashtags = hashtags;
this.fetching = false;
2018-07-20 21:15:31 +03:00
} else {
2018-11-09 01:13:34 +02:00
this.$root.api('hashtags/search', {
2018-07-20 21:15:31 +03:00
query: this.q,
limit: 30
}).then(hashtags => {
this.hashtags = hashtags;
this.fetching = false;
// キャッシュ
sessionStorage.setItem(cacheKey, JSON.stringify(hashtags));
});
}
2018-02-25 10:38:16 +02:00
}
} else if (this.type == 'emoji') {
2018-11-05 19:05:16 +02:00
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;
});
2018-11-05 19:05:16 +02:00
return;
}
2018-02-25 10:38:16 +02:00
const matched = [];
2018-11-01 04:51:49 +02:00
const max = 30;
2018-11-01 14:28:39 +02:00
this.emojiDb.some(x => {
if (x.name.startsWith(this.q) && !x.aliasOf && !matched.some(y => y.emoji == x.emoji)) matched.push(x);
2018-11-01 04:51:49 +02:00
return matched.length == max;
2018-02-25 10:38:16 +02:00
});
2018-11-01 04:51:49 +02:00
if (matched.length < max) {
2018-11-01 14:28:39 +02:00
this.emojiDb.some(x => {
2018-11-01 14:01:47 +02:00
if (x.name.startsWith(this.q) && !matched.some(y => y.emoji == x.emoji)) matched.push(x);
2018-11-01 04:51:49 +02:00
return matched.length == max;
2018-02-27 07:11:18 +02:00
});
}
2018-11-01 04:51:49 +02:00
if (matched.length < max) {
2018-11-01 14:28:39 +02:00
this.emojiDb.some(x => {
2018-11-01 14:01:47 +02:00
if (x.name.includes(this.q) && !matched.some(y => y.emoji == x.emoji)) matched.push(x);
2018-11-01 04:51:49 +02:00
return matched.length == max;
2018-02-27 07:11:18 +02:00
});
}
2018-11-01 04:51:49 +02:00
2018-02-25 10:38:16 +02:00
this.emojis = matched;
}
},
2018-02-20 13:29:52 +02:00
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();
2018-02-24 18:55:49 +02:00
(this.items[this.select] as any).click();
2018-02-20 13:29:52 +02:00
} 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;
2018-02-25 11:40:39 +02:00
default:
e.stopPropagation();
this.textarea.focus();
2018-02-20 13:29:52 +02:00
}
},
selectNext() {
2018-02-24 18:55:49 +02:00
if (++this.select >= this.items.length) this.select = 0;
2018-02-20 13:29:52 +02:00
this.applySelect();
},
selectPrev() {
2018-02-24 18:55:49 +02:00
if (--this.select < 0) this.select = this.items.length - 1;
2018-02-20 13:29:52 +02:00
this.applySelect();
},
applySelect() {
for (const el of Array.from(this.items)) {
2018-02-20 13:29:52 +02:00
el.removeAttribute('data-selected');
}
2018-02-20 13:29:52 +02:00
2018-02-24 18:55:49 +02:00
this.items[this.select].setAttribute('data-selected', 'true');
(this.items[this.select] as any).focus();
2020-02-01 00:38:40 +02:00
},
chooseUser() {
this.close();
const vm = this.$root.new(MkUserSelect, {});
vm.$once('selected', user => {
this.complete('user', user);
});
2020-02-01 00:42:21 +02:00
vm.$once('closed', () => {
this.textarea.focus();
});
2018-02-20 13:29:52 +02:00
}
}
});
</script>
<style lang="scss" scoped>
2020-02-01 00:38:40 +02:00
.swhvrteh {
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;
}
}
> .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;
}
}
}
2018-02-20 13:29:52 +02:00
</style>