diff options
| author | syuilo <syuilotan@yahoo.co.jp> | 2018-02-25 01:55:49 +0900 |
|---|---|---|
| committer | syuilo <syuilotan@yahoo.co.jp> | 2018-02-25 01:55:49 +0900 |
| commit | 83fde7c131f49f5f9ae335153e89541f481edad2 (patch) | |
| tree | 4b77b9ed261547bb50d015567472accb58fb7d3d /src/web/app/common/views | |
| parent | Refactor (diff) | |
| download | misskey-83fde7c131f49f5f9ae335153e89541f481edad2.tar.gz misskey-83fde7c131f49f5f9ae335153e89541f481edad2.tar.bz2 misskey-83fde7c131f49f5f9ae335153e89541f481edad2.zip | |
#1126
Diffstat (limited to 'src/web/app/common/views')
| -rw-r--r-- | src/web/app/common/views/components/autocomplete.vue | 213 | ||||
| -rw-r--r-- | src/web/app/common/views/components/messaging-room.form.vue | 2 | ||||
| -rw-r--r-- | src/web/app/common/views/directives/autocomplete.ts | 162 | ||||
| -rw-r--r-- | src/web/app/common/views/directives/focus.ts | 5 | ||||
| -rw-r--r-- | src/web/app/common/views/directives/index.ts | 4 |
5 files changed, 378 insertions, 8 deletions
diff --git a/src/web/app/common/views/components/autocomplete.vue b/src/web/app/common/views/components/autocomplete.vue new file mode 100644 index 0000000000..1980804927 --- /dev/null +++ b/src/web/app/common/views/components/autocomplete.vue @@ -0,0 +1,213 @@ +<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.avatar_url}?thumbnail&size=32`" alt=""/> + <span class="name">{{ user.name }}</span> + <span class="username">@{{ user.username }}</span> + </li> + </ol> + <ol class="emojis" ref="suggests" v-if="emojis.length > 0"> + <li v-for="emoji in emojis" @click="complete(type, pictograph.dic[emoji])" @keydown="onKeydown" tabindex="-1"> + <span class="emoji">{{ pictograph.dic[emoji] }}</span> + <span class="name" v-html="emoji.replace(q, `<b>${q}</b>`)"></span> + </li> + </ol> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as pictograph from 'pictograph'; +import contains from '../../../common/scripts/contains'; + +export default Vue.extend({ + props: ['type', 'q', 'textarea', 'complete', 'close'], + data() { + return { + fetching: true, + users: [], + emojis: [], + select: -1, + pictograph + } + }, + computed: { + items(): HTMLCollection { + return (this.$refs.suggests as Element).children; + } + }, + mounted() { + this.textarea.addEventListener('keydown', this.onKeydown); + + Array.from(document.querySelectorAll('body *')).forEach(el => { + el.addEventListener('mousedown', this.onMousedown); + }); + + if (this.type == 'user') { + (this as any).api('users/search_by_username', { + query: this.q, + limit: 30 + }).then(users => { + this.users = users; + this.fetching = false; + }); + } else if (this.type == 'emoji') { + const emojis = Object.keys(pictograph.dic).sort((a, b) => a.length - b.length); + const matched = emojis.filter(e => e.indexOf(this.q) > -1); + this.emojis = matched.filter((x, i) => i <= 30); + } + }, + beforeDestroy() { + this.textarea.removeEventListener('keydown', this.onKeydown); + + Array.from(document.querySelectorAll('body *')).forEach(el => { + el.removeEventListener('mousedown', this.onMousedown); + }); + }, + methods: { + 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: + this.close(); + } + }, + + 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() { + Array.from(this.items).forEach(el => { + el.removeAttribute('data-selected'); + }); + + this.items[this.select].setAttribute('data-selected', 'true'); + (this.items[this.select] as any).focus(); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-autocomplete + position absolute + z-index 65535 + margin-top calc(1em + 8px) + overflow hidden + background #fff + border solid 1px rgba(0, 0, 0, 0.1) + border-radius 4px + + > ol + display block + margin 0 + padding 4px 0 + max-height 190px + max-width 500px + overflow auto + list-style none + + > li + display block + padding 4px 12px + white-space nowrap + overflow hidden + font-size 0.9em + color rgba(0, 0, 0, 0.8) + cursor default + + &, * + user-select none + + &:hover + &[data-selected='true'] + background $theme-color + + &, * + color #fff !important + + &:active + background darken($theme-color, 10%) + + &, * + color #fff !important + + > .users > li + + .avatar + vertical-align middle + 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 + /*font-weight bold*/ + font-weight normal + color rgba(0, 0, 0, 0.8) + + .username + font-weight normal + color rgba(0, 0, 0, 0.3) + + > .emojis > li + + .emoji + display inline-block + margin 0 4px 0 0 + width 24px + + .name + font-weight normal + color rgba(0, 0, 0, 0.8) + +</style> diff --git a/src/web/app/common/views/components/messaging-room.form.vue b/src/web/app/common/views/components/messaging-room.form.vue index b89365a5d8..aa07217b32 100644 --- a/src/web/app/common/views/components/messaging-room.form.vue +++ b/src/web/app/common/views/components/messaging-room.form.vue @@ -1,6 +1,6 @@ <template> <div class="mk-messaging-form"> - <textarea v-model="text" @keypress="onKeypress" @paste="onPaste" placeholder="%i18n:common.input-message-here%"></textarea> + <textarea v-model="text" @keypress="onKeypress" @paste="onPaste" placeholder="%i18n:common.input-message-here%" v-autocomplete></textarea> <div class="file" v-if="file">{{ file.name }}</div> <mk-uploader ref="uploader"/> <button class="send" @click="send" :disabled="sending" title="%i18n:common.send%"> diff --git a/src/web/app/common/views/directives/autocomplete.ts b/src/web/app/common/views/directives/autocomplete.ts new file mode 100644 index 0000000000..bd9c9cb611 --- /dev/null +++ b/src/web/app/common/views/directives/autocomplete.ts @@ -0,0 +1,162 @@ +import * as getCaretCoordinates from 'textarea-caret'; +import MkAutocomplete from '../components/autocomplete.vue'; + +export default { + bind(el, binding, vn) { + const self = el._autoCompleteDirective_ = {} as any; + self.x = new Autocomplete(el); + self.x.attach(); + }, + + unbind(el, binding, vn) { + const self = el._autoCompleteDirective_; + self.x.detach(); + } +}; + +/** + * オートコンプリートを管理するクラス。 + */ +class Autocomplete { + private suggestion: any; + private textarea: any; + + /** + * 対象のテキストエリアを与えてインスタンスを初期化します。 + */ + constructor(textarea) { + //#region BIND + this.onInput = this.onInput.bind(this); + this.complete = this.complete.bind(this); + this.close = this.close.bind(this); + //#endregion + + this.suggestion = null; + this.textarea = textarea; + } + + /** + * このインスタンスにあるテキストエリアの入力のキャプチャを開始します。 + */ + public attach() { + this.textarea.addEventListener('input', this.onInput); + } + + /** + * このインスタンスにあるテキストエリアの入力のキャプチャを解除します。 + */ + public detach() { + this.textarea.removeEventListener('input', this.onInput); + this.close(); + } + + /** + * テキスト入力時 + */ + private onInput() { + this.close(); + + const caret = this.textarea.selectionStart; + const text = this.textarea.value.substr(0, caret); + + const mentionIndex = text.lastIndexOf('@'); + const emojiIndex = text.lastIndexOf(':'); + + if (mentionIndex != -1 && mentionIndex > emojiIndex) { + const username = text.substr(mentionIndex + 1); + if (!username.match(/^[a-zA-Z0-9-]+$/)) return; + this.open('user', username); + } + + if (emojiIndex != -1 && emojiIndex > mentionIndex) { + const emoji = text.substr(emojiIndex + 1); + if (!emoji.match(/^[\+\-a-z_]+$/)) return; + this.open('emoji', emoji); + } + } + + /** + * サジェストを提示します。 + */ + private open(type, q) { + // 既に開いているサジェストは閉じる + this.close(); + + // サジェスト要素作成 + this.suggestion = new MkAutocomplete({ + propsData: { + textarea: this.textarea, + complete: this.complete, + close: this.close, + type: type, + q: q + } + }).$mount(); + + //#region サジェストを表示すべき位置を計算 + const caretPosition = getCaretCoordinates(this.textarea, this.textarea.selectionStart); + + const rect = this.textarea.getBoundingClientRect(); + + const x = rect.left + window.pageXOffset + caretPosition.left - this.textarea.scrollLeft; + const y = rect.top + window.pageYOffset + caretPosition.top - this.textarea.scrollTop; + //#endregion + + this.suggestion.$el.style.left = x + 'px'; + this.suggestion.$el.style.top = y + 'px'; + + // 要素追加 + document.body.appendChild(this.suggestion.$el); + } + + /** + * サジェストを閉じます。 + */ + private close() { + if (this.suggestion == null) return; + + this.suggestion.$destroy(); + this.suggestion = null; + + this.textarea.focus(); + } + + /** + * オートコンプリートする + */ + private complete(type, value) { + this.close(); + + const caret = this.textarea.selectionStart; + + if (type == 'user') { + const source = this.textarea.value; + + const before = source.substr(0, caret); + const trimmedBefore = before.substring(0, before.lastIndexOf('@')); + const after = source.substr(caret); + + // 挿入 + this.textarea.value = trimmedBefore + '@' + value.username + ' ' + after; + + // キャレットを戻す + this.textarea.focus(); + const pos = caret + value.username.length; + this.textarea.setSelectionRange(pos, pos); + } else if (type == 'emoji') { + const source = this.textarea.value; + + const before = source.substr(0, caret); + const trimmedBefore = before.substring(0, before.lastIndexOf(':')); + const after = source.substr(caret); + + // 挿入 + this.textarea.value = trimmedBefore + value + after; + + // キャレットを戻す + this.textarea.focus(); + const pos = caret + value.length; + this.textarea.setSelectionRange(pos, pos); + } + } +} diff --git a/src/web/app/common/views/directives/focus.ts b/src/web/app/common/views/directives/focus.ts deleted file mode 100644 index b4fbcb6a87..0000000000 --- a/src/web/app/common/views/directives/focus.ts +++ /dev/null @@ -1,5 +0,0 @@ -export default { - inserted(el) { - el.focus(); - } -}; diff --git a/src/web/app/common/views/directives/index.ts b/src/web/app/common/views/directives/index.ts index 358866f500..268f07a950 100644 --- a/src/web/app/common/views/directives/index.ts +++ b/src/web/app/common/views/directives/index.ts @@ -1,5 +1,5 @@ import Vue from 'vue'; -import focus from './focus'; +import autocomplete from './autocomplete'; -Vue.directive('focus', focus); +Vue.directive('autocomplete', autocomplete); |