diff options
Diffstat (limited to 'src/components/emoji-input')
| -rw-r--r-- | src/components/emoji-input/emoji-input.js | 250 | ||||
| -rw-r--r-- | src/components/emoji-input/emoji-input.vue | 117 | ||||
| -rw-r--r-- | src/components/emoji-input/suggestor.js | 94 |
3 files changed, 0 insertions, 461 deletions
diff --git a/src/components/emoji-input/emoji-input.js b/src/components/emoji-input/emoji-input.js deleted file mode 100644 index fab64a69..00000000 --- a/src/components/emoji-input/emoji-input.js +++ /dev/null @@ -1,250 +0,0 @@ -import Completion from '../../services/completion/completion.js' -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: { - 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, - focused: false, - blurTimeout: null - } - }, - computed: { - suggestions () { - const firstchar = this.textAtCaret.charAt(0) - 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: imageUrl || '', - highlighted: index === this.highlighted - })) - }, - showPopup () { - return this.focused && this.suggestions && this.suggestions.length > 0 - }, - textAtCaret () { - return (this.wordAtCaret || {}).word || '' - }, - wordAtCaret () { - 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: { - replace (replacement) { - const newValue = Completion.replaceWord(this.value, this.wordAtCaret, replacement) - this.$emit('input', newValue) - this.caret = 0 - }, - replaceText (e, suggestion) { - const len = this.suggestions.length || 0 - 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.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) { - this.highlighted -= 1 - if (this.highlighted < 0) { - this.highlighted = this.suggestions.length - 1 - } - e.preventDefault() - } else { - this.highlighted = 0 - } - }, - cycleForward (e) { - const len = this.suggestions.length || 0 - if (len > 0) { - this.highlighted += 1 - if (this.highlighted >= len) { - this.highlighted = 0 - } - e.preventDefault() - } else { - this.highlighted = 0 - } - }, - 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) - }, - 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' - } - } -} - -export default EmojiInput diff --git a/src/components/emoji-input/emoji-input.vue b/src/components/emoji-input/emoji-input.vue deleted file mode 100644 index 48739ec8..00000000 --- a/src/components/emoji-input/emoji-input.vue +++ /dev/null @@ -1,117 +0,0 @@ -<template> - <div class="emoji-input"> - <slot /> - <div - ref="panel" - class="autocomplete-panel" - :class="{ hide: !showPopup }" - > - <div class="autocomplete-panel-body"> - <div - v-for="(suggestion, index) in suggestions" - :key="index" - class="autocomplete-item" - :class="{ highlighted: suggestion.highlighted }" - @click.stop.prevent="onClick($event, suggestion)" - > - <span class="image"> - <img - v-if="suggestion.img" - :src="suggestion.img" - > - <span v-else>{{ suggestion.replacement }}</span> - </span> - <div class="label"> - <span class="displayText">{{ suggestion.displayText }}</span> - <span class="detailText">{{ suggestion.detailText }}</span> - </div> - </div> - </div> - </div> - </div> -</template> - -<script src="./emoji-input.js"></script> - -<style lang="scss"> -@import '../../_variables.scss'; - -.emoji-input { - 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 deleted file mode 100644 index aec5c39d..00000000 --- a/src/components/emoji-input/suggestor.js +++ /dev/null @@ -1,94 +0,0 @@ -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 */ -} |
