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.js431
-rw-r--r--src/components/emoji_input/emoji_input.vue164
-rw-r--r--src/components/emoji_input/suggestor.js94
3 files changed, 689 insertions, 0 deletions
diff --git a/src/components/emoji_input/emoji_input.js b/src/components/emoji_input/emoji_input.js
new file mode 100644
index 00000000..a586b819
--- /dev/null
+++ b/src/components/emoji_input/emoji_input.js
@@ -0,0 +1,431 @@
+import Completion from '../../services/completion/completion.js'
+import EmojiPicker from '../emoji_picker/emoji_picker.vue'
+import { take } from 'lodash'
+import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
+
+/**
+ * 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
+ },
+ enableEmojiPicker: {
+ /**
+ * Enables emoji picker support, this implies that custom emoji are supported
+ */
+ required: false,
+ type: Boolean,
+ default: false
+ },
+ hideEmojiButton: {
+ /**
+ * intended to use with external picker trigger, i.e. you have a button outside
+ * input that will open up the picker, see triggerShowPicker()
+ */
+ required: false,
+ type: Boolean,
+ default: false
+ },
+ enableStickerPicker: {
+ /**
+ * Enables sticker picker support, only makes sense when enableEmojiPicker=true
+ */
+ required: false,
+ type: Boolean,
+ default: false
+ }
+ },
+ data () {
+ return {
+ input: undefined,
+ highlighted: 0,
+ caret: 0,
+ focused: false,
+ blurTimeout: null,
+ showPicker: false,
+ temporarilyHideSuggestions: false,
+ keepOpen: false,
+ disableClickOutside: false
+ }
+ },
+ components: {
+ EmojiPicker
+ },
+ computed: {
+ padEmoji () {
+ return this.$store.state.config.padEmoji
+ },
+ 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
+ }))
+ },
+ showSuggestions () {
+ return this.focused &&
+ this.suggestions &&
+ this.suggestions.length > 0 &&
+ !this.showPicker &&
+ !this.temporarilyHideSuggestions
+ },
+ 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('click', this.onClickInput)
+ 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('click', this.onClickInput)
+ input.elm.removeEventListener('transitionend', this.onTransition)
+ input.elm.removeEventListener('compositionupdate', this.onCompositionUpdate)
+ }
+ },
+ methods: {
+ triggerShowPicker () {
+ this.showPicker = true
+ this.$nextTick(() => {
+ this.scrollIntoView()
+ })
+ // This temporarily disables "click outside" handler
+ // since external trigger also means click originates
+ // from outside, thus preventing picker from opening
+ this.disableClickOutside = true
+ setTimeout(() => {
+ this.disableClickOutside = false
+ }, 0)
+ },
+ togglePicker () {
+ this.input.elm.focus()
+ this.showPicker = !this.showPicker
+ if (this.showPicker) {
+ this.scrollIntoView()
+ }
+ },
+ replace (replacement) {
+ const newValue = Completion.replaceWord(this.value, this.wordAtCaret, replacement)
+ this.$emit('input', newValue)
+ this.caret = 0
+ },
+ insert ({ insertion, keepOpen }) {
+ const before = this.value.substring(0, this.caret) || ''
+ const after = this.value.substring(this.caret) || ''
+
+ /* Using a bit more smart approach to padding emojis with spaces:
+ * - put a space before cursor if there isn't one already, unless we
+ * are at the beginning of post or in spam mode
+ * - put a space after emoji if there isn't one already unless we are
+ * in spam mode
+ *
+ * The idea is that when you put a cursor somewhere in between sentence
+ * inserting just ' :emoji: ' will add more spaces to post which might
+ * break the flow/spacing, as well as the case where user ends sentence
+ * with a space before adding emoji.
+ *
+ * Spam mode is intended for creating multi-part emojis and overall spamming
+ * them, masto seem to be rendering :emoji::emoji: correctly now so why not
+ */
+ const isSpaceRegex = /\s/
+ const spaceBefore = !isSpaceRegex.exec(before.slice(-1)) && before.length && this.padEmoji > 0 ? ' ' : ''
+ const spaceAfter = !isSpaceRegex.exec(after[0]) && this.padEmoji ? ' ' : ''
+
+ const newValue = [
+ before,
+ spaceBefore,
+ insertion,
+ spaceAfter,
+ after
+ ].join('')
+ this.keepOpen = keepOpen
+ this.$emit('input', newValue)
+ const position = this.caret + (insertion + spaceAfter + spaceBefore).length
+ if (!keepOpen) {
+ this.input.elm.focus()
+ }
+
+ this.$nextTick(function () {
+ // Re-focus inputbox after clicking suggestion
+ // Set selection right after the replacement instead of the very end
+ this.input.elm.setSelectionRange(position, position)
+ this.caret = position
+ })
+ },
+ 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 > 1) {
+ 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 > 1) {
+ this.highlighted += 1
+ if (this.highlighted >= len) {
+ this.highlighted = 0
+ }
+ e.preventDefault()
+ } else {
+ this.highlighted = 0
+ }
+ },
+ scrollIntoView () {
+ const rootRef = this.$refs['picker'].$el
+ /* Scroller is either `window` (replies in TL), sidebar (main post form,
+ * replies in notifs) or mobile post form. Note that getting and setting
+ * scroll is different for `Window` and `Element`s
+ */
+ const scrollerRef = this.$el.closest('.sidebar-scroller') ||
+ this.$el.closest('.post-form-modal-view') ||
+ window
+ const currentScroll = scrollerRef === window
+ ? scrollerRef.scrollY
+ : scrollerRef.scrollTop
+ const scrollerHeight = scrollerRef === window
+ ? scrollerRef.innerHeight
+ : scrollerRef.offsetHeight
+
+ const scrollerBottomBorder = currentScroll + scrollerHeight
+ // We check where the bottom border of root element is, this uses findOffset
+ // to find offset relative to scrollable container (scroller)
+ const rootBottomBorder = rootRef.offsetHeight + findOffset(rootRef, scrollerRef).top
+
+ const bottomDelta = Math.max(0, rootBottomBorder - scrollerBottomBorder)
+ // could also check top delta but there's no case for it
+ const targetScroll = currentScroll + bottomDelta
+
+ if (scrollerRef === window) {
+ scrollerRef.scroll(0, targetScroll)
+ } else {
+ scrollerRef.scrollTop = targetScroll
+ }
+ },
+ 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
+ }
+
+ if (!this.keepOpen) {
+ this.showPicker = false
+ }
+ this.focused = true
+ this.setCaret(e)
+ this.resize()
+ this.temporarilyHideSuggestions = false
+ },
+ onKeyUp (e) {
+ const { key } = e
+ this.setCaret(e)
+ this.resize()
+
+ // Setting hider in keyUp to prevent suggestions from blinking
+ // when moving away from suggested spot
+ if (key === 'Escape') {
+ this.temporarilyHideSuggestions = true
+ } else {
+ this.temporarilyHideSuggestions = false
+ }
+ },
+ onPaste (e) {
+ this.setCaret(e)
+ this.resize()
+ },
+ onKeyDown (e) {
+ const { ctrlKey, shiftKey, key } = e
+ // Disable suggestions hotkeys if suggestions are hidden
+ if (!this.temporarilyHideSuggestions) {
+ 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)
+ }
+ }
+ }
+ // Probably add optional keyboard controls for emoji picker?
+
+ // Escape hides suggestions, if suggestions are hidden it
+ // de-focuses the element (i.e. default browser behavior)
+ if (key === 'Escape') {
+ if (!this.temporarilyHideSuggestions) {
+ this.input.elm.focus()
+ }
+ }
+
+ this.showPicker = false
+ this.resize()
+ },
+ onInput (e) {
+ this.showPicker = false
+ this.setCaret(e)
+ this.resize()
+ this.$emit('input', e.target.value)
+ },
+ onCompositionUpdate (e) {
+ this.showPicker = false
+ this.setCaret(e)
+ this.resize()
+ this.$emit('input', e.target.value)
+ },
+ onClickInput (e) {
+ this.showPicker = false
+ },
+ onClickOutside (e) {
+ if (this.disableClickOutside) return
+ this.showPicker = false
+ },
+ onStickerUploaded (e) {
+ this.showPicker = false
+ this.$emit('sticker-uploaded', e)
+ },
+ onStickerUploadFailed (e) {
+ this.showPicker = false
+ this.$emit('sticker-upload-Failed', e)
+ },
+ 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'
+ this.$refs.picker.$el.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
new file mode 100644
index 00000000..13530e8b
--- /dev/null
+++ b/src/components/emoji_input/emoji_input.vue
@@ -0,0 +1,164 @@
+<template>
+ <div
+ v-click-outside="onClickOutside"
+ class="emoji-input"
+ >
+ <slot />
+ <template v-if="enableEmojiPicker">
+ <div
+ v-if="!hideEmojiButton"
+ class="emoji-picker-icon"
+ @click.prevent="togglePicker"
+ >
+ <i class="icon-smile" />
+ </div>
+ <EmojiPicker
+ v-if="enableEmojiPicker"
+ ref="picker"
+ :class="{ hide: !showPicker }"
+ :enable-sticker-picker="enableStickerPicker"
+ class="emoji-picker-panel"
+ @emoji="insert"
+ @sticker-uploaded="onStickerUploaded"
+ @sticker-upload-failed="onStickerUploadFailed"
+ />
+ </template>
+ <div
+ ref="panel"
+ class="autocomplete-panel"
+ :class="{ hide: !showSuggestions }"
+ >
+ <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;
+ position: relative;
+
+ .emoji-picker-icon {
+ position: absolute;
+ top: 0;
+ right: 0;
+ margin: .2em .25em;
+ font-size: 16px;
+ cursor: pointer;
+ line-height: 24px;
+
+ &:hover i {
+ color: $fallback--text;
+ color: var(--text, $fallback--text);
+ }
+ }
+ .emoji-picker-panel {
+ position: absolute;
+ z-index: 20;
+ margin-top: 2px;
+
+ &.hide {
+ display: none
+ }
+ }
+
+ .autocomplete {
+ &-panel {
+ position: absolute;
+ z-index: 20;
+ 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 */
+}