jquery-comments
Version:
jQuery plugin for implementing an out-of-the-box commenting solution to any web application with an existing backend
1,289 lines (1,033 loc) • 53.9 kB
JavaScript
// jquery-comments.js 1.0.0
// (c) 2015 Joona Tykkyläinen, Viima Solutions Oy
// jquery-comments may be freely distributed under the MIT license.
// For all details and documentation:
// http://viima.github.io/jquery-comments/
(function($) {
var Comments = {
// Instance variables
// ==================
$el: null,
commentsById: {},
currentSortKey: '',
options: {
profilePictureURL: '',
// Font awesome icon overrides
spinnerIconURL: '',
upvoteIconURL: '',
replyIconURL: '',
noCommentsIconURL: '',
// Strings to be formatted (for example localization)
textareaPlaceholderText: 'Add a comment',
popularText: 'Popular',
newestText: 'Newest',
oldestText: 'Oldest',
sendText: 'Send',
replyText: 'Reply',
editText: 'Edit',
editedText: 'Edited',
youText: 'You',
saveText: 'Save',
deleteText: 'Delete',
viewAllRepliesText: 'View all __replyCount__ replies',
hideRepliesText: 'Hide replies',
noCommentsText: 'No comments',
textFormatter: function(text) {
return text;
},
// Functionalities
enableReplying: true,
enableEditing: true,
enableUpvoting: true,
enableDeleting: true,
enableDeletingCommentWithReplies: true,
// Colors
highlightColor: '#337AB7',
deleteButtonColor: '#C9302C',
roundProfilePictures: false,
textareaRows: 2,
textareaRowsOnFocus: 2,
textareaMaxRows: 5,
maxRepliesVisible: 2,
fieldMappings: {
id: 'id',
parent: 'parent',
created: 'created',
modified: 'modified',
content: 'content',
fullname: 'fullname',
profilePictureURL: 'profile_picture_url',
createdByAdmin: 'created_by_admin',
createdByCurrentUser: 'created_by_current_user',
upvoteCount: 'upvote_count',
userHasUpvoted: 'user_has_upvoted',
},
getComments: function(success, error) {success([])},
postComment: function(commentJSON, success, error) {success(commentJSON)},
putComment: function(commentJSON, success, error) {success(commentJSON)},
deleteComment: function(commentJSON, success, error) {success()},
upvoteComment: function(commentJSON, success, error) {success(commentJSON)},
refresh: function() {},
timeFormatter: function(time) {
return new Date(time).toLocaleDateString();
},
},
events: {
// Save comment on keydown
'keydown [contenteditable]' : 'saveOnKeydown',
// Listening changes in contenteditable fields (due to input event not working with IE)
'focus [contenteditable]' : 'saveEditableContent',
'keyup [contenteditable]' : 'checkEditableContentForChange',
'paste [contenteditable]' : 'checkEditableContentForChange',
'input [contenteditable]' : 'checkEditableContentForChange',
'blur [contenteditable]' : 'checkEditableContentForChange',
// Navigation
'click .navigation li' : 'navigationElementClicked',
// Main comenting field
'click .commenting-field.main .textarea': 'showMainCommentingField',
'click .commenting-field.main .close' : 'hideMainCommentingField',
// All commenting fields
'click .commenting-field .textarea' : 'increaseTextareaHeight',
'change .commenting-field .textarea' : 'increaseTextareaHeight textareaContentChanged',
'click .commenting-field:not(.main) .close' : 'removeCommentingField',
// Actions
'click .commenting-field .send.enabled' : 'postComment',
'click .commenting-field .update.enabled' : 'putComment',
'click .commenting-field .delete.enabled' : 'deleteComment',
// Comment
'click li.comment ul.child-comments .toggle-all': 'toggleReplies',
'click li.comment button.reply': 'replyButtonClicked',
'click li.comment button.edit': 'editButtonClicked',
'click li.comment button.upvote' : 'upvoteComment',
},
// Initialization
// ==============
init: function(options, el) {
this.$el = $(el);
this.$el.addClass('jquery-comments');
this.undelegateEvents();
this.delegateEvents();
// Detect mobile devices
(function(a){(jQuery.browser=jQuery.browser||{}).mobile=/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test(a)||/1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(a.substr(0,4))})(navigator.userAgent||navigator.vendor||window.opera);
if($.browser.mobile) this.$el.addClass('mobile');
// Init options
if(options.fieldMappings) {
options = $.extend({}, options);
$.extend(this.options.fieldMappings, options.fieldMappings);
// Field mappings needs to be deleted so that the field won't get overidden
delete options['fieldMappings'];
}
$.extend(this.options, options);
// Create CSS declarations for highlight color
this.createCssDeclarations();
// Fetching data and rendering
this.fetchDataAndRender();
},
delegateEvents: function() {
this.bindEvents(false);
},
undelegateEvents: function() {
this.bindEvents(true);
},
bindEvents: function(unbind) {
var bindFunction = unbind ? 'off' : 'on';
for (var key in this.events) {
var eventName = key.split(' ')[0];
var selector = key.split(' ').slice(1).join(' ');
var methodNames = this.events[key].split(' ');
for(var index in methodNames) {
var method = this[methodNames[index]];
// Keep the context
method = $.proxy(method, this);
if (selector == '') {
this.$el[bindFunction](eventName, method);
} else {
this.$el[bindFunction](eventName, selector, method);
}
}
}
},
// Basic functionalities
// =====================
fetchDataAndRender: function () {
var self = this;
this.$el.empty();
this.createHTML();
// Get comments
this.commentsById = {};
var success = function(commentsArray) {
// Convert comments to custom data model
var commentModels = commentsArray.map(function(commentsJSON){
return self.createCommentModel(commentsJSON)
});
// Sort comments by date (oldest first so that they can be appended to the data model
// without caring dependencies)
self.sortComments(commentModels, 'oldest');
$(commentModels).each(function(index, commentModel) {
self.addCommentToDataModel(commentModel);
});
self.render();
}
var error = function() {
success([]);
}
this.options.getComments(success, error);
},
createCommentModel: function(commentJSON) {
var commentModel = this.applyInternalMappings(commentJSON);
commentModel.childs = [];
return commentModel;
},
addCommentToDataModel: function(commentModel) {
if(!(commentModel.id in this.commentsById)) {
this.commentsById[commentModel.id] = commentModel;
// Update child array of the parent (append childs to the array of outer most parent)
if(commentModel.parent) {
var outermostParent = this.getOutermostParent(commentModel.parent);
outermostParent.childs.push(commentModel.id);
}
}
},
updateCommentModel: function(commentModel) {
$.extend(this.commentsById[commentModel.id], commentModel);
},
render: function() {
var self = this;
// Create new comment list
this.$el.find('#comment-list').remove();
var commentList = $('<ul/>', {
id: 'comment-list',
});
// Get the sort key from UI
this.currentSortKey = this.$el.find('.navigation li.active').data().sortKey;
// Divide commments into main level comments and replies
var mainLevelComments = [];
var replies = [];
$(this.getComments()).each(function(index, commentModel) {
if(commentModel.parent == null) {
mainLevelComments.push(commentModel);
} else {
replies.push(commentModel);
}
});
// Append main level comments
this.sortComments(mainLevelComments, this.currentSortKey)
mainLevelComments.reverse(); // Reverse the order as they are prepended to DOM
$(mainLevelComments).each(function(index, commentModel) {
self.addComment(commentModel, commentList);
});
// Append replies in chronological order
this.sortComments(replies, 'oldest');
$(replies).each(function(index, commentModel) {
self.addComment(commentModel, commentList);
});
// Appned comment list to DOM and remove spinner
this.$el.find('> .spinner').remove();
this.$el.find('.no-comments').before(commentList);
this.options.refresh();
},
addComment: function(commentModel, commentList) {
commentList = commentList || this.$el.find('#comment-list');
var commentEl = this.createCommentElement(commentModel);
// Case: reply
if(commentModel.parent) {
var directParentEl = commentList.find('.comment[data-id="'+commentModel.parent+'"]');
// Force replies into one level only
var outerMostParent = directParentEl.parents('.comment').last();
if(outerMostParent.length == 0) outerMostParent = directParentEl;
// Append element to DOM
var childCommentsEl = outerMostParent.find('.child-comments');
childCommentsEl.append(commentEl)
// Update toggle all -button
this.updateToggleAllButton(outerMostParent);
// Case: main level comment
} else {
commentList.prepend(commentEl);
}
},
removeComment: function(commentId) {
var self = this;
var commentModel = this.commentsById[commentId];
// Remove child comments recursively
var childComments = this.getChildComments(commentModel.id);
$(childComments).each(function(index, childComment) {
self.removeComment(childComment.id);
});
// Update the child array of outermost parent
if(commentModel.parent) {
var outermostParent = this.getOutermostParent(commentModel.parent)
var indexToRemove = outermostParent.childs.indexOf(commentModel.id);
outermostParent.childs.splice(indexToRemove, 1);
}
// Remove the comment from data model
delete this.commentsById[commentId];
var commentEl = this.$el.find('li.comment[data-id="'+commentId+'"]');
var parentEl = commentEl.parents('li.comment').last();
// Remove the element
commentEl.remove();
// Update the toggle all button
this.updateToggleAllButton(parentEl);
},
updateToggleAllButton: function(parentEl) {
var childCommentsEl = parentEl.find('.child-comments');
var childComments = childCommentsEl.find('.comment');
var toggleAllButton = childCommentsEl.find('li.toggle-all')
childComments.removeClass('hidden-reply');
// Add identifying class for hidden replies so they can be toggled
var hiddenReplies = childComments.slice(0, -this.options.maxRepliesVisible)
hiddenReplies.addClass('hidden-reply');
// Show all replies if replies are expanded
if(toggleAllButton.find('span.text').text() == this.options.textFormatter(this.options.hideRepliesText)) {
hiddenReplies.addClass('visible');
}
// Make sure that toggle all button is present
if(childComments.length > this.options.maxRepliesVisible) {
// Append button to toggle all replies if necessary
if(!toggleAllButton.length) {
toggleAllButton = $('<li/>', {
class: 'toggle-all highlight-font-bold',
});
var toggleAllButtonText = $('<span/>', {
class: 'text'
});
var caret = $('<span/>', {
class: 'caret',
});
// Append toggle button to DOM
toggleAllButton.append(toggleAllButtonText).append(caret)
childCommentsEl.prepend(toggleAllButton);
}
// Update the text of toggle all -button
this.setToggleAllButtonText(toggleAllButton, false);
// Make sure that toggle all button is not present
} else {
toggleAllButton.remove();
}
},
sortComments: function (comments, sortKey) {
var self = this;
// Sort by popularity
if(sortKey == 'popularity') {
comments.sort(function(commentA, commentB) {
var pointsOfA = commentA.childs.length;
var pointsOfB = commentB.childs.length;
if(self.options.enableUpvoting) {
pointsOfA += commentA.upvoteCount;
pointsOfB += commentB.upvoteCount;
}
if(pointsOfB != pointsOfA) {
return pointsOfB - pointsOfA;
// Return newer if popularity is the same
} else {
var createdA = new Date(commentA.created).getTime();
var createdB = new Date(commentB.created).getTime();
return createdB - createdA;
}
});
// Sort by date
} else {
comments.sort(function(commentA, commentB) {
var createdA = new Date(commentA.created).getTime();
var createdB = new Date(commentB.created).getTime();
if(sortKey == 'newest') {
return createdB - createdA;
} else {
return createdA - createdB;
}
});
}
},
sortAndReArrangeComments: function(sortKey) {
var commentList = this.$el.find('#comment-list');
// Get main level comments
var mainLevelComments = this.getComments().filter(function(commentModel){return !commentModel.parent});
this.sortComments(mainLevelComments, sortKey);
// Rearrange the main level comments
$(mainLevelComments).each(function(index, commentModel) {
var commentEl = commentList.find('> li.comment[data-id='+commentModel.id+']');
commentList.append(commentEl);
});
},
// Event handlers
// ==============
saveOnKeydown: function(ev) {
// Save comment on cmd/ctrl + enter
if(ev.keyCode == 13 && (ev.metaKey || ev.ctrlKey)) {
var el = $(ev.currentTarget);
el.siblings('.control-row').find('.save').trigger('click');
ev.stopPropagation();
ev.preventDefault();
}
},
saveEditableContent: function(ev) {
var el = $(ev.currentTarget);
el.data('before', el.html());
},
checkEditableContentForChange: function(ev) {
var el = $(ev.currentTarget);
if (el.data('before') != el.html()) {
el.data('before', el.html());
el.trigger('change');
}
},
navigationElementClicked: function(ev) {
var navigationEl = $(ev.currentTarget);
// Indicate active sort
navigationEl.siblings().removeClass('active');
navigationEl.addClass('active');
// Sort the comments
var sortKey = navigationEl.data().sortKey;
this.sortAndReArrangeComments(sortKey);
// Save the current sort key
this.currentSortKey = sortKey;
},
showMainCommentingField: function(ev) {
var mainTextarea = $(ev.currentTarget);
mainTextarea.siblings('.control-row').show();
mainTextarea.parent().find('.close').show();
mainTextarea.focus();
},
hideMainCommentingField: function(ev) {
var closeButton = $(ev.currentTarget);
var mainTextarea = this.$el.find('.commenting-field.main .textarea');
var mainControlRow = this.$el.find('.commenting-field.main .control-row');
this.clearTextarea(mainTextarea);
this.adjustTextareaHeight(mainTextarea, false);
mainControlRow.hide();
closeButton.hide();
mainTextarea.blur();
},
increaseTextareaHeight: function(ev) {
var textarea = $(ev.currentTarget);
this.adjustTextareaHeight(textarea, true);
},
textareaContentChanged: function(ev) {
var textarea = $(ev.currentTarget);
var content = this.getTextareaContent(textarea);
var saveButton = textarea.siblings('.control-row').find('.save');
// Update parent id if reply-to-badge was removed
if(!textarea.find('.reply-to-badge').length) {
var commentId = textarea.attr('data-comment');
// Case: editing comment
if(commentId) {
var parentComments = textarea.parents('li.comment');
if(parentComments.length > 1) {
var parentId = parentComments.last().data('id');
textarea.attr('data-parent', parentId);
}
// Case: new comment
} else {
var parentId = textarea.parents('li.comment').last().data('id');
textarea.attr('data-parent', parentId);
}
}
// Move close button if scrollbar is visible
var commentingField = textarea.parents('.commenting-field').first();
if(textarea[0].scrollHeight > textarea.outerHeight()) {
commentingField.addClass('scrollable');
} else {
commentingField.removeClass('scrollable');
}
// Check if content or parent has changed if editing
var contentOrParentChangedIfEditing = true;
if(commentId = textarea.attr('data-comment')) {
var contentChanged = content != this.commentsById[commentId].content;
var parentFromModel;
if(this.commentsById[commentId].parent) {
parentFromModel = this.commentsById[commentId].parent.toString();
}
var parentChanged = textarea.attr('data-parent') != parentFromModel;
contentOrParentChangedIfEditing = contentChanged || parentChanged;
}
// Check whether save button needs to be enabled
if(content.length && contentOrParentChangedIfEditing) {
saveButton.addClass('enabled');
} else {
saveButton.removeClass('enabled');
}
},
removeCommentingField: function(ev) {
var closeButton = $(ev.currentTarget);
// Remove edit class from comment if user was editing the comment
var textarea = closeButton.siblings('.textarea');
if(textarea.attr('data-comment')) {
closeButton.parents('li.comment').first().removeClass('edit');
}
// Remove the field
var commentingField = closeButton.parents('.commenting-field').first();
commentingField.remove();
},
postComment: function(ev) {
var self = this;
var sendButton = $(ev.currentTarget);
var commentingField = sendButton.parents('.commenting-field').first();
var textarea = commentingField.find('.textarea');
// Disable send button while request is pending
sendButton.removeClass('enabled');
var time = new Date().toISOString();
var commentJSON = {
id: 'c' + (this.getComments().length + 1), // Temporary id
parent: textarea.attr('data-parent') || null,
created: time,
modified: time,
content: this.getTextareaContent(textarea),
fullname: this.options.textFormatter(this.options.youText),
profilePictureURL: this.options.profilePictureURL,
createdByCurrentUser: true,
upvoteCount: 0,
userHasUpvoted: false,
}
// Reverse mapping
commentJSON = this.applyExternalMappings(commentJSON);
var success = function(commentJSON) {
var commentModel = self.createCommentModel(commentJSON);
self.addCommentToDataModel(commentModel);
self.addComment(commentModel);
commentingField.find('.close').trigger('click');
}
var error = function() {
sendButton.addClass('enabled');
}
this.options.postComment(commentJSON, success, error);
},
putComment: function(ev) {
var self = this;
var saveButton = $(ev.currentTarget);
var commentingField = saveButton.parents('.commenting-field').first();
var textarea = commentingField.find('.textarea');
// Disable send button while request is pending
saveButton.removeClass('enabled');
// Use a clone of the existing model and update the model after succesfull update
var commentJSON = $.extend({}, this.commentsById[textarea.attr('data-comment')]);
$.extend(commentJSON, {
parent: textarea.attr('data-parent') || null,
content: this.getTextareaContent(textarea),
modified: new Date().getTime(),
});
// Reverse mapping
commentJSON = this.applyExternalMappings(commentJSON);
var success = function(commentJSON) {
// The outermost parent can not be changed by editing the comment so the childs array
// of parent does not require an update
var commentModel = self.createCommentModel(commentJSON);
// Delete childs array from new comment model since it doesn't need an update
delete commentModel['childs'];
self.updateCommentModel(commentModel);
// Close the editing field
commentingField.find('.close').trigger('click');
// Re-render the comment
self.reRenderComment(commentModel.id);
}
var error = function() {
saveButton.addClass('enabled');
}
this.options.putComment(commentJSON, success, error);
},
deleteComment: function(ev) {
var self = this;
var deleteButton = $(ev.currentTarget);
var textarea = deleteButton.parents('.commenting-field').first().find('.textarea');
var commentJSON = $.extend({}, this.commentsById[textarea.attr('data-comment')]);
var commentId = commentJSON.id;
// Disable send button while request is pending
deleteButton.removeClass('enabled');
// Reverse mapping
commentJSON = this.applyExternalMappings(commentJSON);
var success = function() {
self.removeComment(commentId);
}
var error = function() {
deleteButton.addClass('enabled');
}
this.options.deleteComment(commentJSON, success, error);
},
toggleReplies: function(ev) {
var el = $(ev.currentTarget);
el.siblings('.hidden-reply').toggleClass('visible');
this.setToggleAllButtonText(el, true);
},
replyButtonClicked: function(ev) {
var replyButton = $(ev.currentTarget);
var outermostParent = replyButton.parents('li.comment').last();
var parentId = replyButton.parents('.comment').first().data().id;
// Remove existing field
var replyField = outermostParent.find('.child-comments > .commenting-field');
if(replyField.length) replyField.remove();
var previousParentId = replyField.find('.textarea').attr('data-parent');
// Create the reply field (do not re-create)
if(previousParentId != parentId) {
var replyField = this.createCommentingFieldElement(parentId);
outermostParent.find('.child-comments').append(replyField);
// Move cursor to end
var textarea = replyField.find('.textarea');
this.moveCursorToEnd(textarea)
}
},
editButtonClicked: function(ev) {
var editButton = $(ev.currentTarget);
var commentEl = editButton.parents('li.comment').first();
var commentModel = commentEl.data().model;
commentEl.addClass('edit');
// Create the editing field
var editField = this.createCommentingFieldElement(commentModel.parent, commentModel.id);
commentEl.find('.comment-wrapper').first().append(editField);
// Append original content
var textarea = editField.find('.textarea');
textarea.attr('data-comment', commentModel.id);
// Escaping HTML
textarea.append(this.getTextareaContentAsEscapedHTML(commentModel.content));
// Move cursor to end
this.moveCursorToEnd(textarea);
},
upvoteComment: function(ev) {
var self = this;
var commentEl = $(ev.currentTarget).parents('li.comment').first();
var commentModel = commentEl.data().model;
// Check whether user upvoted the comment or revoked the upvote
var previousUpvoteCount = commentModel.upvoteCount;
var newUpvoteCount;
if(commentModel.userHasUpvoted) {
newUpvoteCount = previousUpvoteCount - 1;
} else {
newUpvoteCount = previousUpvoteCount + 1;
}
// Show changes immediatelly
commentModel.userHasUpvoted = !commentModel.userHasUpvoted;
commentModel.upvoteCount = newUpvoteCount;
this.reRenderUpvotes(commentModel.id);
// Reverse mapping
var commentJSON = $.extend({}, commentModel);
commentJSON = this.applyExternalMappings(commentJSON);
var success = function(commentJSON) {
var commentModel = self.createCommentModel(commentJSON);
self.updateCommentModel(commentModel);
self.reRenderUpvotes(commentModel.id);
}
var error = function() {
// Revert changes
commentModel.userHasUpvoted = !commentModel.userHasUpvoted;
commentModel.upvoteCount = previousUpvoteCount;
self.reRenderUpvotes(commentModel.id);
}
this.options.upvoteComment(commentJSON, success, error);
},
// HTML elements
// =============
createHTML: function() {
var self = this;
// Commenting field
var mainCommentingField = this.createCommentingFieldElement();
mainCommentingField.addClass('main');
this.$el.append(mainCommentingField);
// Hide control row and close button
var mainControlRow = mainCommentingField.find('.control-row');
mainControlRow.hide();
mainCommentingField.find('.close').hide();
// Navigation bar
this.$el.append(this.createNavigationElement());
// Loading spinner
var spinner = $('<div/>', {
class: 'spinner',
});
var spinnerIcon = $('<i/>', {
class: 'fa fa-spinner fa-spin',
});
if(this.options.spinnerIconURL.length) {
spinnerIcon.css('background-image', 'url("'+this.options.spinnerIconURL+'")');
spinnerIcon.addClass('image');
}
spinner.html(spinnerIcon);
this.$el.append(spinner);
// "No comments" placeholder
var noComments = $('<div/>', {
class: 'no-comments',
text: this.options.textFormatter(this.options.noCommentsText)
});
var noCommentsIcon = $('<i/>', {
class: 'fa fa-comments fa-2x',
});
if(this.options.noCommentsIconURL.length) {
noCommentsIcon.css('background-image', 'url("'+this.options.noCommentsIconURL+'")');
noCommentsIcon.addClass('image');
}
noComments.prepend($('<br/>')).prepend(noCommentsIcon);
this.$el.append(noComments);
},
createProfilePictureElement: function(src) {
var profilePicture = $('<img/>', {
src: src,
class: 'profile-picture' + (this.options.roundProfilePictures ? ' round' : '')
});
return profilePicture;
},
createCommentingFieldElement: function(parentId, existingCommentId) {
var self = this;
// Commenting field
var commentingField = $('<div/>', {
class: 'commenting-field',
});
// Profile picture
var profilePicture = this.createProfilePictureElement(this.options.profilePictureURL);
profilePicture.addClass('by-current-user');
// New comment
var textareaWrapper = $('<div/>', {
class: 'textarea-wrapper',
});
// Control row
var controlRow = $('<div/>', {
class: 'control-row',
});
// Textarea
var textarea = $('<div/>', {
class: 'textarea',
'data-placeholder': this.options.textFormatter(this.options.textareaPlaceholderText),
contenteditable: true,
});
// Setting the initial height for the textarea
this.adjustTextareaHeight(textarea, false);
// Close button
var closeButton = $('<span/>', {
class: 'close',
}).append($('<span class="left"/>')).append($('<span class="right"/>'));
// Save button text
if(existingCommentId) {
var saveButtonText = this.options.textFormatter(this.options.saveText);
// Append delete button if necessary
if(this.options.enableDeleting) {
var isAllowedToDelete = true;
// Check if comment with replies can be deleted
if(!this.options.enableDeletingCommentWithReplies) {
$(this.getComments()).each(function(index, comment) {
if(comment.parent == existingCommentId) isAllowedToDelete = false;
});
}
if(isAllowedToDelete) {
var deleteButton = $('<span/>', {
class: 'enabled delete',
text: this.options.textFormatter(this.options.deleteText)
}).css('background-color', this.options.deleteButtonColor);
controlRow.append(deleteButton);
}
}
} else {
var saveButtonText = this.options.textFormatter(this.options.sendText);
}
// Save button
var saveButtonClass = existingCommentId ? 'update' : 'send';
var saveButton = $('<span/>', {
class: saveButtonClass + ' save highlight-background',
text: saveButtonText,
});
// Populate the element
controlRow.prepend(saveButton);
textareaWrapper.append(closeButton).append(textarea).append(controlRow);
commentingField.append(profilePicture).append(textareaWrapper);
if(parentId) {
// Set the parent id to the field if necessary
textarea.attr('data-parent', parentId)
// Append reply-to badge if necessary
var parentModel = this.commentsById[parentId];
if(parentModel.parent) {
textarea.html(' '); // Needed to set the cursor to correct place
// Creating the reply-to badge
var replyToBadge = $('<input/>', {
class: 'reply-to-badge highlight-font-bold',
type: 'button'
});
var replyToName = '@' + parentModel.fullname;
replyToBadge.val(replyToName);
textarea.prepend(replyToBadge);
}
}
return commentingField;
},
createNavigationElement: function() {
var navigationEl = $('<ul/>', {
class: 'navigation'
});
// Popular
var popular = $('<li/>', {
text: this.options.textFormatter(this.options.popularText),
'data-sort-key': 'popularity',
});
// Newest
var newest = $('<li/>', {
text: this.options.textFormatter(this.options.newestText),
'data-sort-key': 'newest',
});
// Oldest
var oldest = $('<li/>', {
text: this.options.textFormatter(this.options.oldestText),
'data-sort-key': 'oldest',
});
var enableSortingByPopulairty = this.options.enableReplying || this.options.enableUpvoting;
if(enableSortingByPopulairty) navigationEl.append(popular);
navigationEl.append(newest).append(oldest);
navigationEl.children().first().addClass('active');
return navigationEl;
},
createCommentElement: function(commentModel) {
// Comment container element
var commentEl = $('<li/>', {
'data-id': commentModel.id,
class: 'comment',
}).data('model', commentModel);
if(commentModel.createdByCurrentUser) commentEl.addClass('by-current-user');
if(commentModel.createdByAdmin) commentEl.addClass('by-admin');
// Child comments
var childComments = $('<ul/>', {
class: 'child-comments'
});
// Comment wrapper
var commentWrapper = this.createCommentWrapperElement(commentModel);
commentEl.append(commentWrapper);
if(commentModel.parent == null) commentEl.append(childComments);
return commentEl;
},
createCommentWrapperElement: function(commentModel) {
var commentWrapper = $('<div/>', {
class: 'comment-wrapper'
});
// Profile picture
var profilePicture = this.createProfilePictureElement(commentModel.profilePictureURL);
// Time
var time = $('<time/>', {
text: this.options.timeFormatter(commentModel.created),
'data-original': commentModel.created
});
// Name
var name = $('<div/>', {
class: 'name',
text: commentModel.fullname,
});
// Highlight name for admins
if(commentModel.createdByAdmin) name.addClass('highlight-font-bold');
// Show reply-to name if parent of parent exists
if(commentModel.parent) {
var parent = this.commentsById[commentModel.parent];
if(parent.parent) {
var replyTo = $('<span/>', {
class: 'reply-to',
text: parent.fullname,
});
// reply icon
var replyIcon = $('<i/>', {
class: 'fa fa-share'
});
if(this.options.replyIconURL.length) {
replyIcon.css('background-image', 'url("'+this.options.replyIconURL+'")');
replyIcon.addClass('image');
}
replyTo.prepend(replyIcon);
name.append(replyTo);
}
}
// Wrapper
var wrapper = $('<div/>', {
class: 'wrapper',
});
// Content
var content = $('<div/>', {
class: 'content',
text: commentModel.content,
}).html(this.linkify(this.escape(commentModel.content)));
// Edited timestamp
if(commentModel.modified && commentModel.modified != commentModel.created) {
var editedTime = this.options.timeFormatter(commentModel.modified);
var edited = $('<time/>', {
class: 'edited',
text: this.options.textFormatter(this.options.editedText) + ' ' + editedTime,
'data-original': commentModel.modified
});
content.append(edited);
}
// Actions
var actions = $('<span/>', {
class: 'actions',
});
// Separator
var separator = $('<span/>', {
class: 'separator',
text: '·',
});
// Reply
var reply = $('<button/>', {
class: 'action reply',
text: this.options.textFormatter(this.options.replyText),
});
// Upvote icon
var upvoteIcon = $('<i/>', {
class: 'fa fa-thumbs-up'
});
if(this.options.upvoteIconURL.length) {
upvoteIcon.css('background-image', 'url("'+this.options.upvoteIconURL+'")');
upvoteIcon.addClass('image');
}
// Upvotes
var upvotes = this.createUpvoteElement(commentModel);
// Edit
var edit = $('<button/>', {
class: 'action edit',
text: this.options.textFormatter(this.options.editText),
});
// Append buttons for actions that are enabled
if(this.options.enableReplying) actions.append(reply);
if(this.options.enableUpvoting) actions.append(upvotes);
if(this.options.enableEditing && commentModel.createdByCurrentUser){
actions.append(edit);
}
// Append separators between the actions
actions.children().each(function(index, actionEl) {
if(!$(actionEl).is(':last-child')) {
$(actionEl).after(separator.clone());
}
});
wrapper.append(content);
wrapper.append(actions);
commentWrapper.append(profilePicture).append(time).append(name).append(wrapper);
return commentWrapper;
},
createUpvoteElement: function(commentModel) {
// Upvote icon
var upvoteIcon = $('<i/>', {
class: 'fa fa-thumbs-up'
});
if(this.options.upvoteIconURL.length) {
upvoteIcon.css('background-image', 'url("'+this.options.upvoteIconURL+'")');
upvoteIcon.addClass('image');
}
// Upvotes
var upvoteEl = $('<button/>', {
class: 'action upvote' + (commentModel.userHasUpvoted ? ' highlight-font' : ''),
}).append($('<span/>', {
text: commentModel.upvoteCount,
class: 'upvote-count'
})).append(upvoteIcon);
return upvoteEl;
},
reRenderComment: function(id) {
var commentModel = this.commentsById[id];
var commentWrapper = this.createCommentWrapperElement(commentModel);
var commentEl = this.$el.find('li.comment[data-id="'+commentModel.id+'"]');
commentEl.find('> .comment-wrapper').replaceWith(commentWrapper);
},
reRenderUpvotes: function(id) {
var commentModel = this.commentsById[id];
var upvotes = this.createUpvoteElement(commentModel);
var commentEl = this.$el.find('li.comment[data-id="'+commentModel.id+'"]');
commentEl.find('.upvote').first().replaceWith(upvotes);
},
// Styling
// =======
createCssDeclarations: function() {
// Do not recreate CSS declarations
if($('head style.jquery-comments-css').length > 0) return;
// Navigation underline
this.createCss('.jquery-comments ul.navigation li.active:after {background: '
+ this.options.highlightColor + ' !important;',
+'}');
// Background highlight
this.createCss('.jquery-comments .highlight-background {background: '
+ this.options.highlightColor + ' !important;',
+'}');
// Font highlight
this.createCss('.jquery-comments .highlight-font {color: '
+ this.options.highlightColor + ' !important;'
+'}');
this.createCss('.jquery-comments .highlight-font-bold {color: '
+ this.options.highlightColor + ' !important;'
+ 'font-weight: bold;'
+'}');
},
createCss: function(css) {
var styleEl = $('<style/>', {
type: 'text/css',
class: 'jquery-comments-css',
text: css,
});
$('head').append(styleEl);
},
// Utilities
// =========
getComments: function() {
var self = this;
return Object.keys(this.commentsById).map(function(id){return self.commentsById[id]});
},
getChildComments: function(parentId) {
return this.getComments().filter(function(comment){return comment.parent == parentId});
},
getOutermostParent: function(directParentId) {
var parentId = directParentId;
do {
var parentComment = this.commentsById[parentId];
parentId = parentComment.parent;
} while(parentComment.parent != null)
return parentComment;
},
setToggleAllButtonText: function(toggleAllButton, toggle) {
var self = this;
var textContainer = toggleAllButton.find('span.text');
var caret = toggleAllButton.find('.caret');
var showExpandingText = function() {
var text = self.options.textFormatter(self.options.viewAllRepliesText);
var replyCount = toggleAllButton.siblings('.comment').length;
text = text.replace('__replyCount__', replyCount);
textContainer.text(text);
}
var hideRepliesText = this.options.textFormatter(this.options.hideRepliesText);
if(toggle) {
// Toggle text
if(textContainer.text() == hideRepliesText) {
showExpandingText();
} else {
textContainer.text(hideRepliesText);
}
// Toggle direction of the caret
caret.toggleClass('up');
} else {
// Update text if necessary
if(textContainer.text() != hideRepliesText) {
showExpandingText();
}
}
},
adjustTextareaHeight: function(textarea, focus) {
var textareaBaseHeight = 2.2;
var lineHeight = 1.45;
var setRows = function(rows) {
var height = textareaBaseHeight + (rows - 1) * lineHeight;
textarea.css('height', height + 'em');
}
var textarea = $(textarea);
var rowCount = focus == true ? this.options.textareaRowsOnFocus : this.options.textareaRows;
do {
setRows(rowCount);
rowCount++;
var isAreaScrollable = textarea[0].scrollHeight > textarea.outerHeight();
var maxRowsUsed = this.options.textareaMaxRows == false ?
false : rowCount > this.options.textareaMaxRows;
} while(isAreaScrollable && !maxRowsUsed);
},
clearTextarea: function(textarea) {
textarea.empty().trigger('input');
},
getTextareaContent: function(textarea) {
var ce = $('<pre/>').html(textarea.html());
ce.find('div, p, br').replaceWith(function() { return '\n' + this.innerHTML; });
// Trim leading spaces
var text = ce.text().replace(/^\s+/g, '');
return text;
},
getTextareaContentAsEscapedHTML: function(html) {
// Escaping HTML except the new lines
var escaped = this.escape(html);
return escaped.replace(/(?:\n)/g, '<br>');
},
moveCursorToEnd: function(el) {
el = $(el)[0];
// Trigger input to adjust size
$(el).trigger('input');
// Scroll to bottom
$(el).scrollTop(el.scrollHeight);
// Move cursor to end
if (typeof window.getSelection != 'undefined' && typeof documen