commit: 25370083b63328ad489f4f0676a4bfa33de59671
parent: c829b1a5f4a013882ba298b0f95ec0f8b3ed3758
Author: Shpuld Shpludson <shp@cock.li>
Date:   Thu, 28 Mar 2019 21:09:48 +0000
Merge branch '255-emoji-input' into 'develop'
#255 - add emoji input component
Closes #255
See merge request pleroma/pleroma-fe!706
Diffstat:
7 files changed, 262 insertions(+), 68 deletions(-)
diff --git a/src/App.scss b/src/App.scss
@@ -767,3 +767,54 @@ nav {
 .btn.btn-default {
   min-height: 28px;
 }
+
+.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);
+    }
+  }
+}+
\ No newline at end of file
diff --git a/src/components/emoji-input/emoji-input.js b/src/components/emoji-input/emoji-input.js
@@ -0,0 +1,107 @@
+import Completion from '../../services/completion/completion.js'
+import { take, filter, map } from 'lodash'
+
+const EmojiInput = {
+  props: [
+    'value',
+    'placeholder',
+    'type',
+    'classname'
+  ],
+  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
@@ -0,0 +1,64 @@
+<template>
+  <div class="emoji-input">
+    <input
+      v-if="type !== 'textarea'"
+      :class="classname"
+      :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"
+    />
+    <textarea
+      v-else
+      :class="classname"
+      :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"
+    ></textarea>
+    <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%;
+  }
+}
+</style>
diff --git 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
@@ -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">
+        classname="form-control"
+      />
       <textarea
         ref="textarea"
         @click="setCaret"
@@ -55,14 +56,18 @@
         </div>
       </div>
     </div>
-    <div style="position:relative;" v-if="candidates">
-        <div class="autocomplete-panel">
-          <div v-for="candidate in candidates" @click="replace(candidate.utf || (candidate.screen_name + ' '))">
-            <div class="autocomplete" :class="{ highlighted: candidate.highlighted }">
-              <span v-if="candidate.img"><img :src="candidate.img"></img></span>
-              <span v-else>{{candidate.utf}}</span>
-              <span>{{candidate.screen_name}}<small>{{candidate.name}}</small></span>
-            </div>
+    <div class="autocomplete-panel" v-if="candidates">
+        <div class="autocomplete-panel-body">
+          <div
+            v-for="(candidate, index) in candidates"
+            :key="index"
+            @click="replace(candidate.utf || (candidate.screen_name + ' '))"
+            class="autocomplete-item"
+            :class="{ highlighted: candidate.highlighted }"
+          >
+            <span v-if="candidate.img"><img :src="candidate.img" /></span>
+            <span v-else>{{candidate.utf}}</span>
+            <span>{{candidate.screen_name}}<small>{{candidate.name}}</small></span>
           </div>
         </div>
       </div>
@@ -261,50 +266,5 @@
     cursor: pointer;
     z-index: 4;
   }
-
-  .autocomplete-panel {
-    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);
-  }
-
-  .autocomplete {
-    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/user_settings/user_settings.js b/src/components/user_settings/user_settings.js
@@ -7,6 +7,7 @@ import StyleSwitcher from '../style_switcher/style_switcher.vue'
 import fileSizeFormatService from '../../services/file_size_format/file_size_format.js'
 import BlockCard from '../block_card/block_card.vue'
 import MuteCard from '../mute_card/mute_card.vue'
+import EmojiInput from '../emoji-input/emoji-input.vue'
 import withSubscription from '../../hocs/with_subscription/with_subscription'
 import withList from '../../hocs/with_list/with_list'
 
@@ -69,7 +70,8 @@ const UserSettings = {
     TabSwitcher,
     ImageCropper,
     BlockList,
-    MuteList
+    MuteList,
+    EmojiInput
   },
   computed: {
     user () {
diff --git a/src/components/user_settings/user_settings.vue b/src/components/user_settings/user_settings.vue
@@ -22,9 +22,18 @@
           <div class="setting-item" >
             <h2>{{$t('settings.name_bio')}}</h2>
             <p>{{$t('settings.name')}}</p>
-            <input class='name-changer' id='username' v-model="newName"></input>
+            <EmojiInput 
+              type="text"
+              v-model="newName"
+              id="username"
+              classname="name-changer"
+            />
             <p>{{$t('settings.bio')}}</p>
-            <textarea class="bio" v-model="newBio"></textarea>
+            <EmojiInput
+              type="textarea"
+              v-model="newBio"
+              classname="bio"
+            />
             <p>
               <input type="checkbox" v-model="newLocked" id="account-locked">
               <label for="account-locked">{{$t('settings.lock_account_description')}}</label>
@@ -61,7 +70,7 @@
             <h2>{{$t('settings.avatar')}}</h2>
             <p class="visibility-notice">{{$t('settings.avatar_size_instruction')}}</p>
             <p>{{$t('settings.current_avatar')}}</p>
-            <img :src="user.profile_image_url_original" class="current-avatar"></img>
+            <img :src="user.profile_image_url_original" class="current-avatar" />
             <p>{{$t('settings.set_new_avatar')}}</p>
             <button class="btn" type="button" id="pick-avatar" v-show="pickAvatarBtnVisible">{{$t('settings.upload_a_photo')}}</button>
             <image-cropper trigger="#pick-avatar" :submitHandler="submitAvatar" @open="pickAvatarBtnVisible=false" @close="pickAvatarBtnVisible=true" />
@@ -69,12 +78,11 @@
           <div class="setting-item">
             <h2>{{$t('settings.profile_banner')}}</h2>
             <p>{{$t('settings.current_profile_banner')}}</p>
-            <img :src="user.cover_photo" class="banner"></img>
+            <img :src="user.cover_photo" class="banner" />
             <p>{{$t('settings.set_new_profile_banner')}}</p>
-            <img class="banner" v-bind:src="bannerPreview" v-if="bannerPreview">
-            </img>
+            <img class="banner" v-bind:src="bannerPreview" v-if="bannerPreview" />
             <div>
-              <input type="file" @change="uploadFile('banner', $event)" ></input>
+              <input type="file" @change="uploadFile('banner', $event)" />
             </div>
             <i class=" icon-spin4 animate-spin uploading" v-if="bannerUploading"></i>
             <button class="btn btn-default" v-else-if="bannerPreview" @click="submitBanner">{{$t('general.submit')}}</button>
@@ -86,10 +94,9 @@
           <div class="setting-item">
             <h2>{{$t('settings.profile_background')}}</h2>
             <p>{{$t('settings.set_new_profile_background')}}</p>
-            <img class="bg" v-bind:src="backgroundPreview" v-if="backgroundPreview">
-            </img>
+            <img class="bg" v-bind:src="backgroundPreview" v-if="backgroundPreview" />
             <div>
-              <input type="file" @change="uploadFile('background', $event)" ></input>
+              <input type="file" @change="uploadFile('background', $event)" />
             </div>
             <i class=" icon-spin4 animate-spin uploading" v-if="backgroundUploading"></i>
             <button class="btn btn-default" v-else-if="backgroundPreview" @click="submitBg">{{$t('general.submit')}}</button>
@@ -165,7 +172,7 @@
             <h2>{{$t('settings.follow_import')}}</h2>
             <p>{{$t('settings.import_followers_from_a_csv_file')}}</p>
             <form>
-              <input type="file" ref="followlist" v-on:change="followListChange"></input>
+              <input type="file" ref="followlist" v-on:change="followListChange" />
             </form>
             <i class=" icon-spin4 animate-spin uploading" v-if="followListUploading"></i>
             <button class="btn btn-default" v-else @click="importFollows">{{$t('general.submit')}}</button>