From 0fbf56219f4067e0ba952ab8727cd76dc8919e16 Mon Sep 17 00:00:00 2001 From: syuilo <syuilotan@yahoo.co.jp> Date: Tue, 13 Nov 2018 00:12:55 +0900 Subject: [PATCH] [Client] Emoji picker Closes #3130 --- locales/ja-JP.yml | 11 + .../common/views/components/emoji-picker.vue | 200 ++++++++++++++++++ .../app/common/views/components/emoji.vue | 2 +- .../views/components/emoji-picker-dialog.vue | 84 ++++++++ .../desktop/views/components/post-form.vue | 87 +++++--- src/client/theme/dark.json5 | 1 + src/client/theme/light.json5 | 1 + 7 files changed, 361 insertions(+), 25 deletions(-) create mode 100644 src/client/app/common/views/components/emoji-picker.vue create mode 100644 src/client/app/desktop/views/components/emoji-picker-dialog.vue diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 44e69d4fd..de21364b9 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -379,6 +379,17 @@ common/views/components/poll-editor.vue: common/views/components/reaction-picker.vue: choose-reaction: "リアクションを選択" +common/views/components/emoji-picker.vue: + custom-emoji: "カスタム絵文字" + people: "人" + animals-and-nature: "動物&自然" + food-and-drink: "食べ物&飲み物" + activity: "アクティビティ" + travel-and-places: "場所" + objects: "物" + symbols: "記号" + flags: "旗" + common/views/components/signin.vue: username: "ユーザー名" password: "パスワード" diff --git a/src/client/app/common/views/components/emoji-picker.vue b/src/client/app/common/views/components/emoji-picker.vue new file mode 100644 index 000000000..3d1dbd23a --- /dev/null +++ b/src/client/app/common/views/components/emoji-picker.vue @@ -0,0 +1,200 @@ +<template> +<div class="prlncendiewqqkrevzeruhndoakghvtx"> + <header> + <button v-for="category in categories" + :title="category.text" + @click="go(category.ref)" + :class="{ active: category.isActive }" + > + <fa :icon="category.icon" fixed-width/> + </button> + </header> + <div class="emojis" ref="emojis" @scroll.passive="onScroll"> + <section v-for="category in categories" :ref="category.ref"> + <header><fa :icon="category.icon" fixed-width/> {{ category.text }}</header> + <div v-if="category.name"> + <button v-for="emoji in Object.entries(lib).filter(([k, v]) => v.category === category.name)" + :title="emoji[0]" + @click="chosen(emoji[1].char)" + > + <mk-emoji :emoji="emoji[1].char"/> + </button> + </div> + <div v-else> + <button v-for="emoji in customEmojis" + :title="emoji.name" + @click="chosen(`:${emoji.name}:`)" + > + <img :src="emoji.url" :alt="emoji.name"/> + </button> + </div> + </section> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../../../i18n'; +import { lib } from 'emojilib'; + +export default Vue.extend({ + i18n: i18n('common/views/components/emoji-picker.vue'), + + data() { + return { + lib, + customEmojis: [], + categories: [{ + ref: 'customEmojiSection', + text: this.$t('custom-emoji'), + icon: ['fas', 'asterisk'], + isActive: true + }, { + name: 'people', + ref: 'peopleSection', + text: this.$t('people'), + icon: ['far', 'laugh'], + isActive: false + }, { + name: 'animals_and_nature', + ref: 'animalsAndNatureSection', + text: this.$t('animals-and-nature'), + icon: ['fas', 'leaf'], + isActive: false + }, { + name: 'food_and_drink', + ref: 'foodAndDrinkSection', + text: this.$t('food-and-drink'), + icon: ['fas', 'utensils'], + isActive: false + }, { + name: 'activity', + ref: 'activitySection', + text: this.$t('activity'), + icon: ['fas', 'futbol'], + isActive: false + }, { + name: 'travel_and_places', + ref: 'travelAndPlacesSection', + text: this.$t('travel-and-places'), + icon: ['fas', 'city'], + isActive: false + }, { + name: 'objects', + ref: 'objectsSection', + text: this.$t('objects'), + icon: ['fas', 'poo-storm'], + isActive: false + }, { + name: 'symbols', + ref: 'symbolsSection', + text: this.$t('symbols'), + icon: ['far', 'heart'], + isActive: false + }, { + name: 'flags', + ref: 'flagsSection', + text: this.$t('flags'), + icon: ['far', 'flag'], + isActive: false + }] + } + }, + + created() { + this.customEmojis = (this.$root.getMetaSync() || { emojis: [] }).emojis || []; + }, + + methods: { + go(ref) { + this.$refs.emojis.scrollTop = this.$refs[ref][0].offsetTop; + }, + + onScroll(e) { + const section = this.categories.forEach(x => { + const top = e.target.scrollTop; + const el = this.$refs[x.ref][0]; + x.isActive = el.offsetTop <= top && el.offsetTop + el.offsetHeight > top; + }); + }, + + chosen(emoji) { + this.$emit('chosen', emoji); + } + } +}); +</script> + +<style lang="stylus" scoped> +.prlncendiewqqkrevzeruhndoakghvtx + width 350px + background var(--face) + + > header + display flex + + > button + flex 1 + padding 10px 0 + font-size 16px + color var(--text) + transition color 0.2s ease + + &:hover + color var(--textHighlighted) + transition color 0s + + &.active + color var(--primary) + transition color 0s + + > .emojis + height 300px + overflow-y auto + overflow-x hidden + + > section + > header + position sticky + top 0 + left 0 + z-index 1 + padding 8px + background var(--faceHeader) + color var(--text) + font-size 12px + + > div + display grid + grid-template-columns 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr + gap 4px + padding 8px + + > button + padding 0 + width 100% + + &:before + content '' + display block + width 1px + height 0 + padding-bottom 100% + + &:hover + > * + transform scale(1.2) + transition transform 0s + + > * + position absolute + top 0 + left 0 + width 100% + height 100% + font-size 28px + transition transform 0.2s ease + pointer-events none + +</style> diff --git a/src/client/app/common/views/components/emoji.vue b/src/client/app/common/views/components/emoji.vue index c57d6a944..a8fef35b8 100644 --- a/src/client/app/common/views/components/emoji.vue +++ b/src/client/app/common/views/components/emoji.vue @@ -22,7 +22,7 @@ export default Vue.extend({ }, customEmojis: { required: false, - default: [] + default: () => [] } }, diff --git a/src/client/app/desktop/views/components/emoji-picker-dialog.vue b/src/client/app/desktop/views/components/emoji-picker-dialog.vue new file mode 100644 index 000000000..06dbe7584 --- /dev/null +++ b/src/client/app/desktop/views/components/emoji-picker-dialog.vue @@ -0,0 +1,84 @@ +<template> +<div class="gcafiosrssbtbnbzqupfmglvzgiaipyv"> + <x-picker @chosen="chosen"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import contains from '../../../common/scripts/contains'; + +export default Vue.extend({ + components: { + XPicker: () => import('../../../common/views/components/emoji-picker.vue').then(m => m.default) + }, + + props: { + x: { + type: Number, + required: true + }, + y: { + type: Number, + required: true + } + }, + + mounted() { + this.$nextTick(() => { + const width = this.$el.offsetWidth; + const height = this.$el.offsetHeight; + + let x = this.x; + let y = this.y; + + if (x + width - window.pageXOffset > window.innerWidth) { + x = window.innerWidth - width + window.pageXOffset; + } + + if (y + height - window.pageYOffset > window.innerHeight) { + y = window.innerHeight - height + window.pageYOffset; + } + + this.$el.style.left = x + 'px'; + this.$el.style.top = y + 'px'; + + Array.from(document.querySelectorAll('body *')).forEach(el => { + el.addEventListener('mousedown', this.onMousedown); + }); + }); + }, + + methods: { + onMousedown(e) { + e.preventDefault(); + if (!contains(this.$el, e.target) && (this.$el != e.target)) this.close(); + return false; + }, + + chosen(emoji) { + this.$emit('chosen', emoji); + this.close(); + }, + + close() { + Array.from(document.querySelectorAll('body *')).forEach(el => { + el.removeEventListener('mousedown', this.onMousedown); + }); + + this.$emit('closed'); + this.destroyDom(); + } + } +}); +</script> + +<style lang="stylus" scoped> +.gcafiosrssbtbnbzqupfmglvzgiaipyv + position fixed + top 0 + left 0 + z-index 3000 + box-shadow 0 2px 12px 0 rgba(0, 0, 0, 0.3) + +</style> diff --git a/src/client/app/desktop/views/components/post-form.vue b/src/client/app/desktop/views/components/post-form.vue index 44178d941..2bdefe94a 100644 --- a/src/client/app/desktop/views/components/post-form.vue +++ b/src/client/app/desktop/views/components/post-form.vue @@ -15,11 +15,16 @@ <a v-for="tag in recentHashtags.slice(0, 5)" @click="addTag(tag)" :title="$t('click-to-tagging')">#{{ tag }}</a> </div> <input v-show="useCw" v-model="cw" :placeholder="$t('annotations')"> - <textarea :class="{ with: (files.length != 0 || poll) }" - ref="text" v-model="text" :disabled="posting" - @keydown="onKeydown" @paste="onPaste" :placeholder="placeholder" - v-autocomplete="'text'" - ></textarea> + <div class="textarea"> + <textarea :class="{ with: (files.length != 0 || poll) }" + ref="text" v-model="text" :disabled="posting" + @keydown="onKeydown" @paste="onPaste" :placeholder="placeholder" + v-autocomplete="'text'" + ></textarea> + <button class="emoji" @click="emoji" ref="emoji"> + <fa :icon="['far', 'laugh']"/> + </button> + </div> <div class="files" :class="{ with: poll }" v-show="files.length != 0"> <x-draggable :list="files" :options="{ animation: 150 }"> <div v-for="file in files" :key="file.id"> @@ -377,6 +382,19 @@ export default Vue.extend({ this.visibleUsers = erase(user, this.visibleUsers); }, + async emoji() { + const Picker = await import('./emoji-picker-dialog.vue').then(m => m.default); + const button = this.$refs.emoji; + const rect = button.getBoundingClientRect(); + const vm = this.$root.new(Picker, { + x: button.offsetWidth + rect.left + window.pageXOffset, + y: rect.top + window.pageYOffset + }); + vm.$once('chosen', emoji => { + insertTextAtCursor(this.$refs.text, emoji); + }); + }, + post() { this.posting = true; @@ -469,7 +487,7 @@ export default Vue.extend({ > .content > input - > textarea + > .textarea > textarea display block width 100% padding 12px @@ -498,27 +516,48 @@ export default Vue.extend({ > input margin-bottom 8px - > textarea - margin 0 - max-width 100% - min-width 100% - min-height 84px + > .textarea + > .emoji + position absolute + top 0 + right 0 + padding 10px + font-size 18px + color var(--text) + opacity 0.5 - &:hover - & + * - & + * + * - border-color var(--primaryAlpha02) - transition border-color .1s ease + &:hover + color var(--textHighlighted) + opacity 1 - &:focus - & + * - & + * + * - border-color var(--primaryAlpha05) - transition border-color 0s ease + &:active + color var(--primary) + opacity 1 - &.with - border-bottom solid 1px var(--primaryAlpha01) !important - border-radius 4px 4px 0 0 + > textarea + margin 0 + max-width 100% + min-width 100% + min-height 84px + + &:hover + & + * + & + * + * + border-color var(--primaryAlpha02) + transition border-color .1s ease + + &:focus + & + * + & + * + * + border-color var(--primaryAlpha05) + transition border-color 0s ease + + & + .emoji + opacity 0.7 + + &.with + border-bottom solid 1px var(--primaryAlpha01) !important + border-radius 4px 4px 0 0 > .visibleUsers margin-bottom 8px diff --git a/src/client/theme/dark.json5 b/src/client/theme/dark.json5 index 150b6f599..446eac557 100644 --- a/src/client/theme/dark.json5 +++ b/src/client/theme/dark.json5 @@ -18,6 +18,7 @@ secondary: '$secondary', bg: ':darken<8<$secondary', text: '$text', + textHighlighted: ':lighten<7<$text', scrollbarTrack: ':darken<5<$secondary', scrollbarHandle: ':lighten<5<$secondary', diff --git a/src/client/theme/light.json5 b/src/client/theme/light.json5 index 28b9ba783..4a182c242 100644 --- a/src/client/theme/light.json5 +++ b/src/client/theme/light.json5 @@ -18,6 +18,7 @@ secondary: '$secondary', bg: ':darken<8<$secondary', text: '$text', + textHighlighted: ':darken<7<$text', scrollbarTrack: '#fff', scrollbarHandle: '#00000033',