diff options
| author | syuilo <syuilotan@yahoo.co.jp> | 2020-11-07 10:43:27 +0900 |
|---|---|---|
| committer | syuilo <syuilotan@yahoo.co.jp> | 2020-11-07 10:43:27 +0900 |
| commit | bef2534fa86cc58654c23bbc8d59f9f9e756f762 (patch) | |
| tree | db8537d43d9bf7d88f8c88c7f33dab9d33ac18fc /src/client/components | |
| parent | 画像ダイアログでスクロールが発生しないように (diff) | |
| download | sharkey-bef2534fa86cc58654c23bbc8d59f9f9e756f762.tar.gz sharkey-bef2534fa86cc58654c23bbc8d59f9f9e756f762.tar.bz2 sharkey-bef2534fa86cc58654c23bbc8d59f9f9e756f762.zip | |
絵文字ピッカーを強化 + 絵文字ピッカーをリアクションピッカーとして使えるように
Resolve #5079
Resolve #3219
Diffstat (limited to 'src/client/components')
| -rw-r--r-- | src/client/components/emoji-picker.vue | 424 | ||||
| -rw-r--r-- | src/client/components/note.vue | 44 |
2 files changed, 328 insertions, 140 deletions
diff --git a/src/client/components/emoji-picker.vue b/src/client/components/emoji-picker.vue index a9d7c71141..12f770205d 100644 --- a/src/client/components/emoji-picker.vue +++ b/src/client/components/emoji-picker.vue @@ -1,62 +1,94 @@ <template> <MkModal ref="modal" :src="src" @click="$refs.modal.close()" @closed="$emit('closed')"> <div class="omfetrab _popup"> - <header> - <button v-for="(category, i) in categories" - class="_button" - @click="go(category)" - :class="{ active: category.isActive }" - :key="i" - > - <Fa :icon="category.icon" fixed-width/> - </button> - </header> - + <input ref="search" class="search" v-model.trim="q" :placeholder="$t('search')" @paste.stop="paste" @keyup.enter="done()" autofocus> <div class="emojis"> - <template v-if="categories[0].isActive"> - <header class="category"><Fa :icon="faHistory" fixed-width/> {{ $t('recentUsed') }}</header> - <div class="list"> - <button v-for="emoji in ($store.state.device.recentEmojis || [])" + <section class="result"> + <div v-if="searchResultCustom.length > 0"> + <button v-for="emoji in searchResultCustom" class="_button" :title="emoji.name" - @click="chosen(emoji)" + @click="chosen(emoji, $event)" :key="emoji" + tabindex="0" > <MkEmoji v-if="emoji.char != null" :emoji="emoji.char"/> <img v-else :src="$store.state.device.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url"/> </button> </div> - - <header class="category"><Fa :icon="faAsterisk" fixed-width/> {{ $t('customEmojis') }}</header> - </template> - - <template v-if="categories.find(x => x.isActive).name"> - <div class="list"> - <button v-for="emoji in emojilist.filter(e => e.category === categories.find(x => x.isActive).name)" + <div v-if="searchResultUnicode.length > 0"> + <button v-for="emoji in searchResultUnicode" class="_button" :title="emoji.name" - @click="chosen(emoji)" + @click="chosen(emoji, $event)" :key="emoji.name" + tabindex="0" > <MkEmoji :emoji="emoji.char"/> </button> </div> - </template> - <template v-else> - <div v-for="(key, i) in Object.keys(customEmojis)" :key="i"> - <header class="sub" v-if="key">{{ key }}</header> - <div class="list"> - <button v-for="emoji in customEmojis[key]" + </section> + + <div class="index"> + <section> + <div> + <button v-for="emoji in reactions || $store.state.settings.reactions" + class="_button" + :title="emoji.name" + @click="chosen(emoji, $event)" + :key="emoji" + tabindex="0" + > + <MkEmoji :emoji="emoji.startsWith(':') ? null : emoji" :name="emoji.startsWith(':') ? emoji.substr(1, emoji.length - 2) : null" :normal="true"/> + </button> + </div> + </section> + + <section> + <header class="_acrylic"><Fa :icon="faHistory" fixed-width/> {{ $t('recentUsed') }}</header> + <div> + <button v-for="emoji in ($store.state.device.recentEmojis || [])" class="_button" :title="emoji.name" - @click="chosen(emoji)" - :key="emoji.name" + @click="chosen(emoji, $event)" + :key="emoji" > - <img :src="$store.state.device.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url"/> + <MkEmoji v-if="emoji.char != null" :emoji="emoji.char"/> + <img v-else :src="$store.state.device.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url"/> </button> </div> + </section> + + <div class="arrow"><Fa :icon="faChevronDown"/></div> + </div> + + <section v-for="category in customEmojiCategories" :key="'custom:' + category" class="custom"> + <header class="_acrylic" v-appear="() => visibleCategories[category] = true">{{ category || $t('other') }}</header> + <div v-if="visibleCategories[category]"> + <button v-for="emoji in customEmojis.filter(e => e.category === category)" + class="_button" + :title="emoji.name" + @click="chosen(emoji, $event)" + :key="emoji.name" + > + <img :src="$store.state.device.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url"/> + </button> </div> - </template> + </section> + + <section v-for="category in categories" :key="category.name" class="unicode"> + <header class="_acrylic" v-appear="() => category.isActive = true"><Fa :icon="category.icon" fixed-width/> {{ category.name }}</header> + <div v-if="category.isActive"> + <button v-for="emoji in emojilist.filter(e => e.category === category.name)" + class="_button" + :title="emoji.name" + @click="chosen(emoji, $event)" + :key="emoji.name" + > + <MkEmoji :emoji="emoji.char"/> + </button> + </div> + </section> </div> </div> </MkModal> @@ -66,10 +98,11 @@ import { defineComponent, markRaw } from 'vue'; import { emojilist } from '../../misc/emojilist'; import { getStaticImageUrl } from '@/scripts/get-static-image-url'; -import { faAsterisk, faLeaf, faUtensils, faFutbol, faCity, faDice, faGlobe, faHistory, faUser } from '@fortawesome/free-solid-svg-icons'; +import { faAsterisk, faLeaf, faUtensils, faFutbol, faCity, faDice, faGlobe, faHistory, faUser, faChevronDown } from '@fortawesome/free-solid-svg-icons'; import { faHeart, faFlag, faLaugh } from '@fortawesome/free-regular-svg-icons'; -import { groupByX } from '../../prelude/array'; import MkModal from '@/components/ui/modal.vue'; +import Particle from '@/components/particle.vue'; +import * as os from '@/os'; export default defineComponent({ components: { @@ -80,6 +113,9 @@ export default defineComponent({ src: { required: false }, + reactions: { + required: false + }, }, emits: ['done', 'closed'], @@ -88,12 +124,14 @@ export default defineComponent({ return { emojilist: markRaw(emojilist), getStaticImageUrl, - customEmojis: {}, - faGlobe, faHistory, + customEmojiCategories: this.$store.getters['instance/emojiCategories'], + customEmojis: this.$store.state.instance.meta.emojis, + visibleCategories: {}, + q: null, + searchResultCustom: [], + searchResultUnicode: [], + faGlobe, faHistory, faChevronDown, categories: [{ - icon: faAsterisk, - isActive: true - }, { name: 'face', icon: faLaugh, isActive: false @@ -134,38 +172,149 @@ export default defineComponent({ }; }, - created() { - let local = this.$store.state.instance.meta.emojis; - local = groupByX(local, (x: any) => x.category || ''); - this.customEmojis = markRaw(local); - }, + watch: { + q() { + if (this.q == null || this.q === '') { + this.searchResultCustom = []; + this.searchResultUnicode = []; + return; + } - methods: { - go(category: any) { - this.goCategory(category.name); - }, + const q = this.q.replace(/:/g, ''); + + const searchCustom = () => { + const max = 8; + const emojis = this.customEmojis; + const matches = new Set(); + + const exactMatch = emojis.find(e => e.name === q); + if (exactMatch) matches.add(exactMatch); - goCategory(name: string) { - let matched = false; - for (const c of this.categories) { - c.isActive = c.name === name; - if (c.isActive) { - matched = true; + for (const emoji of emojis) { + if (emoji.name.startsWith(q)) { + matches.add(emoji); + if (matches.size >= max) break; + } } + if (matches.size >= max) return matches; + + for (const emoji of emojis) { + if (emoji.aliases.some(alias => alias.startsWith(q))) { + matches.add(emoji); + if (matches.size >= max) break; + } + } + if (matches.size >= max) return matches; + + for (const emoji of emojis) { + if (emoji.name.includes(q)) { + matches.add(emoji); + if (matches.size >= max) break; + } + } + if (matches.size >= max) return matches; + + for (const emoji of emojis) { + if (emoji.aliases.some(alias => alias.includes(q))) { + matches.add(emoji); + if (matches.size >= max) break; + } + } + return matches; + }; + + const searchUnicode = () => { + const max = 8; + const emojis = this.emojilist; + const matches = new Set(); + + const exactMatch = emojis.find(e => e.name === q); + if (exactMatch) matches.add(exactMatch); + + for (const emoji of emojis) { + if (emoji.name.startsWith(q)) { + matches.add(emoji); + if (matches.size >= max) break; + } + } + if (matches.size >= max) return matches; + + for (const emoji of emojis) { + if (emoji.keywords.some(keyword => keyword.startsWith(q))) { + matches.add(emoji); + if (matches.size >= max) break; + } + } + if (matches.size >= max) return matches; + + for (const emoji of emojis) { + if (emoji.name.includes(q)) { + matches.add(emoji); + if (matches.size >= max) break; + } + } + if (matches.size >= max) return matches; + + for (const emoji of emojis) { + if (emoji.keywords.some(keyword => keyword.includes(q))) { + matches.add(emoji); + if (matches.size >= max) break; + } + } + return matches; + }; + + this.searchResultCustom = Array.from(searchCustom()); + this.searchResultUnicode = Array.from(searchUnicode()); + } + }, + + mounted() { + this.$refs.search.focus(); + }, + + methods: { + chosen(emoji: any, ev) { + if (ev) { + const el = ev.currentTarget || ev.target; + const rect = el.getBoundingClientRect(); + const x = rect.left + (el.clientWidth / 2); + const y = rect.top + (el.clientHeight / 2); + os.popup(Particle, { x, y }, {}, 'end'); } - if (!matched) { - this.categories[0].isActive = true; - } - }, - chosen(emoji: any) { - const getKey = (emoji: any) => emoji.char || `:${emoji.name}:`; + const getKey = (emoji: any) => typeof emoji === 'string' ? emoji : emoji.char || `:${emoji.name}:`; + this.$emit('done', getKey(emoji)); + this.$refs.modal.close(); + + // 最近使った絵文字更新 let recents = this.$store.state.device.recentEmojis || []; recents = recents.filter((e: any) => getKey(e) !== getKey(emoji)); recents.unshift(emoji) this.$store.commit('device/set', { key: 'recentEmojis', value: recents.splice(0, 16) }); - this.$emit('done', getKey(emoji)); - this.$refs.modal.close(); + }, + + paste(event) { + const paste = (event.clipboardData || window.clipboardData).getData('text'); + if (this.done(paste)) { + event.preventDefault(); + } + }, + + done(query) { + if (query == null) query = this.q; + if (query == null) return; + const q = query.replace(/:/g, ''); + const exactMatchCustom = this.customEmojis.find(e => e.name === q); + if (exactMatchCustom) { + this.chosen(exactMatchCustom); + return true; + } + const exactMatchUnicode = this.emojilist.find(e => e.name === q); + if (exactMatchUnicode) { + this.chosen(exactMatchUnicode); + return true; + } }, } }); @@ -174,85 +323,108 @@ export default defineComponent({ <style lang="scss" scoped> .omfetrab { width: 350px; + contain: content; - > header { - display: flex; - - > button { - flex: 1; - padding: 10px 0; - font-size: 16px; - transition: color 0.2s ease; - - &:hover { - color: var(--fgHighlighted); - transition: color 0s; - } - - &.active { - color: var(--accent); - transition: color 0s; - } - } + > .search { + width: 100%; + padding: 12px; + box-sizing: border-box; + font-size: 1em; + outline: none; + border: none; + background: transparent; + color: var(--fg); } > .emojis { - height: 300px; + $height: 300px; + + height: $height; overflow-y: auto; overflow-x: hidden; - > header.category { - position: sticky; - top: 0; - left: 0; - z-index: 1; - padding: 8px; - background: var(--panel); - font-size: 12px; + > .index { + min-height: $height; + position: relative; + border-bottom: solid 1px var(--divider); + + > .arrow { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + padding: 16px 0; + text-align: center; + opacity: 0.5; + pointer-events: none; + } } - header.sub { - padding: 4px 8px; - font-size: 12px; - } + section { + > header { + position: sticky; + top: 0; + left: 0; + z-index: 1; + padding: 8px; + font-size: 12px; + } - div.list { - display: grid; - grid-template-columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr; - gap: 4px; - padding: 8px; + > div { + display: grid; + grid-template-columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr; + gap: 4px; + padding: 8px; - > button { - position: relative; - padding: 0; - width: 100%; + > button { + position: relative; + padding: 0; + width: 100%; - &:before { - content: ''; - display: block; - width: 1px; - height: 0; - padding-bottom: 100%; - } + &:focus { + outline: solid 2px var(--focus); + z-index: 1; + } + + &:before { + content: ''; + display: block; + width: 1px; + height: 0; + padding-bottom: 100%; + } + + &:hover { + > * { + transform: scale(1.2); + transition: transform 0s; + } + } - &:hover { > * { - transform: scale(1.2); - transition: transform 0s; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: contain; + font-size: 28px; + transition: transform 0.2s ease; + pointer-events: none; } } + } - > * { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - object-fit: contain; - font-size: 28px; - transition: transform 0.2s ease; - pointer-events: none; - } + &.result { + border-bottom: solid 1px var(--divider); + } + + &.unicode { + min-height: 384px; + } + + &.custom { + min-height: 64px; } } } diff --git a/src/client/components/note.vue b/src/client/components/note.vue index 377496b402..53972d9f6f 100644 --- a/src/client/components/note.vue +++ b/src/client/components/note.vue @@ -498,20 +498,36 @@ export default defineComponent({ react(viaKeyboard = false) { pleaseLogin(); this.blur(); - os.popup(import('@/components/reaction-picker.vue'), { - showFocus: viaKeyboard, - src: this.$refs.reactButton, - }, { - done: reaction => { - if (reaction) { - os.api('notes/reactions/create', { - noteId: this.appearNote.id, - reaction: reaction - }); - } - this.focus(); - }, - }, 'closed'); + if (this.$store.state.device.useFullReactionPicker) { + os.popup(import('@/components/emoji-picker.vue'), { + src: this.$refs.reactButton, + }, { + done: reaction => { + if (reaction) { + os.api('notes/reactions/create', { + noteId: this.appearNote.id, + reaction: reaction + }); + } + this.focus(); + }, + }, 'closed'); + } else { + os.popup(import('@/components/reaction-picker.vue'), { + showFocus: viaKeyboard, + src: this.$refs.reactButton, + }, { + done: reaction => { + if (reaction) { + os.api('notes/reactions/create', { + noteId: this.appearNote.id, + reaction: reaction + }); + } + this.focus(); + }, + }, 'closed'); + } }, reactDirectly(reaction) { |