From 2c34833d2c860c5f7fa9b5ba7c49335191cf3d63 Mon Sep 17 00:00:00 2001 From: ansuz Date: Tue, 12 Apr 2016 12:27:33 +0200 Subject: [PATCH 1/6] break text-patcher's functionality into components text-patcher.js now exports diff, patch, log, and apply change in addition to the previous 'create' method. --- www/_socket/text-patcher.js | 100 ++++++++++++++++++++++++------------ 1 file changed, 66 insertions(+), 34 deletions(-) diff --git a/www/_socket/text-patcher.js b/www/_socket/text-patcher.js index 9f51c07b5..87cefa9dc 100644 --- a/www/_socket/text-patcher.js +++ b/www/_socket/text-patcher.js @@ -1,14 +1,12 @@ define(function () { -/* applyChange takes: - ctx: the context (aka the realtime) - oldval: the old value - newval: the new value +/* 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). - it performs a diff on the two values, and generates patches - which are then passed into `ctx.remove` and `ctx.insert` + diff is purely functional. */ -var applyChange = function(ctx, oldval, newval) { +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; @@ -25,38 +23,66 @@ var applyChange = function(ctx, oldval, newval) { commonEnd++; } - var result; - - /* throw some assertions in here before dropping patches into the realtime - - */ + var toRemove; + var toInsert; + /* 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 ) + ']'); + toRemove = oldval.length - commonStart - commonEnd; } 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 + "]"); + toInsert = newval.slice(commonStart, newval.length - commonEnd); } - 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 - }; - } + 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) { + var op = diff(oldval, newval); + // log(oldval, op) + patch(ctx, op); }; var create = function(config) { @@ -84,5 +110,11 @@ var create = function(config) { }; }; -return { create: create }; +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 +}; }); From 9805958ad76637f8240acae8c48bdbf2c3fd2574 Mon Sep 17 00:00:00 2001 From: ansuz Date: Tue, 12 Apr 2016 12:46:49 +0200 Subject: [PATCH 2/6] stabilize text-patcher.js into /common/TextPatcher.js --- www/_socket/realtime-input.js | 2 +- www/{_socket/text-patcher.js => common/TextPatcher.js} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename www/{_socket/text-patcher.js => common/TextPatcher.js} (100%) diff --git a/www/_socket/realtime-input.js b/www/_socket/realtime-input.js index a8d02e0b5..d9f2970e8 100644 --- a/www/_socket/realtime-input.js +++ b/www/_socket/realtime-input.js @@ -19,7 +19,7 @@ define([ '/bower_components/reconnectingWebsocket/reconnecting-websocket.js', '/common/crypto.js', '/_socket/toolbar.js', - '/_socket/text-patcher.js', + '/common/TextPatcher.js', '/common/chainpad.js', '/bower_components/jquery/dist/jquery.min.js', ], function (Messages,/*FIXME*/ ReconnectingWebSocket, Crypto, Toolbar, TextPatcher) { diff --git a/www/_socket/text-patcher.js b/www/common/TextPatcher.js similarity index 100% rename from www/_socket/text-patcher.js rename to www/common/TextPatcher.js From 39071021ebfddd4d618b44891550c0847bcbff8e Mon Sep 17 00:00:00 2001 From: ansuz Date: Tue, 12 Apr 2016 12:53:23 +0200 Subject: [PATCH 3/6] stabilize typingTest.js as /common/TypingTests.js --- www/_socket/main.js | 2 +- www/{_socket/typingTest.js => common/TypingTests.js} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename www/{_socket/typingTest.js => common/TypingTests.js} (100%) diff --git a/www/_socket/main.js b/www/_socket/main.js index f334929e9..27c2ef058 100644 --- a/www/_socket/main.js +++ b/www/_socket/main.js @@ -8,7 +8,7 @@ define([ '/_socket/toolbar.js', '/common/cursor.js', '/common/json-ot.js', - '/_socket/typingTest.js', + '/common/TypingTests.js', '/bower_components/diff-dom/diffDOM.js', '/bower_components/jquery/dist/jquery.min.js', '/customize/pad.js' diff --git a/www/_socket/typingTest.js b/www/common/TypingTests.js similarity index 100% rename from www/_socket/typingTest.js rename to www/common/TypingTests.js From 1a22592afa6e819fac80dfececc845668d58fd75 Mon Sep 17 00:00:00 2001 From: ansuz Date: Tue, 12 Apr 2016 13:06:52 +0200 Subject: [PATCH 4/6] remove unused modules from realtime-input.js --- www/_socket/realtime-input.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/www/_socket/realtime-input.js b/www/_socket/realtime-input.js index d9f2970e8..edb8e3791 100644 --- a/www/_socket/realtime-input.js +++ b/www/_socket/realtime-input.js @@ -18,11 +18,10 @@ define([ '/common/messages.js', '/bower_components/reconnectingWebsocket/reconnecting-websocket.js', '/common/crypto.js', - '/_socket/toolbar.js', '/common/TextPatcher.js', '/common/chainpad.js', '/bower_components/jquery/dist/jquery.min.js', -], function (Messages,/*FIXME*/ ReconnectingWebSocket, Crypto, Toolbar, TextPatcher) { +], function (Messages, ReconnectingWebSocket, Crypto, TextPatcher) { var $ = window.jQuery; var ChainPad = window.ChainPad; var PARANOIA = true; From 6b9d982d40ceffd06cec4acfc0a0216b8a9bd8fb Mon Sep 17 00:00:00 2001 From: ansuz Date: Tue, 12 Apr 2016 13:10:57 +0200 Subject: [PATCH 5/6] stabilize _socket/realtime-input.js ...as common/RealtimeTextSocket.js --- www/_socket/main.js | 2 +- www/{_socket/realtime-input.js => common/RealtimeTextSocket.js} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename www/{_socket/realtime-input.js => common/RealtimeTextSocket.js} (100%) diff --git a/www/_socket/main.js b/www/_socket/main.js index 27c2ef058..bccdc99c6 100644 --- a/www/_socket/main.js +++ b/www/_socket/main.js @@ -2,7 +2,7 @@ define([ '/api/config?cb=' + Math.random().toString(16).substring(2), '/common/messages.js', '/common/crypto.js', - '/_socket/realtime-input.js', + '/common/RealtimeTextSocket.js', '/common/hyperjson.js', '/common/hyperscript.js', '/_socket/toolbar.js', diff --git a/www/_socket/realtime-input.js b/www/common/RealtimeTextSocket.js similarity index 100% rename from www/_socket/realtime-input.js rename to www/common/RealtimeTextSocket.js From a07774e81ac8a6e39cf9686fbd21cf64cff13934 Mon Sep 17 00:00:00 2001 From: ansuz Date: Tue, 12 Apr 2016 14:12:44 +0200 Subject: [PATCH 6/6] Implement tests for serialization ensure that complex DOM elements can serialize and deserialize without modifications RTWYSIWYG-54 > implement tests for components of the WYSIWYG editor --- www/assert/hyperjson.js | 124 ++++++++++++ www/assert/hyperscript.js | 400 ++++++++++++++++++++++++++++++++++++++ www/assert/index.html | 25 +++ www/assert/main.js | 50 +++++ 4 files changed, 599 insertions(+) create mode 100644 www/assert/hyperjson.js create mode 100644 www/assert/hyperscript.js create mode 100644 www/assert/index.html create mode 100644 www/assert/main.js 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

+ +

pewpewpew

+ +
+ +

Test 2

+

XWiki Macros

+ + +

Here is a macro

+ +
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'); +});