diff --git a/src/App.scss b/src/App.scss index 52a786ad0..e4f58e4ef 100644 --- a/src/App.scss +++ b/src/App.scss @@ -806,54 +806,3 @@ nav { .btn.btn-default { min-height: 28px; } - -.autocomplete { - &-panel { - position: relative; - - &-body { - margin: 0 0.5em 0 0.5em; - border-radius: $fallback--tooltipRadius; - border-radius: var(--tooltipRadius, $fallback--tooltipRadius); - position: absolute; - z-index: 1; - box-shadow: 1px 2px 4px rgba(0, 0, 0, 0.5); - // this doesn't match original but i don't care, making it uniform. - box-shadow: var(--popupShadow); - min-width: 75%; - background: $fallback--bg; - background: var(--bg, $fallback--bg); - color: $fallback--lightText; - color: var(--lightText, $fallback--lightText); - } - } - - &-item { - cursor: pointer; - padding: 0.2em 0.4em 0.2em 0.4em; - border-bottom: 1px solid rgba(0, 0, 0, 0.4); - display: flex; - - img { - width: 24px; - height: 24px; - object-fit: contain; - } - - span { - line-height: 24px; - margin: 0 0.1em 0 0.2em; - } - - small { - margin-left: .5em; - color: $fallback--faint; - color: var(--faint, $fallback--faint); - } - - &.highlighted { - background-color: $fallback--fg; - background-color: var(--lightBg, $fallback--fg); - } - } -} diff --git a/src/boot/after_store.js b/src/boot/after_store.js index 603de3483..c0faa0a28 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 { + displayText: key, + imageUrl: false, + replacement: values[key] + } }) store.dispatch('setInstanceOption', { name: 'emoji', value: emoji }) } else { @@ -174,7 +178,12 @@ 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] } + const imageUrl = values[key].image_url + return { + displayText: key, + imageUrl: imageUrl ? store.state.instance.server + imageUrl : 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 a5bb6eaf4..aae11a9ba 100644 --- a/src/components/emoji-input/emoji-input.js +++ b/src/components/emoji-input/emoji-input.js @@ -1,51 +1,83 @@ 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 + caret: 0, + focused: false, + popupOptions: { + placement: 'bottom-start', + trigger: 'hover', + // See: https://github.com/RobinCK/vue-popper/issues/63 + 'delay-on-mouse-over': 9999999, + 'delay-on-mouse-out': 9999999, + modifiers: { + arrow: { enabled: true }, + offset: { offset: '0, 5px' } + } + } } }, 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(({ 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 () { - 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) + }, + 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) } }, methods: { @@ -54,23 +86,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 +109,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,14 +120,63 @@ const EmojiInput = { this.highlighted = 0 } }, - onKeydown (e) { - e.stopPropagation() + onBlur (e) { + this.focused = false + this.setCaret(e) + this.resize(e) + }, + onFocus (e) { + this.focused = true + this.setCaret(e) + this.resize(e) + }, + onKeyUp (e) { + this.setCaret(e) + this.resize(e) + }, + onPaste (e) { + this.setCaret(e) + this.resize(e) + }, + onKeyDown (e) { + this.setCaret(e) + this.resize(e) + + const { ctrlKey, shiftKey, key } = e + if (key === 'Tab') { + if (shiftKey) { + this.cycleBackward() + e.preventDefault() + } else { + this.cycleForward() + e.preventDefault() + } + } + if (key === 'ArrowUp') { + this.cycleBackward() + e.preventDefault() + } else if (key === 'ArrowDown') { + this.cycleForward() + e.preventDefault() + } + if (key === 'Enter') { + if (!ctrlKey) { + this.replaceText() + e.preventDefault() + } + } }, onInput (e) { this.$emit('input', e.target.value) }, - setCaret ({target: {selectionStart}}) { + setCaret ({ target: { selectionStart, value } }) { 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 338b77cd0..dec717441 100644 --- a/src/components/emoji-input/emoji-input.vue +++ b/src/components/emoji-input/emoji-input.vue @@ -1,54 +1,27 @@ @@ -57,8 +30,82 @@ @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; } } diff --git a/src/components/emoji-input/suggestor.js b/src/components/emoji-input/suggestor.js new file mode 100644 index 000000000..c414b1bfa --- /dev/null +++ b/src/components/emoji-input/suggestor.js @@ -0,0 +1,54 @@ +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 noPrefix = input.toLowerCase().substr(1) + return emojis + .filter(({ displayText }) => displayText.toLowerCase().startsWith(noPrefix)) + } +} + +function suggestUsers (users) { + return input => { + const noPrefix = input.toLowerCase().substr(1) + return users.filter( + user => + user.screen_name.toLowerCase().startsWith(noPrefix) || + user.name.toLowerCase().startsWith(noPrefix) + /* eslint-disable camelcase */ + ).slice(0, 20).sort((a, b) => { + let aScore = 0 + let bScore = 0 + + aScore += a.screen_name.toLowerCase().startsWith(noPrefix) * 2 + aScore += a.name.toLowerCase().startsWith(noPrefix) + bScore += b.screen_name.toLowerCase().startsWith(noPrefix) * 2 + bScore += b.name.toLowerCase().startsWith(noPrefix) + + const diff = bScore * 10 - aScore * 10 + const nameAlphabetically = a.name > b.name ? 1 : -1 + const screenNameAlphabetically = a.screen_name > b.screen_name ? 1 : -1 + + return diff + nameAlphabetically + screenNameAlphabetically + }).map(({ screen_name, name, profile_image_url_original }) => ({ + displayText: screen_name, + detailText: name, + imageUrl: profile_image_url_original, + replacement: '@' + screen_name + })) + /* eslint-enable camelcase */ + } +} diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js index 5ae4efeef..0d5567a82 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] @@ -82,50 +83,6 @@ const PostStatusForm = { } }, computed: { - candidates () { - const firstchar = this.textAtCaret.charAt(0) - if (firstchar === '@') { - const query = this.textAtCaret.slice(1).toUpperCase() - const matchedUsers = filter(this.users, (user) => { - return user.screen_name.toUpperCase().startsWith(query) || - user.name && user.name.toUpperCase().startsWith(query) - }) - if (matchedUsers.length <= 0) { - return false - } - // eslint-disable-next-line camelcase - return map(take(matchedUsers, 5), ({screen_name, name, profile_image_url_original}, index) => ({ - // eslint-disable-next-line camelcase - screen_name: `@${screen_name}`, - name: name, - img: profile_image_url_original, - highlighted: index === this.highlighted - })) - } else 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) => ({ - screen_name: `:${shortcode}:`, - name: '', - utf: utf || '', - // eslint-disable-next-line camelcase - img: utf ? '' : this.$store.state.instance.server + image_url, - highlighted: index === this.highlighted - })) - } else { - 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 +95,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 +160,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 25c5284f6..a8a34265e 100644 --- a/src/components/post_status_form/post_status_form.vue +++ b/src/components/post_status_form/post_status_form.vue @@ -31,32 +31,41 @@ {{ $t('post_status.direct_warning_to_first_only') }} {{ $t('post_status.direct_warning_to_all') }}

- - + class="form-control" + > + + + + +
-
-
-
- - {{candidate.utf}} - {{candidate.screen_name}}{{candidate.name}} -
-
-
@@ -263,7 +257,7 @@ min-height: 1px; } - form textarea.form-control { + .form-post-body { line-height:16px; resize: none; overflow: hidden; @@ -272,7 +266,7 @@ box-sizing: content-box; } - form textarea.form-control:focus { + .form-post-body:focus { min-height: 48px; } diff --git a/src/components/user_settings/user_settings.js b/src/components/user_settings/user_settings.js index ae36e5e8f..ca7c23ece 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 20b109799..d3d333bd7 100644 --- a/src/components/user_settings/user_settings.vue +++ b/src/components/user_settings/user_settings.vue @@ -22,18 +22,20 @@

{{$t('settings.name_bio')}}

{{$t('settings.name')}}

- + + +

{{$t('settings.bio')}}

- + +