diff --git a/core/client/Brocfile.js b/core/client/Brocfile.js index eedda154a0f..e3045cd20e6 100644 --- a/core/client/Brocfile.js +++ b/core/client/Brocfile.js @@ -59,6 +59,7 @@ app.import('bower_components/codemirror/mode/javascript/javascript.js'); app.import('bower_components/xregexp/xregexp-all.js'); app.import('bower_components/password-generator/lib/password-generator.js'); app.import('bower_components/blueimp-md5/js/md5.js'); +app.import('bower_components/typeahead.js/dist/typeahead.bundle.js'); // 'dem Styles app.import('bower_components/codemirror/lib/codemirror.css'); diff --git a/core/client/app/components/gh-post-tags-input.js b/core/client/app/components/gh-post-tags-input.js deleted file mode 100644 index 2ce07d1073a..00000000000 --- a/core/client/app/components/gh-post-tags-input.js +++ /dev/null @@ -1,149 +0,0 @@ -import Ember from 'ember'; - -export default Ember.Component.extend({ - tagName: 'section', - elementId: 'entry-tags', - classNames: 'publish-bar-inner', - classNameBindings: ['hasFocus:focused'], - - hasFocus: false, - - keys: { - BACKSPACE: 8, - TAB: 9, - ENTER: 13, - ESCAPE: 27, - UP: 38, - DOWN: 40, - NUMPAD_ENTER: 108 - }, - - didInsertElement: function () { - // this.get('controller').send('loadAllTags'); - }, - - willDestroyElement: function () { - // this.get('controller').send('reset'); - }, - - overlayStyles: Ember.computed('hasFocus', 'controller.suggestions.length', function () { - var styles = [], - leftPos; - - if (this.get('hasFocus') && this.get('controller.suggestions.length')) { - leftPos = this.$().find('#tags').position().left; - styles.push('display: block'); - styles.push('left: ' + leftPos + 'px'); - } else { - styles.push('display: none'); - styles.push('left', 0); - } - - return styles.join(';').htmlSafe(); - }), - - // replace these views with components, or whatever works - // during the reimplementation of this component. - - // tagInputView: Ember.TextField.extend({ - // focusIn: function () { - // this.get('parentView').set('hasFocus', true); - // }, - - // focusOut: function () { - // this.get('parentView').set('hasFocus', false); - // }, - - // keyPress: function (event) { - // // listen to keypress event to handle comma key on international keyboard - // var controller = this.get('parentView.controller'), - // isComma = ','.localeCompare(String.fromCharCode(event.keyCode || event.charCode)) === 0; - - // // use localeCompare in case of international keyboard layout - // if (isComma) { - // event.preventDefault(); - - // if (controller.get('selectedSuggestion')) { - // controller.send('addSelectedSuggestion'); - // } else { - // controller.send('addNewTag'); - // } - // } - // }, - - // keyDown: function (event) { - // var controller = this.get('parentView.controller'), - // keys = this.get('parentView.keys'), - // hasValue; - - // switch (event.keyCode) { - // case keys.UP: - // event.preventDefault(); - // controller.send('selectPreviousSuggestion'); - // break; - - // case keys.DOWN: - // event.preventDefault(); - // controller.send('selectNextSuggestion'); - // break; - - // case keys.TAB: - // case keys.ENTER: - // case keys.NUMPAD_ENTER: - // if (controller.get('selectedSuggestion')) { - // event.preventDefault(); - // controller.send('addSelectedSuggestion'); - // } else { - // // allow user to tab out of field if input is empty - // hasValue = !Ember.isEmpty(this.get('value')); - // if (hasValue || event.keyCode !== keys.TAB) { - // event.preventDefault(); - // controller.send('addNewTag'); - // } - // } - // break; - - // case keys.BACKSPACE: - // if (Ember.isEmpty(this.get('value'))) { - // event.preventDefault(); - // controller.send('deleteLastTag'); - // } - // break; - - // case keys.ESCAPE: - // event.preventDefault(); - // controller.send('reset'); - // break; - // } - // } - // }), - - // suggestionView: Ember.View.extend({ - // tagName: 'li', - // classNameBindings: 'suggestion.selected', - - // suggestion: null, - - // // we can't use the 'click' event here as the focusOut event on the - // // input will fire first - - // mouseDown: function (event) { - // event.preventDefault(); - // }, - - // mouseUp: function (event) { - // event.preventDefault(); - // this.get('parentView.controller').send('addTag', - // this.get('suggestion.tag')); - // } - // }), - - actions: { - deleteTag: function (tag) { - // The view wants to keep focus on the input after a click on a tag - Ember.$('.js-tag-input').focus(); - // Make the controller do the actual work - this.sendAction('deleteTag', tag); - } - } -}); diff --git a/core/client/app/components/gh-tags-input.js b/core/client/app/components/gh-tags-input.js new file mode 100644 index 00000000000..7e32022ee49 --- /dev/null +++ b/core/client/app/components/gh-tags-input.js @@ -0,0 +1,280 @@ +/* global Bloodhound, key */ +import Ember from 'ember'; + +/** + * Ghost Tag Input Component + * + * Creates an input field that is used to input tags for a post. + * @param {Boolean} hasFocus Whether or not the input is focused + * @param {DS.Model} post The current post object to input tags for + */ +export default Ember.Component.extend({ + classNames: ['gh-input'], + classNameBindings: ['hasFocus:focus'], + + // Uses the Ember-Data store directly, as it needs to create and get tag records + store: Ember.inject.service(), + + hasFocus: false, + post: null, + highlightIndex: null, + + isDirty: false, + isReloading: false, + + unassignedTags: Ember.A(), // tags that AREN'T assigned to this post + currentTags: Ember.A(), // tags that ARE assigned to this post + + // Input field events + click: function () { + this.$('#tag-input').focus(); + }, + + focusIn: function () { + this.set('hasFocus', true); + key.setScope('tags'); + }, + + focusOut: function () { + this.set('hasFocus', false); + key.setScope('default'); + this.set('highlightIndex', null); + // if there is text in the input field, create a tag with it + if (this.$('#tag-input').val() !== '') { + this.send('addTag', this.$('#tag-input').val()); + } + this.saveTags(); + }, + + keyPress: function (event) { + var val = this.$('#tag-input').val(), + isComma = ','.localeCompare(String.fromCharCode(event.keyCode || event.charCode)) === 0; + + if (isComma && val !== '') { + event.preventDefault(); + this.send('addTag', val); + } + }, + + // Tag Loading functions + loadTagsOnInit: Ember.on('init', function () { + var self = this; + + if (this.get('post')) { + this.loadTags().then(function () { + Ember.run.schedule('afterRender', self, 'initTypeahead'); + }); + } + }), + + reloadTags: Ember.observer('post', function () { + var self = this; + + this.loadTags().then(function () { + self.reloadTypeahead(false); + }); + }), + + loadTags: function () { + var self = this, + post = this.get('post'); + + this.get('currentTags').clear(); + this.get('unassignedTags').clear(); + + return this.get('store').find('tag', {limit: 'all'}).then(function (tags) { + if (post.get('id')) { // if it's a new post, it won't have an id + self.get('currentTags').pushObjects(post.get('tags').toArray()); + } + + tags.forEach(function (tag) { + if (Ember.isEmpty(post.get('id')) || Ember.isEmpty(self.get('currentTags').findBy('id', tag.get('id')))) { + self.get('unassignedTags').pushObject(tag); + } + }); + + return Ember.RSVP.resolve(); + }); + }, + + // Key Binding functions + bindKeys: function () { + var self = this; + + key('enter, tab', 'tags', function (event) { + var val = self.$('#tag-input').val(); + + if (val !== '') { + event.preventDefault(); + self.send('addTag', val); + } + }); + + key('backspace', 'tags', function (event) { + if (self.$('#tag-input').val() === '') { + event.preventDefault(); + self.send('deleteTag'); + } + }); + + key('left', 'tags', function (event) { + self.updateHighlightIndex(-1, event); + }); + + key('right', 'tags', function (event) { + self.updateHighlightIndex(1, event); + }); + }, + + unbindKeys: function () { + key.unbind('enter, tab', 'tags'); + key.unbind('backspace', 'tags'); + key.unbind('left', 'tags'); + key.unbind('right', 'tags'); + }, + + didInsertElement: function () { + this.bindKeys(); + }, + + willDestroyElement: function () { + this.unbindKeys(); + this.destroyTypeahead(); + }, + + updateHighlightIndex: function (modifier, event) { + if (this.$('#tag-input').val() === '') { + var highlightIndex = this.get('highlightIndex'), + length = this.get('currentTags.length'), + newIndex; + + if (event) { + event.preventDefault(); + } + + if (highlightIndex === null) { + newIndex = (modifier > 0) ? 0 : length - 1; + } else { + newIndex = highlightIndex + modifier; + if (newIndex < 0 || newIndex >= length) { + newIndex = null; + } + } + this.set('highlightIndex', newIndex); + } + }, + + // Typeahead functions + initTypeahead: function () { + var tags = new Bloodhound({ + datumTokenizer: Bloodhound.tokenizers.whitespace, + queryTokenizer: Bloodhound.tokenizers.whitespace, + local: this.get('unassignedTags').map(function (tag) { + return tag.get('name'); + }) + }); + + this.$('#tag-input').typeahead({ + minLength: 1, + classNames: { + // TODO: Fix CSS for these + input: 'tag-input', + hint: 'tag-input', + menu: 'dropdown-menu', + suggestion: 'dropdown-item', + open: 'open' + } + }, { + name: 'tags', + source: tags + }).bind('typeahead:selected', Ember.run.bind(this, 'typeaheadAdd')); + }, + + destroyTypeahead: function () { + this.$('#tag-input').typeahead('destroy'); + }, + + reloadTypeahead: function (refocus) { + this.set('isReloading', true); + this.destroyTypeahead(); + this.initTypeahead(); + if (refocus) { + this.click(); + } + this.set('isReloading', false); + }, + + // Tag Saving / Tag Add/Delete Actions + saveTags: function () { + var post = this.get('post'); + + if (post && this.get('isDirty') && !this.get('isReloading')) { + post.get('tags').clear(); + post.get('tags').pushObjects(this.get('currentTags').toArray()); + this.set('isDirty', false); + } + }, + + // Used for typeahead selection + typeaheadAdd: function (obj, datum) { + if (datum) { + // this is needed so two tags with the same name aren't added + this.$('#tag-input').typeahead('val', ''); + this.send('addTag', datum); + } + }, + + actions: { + addTag: function (tagName) { + var tagToAdd, checkTag; + + // Prevent multiple tags with the same name occuring + if (this.get('currentTags').findBy('name', tagName)) { + this.$('#tag-input').typeahead('val', ''); + return; + } + + checkTag = this.get('unassignedTags').findBy('name', tagName); + + if (checkTag) { + tagToAdd = checkTag; + this.get('unassignedTags').removeObject(checkTag); + this.reloadTypeahead(); + } else { + tagToAdd = this.get('store').createRecord('tag', {name: tagName}); + } + + this.set('isDirty', true); + this.set('highlightIndex', null); + this.get('currentTags').pushObject(tagToAdd); + this.$('#tag-input').typeahead('val', ''); + }, + + deleteTag: function (tag) { + var removedTag; + + if (tag) { + removedTag = this.get('currentTags').findBy('name', tag); + this.get('currentTags').removeObject(removedTag); + } else { + if (this.get('highlightIndex') !== null) { + removedTag = this.get('currentTags').objectAt(this.get('highlightIndex')); + this.get('currentTags').removeObject(removedTag); + this.set('highlightIndex', null); + } else { + this.set('highlightIndex', this.get('currentTags.length') - 1); + } + } + + if (removedTag) { + if (removedTag.get('isNew')) { // if tag is new, don't change isDirty, + removedTag.deleteRecord(); // and delete the new record + } else { + this.set('isDirty', true); + this.get('unassignedTags').pushObject(removedTag); + this.reloadTypeahead(); + } + } + } + } +}); diff --git a/core/client/app/controllers/post-tags-input.js b/core/client/app/controllers/post-tags-input.js deleted file mode 100644 index 548f0ca1dd8..00000000000 --- a/core/client/app/controllers/post-tags-input.js +++ /dev/null @@ -1,248 +0,0 @@ -import Ember from 'ember'; - -// should be integrated into tag input component during reimplementation - -export default Ember.Controller.extend({ - tagEnteredOrder: Ember.A(), - - tags: Ember.computed('parentController.model.tags', function () { - var proxyTags = Ember.ArrayProxy.create({ - content: this.get('parentController.model.tags') - }), - temp = proxyTags.get('arrangedContent').slice(); - - proxyTags.get('arrangedContent').clear(); - - this.get('tagEnteredOrder').forEach(function (tagName) { - var tag = temp.find(function (tag) { - return tag.get('name') === tagName; - }); - - if (tag) { - proxyTags.get('arrangedContent').addObject(tag); - temp.removeObject(tag); - } - }); - - proxyTags.get('arrangedContent').unshiftObjects(temp); - - return proxyTags; - }), - - suggestions: null, - newTagText: null, - - actions: { - // triggered when the view is inserted so that later store.all('tag') - // queries hit a full store cache and we don't see empty or out-of-date - // suggestion lists - loadAllTags: function () { - this.store.find('tag', {limit: 'all'}); - }, - - addNewTag: function () { - var newTagText = this.get('newTagText'), - searchTerm, - existingTags, - newTag; - - if (Ember.isEmpty(newTagText) || this.hasTag(newTagText)) { - this.send('reset'); - return; - } - - newTagText = newTagText.trim(); - searchTerm = newTagText.toLowerCase(); - - // add existing tag if we have a match - existingTags = this.store.all('tag').filter(function (tag) { - if (tag.get('isNew')) { - return false; - } - - return tag.get('name').toLowerCase() === searchTerm; - }); - - if (existingTags.get('length')) { - this.send('addTag', existingTags.get('firstObject')); - } else { - // otherwise create a new one - newTag = this.store.createRecord('tag'); - newTag.set('name', newTagText); - - this.send('addTag', newTag); - } - - this.send('reset'); - }, - - addTag: function (tag) { - if (!Ember.isEmpty(tag)) { - this.get('tags').addObject(tag); - this.get('tagEnteredOrder').addObject(tag.get('name')); - } - - this.send('reset'); - }, - - deleteTag: function (tag) { - if (tag) { - this.get('tags').removeObject(tag); - this.get('tagEnteredOrder').removeObject(tag.get('name')); - } - }, - - deleteLastTag: function () { - this.send('deleteTag', this.get('tags.lastObject')); - }, - - selectSuggestion: function (suggestion) { - if (!Ember.isEmpty(suggestion)) { - this.get('suggestions').setEach('selected', false); - suggestion.set('selected', true); - } - }, - - selectNextSuggestion: function () { - var suggestions = this.get('suggestions'), - selectedSuggestion = this.get('selectedSuggestion'), - currentIndex, - newSelection; - - if (!Ember.isEmpty(suggestions)) { - currentIndex = suggestions.indexOf(selectedSuggestion); - if (currentIndex + 1 < suggestions.get('length')) { - newSelection = suggestions[currentIndex + 1]; - this.send('selectSuggestion', newSelection); - } else { - suggestions.setEach('selected', false); - } - } - }, - - selectPreviousSuggestion: function () { - var suggestions = this.get('suggestions'), - selectedSuggestion = this.get('selectedSuggestion'), - currentIndex, - lastIndex, - newSelection; - - if (!Ember.isEmpty(suggestions)) { - currentIndex = suggestions.indexOf(selectedSuggestion); - if (currentIndex === -1) { - lastIndex = suggestions.get('length') - 1; - this.send('selectSuggestion', suggestions[lastIndex]); - } else if (currentIndex - 1 >= 0) { - newSelection = suggestions[currentIndex - 1]; - this.send('selectSuggestion', newSelection); - } else { - suggestions.setEach('selected', false); - } - } - }, - - addSelectedSuggestion: function () { - var suggestion = this.get('selectedSuggestion'); - - if (Ember.isEmpty(suggestion)) { - return; - } - - this.send('addTag', suggestion.get('tag')); - }, - - reset: function () { - this.set('suggestions', null); - this.set('newTagText', null); - } - }, - - selectedSuggestion: Ember.computed('suggestions.@each.selected', function () { - var suggestions = this.get('suggestions'); - - if (suggestions && suggestions.get('length')) { - return suggestions.filterBy('selected').get('firstObject'); - } else { - return null; - } - }), - - updateSuggestionsList: Ember.observer('newTagText', function () { - var searchTerm = this.get('newTagText'), - matchingTags, - // Limit the suggestions number - maxSuggestions = 5, - suggestions = Ember.A(); - - if (!searchTerm || Ember.isEmpty(searchTerm.trim())) { - this.set('suggestions', null); - return; - } - - searchTerm = searchTerm.trim(); - - matchingTags = this.findMatchingTags(searchTerm); - matchingTags = matchingTags.slice(0, maxSuggestions); - matchingTags.forEach(function (matchingTag) { - var suggestion = this.makeSuggestionObject(matchingTag, searchTerm); - suggestions.pushObject(suggestion); - }, this); - - this.set('suggestions', suggestions); - }), - - findMatchingTags: function (searchTerm) { - var matchingTags, - self = this, - allTags = this.store.all('tag').filterBy('isNew', false), - deDupe = {}; - - if (allTags.get('length') === 0) { - return []; - } - - searchTerm = searchTerm.toLowerCase(); - - matchingTags = allTags.filter(function (tag) { - var tagNameMatches, - hasAlreadyBeenAdded, - tagName = tag.get('name'); - - tagNameMatches = tagName.toLowerCase().indexOf(searchTerm) !== -1; - hasAlreadyBeenAdded = self.hasTag(tagName); - - if (tagNameMatches && !hasAlreadyBeenAdded) { - if (typeof deDupe[tagName] === 'undefined') { - deDupe[tagName] = 1; - } else { - deDupe[tagName] += 1; - } - } - - return deDupe[tagName] === 1; - }); - - return matchingTags; - }, - - hasTag: function (tagName) { - return this.get('tags').mapBy('name').contains(tagName); - }, - - makeSuggestionObject: function (matchingTag, _searchTerm) { - var searchTerm = Ember.Handlebars.Utils.escapeExpression(_searchTerm), - regexEscapedSearchTerm = searchTerm.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, '\\$&'), - tagName = Ember.Handlebars.Utils.escapeExpression(matchingTag.get('name')), - regex = new RegExp('(' + regexEscapedSearchTerm + ')', 'gi'), - highlightedName, - suggestion = Ember.Object.create(); - - highlightedName = tagName.replace(regex, '$1'); - highlightedName = Ember.String.htmlSafe(highlightedName); - - suggestion.set('tag', matchingTag); - suggestion.set('highlightedName', highlightedName); - - return suggestion; - } -}); diff --git a/core/client/app/styles/layouts/editor.css b/core/client/app/styles/layouts/editor.css index 3f25e3c5bb6..4c82eaf1899 100644 --- a/core/client/app/styles/layouts/editor.css +++ b/core/client/app/styles/layouts/editor.css @@ -249,7 +249,48 @@ display: none; } -#entry-tags input[type="text"].tag-input { +/* Tags input CSS (TODO: needs some revision) +/* ------------------------------------------------------ */ +.tags-input-list { + display: flex; + flex-wrap: wrap; + margin: 0; + padding: 0; + list-style-type: none; +} + +.tags-input-list li { + flex: 1 0 auto; +} + +.label-tag { + margin-right: 0.3em; + padding: 0.2em 0.6em 0.3em; + background-color: var(--darkgrey); + border-radius: 0.25em; + color: var(--lightgrey); + text-align: center; + font-weight: 300; +} + +.label-tag.highlight { + background: var(--midgrey); + color: #fff; +} + +.tag-input { + margin-top: 5px; + border: none; + font-weight: 300; + cursor: default; +} + +.tag-input:focus { + outline: 0; +} + +/* TODO: can be removed once tag-component css is fixed */ +/*#entry-tags input[type="text"].tag-input { display: inline-block; padding: 9px 9px 9px 0; width: 100%; @@ -410,7 +451,7 @@ position: relative; flex: 1 1 auto; align-self: auto; -} +} */ .publish-bar-actions { flex: 1 0 auto; diff --git a/core/client/app/styles/patterns/forms.css b/core/client/app/styles/patterns/forms.css index 969454068fd..568ee18341c 100644 --- a/core/client/app/styles/patterns/forms.css +++ b/core/client/app/styles/patterns/forms.css @@ -125,6 +125,7 @@ select.error { } .gh-input:focus, +.gh-input.focus, .gh-select:focus, select:focus { outline: 0; diff --git a/core/client/app/templates/components/gh-post-tags-input.hbs b/core/client/app/templates/components/gh-post-tags-input.hbs deleted file mode 100644 index fdf3b46c3a1..00000000000 --- a/core/client/app/templates/components/gh-post-tags-input.hbs +++ /dev/null @@ -1,23 +0,0 @@ -
- - diff --git a/core/client/app/templates/components/gh-tags-input.hbs b/core/client/app/templates/components/gh-tags-input.hbs new file mode 100644 index 00000000000..678214802df --- /dev/null +++ b/core/client/app/templates/components/gh-tags-input.hbs @@ -0,0 +1,6 @@ + diff --git a/core/client/app/templates/post-settings-menu.hbs b/core/client/app/templates/post-settings-menu.hbs index 2eea3dbc17c..2f9d9dd5ca2 100644 --- a/core/client/app/templates/post-settings-menu.hbs +++ b/core/client/app/templates/post-settings-menu.hbs @@ -33,6 +33,11 @@ +