From 024230c7f4792d9f6fb9b899b1c9f8738dbacce2 Mon Sep 17 00:00:00 2001 From: Roger Braun Date: Wed, 15 Mar 2017 16:22:36 +0100 Subject: [PATCH 1/9] Basic word position and completion service. --- src/services/completion/completion.js | 70 +++++++++++++++++++ .../services/completion/completion.spec.js | 70 +++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 src/services/completion/completion.js create mode 100644 test/unit/specs/services/completion/completion.spec.js diff --git a/src/services/completion/completion.js b/src/services/completion/completion.js new file mode 100644 index 00000000..8788d837 --- /dev/null +++ b/src/services/completion/completion.js @@ -0,0 +1,70 @@ +import { reduce, find } from 'lodash' + +export const replaceWord = (str, toReplace, replacement) => { + return str.slice(0, toReplace.start) + replacement + str.slice(toReplace.end) +} + +export const wordAtPosition = (str, pos) => { + const words = splitIntoWords(str) + const wordsWithPosition = addPositionToWords(words) + + return find(wordsWithPosition, ({start, end}) => start <= pos && end > pos) +} + +export const addPositionToWords = (words) => { + return reduce(words, (result, word) => { + const data = { + word, + start: 0, + end: word.length + } + + if (result.length > 0) { + const previous = result.pop() + + data.start += previous.end + data.end += previous.end + + result.push(previous) + } + + result.push(data) + + return result + }, []) +} + +export const splitIntoWords = (str) => { + // Split at word boundaries + const regex = /\b/ + const triggers = /[@#]+$/ + + let split = str.split(regex) + + // Add trailing @ and # to the following word. + const words = reduce(split, (result, word) => { + if (result.length > 0) { + let previous = result.pop() + const matches = previous.match(triggers) + if (matches) { + previous = previous.replace(triggers, '') + word = matches[0] + word + } + result.push(previous) + } + result.push(word) + + return result + }, []) + + return words +} + +const completion = { + wordAtPosition, + addPositionToWords, + splitIntoWords, + replaceWord +} + +export default completion diff --git a/test/unit/specs/services/completion/completion.spec.js b/test/unit/specs/services/completion/completion.spec.js new file mode 100644 index 00000000..8a41c653 --- /dev/null +++ b/test/unit/specs/services/completion/completion.spec.js @@ -0,0 +1,70 @@ +import { replaceWord, addPositionToWords, wordAtPosition, splitIntoWords } from '../../../../../src/services/completion/completion.js' + +describe('addPositiontoWords', () => { + it('adds the position to a word list', () => { + const words = ['hey', 'this', 'is', 'fun'] + + const expected = [ + { + word: 'hey', + start: 0, + end: 3 + }, + { + word: 'this', + start: 3, + end: 7 + }, + { + word: 'is', + start: 7, + end: 9 + }, + { + word: 'fun', + start: 9, + end: 12 + } + ] + + const res = addPositionToWords(words) + + expect(res).to.eql(expected) + }) +}) + +describe('splitIntoWords', () => { + it('splits at whitespace boundaries', () => { + const str = 'This is a #nice @test for you, @idiot.' + const expected = ['This', ' ', 'is', ' ', 'a', ' ', '#nice', ' ', '@test', ' ', 'for', ' ', 'you', ', ', '@idiot', '.'] + const res = splitIntoWords(str) + + expect(res).to.eql(expected) + }) +}) + +describe('wordAtPosition', () => { + it('returns the word for a given string and postion, plus the start and end position of that word', () => { + const str = 'Hey this is fun' + + const { word, start, end } = wordAtPosition(str, 4) + + expect(word).to.eql('this') + expect(start).to.eql(4) + expect(end).to.eql(8) + }) +}) + +describe('replaceWord', () => { + it('replaces a word (with start and end) with another word in a given string', () => { + const str = 'hey @take, how are you' + const wordsWithPosition = addPositionToWords(splitIntoWords(str)) + const toReplace = wordsWithPosition[2] + + expect(toReplace.word).to.eql('@take') + + const expected = 'hey @takeshitakenji, how are you' + const res = replaceWord(str, toReplace, '@takeshitakenji') + expect(res).to.eql(expected) + }) +}) From 64153e2303eed47d8206f1851d352d82bb342d81 Mon Sep 17 00:00:00 2001 From: Roger Braun Date: Wed, 15 Mar 2017 16:23:39 +0100 Subject: [PATCH 2/9] Add autowatching test running task. --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 74706389..b66b8797 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "dev": "node build/dev-server.js", "build": "node build/build.js", "unit": "karma start test/unit/karma.conf.js --single-run", + "unit:watch": "karma start test/unit/karma.conf.js --single-run=false", "e2e": "node test/e2e/runner.js", "test": "npm run unit && npm run e2e", "lint": "eslint --ext .js,.vue src test/unit/specs test/e2e/specs" From df2a39c0d6ed6e2eeca0d0dceb09fb2d92b1c361 Mon Sep 17 00:00:00 2001 From: Roger Braun Date: Wed, 15 Mar 2017 17:06:33 +0100 Subject: [PATCH 3/9] Remove tributejs. --- package.json | 1 - yarn.lock | 4 ---- 2 files changed, 5 deletions(-) diff --git a/package.json b/package.json index b66b8797..669a5932 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,6 @@ "object-path": "^0.11.3", "sanitize-html": "^1.13.0", "sass-loader": "^4.0.2", - "tributejs": "^2.1.0", "vue": "^2.1.0", "vue-router": "^2.2.0", "vue-template-compiler": "^2.1.10", diff --git a/yarn.lock b/yarn.lock index 677c9690..6249966e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5500,10 +5500,6 @@ tough-cookie@~2.3.0: dependencies: punycode "^1.4.1" -tributejs@^2.1.0: - version "2.3.3" - resolved "https://registry.yarnpkg.com/tributejs/-/tributejs-2.3.3.tgz#ec3b9ae3edd0f7e2bc5ca56d11ae43fdd7a8cd28" - trim-newlines@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-1.0.0.tgz#5887966bb582a4503a41eb524f7d35011815a613" From 5249b1d23ab2c2fb48de9137be529f38dbbceaf2 Mon Sep 17 00:00:00 2001 From: Roger Braun Date: Wed, 15 Mar 2017 17:06:48 +0100 Subject: [PATCH 4/9] Add basic mention completion. --- .../post_status_form/post_status_form.js | 89 ++++++------------- .../post_status_form/post_status_form.vue | 9 +- 2 files changed, 33 insertions(+), 65 deletions(-) diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js index 01aeeb68..be2ecc2f 100644 --- a/src/components/post_status_form/post_status_form.js +++ b/src/components/post_status_form/post_status_form.js @@ -1,10 +1,9 @@ import statusPoster from '../../services/status_poster/status_poster.service.js' import MediaUpload from '../media_upload/media_upload.vue' import fileTypeService from '../../services/file_type/file_type.service.js' -import Tribute from '../../../node_modules/tributejs/src/Tribute.js' -require('../../../node_modules/tributejs/scss/tribute.scss') +import Completion from '../../services/completion/completion.js' -import { merge, reject, map, uniqBy } from 'lodash' +import { take, filter, reject, map, uniqBy } from 'lodash' const buildMentionsString = ({user, attentions}, currentUser) => { let allAttentions = [...attentions] @@ -21,51 +20,6 @@ const buildMentionsString = ({user, attentions}, currentUser) => { return mentions.join(' ') + ' ' } -const defaultCollection = { - // symbol that starts the lookup - trigger: '@', - - // element to target for @mentions - iframe: null, - - // class added in the flyout menu for active item - selectClass: 'highlight', - - // function called on select that returns the content to insert - selectTemplate: function (item) { - return '@' + item.original.screen_name - }, - - // template for displaying item in menu - menuItemTemplate: function (item) { - return `
${item.string}
` - }, - - // template for when no match is found (optional), - // If no template is provided, menu is hidden. - noMatchTemplate: null, - - // specify an alternative parent container for the menu - menuContainer: document.body, - - // column to search against in the object (accepts function or string) - lookup: ({name, screen_name}) => `${name} (@${screen_name})`, // eslint-disable-line camelcase - - // column that contains the content to insert by default - fillAttr: 'screen_name', - - // REQUIRED: array of objects to match - values: [], - - // specify whether a space is required before the trigger character - requireLeadingSpace: true, - - // specify whether a space is allowed in the middle of mentions - allowSpaces: false -} - -const tribute = new Tribute({ collection: [] }) - const PostStatusForm = { props: [ 'replyTo', @@ -89,30 +43,37 @@ const PostStatusForm = { newStatus: { status: statusText, files: [] - } + }, + caret: 0 } }, computed: { + candidates () { + if (this.textAtCaret.charAt(0) === '@') { + const matchedUsers = filter(this.users, (user) => (user.name + user.screen_name).match(this.textAtCaret.slice(1))) + return map(take(matchedUsers, 5), ({screen_name, name}) => screen_name) + } else { + return ['nothing'] + } + }, + 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 - }, - completions () { - let users = this.users - users = merge({values: users}, defaultCollection) - return [users] } }, - watch: { - completions () { - tribute.collection = this.completions - } - }, - mounted () { - const textarea = this.$el.querySelector('textarea') - tribute.collection = this.completions - tribute.attach(textarea) - }, methods: { + replace (replacement) { + this.newStatus.status = Completion.replaceWord(this.newStatus.status, this.wordAtCaret, replacement) + }, + setCaret ({target: {selectionStart}}) { + this.caret = selectionStart + }, postStatus (newStatus) { statusPoster.postStatus({ status: newStatus.status, diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue index 07280a41..12a9c88a 100644 --- a/src/components/post_status_form/post_status_form.vue +++ b/src/components/post_status_form/post_status_form.vue @@ -2,7 +2,7 @@
- +
@@ -13,6 +13,13 @@ {{file.url}}
+
+

Word

+

{{textAtCaret}}

+

Candidates

+ +

{{candidate}}

+
From 44923afbee23ef7bd22c20d25bf7776b284f5f88 Mon Sep 17 00:00:00 2001 From: Roger Braun Date: Wed, 15 Mar 2017 17:14:51 +0100 Subject: [PATCH 5/9] Make linter happy. --- src/components/post_status_form/post_status_form.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js index be2ecc2f..797fcdbb 100644 --- a/src/components/post_status_form/post_status_form.js +++ b/src/components/post_status_form/post_status_form.js @@ -51,6 +51,7 @@ const PostStatusForm = { candidates () { if (this.textAtCaret.charAt(0) === '@') { const matchedUsers = filter(this.users, (user) => (user.name + user.screen_name).match(this.textAtCaret.slice(1))) + // eslint-disable-next-line camelcase return map(take(matchedUsers, 5), ({screen_name, name}) => screen_name) } else { return ['nothing'] From d0d95c59aa6c65a4866542dce7f0163ac22ec8ff Mon Sep 17 00:00:00 2001 From: Shpuld Shpuldson Date: Thu, 15 Jun 2017 23:26:16 +0300 Subject: [PATCH 6/9] fix lint --- src/components/post_status_form/post_status_form.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js index 881a9d1c..73fb2371 100644 --- a/src/components/post_status_form/post_status_form.js +++ b/src/components/post_status_form/post_status_form.js @@ -2,10 +2,8 @@ import statusPoster from '../../services/status_poster/status_poster.service.js' import MediaUpload from '../media_upload/media_upload.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' - const buildMentionsString = ({user, attentions}, currentUser) => { let allAttentions = [...attentions] From 81d262af42b7b44ac8582a9dcfd7791423e3a0b4 Mon Sep 17 00:00:00 2001 From: Shpuld Shpuldson Date: Fri, 16 Jun 2017 01:13:54 +0300 Subject: [PATCH 7/9] Fancier visuals for autocomplete list, with small avatar previews and all that. --- .../post_status_form/post_status_form.js | 11 ++++- .../post_status_form/post_status_form.vue | 46 ++++++++++++++++--- 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js index 73fb2371..b1e5f84d 100644 --- a/src/components/post_status_form/post_status_form.js +++ b/src/components/post_status_form/post_status_form.js @@ -50,10 +50,17 @@ const PostStatusForm = { candidates () { if (this.textAtCaret.charAt(0) === '@') { const matchedUsers = filter(this.users, (user) => (user.name + user.screen_name).match(this.textAtCaret.slice(1))) + if (matchedUsers.length <= 0) { + return false + } // eslint-disable-next-line camelcase - return map(take(matchedUsers, 5), ({screen_name, name}) => screen_name) + return map(take(matchedUsers, 5), ({screen_name, name, profile_image_url_original}) => ({ + screen_name: screen_name, + name: name, + img: profile_image_url_original + })) } else { - return ['nothing'] + return false } }, textAtCaret () { diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue index 4f6d4565..a94fe2cb 100644 --- a/src/components/post_status_form/post_status_form.vue +++ b/src/components/post_status_form/post_status_form.vue @@ -4,12 +4,16 @@
-
-

Word

-

{{textAtCaret}}

-

Candidates

- -

{{candidate}}

+
+
+
+ + + @{{candidate.screen_name}} + {{candidate.name}} + +
+
@@ -115,6 +119,36 @@ .icon-cancel { cursor: pointer; } + + .autocomplete-panel { + margin: 0 0.5em 0 0.5em; + padding: 0.25em 0.45em 0 0.45em; + border-radius: 5px; + position: absolute; + z-index: 1; + box-shadow: 1px 2px 4px rgba(0, 0, 0, 0.5); + min-width: 75%; + } + + .autocomplete { + cursor: pointer; + padding: 0.2em 0 0.2em 0; + margin: 0.1em 0 0.1em 0; + border-bottom: 1px solid rgba(0, 0, 0, 0.4); + display: flex; + img { + width: 22px; + height: 22px; + border-radius: 2px; + } + span { + line-height: 20px; + margin: 0 0.1em 0 0.2em; + } + small { + font-style: italic; + } + } } From b24b891c1c8e18beece00cbe6fe92375df8b0337 Mon Sep 17 00:00:00 2001 From: Shpuld Shpuldson Date: Fri, 16 Jun 2017 10:15:10 +0300 Subject: [PATCH 8/9] Clean up styling a bit. --- src/components/post_status_form/post_status_form.vue | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue index a94fe2cb..c578528b 100644 --- a/src/components/post_status_form/post_status_form.vue +++ b/src/components/post_status_form/post_status_form.vue @@ -122,7 +122,6 @@ .autocomplete-panel { margin: 0 0.5em 0 0.5em; - padding: 0.25em 0.45em 0 0.45em; border-radius: 5px; position: absolute; z-index: 1; @@ -132,17 +131,16 @@ .autocomplete { cursor: pointer; - padding: 0.2em 0 0.2em 0; - margin: 0.1em 0 0.1em 0; + padding: 0.2em 0.4em 0.2em 0.4em; border-bottom: 1px solid rgba(0, 0, 0, 0.4); display: flex; img { - width: 22px; - height: 22px; + width: 24px; + height: 24px; border-radius: 2px; } span { - line-height: 20px; + line-height: 24px; margin: 0 0.1em 0 0.2em; } small { From 5dc22e9273286e3b47b1fa2d8f038f7def4e658b Mon Sep 17 00:00:00 2001 From: Shpuld Shpuldson Date: Fri, 16 Jun 2017 10:26:54 +0300 Subject: [PATCH 9/9] Clicking autocomplete will return focus to text area, make the autocomplete box disappear after clicking on a name. --- src/components/post_status_form/post_status_form.js | 3 +++ src/components/post_status_form/post_status_form.vue | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js index b1e5f84d..a8b4d39c 100644 --- a/src/components/post_status_form/post_status_form.js +++ b/src/components/post_status_form/post_status_form.js @@ -77,6 +77,9 @@ 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 }, setCaret ({target: {selectionStart}}) { this.caret = selectionStart diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue index c578528b..a95f92ab 100644 --- a/src/components/post_status_form/post_status_form.vue +++ b/src/components/post_status_form/post_status_form.vue @@ -6,7 +6,7 @@
-
+
@{{candidate.screen_name}}