UNPKG

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
// 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('&nbsp;'); // 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