From 31081eac462b084b1bc2331fad4c3ed5e02efd04 Mon Sep 17 00:00:00 2001 From: yflory Date: Wed, 22 Apr 2020 16:23:45 +0200 Subject: [PATCH] Add comments UI --- www/pad/app-pad.less | 6 +- www/pad/comment.js | 53 ++++--- www/pad/comments.js | 371 +++++++++++++++++++++++++++++++++++++++++-- www/pad/inner.js | 15 +- 4 files changed, 403 insertions(+), 42 deletions(-) diff --git a/www/pad/app-pad.less b/www/pad/app-pad.less index 730517f74..507ae0739 100644 --- a/www/pad/app-pad.less +++ b/www/pad/app-pad.less @@ -1,4 +1,5 @@ @import (reference) "../../customize/src/less2/include/framework.less"; +@import (reference) "../../customize/src/less2/include/comments.less"; body.cp-app-pad { .framework_main( @@ -71,9 +72,10 @@ body.cp-app-pad { min-width: 0; } #cp-app-pad-comments { - width: 400px; - background-color: white; + width: 300px; + //background-color: white; margin: 30px; + .comments_main(); } &.cke_body_width { iframe { diff --git a/www/pad/comment.js b/www/pad/comment.js index 0a9e78db9..884b3f749 100644 --- a/www/pad/comment.js +++ b/www/pad/comment.js @@ -36,14 +36,9 @@ childRule: isUnstylable }; -// XXX define default style -// XXX we can't uncomment if nothing has been added yet -// XXX "styles" is useless because not rebuilt on reload -// XXX and one style can remove all the other ones so no need to store all of them? - // Register the command. var removeStyle = new CKEDITOR.style(styleDef, { 'uid': '' }); - editor.addCommand(pluginName, { + editor.addCommand('comment', { exec: function (editor, data) { if (editor.readOnly) { return; } editor.focus(); @@ -57,19 +52,15 @@ var uid = CKEDITOR.tools.getUniqueId(); editor.plugins.comments.addComment(uid, function () { - // XXX call cryptpad code here + // Make an undo spnashot editor.fire('saveSnapshot'); + // Make sure comments won't overlap editor.removeStyle(removeStyle); - /* - Object.keys(styles).forEach(function (id) { - editor.removeStyle(styles[id]); - }); - */ - styles[uid] = new CKEDITOR.style(styleDef, { 'uid': uid }); - editor.applyStyle(styles[uid]); - - //editor.removeStyle(removeStyle); // XXX to remove comment on the selection - //editor.plugins.comments.addComment(); + + // Add the comment marker + var s = new CKEDITOR.style(styleDef, { 'uid': uid }); + editor.applyStyle(s); + // Save the undo snapshot after all changes are affected. setTimeout( function() { editor.fire('saveSnapshot'); @@ -79,13 +70,34 @@ } }); - // XXX Uncomment selection, remove on prod, only used for dev editor.addCommand('uncomment', { exec: function (editor, data) { if (editor.readOnly) { return; } - editor.focus(); editor.fire('saveSnapshot'); - editor.removeStyle(removeStyle); + if (!data || !data.id) { + // XXX Uncomment the selection, remove on prod, only used for dev + editor.focus(); + editor.removeStyle(removeStyle); + setTimeout( function() { + editor.fire('saveSnapshot'); + }, 0 ); + return; + } + // Uncomment provided element + + //Create style for this id + var style = new CKEDITOR.style({ + element: 'comment', + attributes: { + 'data-uid': data.id + }, + }); + // Create range for the entire document + var range = editor.createRange(); + range.selectNodeContents( editor.document.getBody() ); + // Remove style for the document + style.removeFromRange(range, editor); + setTimeout( function() { editor.fire('saveSnapshot'); }, 0 ); @@ -93,6 +105,7 @@ }); // Register the toolbar button. + // XXX Uncomment selection, remove on prod, only used for dev editor.ui.addButton && editor.ui.addButton('UnComment', { label: 'UNCOMMENT', command: 'uncomment', diff --git a/www/pad/comments.js b/www/pad/comments.js index 8c4119f92..2c1cd78a6 100644 --- a/www/pad/comments.js +++ b/www/pad/comments.js @@ -1,16 +1,43 @@ define([ 'json.sortify', '/common/common-util.js', + '/common/hyperscript.js', '/common/common-interface.js', '/customize/messages.js' -], function (Sortify, Util, UI, Messages) { +], function (Sortify, Util, h, UI, Messages) { var Comments = {}; +/* +{ + authors: { + "id": { + name: "", + curvePublic: "", + avatar: "", + profile: "" + } + }, + data: { + "uid": { + m: [{ + u: id, + m: "str", // comment + t: +new Date, + v: "str" // value of the commented content + }], + (deleted: undefined/true,) + } + } +} +*/ + var COMMENTS = { authors: {}, - messages: {} + data: {} }; + var canonicalize = function (t) { return t.replace(/\r\n/g, '\n'); }; + // XXX function duplicated from www/code/markers.js var authorUid = function (existing) { if (!Array.isArray(existing)) { existing = []; } @@ -49,40 +76,353 @@ define([ return myAuthorId; }; + var updateMetadata = function (Env) { + var md = Util.clone(Env.metadataMgr.getMetadata()); + md.comments = Util.clone(Env.comments); + Env.metadataMgr.updateMetadata(md); + }; + + Messages.comments_submit = "Submit"; // XXX + Messages.comments_reply = "Reply"; // XXX + Messages.comments_resolve = "Resolve"; // XXX + + var getCommentForm = function (Env, reply, _cb) { + var cb = Util.once(_cb); + var userData = Env.metadataMgr.getUserData(); + var name = Util.fixHTML(userData.name || Messages.anonymous); + var avatar = h('span.cp-avatar'); + var textarea = h('textarea'); + Env.common.displayAvatar($(avatar), userData.avatar, name); + + var cancel = h('button.btn.btn-cancel', [ + h('i.fa.fa-times'), + Messages.cancel + ]); + var submit = h('button.btn.btn-primary', [ + h('i.fa.fa-paper-plane-o'), + Messages.comments_submit + ]); + + var done = false; + + $(submit).click(function (e) { + e.stopPropagation(); + cb(textarea.value); + }); + $(cancel).click(function (e) { + e.stopPropagation(); + cb(); + }); + + $(textarea).keydown(function (e) { + if (e.which === 27) { + $(cancel).click(); + } + if (e.which === 13 && e.ctrlKey) { + $(submit).click(); + } + }); + + return h('div.cp-comment-form' + (reply ? '.cp-comment-reply' : ''), [ + h('div.cp-comment-form-input', [ + avatar, + textarea + ]), + h('div.cp-comment-form-actions', [ + cancel, + submit + ]) + ]); + }; + + var redrawComments = function (Env) { + // Don't redraw if there were no change + var str = Sortify(Env.comments || {}); + if (str === Env.oldComments) { return; } + Env.oldComments = str; + + // XXX don't wipe inputs? + + var $oldInput = Env.$container.find('.cp-comment-form'); + if ($oldInput.length !== 1) { $oldInput = undefined; } + + Env.$container.html(''); + + if ($oldInput && !$oldInput.attr('data-uid')) { + Env.$container.append($oldInput); + } + + var order = Env.$inner.find('comment').map(function (i, el) { + return el.getAttribute('data-uid'); + }).toArray(); + var done = []; + + + var show = false; + order.forEach(function (key) { + // Avoir duplicates + if (done.indexOf(key) !== -1) { return; } + done.push(key); + + var obj = Env.comments.data[key]; + if (!obj || obj.deleted || !Array.isArray(obj.m) || !obj.m.length) { + return; + } + show = true; + + var content = []; + obj.m.forEach(function (msg, i) { + var author = (Env.comments.authors || {})[msg.u] || {}; + var name = Util.fixHTML(author.name || Messages.anonymous); + var date = new Date(msg.t); + var avatar = h('span.cp-avatar'); + Env.common.displayAvatar($(avatar), author.avatar, name); + + content.push(h('div.cp-comment'+(i === 0 ? '' : '.cp-comment-reply'), [ + h('div.cp-comment-header', [ + avatar, + h('span.cp-comment-metadata', [ + h('span.cp-comment-author', name), + h('span.cp-comment-time', date.toLocaleString()) + ]) + ]), + h('div.cp-comment-content', [ + msg.m + ]) + ])); + + }); + + var reply = h('button.btn.btn-secondary', [ + h('i.fa.fa-reply'), + Messages.comments_reply + ]); + var resolve = h('button.btn.btn-primary', [ + h('i.fa.fa-check'), + Messages.comments_resolve + ]); + + var actions; + content.push(actions = h('div.cp-comment-actions', [ + reply, + resolve + ])); + var $actions = $(actions); + + var div; + Env.$container.append(div = h('div.cp-comment-container', { + 'data-uid': key, + tabindex: 1 + }, content)); + var $div = $(div); + + $(reply).click(function (e) { + e.stopPropagation(); + $actions.hide(); + var form = getCommentForm(Env, true, function (val) { + $(form).remove(); + $actions.css('display', ''); + if (!val) { return; } + var obj = Env.comments.data[key]; + if (!obj || !Array.isArray(obj.m)) { return; } + + // Get the value of the commented text + var res = Env.$inner.find('comment[data-uid="'+key+'"]').toArray(); + var value = res.map(function (el) { + return el.innerText; + }).join('\n'); + + // Push the reply + var myId = updateAuthorData(Env); + obj.m.push({ + u: myId, + t: +new Date(), + m: val, + v: value + }); + + // Send to chainpad + updateMetadata(Env); + Env.framework.localChange(); + }); + $div.append(form); + }); + + UI.confirmButton(resolve, { + classes: 'btn-danger-alt' + }, function () { + // Delete the comment + delete Env.comments.data[key]; + + // Send to chainpad + updateMetadata(Env); + Env.framework.localChange(); + }); + + $div.click(function () { + Env.$container.find('.cp-comment-active').removeClass('cp-comment-active'); + $div.addClass('cp-comment-active'); + $actions.css('display', ''); + Env.$container.find('.cp-comment-form').remove(); + // XXX highlight (and scroll to) the comment in the doc? + }); + }); + + if (show) { + Env.$container.show(); + } else { + Env.$container.hide(); + } + }; + var onChange = function (Env) { var md = Util.clone(Env.metadataMgr.getMetadata()); Env.comments = md.comments; - if (!Env.comments) { Env.comments = Util.clone(COMMENTS); } + if (!Env.comments || !Env.comments.data) { Env.comments = Util.clone(COMMENTS); } + if (Env.ready === 0) { + Env.ready = true; + } + redrawComments(Env); }; - Comments.create = function (cfg) { - var Env = cfg; - Env.comments = Util.clone(COMMENTS); + // Check if comments have been deleted from the document but not from metadata + var checkDeleted = function (Env) { + if (!Env.comments || !Env.comments.data) { return; } + + // If there is no comment stored in the metadata, abort + var comments = Object.keys(Env.comments.data || {}).filter(function (id) { + return !Env.comments.data[id].deleted; + }); + var changed = false; + + // Get the comments from the document + var uids = Env.$inner.find('comment').map(function (i, el) { + var id = el.getAttribute('data-uid'); + // Empty comment: remove from dom + if (!el.innerText && el.parentElement) { + el.parentElement.removeChild(el); + changed = true; + return; + } + // Comment not in the metadata: uncomment (probably an undo) + if (comments.indexOf(id) === -1) { + console.error(id, el); + Env.editor.execCommand('uncomment', {id:id}); + changed = true; + return; + } + return id; + }).toArray(); + + // Check if a comment has been deleted + comments.forEach(function (uid) { + if (uids.indexOf(uid) !== -1) { return; } + // comment has been deleted + var data = Env.comments.data[uid]; + if (!data) { return; } + //data.deleted = true; + delete Env.comments.data[uid]; + changed = true; + }); + + if (changed) { + updateMetadata(Env); + } + }; + + var addAddCommentHandler = function (Env) { Env.editor.plugins.comments.addComment = function (uid, addMark) { if (!Env.comments) { Env.comments = Util.clone(COMMENTS); } - UI.prompt("Message", "", function (val) { // XXX + // Get all comments ID contained within the selection + var sel = Env.editor.getSelectedHtml().$.querySelectorAll('comment'); + if (sel.length) { + // Abort if our selection contains a comment + console.error("Your selection contains a comment"); + UI.warn(Messages.error); + // XXX show error + return; + } + +/* +sel.forEach(function (el) { + // For each comment ID, check if the comment will be deleted + // if we add a comment on our selection + var id = el.getAttribute('data-uid'); + + // Get all nodes for this comment + var all = Env.$inner.find('comment[data-uid="'+id+'"]'); + // Get our selection + var sel = Env.ifrWindow.getSelection(); + if (!sel.containsNode) { + // IE doesn't support this method, always allow comments for them... + sel.containsNode = function () { return false; }; + } + + var notDeleted = all.some(function (i, el) { + // If this node is completely outside of the selection, continue + if (!sel.containsNode(el, true)) { return true; } + }); + + // only continue if notDeleted is true (at least one node for + // this comment won't be deleted) +}); +*/ + Env.$container.find('.cp-comment-form').remove(); + var form = getCommentForm(Env, false, function (val) { + $(form).remove(); if (!val) { return; } if (!editor.getSelection().getSelectedText()) { // text has been deleted by another user while we were typing our comment? return void UI.warn(Messages.error); } var myId = updateAuthorData(Env); - Env.comments.messages[uid] = { - user: myId, - time: +new Date(), - message: val + Env.comments.data[uid] = { + m: [{ + u: myId, + t: +new Date(), + m: val, + v: canonicalize(editor.getSelection().getSelectedText()) + }] }; - var md = Util.clone(Env.metadataMgr.getMetadata()); - md.comments = Util.clone(Env.comments); - Env.metadataMgr.updateMetadata(md); + updateMetadata(Env); addMark(); Env.framework.localChange(); }); + Env.$container.prepend(form); }; + }; + + var onContentUpdate = function (Env) { + if (!Env.ready) { return; } + // Check deleted + checkDeleted(Env); + }; + var localChange = function (Env) { + if (!Env.ready) { return; } + // Check deleted + checkDeleted(Env); + }; + + var ready = function (Env) { + Env.ready = 0; + }; + + Comments.create = function (cfg) { + var Env = cfg; + Env.comments = Util.clone(COMMENTS); + + addAddCommentHandler(Env); + + $(window).click(function (e) { + if ($(e.target).closest('.cp-comment-container').length) { + return; + } + Env.$container.find('.cp-comment-active').removeClass('cp-comment-active'); + }); var call = function (f) { return function () { @@ -98,6 +438,9 @@ define([ Env.metadataMgr.onChange(call(onChange)); return { + onContentUpdate: call(onContentUpdate), + localChange: call(localChange), + ready: call(ready) }; }; diff --git a/www/pad/inner.js b/www/pad/inner.js index b9b63a330..09161cea1 100644 --- a/www/pad/inner.js +++ b/www/pad/inner.js @@ -494,7 +494,9 @@ define([ metadataMgr: metadataMgr, common: common, editor: editor, - container: $('#cp-app-pad-comments')[0] + ifrWindow: ifrWindow, + $inner: $inner, + $container: $('#cp-app-pad-comments') }); var onLinkClicked = function (e) { @@ -662,11 +664,7 @@ define([ $links.off('click', openLink).on('click', openLink); } - // XXX check comments - // new comments - // deleted comments - // check comment authors too - + comments.onContentUpdate(); }); framework.setTextContentGetter(function () { @@ -689,6 +687,8 @@ define([ // the text nodes and OT/ChainPad would freak out cursors.removeCursors(inner); + comments.onContentUpdate(); + displayMediaTags(framework, inner, mediaTagMap); inner.normalize(); var hjson = Hyperjson.fromDOM(inner, shouldSerialize, hjsonFilters); @@ -805,6 +805,9 @@ define([ }); } }); + + comments.ready(); + /*setTimeout(function () { $('iframe.cke_wysiwyg_frame').focus(); editor.focus();