diff --git a/www/_pad/realtime-wysiwyg.js b/www/_pad/realtime-wysiwyg.js index 30529a90f..2b35ef0a8 100644 --- a/www/_pad/realtime-wysiwyg.js +++ b/www/_pad/realtime-wysiwyg.js @@ -327,10 +327,11 @@ console.log(new Error().stack); error(false, 'realtime.getUserDoc() !== docText'); } }; - +var now = function () { return new Date().getTime(); }; var userDocBeforePatch; var incomingPatch = function () { if (isErrorState || initializing) { return; } + console.log("before patch " + now()); userDocBeforePatch = userDocBeforePatch || getFixedDocText(doc, ifr.contentWindow); if (PARANOIA && userDocBeforePatch !== getFixedDocText(doc, ifr.contentWindow)) { error(false, "userDocBeforePatch !== getFixedDocText(doc, ifr.contentWindow)"); @@ -339,6 +340,7 @@ console.log(new Error().stack); if (!op) { return; } attempt(HTMLPatcher.applyOp)( userDocBeforePatch, op, doc.body, Rangy, ifr.contentWindow); + console.log("after patch " + now()); }; realtime.onUserListChange(function (userList) { diff --git a/www/socket/index.html b/www/_socket/index.html similarity index 68% rename from www/socket/index.html rename to www/_socket/index.html index 6ee2a8596..830d56125 100644 --- a/www/socket/index.html +++ b/www/_socket/index.html @@ -14,28 +14,17 @@ left:0px; bottom:0px; right:0px; - width:70%; + width:100%; height:100%; border:none; margin:0; padding:0; overflow:hidden; } - #feedback { - position: fixed; - top: 0px; - right: 0px; - border: 0px; - height: 100vh; - width: 30vw; - background-color: #222; - color: #ccc; - } - diff --git a/www/socket/inner.html b/www/_socket/inner.html similarity index 100% rename from www/socket/inner.html rename to www/_socket/inner.html diff --git a/www/_socket/main.js b/www/_socket/main.js new file mode 100644 index 000000000..0974a66f0 --- /dev/null +++ b/www/_socket/main.js @@ -0,0 +1,324 @@ +define([ + '/api/config?cb=' + Math.random().toString(16).substring(2), + '/common/messages.js', + '/common/crypto.js', + '/_socket/realtime-input.js', + '/common/hyperjson.js', + '/common/hyperscript.js', + '/_socket/toolbar.js', + '/common/cursor.js', + '/common/json-ot.js', + '/_socket/typingTest.js', + '/bower_components/diff-dom/diffDOM.js', + '/bower_components/jquery/dist/jquery.min.js', + '/customize/pad.js' +], function (Config, Messages, Crypto, realtimeInput, Hyperjson, Hyperscript, Toolbar, Cursor, JsonOT, TypingTest) { + var $ = window.jQuery; + var ifrw = $('#pad-iframe')[0].contentWindow; + var Ckeditor; // to be initialized later... + var DiffDom = window.diffDOM; + + window.Hyperjson = Hyperjson; + + var hjsonToDom = function (H) { + return Hyperjson.callOn(H, Hyperscript); + }; + + var userName = Crypto.rand64(8), + toolbar; + + var module = window.REALTIME_MODULE = { + localChangeInProgress: 0 + }; + + var isNotMagicLine = function (el) { + // factor as: + // return !(el.tagName === 'SPAN' && el.contentEditable === 'false'); + var filter = (el.tagName === 'SPAN' && el.contentEditable === 'false'); + if (filter) { + console.log("[hyperjson.serializer] prevented an element" + + "from being serialized:", el); + return false; + } + return true; + }; + + var andThen = function (Ckeditor) { + $(window).on('hashchange', function() { + window.location.reload(); + }); + if (window.location.href.indexOf('#') === -1) { + window.location.href = window.location.href + '#' + Crypto.genKey(); + return; + } + + var fixThings = false; + var key = Crypto.parseKey(window.location.hash.substring(1)); + var editor = window.editor = Ckeditor.replace('editor1', { + // https://dev.ckeditor.com/ticket/10907 + needsBrFiller: fixThings, + needsNbspFiller: fixThings, + removeButtons: 'Source,Maximize', + // magicline plugin inserts html crap into the document which is not part of the + // document itself and causes problems when it's sent across the wire and reflected back + // but we filter it now, so that's ok. + removePlugins: 'resize' + }); + + editor.on('instanceReady', function (Ckeditor) { + editor.execCommand('maximize'); + var documentBody = ifrw.$('iframe')[0].contentDocument.body; + + documentBody.innerHTML = Messages.initialState; + + var inner = window.inner = documentBody; + var cursor = window.cursor = Cursor(inner); + + var setEditable = function (bool) { + // careful about putting attributes onto the DOM + // they get put into the chain, and you can have trouble + // getting rid of them later + + //inner.style.backgroundColor = bool? 'white': 'grey'; + inner.setAttribute('contenteditable', bool); + }; + + // don't let the user edit until the pad is ready + setEditable(false); + + var diffOptions = { + preDiffApply: function (info) { + /* DiffDOM will filter out magicline plugin elements + in practice this will make it impossible to use it + while someone else is typing, which could be annoying. + + we should check when such an element is going to be + removed, and prevent that from happening. */ + if (info.node && info.node.tagName === 'SPAN' && + info.node.contentEditable === "true") { + // it seems to be a magicline plugin element... + if (info.diff.action === 'removeElement') { + // and you're about to remove it... + // this probably isn't what you want + + /* + I have never seen this in the console, but the + magic line is still getting removed on remote + edits. This suggests that it's getting removed + by something other than diffDom. + */ + console.log("preventing removal of the magic line!"); + + // return true to prevent diff application + return true; + } + } + + // no use trying to recover the cursor if it doesn't exist + if (!cursor.exists()) { return; } + + /* frame is either 0, 1, 2, or 3, depending on which + cursor frames were affected: none, first, last, or both + */ + var frame = info.frame = cursor.inNode(info.node); + + if (!frame) { return; } + + if (typeof info.diff.oldValue === 'string' && typeof info.diff.newValue === 'string') { + var pushes = cursor.pushDelta(info.diff.oldValue, info.diff.newValue); + + if (frame & 1) { + // push cursor start if necessary + if (pushes.commonStart < cursor.Range.start.offset) { + cursor.Range.start.offset += pushes.delta; + } + } + if (frame & 2) { + // push cursor end if necessary + if (pushes.commonStart < cursor.Range.end.offset) { + cursor.Range.end.offset += pushes.delta; + } + } + } + }, + postDiffApply: function (info) { + if (info.frame) { + if (info.node) { + if (info.frame & 1) { cursor.fixStart(info.node); } + if (info.frame & 2) { cursor.fixEnd(info.node); } + } else { console.error("info.node did not exist"); } + + var sel = cursor.makeSelection(); + var range = cursor.makeRange(); + + cursor.fixSelection(sel, range); + } + } + }; + + var now = function () { return new Date().getTime(); }; + + var realtimeOptions = { + // configuration :D + doc: inner, + + // provide initialstate... + initialState: JSON.stringify(Hyperjson.fromDOM(inner, isNotMagicLine)), + + // really basic operational transform + // reject patch if it results in invalid JSON + transformFunction : JsonOT.validate, + + websocketURL: Config.websocketURL, + + // username + userName: userName, + + // communication channel name + channel: key.channel, + + // encryption key + cryptKey: key.cryptKey + }; + + var DD = new DiffDom(diffOptions); + + var localWorkInProgress = function (stage) { + if (module.localChangeInProgress) { + console.error("Applied a change while a local patch was in progress"); + alert("local work was interrupted at stage: " + stage); + //module.realtimeInput.onLocal(); + return true; + } + return false; + }; + + // apply patches, and try not to lose the cursor in the process! + var applyHjson = function (shjson) { + + localWorkInProgress(1); // check if this would interrupt local work + + var userDocStateDom = hjsonToDom(JSON.parse(shjson)); + localWorkInProgress(2); // check again + userDocStateDom.setAttribute("contenteditable", "true"); // lol wtf + localWorkInProgress(3); // check again + var patch = (DD).diff(inner, userDocStateDom); + localWorkInProgress(4); // check again + (DD).apply(inner, patch); + localWorkInProgress(5); // check again + }; + + var initializing = true; + + var onRemote = realtimeOptions.onRemote = function (info) { + if (initializing) { return; } + + localWorkInProgress(0); + + var shjson = info.realtime.getUserDoc(); + + // remember where the cursor is + cursor.update(); + + // build a dom from HJSON, diff, and patch the editor + applyHjson(shjson); + + var shjson2 = JSON.stringify(Hyperjson.fromDOM(inner)); + if (shjson2 !== shjson) { + console.error("shjson2 !== shjson"); + module.realtimeInput.patchText(shjson2); + } + }; + + var onInit = realtimeOptions.onInit = function (info) { + var $bar = $('#pad-iframe')[0].contentWindow.$('#cke_1_toolbox'); + toolbar = info.realtime.toolbar = Toolbar.create($bar, userName, info.realtime); + + /* TODO handle disconnects and such*/ + }; + + var onReady = realtimeOptions.onReady = function (info) { + console.log("Unlocking editor"); + initializing = false; + setEditable(true); + + var shjson = info.realtime.getUserDoc(); + + applyHjson(shjson); + }; + + var onAbort = realtimeOptions.onAbort = function (info) { + console.log("Aborting the session!"); + // stop the user from continuing to edit + // by setting the editable to false + setEditable(false); + toolbar.failed(); + }; + + var rti = module.realtimeInput = realtimeInput.start(realtimeOptions); + + /* catch `type="_moz"` before it goes over the wire */ + var brFilter = function (hj) { + if (hj[1].type === '_moz') { hj[1].type = undefined; } + return hj; + }; + + /* It's incredibly important that you assign 'rti.onLocal' + It's used inside of realtimeInput to make sure that all changes + make it into chainpad. + + It's being assigned this way because it can't be passed in, and + and can't be easily returned from realtime input without making + the code less extensible. + */ + var propogate = rti.onLocal = function () { + /* if the problem were a matter of external patches being + applied while a local patch were in progress, then we would + expect to be able to check and find + 'module.localChangeInProgress' with a non-zero value while + we were applying a remote change. + */ + module.localChangeInProgress += 1; + var shjson = JSON.stringify(Hyperjson.fromDOM(inner, isNotMagicLine, brFilter)); + if (!rti.patchText(shjson)) { + module.localChangeInProgress -= 1; + return; + } + rti.onEvent(shjson); + module.localChangeInProgress -= 1; + }; + + /* hitting enter makes a new line, but places the cursor inside + of the
instead of the

. This makes it such that you + cannot type until you click, which is rather unnacceptable. + If the cursor is ever inside such a
, you probably want + to push it out to the parent element, which ought to be a + paragraph tag. This needs to be done on keydown, otherwise + the first such keypress will not be inserted into the P. */ + inner.addEventListener('keydown', cursor.brFix); + + var easyTest = window.easyTest = function () { + cursor.update(); + var start = cursor.Range.start; + var test = TypingTest.testInput(inner, start.el, start.offset, propogate); + propogate(); + return test; + }; + + editor.on('change', propogate); + }); + }; + + var interval = 100; + var first = function () { + Ckeditor = ifrw.CKEDITOR; + if (Ckeditor) { + andThen(Ckeditor); + } else { + console.log("Ckeditor was not defined. Trying again in %sms",interval); + setTimeout(first, interval); + } + }; + + $(first); +}); diff --git a/www/socket/realtime-input.js b/www/_socket/realtime-input.js similarity index 59% rename from www/socket/realtime-input.js rename to www/_socket/realtime-input.js index 556a4b3c8..06caa1c5d 100644 --- a/www/socket/realtime-input.js +++ b/www/_socket/realtime-input.js @@ -18,10 +18,11 @@ define([ '/common/messages.js', '/bower_components/reconnectingWebsocket/reconnecting-websocket.js', '/common/crypto.js', - '/common/sharejs_textarea.js', + '/_socket/toolbar.js', + '/_socket/text-patcher.js', '/common/chainpad.js', '/bower_components/jquery/dist/jquery.min.js', -], function (Messages, ReconnectingWebSocket, Crypto, sharejs) { +], function (Messages,/*FIXME*/ ReconnectingWebSocket, Crypto, Toolbar, TextPatcher) { var $ = window.jQuery; var ChainPad = window.ChainPad; var PARANOIA = true; @@ -33,51 +34,20 @@ define([ */ var MAX_RECOVERABLE_ERRORS = 15; + var recoverableErrors = 0; + /** Maximum number of milliseconds of lag before we fail the connection. */ var MAX_LAG_BEFORE_DISCONNECT = 20000; - var debug = function (x) { console.log(x); }, - warn = function (x) { console.error(x); }, - verbose = function (x) { /*console.log(x);*/ }; - - // ------------------ Trapping Keyboard Events ---------------------- // - - var bindEvents = function (element, events, callback, unbind) { - for (var i = 0; i < events.length; i++) { - var e = events[i]; - if (element.addEventListener) { - if (unbind) { - element.removeEventListener(e, callback, false); - } else { - element.addEventListener(e, callback, false); - } - } else { - if (unbind) { - element.detachEvent('on' + e, callback); - } else { - element.attachEvent('on' + e, callback); - } - } - } - }; - - var bindAllEvents = function (textarea, docBody, onEvent, unbind) - { - /* - we use docBody for the purposes of CKEditor. - because otherwise special keybindings like ctrl-b and ctrl-i - would open bookmarks and info instead of applying bold/italic styles - */ - if (docBody) { - bindEvents(docBody, - ['textInput', 'keydown', 'keyup', 'select', 'cut', 'paste'], - onEvent, - unbind); + var debug = function (x) { console.log(x); }; + var warn = function (x) { console.error(x); }; + var verbose = function (x) { /*console.log(x);*/ }; + var error = function (x) { + console.error(x); + recoverableErrors++; + if (recoverableErrors >= MAX_RECOVERABLE_ERRORS) { + window.alert("FAIL"); } - bindEvents(textarea, - ['mousedown','mouseup','click','change'], - onEvent, - unbind); }; /* websocket stuff */ @@ -113,11 +83,17 @@ define([ var makeWebsocket = function (url) { var socket = new ReconnectingWebSocket(url); + /* create a set of handlers to use instead of the native socket handler + these handlers will iterate over all of the functions pushed to the + arrays bearing their name. + + The first such function to return `false` will prevent subsequent + functions from being executed. */ var out = { - onOpen: [], - onClose: [], - onError: [], - onMessage: [], + onOpen: [], // takes care of launching the post-open logic + onClose: [], // takes care of cleanup + onError: [], // in case of error, socket will close, and fire this + onMessage: [], // used for the bulk of our logic send: function (msg) { socket.send(msg); }, close: function () { socket.close(); }, _socket: socket @@ -132,6 +108,8 @@ define([ } }; }; + + // bind your new handlers to the important listeners on the socket socket.onopen = mkHandler('onOpen'); socket.onclose = mkHandler('onClose'); socket.onerror = mkHandler('onError'); @@ -140,62 +118,52 @@ define([ }; /* end websocket stuff */ - var start = module.exports.start = - function (textarea, websocketUrl, userName, channel, cryptKey, config) - { - + var start = module.exports.start = function (config) { + //var textarea = config.textarea; + var websocketUrl = config.websocketURL; + var userName = config.userName; + var channel = config.channel; + var cryptKey = config.cryptKey; var passwd = 'y'; - - // make sure configuration is defined - config = config || {}; - var doc = config.doc || null; - // trying to deprecate onRemote, prefer loading it via the conf - var onRemote = config.onRemote || null; - - var transformFunction = config.transformFunction || null; + // wrap up the reconnecting websocket with our additional stack logic + var socket = makeWebsocket(websocketUrl); - var socket; - - if (config.socketAdaptor) { - // do netflux stuff - } else { - socket = makeWebsocket(websocketUrl); - } - // define this in case it gets called before the rest of our stuff is ready. - var onEvent = function () { }; - - var allMessages = []; + var allMessages = window.chainpad_allMessages = []; var isErrorState = false; var initializing = true; var recoverableErrorCount = 0; - var $textarea = $(textarea); - - var bump = function () {}; - - var toReturn = { - socket: socket - }; + var toReturn = { socket: socket }; socket.onOpen.push(function (evt) { - if (!initializing) { - console.log("Starting"); - // realtime is passed around as an attribute of the socket - // FIXME?? - socket.realtime.start(); - return; - } - - var realtime = toReturn.realtime = socket.realtime = ChainPad.create(userName, - passwd, - channel, - $(textarea).val(), - { - transformFunction: config.transformFunction - }); + var realtime = toReturn.realtime = socket.realtime = + // everybody has a username, and we assume they don't collide + // usernames are used to determine whether a message is remote + // or local in origin. This could mess with expected behaviour + // if someone spoofed. + ChainPad.create(userName, + passwd, // password, to be deprecated (maybe) + channel, // the channel we're to connect to + + // initialState argument. (optional) + config.initialState || '', + + // transform function (optional), which handles conflicts + { transformFunction: config.transformFunction }); + + var onEvent = toReturn.onEvent = function (newText) { + if (isErrorState || initializing) { return; } + // assert things here... + if (realtime.getUserDoc() !== newText) { + // this is a problem + warn("realtime.getUserDoc() !== newText"); + } + //try{throw new Error();}catch(e){console.log(e.stack);} + }; + // pass your shiny new realtime into initialization functions if (config.onInit) { // extend as you wish config.onInit({ @@ -203,11 +171,10 @@ define([ }); } - onEvent = function () { - // This looks broken - if (isErrorState || initializing) { return; } - }; - + /* UI hints on userList changes are handled within the toolbar + so we don't actually need to do anything here except confirm + whether we've successfully joined the session, and call our + 'onReady' function */ realtime.onUserListChange(function (userList) { if (!initializing || userList.indexOf(userName) === -1) { return; @@ -221,14 +188,32 @@ define([ if (config.onReady) { // extend as you wish config.onReady({ - userList: userList + userList: userList, + realtime: realtime }); } }); - var whoami = new RegExp(userName.replace(/[\/\+]/g, function (c) { - return '\\' +c; - })); + // when a message is ready to send + // Don't confuse this onMessage with socket.onMessage + realtime.onMessage(function (message) { + if (isErrorState) { return; } + message = Crypto.encrypt(message, cryptKey); + try { + socket.send(message); + } catch (e) { + warn(e); + } + }); + + realtime.onPatch(function () { + if (config.onRemote) { + config.onRemote({ + realtime: realtime + //realtime.getUserDoc() + }); + } + }); // when you receive a message... socket.onMessage.push(function (evt) { @@ -239,37 +224,11 @@ define([ verbose(message); allMessages.push(message); if (!initializing) { - if (PARANOIA) { - // FIXME this is out of sync with the application logic - onEvent(); + if (toReturn.onLocal) { + toReturn.onLocal(); } } realtime.message(message); - if (/\[5,/.test(message)) { verbose("pong"); } - - if (!initializing) { - if (/\[2,/.test(message)) { - //verbose("Got a patch"); - if (whoami.test(message)) { - //verbose("Received own message"); - } else { - //verbose("Received remote message"); - // obviously this is only going to get called if - if (onRemote) { onRemote(realtime.getUserDoc()); } - } - } - } - }); - - // when a message is ready to send - realtime.onMessage(function (message) { - if (isErrorState) { return; } - message = Crypto.encrypt(message, cryptKey); - try { - socket.send(message); - } catch (e) { - warn(e); - } }); // actual socket bindings @@ -286,7 +245,6 @@ define([ socket.onerror = warn; - // TODO confirm that we can rely on netflux API var socketChecker = setInterval(function () { if (checkSocket(socket)) { warn("Socket disconnected!"); @@ -303,26 +261,17 @@ define([ } if (socketChecker) { clearInterval(socketChecker); } } - } else { - // it's working as expected, continue - } + } // it's working as expected, continue }, 200); - bindAllEvents(textarea, doc, onEvent, false); - - // attach textarea - // NOTE: should be able to remove the websocket without damaging this - sharejs.attach(textarea, realtime); + toReturn.patchText = TextPatcher.create({ + realtime: realtime + }); realtime.start(); debug('started'); - - bump = realtime.bumpSharejs; }); - toReturn.onEvent = function () { onEvent(); }; - toReturn.bumpSharejs = function () { bump(); }; - return toReturn; }; return module.exports; diff --git a/www/_socket/text-patcher.js b/www/_socket/text-patcher.js new file mode 100644 index 000000000..9f51c07b5 --- /dev/null +++ b/www/_socket/text-patcher.js @@ -0,0 +1,88 @@ +define(function () { + +/* applyChange takes: + ctx: the context (aka the realtime) + oldval: the old value + newval: the new value + + it performs a diff on the two values, and generates patches + which are then passed into `ctx.remove` and `ctx.insert` +*/ +var applyChange = function(ctx, oldval, newval) { + // Strings are immutable and have reference equality. I think this test is O(1), so its worth doing. + if (oldval === newval) { + return; + } + + var commonStart = 0; + while (oldval.charAt(commonStart) === newval.charAt(commonStart)) { + commonStart++; + } + + var commonEnd = 0; + while (oldval.charAt(oldval.length - 1 - commonEnd) === newval.charAt(newval.length - 1 - commonEnd) && + commonEnd + commonStart < oldval.length && commonEnd + commonStart < newval.length) { + commonEnd++; + } + + var result; + + /* throw some assertions in here before dropping patches into the realtime + + */ + + if (oldval.length !== commonStart + commonEnd) { + if (ctx.localChange) { ctx.localChange(true); } + result = oldval.length - commonStart - commonEnd; + ctx.remove(commonStart, result); + console.log('removal at position: %s, length: %s', commonStart, result); + console.log("remove: [" + oldval.slice(commonStart, commonStart + result ) + ']'); + } + if (newval.length !== commonStart + commonEnd) { + if (ctx.localChange) { ctx.localChange(true); } + result = newval.slice(commonStart, newval.length - commonEnd); + ctx.insert(commonStart, result); + console.log("insert: [" + result + "]"); + } + + var userDoc; + try { + var userDoc = ctx.getUserDoc(); + JSON.parse(userDoc); + } catch (err) { + console.error('[textPatcherParseErr]'); + console.error(err); + window.REALTIME_MODULE.textPatcher_parseError = { + error: err, + userDoc: userDoc + }; + } +}; + +var create = function(config) { + var ctx = config.realtime; + + // initial state will always fail the !== check in genop. + // because nothing will equal this object + var content = {}; + + // *** remote -> local changes + ctx.onPatch(function(pos, length) { + content = ctx.getUserDoc() + }); + + // propogate() + return function (newContent) { + if (newContent !== content) { + applyChange(ctx, ctx.getUserDoc(), newContent); + if (ctx.getUserDoc() !== newContent) { + console.log("Expected that: `ctx.getUserDoc() === newContent`!"); + } + return true; + } + return false; + }; +}; + +return { create: create }; +}); diff --git a/www/socket/toolbar.js b/www/_socket/toolbar.js similarity index 100% rename from www/socket/toolbar.js rename to www/_socket/toolbar.js diff --git a/www/_socket/typingTest.js b/www/_socket/typingTest.js new file mode 100644 index 000000000..af09c72c8 --- /dev/null +++ b/www/_socket/typingTest.js @@ -0,0 +1,63 @@ +define(function () { + var setRandomizedInterval = function (func, target, range) { + var timeout; + var again = function () { + timeout = setTimeout(function () { + again(); + func(); + }, target - (range / 2) + Math.random() * range); + }; + again(); + return { + cancel: function () { + if (timeout) { + clearTimeout(timeout); + timeout = undefined; + } + } + }; + }; + + var testInput = function (doc, el, offset, cb) { + var i = 0, + j = offset, + input = " The quick red fox jumps over the lazy brown dog.", + l = input.length, + errors = 0, + max_errors = 15, + interval; + var cancel = function () { + if (interval) { interval.cancel(); } + }; + + interval = setRandomizedInterval(function () { + cb(); + try { + el.replaceData(j, 0, input.charAt(i)); + } catch (err) { + errors++; + if (errors >= max_errors) { + console.log("Max error number exceeded"); + cancel(); + } + + console.error(err); + var next = document.createTextNode(""); + doc.appendChild(next); + el = next; + j = -1; + } + i = (i + 1) % l; + j++; + }, 200, 50); + + return { + cancel: cancel + }; + }; + + return { + testInput: testInput, + setRandomizedInterval: setRandomizedInterval + }; +}); diff --git a/www/common/chainpad.js b/www/common/chainpad.js index c5854c7cf..502e502ab 100644 --- a/www/common/chainpad.js +++ b/www/common/chainpad.js @@ -372,7 +372,7 @@ var random = Patch.random = function (doc, opCount) { var PARANOIA = module.exports.PARANOIA = false; /* throw errors over non-compliant messages which would otherwise be treated as invalid */ -var TESTING = module.exports.TESTING = false; +var TESTING = module.exports.TESTING = true; var assert = module.exports.assert = function (expr) { if (!expr) { throw new Error("Failed assertion"); } @@ -1443,7 +1443,13 @@ var rebase = Operation.rebase = function (oldOp, newOp) { * @param transformBy an existing operation which also has the same base. * @return toTransform *or* null if the result is a no-op. */ -var transform0 = Operation.transform0 = function (text, toTransform, transformBy) { + +var transform0 = Operation.transform0 = function (text, toTransformOrig, transformByOrig) { + // Cloning the original transformations makes this algorithm such that it + // **DOES NOT MUTATE ANYMORE** + var toTransform = Operation.clone(toTransformOrig); + var transformBy = Operation.clone(transformByOrig); + if (toTransform.offset > transformBy.offset) { if (toTransform.offset > transformBy.offset + transformBy.toRemove) { // simple rebase diff --git a/www/common/cursor.js b/www/common/cursor.js index 069f896c7..8eff84374 100644 --- a/www/common/cursor.js +++ b/www/common/cursor.js @@ -373,6 +373,26 @@ define([ }; }; + cursor.brFix = function () { + cursor.update(); + var start = Range.start; + var end = Range.end; + if (!start.el) { return; } + + if (start.el === end.el && start.offset === end.offset) { + if (start.el.tagName === 'BR') { + // get the parent element, which ought to be a P. + var P = start.el.parentNode; + + [cursor.fixStart, cursor.fixEnd].forEach(function (f) { + f(P, 0); + }); + + cursor.fixSelection(cursor.makeSelection(), cursor.makeRange()); + } + } + }; + return cursor; }; }); diff --git a/www/common/hyperjson.js b/www/common/hyperjson.js index 880a7fe3e..1cfeda733 100644 --- a/www/common/hyperjson.js +++ b/www/common/hyperjson.js @@ -54,7 +54,7 @@ define([], function () { The function, if provided, must return true for elements which should be preserved, and 'false' for elements which should be removed. */ - var DOM2HyperJSON = function(el, predicate){ + var DOM2HyperJSON = function(el, predicate, filter){ if(!el.tagName && el.nodeType === Node.TEXT_NODE){ return el.textContent; } @@ -118,12 +118,16 @@ define([], function () { i = 0; for(; i < el.childNodes.length; i++){ - children.push(DOM2HyperJSON(el.childNodes[i], predicate)); + children.push(DOM2HyperJSON(el.childNodes[i], predicate, filter)); } result.push(children.filter(isTruthy)); - return result; + if (filter) { + return filter(result); + } else { + return result; + } }; return { diff --git a/www/common/json-ot.js b/www/common/json-ot.js index 238ccc18a..41a628dc4 100644 --- a/www/common/json-ot.js +++ b/www/common/json-ot.js @@ -5,19 +5,45 @@ define([ var JsonOT = {}; var validate = JsonOT.validate = function (text, toTransform, transformBy) { - var resultOp = ChainPad.Operation.transform0(text, toTransform, transformBy); - var text2 = ChainPad.Operation.apply(transformBy, text); - var text3 = ChainPad.Operation.apply(resultOp, text2); try { - JSON.parse(text3); - return resultOp; - } catch (e) { + var resultOp = ChainPad.Operation.transform0(text, toTransform, transformBy); + var text2 = ChainPad.Operation.apply(transformBy, text); + var text3 = ChainPad.Operation.apply(resultOp, text2); + try { + JSON.parse(text3); + return resultOp; + } catch (e) { + console.error(e); + var info = window.REALTIME_MODULE.ot_parseError = { + type: 'resultParseError', + resultOp: resultOp, + + toTransform: toTransform, + transformBy: transformBy, + + text1: text, + text2: text2, + text3: text3, + error: e + }; + console.log('Debugging info available at `window.REALTIME_MODULE.ot_parseError`'); + } + } catch (x) { + console.error(x); console.error(e); - console.log({ + var info = window.REALTIME_MODULE.ot_applyError = { + type: 'resultParseError', resultOp: resultOp, + + toTransform: toTransform, + transformBy: transformBy, + + text1: text, text2: text2, - text3: text3 - }); + text3: text3, + error: e + }; + console.log('Debugging info available at `window.REALTIME_MODULE.ot_applyError`'); } // returning **null** breaks out of the loop diff --git a/www/socket/main.js b/www/socket/main.js deleted file mode 100644 index a4c60afa8..000000000 --- a/www/socket/main.js +++ /dev/null @@ -1,294 +0,0 @@ -define([ - '/api/config?cb=' + Math.random().toString(16).substring(2), - '/common/messages.js', - '/common/crypto.js', - '/socket/realtime-input.js', - '/common/convert.js', - '/socket/toolbar.js', - '/common/cursor.js', - '/common/json-ot.js', - '/bower_components/diff-dom/diffDOM.js', - '/bower_components/jquery/dist/jquery.min.js', - '/customize/pad.js' -], function (Config, Messages, Crypto, realtimeInput, Convert, Toolbar, Cursor, JsonOT) { - var $ = window.jQuery; - var ifrw = $('#pad-iframe')[0].contentWindow; - var Ckeditor; // to be initialized later... - var DiffDom = window.diffDOM; - - window.Convert = Convert; - - window.Toolbar = Toolbar; - - var userName = Crypto.rand64(8), - toolbar; - - var module = {}; - - var andThen = function (Ckeditor) { - $(window).on('hashchange', function() { - window.location.reload(); - }); - if (window.location.href.indexOf('#') === -1) { - window.location.href = window.location.href + '#' + Crypto.genKey(); - return; - } - - var fixThings = false; - var key = Crypto.parseKey(window.location.hash.substring(1)); - var editor = window.editor = Ckeditor.replace('editor1', { - // https://dev.ckeditor.com/ticket/10907 - needsBrFiller: fixThings, - needsNbspFiller: fixThings, - removeButtons: 'Source,Maximize', - // magicline plugin inserts html crap into the document which is not part of the - // document itself and causes problems when it's sent across the wire and reflected back - // but we filter it now, so that's ok. - removePlugins: 'resize' - }); - - editor.on('instanceReady', function (Ckeditor) { - editor.execCommand('maximize'); - var documentBody = ifrw.$('iframe')[0].contentDocument.body; - - documentBody.innerHTML = Messages.initialState; - - var inner = window.inner = documentBody; - var cursor = window.cursor = Cursor(inner); - - var $textarea = $('#feedback'); - - var setEditable = function (bool) { - // inner.style.backgroundColor = bool? 'unset': 'grey'; - inner.setAttribute('contenteditable', bool); - }; - - // don't let the user edit until the pad is ready - setEditable(false); - - var diffOptions = { - preDiffApply: function (info) { - /* TODO DiffDOM will filter out magicline plugin elements - in practice this will make it impossible to use it - while someone else is typing, which could be annoying - - we should check when such an element is going to be - removed, and prevent that from happening. */ - - // no use trying to recover the cursor if it doesn't exist - if (!cursor.exists()) { return; } - - /* frame is either 0, 1, 2, or 3, depending on which - cursor frames were affected: none, first, last, or both - */ - var frame = info.frame = cursor.inNode(info.node); - - if (!frame) { return; } - - if (typeof info.diff.oldValue === 'string' && typeof info.diff.newValue === 'string') { - var pushes = cursor.pushDelta(info.diff.oldValue, info.diff.newValue); - - if (frame & 1) { - // push cursor start if necessary - if (pushes.commonStart < cursor.Range.start.offset) { - cursor.Range.start.offset += pushes.delta; - } - } - if (frame & 2) { - // push cursor end if necessary - if (pushes.commonStart < cursor.Range.end.offset) { - cursor.Range.end.offset += pushes.delta; - } - } - } - }, - postDiffApply: function (info) { - if (info.frame) { - if (info.node) { - if (info.frame & 1) { cursor.fixStart(info.node); } - if (info.frame & 2) { cursor.fixEnd(info.node); } - } else { console.error("info.node did not exist"); } - - var sel = cursor.makeSelection(); - var range = cursor.makeRange(); - - cursor.fixSelection(sel, range); - } - } - }; - - var initializing = true; - - var assertStateMatches = function () { - var userDocState = module.realtimeInput.realtime.getUserDoc(); - var currentState = $textarea.val(); - if (currentState !== userDocState) { - console.log({ - userDocState: userDocState, - currentState: currentState - }); - throw new Error("currentState !== userDocState"); - } - }; - - // apply patches, and try not to lose the cursor in the process! - var applyHjson = function (shjson) { - setEditable(false); - var userDocStateDom = Convert.hjson.to.dom(JSON.parse(shjson)); - userDocStateDom.setAttribute("contenteditable", "true"); // lol wtf - var DD = new DiffDom(diffOptions); - - assertStateMatches(); - - var patch = (DD).diff(inner, userDocStateDom); - (DD).apply(inner, patch); - - // push back to the textarea so we get a userDocState - setEditable(true); - }; - - var onRemote = function (shjson) { - if (initializing) { return; } - - // remember where the cursor is - cursor.update(); - - // TODO call propogate - - // build a dom from HJSON, diff, and patch the editor - applyHjson(shjson); - }; - - var onInit = function (info) { - var $bar = $('#pad-iframe')[0].contentWindow.$('#cke_1_toolbox'); - toolbar = info.realtime.toolbar = Toolbar.create($bar, userName, info.realtime); - /* TODO handle disconnects and such*/ - }; - - var onReady = function (info) { - console.log("Unlocking editor"); - initializing = false; - setEditable(true); - applyHjson($textarea.val()); - $textarea.trigger('keyup'); - }; - - var onAbort = function (info) { - console.log("Aborting the session!"); - // stop the user from continuing to edit - setEditable(false); - // TODO inform them that the session was torn down - toolbar.failed(); - }; - - var realtimeOptions = { - // configuration :D - doc: inner, - // first thing called - onInit: onInit, - - onReady: onReady, - - // when remote changes occur - onRemote: onRemote, - - // handle aborts - onAbort: onAbort, - - // really basic operational transform - // reject patch if it results in invalid JSON - transformFunction : JsonOT.validate - }; - - var rti = module.realtimeInput = window.rti = realtimeInput.start($textarea[0], // synced element - Config.websocketURL, // websocketURL, ofc - userName, // userName - key.channel, // channelName - key.cryptKey, // key - realtimeOptions); - - $textarea.val(JSON.stringify(Convert.dom.to.hjson(inner))); - - var isNotMagicLine = function (el) { - // factor as: - // return !(el.tagName === 'SPAN' && el.contentEditable === 'false'); - var filter = (el.tagName === 'SPAN' && el.contentEditable === 'false'); - if (filter) { - console.log("[hyperjson.serializer] prevented an element" + - "from being serialized:", el); - return false; - } - return true; - }; - - var propogate = function () { - var hjson = Convert.core.hyperjson.fromDOM(inner, isNotMagicLine); - - $textarea.val(JSON.stringify(hjson)); - rti.bumpSharejs(); - }; - - var testInput = window.testInput = function (el, offset) { - var i = 0, - j = offset, - input = "The quick red fox jumped over the lazy brown dog. ", - l = input.length, - errors = 0, - max_errors = 15, - interval; - var cancel = function () { - if (interval) { window.clearInterval(interval); } - }; - - interval = window.setInterval(function () { - propogate(); - try { - el.replaceData(j, 0, input.charAt(i)); - } catch (err) { - errors++; - if (errors >= max_errors) { - console.log("Max error number exceeded"); - cancel(); - } - - console.error(err); - var next = document.createTextNode(""); - el.parentNode.appendChild(next); - el = next; - j = 0; - } - i = (i + 1) % l; - j++; - }, 200); - - return { - cancel: cancel - }; - }; - - var easyTest = window.easyTest = function () { - cursor.update(); - var start = cursor.Range.start; - var test = testInput(start.el, start.offset); - //window.rti.bumpSharejs(); - propogate(); - return test; - }; - - editor.on('change', propogate); - }); - }; - - var interval = 100; - var first = function () { - Ckeditor = ifrw.CKEDITOR; - if (Ckeditor) { - andThen(Ckeditor); - } else { - console.log("Ckeditor was not defined. Trying again in %sms",interval); - setTimeout(first, interval); - } - }; - - $(first); -}); diff --git a/www/text/main.js b/www/text/main.js index 4c84064c7..db753e8a7 100644 --- a/www/text/main.js +++ b/www/text/main.js @@ -20,6 +20,7 @@ define([ var $textarea = $('textarea'); var config = { + textarea: $textarea[0], websocketURL: Config.websocketURL, userName: Crypto.rand64(8), channel: key.channel,