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:
parent
73643f6faf
commit
d2663b668a
6 changed files with 275 additions and 194 deletions
|
@ -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 {
|
||||
|
|
|
@ -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 @@
|
|||
}
|
||||
});
|
||||
|
||||
}());
|
||||
}());
|
||||
|
|
8
ghost/admin/models/tag.js
Normal file
8
ghost/admin/models/tag.js
Normal file
|
@ -0,0 +1,8 @@
|
|||
/*global window, document, Ghost, $, _, Backbone */
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
Ghost.Collections.Tags = Backbone.Collection.extend({
|
||||
url: Ghost.settings.apiRoot + '/tags'
|
||||
});
|
||||
}());
|
|
@ -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);
|
||||
|
||||
});
|
||||
|
||||
}());
|
238
ghost/admin/views/editor-tag-widget.js
Normal file
238
ghost/admin/views/editor-tag-widget.js
Normal 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();
|
||||
}
|
||||
});
|
||||
|
||||
}());
|
|
@ -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 () {
|
||||
|
|
Loading…
Add table
Reference in a new issue