diff options
| author | Henry Jameson <me@hjkos.com> | 2019-07-28 13:30:29 +0300 |
|---|---|---|
| committer | Henry Jameson <me@hjkos.com> | 2019-07-28 13:30:29 +0300 |
| commit | b3aff9bbae77b2fd34b2267ce9196c0ebd3e4691 (patch) | |
| tree | 1219e00b6bfe6784add1578a3bc986c1dbb5f34d /src/components/emoji-input/emoji-input.js | |
| parent | 7f6f025792dcb3a10c94c8952d0312abd0b46989 (diff) | |
| parent | 4827e4d972f8ee11e606693e24ae4ca21711c6b1 (diff) | |
Merge remote-tracking branch 'upstream/develop' into emoji-selector-update
* upstream/develop: (469 commits)
Feature/add sticker picker
guard more secure routes
guard secure routes by redirecting to root
closest can returns itself as well
find inside status-content div only
try to use the closest a tag as target
Update es.json
Also apply keyword filter to subjects
Remove files I accidentally pushed in
fix issues caused by merges in usersearch on @
Add user search at
fix eslint warnings
remove vue-popperjs
fix moderation menu partially hidden by usercard boundary
migrate popper css
rewrite ModerationTools using v-tooltip
make popover position for status action dropdow relative to parent node
rewrite ExtraButtons using v-tooltip
install v-tooltip
i18n/Update pedantic Japanese translation
...
Diffstat (limited to 'src/components/emoji-input/emoji-input.js')
| -rw-r--r-- | src/components/emoji-input/emoji-input.js | 248 |
1 files changed, 183 insertions, 65 deletions
diff --git a/src/components/emoji-input/emoji-input.js b/src/components/emoji-input/emoji-input.js index 112fd148..c3c5c88b 100644 --- a/src/components/emoji-input/emoji-input.js +++ b/src/components/emoji-input/emoji-input.js @@ -1,19 +1,66 @@ import Completion from '../../services/completion/completion.js' -import EmojiSelector from '../emoji-selector/emoji-selector.vue' +import { take } from 'lodash' -import { take, filter, map } 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 } }, components: { @@ -22,35 +69,57 @@ const EmojiInput = { 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: { @@ -59,27 +128,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 } @@ -87,47 +164,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() }, - onInput (e) { - this.$emit('input', e.target.value) + 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) }, - setCaret ({target: {selectionStart}}) { - this.caret = selectionStart + onClick (e, suggestion) { + this.replaceText(e, suggestion) }, - onEmoji (emoji) { - const newValue = this.value.substr(0, this.caret) + emoji + this.value.substr(this.caret) - this.$emit('input', newValue) - this.caret += emoji.length - setTimeout(() => { - this.updateCaretPos() - }) - }, - updateCaretPos () { - const elem = this.$refs.input - if (elem.createTextRange) { - const range = elem.createTextRange() - range.move('character', this.caret) - range.select() - } else { - if (elem.selectionStart) { - elem.focus() - elem.setSelectionRange(this.caret, this.caret) + 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 { - elem.focus() + 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' } } } |
