From f78dc52d7b3002b0ba14c7fbd87c88284f08e03e Mon Sep 17 00:00:00 2001 From: Kevin Jahns Date: Thu, 16 Jul 2015 06:15:23 +0200 Subject: [PATCH] added textbind example, improved & fixed syncing, RBTree handles ids correctly now, webrtc connector is quite reliable now --- .eslintrc | 4 +- Examples/TextBind/index.html | 20 ++ Examples/TextBind/index.js | 27 +++ gulpfile.js | 3 +- src/Connector.js | 5 +- src/Connectors/WebRTC.js | 10 +- src/OperationStore.js | 68 ++++-- src/OperationStores/IndexedDB.spec.js | 5 +- src/OperationStores/Memory.js | 14 +- src/OperationStores/RedBlackTree.js | 24 ++- src/Struct.js | 17 +- src/Types/Map.js | 45 ++++ src/Types/TextBind.js | 287 ++++++++++++++++++++++++++ src/y.js | 10 +- 14 files changed, 473 insertions(+), 66 deletions(-) create mode 100644 Examples/TextBind/index.html create mode 100644 Examples/TextBind/index.js diff --git a/.eslintrc b/.eslintrc index 35eecedd..0f0dfcec 100644 --- a/.eslintrc +++ b/.eslintrc @@ -34,6 +34,8 @@ "createUsers": true, "getRandomNumber": true, "applyRandomTransactions": true, - "CustomType": true + "CustomType": true, + "window": true, + "document": true } } diff --git a/Examples/TextBind/index.html b/Examples/TextBind/index.html new file mode 100644 index 00000000..24432ad1 --- /dev/null +++ b/Examples/TextBind/index.html @@ -0,0 +1,20 @@ + + + + + Y Example + + + + + +

yjs Tutorial

+

Collaborative Json editing with yjs +and XMPP Connector.

+ + + +

yjs is a Framework for Real-Time collaboration on arbitrary data types. +

+ + diff --git a/Examples/TextBind/index.js b/Examples/TextBind/index.js new file mode 100644 index 00000000..f755f008 --- /dev/null +++ b/Examples/TextBind/index.js @@ -0,0 +1,27 @@ + +Y({ + db: { + name: "Memory" + }, + connector: { + name: "WebRTC", + room: "mineeeeeee", + debug: true + } +}).then(function(yconfig){ + window.y = yconfig.root; + window.yconfig = yconfig; + var textarea = document.getElementById("textfield"); + yconfig.root.observe(function(events){ + for (var e in events) { + var event = events[e]; + if (event.name === "text" && (event.type === "add" || event.type === "update")) { + event.object.get(event.name).then(function(text){ //eslint-disable-line + text.bind(textarea); + window.ytext = text; + }); + } + } + }); + yconfig.root.set("text", Y.TextBind); +}); diff --git a/gulpfile.js b/gulpfile.js index 5bbafb84..bbf2445d 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -70,7 +70,8 @@ var options = minimist(process.argv.slice(2), { }); var files = { - y: polyfills.concat(["src/y.js", "src/Connector.js", "src/OperationStore.js", "src/Struct.js", "src/**/*.js", "!src/**/*.spec.js"]), + y: polyfills.concat(["src/y.js", "src/Connector.js", "src/OperationStore.js", "src/Struct.js", "src/Utils.js", + "src/OperationStores/RedBlackTree.js", "src/**/*.js", "!src/**/*.spec.js"]), lint: ["src/**/*.js", "gulpfile.js"], test: polyfills.concat([options.testfiles]), build_test: ["build_test/y.js"] diff --git a/src/Connector.js b/src/Connector.js index 77cad2d8..2a7489ee 100644 --- a/src/Connector.js +++ b/src/Connector.js @@ -25,6 +25,7 @@ class AbstractConnector { //eslint-disable-line no-unused-vars this.syncingClients = []; this.forwardToSyncingClients = (opts.forwardToSyncingClients === false) ? false : true; this.debug = opts.debug ? true : false; + this.broadcastedHB = false; } setUserId (userId) { this.userId = userId; @@ -117,7 +118,8 @@ class AbstractConnector { //eslint-disable-line no-unused-vars return; } if (this.debug) { - console.log(`${sender} -> ${this.userId}: ${JSON.stringify(m)}`); //eslint-disable-line + console.log(`${sender} -> me: ${m.type}`);//eslint-disable-line + console.dir(m); //eslint-disable-line } if (m.type === "sync step 1") { // TODO: make transaction, stream the ops @@ -148,6 +150,7 @@ class AbstractConnector { //eslint-disable-line no-unused-vars this.y.db.requestTransaction(function*(){ var ops = yield* this.getOperations(m.stateVector); if (ops.length > 0) { + conn.broadcastedHB = true; conn.broadcast({ type: "update", ops: ops diff --git a/src/Connectors/WebRTC.js b/src/Connectors/WebRTC.js index 575ee892..4a3550d9 100644 --- a/src/Connectors/WebRTC.js +++ b/src/Connectors/WebRTC.js @@ -12,12 +12,12 @@ class WebRTC extends AbstractConnector { var room = options.room; - // connect per default to our server - if(options.url == null){ - options.url = "https://yatta.ninja:8888"; - } + var webrtcOptions = { + url: options.url || "https://yatta.ninja:8888", + room: options.room + }; - var swr = new SimpleWebRTC(options); //eslint-disable-line no-undef + var swr = new SimpleWebRTC(webrtcOptions); //eslint-disable-line no-undef this.swr = swr; var self = this; diff --git a/src/OperationStore.js b/src/OperationStore.js index 60daa893..151fe9de 100644 --- a/src/OperationStore.js +++ b/src/OperationStore.js @@ -21,32 +21,19 @@ class AbstractTransaction { //eslint-disable-line no-unused-vars this.store.initializedTypes[sid] = t; return t; } - // returns false if operation is not expected. - *addOperation (op) { - var state = yield* this.getState(op.id[0]); - if (op.id[1] === state.clock){ - state.clock++; - yield* this.setState(state); - this.os.add(op); - yield* this.store.operationAdded(this, op); - return true; - } else if (op.id[1] < state.clock) { - return false; - } else { - throw new Error("Operations must arrive in order!"); - } - } *applyCreatedOperations (ops) { var send = []; for (var i = 0; i < ops.length; i++) { var op = ops[i]; - yield* Struct[op.struct].execute.call(this, op); + yield* this.store.tryExecute.call(this, op); send.push(copyObject(Struct[op.struct].encode(op))); } - this.store.y.connector.broadcast({ - type: "update", - ops: send - }); + if (this.store.y.connector.broadcastedHB){ + this.store.y.connector.broadcast({ + type: "update", + ops: send + }); + } } } @@ -79,10 +66,23 @@ class AbstractOperationStore { //eslint-disable-line no-unused-vars // TODO: Use ES7 Weak Maps. This way types that are no longer user, // wont be kept in memory. this.initializedTypes = {}; + this.whenUserIdSetListener = null; + this.waitingOperations = new RBTree(); } setUserId (userId) { this.userId = userId; this.opClock = 0; + if (this.whenUserIdSetListener != null) { + this.whenUserIdSetListener(); + this.whenUserIdSetListener = null; + } + } + whenUserIdSet (f) { + if (this.userId != null) { + f(); + } else { + this.whenUserIdSetListener = f; + } } getNextOpId () { if (this.userId == null) { @@ -140,7 +140,7 @@ class AbstractOperationStore { //eslint-disable-line no-unused-vars for (let key in exeNow) { let o = exeNow[key].op; - yield* Struct[o.struct].execute.call(this, o); + yield* store.tryExecute.call(this, o); } for (var sid in ls){ @@ -153,13 +153,37 @@ class AbstractOperationStore { //eslint-disable-line no-unused-vars let listener = l[key]; let o = listener.op; if (--listener.missing === 0){ - yield* Struct[o.struct].execute.call(this, o); + yield* store.tryExecute.call(this, o); } } } } }); } + *tryExecute (op) { + if (op.struct === "Delete") { + yield* Struct.Delete.execute.call(this, op); + } else { + while (op != null) { + var state = yield* this.getState(op.id[0]); + if (op.id[1] === state.clock){ + state.clock++; + yield* this.setState.call(this, state); + yield* Struct[op.struct].execute.call(this, op); + yield* this.addOperation(op); + yield* this.store.operationAdded(this, op); + // find next operation to execute + op = this.store.waitingOperations.find([op.id[0], state.clock]); + } else { + if (op.id[1] > state.clock) { + // has to be executed at some point later + this.store.waitingOperations.add(op); + } + op = null; + } + } + } + } // called by a transaction when an operation is added *operationAdded (transaction, op) { var sid = JSON.stringify(op.id); diff --git a/src/OperationStores/IndexedDB.spec.js b/src/OperationStores/IndexedDB.spec.js index 85ba0d46..7d542d5a 100644 --- a/src/OperationStores/IndexedDB.spec.js +++ b/src/OperationStores/IndexedDB.spec.js @@ -4,7 +4,10 @@ if(typeof window !== "undefined"){ jasmine.DEFAULT_TIMEOUT_INTERVAL = 1000; describe("IndexedDB", function() { - var ob = new Y.IndexedDB(null, {namespace: "Test"}); + var ob; + beforeAll(function(){ + ob = new Y.IndexedDB(null, {namespace: "Test"}); + }); it("can add and get operation", function(done) { ob.requestTransaction(function*(){ diff --git a/src/OperationStores/Memory.js b/src/OperationStores/Memory.js index ab48773f..61bfb521 100644 --- a/src/OperationStores/Memory.js +++ b/src/OperationStores/Memory.js @@ -33,6 +33,9 @@ Y.Memory = (function(){ //eslint-disable-line no-unused-vars n.val = op; return op; } + *addOperation (op) { + this.os.add(op); + } *getOperation (id) { if (id == null) { throw new Error("You must define id!"); @@ -97,21 +100,18 @@ Y.Memory = (function(){ //eslint-disable-line no-unused-vars } *makeOperationReady (ss, op) { // instead of ss, you could use currSS (a ss that increments when you add an operation) - if (op.right == null) { - return op; - } var clock; var o = op; while (o.right != null){ // while unknown, go to the right - o = yield* this.getOperation(o.right); - clock = ss[o.id[0]]; - if (clock != null && o.id[1] < clock) { + clock = ss[o.right[0]]; + if (clock != null && o.right[1] < clock) { break; } + o = yield* this.getOperation(o.right); } op = copyObject(op); - op.right = (o == null) ? null : o.id; + op.right = o.right; return op; } } diff --git a/src/OperationStores/RedBlackTree.js b/src/OperationStores/RedBlackTree.js index 593b6b28..c3f01292 100644 --- a/src/OperationStores/RedBlackTree.js +++ b/src/OperationStores/RedBlackTree.js @@ -1,4 +1,8 @@ +function smaller (a, b) { + return a[0] < b[0] || (a[0] === b[0] && a[1] < b[1]); +} + class N { // A created node is always red! constructor (val) { @@ -106,6 +110,7 @@ class N { class RBTree { //eslint-disable-line no-unused-vars constructor () { this.root = null; + this.length = 0; } findNodeWithLowerBound (from) { var o = this.root; @@ -113,11 +118,11 @@ class RBTree { //eslint-disable-line no-unused-vars return false; } else { while (true) { - if ((from === null || from < o.val.id) && o.left !== null) { + if ((from === null || smaller(from, o.val.id)) && o.left !== null) { // o is included in the bound // try to find an element that is closer to the bound o = o.left; - } else if (o.val.id < from) { + } else if (smaller(o.val.id, from)) { // o is not within the bound, maybe one of the right elements is.. if (o.right !== null) { o = o.right; @@ -134,7 +139,7 @@ class RBTree { //eslint-disable-line no-unused-vars } iterate (from, to, f) { var o = this.findNodeWithLowerBound(from); - while ( o !== null && (to === null || o.val.id <= to) ) { + while ( o !== null && (to === null || smaller(o.val.id, to) || compareIds(o.val.id, to)) ) { f(o.val); o = o.next(); } @@ -152,9 +157,9 @@ class RBTree { //eslint-disable-line no-unused-vars if (o === null) { return false; } - if (id < o.val.id) { + if (smaller(id, o.val.id)) { o = o.left; - } else if (o.val.id < id) { + } else if (smaller(o.val.id, id)) { o = o.right; } else { return o; @@ -164,6 +169,10 @@ class RBTree { //eslint-disable-line no-unused-vars } delete (id) { var d = this.findNode(id); + if (d == null) { + throw new Error("Element does not exist!"); + } + this.length--; if (d.left !== null && d.right !== null) { // switch d with the greates element in the left subtree. // o should have at most one child. @@ -302,14 +311,14 @@ class RBTree { //eslint-disable-line no-unused-vars if (this.root !== null) { var p = this.root; // p abbrev. parent while (true) { - if (node.val.id < p.val.id) { + if (smaller(node.val.id, p.val.id)) { if (p.left === null) { p.left = node; break; } else { p = p.left; } - } else if (p.val.id < node.val.id) { + } else if (smaller(p.val.id, node.val.id)) { if (p.right === null) { p.right = node; break; @@ -324,6 +333,7 @@ class RBTree { //eslint-disable-line no-unused-vars } else { this.root = node; } + this.length++; this.root.blacken(); } _fixInsert (n) { diff --git a/src/Struct.js b/src/Struct.js index fd0e0a37..04a5ca6b 100644 --- a/src/Struct.js +++ b/src/Struct.js @@ -184,22 +184,14 @@ var Struct = { var right = null; parent = parent || (yield* this.getOperation(op.parent)); - // NOTE: You you have to call addOperation before you set any other operation! - // reconnect left and set right of op if (op.left != null) { left = yield* this.getOperation(op.left); op.right = left.right; - if ((yield* this.addOperation(op)) === false) { // add here - return; - } left.right = op.id; yield* this.setOperation(left); } else { op.right = op.parentSub ? (parent.map[op.parentSub] || null) : parent.start; - if ((yield* this.addOperation(op)) === false) { // or here - return; - } } // reconnect right if (op.right != null) { @@ -260,9 +252,6 @@ var Struct = { execute: function* (op) { op.start = null; op.end = null; - if ((yield* this.addOperation(op)) === false) { - return; - } }, ref: function* (op : Op, pos : number) : Insert { if (op.start == null) { @@ -324,10 +313,7 @@ var Struct = { */ return []; }, - execute: function* (op) { - if ((yield* this.addOperation(op)) === false) { - return; - } + execute: function* () { }, get: function* (op, name) { var oid = op.map[name]; @@ -347,3 +333,4 @@ var Struct = { } } }; +Y.Struct = Struct; diff --git a/src/Types/Map.js b/src/Types/Map.js index b1337c5e..bfb28a85 100644 --- a/src/Types/Map.js +++ b/src/Types/Map.js @@ -149,6 +149,51 @@ observe (f) { this.eventHandler.addUserEventListener(f); } + unobserve (f) { + this.eventHandler.removeUserEventListener(f); + } + observePath (path, f) { + var self = this; + if (path.length === 0) { + this.observe(f); + return Promise.resolve(function(){ + self.unobserve(f); + }); + } else { + var deleteChildObservers; + var resetObserverPath = function(){ + var promise = self.get(path[0]); + if (!promise instanceof Promise) { + // its either not defined or a premitive value + promise = self.set(path[0], Y.Map); + } + return promise.then(function(map){ + return map.observePath(path.slice(1), f); + }).then(function(_deleteChildObservers){ + deleteChildObservers = _deleteChildObservers; + return Promise.resolve(); + }); + }; + var observer = function(events){ + for (var e in events) { + var event = events[e]; + if (event.name === path[0]) { + deleteChildObservers(); + if (event.type === "add" || event.type === "update") { + resetObserverPath(); + } + } + } + }; + self.observe(observer); + return resetObserverPath().then( + Promise.resolve(function(){ + deleteChildObservers(); + self.unobserve(observer); + }) + ); + } + } *_changed (transaction, op) { if (op.struct === "Delete") { op.key = (yield* transaction.getOperation(op.target)).parentSub; diff --git a/src/Types/TextBind.js b/src/Types/TextBind.js index e69de29b..884d904e 100644 --- a/src/Types/TextBind.js +++ b/src/Types/TextBind.js @@ -0,0 +1,287 @@ + +(function(){ + class YTextBind extends Y.Array.class { + constructor (os, _model, idArray, valArray) { + super(os, _model, idArray, valArray); + this.textfields = []; + } + toString () { + return this.valArray.join(""); + } + insert (pos, content) { + super(pos, content.split("")); + } + bind (textfield, domRoot) { + domRoot = domRoot || window; //eslint-disable-line + if (domRoot.getSelection == null) { + domRoot = window;//eslint-disable-line + } + + // don't duplicate! + for (var t in this.textfields) { + if (this.textfields[t] === textfield) { + return; + } + } + var creatorToken = false; + + var word = this; + textfield.value = this.toString(); + this.textfields.push(textfield); + var createRange, writeRange, writeContent; + if(textfield.selectionStart != null && textfield.setSelectionRange != null) { + createRange = function (fix) { + var left = textfield.selectionStart; + var right = textfield.selectionEnd; + if (fix != null) { + left = fix(left); + right = fix(right); + } + return { + left: left, + right: right + }; + }; + writeRange = function (range) { + writeContent(word.toString()); + textfield.setSelectionRange(range.left, range.right); + }; + writeContent = function (content){ + textfield.value = content; + }; + } else { + createRange = function (fix) { + var range = {}; + var s = domRoot.getSelection(); + var clength = textfield.textContent.length; + range.left = Math.min(s.anchorOffset, clength); + range.right = Math.min(s.focusOffset, clength); + if(fix != null){ + range.left = fix(range.left); + range.right = fix(range.right); + } + var editedElement = s.focusNode; + if(editedElement === textfield || editedElement === textfield.childNodes[0]){ + range.isReal = true; + } else { + range.isReal = false; + } + return range; + }; + + writeRange = function (range) { + writeContent(word.val()); + var textnode = textfield.childNodes[0]; + if(range.isReal && textnode != null) { + if(range.left < 0){ + range.left = 0; + } + range.right = Math.max(range.left, range.right); + if (range.right > textnode.length) { + range.right = textnode.length; + } + range.left = Math.min(range.left, range.right); + var r = document.createRange(); //eslint-disable-line + r.setStart(textnode, range.left); + r.setEnd(textnode, range.right); + var s = window.getSelection(); //eslint-disable-line + s.removeAllRanges(); + s.addRange(r); + } + }; + writeContent = function (content) { + var contentArray = content.replace(new RegExp("\n", 'g')," ").split(" ");//eslint-disable-line + textfield.innerText = ""; + for(var i in contentArray){ + var c = contentArray[i]; + textfield.innerText += c; + if(i !== contentArray.length - 1){ + textfield.innerHTML += " "; + } + } + }; + } + writeContent(this.toString()); + + this.observe(function (events) { + for(var e in events) { + var event = events[e]; + if (!creatorToken) { + var oPos, fix; + if( event.type === "insert") { + oPos = event.index; + fix = function (cursor) {//eslint-disable-line + if (cursor <= oPos) { + return cursor; + } else { + cursor += 1; + return cursor; + } + }; + var r = createRange(fix); + writeRange(r); + } else if (event.type === "delete") { + oPos = event.index; + fix = function (cursor){//eslint-disable-line + if (cursor < oPos) { + return cursor; + } else { + cursor -= 1; + return cursor; + } + }; + r = createRange(fix); + writeRange(r); + } + } + } + }); + // consume all text-insert changes. + textfield.onkeypress = function (event) { + if (word.is_deleted) { + // if word is deleted, do not do anything ever again + textfield.onkeypress = null; + return true; + } + creatorToken = true; + var char; + if (event.keyCode === 13) { + char = "\n"; + } else if (event.key != null) { + if (event.charCode === 32) { + char = " "; + } else { + char = event.key; + } + } else { + char = window.String.fromCharCode(event.keyCode); //eslint-disable-line + } + if (char.length > 1) { + return true; + } else if (char.length > 0) { + var r = createRange(); + var pos = Math.min(r.left, r.right); + var diff = Math.abs(r.right - r.left); + word.delete(pos, diff); + word.insert(pos, char); + r.left = pos + char.length; + r.right = r.left; + writeRange(r); + } + event.preventDefault(); + creatorToken = false; + return false; + }; + textfield.onpaste = function (event) { + if (word.is_deleted) { + // if word is deleted, do not do anything ever again + textfield.onpaste = null; + return true; + } + event.preventDefault(); + }; + textfield.oncut = function (event) { + if (word.is_deleted) { + // if word is deleted, do not do anything ever again + textfield.oncut = null; + return true; + } + event.preventDefault(); + }; + // + // consume deletes. Note that + // chrome: won't consume deletions on keypress event. + // keyCode is deprecated. BUT: I don't see another way. + // since event.key is not implemented in the current version of chrome. + // Every browser supports keyCode. Let's stick with it for now.. + // + textfield.onkeydown = function (event) { + creatorToken = true; + if (word.is_deleted) { + // if word is deleted, do not do anything ever again + textfield.onkeydown = null; + return true; + } + var r = createRange(); + var pos = Math.min(r.left, r.right, word.toString().length); + var diff = Math.abs(r.left - r.right); + if (event.keyCode != null && event.keyCode === 8) { // Backspace + if (diff > 0) { + word.delete(pos, diff); + r.left = pos; + r.right = pos; + writeRange(r); + } else { + if (event.ctrlKey != null && event.ctrlKey) { + var val = word.toString(); + var newPos = pos; + var delLength = 0; + if (pos > 0) { + newPos--; + delLength++; + } + while (newPos > 0 && val[newPos] !== " " && val[newPos] !== "\n") { + newPos--; + delLength++; + } + word.delete(newPos, pos - newPos); + r.left = newPos; + r.right = newPos; + writeRange(r); + } else { + if (pos > 0) { + word.delete(pos - 1, 1); + r.left = pos - 1; + r.right = pos - 1; + writeRange(r); + } + } + } + event.preventDefault(); + creatorToken = false; + return false; + } else if (event.keyCode != null && event.keyCode === 46) { // Delete + if (diff > 0) { + word.delete(pos, diff); + r.left = pos; + r.right = pos; + writeRange(r); + } else { + word.delete(pos, 1); + r.left = pos; + r.right = pos; + writeRange(r); + } + event.preventDefault(); + creatorToken = false; + return false; + } else { + creatorToken = false; + return true; + } + }; + } + } + Y.TextBind = new CustomType({ + class: YTextBind, + createType: function* YTextBindCreator () { + var model = { + start: null, + end: null, + struct: "List", + type: "TextBind", + id: this.store.getNextOpId() + }; + yield* this.applyCreatedOperations([model]); + return yield* this.createType(model); + }, + initType: function* YTextBindInitializer(os, model){ + var valArray = []; + var idArray = yield* Y.Struct.List.map.call(this, model, function(c){ + valArray.push(c.content); + return JSON.stringify(c.id); + }); + return new YTextBind(os, model.id, idArray, valArray); + } + }); +})(); diff --git a/src/y.js b/src/y.js index 00f5ac1e..fd311f58 100644 --- a/src/y.js +++ b/src/y.js @@ -2,8 +2,10 @@ function Y (opts) { var def = Promise.defer(); - new YConfig(opts, function(config){ //eslint-disable-line - def.resolve(config); + new YConfig(opts, function(yconfig){ //eslint-disable-line + yconfig.db.whenUserIdSet(function(){ + def.resolve(yconfig); + }); }); return def.promise; } @@ -37,7 +39,3 @@ class YConfig { //eslint-disable-line no-unused-vars }; } } - -Y.AbstractTransaction = AbstractTransaction; -Y.AbstractOperationStore = AbstractOperationStore; -Y.Struct = Struct;