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',