aboutsummaryrefslogtreecommitdiff
path: root/src/components/emoji-input
diff options
context:
space:
mode:
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 */
+}