0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-03-11 02:12:21 -05:00

Add post tagging functionality

closes #367
closes #368

- Adds Tag model with a many-to-many relationship with Post
- Adds Tag API to retrieve all previously used Tags (needed for suggestions)
- Allows setting and retrieval of Tags for a post through the Post's existing API endpoints.
- Hooks up the editor's tag suggestion box to the Ghost install's previously used tags
- Tidies the client code for adding tags, and encapsulates the functionality into a Backbone view
This commit is contained in:
Adam Howard 2013-08-21 13:55:58 +01:00
parent 73643f6faf
commit d2663b668a
6 changed files with 275 additions and 194 deletions

View file

@ -452,7 +452,7 @@ body.zen {
}
}
#entry-categories {
#entry-tags {
position: absolute;
top: 0;
left: 0;
@ -462,7 +462,7 @@ body.zen {
padding: 10px 0 0 0;
}
.category-label {
.tag-label {
display: block;
float: left;
@include icon($i-tag);
@ -475,7 +475,7 @@ body.zen {
}
}
.category-input {
.tag-input {
display: inline-block;
color: $lightgrey;
font-weight: 300;
@ -485,7 +485,7 @@ body.zen {
&:focus {outline: none;}
}
.category {
.tag {
@include icon-after($i-x, 8px, $darkgrey) {
margin-left: 4px;
vertical-align: 5%;
@ -503,6 +503,8 @@ body.zen {
rgba(255,255,255,0.2) 0 1px 0 inset,
#000 0 1px 3px;
@include user-select(none);
&:hover {
cursor: pointer;
@include icon-after($i-x, 8px, $lightgrey) {text-shadow: none;}
@ -521,11 +523,16 @@ body.zen {
rgba(0,0,0,0.5) 0 1px 5px;
}
li a {
padding-left: 25px;
}
mark{
background: none;
color: white;
font-weight: bold;
}
}
#entry-settings {

View file

@ -26,6 +26,20 @@
if (_.isEmpty(attrs.title)) {
return 'You must specify a title for the post.';
}
},
addTag: function (tagToAdd) {
var tags = this.get('tags') || [];
tags.push(tagToAdd);
this.set('tags', tags);
},
removeTag: function (tagToRemove) {
var tags = this.get('tags') || [];
tags = _.reject(tags, function (tag) {
return tag.id === tagToRemove.id || tag.name === tagToRemove.name;
});
this.set('tags', tags);
}
});
@ -46,4 +60,4 @@
}
});
}());
}());

View file

@ -0,0 +1,8 @@
/*global window, document, Ghost, $, _, Backbone */
(function () {
"use strict";
Ghost.Collections.Tags = Backbone.Collection.extend({
url: Ghost.settings.apiRoot + '/tags'
});
}());

View file

@ -1,181 +0,0 @@
// ## Tag Selector UI
/*global window, document, $ */
(function () {
"use strict";
var suggestions,
categoryOffset,
existingTags = [], // This will be replaced by an API return.
keys = {
UP: 38,
DOWN: 40,
ESC: 27,
ENTER: 13,
COMMA: 188,
BACKSPACE: 8
};
function findTerms(searchTerm, array) {
searchTerm = searchTerm.toUpperCase();
return $.map(array, function (item) {
var match = item.toUpperCase().indexOf(searchTerm) !== -1;
return match ? item : null;
});
}
function showSuggestions($target, searchTerm) {
suggestions.show();
var results = findTerms(searchTerm, existingTags),
pos = $target.position(),
styles = {
left: pos.left
},
maxSuggestions = 5, // Limit the suggestions number
results_length = results.length,
i,
suggest;
suggestions.css(styles);
suggestions.html("");
if (results_length < maxSuggestions) {
maxSuggestions = results_length;
}
for (i = 0; i < maxSuggestions; i += 1) {
suggestions.append("<li><a href='#'>" + results[i] + "</a></li>");
}
suggest = $('ul.suggestions li a:contains("' + searchTerm + '")');
suggest.each(function () {
var src_str = $(this).html(),
term = searchTerm,
pattern;
term = term.replace(/(\s+)/, "(<[^>]+>)*$1(<[^>]+>)*");
pattern = new RegExp("(" + term + ")", "i");
src_str = src_str.replace(pattern, "<mark>$1</mark>");
/*jslint regexp: true */ // - would like to remove this
src_str = src_str.replace(/(<mark>[^<>]*)((<[^>]+>)+)([^<>]*<\/mark>)/, "$1</mark>$2<mark>$4");
$(this).html(src_str);
});
}
function handleTagKeyup(e) {
var $target = $(e.currentTarget),
searchTerm = $.trim($target.val()).toLowerCase(),
category,
populator;
if (e.keyCode === keys.UP) {
e.preventDefault();
if (suggestions.is(":visible")) {
if (suggestions.children(".selected").length === 0) {
suggestions.find("li:last-child").addClass('selected');
} else {
suggestions.children(".selected").removeClass('selected').prev().addClass('selected');
}
}
} else if (e.keyCode === keys.DOWN) {
e.preventDefault();
if (suggestions.is(":visible")) {
if (suggestions.children(".selected").length === 0) {
suggestions.find("li:first-child").addClass('selected');
} else {
suggestions.children(".selected").removeClass('selected').next().addClass('selected');
}
}
} else if (e.keyCode === keys.ESC) {
suggestions.hide();
} else if ((e.keyCode === keys.ENTER || e.keyCode === keys.COMMA) && searchTerm) {
// Submit tag using enter or comma key
e.preventDefault();
if (suggestions.is(":visible") && suggestions.children(".selected").length !== 0) {
if ($('.category:containsExact("' + suggestions.children(".selected").text() + '")').length === 0) {
category = $('<span class="category">' + suggestions.children(".selected").text() + '</span>');
if ($target.data('populate')) {
populator = $($target.data('populate'));
populator.append(category);
}
}
suggestions.hide();
} else {
if (e.keyCode === keys.COMMA) {
searchTerm = searchTerm.replace(",", "");
} // Remove comma from string if comma is uses to submit.
if ($('.category:containsExact("' + searchTerm + '")').length === 0) {
category = $('<span class="category">' + searchTerm + '</span>');
if ($target.data('populate')) {
populator = $($target.data('populate'));
populator.append(category);
}
}
}
$target.val('').focus();
searchTerm = ""; // Used to reset search term
suggestions.hide();
}
if (e.keyCode === keys.UP || e.keyCode === keys.DOWN) {
return false;
}
if (searchTerm) {
showSuggestions($target, searchTerm);
} else {
suggestions.hide();
}
}
function handleTagKeyDown(e) {
var $target = $(e.currentTarget),
populator,
lastBlock;
// Delete character tiggers on Keydown, so needed to check on that event rather than Keyup.
if (e.keyCode === keys.BACKSPACE && !$target.val()) {
populator = $($target.data('populate'));
lastBlock = populator.find('.category').last();
lastBlock.remove();
}
}
function handleSuggestionClick(e) {
var $target = $(e.currentTarget),
category = $('<span class="category">' + $(e.currentTarget).text() + '</span>'),
populator;
if ($target.parent().data('populate')) {
populator = $($target.parent().data('populate'));
populator.append(category);
suggestions.hide();
$('[data-input-behaviour="tag"]').val('').focus();
}
}
function handleCategoryClick(e) {
$(e.currentTarget).remove();
}
$(document).ready(function () {
suggestions = $("ul.suggestions").hide(); // Initnialise suggestions overlay
if ($('.category-input').length) {
categoryOffset = $('.category-input').offset().left;
$('.category-blocks').css({'left': categoryOffset + 'px'});
}
$('[data-input-behaviour="tag"]')
.on('keyup', handleTagKeyup)
.on('keydown', handleTagKeyDown);
$('ul.suggestions').on('click', "li", handleSuggestionClick);
$('.categories').on('click', ".category", handleCategoryClick);
});
}());

View file

@ -0,0 +1,238 @@
// The Tag UI area associated with a post
/*global window, document, $, _, Backbone, Ghost */
(function () {
"use strict";
Ghost.View.EditorTagWidget = Ghost.View.extend({
events: {
'keyup [data-input-behaviour="tag"]': 'handleKeyup',
'keydown [data-input-behaviour="tag"]': 'handleKeydown',
'click ul.suggestions li': 'handleSuggestionClick',
'click .tags .tag': 'handleTagClick'
},
keys: {
UP: 38,
DOWN: 40,
ESC: 27,
ENTER: 13,
COMMA: 188,
BACKSPACE: 8
},
initialize: function () {
var self = this,
tagCollection = new Ghost.Collections.Tags();
tagCollection.fetch().then(function () {
self.allGhostTags = tagCollection.toJSON();
});
this.model.on('willSave', this.completeCurrentTag, this);
},
render: function () {
var tags = this.model.get('tags'),
$tags = $('.tags'),
tagOffset;
$tags.empty();
if (tags) {
_.forEach(tags, function (tag) {
var $tag = $('<span class="tag" data-tag-id="' + tag.id + '">' + tag.name + '</span>');
$tags.append($tag);
});
}
this.$suggestions = $("ul.suggestions").hide(); // Initialise suggestions overlay
if ($tags.length) {
tagOffset = $('.tag-input').offset().left;
$('.tag-blocks').css({'left': tagOffset + 'px'});
}
return this;
},
showSuggestions: function ($target, searchTerm) {
this.$suggestions.show();
var results = this.findMatchingTags(searchTerm),
styles = {
left: $target.position().left
},
maxSuggestions = 5, // Limit the suggestions number
results_length = results.length,
i,
suggest;
this.$suggestions.css(styles);
this.$suggestions.html("");
if (results_length < maxSuggestions) {
maxSuggestions = results_length;
}
for (i = 0; i < maxSuggestions; i += 1) {
this.$suggestions.append("<li data-tag-id='" + results[i].id + "' data-tag-name='" + results[i].name + "'><a href='#'>" + results[i].name + "</a></li>");
}
suggest = $('ul.suggestions li a:contains("' + searchTerm + '")');
suggest.each(function () {
var src_str = $(this).html(),
term = searchTerm,
pattern;
term = term.replace(/(\s+)/, "(<[^>]+>)*$1(<[^>]+>)*");
pattern = new RegExp("(" + term + ")", "i");
src_str = src_str.replace(pattern, "<mark>$1</mark>");
/*jslint regexp: true */ // - would like to remove this
src_str = src_str.replace(/(<mark>[^<>]*)((<[^>]+>)+)([^<>]*<\/mark>)/, "$1</mark>$2<mark>$4");
$(this).html(src_str);
});
},
handleKeyup: function (e) {
var $target = $(e.currentTarget),
searchTerm = $.trim($target.val()).toLowerCase(),
tag,
$selectedSuggestion;
if (e.keyCode === this.keys.UP) {
e.preventDefault();
if (this.$suggestions.is(":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.$suggestions.is(":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.$suggestions.hide();
} else if ((e.keyCode === this.keys.ENTER || e.keyCode === this.keys.COMMA) && searchTerm) {
// Submit tag using enter or comma key
e.preventDefault();
$selectedSuggestion = this.$suggestions.children(".selected");
if (this.$suggestions.is(":visible") && $selectedSuggestion.length !== 0) {
if ($('.tag:containsExact("' + $selectedSuggestion.data('tag-name') + '")').length === 0) {
tag = {id: $selectedSuggestion.data('tag-id'), name: $selectedSuggestion.data('tag-name')};
this.addTag(tag);
}
} else {
if (e.keyCode === this.keys.COMMA) {
searchTerm = searchTerm.replace(/,/g, "");
} // Remove comma from string if comma is used to submit.
if ($('.tag:containsExact("' + searchTerm + '")').length === 0) {
this.addTag({id: null, name: searchTerm});
}
}
$target.val('').focus();
searchTerm = ""; // Used to reset search term
this.$suggestions.hide();
}
if (e.keyCode === this.keys.UP || e.keyCode === this.keys.DOWN) {
return false;
}
if (searchTerm) {
this.showSuggestions($target, searchTerm);
} else {
this.$suggestions.hide();
}
},
handleKeydown: function (e) {
var $target = $(e.currentTarget),
lastBlock,
tag;
// Delete character tiggers on Keydown, so needed to check on that event rather than Keyup.
if (e.keyCode === this.keys.BACKSPACE && !$target.val()) {
lastBlock = this.$('.tags').find('.tag').last();
lastBlock.remove();
tag = {id: lastBlock.data('tag-id'), name: lastBlock.text()};
this.model.removeTag(tag);
}
},
completeCurrentTag: function () {
var $target = this.$('.tag-input'),
tagName = $target.val(),
usedTagNames,
hasAlreadyBeenAdded;
usedTagNames = _.map(this.model.get('tags'), function (tag) {
return tag.name.toUpperCase();
});
hasAlreadyBeenAdded = usedTagNames.indexOf(tagName.toUpperCase()) !== -1;
if (tagName.length > 0 && !hasAlreadyBeenAdded) {
this.addTag({id: null, name: tagName});
}
},
handleSuggestionClick: function (e) {
var $target = $(e.currentTarget);
if (e) { e.preventDefault(); }
this.addTag({id: $target.data('tag-id'), name: $target.data('tag-name')});
},
handleTagClick: function (e) {
var $tag = $(e.currentTarget),
tag = {id: $tag.data('tag-id'), name: $tag.text()};
$tag.remove();
this.model.removeTag(tag);
},
findMatchingTags: function (searchTerm) {
var matchingTagModels,
self = this;
if (!this.allGhostTags) {
return [];
}
searchTerm = searchTerm.toUpperCase();
matchingTagModels = _.filter(this.allGhostTags, function (tag) {
var tagNameMatches,
hasAlreadyBeenAdded;
tagNameMatches = tag.name.toUpperCase().indexOf(searchTerm) !== -1;
hasAlreadyBeenAdded = _.some(self.model.get('tags'), function (usedTag) {
return tag.name.toUpperCase() === usedTag.name.toUpperCase();
});
return tagNameMatches && !hasAlreadyBeenAdded;
});
return matchingTagModels;
},
addTag: function (tag) {
var $tag = $('<span class="tag" data-tag-id="' + tag.id + '">' + tag.name + '</span>');
this.$('.tags').append($tag);
this.model.addTag(tag);
this.$('.tag-input').val('').focus();
this.$suggestions.hide();
}
});
}());

View file

@ -5,7 +5,6 @@
"use strict";
var PublishBar,
TagWidget,
ActionsWidget,
MarkdownShortcuts = [
{'key': 'Ctrl+B', 'style': 'bold'},
@ -40,7 +39,7 @@
PublishBar = Ghost.View.extend({
initialize: function () {
this.addSubview(new TagWidget({el: this.$('#entry-categories'), model: this.model})).render();
this.addSubview(new Ghost.View.EditorTagWidget({el: this.$('#entry-tags'), model: this.model})).render();
this.addSubview(new ActionsWidget({el: this.$('#entry-actions'), model: this.model})).render();
},
@ -48,12 +47,6 @@
});
// The Tag UI area associated with a post
// ----------------------------------------
TagWidget = Ghost.View.extend({
render: function () { return this; }
});
// The Publish, Queue, Publish Now buttons
// ----------------------------------------
ActionsWidget = Ghost.View.extend({
@ -167,6 +160,8 @@
});
}
this.model.trigger('willSave');
this.savePost({
status: status
}).then(function () {