aboutsummaryrefslogtreecommitdiff
path: root/src/components/emoji-input
diff options
context:
space:
mode:
authorHenry Jameson <me@hjkos.com>2019-08-31 22:38:02 +0300
committerHenry Jameson <me@hjkos.com>2019-08-31 22:38:02 +0300
commit18ec13d796c0928d09fa93de4117822d2e35502c (patch)
tree1cfb4d68a246c604396bb64bbba3e69bdf4fe511 /src/components/emoji-input
parentb3e9a5a71819c7d3a4b35c5b6ad551785a7ba44f (diff)
parent018a650166a5dce0878b696359a999ab67634cfc (diff)
Merge remote-tracking branch 'upstream/develop' into docs
* upstream/develop: (193 commits) fix user avatar fallback logic remove dead code make bio textarea resizable vertically only remove dead code remove dead code fix crazy watch logic in conversation show three dot button only if needed hide mute conversation button to guests update keyBy generate idObj at timeline level fix pin showing logic in conversation Show a message when JS is disabled Initialize chat only if user is logged in and it wasn't initialized before i18n/Update Japanese i18n/Update pedantic Japanese sync profile tab state with location query refactor TabSwitcher use better name of controlled prop fix potential bug to render active tab in controlled way remove unused param ...
Diffstat (limited to 'src/components/emoji-input')
-rw-r--r--src/components/emoji-input/emoji-input.js227
-rw-r--r--src/components/emoji-input/emoji-input.vue135
-rw-r--r--src/components/emoji-input/suggestor.js94
3 files changed, 373 insertions, 83 deletions
diff --git a/src/components/emoji-input/emoji-input.js b/src/components/emoji-input/emoji-input.js
index a5bb6eaf..fab64a69 100644
--- a/src/components/emoji-input/emoji-input.js
+++ b/src/components/emoji-input/emoji-input.js
@@ -1,51 +1,122 @@
import Completion from '../../services/completion/completion.js'
-import { take, filter, map } from 'lodash'
+import { take } from 'lodash'
+
+/**
+ * EmojiInput - augmented inputs for emoji and autocomplete support in inputs
+ * without having to give up the comfort of <input/> and <textarea/> elements
+ *
+ * Intended usage is:
+ * <EmojiInput v-model="something">
+ * <input v-model="something"/>
+ * </EmojiInput>
+ *
+ * Works only with <input> and <textarea>. Intended to use with only one nested
+ * input. It will find first input or textarea and work with that, multiple
+ * nested children not tested. You HAVE TO duplicate v-model for both
+ * <emoji-input> and <input>/<textarea> otherwise it will not work.
+ *
+ * Be prepared for CSS troubles though because it still wraps component in a div
+ * while TRYING to make it look like nothing happened, but it could break stuff.
+ */
const EmojiInput = {
- props: [
- 'value',
- 'placeholder',
- 'type',
- 'classname'
- ],
+ props: {
+ suggest: {
+ /**
+ * suggest: function (input: String) => Suggestion[]
+ *
+ * Function that takes input string which takes string (textAtCaret)
+ * and returns an array of Suggestions
+ *
+ * Suggestion is an object containing following properties:
+ * displayText: string. Main display text, what actual suggestion
+ * represents (user's screen name/emoji shortcode)
+ * replacement: string. Text that should replace the textAtCaret
+ * detailText: string, optional. Subtitle text, providing additional info
+ * if present (user's nickname)
+ * imageUrl: string, optional. Image to display alongside with suggestion,
+ * currently if no image is provided, replacement will be used (for
+ * unicode emojis)
+ *
+ * TODO: make it asynchronous when adding proper server-provided user
+ * suggestions
+ *
+ * For commonly used suggestors (emoji, users, both) use suggestor.js
+ */
+ required: true,
+ type: Function
+ },
+ value: {
+ /**
+ * Used for v-model
+ */
+ required: true,
+ type: String
+ }
+ },
data () {
return {
+ input: undefined,
highlighted: 0,
- caret: 0
+ caret: 0,
+ focused: false,
+ blurTimeout: null
}
},
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 || '',
+ if (this.textAtCaret === firstchar) { return [] }
+ const matchedSuggestions = this.suggest(this.textAtCaret)
+ if (matchedSuggestions.length <= 0) {
+ return []
+ }
+ return take(matchedSuggestions, 5)
+ .map(({ imageUrl, ...rest }, index) => ({
+ ...rest,
// eslint-disable-next-line camelcase
- img: utf ? '' : this.$store.state.instance.server + image_url,
+ img: imageUrl || '',
highlighted: index === this.highlighted
}))
- } else {
- return false
- }
+ },
+ showPopup () {
+ return this.focused && this.suggestions && this.suggestions.length > 0
},
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 || []
+ if (this.value && this.caret) {
+ const word = Completion.wordAtPosition(this.value, this.caret - 1) || {}
+ return word
+ }
+ }
+ },
+ mounted () {
+ const slots = this.$slots.default
+ if (!slots || slots.length === 0) return
+ const input = slots.find(slot => ['input', 'textarea'].includes(slot.tag))
+ if (!input) return
+ this.input = input
+ this.resize()
+ input.elm.addEventListener('blur', this.onBlur)
+ input.elm.addEventListener('focus', this.onFocus)
+ input.elm.addEventListener('paste', this.onPaste)
+ input.elm.addEventListener('keyup', this.onKeyUp)
+ input.elm.addEventListener('keydown', this.onKeyDown)
+ input.elm.addEventListener('transitionend', this.onTransition)
+ input.elm.addEventListener('compositionupdate', this.onCompositionUpdate)
+ },
+ unmounted () {
+ const { input } = this
+ if (input) {
+ input.elm.removeEventListener('blur', this.onBlur)
+ input.elm.removeEventListener('focus', this.onFocus)
+ input.elm.removeEventListener('paste', this.onPaste)
+ input.elm.removeEventListener('keyup', this.onKeyUp)
+ input.elm.removeEventListener('keydown', this.onKeyDown)
+ input.elm.removeEventListener('transitionend', this.onTransition)
+ input.elm.removeEventListener('compositionupdate', this.onCompositionUpdate)
}
},
methods: {
@@ -54,27 +125,35 @@ const EmojiInput = {
this.$emit('input', newValue)
this.caret = 0
},
- replaceEmoji (e) {
+ replaceText (e, suggestion) {
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 + ' ')
+ if (this.textAtCaret.length === 1) { return }
+ if (len > 0 || suggestion) {
+ const chosenSuggestion = suggestion || this.suggestions[this.highlighted]
+ const replacement = chosenSuggestion.replacement
const newValue = Completion.replaceWord(this.value, this.wordAtCaret, replacement)
this.$emit('input', newValue)
- this.caret = 0
this.highlighted = 0
+ const position = this.wordAtCaret.start + replacement.length
+
+ this.$nextTick(function () {
+ // Re-focus inputbox after clicking suggestion
+ this.input.elm.focus()
+ // Set selection right after the replacement instead of the very end
+ this.input.elm.setSelectionRange(position, position)
+ this.caret = position
+ })
+ e.preventDefault()
}
},
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
}
+ e.preventDefault()
} else {
this.highlighted = 0
}
@@ -82,24 +161,88 @@ const EmojiInput = {
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
}
+ e.preventDefault()
} else {
this.highlighted = 0
}
},
- onKeydown (e) {
- e.stopPropagation()
+ onTransition (e) {
+ this.resize()
+ },
+ onBlur (e) {
+ // Clicking on any suggestion removes focus from autocomplete,
+ // preventing click handler ever executing.
+ this.blurTimeout = setTimeout(() => {
+ this.focused = false
+ this.setCaret(e)
+ this.resize()
+ }, 200)
+ },
+ onClick (e, suggestion) {
+ this.replaceText(e, suggestion)
+ },
+ onFocus (e) {
+ if (this.blurTimeout) {
+ clearTimeout(this.blurTimeout)
+ this.blurTimeout = null
+ }
+
+ this.focused = true
+ this.setCaret(e)
+ this.resize()
+ },
+ onKeyUp (e) {
+ this.setCaret(e)
+ this.resize()
+ },
+ onPaste (e) {
+ this.setCaret(e)
+ this.resize()
+ },
+ onKeyDown (e) {
+ this.setCaret(e)
+ this.resize()
+
+ const { ctrlKey, shiftKey, key } = e
+ if (key === 'Tab') {
+ if (shiftKey) {
+ this.cycleBackward(e)
+ } else {
+ this.cycleForward(e)
+ }
+ }
+ if (key === 'ArrowUp') {
+ this.cycleBackward(e)
+ } else if (key === 'ArrowDown') {
+ this.cycleForward(e)
+ }
+ if (key === 'Enter') {
+ if (!ctrlKey) {
+ this.replaceText(e)
+ }
+ }
},
onInput (e) {
+ this.setCaret(e)
this.$emit('input', e.target.value)
},
- setCaret ({target: {selectionStart}}) {
+ onCompositionUpdate (e) {
+ this.setCaret(e)
+ this.resize()
+ this.$emit('input', e.target.value)
+ },
+ setCaret ({ target: { selectionStart } }) {
this.caret = selectionStart
+ },
+ resize () {
+ const { panel } = this.$refs
+ if (!panel) return
+ const { offsetHeight, offsetTop } = this.input.elm
+ this.$refs.panel.style.top = (offsetTop + offsetHeight) + 'px'
}
}
}
diff --git a/src/components/emoji-input/emoji-input.vue b/src/components/emoji-input/emoji-input.vue
index 338b77cd..48739ec8 100644
--- a/src/components/emoji-input/emoji-input.vue
+++ b/src/components/emoji-input/emoji-input.vue
@@ -1,50 +1,30 @@
<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">
+ <slot />
+ <div
+ ref="panel"
+ class="autocomplete-panel"
+ :class="{ hide: !showPopup }"
+ >
<div class="autocomplete-panel-body">
<div
- v-for="(emoji, index) in suggestions"
+ v-for="(suggestion, index) in suggestions"
:key="index"
- @click="replace(emoji.utf || (emoji.shortcode + ' '))"
class="autocomplete-item"
- :class="{ highlighted: emoji.highlighted }"
+ :class="{ highlighted: suggestion.highlighted }"
+ @click.stop.prevent="onClick($event, suggestion)"
>
- <span v-if="emoji.img">
- <img :src="emoji.img" />
+ <span class="image">
+ <img
+ v-if="suggestion.img"
+ :src="suggestion.img"
+ >
+ <span v-else>{{ suggestion.replacement }}</span>
</span>
- <span v-else>{{emoji.utf}}</span>
- <span>{{emoji.shortcode}}</span>
+ <div class="label">
+ <span class="displayText">{{ suggestion.displayText }}</span>
+ <span class="detailText">{{ suggestion.detailText }}</span>
+ </div>
</div>
</div>
</div>
@@ -57,8 +37,81 @@
@import '../../_variables.scss';
.emoji-input {
- .form-control {
- width: 100%;
+ display: flex;
+ flex-direction: column;
+
+ .autocomplete {
+ &-panel {
+ position: absolute;
+ z-index: 9;
+ margin-top: 2px;
+
+ &.hide {
+ display: none
+ }
+
+ &-body {
+ margin: 0 0.5em 0 0.5em;
+ border-radius: $fallback--tooltipRadius;
+ border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
+ box-shadow: 1px 2px 4px rgba(0, 0, 0, 0.5);
+ box-shadow: var(--popupShadow);
+ min-width: 75%;
+ background: $fallback--bg;
+ background: var(--bg, $fallback--bg);
+ color: $fallback--lightText;
+ color: var(--lightText, $fallback--lightText);
+ }
+ }
+
+ &-item {
+ display: flex;
+ cursor: pointer;
+ padding: 0.2em 0.4em;
+ border-bottom: 1px solid rgba(0, 0, 0, 0.4);
+ height: 32px;
+
+ .image {
+ width: 32px;
+ height: 32px;
+ line-height: 32px;
+ text-align: center;
+ font-size: 32px;
+
+ margin-right: 4px;
+
+ img {
+ width: 32px;
+ height: 32px;
+ object-fit: contain;
+ }
+ }
+
+ .label {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ margin: 0 0.1em 0 0.2em;
+
+ .displayText {
+ line-height: 1.5;
+ }
+
+ .detailText {
+ font-size: 9px;
+ line-height: 9px;
+ }
+ }
+
+ &.highlighted {
+ background-color: $fallback--fg;
+ background-color: var(--lightBg, $fallback--fg);
+ }
+ }
+ }
+
+ input, textarea {
+ flex: 1 0 auto;
}
}
</style>
diff --git a/src/components/emoji-input/suggestor.js b/src/components/emoji-input/suggestor.js
new file mode 100644
index 00000000..aec5c39d
--- /dev/null
+++ b/src/components/emoji-input/suggestor.js
@@ -0,0 +1,94 @@
+import { debounce } from 'lodash'
+/**
+ * suggest - generates a suggestor function to be used by emoji-input
+ * data: object providing source information for specific types of suggestions:
+ * data.emoji - optional, an array of all emoji available i.e.
+ * (state.instance.emoji + state.instance.customEmoji)
+ * data.users - optional, an array of all known users
+ * updateUsersList - optional, a function to search and append to users
+ *
+ * Depending on data present one or both (or none) can be present, so if field
+ * doesn't support user linking you can just provide only emoji.
+ */
+
+const debounceUserSearch = debounce((data, input) => {
+ data.updateUsersList(input)
+}, 500, { leading: true, trailing: false })
+
+export default data => input => {
+ const firstChar = input[0]
+ if (firstChar === ':' && data.emoji) {
+ return suggestEmoji(data.emoji)(input)
+ }
+ if (firstChar === '@' && data.users) {
+ return suggestUsers(data)(input)
+ }
+ return []
+}
+
+export const suggestEmoji = emojis => input => {
+ const noPrefix = input.toLowerCase().substr(1)
+ return emojis
+ .filter(({ displayText }) => displayText.toLowerCase().startsWith(noPrefix))
+ .sort((a, b) => {
+ let aScore = 0
+ let bScore = 0
+
+ // Make custom emojis a priority
+ aScore += a.imageUrl ? 10 : 0
+ bScore += b.imageUrl ? 10 : 0
+
+ // Sort alphabetically
+ const alphabetically = a.displayText > b.displayText ? 1 : -1
+
+ return bScore - aScore + alphabetically
+ })
+}
+
+export const suggestUsers = data => input => {
+ const noPrefix = input.toLowerCase().substr(1)
+ const users = data.users
+
+ const newUsers = users.filter(
+ user =>
+ user.screen_name.toLowerCase().startsWith(noPrefix) ||
+ user.name.toLowerCase().startsWith(noPrefix)
+
+ /* taking only 20 results so that sorting is a bit cheaper, we display
+ * only 5 anyway. could be inaccurate, but we ideally we should query
+ * backend anyway
+ */
+ ).slice(0, 20).sort((a, b) => {
+ let aScore = 0
+ let bScore = 0
+
+ // Matches on screen name (i.e. user@instance) makes a priority
+ aScore += a.screen_name.toLowerCase().startsWith(noPrefix) ? 2 : 0
+ bScore += b.screen_name.toLowerCase().startsWith(noPrefix) ? 2 : 0
+
+ // Matches on name takes second priority
+ aScore += a.name.toLowerCase().startsWith(noPrefix) ? 1 : 0
+ bScore += b.name.toLowerCase().startsWith(noPrefix) ? 1 : 0
+
+ const diff = (bScore - aScore) * 10
+
+ // Then sort alphabetically
+ const nameAlphabetically = a.name > b.name ? 1 : -1
+ const screenNameAlphabetically = a.screen_name > b.screen_name ? 1 : -1
+
+ return diff + nameAlphabetically + screenNameAlphabetically
+ /* eslint-disable camelcase */
+ }).map(({ screen_name, name, profile_image_url_original }) => ({
+ displayText: screen_name,
+ detailText: name,
+ imageUrl: profile_image_url_original,
+ replacement: '@' + screen_name + ' '
+ }))
+
+ // BE search users if there are no matches
+ if (newUsers.length === 0 && data.updateUsersList) {
+ debounceUserSearch(data, noPrefix)
+ }
+ return newUsers
+ /* eslint-enable camelcase */
+}