diff --git a/customize.dist/src/less2/include/modals-ui-elements.less b/customize.dist/src/less2/include/modals-ui-elements.less index 0cc580ec0..c407c8523 100644 --- a/customize.dist/src/less2/include/modals-ui-elements.less +++ b/customize.dist/src/less2/include/modals-ui-elements.less @@ -26,6 +26,11 @@ // Properties modal .cp-app-prop { margin-bottom: 10px; + .cp-app-prop-hint { + color: @cryptpad_text_col; + font-size: 0.8em; + margin-bottom: 5px; + } .cp-app-prop-size-container { height: 20px; background-color: @colortheme_logo-2; diff --git a/www/code/inner.js b/www/code/inner.js index 28b90b07c..09158d952 100644 --- a/www/code/inner.js +++ b/www/code/inner.js @@ -7,12 +7,14 @@ define([ '/common/sframe-common-codemirror.js', '/common/common-util.js', '/common/common-hash.js', + '/code/markers.js', '/common/modes.js', '/common/visible.js', '/common/TypingTests.js', '/customize/messages.js', 'cm/lib/codemirror', + 'css!cm/lib/codemirror.css', 'css!cm/addon/dialog/dialog.css', 'css!cm/addon/fold/foldgutter.css', @@ -50,6 +52,7 @@ define([ SFCodeMirror, Util, Hash, + Markers, Modes, Visible, TypingTest, @@ -301,6 +304,20 @@ define([ var previewPane = mkPreviewPane(editor, CodeMirror, framework, isPresentMode); var markdownTb = mkMarkdownTb(editor, framework); + var markers = Markers.create({ + common: common, + framework: framework, + CodeMirror: CodeMirror, + devMode: privateData.devMode, + editor: editor + }); + + var $showAuthorColorsButton = framework._.sfCommon.createButton('', true, { + icon: 'fa-paint-brush', + }).hide(); + framework._.toolbar.$rightside.append($showAuthorColorsButton); + markers.setButton($showAuthorColorsButton); + var $print = $('#cp-app-code-print'); var $content = $('#cp-app-code-preview-content'); mkPrintButton(framework, $content, $print); @@ -323,15 +340,23 @@ define([ CodeMirror.configureTheme(common); } - //// - framework.onContentUpdate(function (newContent) { var highlightMode = newContent.highlightMode; if (highlightMode && highlightMode !== CodeMirror.highlightMode) { CodeMirror.setMode(highlightMode, evModeChange.fire); } + + // Fix the markers offsets + markers.checkMarks(newContent); + + // Apply the text content CodeMirror.contentUpdate(newContent); previewPane.draw(); + + // Apply the markers + markers.setMarks(); + + framework.localChange(); }); framework.setContentGetter(function () { @@ -339,6 +364,10 @@ define([ var content = CodeMirror.getContent(); content.highlightMode = CodeMirror.highlightMode; previewPane.draw(); + + markers.updateAuthorMarks(); + content.authormarks = markers.getAuthorMarks(); + return content; }); @@ -368,6 +397,15 @@ define([ //console.log("%s => %s", CodeMirror.highlightMode, CodeMirror.$language.val()); } + if (newPad && Util.find(privateData, ['settings', 'code', 'enableColors'])) { + var metadataMgr = common.getMetadataMgr(); + var md = Util.clone(metadataMgr.getMetadata()); + md.enableColors = true; + metadataMgr.updateMetadata(md); + } + + markers.ready(); + var fmConfig = { dropArea: $('.CodeMirror'), body: $('body'), @@ -385,7 +423,7 @@ define([ }); framework.onDefaultContentNeeded(function () { - editor.setValue(''); //Messages.codeInitialState); + editor.setValue(''); }); framework.setFileExporter(CodeMirror.getContentExtension, CodeMirror.fileExporter); @@ -402,11 +440,14 @@ define([ framework.setNormalizer(function (c) { return { content: c.content, - highlightMode: c.highlightMode + highlightMode: c.highlightMode, + authormarks: c.authormarks }; }); - editor.on('change', framework.localChange); + editor.on('change', function( cm, change ) { + markers.localChange(change, framework.localChange); + }); framework.start(); diff --git a/www/code/markers.js b/www/code/markers.js new file mode 100644 index 000000000..4b90da721 --- /dev/null +++ b/www/code/markers.js @@ -0,0 +1,743 @@ +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 () {}; + + var MARK_OPACITY = 0.5; + var DEFAULT = { + authors: {}, + marks: [[-1, 0, 0, Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER]] + }; + + var addMark = function (Env, from, to, uid) { + if (!Env.enabled) { return; } + var author = Env.authormarks.authors[uid] || {}; + if (uid === -1) { + return void Env.editor.markText(from, to, { + css: "background-color: transparent", + attributes: { + 'data-type': 'authormark', + 'data-uid': 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+');'; + return Env.editor.markText(from, to, { + inclusiveLeft: uid === Env.myAuthorId, + inclusiveRight: uid === Env.myAuthorId, + css: "background-color: " + rgba, + attributes: { + title: Env.opacity ? Messages._getKey('cba_writtenBy', [name]) : undefined, + '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; + }; + + /* Formats: + [uid, startLine, startCh, endLine, endCh] (multi line) + [uid, startLine, startCh, endCh] (single line) + [uid, startLine, startCh] (single character) + */ + 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], + 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) { + if (!Env.enabled) { + Env.authormarks = {}; + return; + } + authormarks = authormarks || {}; + if (!authormarks.marks) { authormarks.marks = Util.clone(DEFAULT.marks); } + if (!authormarks.authors) { authormarks.authors = Util.clone(DEFAULT.authors); } + Env.oldMarks = Env.authormarks; + Env.authormarks = authormarks; + }; + + var getAuthorMarks = function (Env) { + return Env.authormarks; + }; + + var updateAuthorMarks = function (Env) { + if (!Env.enabled) { return; } + + // 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; } + + + 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('warn', _marks); + 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; } + + var splitted; + + marks.forEach(function (mark, i) { + if (!mark) { return; } + 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))) { + marks[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 === rPos.line && p.startCh < rPos.ch)) { + // If our change will split an existing mark, put the existing mark after the change + // and create a new mark before + if (p.startLine < pos.line || (p.startLine === pos.line && p.startCh < pos.ch)) { + splitted = [mark[0], mark[1], mark[2], pos.line, 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]); + } + if (splitted) { + marks.push(splitted); + } + marks.sort(sortMarks); + }; + + // 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" + var fixMarks = function (Env, first, last, content, toKeepEnd) { + var toKeep = []; + var toJoin = {}; + + debug('error', "Fix marks"); + debug('warn', first); + debug('warn', last); + + 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; + } + }); + } + + // 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); + } + + + // Add the new markers to the result + Array.prototype.unshift.apply(toKeepEnd, toKeep); + + debug('warn', toJoin); + debug('warn', toKeep); + debug('warn', toKeepEnd); + + // 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 + 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; } + toKeepEnd.forEach(function (array) { + // 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) { + 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; + } + } + }); + + if (toKeep.length && toJoin && typeof(toJoin.endLine) !== "undefined" + && typeof(toJoin.endCh) !== "undefined") { + // 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 + } + toKeepEnd[0][1] = toJoin.endLine; + toKeepEnd[0][2] = toJoin.endCh; + } + + debug('log', 'Fixed'); + debug('warn', toKeepEnd); + }; + + var checkMarks = function (Env, userDoc) { + + var chainpad = Env.framework._.cpNfInner.chainpad; + var editor = Env.editor; + var CodeMirror = Env.CodeMirror; + + setAuthorMarks(Env, userDoc.authormarks); + + if (!Env.enabled) { return; } + + debug('error', 'Check marks'); + + var authDoc = JSON.parse(chainpad.getAuthDoc() || '{}'); + if (!authDoc.content || !userDoc.content) { return; } + + var authPatch = chainpad.getAuthBlock(); + if (authPatch.isFromMe) { + debug('log', 'Switch branch, from me'); + debug('log', authDoc.content); + debug('log', authDoc.authormarks.marks); + debug('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 + var _myOps = ChainPad.Diff.diff(authDoc.content, userDoc.content).reverse(); + var authormarks = Util.clone(authDoc.authormarks); + _myOps.forEach(function (op) { + fixMarksFromOp(Env, op, authormarks.marks, authDoc.content); + }); + authormarks.marks = authormarks.marks.filter(Boolean); + debug('log', 'Fixed marks'); + debug('warn', authormarks.marks); + setAuthorMarks(Env, authormarks); + return; + } + + + var oldMarks = Env.oldMarks; + + + if (authDoc.content === userDoc.content) { return; } // No uncommitted work + + if (!userDoc.authormarks || !Array.isArray(userDoc.authormarks.marks)) { return; } + + debug('warn', 'Begin...'); + + 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('log', theirOps); + debug('log', myOps); + + 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 = []; + + var myTotal = 0; + var theirTotal = 0; + var parseOp = function (me) { + return function (op) { + var size = (op.toInsert.length - op.toRemove); + + sorted.push({ + 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 + }); + + 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('log', sorted); + + // We start from the end so that we don't have to fix the offsets everytime + var prev; + var toKeepEnd = []; + sorted.forEach(function (op) { + + // Not the same author? fix! + if (prev) { + // Provide the new "totals" + prev.total = prev.me ? myTotal : theirTotal; + op.total = op.me ? myTotal : theirTotal; + // Fix the markers + fixMarks(Env, op, prev, content, toKeepEnd); + } + + if (op.me) { myTotal -= op.size; } + else { theirTotal -= op.size; } + prev = op; + }); + + debug('log', toKeepEnd); + + // 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]; + if (first) { Array.prototype.unshift.apply(toKeepEnd, first.marks); } + + // Commit our new markers + Env.authormarks.marks = toKeepEnd; + + debug('warn', toKeepEnd); + debug('warn', '...End'); + }; + + // Reset marks displayed in CodeMirror to the marks stored in Env + var setMarks = function (Env) { + // on remote update: remove all marks, add new marks if colors are enabled + Env.editor.getAllMarks().forEach(function (marker) { + if (marker.attributes && marker.attributes['data-type'] === 'authormark') { + marker.clear(); + } + }); + + if (!Env.enabled) { return; } + + debug('error', 'setMarks'); + debug('log', Env.authormarks.marks); + + var authormarks = Env.authormarks; + authormarks.marks.forEach(function (mark) { + var uid = mark[0]; + if (uid !== -1 && (!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 + try { + Env.editor.findMarks(from, to).forEach(function (mark) { + if (!mark || !mark.attributes || mark.attributes['data-type'] !== 'authormark') { return; } + mark.clear(); + }); + } catch (e) { + console.warn(mark, JSON.stringify(authormarks.marks)); + console.error(from, to); + console.error(e); + } + + addMark(Env, from, to, uid); + }); + }; + + var setMyData = function (Env) { + if (!Env.enabled) { return; } + + 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; } + return true; + }; + + var localChange = function (Env, change, cb) { + cb = cb || function () {}; + + if (!Env.enabled) { return void cb(); } + + debug('error', 'Local change'); + debug('log', change, true); + + 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; + + + 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(); + }; + + 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); + }); + }; + + 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 || {}).map(Number); + 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; + Env.myAuthorId = getAuthorId(Env); + Env.enabled = md.enableColors; + + if (Env.enabled) { + if (Env.$button) { Env.$button.show(); } + setMarks(Env); + } + }; + + Markers.create = function (config) { + var Env = config; + Env.authormarks = Util.clone(DEFAULT); + Env.enabled = false; + Env.myAuthorId = 0; + + if (Env.devMode) { + debug = function (level, obj, logObject) { + var f = console.log; + if (typeof(console[level]) === "function") { + f = console[level]; + } + if (logObject) { return void f(obj); } + }; + } + + 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 = {}; + setMarks(Env); + if (Env.$button) { Env.$button.hide(); } + } else { + Env.myAuthorId = getAuthorId(Env); + // If it's a reset, add initial marker + if (!Env.authormarks.marks || !Env.authormarks.marks.length) { + Env.authormarks = Util.clone(DEFAULT); + setMarks(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; + } + + // Update my data + var changed = setMyData(Env); + if (changed) { + setMarks(Env); + Env.framework.localChange(); + } + }); + + var call = function (f) { + return function () { + try { + [].unshift.call(arguments, Env); + return f.apply(null, arguments); + } catch (e) { + console.error(e); + } + }; + }; + + + return { + addMark: call(addMark), + getAuthorMarks: call(getAuthorMarks), + updateAuthorMarks: call(updateAuthorMarks), + checkMarks: call(checkMarks), + setMarks: call(setMarks), + localChange: call(localChange), + ready: call(ready), + setButton: call(setButton) + }; + }; + + return Markers; +}); diff --git a/www/common/common-ui-elements.js b/www/common/common-ui-elements.js index a5c1396c2..049e26366 100644 --- a/www/common/common-ui-elements.js +++ b/www/common/common-ui-elements.js @@ -1621,7 +1621,19 @@ define([ if (!data) { return void UI.alert(Messages.autostore_notAvailable); } - sframeChan.event('EV_PROPERTIES_OPEN'); + var metadataMgr = common.getMetadataMgr(); + sframeChan.query('Q_PROPERTIES_OPEN', { + metadata: metadataMgr.getMetadata() + }, function (err, data) { + if (!data || !data.cmd) { return; } + if (data.cmd === "UPDATE_METADATA") { + if (!data.key) { return; } + var md = Util.clone(metadataMgr.getMetadata()); + md[data.key] = data.value; + if (!data.value) { delete md[data.key]; } + metadataMgr.updateMetadata(md); + } + }); }); }); break; diff --git a/www/common/inner/common-mediatag.js b/www/common/inner/common-mediatag.js index 40ae19059..3e1f2e75b 100644 --- a/www/common/inner/common-mediatag.js +++ b/www/common/inner/common-mediatag.js @@ -284,10 +284,12 @@ define([ 'data-crypto-key': key }); $inner.append(tag); - MediaTag(tag).on('error', function () { - locked = false; - $spinner.hide(); - UI.log(Messages.error); + setTimeout(function () { + MediaTag(tag).on('error', function () { + locked = false; + $spinner.hide(); + UI.log(Messages.error); + }); }); } diff --git a/www/common/inner/properties.js b/www/common/inner/properties.js index 58f10e738..c41119a6f 100644 --- a/www/common/inner/properties.js +++ b/www/common/inner/properties.js @@ -50,6 +50,56 @@ define([ // File and history size... var owned = Modal.isOwned(Env, data); + var metadataMgr = common.getMetadataMgr(); + var priv = metadataMgr.getPrivateData(); + if (owned && priv.app === 'code') { + (function () { + var sframeChan = common.getSframeChannel(); + var md = (opts.data && opts.data.metadata) || {}; + var div = h('div'); + var hint = h('div.cp-app-prop-hint', Messages.cba_hint); + var $div = $(div); + var setButton = function (state) { + var button = h('button.btn'); + var $button = $(button); + $div.html('').append($button); + if (state) { + // Add "enable" button + $button.addClass('btn-secondary').text(Messages.cba_enable); + UI.confirmButton(button, { + classes: 'btn-primary' + }, function () { + $button.remove(); + sframeChan.event("EV_SECURE_ACTION", { + cmd: 'UPDATE_METADATA', + key: 'enableColors', + value: true + }); + common.setAttribute(['code', 'enableColors'], true); + setButton(false); + }); + return; + } + // Add "disable" button + $button.addClass('btn-danger-alt').text(Messages.cba_disable); + UI.confirmButton(button, { + classes: 'btn-danger' + }, function () { + $button.remove(); + sframeChan.event("EV_SECURE_ACTION", { + cmd: 'UPDATE_METADATA', + key: 'enableColors', + value: false + }); + common.setAttribute(['code', 'enableColors'], false); + setButton(true); + }); + }; + setButton(!md.enableColors); + $d.append(h('div.cp-app-prop', [Messages.cba_properties, hint, div])); + })(); + } + // check the size of this file, including additional channels var bytes = 0; var historyBytes; diff --git a/www/common/metadata-manager.js b/www/common/metadata-manager.js index 717705d84..70e9263e5 100644 --- a/www/common/metadata-manager.js +++ b/www/common/metadata-manager.js @@ -48,7 +48,6 @@ define(['json.sortify'], function (Sortify) { //title: meta.doc.defaultTitle, type: meta.doc.type, users: {}, - authors: {} }; metadataLazyObj = JSON.parse(JSON.stringify(metadataObj)); } @@ -69,6 +68,10 @@ define(['json.sortify'], function (Sortify) { } metadataObj.users = mdo; + // Clean old data + delete metadataObj.authors; + delete metadataLazyObj.authors; + // Always update the userlist in the lazy object, otherwise it may be outdated // and metadataMgr.updateMetadata() won't do anything, and so we won't push events // to the userlist UI ==> phantom viewers @@ -96,27 +99,6 @@ define(['json.sortify'], function (Sortify) { checkUpdate(lazy); }); }; - var addAuthor = function () { - if (!meta.user || !meta.user.netfluxId || !priv || !priv.edPublic) { return; } - var authors = metadataObj.authors || {}; - var old = Sortify(authors); - if (!authors[priv.edPublic]) { - authors[priv.edPublic] = { - nId: [meta.user.netfluxId], - name: meta.user.name - }; - } else { - authors[priv.edPublic].name = meta.user.name; - if (authors[priv.edPublic].nId.indexOf(meta.user.netfluxId) === -1) { - authors[priv.edPublic].nId.push(meta.user.netfluxId); - } - } - if (Sortify(authors) !== old) { - metadataObj.authors = authors; - metadataLazyObj.authors = JSON.parse(JSON.stringify(authors)); - change(); - } - }; var netfluxId; var isReady = false; @@ -225,7 +207,6 @@ define(['json.sortify'], function (Sortify) { if (isReady) { return void f(); } readyHandlers.push(f); }, - addAuthor: addAuthor, }); }; return Object.freeze({ create: create }); diff --git a/www/common/outer/async-store.js b/www/common/outer/async-store.js index fb7b2cc8e..2841d8130 100644 --- a/www/common/outer/async-store.js +++ b/www/common/outer/async-store.js @@ -1890,7 +1890,15 @@ define([ if (msg) { msg = msg.replace(/cp\|(([A-Za-z0-9+\/=]+)\|)?/, ''); //var decryptedMsg = crypto.decrypt(msg, true); - msgs.push(msg); + if (data.debug) { + msgs.push({ + msg: msg, + author: parsed[1][1], + time: parsed[1][5] + }); + } else { + msgs.push(msg); + } } }; network.on('message', onMsg); diff --git a/www/common/sframe-app-framework.js b/www/common/sframe-app-framework.js index fc149b2cc..24e7d0abd 100644 --- a/www/common/sframe-app-framework.js +++ b/www/common/sframe-app-framework.js @@ -290,7 +290,7 @@ define([ } if (padChange && hasChanged(content)) { - cpNfInner.metadataMgr.addAuthor(); + //cpNfInner.metadataMgr.addAuthor(); } oldContent = content; diff --git a/www/common/sframe-common-codemirror.js b/www/common/sframe-common-codemirror.js index 2192f168c..40e820d41 100644 --- a/www/common/sframe-common-codemirror.js +++ b/www/common/sframe-common-codemirror.js @@ -12,7 +12,7 @@ define([ ], function ($, Modes, Themes, Messages, UIElements, MT, Hash, Util, TextCursor, ChainPad) { var module = {}; - var cursorToPos = function(cursor, oldText) { + var cursorToPos = module.cursorToPos = function(cursor, oldText) { var cLine = cursor.line; var cCh = cursor.ch; var pos = 0; @@ -28,7 +28,7 @@ define([ return pos; }; - var posToCursor = function(position, newText) { + var posToCursor = module.posToCursor = function(position, newText) { var cursor = { line: 0, ch: 0 @@ -415,8 +415,7 @@ define([ ///// - var canonicalize = function (t) { return t.replace(/\r\n/g, '\n'); }; - + var canonicalize = exp.canonicalize = function (t) { return t.replace(/\r\n/g, '\n'); }; exp.contentUpdate = function (newContent) { @@ -424,6 +423,7 @@ define([ var remoteDoc = newContent.content; // setValueAndCursor triggers onLocal, even if we don't make any change to the content // and it may revert other changes (metadata) + if (oldDoc === remoteDoc) { return; } exp.setValueAndCursor(oldDoc, remoteDoc); }; diff --git a/www/common/sframe-common-history.js b/www/common/sframe-common-history.js index be2793128..0217fee7a 100644 --- a/www/common/sframe-common-history.js +++ b/www/common/sframe-common-history.js @@ -71,7 +71,10 @@ define([ lastKnownHash = data.lastKnownHash; isComplete = data.isFull; var messages = (data.messages || []).map(function (obj) { - return obj.msg; + if (!config.debug) { + return obj.msg; + } + return obj; }); if (config.debug) { console.log(data.messages); } Array.prototype.unshift.apply(allMessages, messages); // Destructive concat diff --git a/www/common/sframe-common-outer.js b/www/common/sframe-common-outer.js index 2ae6f789a..b7bfcbc67 100644 --- a/www/common/sframe-common-outer.js +++ b/www/common/sframe-common-outer.js @@ -838,17 +838,26 @@ define([ sframeChan.on('Q_GET_FULL_HISTORY', function (data, cb) { var crypto = Crypto.createEncryptor(secret.keys); Cryptpad.getFullHistory({ + debug: data && data.debug, channel: secret.channel, validateKey: secret.keys.validateKey }, function (encryptedMsgs) { var nt = nThen; var decryptedMsgs = []; var total = encryptedMsgs.length; - encryptedMsgs.forEach(function (msg, i) { + encryptedMsgs.forEach(function (_msg, i) { nt = nt(function (waitFor) { // The 3rd parameter "true" means we're going to skip signature validation. // We don't need it since the message is already validated serverside by hk - decryptedMsgs.push(crypto.decrypt(msg, true, true)); + if (typeof(_msg) === "object") { + decryptedMsgs.push({ + author: _msg.author, + time: _msg.time, + msg: crypto.decrypt(_msg.msg, true, true) + }); + } else { + decryptedMsgs.push(crypto.decrypt(_msg, true, true)); + } setTimeout(waitFor(function () { sframeChan.event('EV_FULL_HISTORY_STATUS', (i+1)/total); })); @@ -992,12 +1001,12 @@ define([ config.onAction = function (data) { if (typeof(SecureModal.cb) !== "function") { return; } SecureModal.cb(data); - SecureModal.$iframe.hide(); }; config.onClose = function () { SecureModal.$iframe.hide(); }; config.data = { + app: parsed.type, hashes: hashes, password: password, isTemplate: isTemplate @@ -1010,12 +1019,12 @@ define([ }; SecureModal.$iframe = $('