|
|
define([
|
|
|
'jquery',
|
|
|
'json.sortify',
|
|
|
'/common/common-util.js',
|
|
|
'/common/hyperscript.js',
|
|
|
'/common/common-interface.js',
|
|
|
'/customize/messages.js'
|
|
|
], 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: {},
|
|
|
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 = []; }
|
|
|
var n;
|
|
|
var i = 0;
|
|
|
while (!n || existing.indexOf(n) !== -1 && i++ < 1000) {
|
|
|
n = Math.floor(Math.random() * 1000000);
|
|
|
}
|
|
|
// If we can't find a valid number in 1000 iterations, use 0...
|
|
|
if (existing.indexOf(n) !== -1) { n = 0; }
|
|
|
return n;
|
|
|
};
|
|
|
var getAuthorId = function (Env, curve) {
|
|
|
var existing = Object.keys(Env.comments.authors || {}).map(Number);
|
|
|
if (!Env.common.isLoggedIn()) { return authorUid(existing); }
|
|
|
|
|
|
var uid;
|
|
|
existing.some(function (id) {
|
|
|
var author = Env.comments.authors[id] || {};
|
|
|
if (author.curvePublic !== curve) { return; }
|
|
|
uid = Number(id);
|
|
|
return true;
|
|
|
});
|
|
|
return uid || authorUid(existing);
|
|
|
};
|
|
|
|
|
|
var updateAuthorData = function (Env) {
|
|
|
var userData = Env.metadataMgr.getUserData();
|
|
|
var myAuthorId = getAuthorId(Env, userData.curvePublic);
|
|
|
var data = Env.comments.authors[myAuthorId] = Env.comments.authors[myAuthorId] || {};
|
|
|
data.name = userData.name;
|
|
|
data.avatar = userData.avatar;
|
|
|
data.profile = userData.profile;
|
|
|
data.curvePublic = userData.curvePublic;
|
|
|
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', {
|
|
|
tabindex: 1
|
|
|
});
|
|
|
Env.common.displayAvatar($(avatar), userData.avatar, name);
|
|
|
|
|
|
var cancel = h('button.btn.btn-cancel', {
|
|
|
tabindex: 1
|
|
|
}, [
|
|
|
h('i.fa.fa-times'),
|
|
|
Messages.cancel
|
|
|
]);
|
|
|
var submit = h('button.btn.btn-primary', {
|
|
|
tabindex: 1
|
|
|
}, [
|
|
|
h('i.fa.fa-paper-plane-o'),
|
|
|
Messages.comments_submit
|
|
|
]);
|
|
|
|
|
|
$(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.shiftKey) {
|
|
|
$(submit).click();
|
|
|
e.preventDefault();
|
|
|
}
|
|
|
});
|
|
|
|
|
|
setTimeout(function () {
|
|
|
$(textarea).focus();
|
|
|
});
|
|
|
|
|
|
return h('div.cp-comment-form' + (reply ? '.cp-comment-reply' : ''), {
|
|
|
'data-uid': reply || undefined
|
|
|
}, [
|
|
|
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 || {}).data || {});
|
|
|
|
|
|
if (str === Env.oldComments) { return; }
|
|
|
Env.oldComments = str;
|
|
|
|
|
|
var $oldInput = Env.$container.find('.cp-comment-form').detach();
|
|
|
if ($oldInput.length !== 1) { $oldInput = undefined; }
|
|
|
|
|
|
Env.$container.html('');
|
|
|
|
|
|
var show = false;
|
|
|
|
|
|
if ($oldInput && !$oldInput.attr('data-uid')) {
|
|
|
show = true;
|
|
|
Env.$container.append($oldInput);
|
|
|
}
|
|
|
|
|
|
var order = Env.$inner.find('comment').map(function (i, el) {
|
|
|
return el.getAttribute('data-uid');
|
|
|
}).toArray();
|
|
|
var done = [];
|
|
|
|
|
|
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', {
|
|
|
tabindex: 1
|
|
|
}, [
|
|
|
h('i.fa.fa-reply'),
|
|
|
Messages.comments_reply
|
|
|
]);
|
|
|
var resolve = h('button.btn.btn-primary', {
|
|
|
tabindex: 1
|
|
|
}, [
|
|
|
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, key, function (val) {
|
|
|
$(form).remove();
|
|
|
$(form).closest('.cp-comment-container')
|
|
|
.find('.cp-comment-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];
|
|
|
var els = Env.$inner.find('comment[data-uid="'+key+'"]').toArray();
|
|
|
Env.editor.plugins.comments.uncomment(key, els);
|
|
|
|
|
|
// Send to chainpad
|
|
|
updateMetadata(Env);
|
|
|
Env.framework.localChange();
|
|
|
});
|
|
|
|
|
|
var focusContent = function () {
|
|
|
// Add class "active"
|
|
|
Env.$inner.find('comment.active').removeClass('active');
|
|
|
Env.$inner.find('comment[data-uid="'+key+'"]').addClass('active');
|
|
|
var $last = Env.$inner.find('comment[data-uid="'+key+'"]').last();
|
|
|
|
|
|
// Scroll into view
|
|
|
if (!$last.length) { return; }
|
|
|
var size = Env.$inner.outerHeight();
|
|
|
var pos = $last[0].getBoundingClientRect();
|
|
|
var visible = (pos.y + pos.height) < size;
|
|
|
if (!visible) { $last[0].scrollIntoView(); }
|
|
|
};
|
|
|
|
|
|
$div.on('click focus', function () {
|
|
|
if ($div.hasClass('cp-comment-active')) { return; }
|
|
|
Env.$container.find('.cp-comment-active').removeClass('cp-comment-active');
|
|
|
$div.addClass('cp-comment-active');
|
|
|
div.scrollIntoView();
|
|
|
$actions.css('display', '');
|
|
|
Env.$container.find('.cp-comment-form').remove();
|
|
|
|
|
|
focusContent();
|
|
|
});
|
|
|
|
|
|
if ($oldInput && $oldInput.attr('data-uid') === key) {
|
|
|
$div.addClass('cp-comment-active');
|
|
|
$actions.hide();
|
|
|
$div.append($oldInput);
|
|
|
$oldInput.find('textarea').focus();
|
|
|
focusContent();
|
|
|
}
|
|
|
});
|
|
|
|
|
|
if (show) {
|
|
|
Env.$container.show();
|
|
|
} else {
|
|
|
Env.$container.hide();
|
|
|
}
|
|
|
};
|
|
|
|
|
|
var onChange = function (Env) {
|
|
|
var md = Util.clone(Env.metadataMgr.getMetadata());
|
|
|
Env.comments = md.comments;
|
|
|
var changed = false;
|
|
|
if (!Env.comments || !Env.comments.data) {
|
|
|
changed = true;
|
|
|
Env.comments = Util.clone(COMMENTS);
|
|
|
}
|
|
|
if (Env.ready === 0) {
|
|
|
Env.ready = true;
|
|
|
if (changed) {
|
|
|
updateMetadata(Env);
|
|
|
Env.framework.localChange();
|
|
|
}
|
|
|
}
|
|
|
redrawComments(Env);
|
|
|
};
|
|
|
|
|
|
// Check if comments have been deleted from the document but not from metadata
|
|
|
var checkDeleted = function (Env) {
|
|
|
if (!Env.comments || !Env.comments.data) { return; }
|
|
|
|
|
|
// Don't recheck if there were no change
|
|
|
var str = Env.$inner[0].innerHTML;
|
|
|
if (str === Env.oldCheck) { return; }
|
|
|
Env.oldCheck = str;
|
|
|
|
|
|
// 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) {
|
|
|
Env.editor.plugins.comments.uncomment(id, [el]);
|
|
|
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); }
|
|
|
|
|
|
// 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;
|
|
|
}
|
|
|
|
|
|
Env.$container.find('.cp-comment-form').remove();
|
|
|
var form = getCommentForm(Env, false, function (val) {
|
|
|
$(form).remove();
|
|
|
Env.$inner.focus();
|
|
|
|
|
|
if (!val) { return; }
|
|
|
if (!Env.editor.getSelection().getSelectedText()) {
|
|
|
// text has been deleted by another user while we were typing our comment?
|
|
|
return void UI.warn(Messages.error);
|
|
|
}
|
|
|
// Don't override existing data
|
|
|
if (Env.comments.data[uid]) { return; }
|
|
|
|
|
|
var myId = updateAuthorData(Env);
|
|
|
Env.comments.data[uid] = {
|
|
|
m: [{
|
|
|
u: myId,
|
|
|
t: +new Date(),
|
|
|
m: val,
|
|
|
v: canonicalize(Env.editor.getSelection().getSelectedText())
|
|
|
}]
|
|
|
};
|
|
|
// There may be a race condition between updateMetadata and addMark that causes
|
|
|
// * updateMetadata first: comment not rendered (redrawComments called
|
|
|
// before addMark)
|
|
|
// * addMark first: comment deleted (checkDeleted called before updateMetadata)
|
|
|
// ==> we're going to call updateMetadata first, and we'll invalidate the cache
|
|
|
// of rendered comments to display them properly in redrawComments
|
|
|
updateMetadata(Env);
|
|
|
addMark();
|
|
|
|
|
|
Env.framework.localChange();
|
|
|
|
|
|
Env.oldComments = undefined;
|
|
|
});
|
|
|
Env.$container.prepend(form).show();
|
|
|
};
|
|
|
};
|
|
|
|
|
|
var onContentUpdate = function (Env) {
|
|
|
if (!Env.ready) { return; }
|
|
|
// Check deleted
|
|
|
onChange(Env);
|
|
|
checkDeleted(Env);
|
|
|
};
|
|
|
|
|
|
var ready = function (Env) {
|
|
|
Env.ready = 0;
|
|
|
};
|
|
|
|
|
|
Comments.create = function (cfg) {
|
|
|
var Env = cfg;
|
|
|
Env.comments = Util.clone(COMMENTS);
|
|
|
|
|
|
addAddCommentHandler(Env);
|
|
|
|
|
|
// Unselect comment when clicking outside
|
|
|
$(window).click(function (e) {
|
|
|
if ($(e.target).closest('.cp-comment-container').length) {
|
|
|
return;
|
|
|
}
|
|
|
Env.$container.find('.cp-comment-active').removeClass('cp-comment-active');
|
|
|
Env.$inner.find('comment.active').removeClass('active');
|
|
|
});
|
|
|
// Unselect comment when clicking on another part of the doc
|
|
|
Env.$inner.on('click', function (e) {
|
|
|
if ($(e.target).closest('comment').length) { return; }
|
|
|
Env.$container.find('.cp-comment-active').removeClass('cp-comment-active');
|
|
|
Env.$inner.find('comment.active').removeClass('active');
|
|
|
});
|
|
|
Env.$inner.on('click', 'comment', function (e) {
|
|
|
var $comment = $(e.target);
|
|
|
var uid = $comment.attr('data-uid');
|
|
|
if (!uid) { return; }
|
|
|
Env.$container.find('.cp-comment-container[data-uid="'+uid+'"]').click();
|
|
|
});
|
|
|
|
|
|
var call = function (f) {
|
|
|
return function () {
|
|
|
try {
|
|
|
[].unshift.call(arguments, Env);
|
|
|
return f.apply(null, arguments);
|
|
|
} catch (e) {
|
|
|
console.error(e);
|
|
|
}
|
|
|
};
|
|
|
};
|
|
|
|
|
|
Env.metadataMgr.onChange(call(onChange));
|
|
|
|
|
|
return {
|
|
|
onContentUpdate: call(onContentUpdate),
|
|
|
ready: call(ready)
|
|
|
};
|
|
|
};
|
|
|
|
|
|
return Comments;
|
|
|
});
|