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"