From b97a03383990d54573bd5e68393a1ad11e33608b Mon Sep 17 00:00:00 2001
From: jared <jaredrmain@gmail.com>
Date: Mon, 25 Mar 2019 22:38:15 -0400
Subject: [PATCH] #255 - add emoji input component

---
 src/components/emoji-input/emoji-input.js     | 106 ++++++++++++++++++
 src/components/emoji-input/emoji-input.vue    |  99 ++++++++++++++++
 .../post_status_form/post_status_form.js      |   4 +-
 .../post_status_form/post_status_form.vue     |   5 +-
 4 files changed, 211 insertions(+), 3 deletions(-)
 create mode 100644 src/components/emoji-input/emoji-input.js
 create mode 100644 src/components/emoji-input/emoji-input.vue

diff --git a/src/components/emoji-input/emoji-input.js b/src/components/emoji-input/emoji-input.js
new file mode 100644
index 00000000..56414358
--- /dev/null
+++ b/src/components/emoji-input/emoji-input.js
@@ -0,0 +1,106 @@
+import Completion from '../../services/completion/completion.js'
+import { take, filter, map } from 'lodash'
+
+const EmojiInput = {
+  props: [
+    'value',
+    'placeholder',
+    'type'
+  ],
+  data () {
+    return {
+      highlighted: 0,
+      caret: 0
+    }
+  },
+  computed: {
+    suggestions () {
+      const firstchar = this.textAtCaret.charAt(0)
+      if (firstchar === ':') {
+        if (this.textAtCaret === ':') { return }
+        const matchedEmoji = filter(this.emoji.concat(this.customEmoji), (emoji) => emoji.shortcode.startsWith(this.textAtCaret.slice(1)))
+        if (matchedEmoji.length <= 0) {
+          return false
+        }
+        return map(take(matchedEmoji, 5), ({shortcode, image_url, utf}, index) => ({
+          shortcode: `:${shortcode}:`,
+          utf: utf || '',
+          // eslint-disable-next-line camelcase
+          img: utf ? '' : this.$store.state.instance.server + image_url,
+          highlighted: index === this.highlighted
+        }))
+      } else {
+        return false
+      }
+    },
+    textAtCaret () {
+      return (this.wordAtCaret || {}).word || ''
+    },
+    wordAtCaret () {
+      const word = Completion.wordAtPosition(this.value, this.caret - 1) || {}
+      return word
+    },
+    emoji () {
+      return this.$store.state.instance.emoji || []
+    },
+    customEmoji () {
+      return this.$store.state.instance.customEmoji || []
+    }
+  },
+  methods: {
+    replace (replacement) {
+      const newValue = Completion.replaceWord(this.value, this.wordAtCaret, replacement)
+      this.$emit('input', newValue)
+      this.caret = 0
+    },
+    replaceEmoji (e) {
+      const len = this.suggestions.length || 0
+      if (this.textAtCaret === ':' || e.ctrlKey) { return }
+      if (len > 0) {
+        e.preventDefault()
+        const emoji = this.suggestions[this.highlighted]
+        const replacement = emoji.utf || (emoji.shortcode + ' ')
+        const newValue = Completion.replaceWord(this.value, this.wordAtCaret, replacement)
+        this.$emit('input', newValue)
+        this.caret = 0
+        this.highlighted = 0
+      }
+    },
+    cycleBackward (e) {
+      const len = this.suggestions.length || 0
+      if (len > 0) {
+        e.preventDefault()
+        this.highlighted -= 1
+        if (this.highlighted < 0) {
+          this.highlighted = this.suggestions.length - 1
+        }
+      } else {
+        this.highlighted = 0
+      }
+    },
+    cycleForward (e) {
+      const len = this.suggestions.length || 0
+      if (len > 0) {
+        if (e.shiftKey) { return }
+        e.preventDefault()
+        this.highlighted += 1
+        if (this.highlighted >= len) {
+          this.highlighted = 0
+        }
+      } else {
+        this.highlighted = 0
+      }
+    },
+    onKeydown (e) {
+      e.stopPropagation()
+    },
+    onInput (e) {
+      this.$emit('input', e.target.value)
+    },
+    setCaret ({target: {selectionStart}}) {
+      this.caret = selectionStart
+    }
+  }
+}
+
+export default EmojiInput
diff --git a/src/components/emoji-input/emoji-input.vue b/src/components/emoji-input/emoji-input.vue
new file mode 100644
index 00000000..95606305
--- /dev/null
+++ b/src/components/emoji-input/emoji-input.vue
@@ -0,0 +1,99 @@
+<template>
+  <div class="emoji-input">
+    <input
+      class="form-control"
+      :type="type"
+      :value="value"
+      :placeholder="placeholder"
+      @input="onInput"
+      @click="setCaret"
+      @keyup="setCaret"
+      @keydown="onKeydown"
+      @keydown.down="cycleForward"
+      @keydown.up="cycleBackward"
+      @keydown.shift.tab="cycleBackward"
+      @keydown.tab="cycleForward"
+      @keydown.enter="replaceEmoji"
+    />
+    <div class="autocomplete-panel" v-if="suggestions">
+      <div class="autocomplete-panel-body">
+        <div
+          v-for="(emoji, index) in suggestions"
+          :key="index"
+          @click="replace(emoji.utf || (emoji.shortcode + ' '))"
+          class="autocomplete-item"
+          :class="{ highlighted: emoji.highlighted }"
+        >
+          <span v-if="emoji.img">
+            <img :src="emoji.img" />
+          </span>
+          <span v-else>{{emoji.utf}}</span>
+          <span>{{emoji.shortcode}}</span>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script src="./emoji-input.js"></script>
+
+<style lang="scss">
+@import '../../_variables.scss';
+
+.emoji-input {
+  .form-control {
+    width: 100%;
+  }
+}
+
+.autocomplete {
+  &-panel {
+    position: relative;
+
+    &-body {
+      margin: 0 0.5em 0 0.5em;
+      border-radius: $fallback--tooltipRadius;
+      border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
+      position: absolute;
+      z-index: 1;
+      box-shadow: 1px 2px 4px rgba(0, 0, 0, 0.5);
+      // this doesn't match original but i don't care, making it uniform.
+      box-shadow: var(--popupShadow);
+      min-width: 75%;
+      background: $fallback--bg;
+      background: var(--bg, $fallback--bg);
+      color: $fallback--lightText;
+      color: var(--lightText, $fallback--lightText);
+    }
+  }
+
+  &-item {
+    cursor: pointer;
+    padding: 0.2em 0.4em 0.2em 0.4em;
+    border-bottom: 1px solid rgba(0, 0, 0, 0.4);
+    display: flex;
+
+    img {
+      width: 24px;
+      height: 24px;
+      object-fit: contain;
+    }
+
+    span {
+      line-height: 24px;
+      margin: 0 0.1em 0 0.2em;
+    }
+
+    small {
+      margin-left: .5em;
+      color: $fallback--faint;
+      color: var(--faint, $fallback--faint);
+    }
+
+    &.highlighted {
+      background-color: $fallback--fg;
+      background-color: var(--lightBg, $fallback--fg);
+    }
+  }
+}
+</style>
diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js
index c5f30ca6..229aefb7 100644
--- a/src/components/post_status_form/post_status_form.js
+++ b/src/components/post_status_form/post_status_form.js
@@ -1,5 +1,6 @@
 import statusPoster from '../../services/status_poster/status_poster.service.js'
 import MediaUpload from '../media_upload/media_upload.vue'
+import EmojiInput from '../emoji-input/emoji-input.vue'
 import fileTypeService from '../../services/file_type/file_type.service.js'
 import Completion from '../../services/completion/completion.js'
 import { take, filter, reject, map, uniqBy } from 'lodash'
@@ -28,7 +29,8 @@ const PostStatusForm = {
     'subject'
   ],
   components: {
-    MediaUpload
+    MediaUpload,
+    EmojiInput
   },
   mounted () {
     this.resize(this.$refs.textarea)
diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue
index 612f87c1..9d449b74 100644
--- a/src/components/post_status_form/post_status_form.vue
+++ b/src/components/post_status_form/post_status_form.vue
@@ -10,12 +10,13 @@
         <router-link :to="{ name: 'user-settings' }">{{ $t('post_status.account_not_locked_warning_link') }}</router-link>
       </i18n>
       <p v-if="this.newStatus.visibility == 'direct'" class="visibility-notice">{{ $t('post_status.direct_warning') }}</p>
-      <input
+      <EmojiInput
         v-if="newStatus.spoilerText || alwaysShowSubject"
         type="text"
         :placeholder="$t('post_status.content_warning')"
         v-model="newStatus.spoilerText"
-        class="form-cw">
+        class="form-cw"
+      />
       <textarea
         ref="textarea"
         @click="setCaret"