diff --git a/.jshintignore b/.jshintignore
index 29b53f0f4..3780e2719 100644
--- a/.jshintignore
+++ b/.jshintignore
@@ -1,6 +1,6 @@
node_modules/
www/bower_components/
-www/code/codemirror-5.7/
+www/code/codemirror*
www/code/mode/
www/code/codemirror.js
www/pad/rangy.js
@@ -12,3 +12,14 @@ storage/kad.js
www/common/otaml.js
www/common/diffDOM.js
www/common/netflux.js
+
+www/padrtc
+www/common/netflux-client.js
+www/common/es6-promise.min.js
+www/_pad
+
+NetFluxWebsocketSrv.js
+NetFluxWebsocketServer.js
+WebRTCSrv.js
+
+www/assert/hyperscript.js
diff --git a/customize.dist/index.html b/customize.dist/index.html
index b652894e6..d5080fcc5 100644
--- a/customize.dist/index.html
+++ b/customize.dist/index.html
@@ -98,9 +98,8 @@
CryptPad is the zero knowledge realtime collaborative editor.
Encryption carried out in your web browser protects the data from the server, the cloud
and the NSA. This project uses the CKEditor Visual Editor
- the ChainPad realtime engine and now
- jQuery.sheet for realtime spreadsheet
- editing! The secret encryption key is stored in the URL
+ the ChainPad realtime engine. The secret
+ encryption key is stored in the URL
fragment identifier which is
never sent to the server but is available to javascript so by sharing the URL, you give
authorization to others who want to participate.
diff --git a/www/assert/hyperjson.js b/www/assert/hyperjson.js
new file mode 100644
index 000000000..90edb0616
--- /dev/null
+++ b/www/assert/hyperjson.js
@@ -0,0 +1,124 @@
+define([], function () {
+ // this makes recursing a lot simpler
+ var isArray = function (A) {
+ return Object.prototype.toString.call(A)==='[object Array]';
+ };
+
+ var parseStyle = function(el){
+ var style = el.style;
+ var output = {};
+ for (var i = 0; i < style.length; ++i) {
+ var item = style.item(i);
+ output[item] = style[item];
+ }
+ return output;
+ };
+
+ var callOnHyperJSON = function (hj, cb) {
+ var children;
+
+ if (hj && hj[2]) {
+ children = hj[2].map(function (child) {
+ if (isArray(child)) {
+ // if the child is an array, recurse
+ return callOnHyperJSON(child, cb);
+ } else if (typeof (child) === 'string') {
+ // string nodes have leading and trailing quotes
+ return child.replace(/(^"|"$)/g,"");
+ } else {
+ // the above branches should cover all methods
+ // if we hit this, there is a problem
+ throw new Error();
+ }
+ });
+ } else {
+ children = [];
+ }
+ // this should return the top level element of your new DOM
+ return cb(hj[0], hj[1], children);
+ };
+
+ var classify = function (token) {
+ return '.' + token.trim();
+ };
+
+ var isValidClass = function (x) {
+ if (x && /\S/.test(x)) {
+ return true;
+ }
+ };
+
+ var isTruthy = function (x) {
+ return x;
+ };
+
+ var DOM2HyperJSON = function(el, predicate, filter){
+ if(!el.tagName && el.nodeType === Node.TEXT_NODE){
+ return el.textContent;
+ }
+ if(!el.attributes){
+ return;
+ }
+ if (predicate) {
+ if (!predicate(el)) {
+ // shortcircuit
+ return;
+ }
+ }
+
+ var attributes = {};
+
+ var i = 0;
+ for(;i < el.attributes.length; i++){
+ var attr = el.attributes[i];
+ if(attr.name && attr.value){
+ if(attr.name === "style"){
+ attributes.style = parseStyle(el);
+ }
+ else{
+ attributes[attr.name] = attr.value;
+ }
+ }
+ }
+
+ // this should never be longer than three elements
+ var result = [];
+
+ // get the element type, id, and classes of the element
+ // and push them to the result array
+ var sel = el.tagName;
+
+ if(attributes.id){
+ // we don't have to do much to validate IDs because the browser
+ // will only permit one id to exist
+ // unless we come across a strange browser in the wild
+ sel = sel +'#'+ attributes.id;
+ delete attributes.id;
+ }
+ result.push(sel);
+
+ // second element of the array is the element attributes
+ result.push(attributes);
+
+ // third element of the array is an array of child nodes
+ var children = [];
+
+ // js hint complains if we use 'var' here
+ i = 0;
+ for(; i < el.childNodes.length; i++){
+ children.push(DOM2HyperJSON(el.childNodes[i], predicate, filter));
+ }
+ result.push(children.filter(isTruthy));
+
+ if (filter) {
+ return filter(result);
+ } else {
+ return result;
+ }
+ };
+
+ return {
+ fromDOM: DOM2HyperJSON,
+ callOn: callOnHyperJSON
+ };
+});
diff --git a/www/assert/hyperscript.js b/www/assert/hyperscript.js
new file mode 100644
index 000000000..52282dcfc
--- /dev/null
+++ b/www/assert/hyperscript.js
@@ -0,0 +1,400 @@
+define([], function () {
+ var Hyperscript;
+
+(function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o
+ * Available under the MIT License
+ * ECMAScript compliant, uniform cross-browser split method
+ */
+
+/**
+ * Splits a string into an array of strings using a regex or string separator. Matches of the
+ * separator are not included in the result array. However, if `separator` is a regex that contains
+ * capturing groups, backreferences are spliced into the result each time `separator` is matched.
+ * Fixes browser bugs compared to the native `String.prototype.split` and can be used reliably
+ * cross-browser.
+ * @param {String} str String to split.
+ * @param {RegExp|String} separator Regex or string to use for separating the string.
+ * @param {Number} [limit] Maximum number of items to include in the result array.
+ * @returns {Array} Array of substrings.
+ * @example
+ *
+ * // Basic use
+ * split('a b c d', ' ');
+ * // -> ['a', 'b', 'c', 'd']
+ *
+ * // With limit
+ * split('a b c d', ' ', 2);
+ * // -> ['a', 'b']
+ *
+ * // Backreferences in result array
+ * split('..word1 word2..', /([a-z]+)(\d+)/i);
+ * // -> ['..', 'word', '1', ' ', 'word', '2', '..']
+ */
+module.exports = (function split(undef) {
+
+ var nativeSplit = String.prototype.split,
+ compliantExecNpcg = /()??/.exec("")[1] === undef,
+ // NPCG: nonparticipating capturing group
+ self;
+
+ self = function(str, separator, limit) {
+ // If `separator` is not a regex, use `nativeSplit`
+ if (Object.prototype.toString.call(separator) !== "[object RegExp]") {
+ return nativeSplit.call(str, separator, limit);
+ }
+ var output = [],
+ flags = (separator.ignoreCase ? "i" : "") + (separator.multiline ? "m" : "") + (separator.extended ? "x" : "") + // Proposed for ES6
+ (separator.sticky ? "y" : ""),
+ // Firefox 3+
+ lastLastIndex = 0,
+ // Make `global` and avoid `lastIndex` issues by working with a copy
+ separator = new RegExp(separator.source, flags + "g"),
+ separator2, match, lastIndex, lastLength;
+ str += ""; // Type-convert
+ if (!compliantExecNpcg) {
+ // Doesn't need flags gy, but they don't hurt
+ separator2 = new RegExp("^" + separator.source + "$(?!\\s)", flags);
+ }
+ /* Values for `limit`, per the spec:
+ * If undefined: 4294967295 // Math.pow(2, 32) - 1
+ * If 0, Infinity, or NaN: 0
+ * If positive number: limit = Math.floor(limit); if (limit > 4294967295) limit -= 4294967296;
+ * If negative number: 4294967296 - Math.floor(Math.abs(limit))
+ * If other: Type-convert, then use the above rules
+ */
+ limit = limit === undef ? -1 >>> 0 : // Math.pow(2, 32) - 1
+ limit >>> 0; // ToUint32(limit)
+ while (match = separator.exec(str)) {
+ // `separator.lastIndex` is not reliable cross-browser
+ lastIndex = match.index + match[0].length;
+ if (lastIndex > lastLastIndex) {
+ output.push(str.slice(lastLastIndex, match.index));
+ // Fix browsers whose `exec` methods don't consistently return `undefined` for
+ // nonparticipating capturing groups
+ if (!compliantExecNpcg && match.length > 1) {
+ match[0].replace(separator2, function() {
+ for (var i = 1; i < arguments.length - 2; i++) {
+ if (arguments[i] === undef) {
+ match[i] = undef;
+ }
+ }
+ });
+ }
+ if (match.length > 1 && match.index < str.length) {
+ Array.prototype.push.apply(output, match.slice(1));
+ }
+ lastLength = match[0].length;
+ lastLastIndex = lastIndex;
+ if (output.length >= limit) {
+ break;
+ }
+ }
+ if (separator.lastIndex === match.index) {
+ separator.lastIndex++; // Avoid an infinite loop
+ }
+ }
+ if (lastLastIndex === str.length) {
+ if (lastLength || !separator.test("")) {
+ output.push("");
+ }
+ } else {
+ output.push(str.slice(lastLastIndex));
+ }
+ return output.length > limit ? output.slice(0, limit) : output;
+ };
+
+ return self;
+})();
+
+},{}],3:[function(require,module,exports){
+// contains, add, remove, toggle
+var indexof = require('indexof')
+
+module.exports = ClassList
+
+function ClassList(elem) {
+ var cl = elem.classList
+
+ if (cl) {
+ return cl
+ }
+
+ var classList = {
+ add: add
+ , remove: remove
+ , contains: contains
+ , toggle: toggle
+ , toString: $toString
+ , length: 0
+ , item: item
+ }
+
+ return classList
+
+ function add(token) {
+ var list = getTokens()
+ if (indexof(list, token) > -1) {
+ return
+ }
+ list.push(token)
+ setTokens(list)
+ }
+
+ function remove(token) {
+ var list = getTokens()
+ , index = indexof(list, token)
+
+ if (index === -1) {
+ return
+ }
+
+ list.splice(index, 1)
+ setTokens(list)
+ }
+
+ function contains(token) {
+ return indexof(getTokens(), token) > -1
+ }
+
+ function toggle(token) {
+ if (contains(token)) {
+ remove(token)
+ return false
+ } else {
+ add(token)
+ return true
+ }
+ }
+
+ function $toString() {
+ return elem.className
+ }
+
+ function item(index) {
+ var tokens = getTokens()
+ return tokens[index] || null
+ }
+
+ function getTokens() {
+ var className = elem.className
+
+ return filter(className.split(" "), isTruthy)
+ }
+
+ function setTokens(list) {
+ var length = list.length
+
+ elem.className = list.join(" ")
+ classList.length = length
+
+ for (var i = 0; i < list.length; i++) {
+ classList[i] = list[i]
+ }
+
+ delete list[length]
+ }
+}
+
+function filter (arr, fn) {
+ var ret = []
+ for (var i = 0; i < arr.length; i++) {
+ if (fn(arr[i])) ret.push(arr[i])
+ }
+ return ret
+}
+
+function isTruthy(value) {
+ return !!value
+}
+
+},{"indexof":4}],4:[function(require,module,exports){
+
+var indexOf = [].indexOf;
+
+module.exports = function(arr, obj){
+ if (indexOf) return arr.indexOf(obj);
+ for (var i = 0; i < arr.length; ++i) {
+ if (arr[i] === obj) return i;
+ }
+ return -1;
+};
+},{}],5:[function(require,module,exports){
+var h = require("./index.js");
+
+module.exports = h;
+
+/*
+$(function () {
+
+ var newDoc = h('p',
+
+ h('ul', 'bang bang bang'.split(/\s/).map(function (word) {
+ return h('li', word);
+ }))
+ );
+ $('body').html(newDoc.outerHTML);
+});
+
+*/
+
+},{"./index.js":1}],6:[function(require,module,exports){
+
+},{}]},{},[5]);
+
+ return Hyperscript;
+});
diff --git a/www/assert/index.html b/www/assert/index.html
new file mode 100644
index 000000000..5feebf5ae
--- /dev/null
+++ b/www/assert/index.html
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+Serialization tests
+
+Test 1
+class strings
+
+
+
+
+
+Test 2
+XWiki Macros
+
+
+
+
+
diff --git a/www/assert/main.js b/www/assert/main.js
new file mode 100644
index 000000000..f8c4f7ce1
--- /dev/null
+++ b/www/assert/main.js
@@ -0,0 +1,50 @@
+define([
+ '/bower_components/jquery/dist/jquery.min.js',
+ '/assert/hyperjson.js', // serializing classes as an attribute
+ '/assert/hyperscript.js', // using setAttribute
+ '/common/TextPatcher.js'
+], function (jQuery, Hyperjson, Hyperscript, TextPatcher) {
+ var $ = window.jQuery;
+ window.Hyperjson = Hyperjson;
+ window.Hyperscript = Hyperscript;
+ window.TextPatcher = TextPatcher;
+
+ var assertions = 0;
+
+ var assert = function (test, msg) {
+ if (test()) {
+ assertions++;
+ } else {
+ throw new Error(msg || '');
+ }
+ };
+
+ var $body = $('body');
+
+ var roundTrip = function (target) {
+ assert(function () {
+ var hjson = Hyperjson.fromDOM(target);
+ var cloned = Hyperjson.callOn(hjson, Hyperscript);
+
+ var success = cloned.outerHTML === target.outerHTML;
+
+ if (!success) {
+ window.DEBUG = {
+ error: "Expected equality between A and B",
+ A: target.outerHTML,
+ B: cloned.outerHTML,
+ target: target,
+ diff: TextPatcher.diff(target.outerHTML, cloned.outerHTML)
+ };
+ console.log(JSON.stringify(window.DEBUG, null, 2));
+ }
+
+ return success;
+ }, "Round trip serialization introduced artifacts.");
+ };
+
+ roundTrip($('#target')[0]);
+ roundTrip($('#widget')[0]);
+
+ console.log("%s test%s passed", assertions, assertions === 1? '':'s');
+});
diff --git a/www/common/RealtimeTextSocket.js b/www/common/RealtimeTextSocket.js
new file mode 100644
index 000000000..4821ce454
--- /dev/null
+++ b/www/common/RealtimeTextSocket.js
@@ -0,0 +1,278 @@
+/*
+ * Copyright 2014 XWiki SAS
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+define([
+ '/common/messages.js',
+ '/bower_components/reconnectingWebsocket/reconnecting-websocket.js',
+ '/common/crypto.js',
+ '/common/TextPatcher.js',
+ '/common/chainpad.js',
+ '/bower_components/jquery/dist/jquery.min.js',
+], function (Messages, ReconnectingWebSocket, Crypto, TextPatcher) {
+ var $ = window.jQuery;
+ var ChainPad = window.ChainPad;
+ var PARANOIA = true;
+ var module = { exports: {} };
+
+ /**
+ * If an error is encountered but it is recoverable, do not immediately fail
+ * but if it keeps firing errors over and over, do fail.
+ */
+ 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); };
+ 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");
+ }
+ };
+
+ /* websocket stuff */
+ var isSocketDisconnected = function (socket, realtime) {
+ var sock = socket._socket;
+ return sock.readyState === sock.CLOSING
+ || sock.readyState === sock.CLOSED
+ || (realtime.getLag().waiting && realtime.getLag().lag > MAX_LAG_BEFORE_DISCONNECT);
+ };
+
+ // this differs from other functions with similar names in that
+ // you are expected to pass a socket into it.
+ var checkSocket = function (socket) {
+ if (isSocketDisconnected(socket, socket.realtime) &&
+ !socket.intentionallyClosing) {
+ return true;
+ } else {
+ return false;
+ }
+ };
+
+ // TODO before removing websocket implementation
+ // bind abort to onLeaving
+ var abort = function (socket, realtime) {
+ realtime.abort();
+ try { socket._socket.close(); } catch (e) { warn(e); }
+ };
+
+ var handleError = function (socket, realtime, err, docHTML, allMessages) {
+ // var internalError = createDebugInfo(err, realtime, docHTML, allMessages);
+ abort(socket, realtime);
+ };
+
+ 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: [], // 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
+ };
+ var mkHandler = function (name) {
+ return function (evt) {
+ for (var i = 0; i < out[name].length; i++) {
+ if (out[name][i](evt) === false) {
+ console.log(name +"Handler");
+ return;
+ }
+ }
+ };
+ };
+
+ // bind your new handlers to the important listeners on the socket
+ socket.onopen = mkHandler('onOpen');
+ socket.onclose = mkHandler('onClose');
+ socket.onerror = mkHandler('onError');
+ socket.onmessage = mkHandler('onMessage');
+ return out;
+ };
+ /* end websocket stuff */
+
+ 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';
+ var doc = config.doc || null;
+
+ // wrap up the reconnecting websocket with our additional stack logic
+ var socket = makeWebsocket(websocketUrl);
+
+ var allMessages = window.chainpad_allMessages = [];
+ var isErrorState = false;
+ var initializing = true;
+ var recoverableErrorCount = 0;
+
+ var toReturn = { socket: socket };
+
+ socket.onOpen.push(function (evt) {
+ 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
+
+ /* optional unless your application expects JSON
+ from getUserDoc */
+ 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");
+ }
+ };
+
+ // pass your shiny new realtime into initialization functions
+ if (config.onInit) {
+ // extend as you wish
+ config.onInit({
+ realtime: realtime
+ });
+ }
+
+ /* 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;
+ }
+ // if we spot ourselves being added to the document, we'll switch
+ // 'initializing' off because it means we're fully synced.
+ initializing = false;
+
+ // execute an onReady callback if one was supplied
+ // pass an object so we can extend this later
+ if (config.onReady) {
+ // extend as you wish
+ config.onReady({
+ userList: userList,
+ realtime: realtime
+ });
+ }
+ });
+
+ // 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) {
+ verbose(evt.data);
+ if (isErrorState) { return; }
+
+ var message = Crypto.decrypt(evt.data, cryptKey);
+ verbose(message);
+ allMessages.push(message);
+ if (!initializing) {
+ if (toReturn.onLocal) {
+ toReturn.onLocal();
+ }
+ }
+ realtime.message(message);
+ });
+
+ // actual socket bindings
+ socket.onmessage = function (evt) {
+ for (var i = 0; i < socket.onMessage.length; i++) {
+ if (socket.onMessage[i](evt) === false) { return; }
+ }
+ };
+ socket.onclose = function (evt) {
+ for (var i = 0; i < socket.onMessage.length; i++) {
+ if (socket.onClose[i](evt) === false) { return; }
+ }
+ };
+
+ socket.onerror = warn;
+
+ var socketChecker = setInterval(function () {
+ if (checkSocket(socket)) {
+ warn("Socket disconnected!");
+
+ recoverableErrorCount += 1;
+
+ if (recoverableErrorCount >= MAX_RECOVERABLE_ERRORS) {
+ warn("Giving up!");
+ abort(socket, realtime);
+ if (config.onAbort) {
+ config.onAbort({
+ socket: socket
+ });
+ }
+ if (socketChecker) { clearInterval(socketChecker); }
+ }
+ } // it's working as expected, continue
+ }, 200);
+
+ toReturn.patchText = TextPatcher.create({
+ realtime: realtime,
+ logging: true
+ });
+
+ realtime.start();
+ debug('started');
+ });
+
+ return toReturn;
+ };
+ return module.exports;
+});
diff --git a/www/common/TextPatcher.js b/www/common/TextPatcher.js
new file mode 100644
index 000000000..98ccea65a
--- /dev/null
+++ b/www/common/TextPatcher.js
@@ -0,0 +1,121 @@
+define(function () {
+
+/* diff takes two strings, the old content, and the desired content
+ it returns the difference between these two strings in the form
+ of an 'Operation' (as defined in chainpad.js).
+
+ diff is purely functional.
+*/
+var diff = function (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 toRemove = 0;
+ var toInsert = '';
+
+ /* throw some assertions in here before dropping patches into the realtime */
+ if (oldval.length !== commonStart + commonEnd) {
+ toRemove = oldval.length - commonStart - commonEnd;
+ }
+ if (newval.length !== commonStart + commonEnd) {
+ toInsert = newval.slice(commonStart, newval.length - commonEnd);
+ }
+
+ return {
+ type: 'Operation',
+ offset: commonStart,
+ toInsert: toInsert,
+ toRemove: toRemove
+ };
+};
+
+/* patch accepts a realtime facade and an operation (which might be falsey)
+ it applies the operation to the realtime as components (remove/insert)
+
+ patch has no return value, and operates solely through side effects on
+ the realtime facade.
+*/
+var patch = function (ctx, op) {
+ if (!op) { return; }
+ if (op.toRemove) { ctx.remove(op.offset, op.toRemove); }
+ if (op.toInsert) { ctx.insert(op.offset, op.toInsert); }
+};
+
+/* log accepts a string and an operation, and prints an object to the console
+ the object will display the content which is to be removed, and the content
+ which will be inserted in its place.
+
+ log is useful for debugging, but can otherwise be disabled.
+*/
+var log = function (text, op) {
+ if (!op) { return; }
+ console.log({
+ insert: op.toInsert,
+ remove: text.slice(op.offset, op.offset + op.toRemove)
+ });
+};
+
+/* 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`.
+
+ Due to its reliance on patch, applyChange has side effects on the supplied
+ realtime facade.
+*/
+var applyChange = function(ctx, oldval, newval, logging) {
+ var op = diff(oldval, newval);
+ if (logging) { log(oldval, op) }
+ patch(ctx, op);
+};
+
+var create = function(config) {
+ var ctx = config.realtime;
+ var logging = config.logging;
+
+ // 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, logging);
+ if (ctx.getUserDoc() !== newContent) {
+ console.log("Expected that: `ctx.getUserDoc() === newContent`!");
+ }
+ return true;
+ }
+ return false;
+ };
+};
+
+return {
+ create: create, // create a TextPatcher object
+ diff: diff, // diff two strings
+ patch: patch, // apply an operation to a chainpad's realtime facade
+ log: log, // print the components of an operation
+ applyChange: applyChange // a convenient wrapper around diff/log/patch
+};
+});
diff --git a/www/common/TypingTests.js b/www/common/TypingTests.js
new file mode 100644
index 000000000..af09c72c8
--- /dev/null
+++ b/www/common/TypingTests.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..134d80ce2 100644
--- a/www/common/chainpad.js
+++ b/www/common/chainpad.js
@@ -220,10 +220,9 @@ var transform = Patch.transform = function (origToTransform, transformBy, doc, t
Common.assert(origToTransform.parentHash === transformBy.parentHash);
var resultOfTransformBy = apply(transformBy, doc);
- toTransform = clone(origToTransform);
+ var toTransform = clone(origToTransform);
var text = doc;
for (var i = toTransform.operations.length-1; i >= 0; i--) {
- text = Operation.apply(toTransform.operations[i], text);
for (var j = transformBy.operations.length-1; j >= 0; j--) {
toTransform.operations[i] = Operation.transform(text,
toTransform.operations[i],
@@ -369,10 +368,10 @@ var random = Patch.random = function (doc, opCount) {
* along with this program. If not, see .
*/
-var PARANOIA = module.exports.PARANOIA = false;
+var PARANOIA = module.exports.PARANOIA = true;
/* 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"); }
@@ -833,7 +832,7 @@ var check = ChainPad.check = function(realtime) {
Common.assert(uiDoc === realtime.userInterfaceContent);
}
- var doc = realtime.authDoc;
+ /*var doc = realtime.authDoc;
var patchMsg = realtime.best;
Common.assert(patchMsg.content.inverseOf.parentHash === realtime.uncommitted.parentHash);
var patches = [];
@@ -845,7 +844,7 @@ var check = ChainPad.check = function(realtime) {
while ((patchMsg = patches.pop())) {
doc = Patch.apply(patchMsg.content, doc);
}
- Common.assert(doc === realtime.authDoc);
+ Common.assert(doc === realtime.authDoc);*/
};
var doOperation = ChainPad.doOperation = function (realtime, op) {
@@ -1443,7 +1442,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/convert.js b/www/common/convert.js
index b95591e93..973881c80 100644
--- a/www/common/convert.js
+++ b/www/common/convert.js
@@ -5,7 +5,7 @@ define([
], function (vdom, hyperjson, hyperscript) {
// complain if you don't find the required APIs
if (!(vdom && hyperjson && hyperscript)) { throw new Error(); }
-
+
// Generate a matrix of conversions
/*
convert.dom.to.hjson, convert.hjson.to.dom,
@@ -46,7 +46,7 @@ define([
return hyperjson.fromDOM(vdom.create(V));
}
}
- },
+ },
convert = {};
Object.keys(methods).forEach(function (method) {
convert[method] = { to: methods[method] };
diff --git a/www/common/cursor.js b/www/common/cursor.js
index 583a8860b..8eff84374 100644
--- a/www/common/cursor.js
+++ b/www/common/cursor.js
@@ -136,10 +136,9 @@ define([
verbose("cursor.update");
root = root || inner;
sel = sel || Rangy.getSelection(root);
- // FIXME under what circumstances are no ranges found?
if (!sel.rangeCount) {
error('[cursor.update] no ranges found');
- //return 'no ranges found';
+ return;
}
var range = sel.getRangeAt(0);
@@ -374,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 db6d3f20f..ff04a0da9 100644
--- a/www/common/hyperjson.js
+++ b/www/common/hyperjson.js
@@ -1,5 +1,4 @@
define([], function () {
-
// this makes recursing a lot simpler
var isArray = function (A) {
return Object.prototype.toString.call(A)==='[object Array]';
@@ -17,7 +16,7 @@ define([], function () {
var callOnHyperJSON = function (hj, cb) {
var children;
-
+
if (hj && hj[2]) {
children = hj[2].map(function (child) {
if (isArray(child)) {
@@ -39,13 +38,34 @@ define([], function () {
return cb(hj[0], hj[1], children);
};
- var DOM2HyperJSON = function(el){
+ var classify = function (token) {
+ return '.' + token.trim();
+ };
+
+ var isValidClass = function (x) {
+ if (x && /\S/.test(x)) {
+ return true;
+ }
+ };
+
+ var isTruthy = function (x) {
+ return x;
+ };
+
+ var DOM2HyperJSON = function(el, predicate, filter){
if(!el.tagName && el.nodeType === Node.TEXT_NODE){
return el.textContent;
}
if(!el.attributes){
return;
}
+ if (predicate) {
+ if (!predicate(el)) {
+ // shortcircuit
+ return;
+ }
+ }
+
var attributes = {};
var i = 0;
@@ -69,11 +89,25 @@ define([], function () {
var sel = el.tagName;
if(attributes.id){
+ // we don't have to do much to validate IDs because the browser
+ // will only permit one id to exist
+ // unless we come across a strange browser in the wild
sel = sel +'#'+ attributes.id;
delete attributes.id;
}
if(attributes.class){
- sel = sel +'.'+ attributes.class.replace(/ /g,".");
+ // actually parse out classes so that we produce a valid selector
+ // string. leading or trailing spaces would have caused it to choke
+ // these are really common in generated html
+ /* TODO this can be done with RegExps alone, and it will be faster
+ but this works and is a little less error prone, albeit slower
+ come back and speed it up when it comes time to optimize */
+ sel = sel + attributes.class
+ .split(/\s+/g)
+ .filter(isValidClass)
+ .map(classify)
+ .join('')
+ .replace(/\.\./g, '.');
delete attributes.class;
}
result.push(sel);
@@ -87,11 +121,15 @@ define([], function () {
// js hint complains if we use 'var' here
i = 0;
for(; i < el.childNodes.length; i++){
- children.push(DOM2HyperJSON(el.childNodes[i]));
+ children.push(DOM2HyperJSON(el.childNodes[i], predicate, filter));
}
- result.push(children);
+ 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 167e3d11a..a785d524c 100644
--- a/www/common/json-ot.js
+++ b/www/common/json-ot.js
@@ -4,15 +4,59 @@ define([
var ChainPad = window.ChainPad;
var JsonOT = {};
+/* FIXME
+ resultOp after transform0() might be null, in which case you should return null
+ because it is simply a transformation which yields a "do nothing" operation */
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);
+ var resultOp, text2, text3;
try {
- JSON.parse(text3);
- return resultOp;
- } catch (e) {
- console.log(e);
+ // text = O (mutual common ancestor)
+ // toTransform = A (the first incoming operation)
+ // transformBy = B (the second incoming operation)
+ // threeway merge (0, A, B)
+
+ resultOp = ChainPad.Operation.transform0(text, toTransform, transformBy);
+
+ /* if after operational transform we find that no op is necessary
+ return null to ignore this patch */
+ if (!resultOp) { return null; }
+
+ text2 = ChainPad.Operation.apply(transformBy, text);
+ 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);
+ window.REALTIME_MODULE.ot_applyError = {
+ type: 'resultParseError',
+ resultOp: resultOp,
+
+ toTransform: toTransform,
+ transformBy: transformBy,
+
+ text1: text,
+ text2: text2,
+ text3: text3,
+ error: x
+ };
+ console.log('Debugging info available at `window.REALTIME_MODULE.ot_applyError`');
}
// returning **null** breaks out of the loop
diff --git a/www/hack/index.html b/www/hack/index.html
index 4fa7e6065..2e8df970f 100644
--- a/www/hack/index.html
+++ b/www/hack/index.html
@@ -12,8 +12,14 @@
box-sizing: border-box;
}
textarea{
+ position: absolute;
+ top: 5vh;
+ left: 0px;
+ border: 0px;
+
+ padding-top: 15px;
width: 100%;
- height: 100vh;
+ height: 95vh;
max-width: 100%;
max-height: 100vh;
@@ -32,26 +38,41 @@
color: #637476;
}
- #run {
+ #panel {
position: fixed;
top: 0px;
right: 0px;
-
- z-index: 100;
- width: 5vw;
+ width: 100%;
height: 5vh;
+ z-index: 95;
+ background-color: #777;
+ /* min-height: 75px; */
+ }
+ #run {
+ display: block;
+ float: right;
+ height: 100%;
+ width: 10vw;
+ z-index: 100;
+ line-height: 5vw;
+ font-size: 1.5em;
background-color: #222;
color: #CCC;
-
- display: block;
text-align: center;
+ border-radius: 5%;
+ border: 0px;
}
- RUN
+
diff --git a/www/hack/main.js b/www/hack/main.js
index ab38f8402..b775b73dc 100644
--- a/www/hack/main.js
+++ b/www/hack/main.js
@@ -3,9 +3,10 @@ define([
'/common/realtime-input.js',
'/common/messages.js',
'/common/crypto.js',
+ '/common/cursor.js',
'/bower_components/jquery/dist/jquery.min.js',
'/customize/pad.js'
-], function (Config, Realtime, Messages, Crypto) {
+], function (Config, Realtime, Messages, Crypto, Cursor) {
var $ = window.jQuery;
$(window).on('hashchange', function() {
window.location.reload();
@@ -57,7 +58,75 @@ define([
window.alert("Server Connection Lost");
};
- var rt = Realtime.start(config);
+ var rt = window.rt = Realtime.start(config);
+
+ var cursor = Cursor($textarea[0]);
+
+ var splice = function (str, index, chars) {
+ var count = chars.length;
+ return str.slice(0, index) + chars + str.slice((index -1) + count);
+ };
+
+ var setSelectionRange = function (input, start, end) {
+ if (input.setSelectionRange) {
+ input.focus();
+ input.setSelectionRange(start, end);
+ } else if (input.createTextRange) {
+ var range = input.createTextRange();
+ range.collapse(true);
+ range.moveEnd('character', end);
+ range.moveStart('character', start);
+ range.select();
+ }
+ };
+
+ var setCursor = function (el, pos) {
+ setSelectionRange(el, pos, pos);
+ };
+
+ var state = {};
+
+ // TODO
+ $textarea.on('keydown', function (e) {
+ // track when control keys are pushed down
+ //switch (e.key) { }
+ });
+
+ // TODO
+ $textarea.on('keyup', function (e) {
+ // track when control keys are released
+ });
+
+ $textarea.on('keypress', function (e) {
+ switch (e.key) {
+ case 'Tab':
+ // insert a tab wherever the cursor is...
+ var start = $textarea.prop('selectionStart');
+ var end = $textarea.prop('selectionEnd');
+ if (typeof start !== 'undefined') {
+ if (start === end) {
+ $textarea.val(function (i, val) {
+ return splice(val, start, "\t");
+ });
+ setCursor($textarea[0], start +1);
+ } else {
+ // indentation?? this ought to be fun.
+
+ }
+ }
+ // simulate a keypress so the event goes through..
+ // prevent default behaviour for tab
+ e.preventDefault();
+ rt.bumpSharejs();
+ break;
+ default:
+ break;
+ }
+ });
+
+ $textarea.on('change', function () {
+ rt.bumpSharejs();
+ });
$run.click(function (e) {
e.preventDefault();
diff --git a/www/p/index.html b/www/p/index.html
new file mode 100644
index 000000000..830d56125
--- /dev/null
+++ b/www/p/index.html
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/www/p/inner.html b/www/p/inner.html
new file mode 100644
index 000000000..bf79dcd0d
--- /dev/null
+++ b/www/p/inner.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/www/p/main.js b/www/p/main.js
new file mode 100644
index 000000000..9160cbd67
--- /dev/null
+++ b/www/p/main.js
@@ -0,0 +1,320 @@
+define([
+ '/api/config?cb=' + Math.random().toString(16).substring(2),
+ '/common/messages.js',
+ '/common/crypto.js',
+ '/common/RealtimeTextSocket.js',
+ '/common/hyperjson.js',
+ '/common/hyperscript.js',
+ '/p/toolbar.js',
+ '/common/cursor.js',
+ '/common/json-ot.js',
+ '/common/TypingTests.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 = {
+ Hyperjson: Hyperjson,
+ Hyperscript: Hyperscript
+ };
+
+ var isNotMagicLine = function (el) {
+ // factor as:
+ // return !(el.tagName === 'SPAN' && el.contentEditable === 'false');
+ var filter = (el.tagName === 'SPAN' &&
+ el.getAttribute('contentEditable') === 'false');
+ if (filter) {
+ console.log("[hyperjson.serializer] prevented an element" +
+ "from being serialized:", el);
+ return false;
+ }
+ return true;
+ };
+
+ /* catch `type="_moz"` before it goes over the wire */
+ var brFilter = function (hj) {
+ if (hj[1].type === '_moz') { hj[1].type = undefined; }
+ return hj;
+ };
+
+ var stringifyDOM = function (dom) {
+ return JSON.stringify(Hyperjson.fromDOM(dom, isNotMagicLine, brFilter));
+ };
+
+ 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.getAttribute('contentEditable') === "false") {
+ // 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: stringifyDOM(inner) || '{}',
+
+ // 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);
+
+ // apply patches, and try not to lose the cursor in the process!
+ var applyHjson = function (shjson) {
+ var userDocStateDom = hjsonToDom(JSON.parse(shjson));
+
+ /* in the DOM contentEditable is "false"
+ while "contenteditable" is undefined.
+
+ When it goes over the wire, it seems hyperjson transforms it.
+ of course, hyperjson simply gets attributes from the DOM.
+
+ el.attributes returns 'contenteditable', so we have to correct for that
+
+ There are quite possibly all sorts of other attributes which might lose
+ information, and we won't know what they are until after we've lost them.
+
+ this comes from hyperscript line 101. FIXME maybe
+ */
+ userDocStateDom.setAttribute("contenteditable", "true"); // lol wtf
+ var patch = (DD).diff(inner, userDocStateDom);
+ (DD).apply(inner, patch);
+ };
+
+ var initializing = true;
+
+ var onRemote = realtimeOptions.onRemote = function (info) {
+ if (initializing) { return; }
+
+ 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 = stringifyDOM(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);
+
+
+ /* 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 () {
+ var shjson = stringifyDOM(inner);
+ if (!rti.patchText(shjson)) {
+ return;
+ }
+ rti.onEvent(shjson);
+ };
+
+ /* 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/p/toolbar.js b/www/p/toolbar.js
new file mode 100644
index 000000000..4159ea0ac
--- /dev/null
+++ b/www/p/toolbar.js
@@ -0,0 +1,233 @@
+define([
+ '/common/messages.js'
+], function (Messages) {
+
+ /** Id of the element for getting debug info. */
+ var DEBUG_LINK_CLS = 'rtwysiwyg-debug-link';
+
+ /** Id of the div containing the user list. */
+ var USER_LIST_CLS = 'rtwysiwyg-user-list';
+
+ /** Id of the div containing the lag info. */
+ var LAG_ELEM_CLS = 'rtwysiwyg-lag';
+
+ /** The toolbar class which contains the user list, debug link and lag. */
+ var TOOLBAR_CLS = 'rtwysiwyg-toolbar';
+
+ /** Key in the localStore which indicates realtime activity should be disallowed. */
+ var LOCALSTORAGE_DISALLOW = 'rtwysiwyg-disallow';
+
+ var SPINNER_DISAPPEAR_TIME = 3000;
+ var SPINNER = [ '-', '\\', '|', '/' ];
+
+ var uid = function () {
+ return 'rtwysiwyg-uid-' + String(Math.random()).substring(2);
+ };
+
+ var createRealtimeToolbar = function ($container) {
+ var id = uid();
+ $container.prepend(
+ '
'
+ );
+ var toolbar = $container.find('#'+id);
+ toolbar.append([
+ ''
+ ].join('\n'));
+ return toolbar;
+ };
+
+ var createEscape = function ($container) {
+ var id = uid();
+ $container.append('⇐ Back
');
+ var $ret = $container.find('#'+id);
+ $ret.on('click', function () {
+ window.location.href = '/';
+ });
+ return $ret[0];
+ };
+
+ var createSpinner = function ($container) {
+ var id = uid();
+ $container.append('');
+ return $container.find('#'+id)[0];
+ };
+
+ var kickSpinner = function (spinnerElement, reversed) {
+ var txt = spinnerElement.textContent || '-';
+ var inc = (reversed) ? -1 : 1;
+ spinnerElement.textContent = SPINNER[(SPINNER.indexOf(txt) + inc) % SPINNER.length];
+ if (spinnerElement.timeout) { clearTimeout(spinnerElement.timeout); }
+ spinnerElement.timeout = setTimeout(function () {
+ spinnerElement.textContent = '';
+ }, SPINNER_DISAPPEAR_TIME);
+ };
+
+ var createUserList = function ($container) {
+ var id = uid();
+ $container.append('');
+ return $container.find('#'+id)[0];
+ };
+
+ var updateUserList = function (myUserName, listElement, userList) {
+ var meIdx = userList.indexOf(myUserName);
+ if (meIdx === -1) {
+ listElement.textContent = Messages.synchronizing;
+ return;
+ }
+ if (userList.length === 1) {
+ listElement.textContent = Messages.editingAlone;
+ } else if (userList.length === 2) {
+ listElement.textContent = Messages.editingWithOneOtherPerson;
+ } else {
+ listElement.textContent = Messages.editingWith + ' ' + (userList.length - 1) + ' ' + Messages.otherPeople;
+ }
+ };
+
+ var createLagElement = function ($container) {
+ var id = uid();
+ $container.append('');
+ return $container.find('#'+id)[0];
+ };
+
+ var checkLag = function (realtime, lagElement) {
+ var lag = realtime.getLag();
+ var lagSec = lag.lag/1000;
+ var lagMsg = Messages.lag + ' ';
+ if (lag.waiting && lagSec > 1) {
+ lagMsg += "?? " + Math.floor(lagSec);
+ } else {
+ lagMsg += lagSec;
+ }
+ lagElement.textContent = lagMsg;
+ };
+
+ // this is a little hack, it should go in it's own file.
+ // FIXME ok, so let's put it in its own file then
+ // TODO there should also be a 'clear recent pads' button
+ var rememberPad = function () {
+ // FIXME, this is overly complicated, use array methods
+ var recentPadsStr = localStorage['CryptPad_RECENTPADS'];
+ var recentPads = [];
+ if (recentPadsStr) { recentPads = JSON.parse(recentPadsStr); }
+ // TODO use window.location.hash or something like that
+ if (window.location.href.indexOf('#') === -1) { return; }
+ var now = new Date();
+ var out = [];
+ for (var i = recentPads.length; i >= 0; i--) {
+ if (recentPads[i] &&
+ // TODO precompute this time value, maybe make it configurable?
+ // FIXME precompute the date too, why getTime every time?
+ now.getTime() - recentPads[i][1] < (1000*60*60*24*30) &&
+ recentPads[i][0] !== window.location.href)
+ {
+ out.push(recentPads[i]);
+ }
+ }
+ out.push([window.location.href, now.getTime()]);
+ localStorage['CryptPad_RECENTPADS'] = JSON.stringify(out);
+ };
+
+ var create = function ($container, myUserName, realtime) {
+ var toolbar = createRealtimeToolbar($container);
+ createEscape(toolbar.find('.rtwysiwyg-toolbar-leftside'));
+ var userListElement = createUserList(toolbar.find('.rtwysiwyg-toolbar-leftside'));
+ var spinner = createSpinner(toolbar.find('.rtwysiwyg-toolbar-rightside'));
+ var lagElement = createLagElement(toolbar.find('.rtwysiwyg-toolbar-rightside'));
+
+ rememberPad();
+
+ var connected = false;
+
+ realtime.onUserListChange(function (userList) {
+ if (userList.indexOf(myUserName) !== -1) { connected = true; }
+ if (!connected) { return; }
+ updateUserList(myUserName, userListElement, userList);
+ });
+
+ var ks = function () {
+ if (connected) { kickSpinner(spinner, false); }
+ };
+
+ realtime.onPatch(ks);
+ // Try to filter out non-patch messages, doesn't have to be perfect this is just the spinner
+ realtime.onMessage(function (msg) { if (msg.indexOf(':[2,') > -1) { ks(); } });
+
+ setInterval(function () {
+ if (!connected) { return; }
+ checkLag(realtime, lagElement);
+ }, 3000);
+
+ return {
+ failed: function () {
+ connected = false;
+ userListElement.textContent = '';
+ lagElement.textContent = '';
+ },
+ reconnecting: function () {
+ connected = false;
+ userListElement.textContent = Messages.reconnecting;
+ lagElement.textContent = '';
+ },
+ connected: function () {
+ connected = true;
+ }
+ };
+ };
+
+ return { create: create };
+});
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) {