From bef2534fa86cc58654c23bbc8d59f9f9e756f762 Mon Sep 17 00:00:00 2001 From: syuilo <syuilotan@yahoo.co.jp> Date: Sat, 7 Nov 2020 10:43:27 +0900 Subject: [PATCH] =?UTF-8?q?=E7=B5=B5=E6=96=87=E5=AD=97=E3=83=94=E3=83=83?= =?UTF-8?q?=E3=82=AB=E3=83=BC=E3=82=92=E5=BC=B7=E5=8C=96=20+=20=E7=B5=B5?= =?UTF-8?q?=E6=96=87=E5=AD=97=E3=83=94=E3=83=83=E3=82=AB=E3=83=BC=E3=82=92?= =?UTF-8?q?=E3=83=AA=E3=82=A2=E3=82=AF=E3=82=B7=E3=83=A7=E3=83=B3=E3=83=94?= =?UTF-8?q?=E3=83=83=E3=82=AB=E3=83=BC=E3=81=A8=E3=81=97=E3=81=A6=E4=BD=BF?= =?UTF-8?q?=E3=81=88=E3=82=8B=E3=82=88=E3=81=86=E3=81=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve #5079 Resolve #3219 --- locales/ja-JP.yml | 1 + src/client/components/emoji-picker.vue | 442 +++++++++++++++++-------- src/client/components/note.vue | 44 ++- src/client/pages/settings/reaction.vue | 25 +- src/client/store.ts | 11 + 5 files changed, 369 insertions(+), 154 deletions(-) diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index a88ac74417..4091e7c3f1 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -542,6 +542,7 @@ pluginInstallWarn: "信頼できないプラグインはインストールしな deck: "デッキ" undeck: "デッキ解除" useBlurEffectForModal: "モーダルにぼかし効果を使用" +useFullReactionPicker: "フル機能リアクションピッカーを使用" generateAccessToken: "アクセストークンの発行" permission: "権限" enableAll: "全て有効にする" 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> + </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, $event)" + :key="emoji" + > + <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> + </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> - </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]" - class="_button" - :title="emoji.name" - @click="chosen(emoji)" - :key="emoji.name" - > - <img :src="$store.state.device.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url"/> - </button> - </div> - </div> - </template> + </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; + } + + 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); + + 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: { - go(category: any) { - this.goCategory(category.name); - }, - - goCategory(name: string) { - let matched = false; - for (const c of this.categories) { - c.isActive = c.name === name; - if (c.isActive) { - matched = true; - } + 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; - } - - header.sub { - padding: 4px 8px; - font-size: 12px; - } - - div.list { - display: grid; - grid-template-columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr; - gap: 4px; - padding: 8px; - - > button { - position: relative; - padding: 0; + > .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; + } + } - &:before { - content: ''; - display: block; - width: 1px; - height: 0; - padding-bottom: 100%; - } + section { + > header { + position: sticky; + top: 0; + left: 0; + z-index: 1; + padding: 8px; + font-size: 12px; + } + + > 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%; + + &: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) { diff --git a/src/client/pages/settings/reaction.vue b/src/client/pages/settings/reaction.vue index 7de2f72f61..81bb024995 100644 --- a/src/client/pages/settings/reaction.vue +++ b/src/client/pages/settings/reaction.vue @@ -7,6 +7,7 @@ {{ $t('reaction') }}<template #desc>{{ $t('reactionSettingDescription') }} <button class="_textButton" @click="chooseEmoji">{{ $t('chooseEmoji') }}</button></template> </MkInput> <MkButton inline @click="setDefault"><Fa :icon="faUndo"/> {{ $t('default') }}</MkButton> + <MkSwitch v-model:value="useFullReactionPicker">{{ $t('useFullReactionPicker') }}</MkSwitch> </div> <div class="_footer"> <MkButton @click="save()" primary inline :disabled="!changed"><Fa :icon="faSave"/> {{ $t('save') }}</MkButton> @@ -22,6 +23,7 @@ import { faLaugh, faSave, faEye } from '@fortawesome/free-regular-svg-icons'; import { faUndo } from '@fortawesome/free-solid-svg-icons'; import MkInput from '@/components/ui/input.vue'; import MkButton from '@/components/ui/button.vue'; +import MkSwitch from '@/components/ui/switch.vue'; import { emojiRegexWithCustom } from '../../../misc/emoji-regex'; import { defaultSettings } from '@/store'; import * as os from '@/os'; @@ -30,6 +32,7 @@ export default defineComponent({ components: { MkInput, MkButton, + MkSwitch, }, emits: ['info'], @@ -50,6 +53,11 @@ export default defineComponent({ splited(): any { return this.reactions.match(emojiRegexWithCustom); }, + + useFullReactionPicker: { + get() { return this.$store.state.device.useFullReactionPicker; }, + set(value) { this.$store.commit('device/set', { key: 'useFullReactionPicker', value: value }); } + }, }, watch: { @@ -72,11 +80,18 @@ export default defineComponent({ }, preview(ev) { - os.popup(import('@/components/reaction-picker.vue'), { - reactions: this.splited, - showFocus: false, - src: ev.currentTarget || ev.target, - }, {}, 'closed'); + if (this.$store.state.device.useFullReactionPicker) { + os.popup(import('@/components/emoji-picker.vue'), { + reactions: this.splited, + src: ev.currentTarget || ev.target, + }, {}, 'closed'); + } else { + os.popup(import('@/components/reaction-picker.vue'), { + reactions: this.splited, + showFocus: false, + src: ev.currentTarget || ev.target, + }, {}, 'closed'); + } }, setDefault() { diff --git a/src/client/store.ts b/src/client/store.ts index 8b78824f7f..507c7563a1 100644 --- a/src/client/store.ts +++ b/src/client/store.ts @@ -76,6 +76,7 @@ export const defaultDeviceSettings = { disablePagesScript: false, enableInfiniteScroll: true, useBlurEffectForModal: true, + useFullReactionPicker: false, sidebarDisplay: 'full', // full, icon, hide instanceTicker: 'remote', // none, remote, always roomGraphicsQuality: 'medium', @@ -182,6 +183,16 @@ export const store = createStore({ meta: null }, + getters: { + emojiCategories: state => { + const categories = new Set(); + for (const emoji of state.meta.emojis) { + categories.add(emoji.category); + } + return Array.from(categories); + }, + }, + mutations: { set(state, meta) { state.meta = meta;