Merge pull request #3005 from kevinansfield/ensure-incomplete-tags-arent-lost

Ensure incomplete tags aren't lost on save
This commit is contained in:
Hannah Wolfe 2014-06-24 13:19:22 +01:00
commit 67046f9cd6
6 changed files with 358 additions and 246 deletions

View File

@ -0,0 +1,201 @@
var PostTagsInputController = Ember.Controller.extend({
tags: Ember.computed.alias('parentController.tags'),
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');
},
addNewTag: function () {
var newTagText = this.get('newTagText'),
searchTerm,
existingTags,
newTag;
if (Ember.isEmpty(newTagText) || this.hasTag(newTagText)) {
this.send('reset');
return;
}
searchTerm = newTagText.toLowerCase();
// add existing tag if we have a match
existingTags = this.store.all('tag').filter(function (tag) {
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.get('tags').pushObject(newTag);
}
this.send('reset');
},
addTag: function (tag) {
if (!Ember.isEmpty(tag) && !this.hasTag(tag.get('name'))) {
this.get('tags').pushObject(tag);
}
this.send('reset');
},
deleteTag: function (tag) {
this.get('tags').removeObject(tag);
},
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: function () {
var suggestions = this.get('suggestions');
if (suggestions && suggestions.get('length')) {
return suggestions.filterBy('selected').get('firstObject');
} else {
return null;
}
}.property('suggestions.@each.selected'),
updateSuggestionsList: function () {
var searchTerm = this.get('newTagText'),
matchingTags,
// Limit the suggestions number
maxSuggestions = 5,
suggestions = new 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);
}.observes('newTagText'),
findMatchingTags: function (searchTerm) {
var matchingTags,
self = this,
allTags = this.store.all('tag');
if (allTags.get('length') === 0) {
return [];
}
searchTerm = searchTerm.toLowerCase();
matchingTags = allTags.filter(function (tag) {
var tagNameMatches,
hasAlreadyBeenAdded;
tagNameMatches = tag.get('name').toLowerCase().indexOf(searchTerm) !== -1;
hasAlreadyBeenAdded = self.hasTag(tag.get('name'));
return tagNameMatches && !hasAlreadyBeenAdded;
});
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 = new Ember.Object();
highlightedName = tagName.replace(regex, '<mark>$1</mark>');
highlightedName = new Ember.Handlebars.SafeString(highlightedName);
suggestion.set('tag', matchingTag);
suggestion.set('highlightedName', highlightedName);
return suggestion;
},
});
export default PostTagsInputController;

View File

@ -15,6 +15,9 @@ Ember.get(PostModel, 'attributes').forEach(function (name) {
watchedProps.push('tags.[]');
var EditorControllerMixin = Ember.Mixin.create(MarkerManager, {
needs: ['post-tags-input'],
init: function () {
var self = this;
@ -120,6 +123,9 @@ var EditorControllerMixin = Ember.Mixin.create(MarkerManager, {
var status = this.get('willPublish') ? 'published' : 'draft',
self = this;
// ensure an incomplete tag is finalised before save
this.get('controllers.post-tags-input').send('addNewTag');
// set markdown equal to what's in the editor, minus the image markers.
this.set('markdown', this.getMarkdown().withoutMarkers);

View File

@ -1,6 +1,6 @@
<footer id="publish-bar">
<nav>
{{view "editor-tags" tagName="section" id="entry-tags" class="left"}}
{{render 'post-tags-input'}}
<div class="right">

View File

@ -1,9 +1,13 @@
<label class="tag-label" for="tags" title="Tags"><span class="hidden">Tags</span></label>
<div class="tags">
{{#each tags}}
<span class="tag" {{action 'tagClick' this target="view"}}>{{name}}</span>
{{view view.tagView tag=this}}
{{/each}}
</div>
<input type="hidden" class="tags-holder" id="tags-holder">
{{input type="text" id="tags" class="tag-input" value=view.input}}
<ul class="suggestions overlay" {{bind-attr style=view.overlayStyle}}></ul>
{{view view.tagInputView class="tag-input" id="tags" value=newTagText}}
<ul class="suggestions overlay" {{bind-attr style=view.overlayStyles}}>
{{#each suggestions}}
{{view view.suggestionView suggestion=this}}
{{/each}}
</ul>

View File

@ -1,242 +0,0 @@
var EditorTags = Ember.View.extend({
templateName: 'editor-tags',
didInsertElement: function () {
// Cache elements for later use
this.$input = this.$('#tags');
this.$suggestions = this.$('ul.suggestions');
},
willDestroyElement: function () {
// Release ownership of the object for proper GC
this.$input = null;
this.$suggestions = null;
},
keys: {
UP: 38,
DOWN: 40,
ESC: 27,
ENTER: 13,
BACKSPACE: 8
},
overlay: {
visible: false,
left: 0
},
overlayStyle: function () {
var styles = [];
styles.push(this.get('overlay.visible') ?
'display: block' :
'display: none'
);
styles.push(this.get('overlay.left') ?
'left: ' + this.get('overlay.left') + 'px' :
'left: 0'
);
return styles.join(';');
}.property('overlay.visible'),
showSuggestions: function (_searchTerm) {
var searchTerm = _searchTerm.toLowerCase(),
matchingTags = this.findMatchingTags(searchTerm),
// Limit the suggestions number
maxSuggestions = 5,
// Escape regex special characters
escapedTerm = searchTerm.replace(/[\-\/\\\^$*+?.()|\[\]{}]/g, '\\$&'),
regexTerm = escapedTerm.replace(/(\s+)/g, '(<[^>]+>)*$1(<[^>]+>)*'),
regexPattern = new RegExp('(' + regexTerm + ')', 'i'),
highlightedNameRegex;
this.set('overlay.left', this.$input.position().left);
this.$suggestions.html('');
matchingTags = matchingTags.slice(0, maxSuggestions);
if (matchingTags.length > 0) {
this.set('overlay.visible', true);
}
highlightedNameRegex = /(<mark>[^<>]*)((<[^>]+>)+)([^<>]*<\/mark>)/;
matchingTags.forEach(function (matchingTag) {
var highlightedName,
suggestionHTML;
highlightedName = matchingTag.get('name').replace(regexPattern, function (match, p1) {
return '<mark>' + encodeURIComponent(p1) + '</mark>';
});
/*jslint regexp: true */ // - would like to remove this
highlightedName = highlightedName.replace(highlightedNameRegex, function (match, p1, p2, p3, p4) {
return encodeURIComponent(p1) + '</mark>' + encodeURIComponent(p2) + '<mark>' + encodeURIComponent(p4);
});
suggestionHTML = '<li data-tag-id="' + matchingTag.get('id') +
'" data-tag-name="' + encodeURIComponent(matchingTag.get('name')) +
'"><a href="#">' + highlightedName + '</a></li>';
this.$suggestions.append(suggestionHTML);
}, this);
},
findMatchingTags: function (searchTerm) {
var matchingTagModels,
self = this,
allTags = this.get('controller.store').all('tag');
if (allTags.get('length') === 0) {
return [];
}
searchTerm = searchTerm.toUpperCase();
matchingTagModels = allTags.filter(function (tag) {
var tagNameMatches,
hasAlreadyBeenAdded;
tagNameMatches = tag.get('name').toUpperCase().indexOf(searchTerm) !== -1;
hasAlreadyBeenAdded = self.hasTagBeenAdded(tag.name);
return tagNameMatches && !hasAlreadyBeenAdded;
});
return matchingTagModels;
},
keyDown: function (e) {
var lastTagIndex;
// Delete character tiggers on Keydown, so needed to check on that event rather than Keyup.
if (e.keyCode === this.keys.BACKSPACE && !this.get('input')) {
lastTagIndex = this.get('controller.model.tags').get('length') - 1;
if (lastTagIndex > -1) {
this.get('controller.model.tags').removeAt(lastTagIndex);
}
}
},
keyUp: function (e) {
var searchTerm = $.trim(this.get('input'));
if (e.keyCode === this.keys.UP) {
e.preventDefault();
if (this.get('overlay.visible')) {
if (this.$suggestions.children('.selected').length === 0) {
this.$suggestions.find('li:last-child').addClass('selected');
} else {
this.$suggestions.children('.selected').removeClass('selected').prev().addClass('selected');
}
}
} else if (e.keyCode === this.keys.DOWN) {
e.preventDefault();
if (this.get('overlay.visible')) {
if (this.$suggestions.children('.selected').length === 0) {
this.$suggestions.find('li:first-child').addClass('selected');
} else {
this.$suggestions.children('.selected').removeClass('selected').next().addClass('selected');
}
}
} else if (e.keyCode === this.keys.ESC) {
this.set('overlay.visible', false);
} else {
if (searchTerm) {
this.showSuggestions(searchTerm);
} else {
this.set('overlay.visible', false);
}
}
if (e.keyCode === this.keys.UP || e.keyCode === this.keys.DOWN) {
return false;
}
},
keyPress: function (e) {
var searchTerm = $.trim(this.get('input')),
tag,
$selectedSuggestion,
isComma = ','.localeCompare(String.fromCharCode(e.keyCode || e.charCode)) === 0,
hasAlreadyBeenAdded;
// use localeCompare in case of international keyboard layout
if ((e.keyCode === this.keys.ENTER || isComma) && searchTerm) {
// Submit tag using enter or comma key
e.preventDefault();
$selectedSuggestion = this.$suggestions.children('.selected');
if (this.get('overlay.visible') && $selectedSuggestion.length !== 0) {
tag = {
id: $selectedSuggestion.data('tag-id'),
name: decodeURIComponent($selectedSuggestion.data('tag-name'))
};
hasAlreadyBeenAdded = this.hasTagBeenAdded(tag.name);
if (!hasAlreadyBeenAdded) {
this.addTag(tag);
}
} else {
if (isComma) {
// Remove comma from string if comma is used to submit.
searchTerm = searchTerm.replace(/,/g, '');
}
hasAlreadyBeenAdded = this.hasTagBeenAdded(searchTerm);
if (!hasAlreadyBeenAdded) {
this.addTag({id: null, name: searchTerm});
}
}
this.set('input', '');
this.$input.focus();
this.set('overlay.visible', false);
}
},
addTag: function (tag) {
var allTags = this.get('controller.store').all('tag'),
newTag = allTags.findBy('name', tag.name);
if (!newTag) {
newTag = this.get('controller.store').createRecord('tag', tag);
}
this.get('controller.model.tags').addObject(newTag);
// Wait till Ember render's the new tag to access its dom element.
Ember.run.schedule('afterRender', this, function () {
this.$('.tag').last()[0].scrollIntoView(true);
window.scrollTo(0, 1);
this.set('input', '');
this.$input.focus();
this.set('overlay.visible', false);
});
},
hasTagBeenAdded: function (tagName) {
if (!tagName) {
return false;
}
return this.get('controller.model.tags').filter(function (usedTag) {
return usedTag.get('name').toUpperCase() === tagName.toUpperCase();
}).length > 0;
},
actions: {
tagClick: function (tag) {
this.get('controller.model.tags').removeObject(tag);
window.scrollTo(0, 1);
},
}
});
export default EditorTags;

View File

@ -0,0 +1,143 @@
var PostTagsInputView = Ember.View.extend({
tagName: 'section',
elementId: 'entry-tags',
classNames: 'left',
templateName: 'post-tags-input',
hasFocus: false,
keys: {
BACKSPACE: 8,
TAB: 9,
ENTER: 13,
ESCAPE: 27,
UP: 38,
DOWN: 40,
NUMPAD_ENTER: 108,
COMMA: 188
},
didInsertElement: function () {
this.get('controller').send('loadAllTags');
},
overlayStyles: 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(';');
}.property('hasFocus', 'controller.suggestions.length'),
tagInputView: Ember.TextField.extend({
focusIn: function () {
this.get('parentView').set('hasFocus', true);
},
focusOut: function () {
this.get('parentView').set('hasFocus', false);
// if (!Ember.isEmpty(this.get('value'))) {
// this.get('parentView.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:
case keys.COMMA:
if (event.keyCode === keys.COMMA && event.shiftKey) {
break;
}
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;
}
}
}),
tagView: Ember.View.extend({
tagName: 'span',
template: Ember.Handlebars.compile('{{view.tag.name}}'),
classNames: 'tag',
tag: null,
click: function () {
this.get('parentView.controller').send('deleteTag', this.get('tag'));
}
}),
suggestionView: Ember.View.extend({
tagName: 'li',
template: Ember.Handlebars.compile('<a href="javascript:void(0);">{{view.suggestion.highlightedName}}</a>'),
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'));
},
})
});
export default PostTagsInputView;