diff --git a/src/boot/after_store.js b/src/boot/after_store.js index 603de348..ea5a3f58 100644 --- a/src/boot/after_store.js +++ b/src/boot/after_store.js @@ -153,7 +153,11 @@ const getStaticEmoji = async ({ store }) => { if (res.ok) { const values = await res.json() const emoji = Object.keys(values).map((key) => { - return { shortcode: key, image_url: false, 'utf': values[key] } + return { + shortcode: key, + image_url: false, + 'replacement': values[key] + } }) store.dispatch('setInstanceOption', { name: 'emoji', value: emoji }) } else { @@ -174,7 +178,11 @@ const getCustomEmoji = async ({ store }) => { const result = await res.json() const values = Array.isArray(result) ? Object.assign({}, ...result) : result const emoji = Object.keys(values).map((key) => { - return { shortcode: key, image_url: values[key].image_url || values[key] } + return { + shortcode: key, + image_url: values[key].image_url || values[key], + replacement: `:${key}: ` + } }) store.dispatch('setInstanceOption', { name: 'customEmoji', value: emoji }) store.dispatch('setInstanceOption', { name: 'pleromaBackend', value: true }) diff --git a/src/components/emoji-input/emoji-input.js b/src/components/emoji-input/emoji-input.js index a5bb6eaf..466341c0 100644 --- a/src/components/emoji-input/emoji-input.js +++ b/src/components/emoji-input/emoji-input.js @@ -1,15 +1,17 @@ import Completion from '../../services/completion/completion.js' -import { take, filter, map } from 'lodash' +import { take } from 'lodash' const EmojiInput = { props: [ - 'value', 'placeholder', + 'suggest', + 'value', 'type', 'classname' ], data () { return { + input: undefined, highlighted: 0, caret: 0 } @@ -17,35 +19,46 @@ 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 || '', - // eslint-disable-next-line camelcase - img: utf ? '' : this.$store.state.instance.server + image_url, - highlighted: index === this.highlighted - })) - } else { + if (this.textAtCaret === firstchar) { return } + const matchedSuggestions = this.suggest(this.textAtCaret) + if (matchedSuggestions.length <= 0) { return false } + return take(matchedSuggestions, 5).map(({shortcode, image_url, replacement}, index) => ({ + shortcode, + replacement, + // eslint-disable-next-line camelcase + img: !image_url ? '' : this.$store.state.instance.server + image_url, + highlighted: index === this.highlighted + })) }, textAtCaret () { return (this.wordAtCaret || {}).word || '' }, wordAtCaret () { - const word = Completion.wordAtPosition(this.value, this.caret - 1) || {} - return word + if (this.value && this.caret) { + 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 || [] + }, + mounted () { + const slots = this.$slots.default + if (slots.length === 0) return + const input = slots.find(slot => ['input', 'textarea'].includes(slot.tag)) + if (!input) return + this.input = input + input.elm.addEventListener('keyup', this.setCaret) + input.elm.addEventListener('paste', this.setCaret) + input.elm.addEventListener('focus', this.setCaret) + input.elm.addEventListener('keydown', this.onKeyDown) + }, + unmounted () { + if (this.input) { + this.input.elm.removeEventListener('keyup', this.setCaret) + this.input.elm.removeEventListener('paste', this.setCaret) + this.input.elm.removeEventListener('focus', this.setCaret) + this.input.elm.removeEventListener('keydown', this.onKeyDown) } }, methods: { @@ -54,23 +67,21 @@ const EmojiInput = { this.$emit('input', newValue) this.caret = 0 }, - replaceEmoji (e) { + replaceText () { const len = this.suggestions.length || 0 - if (this.textAtCaret === ':' || e.ctrlKey) { return } + if (this.textAtCaret.length === 1) { return } if (len > 0) { - e.preventDefault() - const emoji = this.suggestions[this.highlighted] - const replacement = emoji.utf || (emoji.shortcode + ' ') + const suggestion = this.suggestions[this.highlighted] + const replacement = suggestion.replacement const newValue = Completion.replaceWord(this.value, this.wordAtCaret, replacement) this.$emit('input', newValue) this.caret = 0 this.highlighted = 0 } }, - cycleBackward (e) { + cycleBackward () { const len = this.suggestions.length || 0 if (len > 0) { - e.preventDefault() this.highlighted -= 1 if (this.highlighted < 0) { this.highlighted = this.suggestions.length - 1 @@ -79,11 +90,9 @@ const EmojiInput = { this.highlighted = 0 } }, - cycleForward (e) { + cycleForward () { 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 @@ -92,13 +101,33 @@ const EmojiInput = { this.highlighted = 0 } }, - onKeydown (e) { + onKeyDown (e) { + this.setCaret(e) e.stopPropagation() + + const { ctrlKey, shiftKey, key } = e + if (key === 'Tab') { + if (shiftKey) { + this.cycleBackward() + } else { + this.cycleForward() + } + } + if (key === 'ArrowUp') { + this.cycleBackward() + } else if (key === 'ArrowDown') { + this.cycleForward() + } + if (key === 'Enter') { + if (!ctrlKey) { + this.replaceText() + } + } }, onInput (e) { this.$emit('input', e.target.value) }, - setCaret ({target: {selectionStart}}) { + setCaret ({ target: { selectionStart, value } }) { this.caret = selectionStart } } diff --git a/src/components/emoji-input/emoji-input.vue b/src/components/emoji-input/emoji-input.vue index 338b77cd..eec33d1a 100644 --- a/src/components/emoji-input/emoji-input.vue +++ b/src/components/emoji-input/emoji-input.vue @@ -1,23 +1,6 @@ <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 + <slot :class="classname" :value="value" :placeholder="placeholder" @@ -30,21 +13,22 @@ @keydown.shift.tab="cycleBackward" @keydown.tab="cycleForward" @keydown.enter="replaceEmoji" - ></textarea> + > + </slot> <div class="autocomplete-panel" v-if="suggestions"> <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 + ' '))" + @click="replace(suggestion.replacement)" class="autocomplete-item" - :class="{ highlighted: emoji.highlighted }" + :class="{ highlighted: suggestion.highlighted }" > - <span v-if="emoji.img"> - <img :src="emoji.img" /> + <span v-if="suggestion.img"> + <img :src="suggestion.img" /> </span> - <span v-else>{{emoji.utf}}</span> - <span>{{emoji.shortcode}}</span> + <span v-else>{{suggestion.replacement}}</span> + <span>{{suggestion.shortcode}}</span> </div> </div> </div> diff --git a/src/components/emoji-input/suggestor.js b/src/components/emoji-input/suggestor.js new file mode 100644 index 00000000..f1a0d0da --- /dev/null +++ b/src/components/emoji-input/suggestor.js @@ -0,0 +1,38 @@ +export default function suggest (data) { + return input => { + const trimmed = input.trim() + const firstChar = trimmed[0] + console.log(`'${trimmed}'`, firstChar, firstChar === ':') + if (firstChar === ':' && data.emoji) { + return suggestEmoji(data.emoji)(trimmed) + } + if (firstChar === '@' && data.users) { + return suggestUsers(data.users)(trimmed) + } + return [] + } +} + +function suggestEmoji (emojis) { + return input => { + const shortcode = input.toLowerCase().substr(1) + console.log(shortcode) + return emojis.filter(emoji => emoji.shortcode.toLowerCase().startsWith(shortcode)) + } +} + +function suggestUsers (users) { + return input => { + const shortcode = input.toLowerCase().substr(1) + return users.filter( + user => + user.screen_name.toLowerCase().startsWith('@' + shortcode) || + user.name.toLowerCase().startsWith(shortcode) + ).map(({ screen_name, name, profile_image_url_original }) => ({ + shortcode: screen_name, + detail: name, + image_url: profile_image_url_original, + replacement: '@' + screen_name + })) + } +} diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js index cbd2024a..1516dd43 100644 --- a/src/components/post_status_form/post_status_form.js +++ b/src/components/post_status_form/post_status_form.js @@ -5,6 +5,7 @@ import EmojiInput from '../emoji-input/emoji-input.vue' import fileTypeService from '../../services/file_type/file_type.service.js' import Completion from '../../services/completion/completion.js' import { take, filter, reject, map, uniqBy } from 'lodash' +import suggestor from '../emoji-input/suggestor.js' const buildMentionsString = ({user, attentions}, currentUser) => { let allAttentions = [...attentions] @@ -119,13 +120,6 @@ const PostStatusForm = { return false } }, - textAtCaret () { - return (this.wordAtCaret || {}).word || '' - }, - wordAtCaret () { - const word = Completion.wordAtPosition(this.newStatus.status, this.caret - 1) || {} - return word - }, users () { return this.$store.state.users.users }, @@ -138,6 +132,21 @@ const PostStatusForm = { : this.$store.state.config.minimalScopesMode return !minimalScopesMode }, + emojiUserSuggestor () { + return suggestor({ + emoji: [ + ...this.$store.state.instance.emoji, + ...this.$store.state.instance.customEmoji + ], + users: this.$store.state.users.users + }) + }, + emojiSuggestor () { + suggestor({ emoji: [ + ...this.$store.state.instance.emoji, + ...this.$store.state.instance.customEmoji + ]}) + }, emoji () { return this.$store.state.instance.emoji || [] }, @@ -188,57 +197,6 @@ const PostStatusForm = { } }, methods: { - replace (replacement) { - this.newStatus.status = Completion.replaceWord(this.newStatus.status, this.wordAtCaret, replacement) - const el = this.$el.querySelector('textarea') - el.focus() - this.caret = 0 - }, - replaceCandidate (e) { - const len = this.candidates.length || 0 - if (this.textAtCaret === ':' || e.ctrlKey) { return } - if (len > 0) { - e.preventDefault() - const candidate = this.candidates[this.highlighted] - const replacement = candidate.utf || (candidate.screen_name + ' ') - this.newStatus.status = Completion.replaceWord(this.newStatus.status, this.wordAtCaret, replacement) - const el = this.$el.querySelector('textarea') - el.focus() - this.caret = 0 - this.highlighted = 0 - } - }, - cycleBackward (e) { - const len = this.candidates.length || 0 - if (len > 0) { - e.preventDefault() - this.highlighted -= 1 - if (this.highlighted < 0) { - this.highlighted = this.candidates.length - 1 - } - } else { - this.highlighted = 0 - } - }, - cycleForward (e) { - const len = this.candidates.length || 0 - if (len > 0) { - if (e.shiftKey) { return } - e.preventDefault() - this.highlighted += 1 - if (this.highlighted >= len) { - this.highlighted = 0 - } - } else { - this.highlighted = 0 - } - }, - onKeydown (e) { - e.stopPropagation() - }, - setCaret ({target: {selectionStart}}) { - this.caret = selectionStart - }, postStatus (newStatus) { if (this.posting) { return } if (this.submitDisabled) { return } diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue index 25c5284f..507b14bf 100644 --- a/src/components/post_status_form/post_status_form.vue +++ b/src/components/post_status_form/post_status_form.vue @@ -31,32 +31,35 @@ <span v-if="safeDMEnabled">{{ $t('post_status.direct_warning_to_first_only') }}</span> <span v-else>{{ $t('post_status.direct_warning_to_all') }}</span> </p> - <EmojiInput + <emoji-input + :suggest="emojiSuggestor" v-model="newStatus.spoilerText" v-if="newStatus.spoilerText || alwaysShowSubject" - type="text" - :placeholder="$t('post_status.content_warning')" - v-model="newStatus.spoilerText" - classname="form-control" - /> - <textarea - ref="textarea" - @click="setCaret" - @keyup="setCaret" v-model="newStatus.status" :placeholder="$t('post_status.default')" rows="1" class="form-control" - @keydown="onKeydown" - @keydown.down="cycleForward" - @keydown.up="cycleBackward" - @keydown.shift.tab="cycleBackward" - @keydown.tab="cycleForward" - @keydown.enter="replaceCandidate" - @keydown.meta.enter="postStatus(newStatus)" - @keyup.ctrl.enter="postStatus(newStatus)" - @drop="fileDrop" - @dragover.prevent="fileDrag" - @input="resize" - @paste="paste" - :disabled="posting" - > - </textarea> + > + <input + + type="text" + :placeholder="$t('post_status.content_warning')" + v-model="newStatus.spoilerText" + classname="form-control" + /> + </emoji-input> + <emoji-input :suggest="emojiUserSuggestor" v-model="newStatus.status"> + <textarea + ref="textarea" + v-model="newStatus.status" + :placeholder="$t('post_status.default')" + rows="1" + class="form-control" + @keydown.meta.enter="postStatus(newStatus)" + @keyup.ctrl.enter="postStatus(newStatus)" + @drop="fileDrop" + @dragover.prevent="fileDrag" + @input="resize" + @paste="paste" + :disabled="posting" + > + </textarea> + </emoji-input> <div class="visibility-tray"> <div class="text-format" v-if="formattingOptionsEnabled"> <label for="post-content-type" class="select"> @@ -77,21 +80,6 @@ :onScopeChange="changeVis"/> </div> </div> - <div class="autocomplete-panel" v-if="candidates"> - <div class="autocomplete-panel-body"> - <div - v-for="(candidate, index) in candidates" - :key="index" - @click="replace(candidate.utf || (candidate.screen_name + ' '))" - class="autocomplete-item" - :class="{ highlighted: candidate.highlighted }" - > - <span v-if="candidate.img"><img :src="candidate.img" /></span> - <span v-else>{{candidate.utf}}</span> - <span>{{candidate.screen_name}}<small>{{candidate.name}}</small></span> - </div> - </div> - </div> <div class='form-bottom'> <media-upload ref="mediaUpload" @uploading="disableSubmit" @uploaded="addMediaFile" @upload-failed="uploadFailed" :drop-files="dropFiles"></media-upload> diff --git a/src/components/user_settings/user_settings.js b/src/components/user_settings/user_settings.js index ae36e5e8..ca7c23ec 100644 --- a/src/components/user_settings/user_settings.js +++ b/src/components/user_settings/user_settings.js @@ -12,6 +12,7 @@ import MuteCard from '../mute_card/mute_card.vue' import SelectableList from '../selectable_list/selectable_list.vue' import ProgressButton from '../progress_button/progress_button.vue' import EmojiInput from '../emoji-input/emoji-input.vue' +import suggestor from '../emoji-input/suggestor.js' import Autosuggest from '../autosuggest/autosuggest.vue' import Importer from '../importer/importer.vue' import Exporter from '../exporter/exporter.vue' @@ -81,6 +82,21 @@ const UserSettings = { user () { return this.$store.state.users.currentUser }, + emojiUserSuggestor () { + return suggestor({ + emoji: [ + ...this.$store.state.instance.emoji, + ...this.$store.state.instance.customEmoji + ], + users: this.$store.state.users.users + }) + }, + emojiSuggestor () { + suggestor({ emoji: [ + ...this.$store.state.instance.emoji, + ...this.$store.state.instance.customEmoji + ]}) + }, pleromaBackend () { return this.$store.state.instance.pleromaBackend }, diff --git a/src/components/user_settings/user_settings.vue b/src/components/user_settings/user_settings.vue index 20b10979..d3d333bd 100644 --- a/src/components/user_settings/user_settings.vue +++ b/src/components/user_settings/user_settings.vue @@ -22,18 +22,20 @@ <div class="setting-item" > <h2>{{$t('settings.name_bio')}}</h2> <p>{{$t('settings.name')}}</p> - <EmojiInput - type="text" - v-model="newName" - id="username" - classname="name-changer" - /> + <emoji-input :suggest="emojiSuggestor" v-model="newName"> + <input + v-model="newName" + id="username" + classname="name-changer" + /> + </emoji-input> <p>{{$t('settings.bio')}}</p> - <EmojiInput - type="textarea" - v-model="newBio" - classname="bio" - /> + <emoji-input :suggest="emojiUserSuggestor" v-model="newBio"> + <textarea + v-model="newBio" + classname="bio" + /> + </emoji-input> <p> <input type="checkbox" v-model="newLocked" id="account-locked"> <label for="account-locked">{{$t('settings.lock_account_description')}}</label>