diff options
Diffstat (limited to 'src/components/emoji-input')
| -rw-r--r-- | src/components/emoji-input/emoji-input.js | 227 | ||||
| -rw-r--r-- | src/components/emoji-input/emoji-input.vue | 135 | ||||
| -rw-r--r-- | src/components/emoji-input/suggestor.js | 79 |
3 files changed, 358 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..a7ac203e --- /dev/null +++ b/src/components/emoji-input/suggestor.js @@ -0,0 +1,79 @@ +/** + * 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 + * + * 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. + */ +export default data => input => { + const firstChar = input[0] + if (firstChar === ':' && data.emoji) { + return suggestEmoji(data.emoji)(input) + } + if (firstChar === '@' && data.users) { + return suggestUsers(data.users)(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 = users => input => { + const noPrefix = input.toLowerCase().substr(1) + return 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 + ' ' + })) + /* eslint-enable camelcase */ +} |
