You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
cryptpad/www/code/markers.js

717 lines
27 KiB
JavaScript

5 years ago
define([
'/common/common-util.js',
'/common/sframe-common-codemirror.js',
'/customize/messages.js',
'/bower_components/chainpad/chainpad.dist.js',
], function (Util, SFCodeMirror, Messages, ChainPad) {
var Markers = {};
/* TODO Known Issues
* 1. ChainPad diff is not completely accurate: we're not aware of the other user's cursor
position so if they insert an "a" in the middle of "aaaaa", the diff will think that
the "a" was inserted at the end of this sequence. This is not an issue for the content
but it will cause issues for the colors
2. ChainPad doesn't always provide the good result in case of conflict (?)
e.g. Alice is inserting "pew" at offset 10, Bob is removing 1 character at offset 10
The expected result is to have "pew" and the following character deleted
In some cases, the result is "ew" inserted and the following character not deleted
*/
var debug = function (Env, level, obj, logObject) {
if (!Env.devMode) { return function () {}; }
var f = console.log;
if (typeof(console[level]) === "function") {
f = console[level];
5 years ago
}
if (logObject) { return void f(obj); }
f(JSON.stringify(obj));
};
var MARK_OPACITY = 0.5;
Messages.cba_writtenBy = 'Written by <em>{0}</em>'; // XXX
5 years ago
var addMark = function (Env, from, to, uid) {
if (!Env.enabled) { return; }
5 years ago
var author = Env.authormarks.authors[uid] || {};
uid = Number(uid);
var name = Util.fixHTML(author.name || Messages.anonymous);
var col = Util.hexToRGB(author.color);
var rgba = 'rgba('+col[0]+','+col[1]+','+col[2]+','+Env.opacity+');';
5 years ago
return Env.editor.markText(from, to, {
inclusiveLeft: uid === Env.myAuthorId,
inclusiveRight: uid === Env.myAuthorId,
css: "background-color: " + rgba,
5 years ago
attributes: {
title: Env.opacity ? Messages._getKey('cba_writtenBy', [name]) : undefined,
5 years ago
'data-type': 'authormark',
'data-uid': uid
}
});
};
var sortMarks = function (a, b) {
if (!Array.isArray(b)) { return -1; }
if (!Array.isArray(a)) { return 1; }
// Check line
if (a[1] < b[1]) { return -1; }
if (a[1] > b[1]) { return 1; }
// Same line: check start offset
if (a[2] < b[2]) { return -1; }
if (a[2] > b[2]) { return 1; }
return 0;
};
5 years ago
/* Formats:
[uid, startLine, startCh, endLine, endCh] (multi line)
[uid, startLine, startCh, endCh] (single line)
[uid, startLine, startCh] (single character)
*/
5 years ago
var parseMark = Markers.parseMark = function (array) {
if (!Array.isArray(array)) { return {}; }
var multiline = typeof(array[4]) !== "undefined";
var singleChar = typeof(array[3]) === "undefined";
return {
uid: array[0],
5 years ago
startLine: array[1],
startCh: array[2],
endLine: multiline ? array[3] : array[1],
endCh: singleChar ? (array[2]+1) : (multiline ? array[4] : array[3])
};
};
var setAuthorMarks = function (Env, authormarks) {
authormarks = authormarks || {};
if (!authormarks.marks) { authormarks.marks = []; }
if (!authormarks.authors) { authormarks.authors = {}; }
Env.oldMarks = Env.authormarks;
Env.authormarks = authormarks;
};
var getAuthorMarks = function (Env) {
return Env.authormarks;
};
var updateAuthorMarks = function (Env) {
if (!Env.enabled) { return; }
5 years ago
// get author marks
var _marks = [];
var all = [];
var i = 0;
Env.editor.getAllMarks().forEach(function (mark) {
var pos = mark.find();
var attributes = mark.attributes || {};
if (!pos || attributes['data-type'] !== 'authormark') { return; }
5 years ago
var uid = Number(attributes['data-uid']) || 0;
all.forEach(function (obj) {
if (obj.uid !== uid) { return; }
if (obj.removed) { return; }
// Merge left
if (obj.pos.to.line === pos.from.line && obj.pos.to.ch === pos.from.ch) {
obj.removed = true;
_marks[obj.index] = undefined;
obj.mark.clear();
mark.clear();
mark = addMark(Env, obj.pos.from, pos.to, uid);
pos.from = obj.pos.from;
return;
}
// Merge right
if (obj.pos.from.line === pos.to.line && obj.pos.from.ch === pos.to.ch) {
obj.removed = true;
_marks[obj.index] = undefined;
obj.mark.clear();
mark.clear();
mark = addMark(Env, pos.from, obj.pos.to, uid);
pos.to = obj.pos.to;
}
});
var array = [uid, pos.from.line, pos.from.ch];
if (pos.from.line === pos.to.line && pos.to.ch > (pos.from.ch+1)) {
// If there is more than 1 character, add the "to" character
array.push(pos.to.ch);
} else if (pos.from.line !== pos.to.line) {
// If the mark is on more than one line, add the "to" line data
Array.prototype.push.apply(array, [pos.to.line, pos.to.ch]);
}
_marks.push(array);
all.push({
uid: uid,
pos: pos,
mark: mark,
index: i
});
i++;
});
_marks.sort(sortMarks);
debug(Env, 'warn', _marks);
5 years ago
Env.authormarks.marks = _marks.filter(Boolean);
};
// Fix all marks located after the given operation in the provided document
var fixMarksFromOp = function (Env, op, marks, doc) {
var pos = SFCodeMirror.posToCursor(op.offset, doc); // pos of start offset
var rPos = SFCodeMirror.posToCursor(op.offset + op.toRemove, doc); // end of removed content
var removed = doc.slice(op.offset, op.offset + op.toRemove).split('\n'); // removed content
var added = op.toInsert.split('\n'); // added content
var posEndLine = pos.line + added.length - 1; // end line after op
var posEndCh = added[added.length - 1].length; // end ch after op
var addLine = added.length - removed.length;
var addCh = added[added.length - 1].length - removed[removed.length - 1].length;
if (addLine > 0) { addCh -= pos.ch; }
else if (addLine < 0) { addCh += pos.ch; }
else { posEndCh += pos.ch; }
marks.forEach(function (mark, i) {
var p = parseMark(mark);
// Don't update marks located before the operation
if (p.endLine < pos.line || (p.endLine === pos.line && p.endCh < pos.ch)) { return; }
// Remove markers that have been deleted by my changes
if ((p.startLine > pos.line || (p.startLine === pos.line && p.startCh >= pos.ch)) &&
(p.endLine < rPos.line || (p.endLine === rPos.line && p.endCh <= rPos.ch))) {
mark[i] = undefined;
return;
}
// Update markers that have been cropped right
if (p.endLine < rPos.line || (p.endLine === rPos.line && p.endCh <= rPos.ch)) {
mark[3] = pos.line;
mark[4] = pos.ch;
return;
}
// Update markers that have been cropped left. This markers will be affected by
// my toInsert so don't abort
if (p.startLine < rPos.line || (p.startLine === pos.line && p.startCh < pos.ch)) {
mark[1] = rPos.line;
mark[2] = rPos.ch;
}
// Apply my toInsert the to remaining marks
mark[1] += addLine;
if (typeof(mark[4]) !== "undefined") { mark[3] += addLine; }
if (mark[1] === posEndLine) {
mark[2] += addCh;
if (typeof(mark[4]) === "undefined" && typeof(mark[3]) !== "undefined") {
mark[3] += addCh;
} else if (typeof(mark[4]) !== "undefined" && mark[3] === posEndLine) {
mark[4] += addCh;
}
}
});
if (op.toInsert.length) {
marks.push([Env.myAuthorId, pos.line, pos.ch, posEndLine, posEndCh]);
}
marks.sort(sortMarks);
};
5 years ago
// Remove marks added by OT and fix the incorrect ones
// first: data about the change with the lowest offset
// last: data about the change with the latest offset
// in the comments, "I" am "first"
5 years ago
var fixMarks = function (Env, first, last, content, toKeepEnd) {
5 years ago
var toKeep = [];
var toJoin = {};
debug(Env, 'error', "Fix marks");
debug(Env, 'warn', first);
debug(Env, 'warn', last);
5 years ago
if (first.me !== last.me) {
// Get their start position compared to the authDoc
var lastAuthOffset = last.offset + last.total;
var lastAuthPos = SFCodeMirror.posToCursor(lastAuthOffset, last.doc);
// Get their start position compared to the localDoc
var lastLocalOffset = last.offset + first.total;
var lastLocalPos = SFCodeMirror.posToCursor(lastLocalOffset, first.doc);
// Keep their changes in the marks (after their offset)
last.marks.some(function (array, i) {
var p = parseMark(array);
// End of the mark before offset? ignore
if (p.endLine < lastAuthPos.line) { return; }
// Take everything from the first mark ending after the pos
if (p.endLine > lastAuthPos.line || p.endCh >= lastAuthPos.ch) {
toKeep = last.marks.slice(i);
last.marks.splice(i);
return true;
}
});
// Keep my marks (based on currentDoc) before their changes
first.marks.some(function (array, i) {
var p = parseMark(array);
// End of the mark before offset? ignore
if (p.endLine < lastLocalPos.line) { return; }
// Take everything from the first mark ending after the pos
if (p.endLine > lastLocalPos.line || p.endCh >= lastLocalPos.ch) {
first.marks.splice(i);
return true;
}
});
}
5 years ago
// If we still have markers in "first", store the last one so that we can "join"
// everything at the end
if (first.marks.length) {
var toJoinMark = first.marks[first.marks.length - 1].slice();
toJoin = parseMark(toJoinMark);
}
5 years ago
// Add the new markers to the result
Array.prototype.unshift.apply(toKeepEnd, toKeep);
debug(Env, 'warn', toJoin);
debug(Env, 'warn', toKeep);
debug(Env, 'warn', toKeepEnd);
5 years ago
// Fix their offset: compute added lines and added characters on the last line
// using the chainpad operation data (toInsert and toRemove)
var pos = SFCodeMirror.posToCursor(first.offset, content);
var removed = content.slice(first.offset, first.offset + first.toRemove).split('\n');
var added = first.toInsert.split('\n');
var posEndLine = pos.line + added.length - 1; // end line after op
5 years ago
var addLine = added.length - removed.length;
var addCh = added[added.length - 1].length - removed[removed.length - 1].length;
if (addLine > 0) { addCh -= pos.ch; }
if (addLine < 0) { addCh += pos.ch; }
5 years ago
toKeepEnd.forEach(function (array) {
5 years ago
// Push to correct lines
array[1] += addLine;
if (typeof(array[4]) !== "undefined") { array[3] += addLine; }
// If they have markers on my end line, push their "ch"
if (array[1] === posEndLine) {
5 years ago
array[2] += addCh;
// If they have no end line, it means end line === start line,
// so we also push their end offset
if (typeof(array[4]) === "undefined" && typeof(array[3]) !== "undefined") {
array[3] += addCh;
} else if (typeof(array[4]) !== "undefined" && array[3] === posEndLine) {
array[4] += addCh;
}
5 years ago
}
});
if (toKeep.length && toJoin && typeof(toJoin.endLine) !== "undefined"
&& typeof(toJoin.endCh) !== "undefined") {
5 years ago
// Make sure the marks are joined correctly:
// fix the start position of the marks to keep
// Note: we must preserve the same end for this mark if it was single line!
if (typeof(toKeepEnd[0][4]) === "undefined") { // Single line
toKeepEnd[0][4] = toKeepEnd[0][3] || (toKeepEnd[0][2]+1); // preserve end ch
toKeepEnd[0][3] = toKeepEnd[0][1]; // preserve end line
}
5 years ago
toKeepEnd[0][1] = toJoin.endLine;
toKeepEnd[0][2] = toJoin.endCh;
}
debug(Env, 'log', 'Fixed');
debug(Env, 'warn', toKeepEnd);
5 years ago
};
var checkMarks = function (Env, userDoc) {
5 years ago
var chainpad = Env.framework._.cpNfInner.chainpad;
var editor = Env.editor;
var CodeMirror = Env.CodeMirror;
setAuthorMarks(Env, userDoc.authormarks);
if (!Env.enabled) { return; }
debug(Env, 'error', 'Check marks');
var authDoc = JSON.parse(chainpad.getAuthDoc() || '{}');
if (!authDoc.content || !userDoc.content) { return; }
var authPatch = chainpad.getAuthBlock();
if (authPatch.isFromMe) {
debug(Env, 'log', 'Switch branch, from me');
debug(Env, 'log', authDoc.content);
debug(Env, 'log', authDoc.authormarks.marks);
debug(Env, 'log', userDoc.content);
// We're switching to a different branch that was created by us.
// We can't trust localDoc anymore because it contains data from the other branch
// It means the only changes that we need to consider are ours.
// Diff between userDoc and authDoc to see what we changed
5 years ago
var _myOps = ChainPad.Diff.diff(authDoc.content, userDoc.content).reverse();
var authormarks = Util.clone(authDoc.authormarks);
5 years ago
_myOps.forEach(function (op) {
fixMarksFromOp(Env, op, authormarks.marks, authDoc.content);
});
debug(Env, 'log', 'Fixed marks');
debug(Env, 'warn', authormarks.marks);
setAuthorMarks(Env, authormarks);
return;
}
5 years ago
var oldMarks = Env.oldMarks;
5 years ago
if (authDoc.content === userDoc.content) { return; } // No uncommitted work
if (!userDoc.authormarks || !Array.isArray(userDoc.authormarks.marks)) { return; }
debug(Env, 'warn', 'Begin...');
5 years ago
var localDoc = CodeMirror.canonicalize(editor.getValue());
var commonParent = chainpad.getAuthBlock().getParent().getContent().doc;
var content = JSON.parse(commonParent || '{}').content || '';
var theirOps = ChainPad.Diff.diff(content, authDoc.content);
var myOps = ChainPad.Diff.diff(content, localDoc);
debug(Env, 'log', theirOps);
debug(Env, 'log', myOps);
5 years ago
if (!myOps.length || !theirOps.length) { return; }
// If I have uncommited content when receiving a remote patch, all the operations
// placed after someone else's changes will create marker issues. We have to fix it
var sorted = [];
5 years ago
var myTotal = 0;
var theirTotal = 0;
var parseOp = function (me) {
return function (op) {
var size = (op.toInsert.length - op.toRemove);
sorted.push({
5 years ago
me: me,
offset: op.offset,
toInsert: op.toInsert,
toRemove: op.toRemove,
size: size,
marks: (me ? (oldMarks && oldMarks.marks)
: (authDoc.authormarks && authDoc.authormarks.marks)) || [],
doc: me ? localDoc : authDoc.content
});
5 years ago
if (me) { myTotal += size; }
else { theirTotal += size; }
};
};
myOps.forEach(parseOp(true));
theirOps.forEach(parseOp(false));
// Sort the operation in reverse order of offset
// If an operation from them has the same offset than an operation from me, put mine first
sorted.sort(function (a, b) {
if (a.offset === b.offset) {
return a.me ? -1 : 1;
}
return b.offset - a.offset;
});
debug(Env, 'log', sorted);
5 years ago
// We start from the end so that we don't have to fix the offsets everytime
var prev;
var toKeepEnd = [];
sorted.forEach(function (op) {
5 years ago
// Not the same author? fix!
if (prev) {
5 years ago
// Provide the new "totals"
prev.total = prev.me ? myTotal : theirTotal;
op.total = op.me ? myTotal : theirTotal;
// Fix the markers
5 years ago
fixMarks(Env, op, prev, content, toKeepEnd);
5 years ago
}
if (op.me) { myTotal -= op.size; }
else { theirTotal -= op.size; }
prev = op;
});
debug(Env, 'log', toKeepEnd);
5 years ago
// We now have all the markers located after the first operation (ordered by offset).
// Prepend the markers placed before this operation
var first = sorted[sorted.length - 1];
5 years ago
if (first) { Array.prototype.unshift.apply(toKeepEnd, first.marks); }
// Commit our new markers
Env.authormarks.marks = toKeepEnd;
debug(Env, 'warn', toKeepEnd);
debug(Env, 'warn', '...End');
5 years ago
};
// Reset marks displayed in CodeMirror to the marks stored in Env
5 years ago
var setMarks = function (Env) {
// on remote update: remove all marks, add new marks if colors are enabled
5 years ago
Env.editor.getAllMarks().forEach(function (marker) {
if (marker.attributes && marker.attributes['data-type'] === 'authormark') {
marker.clear();
}
});
if (!Env.enabled) { return; }
debug(Env, 'error', 'setMarks');
debug(Env, 'log', Env.authormarks.marks);
5 years ago
var authormarks = Env.authormarks;
authormarks.marks.forEach(function (mark) {
var uid = mark[0];
if (!authormarks.authors || !authormarks.authors[uid]) { return; }
var from = {};
var to = {};
from.line = mark[1];
from.ch = mark[2];
if (mark.length === 3) {
to.line = mark[1];
to.ch = mark[2]+1;
} else if (mark.length === 4) {
to.line = mark[1];
to.ch = mark[3];
} else if (mark.length === 5) {
to.line = mark[3];
to.ch = mark[4];
}
// Remove marks that are placed under this one
5 years ago
try {
Env.editor.findMarks(from, to).forEach(function (mark) {
if (mark.attributes['data-type'] !== 'authormark') { return; }
mark.clear();
});
} catch (e) {
console.warn(mark, JSON.stringify(authormarks.marks));
console.error(from, to);
5 years ago
console.error(e);
}
5 years ago
addMark(Env, from, to, uid);
});
};
var setMyData = function (Env) {
if (!Env.enabled) { return; }
5 years ago
var userData = Env.common.getMetadataMgr().getUserData();
var old = Env.authormarks.authors[Env.myAuthorId];
Env.authormarks.authors[Env.myAuthorId] = {
name: userData.name,
curvePublic: userData.curvePublic,
color: userData.color
};
if (!old || (old.name === userData.name && old.color === userData.color)) { return; }
5 years ago
return true;
};
var localChange = function (Env, change, cb) {
cb = cb || function () {};
if (!Env.enabled) { return void cb(); }
debug(Env, 'error', 'Local change');
debug(Env, 'log', change, true);
5 years ago
if (change.origin === "setValue") {
// If the content is changed from a remote patch, we call localChange
// in "onContentUpdate" directly
return;
}
if (change.text === undefined || ['+input', 'paste'].indexOf(change.origin) === -1) {
return void cb();
}
// add new author mark if text is added. marks from removed text are removed automatically
// change.to is not always correct, fix it!
var to_add = {
line: change.from.line + change.text.length-1,
};
if (change.text.length > 1) {
// Multiple lines => take the length of the text added to the last line
to_add.ch = change.text[change.text.length-1].length;
} else {
// Single line => use the "from" position and add the length of the text
to_add.ch = change.from.ch + change.text[change.text.length-1].length;
}
// If my text is inside an existing mark:
// * if it's my mark, do nothing
// * if it's someone else's mark, break it
// We can only have one author mark at a given position, but there may be
// another mark (cursor selection...) at this position so we use ".some"
var toSplit, abort;
5 years ago
Env.editor.findMarks(change.from, to_add).some(function (mark) {
if (!mark.attributes) { return; }
if (mark.attributes['data-type'] !== 'authormark') { return; }
if (mark.attributes['data-uid'] !== Env.myAuthorId) {
toSplit = {
mark: mark,
uid: mark.attributes['data-uid']
};
} else {
// This is our mark: abort to avoid making a new one
abort = true;
}
return true;
});
if (abort) { return void cb(); }
// Add my data to the doc if it's missing
if (!Env.authormarks.authors[Env.myAuthorId]) {
setMyData(Env);
}
if (toSplit && toSplit.mark && typeof(toSplit.uid) !== "undefined") {
// Break the other user's mark if needed
var _pos = toSplit.mark.find();
toSplit.mark.clear();
addMark(Env, _pos.from, change.from, toSplit.uid); // their mark, 1st part
addMark(Env, change.from, to_add, Env.myAuthorId); // my mark
addMark(Env, to_add, _pos.to, toSplit.uid); // their mark, 2nd part
} else {
// Add my mark
addMark(Env, change.from, to_add, Env.myAuthorId);
}
cb();
};
Messages.cba_show = "Show user colors"; // XXX
Messages.cba_hide = "Hide user colors"; // XXX
var setButton = function (Env, $button) {
var toggle = function () {
var tippy = $button[0] && $button[0]._tippy;
if (Env.opacity) {
Env.opacity = 0;
if (tippy) { tippy.title = Messages.cba_show; }
else { $button.attr('title', Messages.cba_show); }
$button.removeClass("cp-toolbar-button-active");
} else {
Env.opacity = MARK_OPACITY;
if (tippy) { tippy.title = Messages.cba_hide; }
else { $button.attr('title', Messages.cba_hide); }
$button.addClass("cp-toolbar-button-active");
}
};
toggle();
Env.$button = $button;
$button.click(function() {
toggle();
setMarks(Env);
});
};
5 years ago
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) {
var existing = Object.keys(Env.authormarks.authors || {});
if (!Env.common.isLoggedIn()) { return authorUid(existing); }
var userData = Env.common.getMetadataMgr().getUserData();
var uid;
existing.some(function (id) {
var author = Env.authormarks.authors[id] || {};
if (author.curvePublic !== userData.curvePublic) { return; }
uid = Number(id);
return true;
});
return uid || authorUid(existing);
};
var ready = function (Env) {
var metadataMgr = Env.common.getMetadataMgr();
var md = metadataMgr.getMetadata();
Env.ready = true;
5 years ago
Env.myAuthorId = getAuthorId(Env);
Env.enabled = md.enableColors;
if (Env.enabled) {
if (Env.$button) { Env.$button.show(); }
5 years ago
setMarks(Env);
}
};
Markers.create = function (config) {
var Env = config;
Env.authormarks = {
authors: {},
marks: []
};
Env.enabled = false;
5 years ago
Env.myAuthorId = 0;
var metadataMgr = Env.common.getMetadataMgr();
metadataMgr.onChange(function () {
var md = metadataMgr.getMetadata();
// If the state has changed in the pad, change the Env too
if (Env.enabled !== md.enableColors) {
Env.enabled = md.enableColors;
if (!Env.enabled) {
// Reset marks
Env.authormarks = {
authors: {},
marks: []
};
setMarks(Env);
if (Env.$button) { Env.$button.hide(); }
} else {
Env.myAuthorId = getAuthorId(Env);
if (Env.$button) { Env.$button.show(); }
}
if (Env.ready) { Env.framework.localChange(); }
}
// If the markers are disabled or if I haven't pushed content since the last reset,
// don't update my data
if (!Env.enabled || !Env.myAuthorId || !Env.authormarks.authors[Env.myAuthorId]) {
return;
}
5 years ago
// Update my data
var changed = setMyData(Env);
if (changed) {
setMarks(Env);
Env.framework.localChange();
5 years ago
}
});
var call = function (f) {
return function () {
try {
[].unshift.call(arguments, Env);
return f.apply(null, arguments);
} catch (e) {
console.error(e);
}
5 years ago
};
};
return {
addMark: call(addMark),
getAuthorMarks: call(getAuthorMarks),
updateAuthorMarks: call(updateAuthorMarks),
checkMarks: call(checkMarks),
5 years ago
setMarks: call(setMarks),
localChange: call(localChange),
ready: call(ready),
setButton: call(setButton)
5 years ago
};
};
return Markers;
});