From 96f31716f94d0e7691b85ca90e7ea977ca3adb4d Mon Sep 17 00:00:00 2001 From: Henry Jameson Date: Fri, 7 Jun 2019 00:17:49 +0300 Subject: slot-based emoji input/autocomplete component --- src/components/emoji-input/suggestor.js | 38 +++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 src/components/emoji-input/suggestor.js (limited to 'src/components/emoji-input/suggestor.js') 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 + })) + } +} -- cgit v1.2.3-70-g09d2 From 8872b4802e2b0dff25eaaf884884d740ed016c98 Mon Sep 17 00:00:00 2001 From: Henry Jameson Date: Sat, 8 Jun 2019 16:23:58 +0300 Subject: standartized autocomplete panel suggesions format, fixed some bugs --- src/App.scss | 4 - src/boot/after_store.js | 11 +-- src/components/emoji-input/emoji-input.js | 87 +++++++++++++++++----- src/components/emoji-input/emoji-input.vue | 64 ++++++++-------- src/components/emoji-input/suggestor.js | 20 ++--- .../post_status_form/post_status_form.js | 37 --------- .../post_status_form/post_status_form.vue | 18 +++-- 7 files changed, 129 insertions(+), 112 deletions(-) (limited to 'src/components/emoji-input/suggestor.js') diff --git a/src/App.scss b/src/App.scss index 52a786ad..fd724979 100644 --- a/src/App.scss +++ b/src/App.scss @@ -809,14 +809,10 @@ nav { .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); diff --git a/src/boot/after_store.js b/src/boot/after_store.js index ea5a3f58..c0faa0a2 100644 --- a/src/boot/after_store.js +++ b/src/boot/after_store.js @@ -154,9 +154,9 @@ const getStaticEmoji = async ({ store }) => { const values = await res.json() const emoji = Object.keys(values).map((key) => { return { - shortcode: key, - image_url: false, - 'replacement': values[key] + displayText: key, + imageUrl: false, + replacement: values[key] } }) store.dispatch('setInstanceOption', { name: 'emoji', value: emoji }) @@ -178,9 +178,10 @@ 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) => { + const imageUrl = values[key].image_url return { - shortcode: key, - image_url: values[key].image_url || values[key], + displayText: key, + imageUrl: imageUrl ? store.state.instance.server + imageUrl : values[key], replacement: `:${key}: ` } }) diff --git a/src/components/emoji-input/emoji-input.js b/src/components/emoji-input/emoji-input.js index 466341c0..fb60d998 100644 --- a/src/components/emoji-input/emoji-input.js +++ b/src/components/emoji-input/emoji-input.js @@ -13,7 +13,19 @@ const EmojiInput = { 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: { @@ -24,13 +36,17 @@ const EmojiInput = { 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 - })) + return take(matchedSuggestions, 5) + .map(({ displayText, imageUrl, replacement }, index) => ({ + displayText, + replacement, + // 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 || '' @@ -40,25 +56,29 @@ const EmojiInput = { const word = Completion.wordAtPosition(this.value, this.caret - 1) || {} return word } - }, + } }, mounted () { const slots = this.$slots.default - if (slots.length === 0) return + if (!slots || 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) + 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 () { - 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) + 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: { @@ -101,26 +121,49 @@ const EmojiInput = { this.highlighted = 0 } }, + 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) - e.stopPropagation() + 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() } } }, @@ -129,6 +172,12 @@ const EmojiInput = { }, 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 eec33d1a..0ca74322 100644 --- a/src/components/emoji-input/emoji-input.vue +++ b/src/components/emoji-input/emoji-input.vue @@ -1,38 +1,25 @@ @@ -41,8 +28,21 @@ @import '../../_variables.scss'; .emoji-input { - .form-control { - width: 100%; + display: flex; + flex-direction: column; + + &.hide { + display: none + } + + .autocomplete-panel { + position: absolute; + z-index: 9; + margin-top: 2px; + } + + input, textarea { + flex: 1; } } diff --git a/src/components/emoji-input/suggestor.js b/src/components/emoji-input/suggestor.js index f1a0d0da..54fd7f29 100644 --- a/src/components/emoji-input/suggestor.js +++ b/src/components/emoji-input/suggestor.js @@ -15,24 +15,26 @@ export default function suggest (data) { function suggestEmoji (emojis) { return input => { - const shortcode = input.toLowerCase().substr(1) - console.log(shortcode) - return emojis.filter(emoji => emoji.shortcode.toLowerCase().startsWith(shortcode)) + const noPrefix = input.toLowerCase().substr(1) + return emojis + .filter(({ displayText }) => displayText.toLowerCase().startsWith(noPrefix)) } } function suggestUsers (users) { return input => { - const shortcode = input.toLowerCase().substr(1) + const noPrefix = input.toLowerCase().substr(1) return users.filter( user => - user.screen_name.toLowerCase().startsWith('@' + shortcode) || - user.name.toLowerCase().startsWith(shortcode) + user.screen_name.toLowerCase().startsWith(noPrefix) || + user.name.toLowerCase().startsWith(noPrefix) + /* eslint-disable camelcase */ ).map(({ screen_name, name, profile_image_url_original }) => ({ - shortcode: screen_name, - detail: name, - image_url: 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 1516dd43..008b821a 100644 --- a/src/components/post_status_form/post_status_form.js +++ b/src/components/post_status_form/post_status_form.js @@ -83,43 +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 - } - }, users () { return this.$store.state.users.users }, diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue index 507b14bf..a8a34265 100644 --- a/src/components/post_status_form/post_status_form.vue +++ b/src/components/post_status_form/post_status_form.vue @@ -32,24 +32,29 @@ {{ $t('post_status.direct_warning_to_all') }}

- + @@ -251,7 +257,7 @@ min-height: 1px; } - form textarea.form-control { + .form-post-body { line-height:16px; resize: none; overflow: hidden; @@ -260,7 +266,7 @@ box-sizing: content-box; } - form textarea.form-control:focus { + .form-post-body:focus { min-height: 48px; } -- cgit v1.2.3-70-g09d2 From a3cc8cc5d8241576d8355200ef79854aedb43c2f Mon Sep 17 00:00:00 2001 From: Henry Jameson Date: Sat, 8 Jun 2019 17:15:48 +0300 Subject: sorting --- src/components/emoji-input/suggestor.js | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) (limited to 'src/components/emoji-input/suggestor.js') diff --git a/src/components/emoji-input/suggestor.js b/src/components/emoji-input/suggestor.js index 54fd7f29..c414b1bf 100644 --- a/src/components/emoji-input/suggestor.js +++ b/src/components/emoji-input/suggestor.js @@ -29,7 +29,21 @@ function suggestUsers (users) { user.screen_name.toLowerCase().startsWith(noPrefix) || user.name.toLowerCase().startsWith(noPrefix) /* eslint-disable camelcase */ - ).map(({ screen_name, name, profile_image_url_original }) => ({ + ).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, -- cgit v1.2.3-70-g09d2 From 20923d590c399d7d29fbf5a6c6c140d981256fa1 Mon Sep 17 00:00:00 2001 From: Henry Jameson Date: Sun, 9 Jun 2019 20:41:12 +0300 Subject: Some comments, added sorting for emojis --- src/components/emoji-input/suggestor.js | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) (limited to 'src/components/emoji-input/suggestor.js') diff --git a/src/components/emoji-input/suggestor.js b/src/components/emoji-input/suggestor.js index c414b1bf..40c7aa3d 100644 --- a/src/components/emoji-input/suggestor.js +++ b/src/components/emoji-input/suggestor.js @@ -1,13 +1,11 @@ export default function suggest (data) { return input => { - const trimmed = input.trim() - const firstChar = trimmed[0] - console.log(`'${trimmed}'`, firstChar, firstChar === ':') + const firstChar = input[0] if (firstChar === ':' && data.emoji) { - return suggestEmoji(data.emoji)(trimmed) + return suggestEmoji(data.emoji)(input) } if (firstChar === '@' && data.users) { - return suggestUsers(data.users)(trimmed) + return suggestUsers(data.users)(input) } return [] } @@ -18,6 +16,19 @@ function suggestEmoji (emojis) { 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 += Number(!!a.imageUrl) * 10 + bScore += Number(!!b.imageUrl) * 10 + + // Sort alphabetically + const alphabetically = a.displayText > b.displayText ? 1 : -1 + + return bScore - aScore + alphabetically + }) } } @@ -33,12 +44,17 @@ function suggestUsers (users) { 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 - aScore += a.name.toLowerCase().startsWith(noPrefix) bScore += b.screen_name.toLowerCase().startsWith(noPrefix) * 2 + + // Matches on name takes second priority + aScore += a.name.toLowerCase().startsWith(noPrefix) bScore += b.name.toLowerCase().startsWith(noPrefix) const diff = bScore * 10 - aScore * 10 + + // Then sort alphabetically const nameAlphabetically = a.name > b.name ? 1 : -1 const screenNameAlphabetically = a.screen_name > b.screen_name ? 1 : -1 -- cgit v1.2.3-70-g09d2 From 0535d2c14cda3b490e5904a93afb5cf6dd2b741b Mon Sep 17 00:00:00 2001 From: Henry Jameson Date: Tue, 11 Jun 2019 21:18:09 +0300 Subject: fixed some bugs i found, also cleaned up some stuff + documentation --- src/components/emoji-input/emoji-input.js | 20 +++++++++++++------- src/components/emoji-input/emoji-input.vue | 2 +- src/components/emoji-input/suggestor.js | 19 +++++++++++++++++-- 3 files changed, 31 insertions(+), 10 deletions(-) (limited to 'src/components/emoji-input/suggestor.js') diff --git a/src/components/emoji-input/emoji-input.js b/src/components/emoji-input/emoji-input.js index 2a32b33b..c425fdf5 100644 --- a/src/components/emoji-input/emoji-input.js +++ b/src/components/emoji-input/emoji-input.js @@ -31,11 +31,11 @@ const EmojiInput = { * Suggestion is an object containing following properties: * displayText: string. Main display text, what actual suggestion * represents (user's screen name/emoji shortcode) - * replacementText: string. Text that should replace the textAtCaret + * 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, replacementText will be used (for + * currently if no image is provided, replacement will be used (for * unicode emojis) * * TODO: make it asynchronous when adding proper server-provided user @@ -98,22 +98,22 @@ const EmojiInput = { if (!input) return this.input = input this.resize() - input.elm.addEventListener('transitionend', this.onTransition) 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) }, unmounted () { const { input } = this if (input) { - input.elm.removeEventListener('transitionend', this.onTransition) 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) } }, methods: { @@ -132,6 +132,8 @@ const EmojiInput = { this.$emit('input', newValue) this.caret = 0 this.highlighted = 0 + // Re-focus inputbox after clicking suggestion + this.input.elm.focus() e.preventDefault() } }, @@ -163,9 +165,13 @@ const EmojiInput = { this.resize(e) }, onBlur (e) { - this.focused = false - this.setCaret(e) - this.resize(e) + // Clicking on any suggestion removes focus from autocomplete, + // preventing click handler ever executing. + setTimeout(() => { + this.focused = false + this.setCaret(e) + this.resize(e) + }, 200) }, onFocus (e) { this.focused = true diff --git a/src/components/emoji-input/emoji-input.vue b/src/components/emoji-input/emoji-input.vue index 77562957..8437a495 100644 --- a/src/components/emoji-input/emoji-input.vue +++ b/src/components/emoji-input/emoji-input.vue @@ -6,7 +6,7 @@
diff --git a/src/components/emoji-input/suggestor.js b/src/components/emoji-input/suggestor.js index 40c7aa3d..eb70f299 100644 --- a/src/components/emoji-input/suggestor.js +++ b/src/components/emoji-input/suggestor.js @@ -1,3 +1,13 @@ +/** + * 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 function suggest (data) { return input => { const firstChar = input[0] @@ -39,7 +49,11 @@ function suggestUsers (users) { user => user.screen_name.toLowerCase().startsWith(noPrefix) || user.name.toLowerCase().startsWith(noPrefix) - /* eslint-disable camelcase */ + + /* 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 @@ -59,11 +73,12 @@ function suggestUsers (users) { 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 + replacement: '@' + screen_name + ' ' })) /* eslint-enable camelcase */ } -- cgit v1.2.3-70-g09d2 From 0032802f0a3ef6249f56acefeb2b0e900b90d1b1 Mon Sep 17 00:00:00 2001 From: Henry Jameson Date: Tue, 18 Jun 2019 21:30:35 +0300 Subject: review --- src/components/emoji-input/emoji-input.js | 2 +- src/components/emoji-input/suggestor.js | 96 +++++++++++----------- .../post_status_form/post_status_form.vue | 8 +- src/components/user_settings/user_settings.vue | 8 +- 4 files changed, 55 insertions(+), 59 deletions(-) (limited to 'src/components/emoji-input/suggestor.js') diff --git a/src/components/emoji-input/emoji-input.js b/src/components/emoji-input/emoji-input.js index 08c2f60b..d2e4a21a 100644 --- a/src/components/emoji-input/emoji-input.js +++ b/src/components/emoji-input/emoji-input.js @@ -8,7 +8,7 @@ import { take } from 'lodash' * Intended usage is: * * - * + * * * Works only with and - +