From 556338a9c6964d110c1351a402b425c71c2571fa Mon Sep 17 00:00:00 2001 From: Wu Cheng-Han Date: Sat, 11 Jul 2015 12:43:08 +0800 Subject: Added support of operational transformation --- lib/ot/client.js | 312 +++++++++++++++++++ lib/ot/editor-socketio-server.js | 146 +++++++++ lib/ot/index.js | 8 + lib/ot/selection.js | 117 ++++++++ lib/ot/server.js | 46 +++ lib/ot/simple-text-operation.js | 188 ++++++++++++ lib/ot/text-operation.js | 530 +++++++++++++++++++++++++++++++++ lib/ot/wrapped-operation.js | 80 +++++ lib/realtime.js | 201 +++++-------- public/js/index.js | 202 +++++-------- public/vendor/ot/ajax-adapter.js | 116 ++++++++ public/vendor/ot/client.js | 312 +++++++++++++++++++ public/vendor/ot/codemirror-adapter.js | 393 ++++++++++++++++++++++++ public/vendor/ot/compress.sh | 10 + public/vendor/ot/editor-client.js | 354 ++++++++++++++++++++++ public/vendor/ot/ot.min.js | 1 + public/vendor/ot/selection.js | 117 ++++++++ public/vendor/ot/socketio-adapter.js | 66 ++++ public/vendor/ot/text-operation.js | 530 +++++++++++++++++++++++++++++++++ public/vendor/ot/undo-manager.js | 111 +++++++ public/vendor/ot/wrapped-operation.js | 80 +++++ public/views/body.ejs | 2 +- public/views/foot.ejs | 2 + 23 files changed, 3668 insertions(+), 256 deletions(-) create mode 100755 lib/ot/client.js create mode 100755 lib/ot/editor-socketio-server.js create mode 100644 lib/ot/index.js create mode 100644 lib/ot/selection.js create mode 100644 lib/ot/server.js create mode 100644 lib/ot/simple-text-operation.js create mode 100644 lib/ot/text-operation.js create mode 100644 lib/ot/wrapped-operation.js create mode 100755 public/vendor/ot/ajax-adapter.js create mode 100755 public/vendor/ot/client.js create mode 100755 public/vendor/ot/codemirror-adapter.js create mode 100644 public/vendor/ot/compress.sh create mode 100755 public/vendor/ot/editor-client.js create mode 100644 public/vendor/ot/ot.min.js create mode 100755 public/vendor/ot/selection.js create mode 100755 public/vendor/ot/socketio-adapter.js create mode 100755 public/vendor/ot/text-operation.js create mode 100755 public/vendor/ot/undo-manager.js create mode 100755 public/vendor/ot/wrapped-operation.js diff --git a/lib/ot/client.js b/lib/ot/client.js new file mode 100755 index 00000000..7ee19fdc --- /dev/null +++ b/lib/ot/client.js @@ -0,0 +1,312 @@ +// translation of https://github.com/djspiewak/cccp/blob/master/agent/src/main/scala/com/codecommit/cccp/agent/state.scala + +if (typeof ot === 'undefined') { + var ot = {}; +} + +ot.Client = (function (global) { + 'use strict'; + + // Client constructor + function Client (revision) { + this.revision = revision; // the next expected revision number + this.setState(synchronized_); // start state + } + + Client.prototype.setState = function (state) { + this.state = state; + }; + + // Call this method when the user changes the document. + Client.prototype.applyClient = function (operation) { + this.setState(this.state.applyClient(this, operation)); + }; + + // Call this method with a new operation from the server + Client.prototype.applyServer = function (revision, operation) { + this.setState(this.state.applyServer(this, revision, operation)); + }; + + Client.prototype.applyOperations = function (head, operations) { + this.setState(this.state.applyOperations(this, head, operations)); + }; + + Client.prototype.serverAck = function (revision) { + this.setState(this.state.serverAck(this, revision)); + }; + + Client.prototype.serverReconnect = function () { + if (typeof this.state.resend === 'function') { this.state.resend(this); } + }; + + // Transforms a selection from the latest known server state to the current + // client state. For example, if we get from the server the information that + // another user's cursor is at position 3, but the server hasn't yet received + // our newest operation, an insertion of 5 characters at the beginning of the + // document, the correct position of the other user's cursor in our current + // document is 8. + Client.prototype.transformSelection = function (selection) { + return this.state.transformSelection(selection); + }; + + // Override this method. + Client.prototype.sendOperation = function (revision, operation) { + throw new Error("sendOperation must be defined in child class"); + }; + + // Override this method. + Client.prototype.applyOperation = function (operation) { + throw new Error("applyOperation must be defined in child class"); + }; + + + // In the 'Synchronized' state, there is no pending operation that the client + // has sent to the server. + function Synchronized () {} + Client.Synchronized = Synchronized; + + Synchronized.prototype.applyClient = function (client, operation) { + // When the user makes an edit, send the operation to the server and + // switch to the 'AwaitingConfirm' state + client.sendOperation(client.revision, operation); + return new AwaitingConfirm(operation); + }; + + Synchronized.prototype.applyServer = function (client, revision, operation) { + if (revision - client.revision > 1) { + throw new Error("Invalid revision."); + } + client.revision = revision; + // When we receive a new operation from the server, the operation can be + // simply applied to the current document + client.applyOperation(operation); + return this; + }; + + Synchronized.prototype.serverAck = function (client, revision) { + throw new Error("There is no pending operation."); + }; + + // Nothing to do because the latest server state and client state are the same. + Synchronized.prototype.transformSelection = function (x) { return x; }; + + // Singleton + var synchronized_ = new Synchronized(); + + + // In the 'AwaitingConfirm' state, there's one operation the client has sent + // to the server and is still waiting for an acknowledgement. + function AwaitingConfirm (outstanding) { + // Save the pending operation + this.outstanding = outstanding; + } + Client.AwaitingConfirm = AwaitingConfirm; + + AwaitingConfirm.prototype.applyClient = function (client, operation) { + // When the user makes an edit, don't send the operation immediately, + // instead switch to 'AwaitingWithBuffer' state + return new AwaitingWithBuffer(this.outstanding, operation); + }; + + AwaitingConfirm.prototype.applyServer = function (client, revision, operation) { + if (revision - client.revision > 1) { + throw new Error("Invalid revision."); + } + client.revision = revision; + // This is another client's operation. Visualization: + // + // /\ + // this.outstanding / \ operation + // / \ + // \ / + // pair[1] \ / pair[0] (new outstanding) + // (can be applied \/ + // to the client's + // current document) + var pair = operation.constructor.transform(this.outstanding, operation); + client.applyOperation(pair[1]); + return new AwaitingConfirm(pair[0]); + }; + + AwaitingConfirm.prototype.serverAck = function (client, revision) { + if (revision - client.revision > 1) { + return new Stale(this.outstanding, client, revision).getOperations(); + } + client.revision = revision; + // The client's operation has been acknowledged + // => switch to synchronized state + return synchronized_; + }; + + AwaitingConfirm.prototype.transformSelection = function (selection) { + return selection.transform(this.outstanding); + }; + + AwaitingConfirm.prototype.resend = function (client) { + // The confirm didn't come because the client was disconnected. + // Now that it has reconnected, we resend the outstanding operation. + client.sendOperation(client.revision, this.outstanding); + }; + + + // In the 'AwaitingWithBuffer' state, the client is waiting for an operation + // to be acknowledged by the server while buffering the edits the user makes + function AwaitingWithBuffer (outstanding, buffer) { + // Save the pending operation and the user's edits since then + this.outstanding = outstanding; + this.buffer = buffer; + } + Client.AwaitingWithBuffer = AwaitingWithBuffer; + + AwaitingWithBuffer.prototype.applyClient = function (client, operation) { + // Compose the user's changes onto the buffer + var newBuffer = this.buffer.compose(operation); + return new AwaitingWithBuffer(this.outstanding, newBuffer); + }; + + AwaitingWithBuffer.prototype.applyServer = function (client, revision, operation) { + if (revision - client.revision > 1) { + throw new Error("Invalid revision."); + } + client.revision = revision; + // Operation comes from another client + // + // /\ + // this.outstanding / \ operation + // / \ + // /\ / + // this.buffer / \* / pair1[0] (new outstanding) + // / \/ + // \ / + // pair2[1] \ / pair2[0] (new buffer) + // the transformed \/ + // operation -- can + // be applied to the + // client's current + // document + // + // * pair1[1] + var transform = operation.constructor.transform; + var pair1 = transform(this.outstanding, operation); + var pair2 = transform(this.buffer, pair1[1]); + client.applyOperation(pair2[1]); + return new AwaitingWithBuffer(pair1[0], pair2[0]); + }; + + AwaitingWithBuffer.prototype.serverAck = function (client, revision) { + if (revision - client.revision > 1) { + return new StaleWithBuffer(this.outstanding, this.buffer, client, revision).getOperations(); + } + client.revision = revision; + // The pending operation has been acknowledged + // => send buffer + client.sendOperation(client.revision, this.buffer); + return new AwaitingConfirm(this.buffer); + }; + + AwaitingWithBuffer.prototype.transformSelection = function (selection) { + return selection.transform(this.outstanding).transform(this.buffer); + }; + + AwaitingWithBuffer.prototype.resend = function (client) { + // The confirm didn't come because the client was disconnected. + // Now that it has reconnected, we resend the outstanding operation. + client.sendOperation(client.revision, this.outstanding); + }; + + + function Stale(acknowlaged, client, revision) { + this.acknowlaged = acknowlaged; + this.client = client; + this.revision = revision; + } + Client.Stale = Stale; + + Stale.prototype.applyClient = function (client, operation) { + return new StaleWithBuffer(this.acknowlaged, operation, client, this.revision); + }; + + Stale.prototype.applyServer = function (client, revision, operation) { + throw new Error("Ignored server-side change."); + }; + + Stale.prototype.applyOperations = function (client, head, operations) { + var transform = this.acknowlaged.constructor.transform; + for (var i = 0; i < operations.length; i++) { + var op = ot.TextOperation.fromJSON(operations[i]); + var pair = transform(this.acknowlaged, op); + client.applyOperation(pair[1]); + this.acknowlaged = pair[0]; + } + client.revision = this.revision; + return synchronized_; + }; + + Stale.prototype.serverAck = function (client, revision) { + throw new Error("There is no pending operation."); + }; + + Stale.prototype.transformSelection = function (selection) { + return selection; + }; + + Stale.prototype.getOperations = function () { + this.client.getOperations(this.client.revision, this.revision - 1); // acknowlaged is the one at revision + return this; + }; + + + function StaleWithBuffer(acknowlaged, buffer, client, revision) { + this.acknowlaged = acknowlaged; + this.buffer = buffer; + this.client = client; + this.revision = revision; + } + Client.StaleWithBuffer = StaleWithBuffer; + + StaleWithBuffer.prototype.applyClient = function (client, operation) { + var buffer = this.buffer.compose(operation); + return new StaleWithBuffer(this.acknowlaged, buffer, client, this.revision); + }; + + StaleWithBuffer.prototype.applyServer = function (client, revision, operation) { + throw new Error("Ignored server-side change."); + }; + + StaleWithBuffer.prototype.applyOperations = function (client, head, operations) { + var transform = this.acknowlaged.constructor.transform; + for (var i = 0; i < operations.length; i++) { + var op = ot.TextOperation.fromJSON(operations[i]); + var pair1 = transform(this.acknowlaged, op); + var pair2 = transform(this.buffer, pair1[1]); + client.applyOperation(pair2[1]); + this.acknowlaged = pair1[0]; + this.buffer = pair2[0]; + } + client.revision = this.revision; + client.sendOperation(client.revision, this.buffer); + return new AwaitingConfirm(this.buffer); + }; + + StaleWithBuffer.prototype.serverAck = function (client, revision) { + throw new Error("There is no pending operation."); + }; + + StaleWithBuffer.prototype.transformSelection = function (selection) { + return selection; + }; + + StaleWithBuffer.prototype.getOperations = function () { + this.client.getOperations(this.client.revision, this.revision - 1); // acknowlaged is the one at revision + return this; + }; + + + return Client; + +}(this)); + +if (typeof module === 'object') { + module.exports = ot.Client; +} + diff --git a/lib/ot/editor-socketio-server.js b/lib/ot/editor-socketio-server.js new file mode 100755 index 00000000..aae156fc --- /dev/null +++ b/lib/ot/editor-socketio-server.js @@ -0,0 +1,146 @@ +'use strict'; + +var EventEmitter = require('events').EventEmitter; +var TextOperation = require('./text-operation'); +var WrappedOperation = require('./wrapped-operation'); +var Server = require('./server'); +var Selection = require('./selection'); +var util = require('util'); + +var LZString = require('lz-string'); + +function EditorSocketIOServer(document, operations, docId, mayWrite) { + EventEmitter.call(this); + Server.call(this, document, operations); + this.users = {}; + this.docId = docId; + this.mayWrite = mayWrite || function (_, cb) { + cb(true); + }; +} + +util.inherits(EditorSocketIOServer, Server); +extend(EditorSocketIOServer.prototype, EventEmitter.prototype); + +function extend(target, source) { + for (var key in source) { + if (source.hasOwnProperty(key)) { + target[key] = source[key]; + } + } +} + +EditorSocketIOServer.prototype.addClient = function (socket) { + var self = this; + socket.join(this.docId); + var docOut = { + str: this.document, + revision: this.operations.length, + clients: this.users + }; + socket.emit('doc', LZString.compressToUTF16(JSON.stringify(docOut))); + socket.on('operation', function (revision, operation, selection) { + operation = LZString.decompressFromUTF16(operation); + operation = JSON.parse(operation); + self.mayWrite(socket, function (mayWrite) { + if (!mayWrite) { + console.log("User doesn't have the right to edit."); + return; + } + self.onOperation(socket, revision, operation, selection); + }); + }); + socket.on('get_operations', function (base, head) { + self.onGetOperations(socket, base, head); + }); + socket.on('selection', function (obj) { + self.mayWrite(socket, function (mayWrite) { + if (!mayWrite) { + console.log("User doesn't have the right to edit."); + return; + } + self.updateSelection(socket, obj && Selection.fromJSON(obj)); + }); + }); + socket.on('disconnect', function () { + //console.log("Disconnect"); + socket.leave(self.docId); + self.onDisconnect(socket); + /* + if (socket.manager && socket.manager.sockets.clients(self.docId).length === 0) { + self.emit('empty-room'); + } + */ + }); +}; + +EditorSocketIOServer.prototype.onOperation = function (socket, revision, operation, selection) { + var wrapped; + try { + wrapped = new WrappedOperation( + TextOperation.fromJSON(operation), + selection && Selection.fromJSON(selection) + ); + } catch (exc) { + console.error("Invalid operation received: " + exc); + return; + } + + try { + var clientId = socket.id; + var wrappedPrime = this.receiveOperation(revision, wrapped); + //console.log("new operation: " + JSON.stringify(wrapped)); + this.getClient(clientId).selection = wrappedPrime.meta; + revision = this.operations.length; + socket.emit('ack', revision); + socket.broadcast.in(this.docId).emit( + 'operation', clientId, revision, + wrappedPrime.wrapped.toJSON(), wrappedPrime.meta + ); + this.isDirty = true; + } catch (exc) { + console.error(exc); + } +}; + +EditorSocketIOServer.prototype.onGetOperations = function (socket, base, head) { + var operations = this.operations.slice(base, head).map(function (op) { + return op.wrapped.toJSON(); + }); + operations = LZString.compressToUTF16(JSON.stringify(operations)); + socket.emit('operations', head, operations); +}; + +EditorSocketIOServer.prototype.updateSelection = function (socket, selection) { + var clientId = socket.id; + if (selection) { + this.getClient(clientId).selection = selection; + } else { + delete this.getClient(clientId).selection; + } + socket.broadcast.to(this.docId).emit('selection', clientId, selection); +}; + +EditorSocketIOServer.prototype.setName = function (socket, name) { + var clientId = socket.id; + this.getClient(clientId).name = name; + socket.broadcast.to(this.docId).emit('set_name', clientId, name); +}; + +EditorSocketIOServer.prototype.setColor = function (socket, color) { + var clientId = socket.id; + this.getClient(clientId).color = color; + socket.broadcast.to(this.docId).emit('set_color', clientId, color); +}; + +EditorSocketIOServer.prototype.getClient = function (clientId) { + return this.users[clientId] || (this.users[clientId] = {}); +}; + +EditorSocketIOServer.prototype.onDisconnect = function (socket) { + var clientId = socket.id; + delete this.users[clientId]; + socket.broadcast.to(this.docId).emit('client_left', clientId); +}; + +module.exports = EditorSocketIOServer; \ No newline at end of file diff --git a/lib/ot/index.js b/lib/ot/index.js new file mode 100644 index 00000000..fcc94c11 --- /dev/null +++ b/lib/ot/index.js @@ -0,0 +1,8 @@ +exports.version = '0.0.15'; + +exports.TextOperation = require('./text-operation'); +exports.SimpleTextOperation = require('./simple-text-operation'); +exports.Client = require('./client'); +exports.Server = require('./server'); +exports.Selection = require('./selection'); +exports.EditorSocketIOServer = require('./editor-socketio-server'); diff --git a/lib/ot/selection.js b/lib/ot/selection.js new file mode 100644 index 00000000..72bf8bd6 --- /dev/null +++ b/lib/ot/selection.js @@ -0,0 +1,117 @@ +if (typeof ot === 'undefined') { + // Export for browsers + var ot = {}; +} + +ot.Selection = (function (global) { + 'use strict'; + + var TextOperation = global.ot ? global.ot.TextOperation : require('./text-operation'); + + // Range has `anchor` and `head` properties, which are zero-based indices into + // the document. The `anchor` is the side of the selection that stays fixed, + // `head` is the side of the selection where the cursor is. When both are + // equal, the range represents a cursor. + function Range (anchor, head) { + this.anchor = anchor; + this.head = head; + } + + Range.fromJSON = function (obj) { + return new Range(obj.anchor, obj.head); + }; + + Range.prototype.equals = function (other) { + return this.anchor === other.anchor && this.head === other.head; + }; + + Range.prototype.isEmpty = function () { + return this.anchor === this.head; + }; + + Range.prototype.transform = function (other) { + function transformIndex (index) { + var newIndex = index; + var ops = other.ops; + for (var i = 0, l = other.ops.length; i < l; i++) { + if (TextOperation.isRetain(ops[i])) { + index -= ops[i]; + } else if (TextOperation.isInsert(ops[i])) { + newIndex += ops[i].length; + } else { + newIndex -= Math.min(index, -ops[i]); + index += ops[i]; + } + if (index < 0) { break; } + } + return newIndex; + } + + var newAnchor = transformIndex(this.anchor); + if (this.anchor === this.head) { + return new Range(newAnchor, newAnchor); + } + return new Range(newAnchor, transformIndex(this.head)); + }; + + // A selection is basically an array of ranges. Every range represents a real + // selection or a cursor in the document (when the start position equals the + // end position of the range). The array must not be empty. + function Selection (ranges) { + this.ranges = ranges || []; + } + + Selection.Range = Range; + + // Convenience method for creating selections only containing a single cursor + // and no real selection range. + Selection.createCursor = function (position) { + return new Selection([new Range(position, position)]); + }; + + Selection.fromJSON = function (obj) { + var objRanges = obj.ranges || obj; + for (var i = 0, ranges = []; i < objRanges.length; i++) { + ranges[i] = Range.fromJSON(objRanges[i]); + } + return new Selection(ranges); + }; + + Selection.prototype.equals = function (other) { + if (this.position !== other.position) { return false; } + if (this.ranges.length !== other.ranges.length) { return false; } + // FIXME: Sort ranges before comparing them? + for (var i = 0; i < this.ranges.length; i++) { + if (!this.ranges[i].equals(other.ranges[i])) { return false; } + } + return true; + }; + + Selection.prototype.somethingSelected = function () { + for (var i = 0; i < this.ranges.length; i++) { + if (!this.ranges[i].isEmpty()) { return true; } + } + return false; + }; + + // Return the more current selection information. + Selection.prototype.compose = function (other) { + return other; + }; + + // Update the selection with respect to an operation. + Selection.prototype.transform = function (other) { + for (var i = 0, newRanges = []; i < this.ranges.length; i++) { + newRanges[i] = this.ranges[i].transform(other); + } + return new Selection(newRanges); + }; + + return Selection; + +}(this)); + +// Export for CommonJS +if (typeof module === 'object') { + module.exports = ot.Selection; +} diff --git a/lib/ot/server.js b/lib/ot/server.js new file mode 100644 index 00000000..608d434b --- /dev/null +++ b/lib/ot/server.js @@ -0,0 +1,46 @@ +if (typeof ot === 'undefined') { + var ot = {}; +} + +ot.Server = (function (global) { + 'use strict'; + + // Constructor. Takes the current document as a string and optionally the array + // of all operations. + function Server (document, operations) { + this.document = document; + this.operations = operations || []; + } + + // Call this method whenever you receive an operation from a client. + Server.prototype.receiveOperation = function (revision, operation) { + if (revision < 0 || this.operations.length < revision) { + throw new Error("operation revision not in history"); + } + // Find all operations that the client didn't know of when it sent the + // operation ... + var concurrentOperations = this.operations.slice(revision); + + // ... and transform the operation against all these operations ... + var transform = operation.constructor.transform; + for (var i = 0; i < concurrentOperations.length; i++) { + operation = transform(operation, concurrentOperations[i])[0]; + } + + // ... and apply that on the document. + this.document = operation.apply(this.document); + // Store operation in history. + this.operations.push(operation); + + // It's the caller's responsibility to send the operation to all connected + // clients and an acknowledgement to the creator. + return operation; + }; + + return Server; + +}(this)); + +if (typeof module === 'object') { + module.exports = ot.Server; +} \ No newline at end of file diff --git a/lib/ot/simple-text-operation.js b/lib/ot/simple-text-operation.js new file mode 100644 index 00000000..6db296e0 --- /dev/null +++ b/lib/ot/simple-text-operation.js @@ -0,0 +1,188 @@ +if (typeof ot === 'undefined') { + // Export for browsers + var ot = {}; +} + +ot.SimpleTextOperation = (function (global) { + + var TextOperation = global.ot ? global.ot.TextOperation : require('./text-operation'); + + function SimpleTextOperation () {} + + + // Insert the string `str` at the zero-based `position` in the document. + function Insert (str, position) { + if (!this || this.constructor !== SimpleTextOperation) { + // => function was called without 'new' + return new Insert(str, position); + } + this.str = str; + this.position = position; + } + + Insert.prototype = new SimpleTextOperation(); + SimpleTextOperation.Insert = Insert; + + Insert.prototype.toString = function () { + return 'Insert(' + JSON.stringify(this.str) + ', ' + this.position + ')'; + }; + + Insert.prototype.equals = function (other) { + return other instanceof Insert && + this.str === other.str && + this.position === other.position; + }; + + Insert.prototype.apply = function (doc) { + return doc.slice(0, this.position) + this.str + doc.slice(this.position); + }; + + + // Delete `count` many characters at the zero-based `position` in the document. + function Delete (count, position) { + if (!this || this.constructor !== SimpleTextOperation) { + return new Delete(count, position); + } + this.count = count; + this.position = position; + } + + Delete.prototype = new SimpleTextOperation(); + SimpleTextOperation.Delete = Delete; + + Delete.prototype.toString = function () { + return 'Delete(' + this.count + ', ' + this.position + ')'; + }; + + Delete.prototype.equals = function (other) { + return other instanceof Delete && + this.count === other.count && + this.position === other.position; + }; + + Delete.prototype.apply = function (doc) { + return doc.slice(0, this.position) + doc.slice(this.position + this.count); + }; + + + // An operation that does nothing. This is needed for the result of the + // transformation of two deletions of the same character. + function Noop () { + if (!this || this.constructor !== SimpleTextOperation) { return new Noop(); } + } + + Noop.prototype = new SimpleTextOperation(); + SimpleTextOperation.Noop = Noop; + + Noop.prototype.toString = function () { + return 'Noop()'; + }; + + Noop.prototype.equals = function (other) { return other instanceof Noop; }; + + Noop.prototype.apply = function (doc) { return doc; }; + + var noop = new Noop(); + + + SimpleTextOperation.transform = function (a, b) { + if (a instanceof Noop || b instanceof Noop) { return [a, b]; } + + if (a instanceof Insert && b instanceof Insert) { + if (a.position < b.position || (a.position === b.position && a.str < b.str)) { + return [a, new Insert(b.str, b.position + a.str.length)]; + } + if (a.position > b.position || (a.position === b.position && a.str > b.str)) { + return [new Insert(a.str, a.position + b.str.length), b]; + } + return [noop, noop]; + } + + if (a instanceof Insert && b instanceof Delete) { + if (a.position <= b.position) { + return [a, new Delete(b.count, b.position + a.str.length)]; + } + if (a.position >= b.position + b.count) { + return [new Insert(a.str, a.position - b.count), b]; + } + // Here, we have to delete the inserted string of operation a. + // That doesn't preserve the intention of operation a, but it's the only + // thing we can do to get a valid transform function. + return [noop, new Delete(b.count + a.str.length, b.position)]; + } + + if (a instanceof Delete && b instanceof Insert) { + if (a.position >= b.position) { + return [new Delete(a.count, a.position + b.str.length), b]; + } + if (a.position + a.count <= b.position) { + return [a, new Insert(b.str, b.position - a.count)]; + } + // Same problem as above. We have to delete the string that was inserted + // in operation b. + return [new Delete(a.count + b.str.length, a.position), noop]; + } + + if (a instanceof Delete && b instanceof Delete) { + if (a.position === b.position) { + if (a.count === b.count) { + return [noop, noop]; + } else if (a.count < b.count) { + return [noop, new Delete(b.count - a.count, b.position)]; + } + return [new Delete(a.count - b.count, a.position), noop]; + } + if (a.position < b.position) { + if (a.position + a.count <= b.position) { + return [a, new Delete(b.count, b.position - a.count)]; + } + if (a.position + a.count >= b.position + b.count) { + return [new Delete(a.count - b.count, a.position), noop]; + } + return [ + new Delete(b.position - a.position, a.position), + new Delete(b.position + b.count - (a.position + a.count), a.position) + ]; + } + if (a.position > b.position) { + if (a.position >= b.position + b.count) { + return [new Delete(a.count, a.position - b.count), b]; + } + if (a.position + a.count <= b.position + b.count) { + return [noop, new Delete(b.count - a.count, b.position)]; + } + return [ + new Delete(a.position + a.count - (b.position + b.count), b.position), + new Delete(a.position - b.position, b.position) + ]; + } + } + }; + + // Convert a normal, composable `TextOperation` into an array of + // `SimpleTextOperation`s. + SimpleTextOperation.fromTextOperation = function (operation) { + var simpleOperations = []; + var index = 0; + for (var i = 0; i < operation.ops.length; i++) { + var op = operation.ops[i]; + if (TextOperation.isRetain(op)) { + index += op; + } else if (TextOperation.isInsert(op)) { + simpleOperations.push(new Insert(op, index)); + index += op.length; + } else { + simpleOperations.push(new Delete(Math.abs(op), index)); + } + } + return simpleOperations; + }; + + + return SimpleTextOperation; +})(this); + +// Export for CommonJS +if (typeof module === 'object') { + module.exports = ot.SimpleTextOperation; +} \ No newline at end of file diff --git a/lib/ot/text-operation.js b/lib/ot/text-operation.js new file mode 100644 index 00000000..d5468497 --- /dev/null +++ b/lib/ot/text-operation.js @@ -0,0 +1,530 @@ +if (typeof ot === 'undefined') { + // Export for browsers + var ot = {}; +} + +ot.TextOperation = (function () { + 'use strict'; + + // Constructor for new operations. + function TextOperation () { + if (!this || this.constructor !== TextOperation) { + // => function was called without 'new' + return new TextOperation(); + } + + // When an operation is applied to an input string, you can think of this as + // if an imaginary cursor runs over the entire string and skips over some + // parts, deletes some parts and inserts characters at some positions. These + // actions (skip/delete/insert) are stored as an array in the "ops" property. + this.ops = []; + // An operation's baseLength is the length of every string the operation + // can be applied to. + this.baseLength = 0; + // The targetLength is the length of every string that results from applying + // the operation on a valid input string. + this.targetLength = 0; + } + + TextOperation.prototype.equals = function (other) { + if (this.baseLength !== other.baseLength) { return false; } + if (this.targetLength !== other.targetLength) { return false; } + if (this.ops.length !== other.ops.length) { return false; } + for (var i = 0; i < this.ops.length; i++) { + if (this.ops[i] !== other.ops[i]) { return false; } + } + return true; + }; + + // Operation are essentially lists of ops. There are three types of ops: + // + // * Retain ops: Advance the cursor position by a given number of characters. + // Represented by positive ints. + // * Insert ops: Insert a given string at the current cursor position. + // Represented by strings. + // * Delete ops: Delete the next n characters. Represented by negative ints. + + var isRetain = TextOperation.isRetain = function (op) { + return typeof op === 'number' && op > 0; + }; + + var isInsert = TextOperation.isInsert = function (op) { + return typeof op === 'string'; + }; + + var isDelete = TextOperation.isDelete = function (op) { + return typeof op === 'number' && op < 0; + }; + + + // After an operation is constructed, the user of the library can specify the + // actions of an operation (skip/insert/delete) with these three builder + // methods. They all return the operation for convenient chaining. + + // Skip over a given number of characters. + TextOperation.prototype.retain = function (n) { + if (typeof n !== 'number') { + throw new Error("retain expects an integer"); + } + if (n === 0) { return this; } + this.baseLength += n; + this.targetLength += n; + if (isRetain(this.ops[this.ops.length-1])) { + // The last op is a retain op => we can merge them into one op. + this.ops[this.ops.length-1] += n; + } else { + // Create a new op. + this.ops.push(n); + } + return this; + }; + + // Insert a string at the current position. + TextOperation.prototype.insert = function (str) { + if (typeof str !== 'string') { + throw new Error("insert expects a string"); + } + if (str === '') { return this; } + this.targetLength += str.length; + var ops = this.ops; + if (isInsert(ops[ops.length-1])) { + // Merge insert op. + ops[ops.length-1] += str; + } else if (isDelete(ops[ops.length-1])) { + // It doesn't matter when an operation is applied whether the operation + // is delete(3), insert("something") or insert("something"), delete(3). + // Here we enforce that in this case, the insert op always comes first. + // This makes all operations that have the same effect when applied to + // a document of the right length equal in respect to the `equals` method. + if (isInsert(ops[ops.length-2])) { + ops[ops.length-2] += str; + } else { + ops[ops.length] = ops[ops.length-1]; + ops[ops.length-2] = str; + } + } else { + ops.push(str); + } + return this; + }; + + // Delete a string at the current position. + TextOperation.prototype['delete'] = function (n) { + if (typeof n === 'string') { n = n.length; } + if (typeof n !== 'number') { + throw new Error("delete expects an integer or a string"); + } + if (n === 0) { return this; } + if (n > 0) { n = -n; } + this.baseLength -= n; + if (isDelete(this.ops[this.ops.length-1])) { + this.ops[this.ops.length-1] += n; + } else { + this.ops.push(n); + } + return this; + }; + + // Tests whether this operation has no effect. + TextOperation.prototype.isNoop = function () { + return this.ops.length === 0 || (this.ops.length === 1 && isRetain(this.ops[0])); + }; + + // Pretty printing. + TextOperation.prototype.toString = function () { + // map: build a new array by applying a function to every element in an old + // array. + var map = Array.prototype.map || function (fn) { + var arr = this; + var newArr = []; + for (var i = 0, l = arr.length; i < l; i++) { + newArr[i] = fn(arr[i]); + } + return newArr; + }; + return map.call(this.ops, function (op) { + if (isRetain(op)) { + return "retain " + op; + } else if (isInsert(op)) { + return "insert '" + op + "'"; + } else { + return "delete " + (-op); + } + }).join(', '); + }; + + // Converts operation into a JSON value. + TextOperation.prototype.toJSON = function () { + return this.ops; + }; + + // Converts a plain JS object into an operation and validates it. + TextOperation.fromJSON = function (ops) { + var o = new TextOperation(); + for (var i = 0, l = ops.length; i < l; i++) { + var op = ops[i]; + if (isRetain(op)) { + o.retain(op); + } else if (isInsert(op)) { + o.insert(op); + } else if (isDelete(op)) { + o['delete'](op); + } else { + throw new Error("unknown operation: " + JSON.stringify(op)); + } + } + return o; + }; + + // Apply an operation to a string, returning a new string. Throws an error if + // there's a mismatch between the input string and the operation. + TextOperation.prototype.apply = function (str) { + var operation = this; + if (str.length !== operation.baseLength) { + throw new Error("The operation's base length must be equal to the string's length."); + } + var newStr = [], j = 0; + var strIndex = 0; + var ops = this.ops; + for (var i = 0, l = ops.length; i < l; i++) { + var op = ops[i]; + if (isRetain(op)) { + if (strIndex + op > str.length) { + throw new Error("Operation can't retain more characters than are left in the string."); + } + // Copy skipped part of the old string. + newStr[j++] = str.slice(strIndex, strIndex + op); + strIndex += op; + } else if (isInsert(op)) { + // Insert string. + newStr[j++] = op; + } else { // delete op + strIndex -= op; + } + } + if (strIndex !== str.length) { + throw new Error("The operation didn't operate on the whole string."); + } + return newStr.join(''); + }; + + // Computes the inverse of an operation. The inverse of an operation is the + // operation that reverts the effects of the operation, e.g. when you have an + // operation 'insert("hello "); skip(6);' then the inverse is 'delete("hello "); + // skip(6);'. The inverse should be used for implementing undo. + TextOperation.prototype.invert = function (str) { + var strIndex = 0; + var inverse = new TextOperation(); + var ops = this.ops; + for (var i = 0, l = ops.length; i < l; i++) { + var op = ops[i]; + if (isRetain(op)) { + inverse.retain(op); + strIndex += op; + } else if (isInsert(op)) { + inverse['delete'](op.length); + } else { // delete op + inverse.insert(str.slice(strIndex, strIndex - op)); + strIndex -= op; + } + } + return inverse; + }; + + // Compose merges two consecutive operations into one operation, that + // preserves the changes of both. Or, in other words, for each input string S + // and a pair of consecutive operations A and B, + // apply(apply(S, A), B) = apply(S, compose(A, B)) must hold. + TextOperation.prototype.compose = function (operation2) { + var operation1 = this; + if (operation1.targetLength !== operation2.baseLength) { + throw new Error("The base length of the second operation has to be the target length of the first operation"); + } + + var operation = new TextOperation(); // the combined operation + var ops1 = operation1.ops, ops2 = operation2.ops; // for fast access + var i1 = 0, i2 = 0; // current index into ops1 respectively ops2 + var op1 = ops1[i1++], op2 = ops2[i2++]; // current ops + while (true) { + // Dispatch on the type of op1 and op2 + if (typeof op1 === 'undefined' && typeof op2 === 'undefined') { + // end condition: both ops1 and ops2 have been processed + break; + } + + if (isDelete(op1)) { + operation['delete'](op1); + op1 = ops1[i1++]; + continue; + } + if (isInsert(op2)) { + operation.insert(op2); + op2 = ops2[i2++]; + continue; + } + + if (typeof op1 === 'undefined') { + throw new Error("Cannot compose operations: first operation is too short."); + } + if (typeof op2 === 'undefined') { + throw new Error("Cannot compose operations: first operation is too long."); + } + + if (isRetain(op1) && isRetain(op2)) { + if (op1 > op2) { + operation.retain(op2); + op1 = op1 - op2; + op2 = ops2[i2++]; + } else if (op1 === op2) { + operation.retain(op1); + op1 = ops1[i1++]; + op2 = ops2[i2++]; + } else { + operation.retain(op1); + op2 = op2 - op1; + op1 = ops1[i1++]; + } + } else if (isInsert(op1) && isDelete(op2)) { + if (op1.length > -op2) { + op1 = op1.slice(-op2); + op2 = ops2[i2++]; + } else if (op1.length === -op2) { + op1 = ops1[i1++]; + op2 = ops2[i2++]; + } else { + op2 = op2 + op1.length; + op1 = ops1[i1++]; + } + } else if (isInsert(op1) && isRetain(op2)) { + if (op1.length > op2) { + operation.insert(op1.slice(0, op2)); + op1 = op1.slice(op2); + op2 = ops2[i2++]; + } else if (op1.length === op2) { + operation.insert(op1); + op1 = ops1[i1++]; + op2 = ops2[i2++]; + } else { + operation.insert(op1); + op2 = op2 - op1.length; + op1 = ops1[i1++]; + } + } else if (isRetain(op1) && isDelete(op2)) { + if (op1 > -op2) { + operation['delete'](op2); + op1 = op1 + op2; + op2 = ops2[i2++]; + } else if (op1 === -op2) { + operation['delete'](op2); + op1 = ops1[i1++]; + op2 = ops2[i2++]; + } else { + operation['delete'](op1); + op2 = op2 + op1; + op1 = ops1[i1++]; + } + } else { + throw new Error( + "This shouldn't happen: op1: " + + JSON.stringify(op1) + ", op2: " + + JSON.stringify(op2) + ); + } + } + return operation; + }; + + function getSimpleOp (operation, fn) { + var ops = operation.ops; + var isRetain = TextOperation.isRetain; + switch (ops.length) { + case 1: + return ops[0]; + case 2: + return isRetain(ops[0]) ? ops[1] : (isRetain(ops[1]) ? ops[0] : null); + case 3: + if (isRetain(ops[0]) && isRetain(ops[2])) { return ops[1]; } + } + return null; + } + + function getStartIndex (operation) { + if (isRetain(operation.ops[0])) { return operation.ops[0]; } + return 0; + } + + // When you use ctrl-z to undo your latest changes, you expect the program not + // to undo every single keystroke but to undo your last sentence you wrote at + // a stretch or the deletion you did by holding the backspace key down. This + // This can be implemented by composing operations on the undo stack. This + // method can help decide whether two operations should be composed. It + // returns true if the operations are consecutive insert operations or both + // operations delete text at the same position. You may want to include other + // factors like the time since the last change in your decision. + TextOperation.prototype.shouldBeComposedWith = function (other) { + if (this.isNoop() || other.isNoop()) { return true; } + + var startA = getStartIndex(this), startB = getStartIndex(other); + var simpleA = getSimpleOp(this), simpleB = getSimpleOp(other); + if (!simpleA || !simpleB) { return false; } + + if (isInsert(simpleA) && isInsert(simpleB)) { + return startA + simpleA.length === startB; + } + + if (isDelete(simpleA) && isDelete(simpleB)) { + // there are two possibilities to delete: with backspace and with the + // delete key. + return (startB - simpleB === startA) || startA === startB; + } + + return false; + }; + + // Decides whether two operations should be composed with each other + // if they were inverted, that is + // `shouldBeComposedWith(a, b) = shouldBeComposedWithInverted(b^{-1}, a^{-1})`. + TextOperation.prototype.shouldBeComposedWithInverted = function (other) { + if (this.isNoop() || other.isNoop()) { return true; } + + var startA = getStartIndex(this), startB = getStartIndex(other); + var simpleA = getSimpleOp(this), simpleB = getSimpleOp(other); + if (!simpleA || !simpleB) { return false; } + + if (isInsert(simpleA) && isInsert(simpleB)) { + return startA + simpleA.length === startB || startA === startB; + } + + if (isDelete(simpleA) && isDelete(simpleB)) { + return startB - simpleB === startA; + } + + return false; + }; + + // Transform takes two operations A and B that happened concurrently and + // produces two operations A' and B' (in an array) such that + // `apply(apply(S, A), B') = apply(apply(S, B), A')`. This function is the + // heart of OT. + TextOperation.transform = function (operation1, operation2) { + if (operation1.baseLength !== operation2.baseLength) { + throw new Error("Both operations have to have the same base length"); + } + + var operation1prime = new TextOperation(); + var operation2prime = new TextOperation(); + var ops1 = operation1.ops, ops2 = operation2.ops; + var i1 = 0, i2 = 0; + var op1 = ops1[i1++], op2 = ops2[i2++]; + while (true) { + // At every iteration of the loop, the imaginary cursor that both + // operation1 and operation2 have that operates on the input string must + // have the same position in the input string. + + if (typeof op1 === 'undefined' && typeof op2 === 'undefined') { + // end condition: both ops1 and ops2 have been processed + break; + } + + // next two cases: one or both ops are insert ops + // => insert the string in the corresponding prime operation, skip it in + // the other one. If both op1 and op2 are insert ops, prefer op1. + if (isInsert(op1)) { + operation1prime.insert(op1); + operation2prime.retain(op1.length); + op1 = ops1[i1++]; + continue; + } + if (isInsert(op2)) { + operation1prime.retain(op2.length); + operation2prime.insert(op2); + op2 = ops2[i2++]; + continue; + } + + if (typeof op1 === 'undefined') { + throw new Error("Cannot compose operations: first operation is too short."); + } + if (typeof op2 === 'undefined') { + throw new Error("Cannot compose operations: first operation is too long."); + } + + var minl; + if (isRetain(op1) && isRetain(op2)) { + // Simple case: retain/retain + if (op1 > op2) { + minl = op2; + op1 = op1 - op2; + op2 = ops2[i2++]; + } else if (op1 === op2) { + minl = op2; + op1 = ops1[i1++]; + op2 = ops2[i2++]; + } else { + minl = op1; + op2 = op2 - op1; + op1 = ops1[i1++]; + } + operation1prime.retain(minl); + operation2prime.retain(minl); + } else if (isDelete(op1) && isDelete(op2)) { + // Both operations delete the same string at the same position. We don't + // need to produce any operations, we just skip over the delete ops and + // handle the case that one operation deletes more than the other. + if (-op1 > -op2) { + op1 = op1 - op2; + op2 = ops2[i2++]; + } else if (op1 === op2) { + op1 = ops1[i1++]; + op2 = ops2[i2++]; + } else { + op2 = op2 - op1; + op1 = ops1[i1++]; + } + // next two cases: delete/retain and retain/delete + } else if (isDelete(op1) && isRetain(op2)) { + if (-op1 > op2) { + minl = op2; + op1 = op1 + op2; + op2 = ops2[i2++]; + } else if (-op1 === op2) { + minl = op2; + op1 = ops1[i1++]; + op2 = ops2[i2++]; + } else { + minl = -op1; + op2 = op2 + op1; + op1 = ops1[i1++]; + } + operation1prime['delete'](minl); + } else if (isRetain(op1) && isDelete(op2)) { + if (op1 > -op2) { + minl = -op2; + op1 = op1 + op2; + op2 = ops2[i2++]; + } else if (op1 === -op2) { + minl = op1; + op1 = ops1[i1++]; + op2 = ops2[i2++]; + } else { + minl = op1; + op2 = op2 + op1; + op1 = ops1[i1++]; + } + operation2prime['delete'](minl); + } else { + throw new Error("The two operations aren't compatible"); + } + } + + return [operation1prime, operation2prime]; + }; + + return TextOperation; + +}()); + +// Export for CommonJS +if (typeof module === 'object') { + module.exports = ot.TextOperation; +} \ No newline at end of file diff --git a/lib/ot/wrapped-operation.js b/lib/ot/wrapped-operation.js new file mode 100644 index 00000000..91050f4e --- /dev/null +++ b/lib/ot/wrapped-operation.js @@ -0,0 +1,80 @@ +if (typeof ot === 'undefined') { + // Export for browsers + var ot = {}; +} + +ot.WrappedOperation = (function (global) { + 'use strict'; + + // A WrappedOperation contains an operation and corresponing metadata. + function WrappedOperation (operation, meta) { + this.wrapped = operation; + this.meta = meta; + } + + WrappedOperation.prototype.apply = function () { + return this.wrapped.apply.apply(this.wrapped, arguments); + }; + + WrappedOperation.prototype.invert = function () { + var meta = this.meta; + return new WrappedOperation( + this.wrapped.invert.apply(this.wrapped, arguments), + meta && typeof meta === 'object' && typeof meta.invert === 'function' ? + meta.invert.apply(meta, arguments) : meta + ); + }; + + // Copy all properties from source to target. + function copy (source, target) { + for (var key in source) { + if (source.hasOwnProperty(key)) { + target[key] = source[key]; + } + } + } + + function composeMeta (a, b) { + if (a && typeof a === 'object') { + if (typeof a.compose === 'function') { return a.compose(b); } + var meta = {}; + copy(a, meta); + copy(b, meta); + return meta; + } + return b; + } + + WrappedOperation.prototype.compose = function (other) { + return new WrappedOperation( + this.wrapped.compose(other.wrapped), + composeMeta(this.meta, other.meta) + ); + }; + + function transformMeta (meta, operation) { + if (meta && typeof meta === 'object') { + if (typeof meta.transform === 'function') { + return meta.transform(operation); + } + } + return meta; + } + + WrappedOperation.transform = function (a, b) { + var transform = a.wrapped.constructor.transform; + var pair = transform(a.wrapped, b.wrapped); + return [ + new WrappedOperation(pair[0], transformMeta(a.meta, b.wrapped)), + new WrappedOperation(pair[1], transformMeta(b.meta, a.wrapped)) + ]; + }; + + return WrappedOperation; + +}(this)); + +// Export for CommonJS +if (typeof module === 'object') { + module.exports = ot.WrappedOperation; +} \ No newline at end of file diff --git a/lib/realtime.js b/lib/realtime.js index ca9cecd8..66a3c9c5 100644 --- a/lib/realtime.js +++ b/lib/realtime.js @@ -16,6 +16,9 @@ var moment = require('moment'); var config = require("../config.js"); var logger = require("./logger.js"); +//ot +var ot = require("./ot/index.js"); + //others var db = require("./db.js"); var Note = require("./note.js"); @@ -60,28 +63,41 @@ function secure(socket, next) { } } +function emitCheck(note) { + var out = { + updatetime: note.updatetime + }; + for (var i = 0, l = note.socks.length; i < l; i++) { + var sock = note.socks[i]; + sock.emit('check', out); + }; +} + //actions var users = {}; var notes = {}; var updater = setInterval(function () { async.each(Object.keys(notes), function (key, callback) { var note = notes[key]; - if (note.isDirty) { + if (note.server.isDirty) { if (config.debug) logger.info("updater found dirty note: " + key); - var body = LZString.decompressFromUTF16(note.body); + var body = note.server.document; var title = Note.getNoteTitle(body); title = LZString.compressToBase64(title); body = LZString.compressToBase64(body); - db.saveToDB(key, title, body, - function (err, result) {}); - note.isDirty = false; + db.saveToDB(key, title, body, function (err, result) { + if (err) return; + note.server.isDirty = false; + note.updatetime = Date.now(); + emitCheck(note); + }); } callback(); }, function (err) { if (err) return logger.error('updater error', err); }); -}, 5000); +}, 1000); function getStatus(callback) { db.countFromDB(function (err, data) { @@ -189,9 +205,6 @@ function emitRefresh(socket) { socket.emit('refresh', { owner: note.owner, permission: note.permission, - body: note.body, - otk: note.otk, - hash: note.hash, updatetime: note.updatetime }); } @@ -202,8 +215,13 @@ var isDisconnectBusy = false; var disconnectSocketQueue = []; function finishConnection(socket, notename) { - notes[notename].users[socket.id] = users[socket.id]; - notes[notename].socks.push(socket); + var note = notes[notename]; + note.users[socket.id] = users[socket.id]; + note.socks.push(socket); + note.server.addClient(socket); + note.server.setName(socket, users[socket.id].name); + note.server.setColor(socket, users[socket.id].color); + emitOnlineUsers(socket); emitRefresh(socket); @@ -260,18 +278,16 @@ function startConnection(socket) { return; } var body = LZString.decompressFromBase64(data.rows[0].content); - body = LZString.compressToUTF16(body); + //body = LZString.compressToUTF16(body); var updatetime = data.rows[0].update_time; + var server = new ot.EditorSocketIOServer(body, [], notename, ifMayEdit); notes[notename] = { owner: owner, permission: note.permission, socks: [], - body: body, - isDirty: false, users: {}, - otk: shortId.generate(), - hash: md5(body), - updatetime: moment(updatetime).valueOf() + updatetime: moment(updatetime).valueOf(), + server: server }; finishConnection(socket, notename); }); @@ -294,17 +310,18 @@ function disconnect(socket) { if (users[socket.id]) { delete users[socket.id]; } - if (notes[notename]) { - delete notes[notename].users[socket.id]; + var note = notes[notename]; + if (note) { + delete note.users[socket.id]; do { - var index = notes[notename].socks.indexOf(socket); + var index = note.socks.indexOf(socket); if (index != -1) { - notes[notename].socks.splice(index, 1); + note.socks.splice(index, 1); } } while (index != -1); - if (Object.keys(notes[notename].users).length <= 0) { - if (notes[notename].isDirty) { - var body = LZString.decompressFromUTF16(notes[notename].body); + if (Object.keys(note.users).length <= 0) { + if (note.server.isDirty) { + var body = note.server.document; var title = Note.getNoteTitle(body); title = LZString.compressToBase64(title); body = LZString.compressToBase64(body); @@ -363,20 +380,20 @@ function updateUserData(socket, user) { if (socket.request.user && socket.request.user.logged_in) { var profile = JSON.parse(socket.request.user.profile); var photo = null; - switch(profile.provider) { - case "facebook": - photo = 'https://graph.facebook.com/' + profile.id + '/picture'; - break; - case "twitter": - photo = profile.photos[0].value; - break; - case "github": - photo = 'https://avatars.githubusercontent.com/u/' + profile.id + '?s=48'; - break; - case "dropbox": - //no image api provided, use gravatar - photo = 'https://www.gravatar.com/avatar/' + md5(profile.emails[0].value); - break; + switch (profile.provider) { + case "facebook": + photo = 'https://graph.facebook.com/' + profile.id + '/picture'; + break; + case "twitter": + photo = profile.photos[0].value; + break; + case "github": + photo = 'https://avatars.githubusercontent.com/u/' + profile.id + '?s=48'; + break; + case "dropbox": + //no image api provided, use gravatar + photo = 'https://www.gravatar.com/avatar/' + md5(profile.emails[0].value); + break; } user.photo = photo; user.name = profile.displayName || profile.username; @@ -389,6 +406,29 @@ function updateUserData(socket, user) { } } +function ifMayEdit(socket, callback) { + var notename = getNotenameFromSocket(socket); + if (!notename || !notes[notename]) return; + var note = notes[notename]; + var mayEdit = true; + switch (note.permission) { + case "freely": + //not blocking anyone + break; + case "editable": + //only login user can change + if (!socket.request.user || !socket.request.user.logged_in) + mayEdit = false; + break; + case "locked": + //only owner can change + if (note.owner != socket.request.user._id) + mayEdit = false; + break; + } + callback(mayEdit); +} + function connection(socket) { //split notename from socket var notename = getNotenameFromSocket(socket); @@ -442,30 +482,6 @@ function connection(socket) { emitRefresh(socket); }); - //received client data updated - socket.on('update', function (body_) { - var notename = getNotenameFromSocket(socket); - if (!notename || !notes[notename]) return; - if (config.debug) - logger.info('SERVER received [' + notename + '] data updated: ' + socket.id); - var note = notes[notename]; - if (note.body != body_) { - note.body = body_; - note.hash = md5(body_); - note.updatetime = Date.now(); - note.isDirty = true; - } - var out = { - id: socket.id, - hash: note.hash, - updatetime: note.updatetime - }; - for (var i = 0, l = note.socks.length; i < l; i++) { - var sock = note.socks[i]; - sock.emit('check', out); - }; - }); - //received user status socket.on('user status', function (data) { var notename = getNotenameFromSocket(socket); @@ -591,67 +607,6 @@ function connection(socket) { disconnectSocketQueue.push(socket); disconnect(socket); }); - - //when received client change data request - socket.on('change', function (op) { - var notename = getNotenameFromSocket(socket); - if (!notename || !notes[notename]) return; - var note = notes[notename]; - switch (note.permission) { - case "freely": - //not blocking anyone - break; - case "editable": - //only login user can change - if (!socket.request.user || !socket.request.user.logged_in) - return; - break; - case "locked": - //only owner can change - if (note.owner != socket.request.user._id) - return; - break; - } - op = LZString.decompressFromUTF16(op); - if (op) - op = JSON.parse(op); - else - return; - if (config.debug) - logger.info('SERVER received [' + notename + '] data changed: ' + socket.id + ', op:' + JSON.stringify(op)); - switch (op.origin) { - case '+input': - case '+delete': - case '+transpose': - case 'paste': - case 'cut': - case 'undo': - case 'redo': - case 'drag': - case '*compose': - case 'case': - case '+insert': - case '+insertLine': - case '+swapLine': - case '+joinLines': - case '+duplicateLine': - case '+sortLines': - op.id = socket.id; - op.otk = note.otk; - op.nextotk = note.otk = shortId.generate(); - var stringop = JSON.stringify(op); - var compressstringop = LZString.compressToUTF16(stringop); - for (var i = 0, l = note.socks.length; i < l; i++) { - var sock = note.socks[i]; - if (config.debug) - logger.info('SERVER emit sync data out [' + notename + ']: ' + sock.id + ', op:' + stringop); - sock.emit('change', compressstringop); - }; - break; - default: - logger.info('SERVER received uncaught [' + notename + '] data changed: ' + socket.id + ', op:' + JSON.stringify(op)); - } - }); } module.exports = realtime; \ No newline at end of file diff --git a/public/js/index.js b/public/js/index.js index 43b02ad5..1caba825 100644 --- a/public/js/index.js +++ b/public/js/index.js @@ -386,7 +386,10 @@ $(window).resize(function () { }); //when page unload $(window).unload(function () { - emitUpdate(); + //na +}); +$(window).error(function () { + setNeedRefresh(); }); //when page hash change @@ -898,21 +901,9 @@ socket.on('version', function (data) { setNeedRefresh(); }); socket.on('check', function (data) { - if (data.id == socket.id) { - lastchangetime = data.updatetime; - lastchangeui = ui.infobar.lastchange; - updateLastChange(); - return; - } - var currentHash = md5(LZString.compressToUTF16(editor.getValue())); - var hashMismatch = (currentHash != data.hash); - if (hashMismatch) - socket.emit('refresh'); - else { - lastchangetime = data.updatetime; - lastchangeui = ui.infobar.lastchange; - updateLastChange(); - } + lastchangetime = data.updatetime; + lastchangeui = ui.infobar.lastchange; + updateLastChange(); }); socket.on('permission', function (data) { permission = data.permission; @@ -922,26 +913,46 @@ var otk = null; var owner = null; var permission = null; socket.on('refresh', function (data) { - var currentHash = md5(LZString.compressToUTF16(editor.getValue())); - var hashMismatch = (currentHash != data.hash); - saveInfo(); - otk = data.otk; owner = data.owner; permission = data.permission; + lastchangetime = data.updatetime; + lastchangeui = ui.infobar.lastchange; + updateLastChange(); + checkPermission(); +}); + +var EditorClient = ot.EditorClient; +var SocketIOAdapter = ot.SocketIOAdapter; +var CodeMirrorAdapter = ot.CodeMirrorAdapter; +var cmClient = null; - if (hashMismatch) { - var body = data.body; - body = LZString.decompressFromUTF16(body); +socket.on('doc', function (obj) { + obj = LZString.decompressFromUTF16(obj); + obj = JSON.parse(obj); + var body = obj.str; + var bodyMismatch = (editor.getValue() != body); + + saveInfo(); + if (bodyMismatch) { if (body) editor.setValue(body); else editor.setValue(""); } - - lastchangetime = data.updatetime; - lastchangeui = ui.infobar.lastchange; - updateLastChange(); + if (!cmClient) { + cmClient = window.cmClient = new EditorClient( + obj.revision, obj.clients, + new SocketIOAdapter(socket), new CodeMirrorAdapter(editor) + ); + } else { + cmClient.revision = obj.revision; + cmClient.initializeClients(obj.clients); + if (bodyMismatch) { + cmClient.undoManager.undoStack.length = 0; + cmClient.undoManager.redoStack.length = 0; + } + } if (!loaded) { editor.clearHistory(); @@ -963,7 +974,7 @@ socket.on('refresh', function (data) { }, 1); } else { //if current doc is equal to the doc before disconnect - if (hashMismatch) + if (bodyMismatch) editor.clearHistory(); else { if (lastInfo.history) @@ -972,57 +983,26 @@ socket.on('refresh', function (data) { lastInfo.history = null; } - if (hashMismatch) + if (bodyMismatch) { + isDirty = true; updateView(); + } if (editor.getOption('readOnly')) editor.setOption('readOnly', false); restoreInfo(); - checkPermission(); }); -var changeStack = []; -var changeBusy = false; - -socket.on('change', function (data) { - data = LZString.decompressFromUTF16(data); - data = JSON.parse(data); - changeStack.push(data); - if (!changeBusy) - executeChange(); -}); +socket.on('ack', _.debounce(function () { + isDirty = true; + updateView(); +}, finishChangeDelay)); -function executeChange() { - if (changeStack.length > 0) { - changeBusy = true; - var data = changeStack.shift(); - if (data.otk != otk) { - var found = false; - for (var i = 0, l = changeStack.length; i < l; i++) { - if (changeStack[i].otk == otk) { - changeStack.unshift(data); - data = changeStack[i]; - found = true; - break; - } - } - if (!found) { - socket.emit('refresh'); - changeBusy = false; - return; - } - } - otk = data.nextotk; - if (data.id == personalInfo.id) - editor.replaceRange(data.text, data.from, data.to, 'self::' + data.origin); - else - editor.replaceRange(data.text, data.from, data.to, "ignoreHistory"); - executeChange(); - } else { - changeBusy = false; - } -} +socket.on('operation', _.debounce(function () { + isDirty = true; + updateView(); +}, finishChangeDelay)); socket.on('online users', function (data) { data = LZString.decompressFromUTF16(data); @@ -1214,7 +1194,7 @@ function renderUserStatusList(list) { var item = items[j]; var userstatus = $(item.elm).find('.ui-user-status'); var usericon = $(item.elm).find('.ui-user-icon'); - if(item.values().login && item.values().photo) { + if (item.values().login && item.values().photo) { usericon.css('background-image', 'url(' + item.values().photo + ')'); usericon.css('box-shadow', '0px 0px 2px ' + item.values().color); //add 1px more to right, make it feel aligned @@ -1420,6 +1400,7 @@ function buildCursor(user) { checkCursorTag(coord, cursortag); } else { var cursor = $('#' + user.id); + var lineDiff = Math.abs(cursor.attr('data-line') - user.cursor.line); cursor.attr('data-line', user.cursor.line); cursor.attr('data-ch', user.cursor.ch); @@ -1454,65 +1435,31 @@ function buildCursor(user) { } //editor actions +var ignoreEmitEvents = ['setValue', 'ignoreHistory']; editor.on('beforeChange', function (cm, change) { if (debug) console.debug(change); - var self = change.origin.split('self::'); - if (self.length == 2) { - change.origin = self[1]; - self = true; - } else { - self = false; - } - if (self) { - change.canceled = true; - } else { - var isIgnoreEmitEvent = (ignoreEmitEvents.indexOf(change.origin) != -1); - if (!isIgnoreEmitEvent) { - switch (permission) { - case "freely": - //na - break; - case "editable": - if (!personalInfo.login) { - change.canceled = true; - $('.signin-modal').modal('show'); - } - break; - case "locked": - if (personalInfo.userid != owner) { - change.canceled = true; - $('.locked-modal').modal('show'); - } - break; + var isIgnoreEmitEvent = (ignoreEmitEvents.indexOf(change.origin) != -1); + if (!isIgnoreEmitEvent) { + switch (permission) { + case "freely": + //na + break; + case "editable": + if (!personalInfo.login) { + change.canceled = true; + $('.signin-modal').modal('show'); + } + break; + case "locked": + if (personalInfo.userid != owner) { + change.canceled = true; + $('.locked-modal').modal('show'); } + break; } } }); - -var ignoreEmitEvents = ['setValue', 'ignoreHistory']; -editor.on('change', function (i, op) { - if (debug) - console.debug(op); - var isIgnoreEmitEvent = (ignoreEmitEvents.indexOf(op.origin) != -1); - if (!isIgnoreEmitEvent) { - var out = { - text: op.text, - from: op.from, - to: op.to, - origin: op.origin - }; - socket.emit('change', LZString.compressToUTF16(JSON.stringify(out))); - } - isDirty = true; - clearTimeout(finishChangeTimer); - finishChangeTimer = setTimeout(function () { - if (!isIgnoreEmitEvent) - finishChange(true); - else - finishChange(false); - }, finishChangeDelay); -}); editor.on('focus', function (cm) { for (var i = 0; i < onlineUsers.length; i++) { if (onlineUsers[i].id == personalInfo.id) { @@ -1617,24 +1564,15 @@ function restoreInfo() { var finishChangeTimer = null; function finishChange(emit) { - if (emit) - emitUpdate(); updateView(); } -function emitUpdate() { - var value = editor.getValue(); - socket.emit('update', LZString.compressToUTF16(value)); -} - var lastResult = null; function updateView() { if (currentMode == modeType.edit || !isDirty) return; var value = editor.getValue(); var result = postProcess(md.render(value)).children().toArray(); - //ui.area.markdown.html(result); - //finishView(ui.area.markdown); partialUpdate(result, lastResult, ui.area.markdown.children().toArray()); if (result && lastResult && result.length != lastResult.length) updateDataAttrs(result, ui.area.markdown.children().toArray()); diff --git a/public/vendor/ot/ajax-adapter.js b/public/vendor/ot/ajax-adapter.js new file mode 100755 index 00000000..51ea9eab --- /dev/null +++ b/public/vendor/ot/ajax-adapter.js @@ -0,0 +1,116 @@ +/*global ot, $ */ + +ot.AjaxAdapter = (function () { + 'use strict'; + + function AjaxAdapter (path, ownUserName, revision) { + if (path[path.length - 1] !== '/') { path += '/'; } + this.path = path; + this.ownUserName = ownUserName; + this.majorRevision = revision.major || 0; + this.minorRevision = revision.minor || 0; + this.poll(); + } + + AjaxAdapter.prototype.renderRevisionPath = function () { + return 'revision/' + this.majorRevision + '-' + this.minorRevision; + }; + + AjaxAdapter.prototype.handleResponse = function (data) { + var i; + var operations = data.operations; + for (i = 0; i < operations.length; i++) { + if (operations[i].user === this.ownUserName) { + this.trigger('ack'); + } else { + this.trigger('operation', operations[i].operation); + } + } + if (operations.length > 0) { + this.majorRevision += operations.length; + this.minorRevision = 0; + } + + var events = data.events; + if (events) { + for (i = 0; i < events.length; i++) { + var user = events[i].user; + if (user === this.ownUserName) { continue; } + switch (events[i].event) { + case 'joined': this.trigger('set_name', user, user); break; + case 'left': this.trigger('client_left', user); break; + case 'selection': this.trigger('selection', user, events[i].selection); break; + } + } + this.minorRevision += events.length; + } + + var users = data.users; + if (users) { + delete users[this.ownUserName]; + this.trigger('clients', users); + } + + if (data.revision) { + this.majorRevision = data.revision.major; + this.minorRevision = data.revision.minor; + } + }; + + AjaxAdapter.prototype.poll = function () { + var self = this; + $.ajax({ + url: this.path + this.renderRevisionPath(), + type: 'GET', + dataType: 'json', + timeout: 5000, + success: function (data) { + self.handleResponse(data); + self.poll(); + }, + error: function () { + setTimeout(function () { self.poll(); }, 500); + } + }); + }; + + AjaxAdapter.prototype.sendOperation = function (revision, operation, selection) { + if (revision !== this.majorRevision) { throw new Error("Revision numbers out of sync"); } + var self = this; + $.ajax({ + url: this.path + this.renderRevisionPath(), + type: 'POST', + data: JSON.stringify({ operation: operation, selection: selection }), + contentType: 'application/json', + processData: false, + success: function (data) {}, + error: function () { + setTimeout(function () { self.sendOperation(revision, operation, selection); }, 500); + } + }); + }; + + AjaxAdapter.prototype.sendSelection = function (obj) { + $.ajax({ + url: this.path + this.renderRevisionPath() + '/selection', + type: 'POST', + data: JSON.stringify(obj), + contentType: 'application/json', + processData: false, + timeout: 1000 + }); + }; + + AjaxAdapter.prototype.registerCallbacks = function (cb) { + this.callbacks = cb; + }; + + AjaxAdapter.prototype.trigger = function (event) { + var args = Array.prototype.slice.call(arguments, 1); + var action = this.callbacks && this.callbacks[event]; + if (action) { action.apply(this, args); } + }; + + return AjaxAdapter; + +})(); \ No newline at end of file diff --git a/public/vendor/ot/client.js b/public/vendor/ot/client.js new file mode 100755 index 00000000..7ee19fdc --- /dev/null +++ b/public/vendor/ot/client.js @@ -0,0 +1,312 @@ +// translation of https://github.com/djspiewak/cccp/blob/master/agent/src/main/scala/com/codecommit/cccp/agent/state.scala + +if (typeof ot === 'undefined') { + var ot = {}; +} + +ot.Client = (function (global) { + 'use strict'; + + // Client constructor + function Client (revision) { + this.revision = revision; // the next expected revision number + this.setState(synchronized_); // start state + } + + Client.prototype.setState = function (state) { + this.state = state; + }; + + // Call this method when the user changes the document. + Client.prototype.applyClient = function (operation) { + this.setState(this.state.applyClient(this, operation)); + }; + + // Call this method with a new operation from the server + Client.prototype.applyServer = function (revision, operation) { + this.setState(this.state.applyServer(this, revision, operation)); + }; + + Client.prototype.applyOperations = function (head, operations) { + this.setState(this.state.applyOperations(this, head, operations)); + }; + + Client.prototype.serverAck = function (revision) { + this.setState(this.state.serverAck(this, revision)); + }; + + Client.prototype.serverReconnect = function () { + if (typeof this.state.resend === 'function') { this.state.resend(this); } + }; + + // Transforms a selection from the latest known server state to the current + // client state. For example, if we get from the server the information that + // another user's cursor is at position 3, but the server hasn't yet received + // our newest operation, an insertion of 5 characters at the beginning of the + // document, the correct position of the other user's cursor in our current + // document is 8. + Client.prototype.transformSelection = function (selection) { + return this.state.transformSelection(selection); + }; + + // Override this method. + Client.prototype.sendOperation = function (revision, operation) { + throw new Error("sendOperation must be defined in child class"); + }; + + // Override this method. + Client.prototype.applyOperation = function (operation) { + throw new Error("applyOperation must be defined in child class"); + }; + + + // In the 'Synchronized' state, there is no pending operation that the client + // has sent to the server. + function Synchronized () {} + Client.Synchronized = Synchronized; + + Synchronized.prototype.applyClient = function (client, operation) { + // When the user makes an edit, send the operation to the server and + // switch to the 'AwaitingConfirm' state + client.sendOperation(client.revision, operation); + return new AwaitingConfirm(operation); + }; + + Synchronized.prototype.applyServer = function (client, revision, operation) { + if (revision - client.revision > 1) { + throw new Error("Invalid revision."); + } + client.revision = revision; + // When we receive a new operation from the server, the operation can be + // simply applied to the current document + client.applyOperation(operation); + return this; + }; + + Synchronized.prototype.serverAck = function (client, revision) { + throw new Error("There is no pending operation."); + }; + + // Nothing to do because the latest server state and client state are the same. + Synchronized.prototype.transformSelection = function (x) { return x; }; + + // Singleton + var synchronized_ = new Synchronized(); + + + // In the 'AwaitingConfirm' state, there's one operation the client has sent + // to the server and is still waiting for an acknowledgement. + function AwaitingConfirm (outstanding) { + // Save the pending operation + this.outstanding = outstanding; + } + Client.AwaitingConfirm = AwaitingConfirm; + + AwaitingConfirm.prototype.applyClient = function (client, operation) { + // When the user makes an edit, don't send the operation immediately, + // instead switch to 'AwaitingWithBuffer' state + return new AwaitingWithBuffer(this.outstanding, operation); + }; + + AwaitingConfirm.prototype.applyServer = function (client, revision, operation) { + if (revision - client.revision > 1) { + throw new Error("Invalid revision."); + } + client.revision = revision; + // This is another client's operation. Visualization: + // + // /\ + // this.outstanding / \ operation + // / \ + // \ / + // pair[1] \ / pair[0] (new outstanding) + // (can be applied \/ + // to the client's + // current document) + var pair = operation.constructor.transform(this.outstanding, operation); + client.applyOperation(pair[1]); + return new AwaitingConfirm(pair[0]); + }; + + AwaitingConfirm.prototype.serverAck = function (client, revision) { + if (revision - client.revision > 1) { + return new Stale(this.outstanding, client, revision).getOperations(); + } + client.revision = revision; + // The client's operation has been acknowledged + // => switch to synchronized state + return synchronized_; + }; + + AwaitingConfirm.prototype.transformSelection = function (selection) { + return selection.transform(this.outstanding); + }; + + AwaitingConfirm.prototype.resend = function (client) { + // The confirm didn't come because the client was disconnected. + // Now that it has reconnected, we resend the outstanding operation. + client.sendOperation(client.revision, this.outstanding); + }; + + + // In the 'AwaitingWithBuffer' state, the client is waiting for an operation + // to be acknowledged by the server while buffering the edits the user makes + function AwaitingWithBuffer (outstanding, buffer) { + // Save the pending operation and the user's edits since then + this.outstanding = outstanding; + this.buffer = buffer; + } + Client.AwaitingWithBuffer = AwaitingWithBuffer; + + AwaitingWithBuffer.prototype.applyClient = function (client, operation) { + // Compose the user's changes onto the buffer + var newBuffer = this.buffer.compose(operation); + return new AwaitingWithBuffer(this.outstanding, newBuffer); + }; + + AwaitingWithBuffer.prototype.applyServer = function (client, revision, operation) { + if (revision - client.revision > 1) { + throw new Error("Invalid revision."); + } + client.revision = revision; + // Operation comes from another client + // + // /\ + // this.outstanding / \ operation + // / \ + // /\ / + // this.buffer / \* / pair1[0] (new outstanding) + // / \/ + // \ / + // pair2[1] \ / pair2[0] (new buffer) + // the transformed \/ + // operation -- can + // be applied to the + // client's current + // document + // + // * pair1[1] + var transform = operation.constructor.transform; + var pair1 = transform(this.outstanding, operation); + var pair2 = transform(this.buffer, pair1[1]); + client.applyOperation(pair2[1]); + return new AwaitingWithBuffer(pair1[0], pair2[0]); + }; + + AwaitingWithBuffer.prototype.serverAck = function (client, revision) { + if (revision - client.revision > 1) { + return new StaleWithBuffer(this.outstanding, this.buffer, client, revision).getOperations(); + } + client.revision = revision; + // The pending operation has been acknowledged + // => send buffer + client.sendOperation(client.revision, this.buffer); + return new AwaitingConfirm(this.buffer); + }; + + AwaitingWithBuffer.prototype.transformSelection = function (selection) { + return selection.transform(this.outstanding).transform(this.buffer); + }; + + AwaitingWithBuffer.prototype.resend = function (client) { + // The confirm didn't come because the client was disconnected. + // Now that it has reconnected, we resend the outstanding operation. + client.sendOperation(client.revision, this.outstanding); + }; + + + function Stale(acknowlaged, client, revision) { + this.acknowlaged = acknowlaged; + this.client = client; + this.revision = revision; + } + Client.Stale = Stale; + + Stale.prototype.applyClient = function (client, operation) { + return new StaleWithBuffer(this.acknowlaged, operation, client, this.revision); + }; + + Stale.prototype.applyServer = function (client, revision, operation) { + throw new Error("Ignored server-side change."); + }; + + Stale.prototype.applyOperations = function (client, head, operations) { + var transform = this.acknowlaged.constructor.transform; + for (var i = 0; i < operations.length; i++) { + var op = ot.TextOperation.fromJSON(operations[i]); + var pair = transform(this.acknowlaged, op); + client.applyOperation(pair[1]); + this.acknowlaged = pair[0]; + } + client.revision = this.revision; + return synchronized_; + }; + + Stale.prototype.serverAck = function (client, revision) { + throw new Error("There is no pending operation."); + }; + + Stale.prototype.transformSelection = function (selection) { + return selection; + }; + + Stale.prototype.getOperations = function () { + this.client.getOperations(this.client.revision, this.revision - 1); // acknowlaged is the one at revision + return this; + }; + + + function StaleWithBuffer(acknowlaged, buffer, client, revision) { + this.acknowlaged = acknowlaged; + this.buffer = buffer; + this.client = client; + this.revision = revision; + } + Client.StaleWithBuffer = StaleWithBuffer; + + StaleWithBuffer.prototype.applyClient = function (client, operation) { + var buffer = this.buffer.compose(operation); + return new StaleWithBuffer(this.acknowlaged, buffer, client, this.revision); + }; + + StaleWithBuffer.prototype.applyServer = function (client, revision, operation) { + throw new Error("Ignored server-side change."); + }; + + StaleWithBuffer.prototype.applyOperations = function (client, head, operations) { + var transform = this.acknowlaged.constructor.transform; + for (var i = 0; i < operations.length; i++) { + var op = ot.TextOperation.fromJSON(operations[i]); + var pair1 = transform(this.acknowlaged, op); + var pair2 = transform(this.buffer, pair1[1]); + client.applyOperation(pair2[1]); + this.acknowlaged = pair1[0]; + this.buffer = pair2[0]; + } + client.revision = this.revision; + client.sendOperation(client.revision, this.buffer); + return new AwaitingConfirm(this.buffer); + }; + + StaleWithBuffer.prototype.serverAck = function (client, revision) { + throw new Error("There is no pending operation."); + }; + + StaleWithBuffer.prototype.transformSelection = function (selection) { + return selection; + }; + + StaleWithBuffer.prototype.getOperations = function () { + this.client.getOperations(this.client.revision, this.revision - 1); // acknowlaged is the one at revision + return this; + }; + + + return Client; + +}(this)); + +if (typeof module === 'object') { + module.exports = ot.Client; +} + diff --git a/public/vendor/ot/codemirror-adapter.js b/public/vendor/ot/codemirror-adapter.js new file mode 100755 index 00000000..93727c58 --- /dev/null +++ b/public/vendor/ot/codemirror-adapter.js @@ -0,0 +1,393 @@ +/*global ot */ + +ot.CodeMirrorAdapter = (function (global) { + 'use strict'; + + var TextOperation = ot.TextOperation; + var Selection = ot.Selection; + + function CodeMirrorAdapter(cm) { + this.cm = cm; + this.ignoreNextChange = false; + this.changeInProgress = false; + this.selectionChanged = false; + + bind(this, 'onChanges'); + bind(this, 'onChange'); + bind(this, 'onCursorActivity'); + bind(this, 'onFocus'); + bind(this, 'onBlur'); + + cm.on('changes', this.onChanges); + cm.on('change', this.onChange); + cm.on('cursorActivity', this.onCursorActivity); + cm.on('focus', this.onFocus); + cm.on('blur', this.onBlur); + } + + // Removes all event listeners from the CodeMirror instance. + CodeMirrorAdapter.prototype.detach = function () { + this.cm.off('changes', this.onChanges); + this.cm.off('change', this.onChange); + this.cm.off('cursorActivity', this.onCursorActivity); + this.cm.off('focus', this.onFocus); + this.cm.off('blur', this.onBlur); + }; + + function cmpPos(a, b) { + if (a.line < b.line) { + return -1; + } + if (a.line > b.line) { + return 1; + } + if (a.ch < b.ch) { + return -1; + } + if (a.ch > b.ch) { + return 1; + } + return 0; + } + + function posEq(a, b) { + return cmpPos(a, b) === 0; + } + + function posLe(a, b) { + return cmpPos(a, b) <= 0; + } + + function minPos(a, b) { + return posLe(a, b) ? a : b; + } + + function maxPos(a, b) { + return posLe(a, b) ? b : a; + } + + function codemirrorDocLength(doc) { + return doc.indexFromPos({ + line: doc.lastLine(), + ch: 0 + }) + + doc.getLine(doc.lastLine()).length; + } + + // Converts a CodeMirror change array (as obtained from the 'changes' event + // in CodeMirror v4) or single change or linked list of changes (as returned + // by the 'change' event in CodeMirror prior to version 4) into a + // TextOperation and its inverse and returns them as a two-element array. + CodeMirrorAdapter.operationFromCodeMirrorChanges = function (changes, doc) { + // Approach: Replay the changes, beginning with the most recent one, and + // construct the operation and its inverse. We have to convert the position + // in the pre-change coordinate system to an index. We have a method to + // convert a position in the coordinate system after all changes to an index, + // namely CodeMirror's `indexFromPos` method. We can use the information of + // a single change object to convert a post-change coordinate system to a + // pre-change coordinate system. We can now proceed inductively to get a + // pre-change coordinate system for all changes in the linked list. + // A disadvantage of this approach is its complexity `O(n^2)` in the length + // of the linked list of changes. + + var docEndLength = codemirrorDocLength(doc); + var operation = new TextOperation().retain(docEndLength); + var inverse = new TextOperation().retain(docEndLength); + + var indexFromPos = function (pos) { + return doc.indexFromPos(pos); + }; + + function last(arr) { + return arr[arr.length - 1]; + } + + function sumLengths(strArr) { + if (strArr.length === 0) { + return 0; + } + var sum = 0; + for (var i = 0; i < strArr.length; i++) { + sum += strArr[i].length; + } + return sum + strArr.length - 1; + } + + function updateIndexFromPos(indexFromPos, change) { + return function (pos) { + if (posLe(pos, change.from)) { + return indexFromPos(pos); + } + if (posLe(change.to, pos)) { + return indexFromPos({ + line: pos.line + change.text.length - 1 - (change.to.line - change.from.line), + ch: (change.to.line < pos.line) ? + pos.ch : (change.text.length <= 1) ? + pos.ch - (change.to.ch - change.from.ch) + sumLengths(change.text) : pos.ch - change.to.ch + last(change.text).length + }) + sumLengths(change.removed) - sumLengths(change.text); + } + if (change.from.line === pos.line) { + return indexFromPos(change.from) + pos.ch - change.from.ch; + } + return indexFromPos(change.from) + + sumLengths(change.removed.slice(0, pos.line - change.from.line)) + + 1 + pos.ch; + }; + } + + for (var i = changes.length - 1; i >= 0; i--) { + var change = changes[i]; + indexFromPos = updateIndexFromPos(indexFromPos, change); + + var fromIndex = indexFromPos(change.from); + var restLength = docEndLength - fromIndex - sumLengths(change.text); + + operation = new TextOperation() + .retain(fromIndex)['delete'](sumLengths(change.removed)) + .insert(change.text.join('\n')) + .retain(restLength) + .compose(operation); + + inverse = inverse.compose(new TextOperation() + .retain(fromIndex)['delete'](sumLengths(change.text)) + .insert(change.removed.join('\n')) + .retain(restLength) + ); + + docEndLength += sumLengths(change.removed) - sumLengths(change.text); + } + + return [operation, inverse]; + }; + + // Singular form for backwards compatibility. + CodeMirrorAdapter.operationFromCodeMirrorChange = + CodeMirrorAdapter.operationFromCodeMirrorChanges; + + // Apply an operation to a CodeMirror instance. + CodeMirrorAdapter.applyOperationToCodeMirror = function (operation, cm) { + cm.operation(function () { + var ops = operation.ops; + var index = 0; // holds the current index into CodeMirror's content + for (var i = 0, l = ops.length; i < l; i++) { + var op = ops[i]; + if (TextOperation.isRetain(op)) { + index += op; + } else if (TextOperation.isInsert(op)) { + cm.replaceRange(op, cm.posFromIndex(index), null, 'ignoreHistory'); + index += op.length; + } else if (TextOperation.isDelete(op)) { + var from = cm.posFromIndex(index); + var to = cm.posFromIndex(index - op); + cm.replaceRange('', from, to, 'ignoreHistory'); + } + } + }); + }; + + CodeMirrorAdapter.prototype.registerCallbacks = function (cb) { + this.callbacks = cb; + }; + + CodeMirrorAdapter.prototype.onChange = function () { + // By default, CodeMirror's event order is the following: + // 1. 'change', 2. 'cursorActivity', 3. 'changes'. + // We want to fire the 'selectionChange' event after the 'change' event, + // but need the information from the 'changes' event. Therefore, we detect + // when a change is in progress by listening to the change event, setting + // a flag that makes this adapter defer all 'cursorActivity' events. + this.changeInProgress = true; + }; + + CodeMirrorAdapter.prototype.onChanges = function (_, changes) { + if (!this.ignoreNextChange) { + var pair = CodeMirrorAdapter.operationFromCodeMirrorChanges(changes, this.cm); + this.trigger('change', pair[0], pair[1]); + } + if (this.selectionChanged) { + this.trigger('selectionChange'); + } + this.changeInProgress = false; + this.ignoreNextChange = false; + }; + + CodeMirrorAdapter.prototype.onCursorActivity = + CodeMirrorAdapter.prototype.onFocus = function () { + if (this.changeInProgress) { + this.selectionChanged = true; + } else { + this.trigger('selectionChange'); + } + }; + + CodeMirrorAdapter.prototype.onBlur = function () { + if (!this.cm.somethingSelected()) { + this.trigger('blur'); + } + }; + + CodeMirrorAdapter.prototype.getValue = function () { + return this.cm.getValue(); + }; + + CodeMirrorAdapter.prototype.getSelection = function () { + var cm = this.cm; + + var selectionList = cm.listSelections(); + var ranges = []; + for (var i = 0; i < selectionList.length; i++) { + ranges[i] = new Selection.Range( + cm.indexFromPos(selectionList[i].anchor), + cm.indexFromPos(selectionList[i].head) + ); + } + + return new Selection(ranges); + }; + + CodeMirrorAdapter.prototype.setSelection = function (selection) { + var ranges = []; + for (var i = 0; i < selection.ranges.length; i++) { + var range = selection.ranges[i]; + ranges[i] = { + anchor: this.cm.posFromIndex(range.anchor), + head: this.cm.posFromIndex(range.head) + }; + } + this.cm.setSelections(ranges); + }; + + var addStyleRule = (function () { + var added = {}; + var styleElement = document.createElement('style'); + document.documentElement.getElementsByTagName('head')[0].appendChild(styleElement); + var styleSheet = styleElement.sheet; + + return function (css) { + if (added[css]) { + return; + } + added[css] = true; + styleSheet.insertRule(css, (styleSheet.cssRules || styleSheet.rules).length); + }; + }()); + + CodeMirrorAdapter.prototype.setOtherCursor = function (position, color, clientId) { + var cursorPos = this.cm.posFromIndex(position); + var cursorCoords = this.cm.cursorCoords(cursorPos); + var cursorEl = document.createElement('span'); + cursorEl.className = 'other-client'; + cursorEl.style.display = 'none'; + /* + cursorEl.style.padding = '0'; + cursorEl.style.marginLeft = cursorEl.style.marginRight = '-1px'; + cursorEl.style.borderLeftWidth = '2px'; + cursorEl.style.borderLeftStyle = 'solid'; + cursorEl.style.borderLeftColor = color; + cursorEl.style.height = (cursorCoords.bottom - cursorCoords.top) * 0.9 + 'px'; + cursorEl.style.zIndex = 0; + */ + cursorEl.setAttribute('data-clientid', clientId); + return this.cm.setBookmark(cursorPos, { + widget: cursorEl, + insertLeft: true + }); + }; + + CodeMirrorAdapter.prototype.setOtherSelectionRange = function (range, color, clientId) { + var match = /^#([0-9a-fA-F]{6})$/.exec(color); + if (!match) { + throw new Error("only six-digit hex colors are allowed."); + } + var selectionClassName = 'selection-' + match[1]; + var rgbcolor = hex2rgb(color); + var rule = '.' + selectionClassName + ' { background: rgba(' + rgbcolor.red + ',' + rgbcolor.green + ',' + rgbcolor.blue + ',0.2); }'; + addStyleRule(rule); + + var anchorPos = this.cm.posFromIndex(range.anchor); + var headPos = this.cm.posFromIndex(range.head); + + return this.cm.markText( + minPos(anchorPos, headPos), + maxPos(anchorPos, headPos), { + className: selectionClassName + } + ); + }; + + CodeMirrorAdapter.prototype.setOtherSelection = function (selection, color, clientId) { + var selectionObjects = []; + for (var i = 0; i < selection.ranges.length; i++) { + var range = selection.ranges[i]; + if (range.isEmpty()) { + selectionObjects[i] = this.setOtherCursor(range.head, color, clientId); + } else { + selectionObjects[i] = this.setOtherSelectionRange(range, color, clientId); + } + } + return { + clear: function () { + for (var i = 0; i < selectionObjects.length; i++) { + selectionObjects[i].clear(); + } + } + }; + }; + + CodeMirrorAdapter.prototype.trigger = function (event) { + var args = Array.prototype.slice.call(arguments, 1); + var action = this.callbacks && this.callbacks[event]; + if (action) { + action.apply(this, args); + } + }; + + CodeMirrorAdapter.prototype.applyOperation = function (operation) { + this.ignoreNextChange = true; + CodeMirrorAdapter.applyOperationToCodeMirror(operation, this.cm); + }; + + CodeMirrorAdapter.prototype.registerUndo = function (undoFn) { + this.cm.undo = undoFn; + }; + + CodeMirrorAdapter.prototype.registerRedo = function (redoFn) { + this.cm.redo = redoFn; + }; + + // Throws an error if the first argument is falsy. Useful for debugging. + function assert(b, msg) { + if (!b) { + throw new Error(msg || "assertion error"); + } + } + + // Bind a method to an object, so it doesn't matter whether you call + // object.method() directly or pass object.method as a reference to another + // function. + function bind(obj, method) { + var fn = obj[method]; + obj[method] = function () { + fn.apply(obj, arguments); + }; + } + + return CodeMirrorAdapter; + +}(this)); + +function hex2rgb(hex) { + if (hex[0] == "#") hex = hex.substr(1); + if (hex.length == 3) { + var temp = hex; + hex = ''; + temp = /^([a-f0-9])([a-f0-9])([a-f0-9])$/i.exec(temp).slice(1); + for (var i = 0; i < 3; i++) hex += temp[i] + temp[i]; + } + var triplets = /^([a-f0-9]{2})([a-f0-9]{2})([a-f0-9]{2})$/i.exec(hex).slice(1); + return { + red: parseInt(triplets[0], 16), + green: parseInt(triplets[1], 16), + blue: parseInt(triplets[2], 16) + } +} \ No newline at end of file diff --git a/public/vendor/ot/compress.sh b/public/vendor/ot/compress.sh new file mode 100644 index 00000000..b7cb423f --- /dev/null +++ b/public/vendor/ot/compress.sh @@ -0,0 +1,10 @@ +uglifyjs --compress --mangle --output ot.min.js \ +./text-operation.js \ +./selection.js \ +./wrapped-operation.js \ +./undo-manager.js \ +./client.js \ +./codemirror-adapter.js \ +./socketio-adapter.js \ +./ajax-adapter.js \ +./editor-client.js \ No newline at end of file diff --git a/public/vendor/ot/editor-client.js b/public/vendor/ot/editor-client.js new file mode 100755 index 00000000..b01afb46 --- /dev/null +++ b/public/vendor/ot/editor-client.js @@ -0,0 +1,354 @@ +/*global ot */ + +ot.EditorClient = (function () { + 'use strict'; + + var Client = ot.Client; + var Selection = ot.Selection; + var UndoManager = ot.UndoManager; + var TextOperation = ot.TextOperation; + var WrappedOperation = ot.WrappedOperation; + + + function SelfMeta (selectionBefore, selectionAfter) { + this.selectionBefore = selectionBefore; + this.selectionAfter = selectionAfter; + } + + SelfMeta.prototype.invert = function () { + return new SelfMeta(this.selectionAfter, this.selectionBefore); + }; + + SelfMeta.prototype.compose = function (other) { + return new SelfMeta(this.selectionBefore, other.selectionAfter); + }; + + SelfMeta.prototype.transform = function (operation) { + return new SelfMeta( + this.selectionBefore.transform(operation), + this.selectionAfter.transform(operation) + ); + }; + + + function OtherMeta (clientId, selection) { + this.clientId = clientId; + this.selection = selection; + } + + OtherMeta.fromJSON = function (obj) { + return new OtherMeta( + obj.clientId, + obj.selection && Selection.fromJSON(obj.selection) + ); + }; + + OtherMeta.prototype.transform = function (operation) { + return new OtherMeta( + this.clientId, + this.selection && this.selection.transform(operation) + ); + }; + + + function OtherClient (id, listEl, editorAdapter, name, color, selection) { + this.id = id; + this.listEl = listEl; + this.editorAdapter = editorAdapter; + this.name = name; + this.color = color; + + this.li = document.createElement('li'); + if (name) { + this.li.textContent = name; + this.listEl.appendChild(this.li); + } + + if(!color) + this.setColor(name ? hueFromName(name) : Math.random()); + else + this.setForceColor(color); + if (selection) { this.updateSelection(selection); } + } + + OtherClient.prototype.setColor = function (hue) { + this.hue = hue; + this.color = hsl2hex(hue, 0.75, 0.5); + this.lightColor = hsl2hex(hue, 0.5, 0.9); + if (this.li) { this.li.style.color = this.color; } + }; + + OtherClient.prototype.setForceColor = function (color) { + this.hue = null; + this.color = color; + this.lightColor = color; + if (this.li) { this.li.style.color = this.color; } + }; + + OtherClient.prototype.setName = function (name) { + if (this.name === name) { return; } + this.name = name; + + this.li.textContent = name; + if (!this.li.parentNode) { + this.listEl.appendChild(this.li); + } + + this.setColor(hueFromName(name)); + }; + + OtherClient.prototype.updateSelection = function (selection) { + this.removeSelection(); + this.selection = selection; + this.mark = this.editorAdapter.setOtherSelection( + selection, + selection.position === selection.selectionEnd ? this.color : this.lightColor, + this.id + ); + }; + + OtherClient.prototype.remove = function () { + if (this.li) { removeElement(this.li); } + this.removeSelection(); + }; + + OtherClient.prototype.removeSelection = function () { + if (this.mark) { + this.mark.clear(); + this.mark = null; + } + }; + + + function EditorClient (revision, clients, serverAdapter, editorAdapter) { + Client.call(this, revision); + this.serverAdapter = serverAdapter; + this.editorAdapter = editorAdapter; + this.undoManager = new UndoManager(); + + this.initializeClientList(); + this.initializeClients(clients); + + var self = this; + + this.editorAdapter.registerCallbacks({ + change: function (operation, inverse) { self.onChange(operation, inverse); }, + selectionChange: function () { self.onSelectionChange(); }, + blur: function () { self.onBlur(); } + }); + this.editorAdapter.registerUndo(function () { self.undo(); }); + this.editorAdapter.registerRedo(function () { self.redo(); }); + + this.serverAdapter.registerCallbacks({ + client_left: function (clientId) { self.onClientLeft(clientId); }, + set_name: function (clientId, name) { self.getClientObject(clientId).setName(name); }, + set_color: function (clientId, color) { self.getClientObject(clientId).setForceColor(color); }, + ack: function (revision) { self.serverAck(revision); }, + operation: function (revision, operation) { + self.applyServer(revision, TextOperation.fromJSON(operation)); + }, + operations: function (head, operations) { + self.applyOperations(head, operations); + }, + selection: function (clientId, selection) { + if (selection) { + self.getClientObject(clientId).updateSelection( + self.transformSelection(Selection.fromJSON(selection)) + ); + } else { + self.getClientObject(clientId).removeSelection(); + } + }, + clients: function (clients) { + var clientId; + for (clientId in self.clients) { + if (self.clients.hasOwnProperty(clientId) && !clients.hasOwnProperty(clientId)) { + self.onClientLeft(clientId); + } + } + + for (clientId in clients) { + if (clients.hasOwnProperty(clientId)) { + var clientObject = self.getClientObject(clientId); + + if (clients[clientId].name) { + clientObject.setName(clients[clientId].name); + } + + var selection = clients[clientId].selection; + if (selection) { + self.clients[clientId].updateSelection( + self.transformSelection(Selection.fromJSON(selection)) + ); + } else { + self.clients[clientId].removeSelection(); + } + } + } + }, + reconnect: function () { self.serverReconnect(); } + }); + } + + inherit(EditorClient, Client); + + EditorClient.prototype.addClient = function (clientId, clientObj) { + this.clients[clientId] = new OtherClient( + clientId, + this.clientListEl, + this.editorAdapter, + clientObj.name || clientId, + clientObj.color || null, + clientObj.selection ? Selection.fromJSON(clientObj.selection) : null + ); + }; + + EditorClient.prototype.initializeClients = function (clients) { + this.clients = {}; + for (var clientId in clients) { + if (clients.hasOwnProperty(clientId)) { + this.addClient(clientId, clients[clientId]); + } + } + }; + + EditorClient.prototype.getClientObject = function (clientId) { + var client = this.clients[clientId]; + if (client) { return client; } + return this.clients[clientId] = new OtherClient( + clientId, + this.clientListEl, + this.editorAdapter + ); + }; + + EditorClient.prototype.onClientLeft = function (clientId) { + //console.log("User disconnected: " + clientId); + var client = this.clients[clientId]; + if (!client) { return; } + client.remove(); + delete this.clients[clientId]; + }; + + EditorClient.prototype.initializeClientList = function () { + this.clientListEl = document.createElement('ul'); + }; + + EditorClient.prototype.applyUnredo = function (operation) { + this.undoManager.add(operation.invert(this.editorAdapter.getValue())); + this.editorAdapter.applyOperation(operation.wrapped); + this.selection = operation.meta.selectionAfter; + this.editorAdapter.setSelection(this.selection); + this.applyClient(operation.wrapped); + }; + + EditorClient.prototype.undo = function () { + var self = this; + if (!this.undoManager.canUndo()) { return; } + this.undoManager.performUndo(function (o) { self.applyUnredo(o); }); + }; + + EditorClient.prototype.redo = function () { + var self = this; + if (!this.undoManager.canRedo()) { return; } + this.undoManager.performRedo(function (o) { self.applyUnredo(o); }); + }; + + EditorClient.prototype.onChange = function (textOperation, inverse) { + var selectionBefore = this.selection; + this.updateSelection(); + var meta = new SelfMeta(selectionBefore, this.selection); + var operation = new WrappedOperation(textOperation, meta); + + var compose = this.undoManager.undoStack.length > 0 && + inverse.shouldBeComposedWithInverted(last(this.undoManager.undoStack).wrapped); + var inverseMeta = new SelfMeta(this.selection, selectionBefore); + this.undoManager.add(new WrappedOperation(inverse, inverseMeta), compose); + this.applyClient(textOperation); + }; + + EditorClient.prototype.updateSelection = function () { + this.selection = this.editorAdapter.getSelection(); + }; + + EditorClient.prototype.onSelectionChange = function () { + var oldSelection = this.selection; + this.updateSelection(); + if (oldSelection && this.selection.equals(oldSelection)) { return; } + this.sendSelection(this.selection); + }; + + EditorClient.prototype.onBlur = function () { + this.selection = null; + this.sendSelection(null); + }; + + EditorClient.prototype.sendSelection = function (selection) { + if (this.state instanceof Client.AwaitingWithBuffer) { return; } + this.serverAdapter.sendSelection(selection); + }; + + EditorClient.prototype.sendOperation = function (revision, operation) { + this.serverAdapter.sendOperation(revision, operation.toJSON(), this.selection); + }; + + EditorClient.prototype.getOperations = function (base, head) { + this.serverAdapter.getOperations(base, head); + }; + + EditorClient.prototype.applyOperation = function (operation) { + this.editorAdapter.applyOperation(operation); + this.updateSelection(); + this.undoManager.transform(new WrappedOperation(operation, null)); + }; + + function rgb2hex (r, g, b) { + function digits (n) { + var m = Math.round(255*n).toString(16); + return m.length === 1 ? '0'+m : m; + } + return '#' + digits(r) + digits(g) + digits(b); + } + + function hsl2hex (h, s, l) { + if (s === 0) { return rgb2hex(l, l, l); } + var var2 = l < 0.5 ? l * (1+s) : (l+s) - (s*l); + var var1 = 2 * l - var2; + var hue2rgb = function (hue) { + if (hue < 0) { hue += 1; } + if (hue > 1) { hue -= 1; } + if (6*hue < 1) { return var1 + (var2-var1)*6*hue; } + if (2*hue < 1) { return var2; } + if (3*hue < 2) { return var1 + (var2-var1)*6*(2/3 - hue); } + return var1; + }; + return rgb2hex(hue2rgb(h+1/3), hue2rgb(h), hue2rgb(h-1/3)); + } + + function hueFromName (name) { + var a = 1; + for (var i = 0; i < name.length; i++) { + a = 17 * (a+name.charCodeAt(i)) % 360; + } + return a/360; + } + + // Set Const.prototype.__proto__ to Super.prototype + function inherit (Const, Super) { + function F () {} + F.prototype = Super.prototype; + Const.prototype = new F(); + Const.prototype.constructor = Const; + } + + function last (arr) { return arr[arr.length - 1]; } + + // Remove an element from the DOM. + function removeElement (el) { + if (el.parentNode) { + el.parentNode.removeChild(el); + } + } + + return EditorClient; +}()); diff --git a/public/vendor/ot/ot.min.js b/public/vendor/ot/ot.min.js new file mode 100644 index 00000000..2051bfeb --- /dev/null +++ b/public/vendor/ot/ot.min.js @@ -0,0 +1 @@ +function hex2rgb(t){if("#"==t[0]&&(t=t.substr(1)),3==t.length){var e=t;t="",e=/^([a-f0-9])([a-f0-9])([a-f0-9])$/i.exec(e).slice(1);for(var n=0;3>n;n++)t+=e[n]+e[n]}var o=/^([a-f0-9]{2})([a-f0-9]{2})([a-f0-9]{2})$/i.exec(t).slice(1);return{red:parseInt(o[0],16),green:parseInt(o[1],16),blue:parseInt(o[2],16)}}if("undefined"==typeof ot)var ot={};if(ot.TextOperation=function(){"use strict";function t(){return this&&this.constructor===t?(this.ops=[],this.baseLength=0,void(this.targetLength=0)):new t}function e(e,n){var o=e.ops,r=t.isRetain;switch(o.length){case 1:return o[0];case 2:return r(o[0])?o[1]:r(o[1])?o[0]:null;case 3:if(r(o[0])&&r(o[2]))return o[1]}return null}function n(t){return o(t.ops[0])?t.ops[0]:0}t.prototype.equals=function(t){if(this.baseLength!==t.baseLength)return!1;if(this.targetLength!==t.targetLength)return!1;if(this.ops.length!==t.ops.length)return!1;for(var e=0;e0},r=t.isInsert=function(t){return"string"==typeof t},i=t.isDelete=function(t){return"number"==typeof t&&0>t};return t.prototype.retain=function(t){if("number"!=typeof t)throw new Error("retain expects an integer");return 0===t?this:(this.baseLength+=t,this.targetLength+=t,o(this.ops[this.ops.length-1])?this.ops[this.ops.length-1]+=t:this.ops.push(t),this)},t.prototype.insert=function(t){if("string"!=typeof t)throw new Error("insert expects a string");if(""===t)return this;this.targetLength+=t.length;var e=this.ops;return r(e[e.length-1])?e[e.length-1]+=t:i(e[e.length-1])?r(e[e.length-2])?e[e.length-2]+=t:(e[e.length]=e[e.length-1],e[e.length-2]=t):e.push(t),this},t.prototype["delete"]=function(t){if("string"==typeof t&&(t=t.length),"number"!=typeof t)throw new Error("delete expects an integer or a string");return 0===t?this:(t>0&&(t=-t),this.baseLength-=t,i(this.ops[this.ops.length-1])?this.ops[this.ops.length-1]+=t:this.ops.push(t),this)},t.prototype.isNoop=function(){return 0===this.ops.length||1===this.ops.length&&o(this.ops[0])},t.prototype.toString=function(){var t=Array.prototype.map||function(t){for(var e=this,n=[],o=0,r=e.length;r>o;o++)n[o]=t(e[o]);return n};return t.call(this.ops,function(t){return o(t)?"retain "+t:r(t)?"insert '"+t+"'":"delete "+-t}).join(", ")},t.prototype.toJSON=function(){return this.ops},t.fromJSON=function(e){for(var n=new t,s=0,a=e.length;a>s;s++){var h=e[s];if(o(h))n.retain(h);else if(r(h))n.insert(h);else{if(!i(h))throw new Error("unknown operation: "+JSON.stringify(h));n["delete"](h)}}return n},t.prototype.apply=function(t){var e=this;if(t.length!==e.baseLength)throw new Error("The operation's base length must be equal to the string's length.");for(var n=[],i=0,s=0,a=this.ops,h=0,p=a.length;p>h;h++){var c=a[h];if(o(c)){if(s+c>t.length)throw new Error("Operation can't retain more characters than are left in the string.");n[i++]=t.slice(s,s+c),s+=c}else r(c)?n[i++]=c:s-=c}if(s!==t.length)throw new Error("The operation didn't operate on the whole string.");return n.join("")},t.prototype.invert=function(e){for(var n=0,i=new t,s=this.ops,a=0,h=s.length;h>a;a++){var p=s[a];o(p)?(i.retain(p),n+=p):r(p)?i["delete"](p.length):(i.insert(e.slice(n,n-p)),n-=p)}return i},t.prototype.compose=function(e){var n=this;if(n.targetLength!==e.baseLength)throw new Error("The base length of the second operation has to be the target length of the first operation");for(var s=new t,a=n.ops,h=e.ops,p=0,c=0,l=a[p++],u=h[c++];;){if("undefined"==typeof l&&"undefined"==typeof u)break;if(i(l))s["delete"](l),l=a[p++];else if(r(u))s.insert(u),u=h[c++];else{if("undefined"==typeof l)throw new Error("Cannot compose operations: first operation is too short.");if("undefined"==typeof u)throw new Error("Cannot compose operations: first operation is too long.");if(o(l)&&o(u))l>u?(s.retain(u),l-=u,u=h[c++]):l===u?(s.retain(l),l=a[p++],u=h[c++]):(s.retain(l),u-=l,l=a[p++]);else if(r(l)&&i(u))l.length>-u?(l=l.slice(-u),u=h[c++]):l.length===-u?(l=a[p++],u=h[c++]):(u+=l.length,l=a[p++]);else if(r(l)&&o(u))l.length>u?(s.insert(l.slice(0,u)),l=l.slice(u),u=h[c++]):l.length===u?(s.insert(l),l=a[p++],u=h[c++]):(s.insert(l),u-=l.length,l=a[p++]);else{if(!o(l)||!i(u))throw new Error("This shouldn't happen: op1: "+JSON.stringify(l)+", op2: "+JSON.stringify(u));l>-u?(s["delete"](u),l+=u,u=h[c++]):l===-u?(s["delete"](u),l=a[p++],u=h[c++]):(s["delete"](l),u+=l,l=a[p++])}}}return s},t.prototype.shouldBeComposedWith=function(t){if(this.isNoop()||t.isNoop())return!0;var o=n(this),s=n(t),a=e(this),h=e(t);return a&&h?r(a)&&r(h)?o+a.length===s:i(a)&&i(h)?s-h===o||o===s:!1:!1},t.prototype.shouldBeComposedWithInverted=function(t){if(this.isNoop()||t.isNoop())return!0;var o=n(this),s=n(t),a=e(this),h=e(t);return a&&h?r(a)&&r(h)?o+a.length===s||o===s:i(a)&&i(h)?s-h===o:!1:!1},t.transform=function(e,n){if(e.baseLength!==n.baseLength)throw new Error("Both operations have to have the same base length");for(var s=new t,a=new t,h=e.ops,p=n.ops,c=0,l=0,u=h[c++],f=p[l++];;){if("undefined"==typeof u&&"undefined"==typeof f)break;if(r(u))s.insert(u),a.retain(u.length),u=h[c++];else if(r(f))s.retain(f.length),a.insert(f),f=p[l++];else{if("undefined"==typeof u)throw new Error("Cannot compose operations: first operation is too short.");if("undefined"==typeof f)throw new Error("Cannot compose operations: first operation is too long.");var d;if(o(u)&&o(f))u>f?(d=f,u-=f,f=p[l++]):u===f?(d=f,u=h[c++],f=p[l++]):(d=u,f-=u,u=h[c++]),s.retain(d),a.retain(d);else if(i(u)&&i(f))-u>-f?(u-=f,f=p[l++]):u===f?(u=h[c++],f=p[l++]):(f-=u,u=h[c++]);else if(i(u)&&o(f))-u>f?(d=f,u+=f,f=p[l++]):-u===f?(d=f,u=h[c++],f=p[l++]):(d=-u,f+=u,u=h[c++]),s["delete"](d);else{if(!o(u)||!i(f))throw new Error("The two operations aren't compatible");u>-f?(d=-f,u+=f,f=p[l++]):u===-f?(d=u,u=h[c++],f=p[l++]):(d=u,f+=u,u=h[c++]),a["delete"](d)}}}return[s,a]},t}(),"object"==typeof module&&(module.exports=ot.TextOperation),"undefined"==typeof ot)var ot={};if(ot.Selection=function(t){"use strict";function e(t,e){this.anchor=t,this.head=e}function n(t){this.ranges=t||[]}var o=t.ot?t.ot.TextOperation:require("./text-operation");return e.fromJSON=function(t){return new e(t.anchor,t.head)},e.prototype.equals=function(t){return this.anchor===t.anchor&&this.head===t.head},e.prototype.isEmpty=function(){return this.anchor===this.head},e.prototype.transform=function(t){function n(e){for(var n=e,r=t.ops,i=0,s=t.ops.length;s>i&&(o.isRetain(r[i])?e-=r[i]:o.isInsert(r[i])?n+=r[i].length:(n-=Math.min(e,-r[i]),e+=r[i]),!(0>e));i++);return n}var r=n(this.anchor);return this.anchor===this.head?new e(r,r):new e(r,n(this.head))},n.Range=e,n.createCursor=function(t){return new n([new e(t,t)])},n.fromJSON=function(t){for(var o=t.ranges||t,r=0,i=[];r=0;r--){var i=o.transform(t[r],e);"function"==typeof i[0].isNoop&&i[0].isNoop()||n.push(i[0]),e=i[1]}return n.reverse()}var n="normal",o="undoing",r="redoing";return t.prototype.add=function(t,e){if(this.state===o)this.redoStack.push(t),this.dontCompose=!0;else if(this.state===r)this.undoStack.push(t),this.dontCompose=!0;else{var n=this.undoStack;!this.dontCompose&&e&&n.length>0?n.push(t.compose(n.pop())):(n.push(t),n.length>this.maxItems&&n.shift()),this.dontCompose=!1,this.redoStack=[]}},t.prototype.transform=function(t){this.undoStack=e(this.undoStack,t),this.redoStack=e(this.redoStack,t)},t.prototype.performUndo=function(t){if(this.state=o,0===this.undoStack.length)throw new Error("undo not possible");t(this.undoStack.pop()),this.state=n},t.prototype.performRedo=function(t){if(this.state=r,0===this.redoStack.length)throw new Error("redo not possible");t(this.redoStack.pop()),this.state=n},t.prototype.canUndo=function(){return 0!==this.undoStack.length},t.prototype.canRedo=function(){return 0!==this.redoStack.length},t.prototype.isUndoing=function(){return this.state===o},t.prototype.isRedoing=function(){return this.state===r},t}(),"object"==typeof module&&(module.exports=ot.UndoManager),"undefined"==typeof ot)var ot={};ot.Client=function(t){"use strict";function e(t){this.revision=t,this.setState(a)}function n(){}function o(t){this.outstanding=t}function r(t,e){this.outstanding=t,this.buffer=e}function i(t,e,n){this.acknowlaged=t,this.client=e,this.revision=n}function s(t,e,n,o){this.acknowlaged=t,this.buffer=e,this.client=n,this.revision=o}e.prototype.setState=function(t){this.state=t},e.prototype.applyClient=function(t){this.setState(this.state.applyClient(this,t))},e.prototype.applyServer=function(t,e){this.setState(this.state.applyServer(this,t,e))},e.prototype.applyOperations=function(t,e){this.setState(this.state.applyOperations(this,t,e))},e.prototype.serverAck=function(t){this.setState(this.state.serverAck(this,t))},e.prototype.serverReconnect=function(){"function"==typeof this.state.resend&&this.state.resend(this)},e.prototype.transformSelection=function(t){return this.state.transformSelection(t)},e.prototype.sendOperation=function(t,e){throw new Error("sendOperation must be defined in child class")},e.prototype.applyOperation=function(t){throw new Error("applyOperation must be defined in child class")},e.Synchronized=n,n.prototype.applyClient=function(t,e){return t.sendOperation(t.revision,e),new o(e)},n.prototype.applyServer=function(t,e,n){if(e-t.revision>1)throw new Error("Invalid revision.");return t.revision=e,t.applyOperation(n),this},n.prototype.serverAck=function(t,e){throw new Error("There is no pending operation.")},n.prototype.transformSelection=function(t){return t};var a=new n;return e.AwaitingConfirm=o,o.prototype.applyClient=function(t,e){return new r(this.outstanding,e)},o.prototype.applyServer=function(t,e,n){if(e-t.revision>1)throw new Error("Invalid revision.");t.revision=e;var r=n.constructor.transform(this.outstanding,n);return t.applyOperation(r[1]),new o(r[0])},o.prototype.serverAck=function(t,e){return e-t.revision>1?new i(this.outstanding,t,e).getOperations():(t.revision=e,a)},o.prototype.transformSelection=function(t){return t.transform(this.outstanding)},o.prototype.resend=function(t){t.sendOperation(t.revision,this.outstanding)},e.AwaitingWithBuffer=r,r.prototype.applyClient=function(t,e){var n=this.buffer.compose(e);return new r(this.outstanding,n)},r.prototype.applyServer=function(t,e,n){if(e-t.revision>1)throw new Error("Invalid revision.");t.revision=e;var o=n.constructor.transform,i=o(this.outstanding,n),s=o(this.buffer,i[1]);return t.applyOperation(s[1]),new r(i[0],s[0])},r.prototype.serverAck=function(t,e){return e-t.revision>1?new s(this.outstanding,this.buffer,t,e).getOperations():(t.revision=e,t.sendOperation(t.revision,this.buffer),new o(this.buffer))},r.prototype.transformSelection=function(t){return t.transform(this.outstanding).transform(this.buffer)},r.prototype.resend=function(t){t.sendOperation(t.revision,this.outstanding)},e.Stale=i,i.prototype.applyClient=function(t,e){return new s(this.acknowlaged,e,t,this.revision)},i.prototype.applyServer=function(t,e,n){throw new Error("Ignored server-side change.")},i.prototype.applyOperations=function(t,e,n){for(var o=this.acknowlaged.constructor.transform,r=0;re.line?1:t.che.ch?1:0}function o(t,e){return n(t,e)<=0}function r(t,e){return o(t,e)?t:e}function i(t,e){return o(t,e)?e:t}function s(t){return t.indexFromPos({line:t.lastLine(),ch:0})+t.getLine(t.lastLine()).length}function a(t,e){var n=t[e];t[e]=function(){n.apply(t,arguments)}}var h=ot.TextOperation,p=ot.Selection;e.prototype.detach=function(){this.cm.off("changes",this.onChanges),this.cm.off("change",this.onChange),this.cm.off("cursorActivity",this.onCursorActivity),this.cm.off("focus",this.onFocus),this.cm.off("blur",this.onBlur)},e.operationFromCodeMirrorChanges=function(t,e){function n(t){return t[t.length-1]}function r(t){if(0===t.length)return 0;for(var e=0,n=0;n=0;u--){var f=t[u];l=i(l,f);var d=l(f.from),g=a-d-r(f.text);p=(new h).retain(d)["delete"](r(f.removed)).insert(f.text.join("\n")).retain(g).compose(p),c=c.compose((new h).retain(d)["delete"](r(f.text)).insert(f.removed.join("\n")).retain(g)),a+=r(f.removed)-r(f.text)}return[p,c]},e.operationFromCodeMirrorChange=e.operationFromCodeMirrorChanges,e.applyOperationToCodeMirror=function(t,e){e.operation(function(){for(var n=t.ops,o=0,r=0,i=n.length;i>r;r++){var s=n[r];if(h.isRetain(s))o+=s;else if(h.isInsert(s))e.replaceRange(s,e.posFromIndex(o),null,"ignoreHistory"),o+=s.length;else if(h.isDelete(s)){var a=e.posFromIndex(o),p=e.posFromIndex(o-s);e.replaceRange("",a,p,"ignoreHistory")}}})},e.prototype.registerCallbacks=function(t){this.callbacks=t},e.prototype.onChange=function(){this.changeInProgress=!0},e.prototype.onChanges=function(t,n){if(!this.ignoreNextChange){var o=e.operationFromCodeMirrorChanges(n,this.cm);this.trigger("change",o[0],o[1])}this.selectionChanged&&this.trigger("selectionChange"),this.changeInProgress=!1,this.ignoreNextChange=!1},e.prototype.onCursorActivity=e.prototype.onFocus=function(){this.changeInProgress?this.selectionChanged=!0:this.trigger("selectionChange")},e.prototype.onBlur=function(){this.cm.somethingSelected()||this.trigger("blur")},e.prototype.getValue=function(){return this.cm.getValue()},e.prototype.getSelection=function(){for(var t=this.cm,e=t.listSelections(),n=[],o=0;o0&&(this.majorRevision+=n.length,this.minorRevision=0);var o=t.events;if(o){for(e=0;en?n*(1+e):n+e-e*n,i=2*n-o,s=function(t){return 0>t&&(t+=1),t>1&&(t-=1),1>6*t?i+6*(o-i)*t:1>2*t?o:2>3*t?i+6*(o-i)*(2/3-t):i};return r(s(t+1/3),s(t),s(t-1/3))}function s(t){for(var e=1,n=0;n0&&n.shouldBeComposedWithInverted(h(this.undoManager.undoStack).wrapped)),s=new t(this.selection,o);this.undoManager.add(new d(n,s),i),this.applyClient(e)},o.prototype.updateSelection=function(){this.selection=this.editorAdapter.getSelection()},o.prototype.onSelectionChange=function(){var t=this.selection;this.updateSelection(),t&&this.selection.equals(t)||this.sendSelection(this.selection)},o.prototype.onBlur=function(){this.selection=null,this.sendSelection(null)},o.prototype.sendSelection=function(t){this.state instanceof c.AwaitingWithBuffer||this.serverAdapter.sendSelection(t)},o.prototype.sendOperation=function(t,e){this.serverAdapter.sendOperation(t,e.toJSON(),this.selection)},o.prototype.getOperations=function(t,e){this.serverAdapter.getOperations(t,e)},o.prototype.applyOperation=function(t){this.editorAdapter.applyOperation(t),this.updateSelection(),this.undoManager.transform(new d(t,null))},o}(); \ No newline at end of file diff --git a/public/vendor/ot/selection.js b/public/vendor/ot/selection.js new file mode 100755 index 00000000..72bf8bd6 --- /dev/null +++ b/public/vendor/ot/selection.js @@ -0,0 +1,117 @@ +if (typeof ot === 'undefined') { + // Export for browsers + var ot = {}; +} + +ot.Selection = (function (global) { + 'use strict'; + + var TextOperation = global.ot ? global.ot.TextOperation : require('./text-operation'); + + // Range has `anchor` and `head` properties, which are zero-based indices into + // the document. The `anchor` is the side of the selection that stays fixed, + // `head` is the side of the selection where the cursor is. When both are + // equal, the range represents a cursor. + function Range (anchor, head) { + this.anchor = anchor; + this.head = head; + } + + Range.fromJSON = function (obj) { + return new Range(obj.anchor, obj.head); + }; + + Range.prototype.equals = function (other) { + return this.anchor === other.anchor && this.head === other.head; + }; + + Range.prototype.isEmpty = function () { + return this.anchor === this.head; + }; + + Range.prototype.transform = function (other) { + function transformIndex (index) { + var newIndex = index; + var ops = other.ops; + for (var i = 0, l = other.ops.length; i < l; i++) { + if (TextOperation.isRetain(ops[i])) { + index -= ops[i]; + } else if (TextOperation.isInsert(ops[i])) { + newIndex += ops[i].length; + } else { + newIndex -= Math.min(index, -ops[i]); + index += ops[i]; + } + if (index < 0) { break; } + } + return newIndex; + } + + var newAnchor = transformIndex(this.anchor); + if (this.anchor === this.head) { + return new Range(newAnchor, newAnchor); + } + return new Range(newAnchor, transformIndex(this.head)); + }; + + // A selection is basically an array of ranges. Every range represents a real + // selection or a cursor in the document (when the start position equals the + // end position of the range). The array must not be empty. + function Selection (ranges) { + this.ranges = ranges || []; + } + + Selection.Range = Range; + + // Convenience method for creating selections only containing a single cursor + // and no real selection range. + Selection.createCursor = function (position) { + return new Selection([new Range(position, position)]); + }; + + Selection.fromJSON = function (obj) { + var objRanges = obj.ranges || obj; + for (var i = 0, ranges = []; i < objRanges.length; i++) { + ranges[i] = Range.fromJSON(objRanges[i]); + } + return new Selection(ranges); + }; + + Selection.prototype.equals = function (other) { + if (this.position !== other.position) { return false; } + if (this.ranges.length !== other.ranges.length) { return false; } + // FIXME: Sort ranges before comparing them? + for (var i = 0; i < this.ranges.length; i++) { + if (!this.ranges[i].equals(other.ranges[i])) { return false; } + } + return true; + }; + + Selection.prototype.somethingSelected = function () { + for (var i = 0; i < this.ranges.length; i++) { + if (!this.ranges[i].isEmpty()) { return true; } + } + return false; + }; + + // Return the more current selection information. + Selection.prototype.compose = function (other) { + return other; + }; + + // Update the selection with respect to an operation. + Selection.prototype.transform = function (other) { + for (var i = 0, newRanges = []; i < this.ranges.length; i++) { + newRanges[i] = this.ranges[i].transform(other); + } + return new Selection(newRanges); + }; + + return Selection; + +}(this)); + +// Export for CommonJS +if (typeof module === 'object') { + module.exports = ot.Selection; +} diff --git a/public/vendor/ot/socketio-adapter.js b/public/vendor/ot/socketio-adapter.js new file mode 100755 index 00000000..329d4f3e --- /dev/null +++ b/public/vendor/ot/socketio-adapter.js @@ -0,0 +1,66 @@ +/*global ot */ + +ot.SocketIOAdapter = (function () { + 'use strict'; + + function SocketIOAdapter(socket) { + this.socket = socket; + + var self = this; + socket.on('client_left', function (clientId) { + self.trigger('client_left', clientId); + }); + socket.on('set_name', function (clientId, name) { + self.trigger('set_name', clientId, name); + }); + socket.on('set_color', function (clientId, color) { + self.trigger('set_color', clientId, color); + }); + socket.on('ack', function (revision) { + self.trigger('ack', revision); + }); + socket.on('operation', function (clientId, revision, operation, selection) { + self.trigger('operation', revision, operation); + self.trigger('selection', clientId, selection); + }); + socket.on('operations', function (head, operations) { + operations = LZString.decompressFromUTF16(operations); + operations = JSON.parse(operations); + self.trigger('operations', head, operations); + }); + socket.on('selection', function (clientId, selection) { + self.trigger('selection', clientId, selection); + }); + socket.on('reconnect', function () { + self.trigger('reconnect'); + }); + } + + SocketIOAdapter.prototype.sendOperation = function (revision, operation, selection) { + operation = LZString.compressToUTF16(JSON.stringify(operation)); + this.socket.emit('operation', revision, operation, selection); + }; + + SocketIOAdapter.prototype.sendSelection = function (selection) { + this.socket.emit('selection', selection); + }; + + SocketIOAdapter.prototype.getOperations = function (base, head) { + this.socket.emit('get_operations', base, head); + }; + + SocketIOAdapter.prototype.registerCallbacks = function (cb) { + this.callbacks = cb; + }; + + SocketIOAdapter.prototype.trigger = function (event) { + var args = Array.prototype.slice.call(arguments, 1); + var action = this.callbacks && this.callbacks[event]; + if (action) { + action.apply(this, args); + } + }; + + return SocketIOAdapter; + +}()); \ No newline at end of file diff --git a/public/vendor/ot/text-operation.js b/public/vendor/ot/text-operation.js new file mode 100755 index 00000000..d5468497 --- /dev/null +++ b/public/vendor/ot/text-operation.js @@ -0,0 +1,530 @@ +if (typeof ot === 'undefined') { + // Export for browsers + var ot = {}; +} + +ot.TextOperation = (function () { + 'use strict'; + + // Constructor for new operations. + function TextOperation () { + if (!this || this.constructor !== TextOperation) { + // => function was called without 'new' + return new TextOperation(); + } + + // When an operation is applied to an input string, you can think of this as + // if an imaginary cursor runs over the entire string and skips over some + // parts, deletes some parts and inserts characters at some positions. These + // actions (skip/delete/insert) are stored as an array in the "ops" property. + this.ops = []; + // An operation's baseLength is the length of every string the operation + // can be applied to. + this.baseLength = 0; + // The targetLength is the length of every string that results from applying + // the operation on a valid input string. + this.targetLength = 0; + } + + TextOperation.prototype.equals = function (other) { + if (this.baseLength !== other.baseLength) { return false; } + if (this.targetLength !== other.targetLength) { return false; } + if (this.ops.length !== other.ops.length) { return false; } + for (var i = 0; i < this.ops.length; i++) { + if (this.ops[i] !== other.ops[i]) { return false; } + } + return true; + }; + + // Operation are essentially lists of ops. There are three types of ops: + // + // * Retain ops: Advance the cursor position by a given number of characters. + // Represented by positive ints. + // * Insert ops: Insert a given string at the current cursor position. + // Represented by strings. + // * Delete ops: Delete the next n characters. Represented by negative ints. + + var isRetain = TextOperation.isRetain = function (op) { + return typeof op === 'number' && op > 0; + }; + + var isInsert = TextOperation.isInsert = function (op) { + return typeof op === 'string'; + }; + + var isDelete = TextOperation.isDelete = function (op) { + return typeof op === 'number' && op < 0; + }; + + + // After an operation is constructed, the user of the library can specify the + // actions of an operation (skip/insert/delete) with these three builder + // methods. They all return the operation for convenient chaining. + + // Skip over a given number of characters. + TextOperation.prototype.retain = function (n) { + if (typeof n !== 'number') { + throw new Error("retain expects an integer"); + } + if (n === 0) { return this; } + this.baseLength += n; + this.targetLength += n; + if (isRetain(this.ops[this.ops.length-1])) { + // The last op is a retain op => we can merge them into one op. + this.ops[this.ops.length-1] += n; + } else { + // Create a new op. + this.ops.push(n); + } + return this; + }; + + // Insert a string at the current position. + TextOperation.prototype.insert = function (str) { + if (typeof str !== 'string') { + throw new Error("insert expects a string"); + } + if (str === '') { return this; } + this.targetLength += str.length; + var ops = this.ops; + if (isInsert(ops[ops.length-1])) { + // Merge insert op. + ops[ops.length-1] += str; + } else if (isDelete(ops[ops.length-1])) { + // It doesn't matter when an operation is applied whether the operation + // is delete(3), insert("something") or insert("something"), delete(3). + // Here we enforce that in this case, the insert op always comes first. + // This makes all operations that have the same effect when applied to + // a document of the right length equal in respect to the `equals` method. + if (isInsert(ops[ops.length-2])) { + ops[ops.length-2] += str; + } else { + ops[ops.length] = ops[ops.length-1]; + ops[ops.length-2] = str; + } + } else { + ops.push(str); + } + return this; + }; + + // Delete a string at the current position. + TextOperation.prototype['delete'] = function (n) { + if (typeof n === 'string') { n = n.length; } + if (typeof n !== 'number') { + throw new Error("delete expects an integer or a string"); + } + if (n === 0) { return this; } + if (n > 0) { n = -n; } + this.baseLength -= n; + if (isDelete(this.ops[this.ops.length-1])) { + this.ops[this.ops.length-1] += n; + } else { + this.ops.push(n); + } + return this; + }; + + // Tests whether this operation has no effect. + TextOperation.prototype.isNoop = function () { + return this.ops.length === 0 || (this.ops.length === 1 && isRetain(this.ops[0])); + }; + + // Pretty printing. + TextOperation.prototype.toString = function () { + // map: build a new array by applying a function to every element in an old + // array. + var map = Array.prototype.map || function (fn) { + var arr = this; + var newArr = []; + for (var i = 0, l = arr.length; i < l; i++) { + newArr[i] = fn(arr[i]); + } + return newArr; + }; + return map.call(this.ops, function (op) { + if (isRetain(op)) { + return "retain " + op; + } else if (isInsert(op)) { + return "insert '" + op + "'"; + } else { + return "delete " + (-op); + } + }).join(', '); + }; + + // Converts operation into a JSON value. + TextOperation.prototype.toJSON = function () { + return this.ops; + }; + + // Converts a plain JS object into an operation and validates it. + TextOperation.fromJSON = function (ops) { + var o = new TextOperation(); + for (var i = 0, l = ops.length; i < l; i++) { + var op = ops[i]; + if (isRetain(op)) { + o.retain(op); + } else if (isInsert(op)) { + o.insert(op); + } else if (isDelete(op)) { + o['delete'](op); + } else { + throw new Error("unknown operation: " + JSON.stringify(op)); + } + } + return o; + }; + + // Apply an operation to a string, returning a new string. Throws an error if + // there's a mismatch between the input string and the operation. + TextOperation.prototype.apply = function (str) { + var operation = this; + if (str.length !== operation.baseLength) { + throw new Error("The operation's base length must be equal to the string's length."); + } + var newStr = [], j = 0; + var strIndex = 0; + var ops = this.ops; + for (var i = 0, l = ops.length; i < l; i++) { + var op = ops[i]; + if (isRetain(op)) { + if (strIndex + op > str.length) { + throw new Error("Operation can't retain more characters than are left in the string."); + } + // Copy skipped part of the old string. + newStr[j++] = str.slice(strIndex, strIndex + op); + strIndex += op; + } else if (isInsert(op)) { + // Insert string. + newStr[j++] = op; + } else { // delete op + strIndex -= op; + } + } + if (strIndex !== str.length) { + throw new Error("The operation didn't operate on the whole string."); + } + return newStr.join(''); + }; + + // Computes the inverse of an operation. The inverse of an operation is the + // operation that reverts the effects of the operation, e.g. when you have an + // operation 'insert("hello "); skip(6);' then the inverse is 'delete("hello "); + // skip(6);'. The inverse should be used for implementing undo. + TextOperation.prototype.invert = function (str) { + var strIndex = 0; + var inverse = new TextOperation(); + var ops = this.ops; + for (var i = 0, l = ops.length; i < l; i++) { + var op = ops[i]; + if (isRetain(op)) { + inverse.retain(op); + strIndex += op; + } else if (isInsert(op)) { + inverse['delete'](op.length); + } else { // delete op + inverse.insert(str.slice(strIndex, strIndex - op)); + strIndex -= op; + } + } + return inverse; + }; + + // Compose merges two consecutive operations into one operation, that + // preserves the changes of both. Or, in other words, for each input string S + // and a pair of consecutive operations A and B, + // apply(apply(S, A), B) = apply(S, compose(A, B)) must hold. + TextOperation.prototype.compose = function (operation2) { + var operation1 = this; + if (operation1.targetLength !== operation2.baseLength) { + throw new Error("The base length of the second operation has to be the target length of the first operation"); + } + + var operation = new TextOperation(); // the combined operation + var ops1 = operation1.ops, ops2 = operation2.ops; // for fast access + var i1 = 0, i2 = 0; // current index into ops1 respectively ops2 + var op1 = ops1[i1++], op2 = ops2[i2++]; // current ops + while (true) { + // Dispatch on the type of op1 and op2 + if (typeof op1 === 'undefined' && typeof op2 === 'undefined') { + // end condition: both ops1 and ops2 have been processed + break; + } + + if (isDelete(op1)) { + operation['delete'](op1); + op1 = ops1[i1++]; + continue; + } + if (isInsert(op2)) { + operation.insert(op2); + op2 = ops2[i2++]; + continue; + } + + if (typeof op1 === 'undefined') { + throw new Error("Cannot compose operations: first operation is too short."); + } + if (typeof op2 === 'undefined') { + throw new Error("Cannot compose operations: first operation is too long."); + } + + if (isRetain(op1) && isRetain(op2)) { + if (op1 > op2) { + operation.retain(op2); + op1 = op1 - op2; + op2 = ops2[i2++]; + } else if (op1 === op2) { + operation.retain(op1); + op1 = ops1[i1++]; + op2 = ops2[i2++]; + } else { + operation.retain(op1); + op2 = op2 - op1; + op1 = ops1[i1++]; + } + } else if (isInsert(op1) && isDelete(op2)) { + if (op1.length > -op2) { + op1 = op1.slice(-op2); + op2 = ops2[i2++]; + } else if (op1.length === -op2) { + op1 = ops1[i1++]; + op2 = ops2[i2++]; + } else { + op2 = op2 + op1.length; + op1 = ops1[i1++]; + } + } else if (isInsert(op1) && isRetain(op2)) { + if (op1.length > op2) { + operation.insert(op1.slice(0, op2)); + op1 = op1.slice(op2); + op2 = ops2[i2++]; + } else if (op1.length === op2) { + operation.insert(op1); + op1 = ops1[i1++]; + op2 = ops2[i2++]; + } else { + operation.insert(op1); + op2 = op2 - op1.length; + op1 = ops1[i1++]; + } + } else if (isRetain(op1) && isDelete(op2)) { + if (op1 > -op2) { + operation['delete'](op2); + op1 = op1 + op2; + op2 = ops2[i2++]; + } else if (op1 === -op2) { + operation['delete'](op2); + op1 = ops1[i1++]; + op2 = ops2[i2++]; + } else { + operation['delete'](op1); + op2 = op2 + op1; + op1 = ops1[i1++]; + } + } else { + throw new Error( + "This shouldn't happen: op1: " + + JSON.stringify(op1) + ", op2: " + + JSON.stringify(op2) + ); + } + } + return operation; + }; + + function getSimpleOp (operation, fn) { + var ops = operation.ops; + var isRetain = TextOperation.isRetain; + switch (ops.length) { + case 1: + return ops[0]; + case 2: + return isRetain(ops[0]) ? ops[1] : (isRetain(ops[1]) ? ops[0] : null); + case 3: + if (isRetain(ops[0]) && isRetain(ops[2])) { return ops[1]; } + } + return null; + } + + function getStartIndex (operation) { + if (isRetain(operation.ops[0])) { return operation.ops[0]; } + return 0; + } + + // When you use ctrl-z to undo your latest changes, you expect the program not + // to undo every single keystroke but to undo your last sentence you wrote at + // a stretch or the deletion you did by holding the backspace key down. This + // This can be implemented by composing operations on the undo stack. This + // method can help decide whether two operations should be composed. It + // returns true if the operations are consecutive insert operations or both + // operations delete text at the same position. You may want to include other + // factors like the time since the last change in your decision. + TextOperation.prototype.shouldBeComposedWith = function (other) { + if (this.isNoop() || other.isNoop()) { return true; } + + var startA = getStartIndex(this), startB = getStartIndex(other); + var simpleA = getSimpleOp(this), simpleB = getSimpleOp(other); + if (!simpleA || !simpleB) { return false; } + + if (isInsert(simpleA) && isInsert(simpleB)) { + return startA + simpleA.length === startB; + } + + if (isDelete(simpleA) && isDelete(simpleB)) { + // there are two possibilities to delete: with backspace and with the + // delete key. + return (startB - simpleB === startA) || startA === startB; + } + + return false; + }; + + // Decides whether two operations should be composed with each other + // if they were inverted, that is + // `shouldBeComposedWith(a, b) = shouldBeComposedWithInverted(b^{-1}, a^{-1})`. + TextOperation.prototype.shouldBeComposedWithInverted = function (other) { + if (this.isNoop() || other.isNoop()) { return true; } + + var startA = getStartIndex(this), startB = getStartIndex(other); + var simpleA = getSimpleOp(this), simpleB = getSimpleOp(other); + if (!simpleA || !simpleB) { return false; } + + if (isInsert(simpleA) && isInsert(simpleB)) { + return startA + simpleA.length === startB || startA === startB; + } + + if (isDelete(simpleA) && isDelete(simpleB)) { + return startB - simpleB === startA; + } + + return false; + }; + + // Transform takes two operations A and B that happened concurrently and + // produces two operations A' and B' (in an array) such that + // `apply(apply(S, A), B') = apply(apply(S, B), A')`. This function is the + // heart of OT. + TextOperation.transform = function (operation1, operation2) { + if (operation1.baseLength !== operation2.baseLength) { + throw new Error("Both operations have to have the same base length"); + } + + var operation1prime = new TextOperation(); + var operation2prime = new TextOperation(); + var ops1 = operation1.ops, ops2 = operation2.ops; + var i1 = 0, i2 = 0; + var op1 = ops1[i1++], op2 = ops2[i2++]; + while (true) { + // At every iteration of the loop, the imaginary cursor that both + // operation1 and operation2 have that operates on the input string must + // have the same position in the input string. + + if (typeof op1 === 'undefined' && typeof op2 === 'undefined') { + // end condition: both ops1 and ops2 have been processed + break; + } + + // next two cases: one or both ops are insert ops + // => insert the string in the corresponding prime operation, skip it in + // the other one. If both op1 and op2 are insert ops, prefer op1. + if (isInsert(op1)) { + operation1prime.insert(op1); + operation2prime.retain(op1.length); + op1 = ops1[i1++]; + continue; + } + if (isInsert(op2)) { + operation1prime.retain(op2.length); + operation2prime.insert(op2); + op2 = ops2[i2++]; + continue; + } + + if (typeof op1 === 'undefined') { + throw new Error("Cannot compose operations: first operation is too short."); + } + if (typeof op2 === 'undefined') { + throw new Error("Cannot compose operations: first operation is too long."); + } + + var minl; + if (isRetain(op1) && isRetain(op2)) { + // Simple case: retain/retain + if (op1 > op2) { + minl = op2; + op1 = op1 - op2; + op2 = ops2[i2++]; + } else if (op1 === op2) { + minl = op2; + op1 = ops1[i1++]; + op2 = ops2[i2++]; + } else { + minl = op1; + op2 = op2 - op1; + op1 = ops1[i1++]; + } + operation1prime.retain(minl); + operation2prime.retain(minl); + } else if (isDelete(op1) && isDelete(op2)) { + // Both operations delete the same string at the same position. We don't + // need to produce any operations, we just skip over the delete ops and + // handle the case that one operation deletes more than the other. + if (-op1 > -op2) { + op1 = op1 - op2; + op2 = ops2[i2++]; + } else if (op1 === op2) { + op1 = ops1[i1++]; + op2 = ops2[i2++]; + } else { + op2 = op2 - op1; + op1 = ops1[i1++]; + } + // next two cases: delete/retain and retain/delete + } else if (isDelete(op1) && isRetain(op2)) { + if (-op1 > op2) { + minl = op2; + op1 = op1 + op2; + op2 = ops2[i2++]; + } else if (-op1 === op2) { + minl = op2; + op1 = ops1[i1++]; + op2 = ops2[i2++]; + } else { + minl = -op1; + op2 = op2 + op1; + op1 = ops1[i1++]; + } + operation1prime['delete'](minl); + } else if (isRetain(op1) && isDelete(op2)) { + if (op1 > -op2) { + minl = -op2; + op1 = op1 + op2; + op2 = ops2[i2++]; + } else if (op1 === -op2) { + minl = op1; + op1 = ops1[i1++]; + op2 = ops2[i2++]; + } else { + minl = op1; + op2 = op2 + op1; + op1 = ops1[i1++]; + } + operation2prime['delete'](minl); + } else { + throw new Error("The two operations aren't compatible"); + } + } + + return [operation1prime, operation2prime]; + }; + + return TextOperation; + +}()); + +// Export for CommonJS +if (typeof module === 'object') { + module.exports = ot.TextOperation; +} \ No newline at end of file diff --git a/public/vendor/ot/undo-manager.js b/public/vendor/ot/undo-manager.js new file mode 100755 index 00000000..19e89f18 --- /dev/null +++ b/public/vendor/ot/undo-manager.js @@ -0,0 +1,111 @@ +if (typeof ot === 'undefined') { + // Export for browsers + var ot = {}; +} + +ot.UndoManager = (function () { + 'use strict'; + + var NORMAL_STATE = 'normal'; + var UNDOING_STATE = 'undoing'; + var REDOING_STATE = 'redoing'; + + // Create a new UndoManager with an optional maximum history size. + function UndoManager (maxItems) { + this.maxItems = maxItems || 50; + this.state = NORMAL_STATE; + this.dontCompose = false; + this.undoStack = []; + this.redoStack = []; + } + + // Add an operation to the undo or redo stack, depending on the current state + // of the UndoManager. The operation added must be the inverse of the last + // edit. When `compose` is true, compose the operation with the last operation + // unless the last operation was alread pushed on the redo stack or was hidden + // by a newer operation on the undo stack. + UndoManager.prototype.add = function (operation, compose) { + if (this.state === UNDOING_STATE) { + this.redoStack.push(operation); + this.dontCompose = true; + } else if (this.state === REDOING_STATE) { + this.undoStack.push(operation); + this.dontCompose = true; + } else { + var undoStack = this.undoStack; + if (!this.dontCompose && compose && undoStack.length > 0) { + undoStack.push(operation.compose(undoStack.pop())); + } else { + undoStack.push(operation); + if (undoStack.length > this.maxItems) { undoStack.shift(); } + } + this.dontCompose = false; + this.redoStack = []; + } + }; + + function transformStack (stack, operation) { + var newStack = []; + var Operation = operation.constructor; + for (var i = stack.length - 1; i >= 0; i--) { + var pair = Operation.transform(stack[i], operation); + if (typeof pair[0].isNoop !== 'function' || !pair[0].isNoop()) { + newStack.push(pair[0]); + } + operation = pair[1]; + } + return newStack.reverse(); + } + + // Transform the undo and redo stacks against a operation by another client. + UndoManager.prototype.transform = function (operation) { + this.undoStack = transformStack(this.undoStack, operation); + this.redoStack = transformStack(this.redoStack, operation); + }; + + // Perform an undo by calling a function with the latest operation on the undo + // stack. The function is expected to call the `add` method with the inverse + // of the operation, which pushes the inverse on the redo stack. + UndoManager.prototype.performUndo = function (fn) { + this.state = UNDOING_STATE; + if (this.undoStack.length === 0) { throw new Error("undo not possible"); } + fn(this.undoStack.pop()); + this.state = NORMAL_STATE; + }; + + // The inverse of `performUndo`. + UndoManager.prototype.performRedo = function (fn) { + this.state = REDOING_STATE; + if (this.redoStack.length === 0) { throw new Error("redo not possible"); } + fn(this.redoStack.pop()); + this.state = NORMAL_STATE; + }; + + // Is the undo stack not empty? + UndoManager.prototype.canUndo = function () { + return this.undoStack.length !== 0; + }; + + // Is the redo stack not empty? + UndoManager.prototype.canRedo = function () { + return this.redoStack.length !== 0; + }; + + // Whether the UndoManager is currently performing an undo. + UndoManager.prototype.isUndoing = function () { + return this.state === UNDOING_STATE; + }; + + // Whether the UndoManager is currently performing a redo. + UndoManager.prototype.isRedoing = function () { + return this.state === REDOING_STATE; + }; + + return UndoManager; + +}()); + +// Export for CommonJS +if (typeof module === 'object') { + module.exports = ot.UndoManager; +} diff --git a/public/vendor/ot/wrapped-operation.js b/public/vendor/ot/wrapped-operation.js new file mode 100755 index 00000000..91050f4e --- /dev/null +++ b/public/vendor/ot/wrapped-operation.js @@ -0,0 +1,80 @@ +if (typeof ot === 'undefined') { + // Export for browsers + var ot = {}; +} + +ot.WrappedOperation = (function (global) { + 'use strict'; + + // A WrappedOperation contains an operation and corresponing metadata. + function WrappedOperation (operation, meta) { + this.wrapped = operation; + this.meta = meta; + } + + WrappedOperation.prototype.apply = function () { + return this.wrapped.apply.apply(this.wrapped, arguments); + }; + + WrappedOperation.prototype.invert = function () { + var meta = this.meta; + return new WrappedOperation( + this.wrapped.invert.apply(this.wrapped, arguments), + meta && typeof meta === 'object' && typeof meta.invert === 'function' ? + meta.invert.apply(meta, arguments) : meta + ); + }; + + // Copy all properties from source to target. + function copy (source, target) { + for (var key in source) { + if (source.hasOwnProperty(key)) { + target[key] = source[key]; + } + } + } + + function composeMeta (a, b) { + if (a && typeof a === 'object') { + if (typeof a.compose === 'function') { return a.compose(b); } + var meta = {}; + copy(a, meta); + copy(b, meta); + return meta; + } + return b; + } + + WrappedOperation.prototype.compose = function (other) { + return new WrappedOperation( + this.wrapped.compose(other.wrapped), + composeMeta(this.meta, other.meta) + ); + }; + + function transformMeta (meta, operation) { + if (meta && typeof meta === 'object') { + if (typeof meta.transform === 'function') { + return meta.transform(operation); + } + } + return meta; + } + + WrappedOperation.transform = function (a, b) { + var transform = a.wrapped.constructor.transform; + var pair = transform(a.wrapped, b.wrapped); + return [ + new WrappedOperation(pair[0], transformMeta(a.meta, b.wrapped)), + new WrappedOperation(pair[1], transformMeta(b.meta, a.wrapped)) + ]; + }; + + return WrappedOperation; + +}(this)); + +// Export for CommonJS +if (typeof module === 'object') { + module.exports = ot.WrappedOperation; +} \ No newline at end of file diff --git a/public/views/body.ejs b/public/views/body.ejs index bf713cba..1367d9f9 100644 --- a/public/views/body.ejs +++ b/public/views/body.ejs @@ -58,7 +58,7 @@