diff --git a/www/code/app-code.less b/www/code/app-code.less index b2b3a1a64..bb53380c5 100644 --- a/www/code/app-code.less +++ b/www/code/app-code.less @@ -9,6 +9,13 @@ @color: @colortheme_code-color ); + .cp-codemirror-cursor { + border-left: 2px solid red; + } + .cp-codemirror-selection { + background-color: rgba(255,0,0,0.3); + } + display: flex; flex-flow: column; max-height: 100%; diff --git a/www/code/inner.js b/www/code/inner.js index 1601bae58..e3885283a 100644 --- a/www/code/inner.js +++ b/www/code/inner.js @@ -301,6 +301,13 @@ define([ return content; }); + framework.onCursorUpdate(CodeMirror.setRemoteCursor); + framework.setCursorGetter(CodeMirror.getCursor); + editor.on('cursorActivity', function () { + if (editor._noCursorUpdate) { console.log('ok'); return; } + framework.updateCursor(); + }); + framework.onEditableChange(function () { editor.setOption('readOnly', framework.isLocked() || framework.isReadOnly()); }); diff --git a/www/common/cryptpad-common.js b/www/common/cryptpad-common.js index 01f898b0a..44bf9cdd3 100644 --- a/www/common/cryptpad-common.js +++ b/www/common/cryptpad-common.js @@ -624,6 +624,14 @@ define([ }; messenger.onEvent = Util.mkEvent(); + // Cursor + var cursor = common.cursor = {}; + cursor.execCommand = function (data, cb) { + postMessage("CURSOR_COMMAND", data, cb); + }; + cursor.onEvent = Util.mkEvent(); + + // Pad RPC var pad = common.padRpc = {}; pad.joinPad = function (data) { @@ -1049,6 +1057,8 @@ define([ }, // Chat CHAT_EVENT: common.messenger.onEvent.fire, + // Cursor + CURSOR_EVENT: common.cursor.onEvent.fire, // Pad PAD_READY: common.padRpc.onReadyEvent.fire, PAD_MESSAGE: common.padRpc.onMessageEvent.fire, diff --git a/www/common/outer/async-store.js b/www/common/outer/async-store.js index 452760918..3267ee99d 100644 --- a/www/common/outer/async-store.js +++ b/www/common/outer/async-store.js @@ -10,6 +10,7 @@ define([ '/common/common-realtime.js', '/common/common-messaging.js', '/common/common-messenger.js', + '/common/outer/cursor.js', '/common/outer/chainpad-netflux-worker.js', '/common/outer/network-config.js', '/customize/application_config.js', @@ -20,7 +21,7 @@ define([ '/bower_components/nthen/index.js', '/bower_components/saferphore/index.js', ], function (Sortify, UserObject, ProxyManager, Migrate, Hash, Util, Constants, Feedback, Realtime, Messaging, Messenger, - CpNfWorker, NetConfig, AppConfig, + Cursor, CpNfWorker, NetConfig, AppConfig, Crypto, ChainPad, Listmap, nThen, Saferphore) { var Store = {}; @@ -904,6 +905,15 @@ define([ } }; + // Cursor + + Store.cursor = { + execCommand: function (clientId, data, cb) { + if (!store.cursor) { return void cb ({error: 'Cursor channel is disabled'}); } + store.cursor.execCommand(clientId, data, cb); + } + }; + ////////////////////////////////////////////////////////////////// /////////////////////// PAD ////////////////////////////////////// ////////////////////////////////////////////////////////////////// @@ -1200,14 +1210,15 @@ define([ var messengerEventClients = []; var dropChannel = function (chanId) { + store.messenger.leavePad(chanId); + store.cursor.leavePad(chanId); + if (!Store.channels[chanId]) { return; } if (Store.channels[chanId].cpNf) { Store.channels[chanId].cpNf.stop(); } - store.messenger.leavePad(chanId); - delete Store.channels[chanId]; }; Store._removeClient = function (clientId) { @@ -1219,6 +1230,7 @@ define([ if (messengerIdx !== -1) { messengerEventClients.splice(messengerIdx, 1); } + store.cursor.removeClient(clientId); Object.keys(Store.channels).forEach(function (chanId) { var chanIdx = Store.channels[chanId].clients.indexOf(clientId); if (chanIdx !== -1) { @@ -1291,6 +1303,16 @@ define([ }); }; + var loadCursor = function () { + store.cursor = Cursor.init(store, function (ev, data, clients) { + clients.forEach(function (cId) { + postMessage(cId, 'CURSOR_EVENT', { + ev: ev, + data: data + }); + }); + }); + }; ////////////////////////////////////////////////////////////////// /////////////////////// Init ///////////////////////////////////// @@ -1378,6 +1400,7 @@ define([ userObject.fixFiles(); loadSharedFolders(waitFor); loadMessenger(); + loadCursor(); }).nThen(function () { var requestLogin = function () { broadcast([], "REQUEST_LOGIN"); diff --git a/www/common/outer/store-rpc.js b/www/common/outer/store-rpc.js index 65cd3be09..f6ed7d503 100644 --- a/www/common/outer/store-rpc.js +++ b/www/common/outer/store-rpc.js @@ -62,6 +62,8 @@ define([ ADD_DIRECT_MESSAGE_HANDLERS: Store.addDirectMessageHandlers, // Chat CHAT_COMMAND: Store.messenger.execCommand, + // Cursor + CURSOR_COMMAND: Store.cursor.execCommand, // Pad SEND_PAD_MSG: Store.sendPadMsg, JOIN_PAD: Store.joinPad, diff --git a/www/common/sframe-app-framework.js b/www/common/sframe-app-framework.js index a19979c5b..ee3feeffb 100644 --- a/www/common/sframe-app-framework.js +++ b/www/common/sframe-app-framework.js @@ -55,6 +55,7 @@ define([ var create = function (options, cb) { var evContentUpdate = Util.mkEvent(); + var evCursorUpdate = Util.mkEvent(); var evEditableStateChange = Util.mkEvent(); var evOnReady = Util.mkEvent(true); var evOnDefaultContentNeeded = Util.mkEvent(); @@ -68,6 +69,7 @@ define([ var cpNfInner; var readOnly; var title; + var cursor; var toolbar; var state = STATE.DISCONNECTED; var firstConnection = true; @@ -90,6 +92,7 @@ define([ var textContentGetter; var titleRecommender = function () { return false; }; var contentGetter = function () { return UNINITIALIZED; }; + var cursorGetter; var normalize0 = function (x) { return x; }; var normalize = function (x) { @@ -326,6 +329,11 @@ define([ evOnReady.fire(newPad); common.openPadChat(onLocal); + if (!readOnly && cursorGetter) { + common.openCursorChannel(onLocal); + cursor = common.createCursor(); + cursor.onCursorUpdate(evCursorUpdate.fire); + } UI.removeLoadingScreen(emitResize); @@ -638,6 +646,15 @@ define([ // in the pad when requested by the framework. setContentGetter: function (cg) { contentGetter = cg; }, + // Set the function providing the cursor position when request by the framework. + setCursorGetter: function (cg) { cursorGetter = cg; }, + onCursorUpdate: evCursorUpdate.reg, + updateCursor: function () { + if (cursor && cursorGetter) { + cursor.updateCursor(cursorGetter()); + } + }, + // Set a text content supplier, this is a function which will give a text // representation of the pad content if a text analyzer is configured setTextContentGetter: function (tcg) { textContentGetter = tcg; }, diff --git a/www/common/sframe-common-codemirror.js b/www/common/sframe-common-codemirror.js index 0163001f7..fdec38729 100644 --- a/www/common/sframe-common-codemirror.js +++ b/www/common/sframe-common-codemirror.js @@ -45,6 +45,7 @@ define([ return new Blob([ content ], { type: 'text/plain;charset=utf-8' }); }; module.setValueAndCursor = function (editor, oldDoc, remoteDoc) { + editor._noCursorUpdate = true; var scroll = editor.getScrollInfo(); //get old cursor here var oldCursor = {}; @@ -59,6 +60,7 @@ define([ return TextCursor.transformCursor(oldCursor[attr], ops); }); + editor._noCursorUpdate = false; if(selects[0] === selects[1]) { editor.setCursor(posToCursor(selects[0], remoteDoc)); } @@ -374,6 +376,56 @@ define([ updateIndentSettings(); }; + exp.getCursor = function () { + var doc = canonicalize(editor.getValue()); + var cursor = {}; + cursor.selectionStart = cursorToPos(editor.getCursor('from'), doc); + cursor.selectionEnd = cursorToPos(editor.getCursor('to'), doc); + return cursor; + }; + + var makeCursor = function (id) { + if (document.getElementById(id)) { + return document.getElementById(id); + } + return $('', { + 'id': id, + 'class': 'cp-codemirror-cursor' + })[0]; + }; + var marks = {}; + exp.setRemoteCursor = function (data) { + if (data.leave) { + $('.cp-codemirror-cursor[id^='+data.id+']').each(function (i, el) { + var id = $(el).attr('id'); + if (marks[id]) { + marks[id].clear(); + delete marks[id]; + } + }); + return; + } + + var id = data.id; + var cursor = data.cursor; + var doc = canonicalize(editor.getValue()); + + if (marks[id]) { + marks[id].clear(); + delete marks[id]; + } + + if (cursor.selectionStart === cursor.selectionEnd) { + var cursorPosS = posToCursor(cursor.selectionStart, doc); + var el = makeCursor(id); + marks[id] = editor.setBookmark(cursorPosS, { widget: el }); + } else { + var pos1 = posToCursor(cursor.selectionStart, doc); + var pos2 = posToCursor(cursor.selectionEnd, doc); + marks[id] = editor.markText(pos1, pos2, { className: 'cp-codemirror-selection' }); + } + }; + return exp; }; diff --git a/www/common/sframe-common-outer.js b/www/common/sframe-common-outer.js index 2b5829946..b2536acdd 100644 --- a/www/common/sframe-common-outer.js +++ b/www/common/sframe-common-outer.js @@ -792,6 +792,22 @@ define([ cfg.addRpc(sframeChan, Cryptpad, Utils); } + sframeChan.on('Q_CURSOR_OPENCHANNEL', function (data, cb) { + Cryptpad.cursor.execCommand({ + cmd: 'INIT_CURSOR', + data: { + channel: data, + secret: secret + } + }, cb); + }); + Cryptpad.cursor.onEvent.reg(function (data) { + sframeChan.event('EV_CURSOR_EVENT', data); + }); + sframeChan.on('Q_CURSOR_COMMAND', function (data, cb) { + Cryptpad.cursor.execCommand(data, cb); + }); + if (cfg.messaging) { Notifier.getPermission(); diff --git a/www/common/sframe-common.js b/www/common/sframe-common.js index de976a171..c64ce8a21 100644 --- a/www/common/sframe-common.js +++ b/www/common/sframe-common.js @@ -9,6 +9,7 @@ define([ '/common/sframe-common-history.js', '/common/sframe-common-file.js', '/common/sframe-common-codemirror.js', + '/common/sframe-common-cursor.js', '/common/metadata-manager.js', '/customize/application_config.js', @@ -31,6 +32,7 @@ define([ History, File, CodeMirror, + Cursor, MetadataMgr, AppConfig, CommonRealtime, @@ -106,6 +108,9 @@ define([ // Title module funcs.createTitle = callWithCommon(Title.create); + // Cursor + funcs.createCursor = callWithCommon(Cursor.create); + // Files funcs.uploadFile = callWithCommon(File.uploadFile); funcs.createFileManager = callWithCommon(File.create); @@ -180,6 +185,24 @@ define([ }); }; + var cursorChannel; + funcs.getCursorChannel = function () { + return cursorChannel; + }; + funcs.openCursorChannel = function (saveChanges) { + var md = JSON.parse(JSON.stringify(ctx.metadataMgr.getMetadata())); + var channel = md.cursor || Hash.createChannelId(); + if (!md.cursor) { + md.cursor = channel; + ctx.metadataMgr.updateMetadata(md); + setTimeout(saveChanges); + } + cursorChannel = channel; + ctx.sframeChan.query('Q_CURSOR_OPENCHANNEL', channel, function (err, obj) { + if (err || (obj && obj.error)) { console.error(err || (obj && obj.error)); } + }); + }; + // CodeMirror funcs.initCodeMirrorApp = callWithCommon(CodeMirror.create); diff --git a/www/common/sframe-protocol.js b/www/common/sframe-protocol.js index f09cfcec3..13148a2d2 100644 --- a/www/common/sframe-protocol.js +++ b/www/common/sframe-protocol.js @@ -158,6 +158,11 @@ define({ 'Q_CHAT_COMMAND': true, 'Q_CHAT_OPENPADCHAT': true, + // Cursor + 'EV_CURSOR_EVENT': true, + 'Q_CURSOR_COMMAND': true, + 'Q_CURSOR_OPENCHANNEL': true, + // Put one or more entries to the localStore which will go in localStorage. 'EV_LOCALSTORE_PUT': true, // Put one entry in the parent sessionStorage