diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..e29f5e504 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = LF +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true diff --git a/.travis.yml b/.travis.yml index 7bd066b20..736e5fe78 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,10 +1,8 @@ language: node_js node_js: - "10" - - "9" - "8" - "6" - - "4" -script: "npm run jshint && npm run test-cover" +script: "ln -s .. node_modules/sharedb; npm run jshint && npm run test-cover" # Send coverage data to Coveralls after_script: "cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js" diff --git a/README.md b/README.md index 6e65f0187..77bea5808 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ tracker](https://github.com/share/sharedb/issues). - Realtime synchronization of any JSON document - Concurrent multi-user collaboration +- Local undo and redo - Synchronous editing API with asynchronous eventual consistency - Realtime query subscriptions - Simple integration with any database - [MongoDB](https://github.com/share/sharedb-mongo), [PostgresQL](https://github.com/share/sharedb-postgres) (experimental) @@ -38,7 +39,7 @@ var socket = new WebSocket('ws://' + window.location.host); var connection = new sharedb.Connection(socket); ``` -The native Websocket object that you feed to ShareDB's `Connection` constructor **does not** handle reconnections. +The native Websocket object that you feed to ShareDB's `Connection` constructor **does not** handle reconnections. The easiest way is to give it a WebSocket object that does reconnect. There are plenty of example on the web. The most important thing is that the custom reconnecting websocket, must have the same API as the native rfc6455 version. @@ -228,9 +229,15 @@ changes. Returns a [`ShareDB.Query`](#class-sharedbquery) instance. * `options.*` All other options are passed through to the database adapter. +`connection.createUndoManager(options)` creates a new `UndoManager`. + +* `options.source` if specified, only the operations from that `source` will be undo-able. If `null` or `undefined`, the `source` filter is disabled. +* `options.limit` the max number of operations to keep on the undo stack. +* `options.composeInterval` the max time difference between operations in milliseconds, which still allows the operations to be composed on the undo stack. + ### Class: `ShareDB.Doc` -`doc.type` _(String_) +`doc.type` _(String)_ The [OT type](https://github.com/ottypes/docs) of this document `doc.id` _(String)_ @@ -287,6 +294,19 @@ Apply operation to document and send it to the server. [operations for the default `'ot-json0'` type](https://github.com/ottypes/json0#summary-of-operations). Call this after you've either fetched or subscribed to the document. * `options.source` Argument passed to the `'op'` event locally. This is not sent to the server or other clients. Defaults to `true`. +* `options.skipNoop` Should processing be skipped entirely, if `op` is a no-op. Defaults to `false`. +* `options.undoable` Should it be possible to undo this operation. Defaults to `false`. +* `options.fixUp` If true, this operation is meant to fix the current invalid state of the snapshot. It also updates UndoManagers accordingly. This feature requires the OT type to implement `compose`. + +`doc.submitSnapshot(snapshot[, options][, function(err) {...}])` +Diff the current and the provided snapshots to generate an operation, apply the operation to the document and send it to the server. +`snapshot` structure depends on the document type. +Call this after you've either fetched or subscribed to the document. +* `options.source` Argument passed to the `'op'` event locally. This is not sent to the server or other clients. Defaults to `true`. +* `options.skipNoop` Should processing be skipped entirely, if `op` is a no-op. Defaults to `false`. +* `options.undoable` Should it be possible to undo this operation. Defaults to `false`. +* `options.fixUp` If true, this operation is meant to fix the current invalid state of the snapshot. It also updates UndoManagers accordingly. This feature requires the OT type to implement `compose`. +* `options.diffHint` A hint passed into the `diff`/`diffX` functions defined by the document type. `doc.del([options][, function(err) {...}])` Delete the document locally and send delete operation to the server. @@ -338,6 +358,28 @@ after a sequence of diffs are handled. `query.on('extra', function() {...}))` (Only fires on subscription queries) `query.extra` changed. +### Class: `ShareDB.UndoManager` + +`undoManager.canUndo()` +Return `true`, if there's an operation on the undo stack that can be undone, otherwise `false`. + +`undoManager.undo([options][, function(err) {...}])` +Undo a previously applied undoable or redo operation. +* `options.source` Argument passed to the `'op'` event locally. This is not sent to the server or other clients. Defaults to `true`. + +`undoManager.canRedo()` +Return `true`, if there's an operation on the redo stack that can be undone, otherwise `false`. + +`undoManager.redo([options][, function(err) {...}])` +Redo a previously applied undo operation. +* `options.source` Argument passed to the `'op'` event locally. This is not sent to the server or other clients. Defaults to `true`. + +`undoManager.clear(doc)` +Remove operations from the undo and redo stacks. +* `doc` if specified, only the operations on that doc are removed, otherwise all operations are removed. + +`undoManager.destroy()` +Remove all operations from the undo and redo stacks, and stop recording new operations. ## Error codes @@ -376,6 +418,8 @@ Additional fields may be added to the error object for debugging context dependi * 4021 - Invalid client id * 4022 - Database adapter does not support queries * 4023 - Cannot project snapshots of this type +* 4024 - OT Type does not support `diff` nor `diffX` +* 4025 - OT Type does not support `invert` nor `applyAndInvert` ### 5000 - Internal error diff --git a/lib/client/connection.js b/lib/client/connection.js index f4cc298e6..cbef375aa 100644 --- a/lib/client/connection.js +++ b/lib/client/connection.js @@ -1,5 +1,6 @@ var Doc = require('./doc'); var Query = require('./query'); +var UndoManager = require('./undoManager'); var emitter = require('../emitter'); var ShareDBError = require('../error'); var types = require('../types'); @@ -33,6 +34,9 @@ function Connection(socket) { // (created documents MUST BE UNIQUE) this.collections = {}; + // A list of active UndoManagers. + this.undoManagers = []; + // Each query is created with an id that the server uses when it sends us // info about the query (updates, etc) this.nextQueryId = 1; @@ -584,3 +588,46 @@ Connection.prototype._firstQuery = function(fn) { } } }; + +Connection.prototype.createUndoManager = function(options) { + var undoManager = new UndoManager(this, options); + this.undoManagers.push(undoManager); + return undoManager; +}; + +Connection.prototype.removeUndoManager = function(undoManager) { + var index = this.undoManagers.indexOf(undoManager); + if (index >= 0) { + this.undoManagers.splice(index, 1); + } +}; + +Connection.prototype.onDocLoad = function(doc) { + for (var i = 0; i < this.undoManagers.length; i++) { + this.undoManagers[i].onDocLoad(doc); + } +}; + +Connection.prototype.onDocDestroy = function(doc) { + for (var i = 0; i < this.undoManagers.length; i++) { + this.undoManagers[i].onDocDestroy(doc); + } +}; + +Connection.prototype.onDocCreate = function(doc) { + for (var i = 0; i < this.undoManagers.length; i++) { + this.undoManagers[i].onDocCreate(doc); + } +}; + +Connection.prototype.onDocDelete = function(doc) { + for (var i = 0; i < this.undoManagers.length; i++) { + this.undoManagers[i].onDocDelete(doc); + } +}; + +Connection.prototype.onDocOp = function(doc, op, undoOp, source, undoable, fixUp) { + for (var i = 0; i < this.undoManagers.length; i++) { + this.undoManagers[i].onDocOp(doc, op, undoOp, source, undoable, fixUp); + } +}; diff --git a/lib/client/doc.js b/lib/client/doc.js index 05e17976d..3848ca577 100644 --- a/lib/client/doc.js +++ b/lib/client/doc.js @@ -104,11 +104,22 @@ emitter.mixin(Doc); Doc.prototype.destroy = function(callback) { var doc = this; doc.whenNothingPending(function() { - doc.connection._destroyDoc(doc); if (doc.wantSubscribe) { - return doc.unsubscribe(callback); + doc.unsubscribe(function(err) { + if (err) { + if (callback) callback(err); + else this.emit('error', err); + return; + } + doc.connection._destroyDoc(doc); + doc.connection.onDocDestroy(doc); + if (callback) callback(); + }); + } else { + doc.connection._destroyDoc(doc); + doc.connection.onDocDestroy(doc); + if (callback) callback(); } - if (callback) callback(); }); }; @@ -191,6 +202,7 @@ Doc.prototype.ingestSnapshot = function(snapshot, callback) { this.data = (this.type && this.type.deserialize) ? this.type.deserialize(snapshot.data) : snapshot.data; + this.connection.onDocLoad(this); this.emit('load'); callback && callback(); }; @@ -318,7 +330,7 @@ Doc.prototype._handleOp = function(err, message) { } this.version++; - this._otApply(message, false); + this._otApply(message); return; }; @@ -500,12 +512,16 @@ function transformX(client, server) { * * @private */ -Doc.prototype._otApply = function(op, source) { +Doc.prototype._otApply = function(op, options) { + var source = options && options.source || false; if (op.op) { if (!this.type) { var err = new ShareDBError(4015, 'Cannot apply op to uncreated document. ' + this.collection + '.' + this.id); return this.emit('error', err); } + var undoOp = options && options.undoOp || null; + var undoable = options && options.undoable || false; + var fixUp = options && options.fixUp || false; // Iteratively apply multi-component remote operations and rollback ops // (source === false) for the default JSON0 OT type. It could use @@ -537,7 +553,7 @@ Doc.prototype._otApply = function(op, source) { } // Apply the individual op component this.emit('before op', componentOp.op, source); - this.data = this.type.apply(this.data, componentOp.op); + this._applyOp(componentOp, undoOp, source, undoable, fixUp); this.emit('op', componentOp.op, source); } // Pop whatever was submitted since we started applying this op @@ -549,7 +565,7 @@ Doc.prototype._otApply = function(op, source) { // the snapshot before it gets changed this.emit('before op', op.op, source); // Apply the operation to the local data, mutating it in place - this.data = this.type.apply(this.data, op.op); + this._applyOp(op, undoOp, source, undoable, fixUp); // Emit an 'op' event once the local data includes the changes from the // op. For locally submitted ops, this will be synchronously with // submission and before the server or other clients have received the op. @@ -566,6 +582,7 @@ Doc.prototype._otApply = function(op, source) { this.type.createDeserialized(op.create.data) : this.type.deserialize(this.type.create(op.create.data)) : this.type.create(op.create.data); + this.connection.onDocCreate(this); this.emit('create', source); return; } @@ -573,11 +590,29 @@ Doc.prototype._otApply = function(op, source) { if (op.del) { var oldData = this.data; this._setType(null); + this.connection.onDocDelete(this); this.emit('del', oldData, source); return; } }; +// Applies `op` to `this.data` and updates the undo/redo stacks. +Doc.prototype._applyOp = function(op, undoOp, source, undoable, fixUp) { + if (undoOp == null && (undoable || fixUp || op.needsUndoOp)) { + if (this.type.applyAndInvert) { + var result = this.type.applyAndInvert(this.data, op.op); + this.data = result[0]; + undoOp = { op: result[1] }; + } else { + this.data = this.type.apply(this.data, op.op); + undoOp = { op: this.type.invert(op.op) }; + } + } else { + this.data = this.type.apply(this.data, op.op); + } + + this.connection.onDocOp(this, op, undoOp, source, undoable, fixUp); +}; // ***** Sending operations @@ -630,10 +665,13 @@ Doc.prototype._sendOp = function() { // @param [op.op] // @param [op.del] // @param [op.create] +// @param options { source, skipNoop, undoable, undoOp, fixUp } // @param [callback] called when operation is submitted -Doc.prototype._submit = function(op, source, callback) { +Doc.prototype._submit = function(op, options, callback) { + if (!options) options = {}; + // Locally submitted ops must always have a truthy source - if (!source) source = true; + if (!options.source) options.source = true; // The op contains either op, create, delete, or none of the above (a no-op). if (op.op) { @@ -642,12 +680,23 @@ Doc.prototype._submit = function(op, source, callback) { if (callback) return callback(err); return this.emit('error', err); } + var needsUndoOp = options.undoable || options.fixUp || op.needsUndoOp; + if (needsUndoOp && !this.type.invert && !this.type.applyAndInvert) { + var err = new ShareDBError(4025, 'Cannot submit op. OT type does not support invert not applyAndInvert. ' + this.collection + '.' + this.id); + if (callback) return callback(err); + return this.emit('error', err); + } // Try to normalize the op. This removes trailing skip:0's and things like that. if (this.type.normalize) op.op = this.type.normalize(op.op); + // Try to skip processing no-ops. + if (options.skipNoop && this.type.isNoop && this.type.isNoop(op.op)) { + if (callback) process.nextTick(callback); + return; + } } this._pushOp(op, callback); - this._otApply(op, source); + this._otApply(op, options); // The call to flush is delayed so if submit() is called multiple times // synchronously, all the ops are combined before being sent to the server. @@ -733,19 +782,85 @@ Doc.prototype._tryCompose = function(op) { // Submit an operation to the document. // -// @param operation handled by the OT type -// @param options {source: ...} +// @param component operation handled by the OT type +// @param options.source passed into 'op' event handler +// @param options.skipNoop should processing be skipped entirely, if `component` is a no-op. +// @param options.undoable should the operation be undoable +// @param options.fixUp If true, this operation is meant to fix the current invalid state of the snapshot. +// It also updates UndoManagers accordingly. This feature requires the OT type to implement `compose`. // @param [callback] called after operation submitted // -// @fires before op, op, after op +// @fires before op, op Doc.prototype.submitOp = function(component, options, callback) { if (typeof options === 'function') { callback = options; options = null; } var op = {op: component}; - var source = options && options.source; - this._submit(op, source, callback); + var submitOptions = { + source: options && options.source, + skipNoop: options && options.skipNoop, + undoable: options && options.undoable, + fixUp: options && options.fixUp + }; + this._submit(op, submitOptions, callback); +}; + +// Submits new content for the document. +// +// This function works only if the type supports `diff` or `diffX`. +// It diffs the current and new snapshot to generate an operation, +// which is then submitted as usual. +// +// @param snapshot new snapshot data +// @param options.source passed into 'op' event handler +// @param options.skipNoop should processing be skipped entirely, if the generated operation is a no-op. +// @param options.undoable should the operation be undoable +// @param options.fixUp If true, this operation is meant to fix the current invalid state of the snapshot. +// It also updates UndoManagers accordingly. This feature requires the OT type to implement `compose`. +// @param options.diffHint a hint passed into diff/diffX +// @param [callback] called after operation submitted + +// @fires before op, op +Doc.prototype.submitSnapshot = function(snapshot, options, callback) { + if (typeof options === 'function') { + callback = options; + options = null; + } + if (!this.type) { + var err = new ShareDBError(4015, 'Cannot submit snapshot. Document has not been created. ' + this.collection + '.' + this.id); + if (callback) return callback(err); + return this.emit('error', err); + } + if (!this.type.diff && !this.type.diffX) { + var err = new ShareDBError(4024, 'Cannot submit snapshot. Document type does not support diff nor diffX. ' + this.collection + '.' + this.id); + if (callback) return callback(err); + return this.emit('error', err); + } + + var undoable = !!(options && options.undoable); + var fixUp = options && options.fixUp; + var diffHint = options && options.diffHint; + var needsUndoOp = undoable || fixUp; + var op, undoOp; + + if ((needsUndoOp && this.type.diffX) || !this.type.diff) { + var diffs = this.type.diffX(this.data, snapshot, diffHint); + undoOp = { op: diffs[0] }; + op = { op: diffs[1] }; + } else { + undoOp = null; + op = { op: this.type.diff(this.data, snapshot, diffHint) }; + } + + var submitOptions = { + source: options && options.source, + skipNoop: options && options.skipNoop, + undoable: undoable, + undoOp: undoOp, + fixUp: fixUp + }; + this._submit(op, submitOptions, callback); }; // Create the document, which in ShareJS semantics means to set its type. Every @@ -776,7 +891,7 @@ Doc.prototype.create = function(data, type, options, callback) { } var op = {create: {type: type, data: data}}; var source = options && options.source; - this._submit(op, source, callback); + this._submit(op, { source: source }, callback); }; // Delete the document. This creates and submits a delete operation to the @@ -798,7 +913,7 @@ Doc.prototype.del = function(options, callback) { } var op = {del: true}; var source = options && options.source; - this._submit(op, source, callback); + this._submit(op, { source: source }, callback); }; @@ -858,7 +973,7 @@ Doc.prototype._rollback = function(err) { // I'm still not 100% sure about this functionality, because its really a // local op. Basically, the problem is that if the client's op is rejected // by the server, the editor window should update to reflect the undo. - this._otApply(op, false); + this._otApply(op); this._clearInflightOp(err); return; diff --git a/lib/client/undoManager.js b/lib/client/undoManager.js new file mode 100644 index 000000000..2c21312a4 --- /dev/null +++ b/lib/client/undoManager.js @@ -0,0 +1,293 @@ +function findLastIndex(stack, doc) { + var index = stack.length - 1; + while (index >= 0) { + if (stack[index].doc === doc) break; + index--; + } + return index; +} + +function getLast(list) { + var lastIndex = list.length - 1; + /* istanbul ignore if */ + if (lastIndex < 0) throw new Error('List empty'); + return list[lastIndex]; +} + +function setLast(list, item) { + var lastIndex = list.length - 1; + /* istanbul ignore if */ + if (lastIndex < 0) throw new Error('List empty'); + list[lastIndex] = item; +} + +function Op(op, doc) { + this.op = op; + this.doc = doc; + this.needsUndoOp = true; +} + +// Manages an undo/redo stack for all operations from the specified `source`. +module.exports = UndoManager; +function UndoManager(connection, options) { + // The Connection which created this UndoManager. + this._connection = connection; + + // If != null, only ops from this "source" will be undoable. + this._source = options && options.source; + + // The max number of undo operations to keep on the stack. + this._limit = options && typeof options.limit === 'number' ? options.limit : 100; + + // The max time difference between operations in milliseconds, + // which still allows the operations to be composed on the undoStack. + this._composeInterval = options && typeof options.composeInterval === 'number' ? options.composeInterval : 1000; + + // Undo stack for local operations. + this._undoStack = []; + + // Redo stack for local operations. + this._redoStack = []; + + // The timestamp of the previous reversible operation. Used to determine if + // the next reversible operation can be composed on the undoStack. + this._previousUndoableOperationTime = -Infinity; +} + +UndoManager.prototype.destroy = function() { + this._connection.removeUndoManager(this); + this.clear(); +}; + +// Clear the undo and redo stack. +// +// @param doc If specified, clear only the ops belonging to this doc. +UndoManager.prototype.clear = function(doc) { + if (doc) { + var filter = function(item) { return item.doc !== doc; }; + this._undoStack = this._undoStack.filter(filter); + this._redoStack = this._redoStack.filter(filter); + } else { + this._undoStack.length = 0; + this._redoStack.length = 0; + } +}; + +// Returns true, if there are any operations on the undo stack, otherwise false. +UndoManager.prototype.canUndo = function() { + return this._undoStack.length > 0 +}; + +// Undoes a submitted operation. +// +// @param options {source: ...} +// @param [callback] called after operation submitted +// @fires before op, op +UndoManager.prototype.undo = function(options, callback) { + if (typeof options === 'function') { + callback = options; + options = null; + } + + if (!this.canUndo()) { + if (callback) process.nextTick(callback); + return; + } + + var op = getLast(this._undoStack); + var submitOptions = { source: options && options.source }; + op.doc._submit(op, submitOptions, callback); +}; + +// Returns true, if there are any operations on the redo stack, otherwise false. +UndoManager.prototype.canRedo = function() { + return this._redoStack.length > 0; +}; + +// Redoes an undone operation. +// +// @param options {source: ...} +// @param [callback] called after operation submitted +// @fires before op, op +UndoManager.prototype.redo = function(options, callback) { + if (typeof options === 'function') { + callback = options; + options = null; + } + + if (!this.canRedo()) { + if (callback) process.nextTick(callback); + return; + } + + var op = getLast(this._redoStack); + var submitOptions = { source: options && options.source }; + op.doc._submit(op, submitOptions, callback); +}; + +UndoManager.prototype.onDocLoad = function(doc) { + this.clear(doc); +}; + +UndoManager.prototype.onDocDestroy = function(doc) { + this.clear(doc); +}; + +UndoManager.prototype.onDocCreate = function(doc) { + // NOTE We don't support undo on create because we can't support undo on delete. +}; + +UndoManager.prototype.onDocDelete = function(doc) { + // NOTE We can't support undo on delete because we can't generate `initialData` required for `create`. + // See https://github.com/ottypes/docs#standard-properties. + // + // We could support undo on delete and create in the future but that would require some breaking changes to ShareDB. + // Here's what we could do: + // + // 1. Do NOT call `create` in ShareDB - ShareDB would get a valid snapshot from the client code. + // 2. Add `validate` to OT types. + // 3. Call `validate` in ShareDB to ensure that the snapshot from the client is valid. + // 4. The `create` ops would contain serialized snapshots instead of `initialData`. + this.clear(doc); +}; + +UndoManager.prototype.onDocOp = function(doc, op, undoOp, source, undoable, fixUp) { + if (this.canUndo() && getLast(this._undoStack) === op) { + this._undoStack.pop(); + this._updateStacksUndo(doc, op.op, undoOp.op); + + } else if (this.canRedo() && getLast(this._redoStack) === op) { + this._redoStack.pop(); + this._updateStacksRedo(doc, op.op, undoOp.op); + + } else if (!fixUp && undoable && (this._source == null || this._source === source)) { + this._updateStacksUndoable(doc, op.op, undoOp.op); + + } else { + this._updateStacksFixed(doc, op.op, undoOp && undoOp.op, fixUp); + } +}; + +UndoManager.prototype._updateStacksUndoable = function(doc, op, undoOp) { + var now = Date.now(); + + if ( + this._undoStack.length === 0 || + getLast(this._undoStack).doc !== doc || + now - this._previousUndoableOperationTime > this._composeInterval + ) { + this._undoStack.push(new Op(undoOp, doc)); + + } else if (doc.type.composeSimilar) { + var lastOp = getLast(this._undoStack); + var composedOp = doc.type.composeSimilar(undoOp, lastOp.op); + if (composedOp != null) { + setLast(this._undoStack, new Op(composedOp, doc)); + } else { + this._undoStack.push(new Op(undoOp, doc)); + } + + } else if (doc.type.compose) { + var lastOp = getLast(this._undoStack); + var composedOp = doc.type.compose(undoOp, lastOp.op); + setLast(this._undoStack, new Op(composedOp, doc)); + + } else { + this._undoStack.push(new Op(undoOp, doc)); + } + + this._redoStack.length = 0; + this._previousUndoableOperationTime = now; + + var isNoop = doc.type.isNoop; + if (isNoop && isNoop(getLast(this._undoStack).op)) { + this._undoStack.pop(); + } + + var itemsToRemove = this._undoStack.length - this._limit; + if (itemsToRemove > 0) { + this._undoStack.splice(0, itemsToRemove); + } +}; + +UndoManager.prototype._updateStacksUndo = function(doc, op, undoOp) { + /* istanbul ignore else */ + if (!doc.type.isNoop || !doc.type.isNoop(undoOp)) { + this._redoStack.push(new Op(undoOp, doc)); + } + this._previousUndoableOperationTime = -Infinity; +}; + +UndoManager.prototype._updateStacksRedo = function(doc, op, undoOp) { + /* istanbul ignore else */ + if (!doc.type.isNoop || !doc.type.isNoop(undoOp)) { + this._undoStack.push(new Op(undoOp, doc)); + } + this._previousUndoableOperationTime = -Infinity; +}; + +UndoManager.prototype._updateStacksFixed = function(doc, op, undoOp, fixUp) { + if (fixUp && undoOp != null && doc.type.compose) { + var lastUndoIndex = findLastIndex(this._undoStack, doc); + if (lastUndoIndex >= 0) { + var lastOp = this._undoStack[lastUndoIndex]; + var composedOp = doc.type.compose(undoOp, lastOp.op); + if (!doc.type.isNoop || !doc.type.isNoop(composedOp)) { + this._undoStack[lastUndoIndex] = new Op(composedOp, doc); + } else { + this._undoStack.splice(lastUndoIndex, 1); + } + } + + var lastRedoIndex = findLastIndex(this._redoStack, doc); + if (lastRedoIndex >= 0) { + var lastOp = this._redoStack[lastRedoIndex]; + var composedOp = doc.type.compose(undoOp, lastOp.op); + if (!doc.type.isNoop || !doc.type.isNoop(composedOp)) { + this._redoStack[lastRedoIndex] = new Op(composedOp, doc); + } else { + this._redoStack.splice(lastRedoIndex, 1); + } + } + + } else { + this._undoStack = this._transformStack(this._undoStack, doc, op); + this._redoStack = this._transformStack(this._redoStack, doc, op); + } +}; + +UndoManager.prototype._transformStack = function(stack, doc, op) { + var transform = doc.type.transform; + var transformX = doc.type.transformX; + var isNoop = doc.type.isNoop; + var newStack = []; + var newStackIndex = 0; + + for (var i = stack.length - 1; i >= 0; --i) { + var item = stack[i]; + if (item.doc !== doc) { + newStack[newStackIndex++] = item; + continue; + } + var stackOp = item.op; + var transformedStackOp; + var transformedOp; + + if (transformX) { + var result = transformX(op, stackOp); + transformedOp = result[0]; + transformedStackOp = result[1]; + } else { + transformedOp = transform(op, stackOp, 'left'); + transformedStackOp = transform(stackOp, op, 'right'); + } + + if (!isNoop || !isNoop(transformedStackOp)) { + newStack[newStackIndex++] = new Op(transformedStackOp, doc); + } + + op = transformedOp; + } + + return newStack.reverse(); +}; diff --git a/package.json b/package.json index 35fc64bc6..2e34445e8 100644 --- a/package.json +++ b/package.json @@ -5,19 +5,23 @@ "main": "lib/index.js", "dependencies": { "arraydiff": "^0.1.1", - "async": "^1.4.2", + "async": "^2.6.1", "deep-is": "^0.1.3", "hat": "0.0.3", "make-error": "^1.1.1", "ot-json0": "^1.0.1" }, "devDependencies": { - "coveralls": "^2.11.8", + "@teamwork/ot-rich-text": "^6.3.3", + "coveralls": "^3.0.2", "expect.js": "^0.3.1", "istanbul": "^0.4.2", "jshint": "^2.9.2", - "mocha": "^3.2.0", - "sharedb-mingo-memory": "^1.0.0-beta" + "lolex": "^2.7.1", + "mocha": "^5.2.0", + "ot-text": "^1.0.1", + "rich-text": "^3.1.0", + "sharedb-mingo-memory": "^1.0.1" }, "scripts": { "test": "./node_modules/.bin/mocha && npm run jshint", diff --git a/test/client/doc.js b/test/client/doc.js index b44f52a2b..c789ad67b 100644 --- a/test/client/doc.js +++ b/test/client/doc.js @@ -1,7 +1,7 @@ var Backend = require('../../lib/backend'); var expect = require('expect.js'); -describe('client query subscribe', function() { +describe('client doc', function() { beforeEach(function() { this.backend = new Backend(); @@ -25,6 +25,39 @@ describe('client query subscribe', function() { expect(doc).not.equal(doc2); }); + it('calling doc.destroy on subscribed doc unregisters it (no callback)', function() { + var connection = this.connection; + var doc = connection.get('dogs', 'fido'); + expect(connection.getExisting('dogs', 'fido')).equal(doc); + + doc.subscribe(function(err) { + if (err) return done(err); + doc.destroy(); + doc.whenNothingPending(function() { + expect(connection.getExisting('dogs', 'fido')).equal(undefined); + + var doc2 = connection.get('dogs', 'fido'); + expect(doc).not.equal(doc2); + }); + }); + }); + + it('calling doc.destroy on subscribed doc unregisters it (with callback)', function() { + var connection = this.connection; + var doc = connection.get('dogs', 'fido'); + expect(connection.getExisting('dogs', 'fido')).equal(doc); + + doc.subscribe(function(err) { + if (err) return done(err); + doc.destroy(function() { + expect(connection.getExisting('dogs', 'fido')).equal(undefined); + + var doc2 = connection.get('dogs', 'fido'); + expect(doc).not.equal(doc2); + }); + }); + }); + it('getting then destroying then getting returns a new doc object', function() { var doc = this.connection.get('dogs', 'fido'); doc.destroy(); diff --git a/test/client/invertible-type.js b/test/client/invertible-type.js new file mode 100644 index 000000000..34a4f3fd6 --- /dev/null +++ b/test/client/invertible-type.js @@ -0,0 +1,80 @@ +// A simple type for testing undo/redo, where: +// +// - snapshot is an integer +// - operation is an integer +exports.type = { + name: 'invertible-type', + uri: 'http://sharejs.org/types/invertible-type', + create: create, + apply: apply, + transform: transform, + invert: invert +}; + +exports.typeWithDiff = { + name: 'invertible-type-with-diff', + uri: 'http://sharejs.org/types/invertible-type-with-diff', + create: create, + apply: apply, + transform: transform, + invert: invert, + diff: diff +}; + +exports.typeWithDiffX = { + name: 'invertible-type-with-diffX', + uri: 'http://sharejs.org/types/invertible-type-with-diffX', + create: create, + apply: apply, + transform: transform, + invert: invert, + diffX: diffX +}; + +exports.typeWithDiffAndDiffX = { + name: 'invertible-type-with-diff-and-diffX', + uri: 'http://sharejs.org/types/invertible-type-with-diff-and-diffX', + create: create, + apply: apply, + transform: transform, + invert: invert, + diff: diff, + diffX: diffX +}; + +exports.typeWithTransformX = { + name: 'invertible-type-with-transformX', + uri: 'http://sharejs.org/types/invertible-type-with-transformX', + create: create, + apply: apply, + transformX: transformX, + invert: invert +}; + +function create(data) { + return data | 0; +} + +function apply(snapshot, op) { + return snapshot + op; +} + +function transform(op1, op2, side) { + return op1; +} + +function transformX(op1, op2) { + return [ op1, op2 ]; +} + +function invert(op) { + return -op; +} + +function diff(oldSnapshot, newSnapshot) { + return newSnapshot - oldSnapshot; +} + +function diffX(oldSnapshot, newSnapshot) { + return [ oldSnapshot - newSnapshot, newSnapshot - oldSnapshot ]; +} diff --git a/test/client/submit.js b/test/client/submit.js index 4e508e66e..8ee279b2e 100644 --- a/test/client/submit.js +++ b/test/client/submit.js @@ -2,8 +2,10 @@ var async = require('async'); var expect = require('expect.js'); var types = require('../../lib/types'); var deserializedType = require('./deserialized-type'); +var otRichText = require('@teamwork/ot-rich-text'); types.register(deserializedType.type); types.register(deserializedType.type2); +types.register(otRichText.type); module.exports = function() { describe('client submit', function() { @@ -608,11 +610,17 @@ describe('client submit', function() { doc2.del(function(err) { if (err) return done(err); doc.pause(); + var calledBack = false; + doc.on('error', function() { + expect(calledBack).equal(true); + done(); + }); doc.submitOp({p: ['age'], na: 1}, function(err) { expect(err).ok(); + expect(err.code).to.equal(4017); expect(doc.version).equal(2); expect(doc.data).eql(undefined); - done(); + calledBack = true; }); doc.fetch(); }); @@ -632,11 +640,17 @@ describe('client submit', function() { doc2.create({age: 5}, function(err) { if (err) return done(err); doc.pause(); + var calledBack = false; + doc.on('error', function() { + expect(calledBack).equal(true); + done(); + }); doc.create({age: 9}, function(err) { expect(err).ok(); + expect(err.code).to.equal(4018); expect(doc.version).equal(3); expect(doc.data).eql({age: 5}); - done(); + calledBack = true; }); doc.fetch(); }); @@ -1044,6 +1058,227 @@ describe('client submit', function() { }); }); + it('does not skip processing when submitting a no-op by default', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.on('op', function() { + expect(doc.data).to.eql([ otRichText.Action.createInsertText('test') ]); + done(); + }); + doc.create([ otRichText.Action.createInsertText('test') ], otRichText.type.uri); + doc.submitOp([]); + }); + + it('does not skip processing when submitting an identical snapshot by default', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.on('op', function() { + expect(doc.data).to.eql([ otRichText.Action.createInsertText('test') ]); + done(); + }); + doc.create([ otRichText.Action.createInsertText('test') ], otRichText.type.uri); + doc.submitSnapshot([ otRichText.Action.createInsertText('test') ]); + }); + + it('skips processing when submitting a no-op (no callback)', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.on('op', function() { + done(new Error('Should not emit `op`')); + }); + doc.create([ otRichText.Action.createInsertText('test') ], otRichText.type.uri); + doc.submitOp([], { skipNoop: true }); + expect(doc.data).to.eql([ otRichText.Action.createInsertText('test') ]); + done(); + }); + + it('skips processing when submitting a no-op (with callback)', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.on('op', function() { + done(new Error('Should not emit `op`')); + }); + doc.create([ otRichText.Action.createInsertText('test') ], otRichText.type.uri); + doc.submitOp([], { skipNoop: true }, done); + }); + + it('skips processing when submitting an identical snapshot (no callback)', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.on('op', function() { + done(new Error('Should not emit `op`')); + }); + doc.create([ otRichText.Action.createInsertText('test') ], otRichText.type.uri); + doc.submitSnapshot([ otRichText.Action.createInsertText('test') ], { skipNoop: true }); + expect(doc.data).to.eql([ otRichText.Action.createInsertText('test') ]); + done(); + }); + + it('skips processing when submitting an identical snapshot (with callback)', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.on('op', function() { + done(new Error('Should not emit `op`')); + }); + doc.create([ otRichText.Action.createInsertText('test') ], otRichText.type.uri); + doc.submitSnapshot([ otRichText.Action.createInsertText('test') ], { skipNoop: true }, done); + }); + + it('submits a snapshot when document is not created (no callback, no options)', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.on('error', function(error) { + expect(error).to.be.an(Error); + expect(error.code).to.equal(4015); + done(); + }); + doc.submitSnapshot(7); + }); + + it('submits a snapshot when document is not created (no callback, with options)', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.on('error', function(error) { + expect(error).to.be.an(Error); + expect(error.code).to.equal(4015); + done(); + }); + doc.submitSnapshot(7, { source: 'test' }); + }); + + it('submits a snapshot when document is not created (with callback, no options)', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.on('error', done); + doc.submitSnapshot(7, function(error) { + expect(error).to.be.an(Error); + expect(error.code).to.equal(4015); + done(); + }); + }); + + it('submits a snapshot when document is not created (with callback, with options)', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.on('error', done); + doc.submitSnapshot(7, { source: 'test' }, function(error) { + expect(error).to.be.an(Error); + expect(error.code).to.equal(4015); + done(); + }); + }); + + it('submits a snapshot with source (no callback)', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.on('op', function(op, source) { + expect(op).to.eql([ otRichText.Action.createInsertText('abc') ]); + expect(doc.data).to.eql([ otRichText.Action.createInsertText('abcdef') ]); + expect(source).to.equal('test'); + done(); + }); + doc.create([ otRichText.Action.createInsertText('def') ], otRichText.type.uri); + doc.submitSnapshot([ otRichText.Action.createInsertText('abcdef') ], { source: 'test' }); + }); + + it('submits a snapshot with source (with callback)', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + var opEmitted = false; + doc.on('op', function(op, source) { + expect(op).to.eql([ otRichText.Action.createInsertText('abc') ]); + expect(doc.data).to.eql([ otRichText.Action.createInsertText('abcdef') ]); + expect(source).to.equal('test'); + opEmitted = true; + }); + doc.create([ otRichText.Action.createInsertText('def') ], otRichText.type.uri); + doc.submitSnapshot([ otRichText.Action.createInsertText('abcdef') ], { source: 'test' }, function(error) { + expect(opEmitted).to.equal(true); + done(error); + }); + }); + + it('submits a snapshot without source (no callback)', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.on('op', function(op, source) { + expect(op).to.eql([ otRichText.Action.createInsertText('abc') ]); + expect(doc.data).to.eql([ otRichText.Action.createInsertText('abcdef') ]); + expect(source).to.equal(true); + done(); + }); + doc.create([ otRichText.Action.createInsertText('def') ], otRichText.type.uri); + doc.submitSnapshot([ otRichText.Action.createInsertText('abcdef') ]); + }); + + it('submits a snapshot without source (with callback)', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + var opEmitted = false; + doc.on('op', function(op, source) { + expect(op).to.eql([ otRichText.Action.createInsertText('abc') ]); + expect(doc.data).to.eql([ otRichText.Action.createInsertText('abcdef') ]); + expect(source).to.equal(true); + opEmitted = true; + }); + doc.create([ otRichText.Action.createInsertText('def') ], otRichText.type.uri); + doc.submitSnapshot([ otRichText.Action.createInsertText('abcdef') ], function(error) { + expect(opEmitted).to.equal(true); + done(error); + }); + }); + + it('submits a snapshot and syncs it', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + var doc2 = this.backend.connect().get('dogs', 'fido'); + doc2.on('create', function() { + doc2.submitSnapshot([ otRichText.Action.createInsertText('abcdef') ]); + }); + doc.on('op', function(op, source) { + expect(op).to.eql([ otRichText.Action.createInsertText('abc') ]); + expect(source).to.equal(false); + expect(doc.data).to.eql([ otRichText.Action.createInsertText('abcdef') ]); + done(); + }); + doc.subscribe(function(err) { + if (err) return done(err); + doc2.subscribe(function(err) { + if (err) return done(err); + doc.create([ otRichText.Action.createInsertText('def') ], otRichText.type.uri); + }); + }); + }); + + it('submits a snapshot (no diff, no diffX, no callback)', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.on('error', function(error) { + expect(error).to.be.an(Error); + expect(error.code).to.equal(4024); + done(); + }); + doc.create({ test: 5 }); + doc.submitSnapshot({ test: 7 }); + }); + + it('submits a snapshot (no diff, no diffX, with callback)', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.on('error', done); + doc.create({ test: 5 }); + doc.submitSnapshot({ test: 7 }, function(error) { + expect(error).to.be.an(Error); + expect(error.code).to.equal(4024); + done(); + }); + }); + + it('submits a snapshot without a diffHint', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.create([ otRichText.Action.createInsertText('aaaa') ], otRichText.type.uri); + doc.on('op', function(op) { + expect(doc.data).to.eql([ otRichText.Action.createInsertText('aaaaa') ]); + expect(op).to.eql([ otRichText.Action.createInsertText('a') ]); + done(); + }); + doc.submitSnapshot([ otRichText.Action.createInsertText('aaaaa') ]); + }); + + it('submits a snapshot with a diffHint', function(done) { + var doc = this.backend.connect().get('dogs', 'fido'); + doc.create([ otRichText.Action.createInsertText('aaaa') ], otRichText.type.uri); + doc.on('op', function(op) { + expect(doc.data).to.eql([ otRichText.Action.createInsertText('aaaaa') ]); + expect(op).to.eql([ otRichText.Action.createRetain(2), otRichText.Action.createInsertText('a') ]); + done(); + }); + doc.submitSnapshot([ otRichText.Action.createInsertText('aaaaa') ], { diffHint: 2 }); + }); + describe('type.deserialize', function() { it('can create a new doc', function(done) { var doc = this.backend.connect().get('dogs', 'fido'); diff --git a/test/client/subscribe.js b/test/client/subscribe.js index 567031d0a..b24a94749 100644 --- a/test/client/subscribe.js +++ b/test/client/subscribe.js @@ -405,23 +405,37 @@ describe('client subscribe', function() { }); it('doc destroy stops op updates', function(done) { - var doc = this.backend.connect().get('dogs', 'fido'); - var doc2 = this.backend.connect().get('dogs', 'fido'); + var connection1 = this.backend.connect(); + var connection2 = this.backend.connect(); + var doc = connection1.get('dogs', 'fido'); + var doc2 = connection2.get('dogs', 'fido'); doc.create({age: 3}, function(err) { if (err) return done(err); doc2.subscribe(function(err) { if (err) return done(err); doc2.on('op', function(op, context) { - done(); + done(new Error('Should not get op event')); }); doc2.destroy(function(err) { if (err) return done(err); + expect(connection2.getExisting('dogs', 'fido')).equal(undefined); doc.submitOp({p: ['age'], na: 1}, done); }); }); }); }); + it('doc destroy removes doc from connection when doc is not subscribed', function(done) { + var connection = this.backend.connect(); + var doc = connection.get('dogs', 'fido'); + expect(connection.getExisting('dogs', 'fido')).equal(doc); + doc.destroy(function(err) { + if (err) return done(err); + expect(connection.getExisting('dogs', 'fido')).equal(undefined); + done(); + }); + }); + it('bulk unsubscribe stops op updates', function(done) { var connection = this.backend.connect(); var connection2 = this.backend.connect(); diff --git a/test/client/undo-redo.js b/test/client/undo-redo.js new file mode 100644 index 000000000..fe9681a54 --- /dev/null +++ b/test/client/undo-redo.js @@ -0,0 +1,1543 @@ +var async = require('async'); +var lolex = require("lolex"); +var util = require('../util'); +var errorHandler = util.errorHandler; +var Backend = require('../../lib/backend'); +var ShareDBError = require('../../lib/error'); +var expect = require('expect.js'); +var types = require('../../lib/types'); +var otText = require('ot-text'); +var otRichText = require('@teamwork/ot-rich-text'); +var richText = require('rich-text'); +var invertibleType = require('./invertible-type'); + +types.register(otText.type); +types.register(richText.type); +types.register(otRichText.type); +types.register(invertibleType.type); +types.register(invertibleType.typeWithDiff); +types.register(invertibleType.typeWithDiffX); +types.register(invertibleType.typeWithDiffAndDiffX); +types.register(invertibleType.typeWithTransformX); + +describe('client undo/redo', function() { + beforeEach(function() { + this.clock = lolex.install(); + this.backend = new Backend(); + this.connection = this.backend.connect(); + this.connection2 = this.backend.connect(); + this.doc = this.connection.get('dogs', 'fido'); + this.doc2 = this.connection2.get('dogs', 'fido'); + }); + + afterEach(function(done) { + this.backend.close(done); + this.clock.uninstall(); + }); + + it('submits a non-undoable operation', function(allDone) { + var undoManager = this.connection.createUndoManager(); + async.series([ + this.doc.create.bind(this.doc, { test: 5 }), + this.doc.submitOp.bind(this.doc, [ { p: [ 'test' ], na: 2 } ]), + function(done) { + expect(this.doc.version).to.equal(2); + expect(this.doc.data).to.eql({ test: 7 }); + expect(undoManager.canUndo()).to.equal(false); + expect(undoManager.canRedo()).to.equal(false); + done(); + }.bind(this) + ], allDone); + }); + + it('receives a remote operation', function(done) { + var undoManager = this.connection.createUndoManager(); + this.doc2.preventCompose = true; + this.doc.on('op', function() { + expect(this.doc.version).to.equal(2); + expect(this.doc.data).to.eql({ test: 7 }); + expect(undoManager.canUndo()).to.equal(false); + expect(undoManager.canRedo()).to.equal(false); + done(); + }.bind(this)); + this.doc.subscribe(function() { + this.doc2.create({ test: 5 }); + this.doc2.submitOp([ { p: [ 'test' ], na: 2 } ]); + }.bind(this)); + }); + + it('submits an undoable operation', function(allDone) { + var undoManager = this.connection.createUndoManager(); + async.series([ + this.doc.create.bind(this.doc, { test: 5 }), + this.doc.submitOp.bind(this.doc, [ { p: [ 'test' ], na: 2 } ], { undoable: true }), + function(done) { + expect(this.doc.version).to.equal(2); + expect(this.doc.data).to.eql({ test: 7 }); + expect(undoManager.canUndo()).to.equal(true); + expect(undoManager.canRedo()).to.equal(false); + done(); + }.bind(this) + ], allDone); + }); + + it('undoes an operation', function(allDone) { + var undoManager = this.connection.createUndoManager(); + async.series([ + this.doc.create.bind(this.doc, { test: 5 }), + this.doc.submitOp.bind(this.doc, [ { p: [ 'test' ], na: 2 } ], { undoable: true }), + undoManager.undo.bind(undoManager), + function(done) { + expect(this.doc.version).to.equal(3); + expect(this.doc.data).to.eql({ test: 5 }); + expect(undoManager.canUndo()).to.equal(false); + expect(undoManager.canRedo()).to.equal(true); + done(); + }.bind(this) + ], allDone); + }); + + it('redoes an operation', function(allDone) { + var undoManager = this.connection.createUndoManager(); + async.series([ + this.doc.create.bind(this.doc, { test: 5 }), + this.doc.submitOp.bind(this.doc, [ { p: [ 'test' ], na: 2 } ], { undoable: true }), + undoManager.undo.bind(undoManager), + undoManager.redo.bind(undoManager), + function(done) { + expect(this.doc.version).to.equal(4); + expect(this.doc.data).to.eql({ test: 7 }); + expect(undoManager.canUndo()).to.equal(true); + expect(undoManager.canRedo()).to.equal(false); + done(); + }.bind(this) + ], allDone); + }); + + it('performs a series of undo and redo operations', function(allDone) { + var undoManager = this.connection.createUndoManager(); + async.series([ + this.doc.create.bind(this.doc, { test: 5 }), + this.doc.submitOp.bind(this.doc, [ { p: [ 'test' ], na: 2 } ], { undoable: true }), + undoManager.undo.bind(undoManager), + undoManager.redo.bind(undoManager), + undoManager.undo.bind(undoManager), + undoManager.redo.bind(undoManager), + undoManager.undo.bind(undoManager), + undoManager.redo.bind(undoManager), + function(done) { + expect(this.doc.version).to.equal(8); + expect(this.doc.data).to.eql({ test: 7 }); + expect(undoManager.canUndo()).to.equal(true); + expect(undoManager.canRedo()).to.equal(false); + done(); + }.bind(this) + ], allDone); + }); + + it('performs a series of undo and redo operations synchronously', function() { + var undoManager = this.connection.createUndoManager(); + this.doc.create({ test: 5 }), + this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }), + expect(this.doc.data).to.eql({ test: 7 }); + undoManager.undo(), + expect(this.doc.data).to.eql({ test: 5 }); + undoManager.redo(), + expect(this.doc.data).to.eql({ test: 7 }); + undoManager.undo(), + expect(this.doc.data).to.eql({ test: 5 }); + undoManager.redo(), + expect(this.doc.data).to.eql({ test: 7 }); + undoManager.undo(), + expect(this.doc.data).to.eql({ test: 5 }); + undoManager.redo(), + expect(this.doc.data).to.eql({ test: 7 }); + expect(undoManager.canUndo()).to.equal(true); + expect(undoManager.canRedo()).to.equal(false); + }); + + it('undoes one of two operations', function(allDone) { + var undoManager = this.connection.createUndoManager({ composeInterval: -1 }); + async.series([ + this.doc.create.bind(this.doc, { test: 5 }), + this.doc.submitOp.bind(this.doc, [ { p: [ 'test' ], na: 2 } ], { undoable: true }), + this.doc.submitOp.bind(this.doc, [ { p: [ 'test' ], na: 3 } ], { undoable: true }), + undoManager.undo.bind(undoManager), + function(done) { + expect(this.doc.version).to.equal(4); + expect(this.doc.data).to.eql({ test: 7 }); + expect(undoManager.canUndo()).to.equal(true); + expect(undoManager.canRedo()).to.equal(true); + done(); + }.bind(this) + ], allDone); + }); + + it('undoes two of two operations', function(allDone) { + var undoManager = this.connection.createUndoManager({ composeInterval: -1 }); + async.series([ + this.doc.create.bind(this.doc, { test: 5 }), + this.doc.submitOp.bind(this.doc, [ { p: [ 'test' ], na: 2 } ], { undoable: true }), + this.doc.submitOp.bind(this.doc, [ { p: [ 'test' ], na: 3 } ], { undoable: true }), + undoManager.undo.bind(undoManager), + undoManager.undo.bind(undoManager), + function(done) { + expect(this.doc.version).to.equal(5); + expect(this.doc.data).to.eql({ test: 5 }); + expect(undoManager.canUndo()).to.equal(false); + expect(undoManager.canRedo()).to.equal(true); + done(); + }.bind(this) + ], allDone); + }); + + it('redoes one of two operations', function(allDone) { + var undoManager = this.connection.createUndoManager({ composeInterval: -1 }); + async.series([ + this.doc.create.bind(this.doc, { test: 5 }), + this.doc.submitOp.bind(this.doc, [ { p: [ 'test' ], na: 2 } ], { undoable: true }), + this.doc.submitOp.bind(this.doc, [ { p: [ 'test' ], na: 3 } ], { undoable: true }), + undoManager.undo.bind(undoManager), + undoManager.undo.bind(undoManager), + undoManager.redo.bind(undoManager), + function(done) { + expect(this.doc.version).to.equal(6); + expect(this.doc.data).to.eql({ test: 7 }); + expect(undoManager.canUndo()).to.equal(true); + expect(undoManager.canRedo()).to.equal(true); + done(); + }.bind(this) + ], allDone); + }); + + it('redoes two of two operations', function(allDone) { + var undoManager = this.connection.createUndoManager({ composeInterval: -1 }); + async.series([ + this.doc.create.bind(this.doc, { test: 5 }), + this.doc.submitOp.bind(this.doc, [ { p: [ 'test' ], na: 2 } ], { undoable: true }), + this.doc.submitOp.bind(this.doc, [ { p: [ 'test' ], na: 3 } ], { undoable: true }), + undoManager.undo.bind(undoManager), + undoManager.undo.bind(undoManager), + undoManager.redo.bind(undoManager), + undoManager.redo.bind(undoManager), + function(done) { + expect(this.doc.version).to.equal(7); + expect(this.doc.data).to.eql({ test: 10 }); + expect(undoManager.canUndo()).to.equal(true); + expect(undoManager.canRedo()).to.equal(false); + done(); + }.bind(this) + ], allDone); + }); + + it('calls undo, when canUndo is false', function(done) { + var undoManager = this.connection.createUndoManager(); + expect(undoManager.canUndo()).to.equal(false); + undoManager.undo(done); + }); + + it('calls undo, when canUndo is false - no callback', function() { + var undoManager = this.connection.createUndoManager(); + expect(undoManager.canUndo()).to.equal(false); + undoManager.undo(); + }); + + it('calls redo, when canRedo is false', function(done) { + var undoManager = this.connection.createUndoManager(); + expect(undoManager.canRedo()).to.equal(false); + undoManager.redo(done); + }); + + it('calls redo, when canRedo is false - no callback', function() { + var undoManager = this.connection.createUndoManager(); + expect(undoManager.canRedo()).to.equal(false); + undoManager.redo(); + }); + + it('preserves source on create', function(done) { + this.doc.on('create', function(source) { + expect(source).to.equal('test source'); + done(); + }); + this.doc.create({ test: 5 }, null, { source: 'test source' }); + }); + + it('preserves source on del', function(done) { + this.doc.on('del', function(oldContent, source) { + expect(source).to.equal('test source'); + done(); + }); + this.doc.create({ test: 5 }); + this.doc.del({ source: 'test source' }); + }); + + it('preserves source on submitOp', function(done) { + this.doc.on('op', function(op, source) { + expect(source).to.equal('test source'); + done(); + }); + this.doc.create({ test: 5 }); + this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { source: 'test source' }); + }); + + it('preserves source on undo', function(done) { + var undoManager = this.connection.createUndoManager(); + this.doc.create({ test: 5 }); + this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); + this.doc.on('op', function(op, source) { + expect(source).to.equal('test source'); + done(); + }); + undoManager.undo({ source: 'test source' }); + }); + + it('preserves source on redo', function(done) { + var undoManager = this.connection.createUndoManager(); + this.doc.create({ test: 5 }); + this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); + undoManager.undo(); + this.doc.on('op', function(op, source) { + expect(source).to.equal('test source'); + done(); + }); + undoManager.redo({ source: 'test source' }); + }); + + it('has source=false on remote operations', function(done) { + this.doc.on('op', function(op, source) { + expect(source).to.equal(false); + done(); + }); + this.doc.subscribe(function() { + this.doc2.preventCompose = true; + this.doc2.create({ test: 5 }); + this.doc2.submitOp([ { p: [ 'test' ], na: 2 } ]); + }.bind(this)); + }); + + it('composes undoable operations within time limit', function(done) { + var undoManager = this.connection.createUndoManager(); + this.doc.create({ test: 5 }); + this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); + setTimeout(function() { + this.doc.submitOp([ { p: [ 'test' ], na: 3 } ], { undoable: true }); + expect(this.doc.data).to.eql({ test: 10 }); + undoManager.undo(); + expect(this.doc.data).to.eql({ test: 5 }); + expect(undoManager.canUndo()).to.equal(false); + done(); + }.bind(this), 1000); + this.clock.runAll(); + }); + + it('composes undoable operations correctly', function() { + var undoManager = this.connection.createUndoManager(); + this.doc.create({ a: 1, b: 2 }); + this.doc.submitOp([ { p: [ 'a' ], od: 1 } ], { undoable: true }); + this.doc.submitOp([ { p: [ 'b' ], od: 2 } ], { undoable: true }); + expect(this.doc.data).to.eql({}); + expect(undoManager.canRedo()).to.equal(false); + var opCalled = false; + this.doc.once('op', function(op) { + opCalled = true; + expect(op).to.eql([ { p: [ 'b' ], oi: 2 }, { p: [ 'a' ], oi: 1 } ]); + }); + undoManager.undo(); + expect(opCalled).to.equal(true); + expect(this.doc.data).to.eql({ a: 1, b: 2 }); + expect(undoManager.canUndo()).to.equal(false); + undoManager.redo(); + expect(this.doc.data).to.eql({}); + expect(undoManager.canRedo()).to.equal(false); + }); + + it('does not compose undoable operations outside time limit', function(done) { + var undoManager = this.connection.createUndoManager(); + this.doc.create({ test: 5 }); + this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); + setTimeout(function () { + this.doc.submitOp([ { p: [ 'test' ], na: 3 } ], { undoable: true }); + expect(this.doc.data).to.eql({ test: 10 }); + undoManager.undo(); + expect(this.doc.data).to.eql({ test: 7 }); + expect(undoManager.canUndo()).to.equal(true); + undoManager.undo(); + expect(this.doc.data).to.eql({ test: 5 }); + expect(undoManager.canUndo()).to.equal(false); + done(); + }.bind(this), 1001); + this.clock.runAll(); + }); + + it('does not compose undoable operations, if composeInterval < 0', function() { + var undoManager = this.connection.createUndoManager({ composeInterval: -1 }); + this.doc.create({ test: 5 }); + this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); + this.doc.submitOp([ { p: [ 'test' ], na: 3 } ], { undoable: true }); + expect(this.doc.data).to.eql({ test: 10 }); + undoManager.undo(); + expect(this.doc.data).to.eql({ test: 7 }); + expect(undoManager.canUndo()).to.equal(true); + undoManager.undo(); + expect(this.doc.data).to.eql({ test: 5 }); + expect(undoManager.canUndo()).to.equal(false); + }); + + it('does not compose undoable operations, if type does not support compose nor composeSimilar', function() { + var undoManager = this.connection.createUndoManager(); + this.doc.create(5, invertibleType.type.uri); + this.doc.submitOp(2, { undoable: true }); + expect(this.doc.data).to.equal(7); + this.doc.submitOp(2, { undoable: true }); + expect(this.doc.data).to.equal(9); + undoManager.undo(); + expect(this.doc.data).to.equal(7); + undoManager.undo(); + expect(this.doc.data).to.equal(5); + expect(undoManager.canUndo()).to.equal(false); + undoManager.redo(); + expect(this.doc.data).to.equal(7); + undoManager.redo(); + expect(this.doc.data).to.equal(9); + expect(undoManager.canRedo()).to.equal(false); + }); + + it('uses applyAndInvert, if available', function() { + var undoManager = this.connection.createUndoManager({ composeInterval: -1 }); + this.doc.create([], otRichText.type.uri); + this.doc.submitOp([ otRichText.Action.createInsertText('two') ], { undoable: true }); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('two') ]); + this.doc.submitOp([ otRichText.Action.createInsertText('one') ], { undoable: true }); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('onetwo') ]); + undoManager.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('two') ]); + undoManager.undo(); + expect(this.doc.data).to.eql([]); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('two') ]); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('onetwo') ]); + }); + + it('fails to submit undoable op, if type is not invertible (callback)', function(done) { + this.doc.create('two', otText.type.uri); + this.doc.on('error', done); + this.doc.submitOp([ 'one' ], { undoable: true }, function(err) { + expect(err.code).to.equal(4025); + done(); + }); + }); + + it('fails to submit undoable op, if type is not invertible (no callback)', function(done) { + this.doc.create('two', otText.type.uri); + this.doc.on('error', function(err) { + expect(err.code).to.equal(4025); + done(); + }); + this.doc.submitOp([ 'one' ], { undoable: true }); + }); + + it('fails to submit undoable snapshot, if type is not invertible (callback)', function(done) { + this.doc.create([], richText.type.uri); + this.doc.on('error', done); + this.doc.submitSnapshot([ { insert: 'abc' } ], { undoable: true }, function(err) { + expect(err.code).to.equal(4025); + done(); + }); + }); + + it('fails to submit undoable snapshot, if type is not invertible (no callback)', function(done) { + this.doc.create([], richText.type.uri); + this.doc.on('error', function(err) { + expect(err.code).to.equal(4025); + done(); + }); + this.doc.submitSnapshot([ { insert: 'abc' } ], { undoable: true }); + }); + + it('fails to submit with fixUp, if type is not invertible', function(done) { + var undoManager = this.connection.createUndoManager(); + this.doc.create('two', otText.type.uri); + this.doc.on('error', done); + this.doc.submitOp([ 'one' ], { fixUp: true }, function(err) { + expect(err.code).to.equal(4025); + done(); + }); + }); + + it('composes similar operations', function() { + var undoManager = this.connection.createUndoManager(); + this.doc.create([], otRichText.type.uri); + this.doc.submitOp([ + otRichText.Action.createInsertText('one') + ], { undoable: true }); + this.doc.submitOp([ + otRichText.Action.createRetain(3), + otRichText.Action.createInsertText('two') + ], { undoable: true }); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('onetwo') ]); + expect(undoManager.canRedo()).to.equal(false); + undoManager.undo(); + expect(this.doc.data).to.eql([]); + expect(undoManager.canUndo()).to.equal(false); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('onetwo') ]); + expect(undoManager.canRedo()).to.equal(false); + }); + + it('does not compose dissimilar operations', function() { + var undoManager = this.connection.createUndoManager(); + this.doc.create([ otRichText.Action.createInsertText(' ') ], otRichText.type.uri); + + this.doc.submitOp([ otRichText.Action.createRetain(1), otRichText.Action.createInsertText('two') ], { undoable: true }); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText(' two') ]); + + this.doc.submitOp([ otRichText.Action.createInsertText('one') ], { undoable: true }); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('one two') ]); + + undoManager.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText(' two') ]); + + undoManager.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText(' ') ]); + + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText(' two') ]); + + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('one two') ]); + }); + + it('does not add no-ops to the undo stack on undoable operation', function() { + var undoManager = this.connection.createUndoManager(); + var opCalled = false; + this.doc.create([ otRichText.Action.createInsertText('test', [ 'key', 'value' ]) ], otRichText.type.uri); + this.doc.on('op', function(op, source) { + expect(op).to.eql([ otRichText.Action.createRetain(4, [ 'key', 'value' ]) ]); + opCalled = true; + }); + this.doc.submitOp([ otRichText.Action.createRetain(4, [ 'key', 'value' ]) ], { undoable: true }); + expect(opCalled).to.equal(true); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('test', [ 'key', 'value' ]) ]); + expect(undoManager.canUndo()).to.eql(false); + expect(undoManager.canRedo()).to.eql(false); + }); + + it('limits the size of the undo stack', function() { + var undoManager = this.connection.createUndoManager({ limit: 2, composeInterval: -1 }); + this.doc.create({ test: 5 }); + this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); + this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); + this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); + expect(this.doc.data).to.eql({ test: 11 }); + expect(undoManager.canUndo()).to.equal(true); + undoManager.undo(); + expect(undoManager.canUndo()).to.equal(true); + undoManager.undo(); + expect(undoManager.canUndo()).to.equal(false); + undoManager.undo(); + expect(this.doc.data).to.eql({ test: 7 }); + }); + + it('does not compose the next operation after undo', function() { + var undoManager = this.connection.createUndoManager(); + this.doc.create({ test: 5 }); + this.clock.tick(1001); + this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); // not composed + this.clock.tick(1001); + this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); // not composed + undoManager.undo(); + this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); // not composed + this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); // composed + expect(this.doc.data).to.eql({ test: 11 }); + expect(undoManager.canUndo()).to.equal(true); + + undoManager.undo(); + expect(this.doc.data).to.eql({ test: 7 }); + expect(undoManager.canUndo()).to.equal(true); + + undoManager.undo(); + expect(this.doc.data).to.eql({ test: 5 }); + expect(undoManager.canUndo()).to.equal(false); + }); + + it('does not compose the next operation after undo and redo', function() { + var undoManager = this.connection.createUndoManager(); + this.doc.create({ test: 5 }); + this.clock.tick(1001); + this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); // not composed + this.clock.tick(1001); + this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); // not composed + undoManager.undo(); + undoManager.redo(); + this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); // not composed + this.doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); // composed + expect(this.doc.data).to.eql({ test: 13 }); + expect(undoManager.canUndo()).to.equal(true); + + undoManager.undo(); + expect(this.doc.data).to.eql({ test: 9 }); + expect(undoManager.canUndo()).to.equal(true); + + undoManager.undo(); + expect(this.doc.data).to.eql({ test: 7 }); + expect(undoManager.canUndo()).to.equal(true); + + undoManager.undo(); + expect(this.doc.data).to.eql({ test: 5 }); + expect(undoManager.canUndo()).to.equal(false); + }); + + it('transforms the stacks by remote operations', function(done) { + var undoManager = this.connection.createUndoManager({ composeInterval: -1 }); + this.doc2.subscribe(); + this.doc.subscribe(); + this.doc.create([], otRichText.type.uri); + this.doc.submitOp([ otRichText.Action.createInsertText('4') ], { undoable: true }); + this.doc.submitOp([ otRichText.Action.createInsertText('3') ], { undoable: true }); + this.doc.submitOp([ otRichText.Action.createInsertText('2') ], { undoable: true }); + this.doc.submitOp([ otRichText.Action.createInsertText('1') ], { undoable: true }); + undoManager.undo(); + undoManager.undo(); + this.doc.whenNothingPending(function() { + this.doc.once('op', function(op, source) { + expect(source).to.equal(false); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC34') ]); + undoManager.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC4') ]); + undoManager.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC') ]); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC4') ]); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC34') ]); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC234') ]); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC1234') ]); + done(); + }.bind(this)); + this.doc2.submitOp([ otRichText.Action.createInsertText('ABC') ]); + }.bind(this)); + }); + + it('transforms the stacks by remote operations and removes no-ops', function(done) { + var undoManager = this.connection.createUndoManager({ composeInterval: -1 }); + this.doc2.subscribe(); + this.doc.subscribe(); + this.doc.create([], otRichText.type.uri); + this.doc.submitOp([ otRichText.Action.createInsertText('4') ], { undoable: true }); + this.doc.submitOp([ otRichText.Action.createInsertText('3') ], { undoable: true }); + this.doc.submitOp([ otRichText.Action.createInsertText('2') ], { undoable: true }); + this.doc.submitOp([ otRichText.Action.createInsertText('1') ], { undoable: true }); + undoManager.undo(); + undoManager.undo(); + this.doc.whenNothingPending(function() { + this.doc.once('op', function(op, source) { + expect(source).to.equal(false); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('4') ]); + undoManager.undo(); + expect(this.doc.data).to.eql([]); + expect(undoManager.canUndo()).to.equal(false); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('4') ]); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('24') ]); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('124') ]); + expect(undoManager.canRedo()).to.equal(false); + done(); + }.bind(this)); + this.doc2.submitOp([ otRichText.Action.createDelete(1) ]); + }.bind(this)); + }); + + it('transforms the stacks by a local operation', function() { + var undoManager = this.connection.createUndoManager({ composeInterval: -1 }); + this.doc.create([], otRichText.type.uri); + this.doc.submitOp([ otRichText.Action.createInsertText('4') ], { undoable: true }); + this.doc.submitOp([ otRichText.Action.createInsertText('3') ], { undoable: true }); + this.doc.submitOp([ otRichText.Action.createInsertText('2') ], { undoable: true }); + this.doc.submitOp([ otRichText.Action.createInsertText('1') ], { undoable: true }); + undoManager.undo(); + undoManager.undo(); + this.doc.submitOp([ otRichText.Action.createInsertText('ABC') ]); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC34') ]); + undoManager.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC4') ]); + undoManager.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC') ]); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC4') ]); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC34') ]); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC234') ]); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC1234') ]); + }); + + it('transforms the stacks by a local operation and removes no-ops', function() { + var undoManager = this.connection.createUndoManager({ composeInterval: -1 }); + this.doc.create([], otRichText.type.uri); + this.doc.submitOp([ otRichText.Action.createInsertText('4') ], { undoable: true }); + this.doc.submitOp([ otRichText.Action.createInsertText('3') ], { undoable: true }); + this.doc.submitOp([ otRichText.Action.createInsertText('2') ], { undoable: true }); + this.doc.submitOp([ otRichText.Action.createInsertText('1') ], { undoable: true }); + undoManager.undo(); + undoManager.undo(); + this.doc.submitOp([ otRichText.Action.createDelete(1) ]); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('4') ]); + undoManager.undo(); + expect(this.doc.data).to.eql([]); + expect(undoManager.canUndo()).to.equal(false); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('4') ]); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('24') ]); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('124') ]); + expect(undoManager.canRedo()).to.equal(false); + }); + + it('transforms stacks by an undoable op', function() { + var undoManager = this.connection.createUndoManager({ composeInterval: -1, source: '1' }); + this.doc.create([], otRichText.type.uri); + this.doc.submitOp([ otRichText.Action.createInsertText('4') ], { undoable: true, source: '1' }); + this.doc.submitOp([ otRichText.Action.createInsertText('3') ], { undoable: true, source: '1' }); + this.doc.submitOp([ otRichText.Action.createInsertText('2') ], { undoable: true, source: '1' }); + this.doc.submitOp([ otRichText.Action.createInsertText('1') ], { undoable: true, source: '1' }); + undoManager.undo(); + undoManager.undo(); + + // The source does not match, so undoManager transforms its stacks rather than pushing this op on its undo stack. + this.doc.submitOp([ otRichText.Action.createInsertText('ABC') ], { undoable: true }); + + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC34') ]); + undoManager.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC4') ]); + undoManager.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC') ]); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC4') ]); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC34') ]); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC234') ]); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC1234') ]); + }); + + it('transforms stacks by an undo op', function() { + var undoManager = this.connection.createUndoManager({ composeInterval: -1, source: '1' }); + var undoManager2 = this.connection.createUndoManager({ composeInterval: -1, source: '2' }); + this.doc.create([], otRichText.type.uri); + this.doc.submitOp([ otRichText.Action.createInsertText('4') ], { undoable: true, source: '1' }); + this.doc.submitOp([ otRichText.Action.createInsertText('3') ], { undoable: true, source: '1' }); + this.doc.submitOp([ otRichText.Action.createInsertText('2') ], { undoable: true, source: '1' }); + this.doc.submitOp([ otRichText.Action.createInsertText('1') ], { undoable: true, source: '1' }); + undoManager.undo(); + undoManager.undo(); + + // These 2 ops cancel each other out, so the undoManager's stacks remain unaffected, + // even though they are transformed against those ops. + // The second op has `source: '2'`, so it is inverted and added to the undo stack of undoManager2. + this.doc.submitOp([ otRichText.Action.createInsertText('ABC') ], { undoable: true }); + this.doc.submitOp([ otRichText.Action.createDelete(3) ], { undoable: true, source: '2' }); + // This inserts ABC at position 0 and the undoManager's stacks are transformed accordingly, ready for testing. + undoManager2.undo(); + + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC34') ]); + undoManager.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC4') ]); + undoManager.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC') ]); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC4') ]); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC34') ]); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC234') ]); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC1234') ]); + }); + + it('transforms stacks by a redo op', function() { + var undoManager = this.connection.createUndoManager({ composeInterval: -1, source: '1' }); + var undoManager2 = this.connection.createUndoManager({ composeInterval: -1, source: '2' }); + this.doc.create([], otRichText.type.uri); + this.doc.submitOp([ otRichText.Action.createInsertText('4') ], { undoable: true, source: '1' }); + this.doc.submitOp([ otRichText.Action.createInsertText('3') ], { undoable: true, source: '1' }); + this.doc.submitOp([ otRichText.Action.createInsertText('2') ], { undoable: true, source: '1' }); + this.doc.submitOp([ otRichText.Action.createInsertText('1') ], { undoable: true, source: '1' }); + undoManager.undo(); + undoManager.undo(); + + // submitOp and undo cancel each other out, so the undoManager's stacks remain unaffected, + // even though they are transformed against those ops. + // The second op has `source: '2'`, so it is inverted and added to the undo stack of undoManager2. + this.doc.submitOp([ otRichText.Action.createInsertText('ABC') ], { undoable: true, source: '2' }); + undoManager2.undo(); + // This inserts ABC at position 0 and the undoManager's stacks are transformed accordingly, ready for testing. + undoManager2.redo(); + + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC34') ]); + undoManager.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC4') ]); + undoManager.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC') ]); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC4') ]); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC34') ]); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC234') ]); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ABC1234') ]); + }); + + it('transforms the stacks using transform', function() { + var undoManager = this.connection.createUndoManager({ composeInterval: -1 }); + this.doc.create(0, invertibleType.type.uri); + this.doc.submitOp(1, { undoable: true }); + this.doc.submitOp(10, { undoable: true }); + this.doc.submitOp(100, { undoable: true }); + this.doc.submitOp(1000, { undoable: true }); + undoManager.undo(); + undoManager.undo(); + expect(this.doc.data).to.equal(11); + this.doc.submitOp(10000); + undoManager.undo(); + expect(this.doc.data).to.equal(10001); + undoManager.undo(); + expect(this.doc.data).to.equal(10000); + undoManager.redo(); + expect(this.doc.data).to.equal(10001); + undoManager.redo(); + expect(this.doc.data).to.equal(10011); + undoManager.redo(); + expect(this.doc.data).to.equal(10111); + undoManager.redo(); + expect(this.doc.data).to.equal(11111); + }); + + it('transforms the stacks using transformX', function() { + var undoManager = this.connection.createUndoManager({ composeInterval: -1 }); + this.doc.create(0, invertibleType.typeWithTransformX.uri); + this.doc.submitOp(1, { undoable: true }); + this.doc.submitOp(10, { undoable: true }); + this.doc.submitOp(100, { undoable: true }); + this.doc.submitOp(1000, { undoable: true }); + undoManager.undo(); + undoManager.undo(); + expect(this.doc.data).to.equal(11); + this.doc.submitOp(10000); + undoManager.undo(); + expect(this.doc.data).to.equal(10001); + undoManager.undo(); + expect(this.doc.data).to.equal(10000); + undoManager.redo(); + expect(this.doc.data).to.equal(10001); + undoManager.redo(); + expect(this.doc.data).to.equal(10011); + undoManager.redo(); + expect(this.doc.data).to.equal(10111); + undoManager.redo(); + expect(this.doc.data).to.equal(11111); + }); + + describe('fixup operations', function() { + beforeEach(function() { + var undoManager = this.connection.createUndoManager({ composeInterval: -1 }); + + this.assert = function(text) { + var expected = text ? [ otRichText.Action.createInsertText(text) ] : []; + expect(this.doc.data).to.eql(expected); + return this; + }; + this.submitOp = function(op, options) { + if (typeof op === 'string') { + this.doc.submitOp([ otRichText.Action.createInsertText(op) ], options); + } else if (op < 0) { + this.doc.submitOp([ otRichText.Action.createDelete(-op) ], options); + } else { + throw new Error('Invalid op'); + } + return this; + }; + this.submitSnapshot = function(snapshot, options) { + this.doc.submitSnapshot([ otRichText.Action.createInsertText(snapshot) ], options); + return this; + }; + this.undo = function() { + undoManager.undo(); + return this; + }; + this.redo = function() { + undoManager.redo(); + return this; + }; + + this.doc.create([], otRichText.type.uri); + this.submitOp('d', { undoable: true }).assert('d'); + this.submitOp('c', { undoable: true }).assert('cd'); + this.submitOp('b', { undoable: true }).assert('bcd'); + this.submitOp('a', { undoable: true }).assert('abcd'); + this.undo().assert('bcd'); + this.undo().assert('cd'); + expect(undoManager.canUndo()).to.equal(true); + expect(undoManager.canRedo()).to.equal(true); + }); + + it('does not fix up anything', function() { + var undoManager = this.connection.createUndoManager(); + expect(undoManager.canUndo()).to.equal(false); + expect(undoManager.canRedo()).to.equal(false); + this.submitOp('!', { fixUp: true }).assert('!cd'); + expect(undoManager.canUndo()).to.equal(false); + expect(undoManager.canRedo()).to.equal(false); + }); + + it('submits an op and does not fix up stacks (insert)', function() { + this.submitOp('!').assert('!cd'); + this.undo().assert('!d'); + this.undo().assert('!'); + this.redo().assert('!d'); + this.redo().assert('!cd'); + this.redo().assert('!bcd'); + this.redo().assert('!abcd'); + }); + + it('submits an op and fixes up stacks (insert)', function() { + this.submitOp('!', { fixUp: true }).assert('!cd'); + this.undo().assert('d'); + this.undo().assert(''); + this.redo().assert('d'); + this.redo().assert('!cd'); + this.redo().assert('bcd'); + this.redo().assert('abcd'); + }); + + it('submits a snapshot and does not fix up stacks (insert)', function() { + this.submitSnapshot('!cd').assert('!cd'); + this.undo().assert('!d'); + this.undo().assert('!'); + this.redo().assert('!d'); + this.redo().assert('!cd'); + this.redo().assert('!bcd'); + this.redo().assert('!abcd'); + }); + + it('submits a snapshot and fixes up stacks (insert)', function() { + this.submitSnapshot('!cd', { fixUp: true }).assert('!cd'); + this.undo().assert('d'); + this.undo().assert(''); + this.redo().assert('d'); + this.redo().assert('!cd'); + this.redo().assert('bcd'); + this.redo().assert('abcd'); + }); + + it('submits an op and does not fix up stacks (delete)', function() { + this.submitOp(-1).assert('d'); + this.undo().assert(''); + this.undo().assert(''); + this.redo().assert('d'); + this.redo().assert('bd'); + this.redo().assert('abd'); + this.redo().assert('abd'); + }); + + it('submits an op and fixes up stacks (delete)', function() { + this.submitOp(-1, { fixUp: true }).assert('d'); + this.undo().assert(''); + this.undo().assert(''); + this.redo().assert('d'); + this.redo().assert('bcd'); + this.redo().assert('abcd'); + this.redo().assert('abcd'); + }); + + it('submits a snapshot and does not fix up stacks (delete)', function() { + this.submitSnapshot('d').assert('d'); + this.undo().assert(''); + this.undo().assert(''); + this.redo().assert('d'); + this.redo().assert('bd'); + this.redo().assert('abd'); + this.redo().assert('abd'); + }); + + it('submits a snapshot and fixes up stacks (delete)', function() { + this.submitSnapshot('d', { fixUp: true }).assert('d'); + this.undo().assert(''); + this.undo().assert(''); + this.redo().assert('d'); + this.redo().assert('bcd'); + this.redo().assert('abcd'); + this.redo().assert('abcd'); + }); + + it('submits a op and fixes up stacks (redo op becomes no-op and is removed from the stack)', function() { + this.redo().redo().assert('abcd'); + this.submitOp(-1, { undoable: true }).assert('bcd'); + this.submitOp(-1, { undoable: true }).assert('cd'); + this.submitOp(-1, { undoable: true }).assert('d'); + this.submitOp(-1, { undoable: true }).assert(''); + this.undo().undo().assert('cd'); + this.submitOp(-1, { fixUp: true }).assert('d'); + this.redo().assert(''); + this.redo().assert(''); + this.undo().assert('d'); + this.undo().assert('bcd'); + this.undo().assert('abcd'); + }); + + it('fixes up the correct ops', function() { + var doc = this.connection.get('dogs', 'toby'); + this.submitSnapshot('', { undoable: true }).assert(''); + this.submitSnapshot('d', { undoable: true }).assert('d'); + this.submitSnapshot('cd', { undoable: true }).assert('cd'); + doc.create({ test: 5 }); + doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); + doc.submitOp([ { p: [ 'test' ], na: 2 } ], { undoable: true }); + this.submitSnapshot('bcd', { undoable: true }).assert('bcd'); + this.submitSnapshot('abcd', { undoable: true }).assert('abcd'); + this.undo().assert('bcd'); + this.undo().assert('cd'); + this.undo().assert('cd'); // undo one of the `doc` ops + expect(doc.data).to.eql({ test: 7 }); + this.submitSnapshot('!cd', { fixUp: true }).assert('!cd'); + this.undo().assert('!cd'); // undo one of the `doc` ops + expect(doc.data).to.eql({ test: 5 }); + this.undo().assert('d'); + this.undo().assert(''); + this.redo().assert('d'); + this.redo().assert('!cd'); + this.redo().assert('!cd'); // redo one of the `doc` ops + expect(doc.data).to.eql({ test: 7 }); + this.redo().assert('!cd'); // redo one of the `doc` ops + expect(doc.data).to.eql({ test: 9 }); + this.redo().assert('bcd'); + this.redo().assert('abcd'); + }); + + it('fixes up ops if both fixUp and undoable are true', function() { + this.submitOp('!', { undoable: true, fixUp: true }).assert('!cd'); + this.undo().assert('d'); + this.undo().assert(''); + this.redo().assert('d'); + this.redo().assert('!cd'); + this.redo().assert('bcd'); + this.redo().assert('abcd'); + }); + }); + + it('filters undo/redo ops by source', function() { + var undoManager1 = this.connection.createUndoManager({ composeInterval: -1, source: '1' }); + var undoManager2 = this.connection.createUndoManager({ composeInterval: -1, source: '2' }); + + this.doc.create({ test: 5 }); + expect(this.doc.data.test).to.equal(5); + expect(undoManager1.canUndo()).to.equal(false); + expect(undoManager1.canRedo()).to.equal(false); + expect(undoManager2.canUndo()).to.equal(false); + expect(undoManager2.canRedo()).to.equal(false); + + this.doc.submitOp([{ p: [ 'test' ], na: 2 }], { undoable: true }); + expect(this.doc.data.test).to.equal(7); + expect(undoManager1.canUndo()).to.equal(false); + expect(undoManager1.canRedo()).to.equal(false); + expect(undoManager2.canUndo()).to.equal(false); + expect(undoManager2.canRedo()).to.equal(false); + + this.doc.submitOp([{ p: [ 'test' ], na: 2 }], { undoable: true, source: '3' }); + expect(this.doc.data.test).to.equal(9); + expect(undoManager1.canUndo()).to.equal(false); + expect(undoManager1.canRedo()).to.equal(false); + expect(undoManager2.canUndo()).to.equal(false); + expect(undoManager2.canRedo()).to.equal(false); + + this.doc.submitOp([{ p: [ 'test' ], na: 7 }], { undoable: true, source: '1' }); + expect(this.doc.data.test).to.equal(16); + expect(undoManager1.canUndo()).to.equal(true); + expect(undoManager1.canRedo()).to.equal(false); + expect(undoManager2.canUndo()).to.equal(false); + expect(undoManager2.canRedo()).to.equal(false); + + this.doc.submitOp([{ p: [ 'test' ], na: 7 }], { undoable: true, source: '1' }); + expect(this.doc.data.test).to.equal(23); + expect(undoManager1.canUndo()).to.equal(true); + expect(undoManager1.canRedo()).to.equal(false); + expect(undoManager2.canUndo()).to.equal(false); + expect(undoManager2.canRedo()).to.equal(false); + + this.doc.submitOp([{ p: [ 'test' ], na: 13 }], { undoable: true, source: '2' }); + expect(this.doc.data.test).to.equal(36); + expect(undoManager1.canUndo()).to.equal(true); + expect(undoManager1.canRedo()).to.equal(false); + expect(undoManager2.canUndo()).to.equal(true); + expect(undoManager2.canRedo()).to.equal(false); + + this.doc.submitOp([{ p: [ 'test' ], na: 13 }], { undoable: true, source: '2' }); + expect(this.doc.data.test).to.equal(49); + expect(undoManager1.canUndo()).to.equal(true); + expect(undoManager1.canRedo()).to.equal(false); + expect(undoManager2.canUndo()).to.equal(true); + expect(undoManager2.canRedo()).to.equal(false); + + undoManager1.undo(); + expect(this.doc.data.test).to.equal(42); + expect(undoManager1.canUndo()).to.equal(true); + expect(undoManager1.canRedo()).to.equal(true); + expect(undoManager2.canUndo()).to.equal(true); + expect(undoManager2.canRedo()).to.equal(false); + + undoManager2.undo(); + expect(this.doc.data.test).to.equal(29); + expect(undoManager1.canUndo()).to.equal(true); + expect(undoManager1.canRedo()).to.equal(true); + expect(undoManager2.canUndo()).to.equal(true); + expect(undoManager2.canRedo()).to.equal(true); + + undoManager1.undo(); + expect(this.doc.data.test).to.equal(22); + expect(undoManager1.canUndo()).to.equal(false); + expect(undoManager1.canRedo()).to.equal(true); + expect(undoManager2.canUndo()).to.equal(true); + expect(undoManager2.canRedo()).to.equal(true); + + undoManager2.undo(); + expect(this.doc.data.test).to.equal(9); + expect(undoManager1.canUndo()).to.equal(false); + expect(undoManager1.canRedo()).to.equal(true); + expect(undoManager2.canUndo()).to.equal(false); + expect(undoManager2.canRedo()).to.equal(true); + }); + + it('cannot undo/redo an undo/redo operation', function() { + var undoManager1 = this.connection.createUndoManager(); + this.doc.create({ test: 5 }); + this.doc.submitOp([{ p: [ 'test' ], na: 2 }], { undoable: true }); + var undoManager2 = this.connection.createUndoManager(); + expect(this.doc.data.test).to.equal(7); + expect(undoManager1.canUndo()).to.equal(true); + expect(undoManager1.canRedo()).to.equal(false); + expect(undoManager2.canUndo()).to.equal(false); + expect(undoManager2.canRedo()).to.equal(false); + + undoManager1.undo(); + expect(this.doc.data.test).to.equal(5); + expect(undoManager1.canUndo()).to.equal(false); + expect(undoManager1.canRedo()).to.equal(true); + expect(undoManager2.canUndo()).to.equal(false); + expect(undoManager2.canRedo()).to.equal(false); + + undoManager1.redo(); + expect(this.doc.data.test).to.equal(7); + expect(undoManager1.canUndo()).to.equal(true); + expect(undoManager1.canRedo()).to.equal(false); + expect(undoManager2.canUndo()).to.equal(false); + expect(undoManager2.canRedo()).to.equal(false); + }); + + it('destroys UndoManager', function() { + var undoManager = this.connection.createUndoManager({ composeInterval: -1 }); + var doc1 = this.connection.get('dogs', 'fido'); + var doc2 = this.connection.get('dogs', 'toby'); + doc1.create({ test: 5 }); + doc2.create({ test: 11 }); + doc1.submitOp([ { p: [ 'test' ], 'na': 2 } ], { undoable: true }); + doc2.submitOp([ { p: [ 'test' ], 'na': 2 } ], { undoable: true }); + doc1.submitOp([ { p: [ 'test' ], 'na': 2 } ], { undoable: true }); + doc2.submitOp([ { p: [ 'test' ], 'na': 2 } ], { undoable: true }); + undoManager.undo(); + undoManager.undo(); + expect(undoManager.canUndo()).to.equal(true); + expect(undoManager.canRedo()).to.equal(true); + undoManager.destroy(); + expect(undoManager.canUndo()).to.equal(false); + expect(undoManager.canRedo()).to.equal(false); + doc1.submitOp([ { p: [ 'test' ], 'na': 2 } ], { undoable: true }); + doc1.submitOp([ { p: [ 'test' ], 'na': 2 } ], { undoable: true }); + expect(undoManager.canUndo()).to.equal(false); + expect(undoManager.canRedo()).to.equal(false); + expect(doc1.data).to.eql({ test: 11 }); + undoManager.undo(); + expect(doc1.data).to.eql({ test: 11 }); + expect(undoManager.canUndo()).to.equal(false); + expect(undoManager.canRedo()).to.equal(false); + }); + + it('destroys UndoManager twice', function() { + var undoManager = this.connection.createUndoManager({ composeInterval: -1 }); + this.doc.create({ test: 5 }); + this.doc.submitOp([ { p: [ 'test' ], 'na': 2 } ], { undoable: true }); + this.doc.submitOp([ { p: [ 'test' ], 'na': 2 } ], { undoable: true }); + undoManager.undo(); + expect(undoManager.canUndo()).to.equal(true); + expect(undoManager.canRedo()).to.equal(true); + undoManager.destroy(); + expect(undoManager.canUndo()).to.equal(false); + expect(undoManager.canRedo()).to.equal(false); + undoManager.destroy(); + expect(undoManager.canUndo()).to.equal(false); + expect(undoManager.canRedo()).to.equal(false); + }); + + describe('UndoManager.clear', function() { + it('clears the stacks', function() { + var undoManager = this.connection.createUndoManager({ composeInterval: -1 }); + var doc1 = this.connection.get('dogs', 'fido'); + var doc2 = this.connection.get('dogs', 'toby'); + doc1.create({ test: 5 }); + doc2.create({ test: 11 }); + doc1.submitOp([ { p: [ 'test' ], 'na': 2 } ], { undoable: true }); + doc2.submitOp([ { p: [ 'test' ], 'na': 2 } ], { undoable: true }); + doc1.submitOp([ { p: [ 'test' ], 'na': 2 } ], { undoable: true }); + doc2.submitOp([ { p: [ 'test' ], 'na': 2 } ], { undoable: true }); + undoManager.undo(); + undoManager.undo(); + expect(doc1.data).to.eql({ test: 7 }); + expect(undoManager.canUndo()).to.equal(true); + expect(undoManager.canRedo()).to.equal(true); + undoManager.clear(); + expect(undoManager.canUndo()).to.equal(false); + expect(undoManager.canRedo()).to.equal(false); + doc1.submitOp([ { p: [ 'test' ], 'na': 2 } ], { undoable: true }); + doc1.submitOp([ { p: [ 'test' ], 'na': 2 } ], { undoable: true }); + undoManager.undo(); + expect(doc1.data).to.eql({ test: 9 }); + expect(undoManager.canUndo()).to.equal(true); + expect(undoManager.canRedo()).to.equal(true); + }); + + it('clears the stacks for a specific document', function() { + var undoManager = this.connection.createUndoManager({ composeInterval: -1 }); + var doc1 = this.connection.get('dogs', 'fido'); + var doc2 = this.connection.get('dogs', 'toby'); + doc1.create({ test: 5 }); + doc2.create({ test: 11 }); + doc1.submitOp([ { p: [ 'test' ], 'na': 2 } ], { undoable: true }); + doc2.submitOp([ { p: [ 'test' ], 'na': 2 } ], { undoable: true }); + doc1.submitOp([ { p: [ 'test' ], 'na': 2 } ], { undoable: true }); + doc2.submitOp([ { p: [ 'test' ], 'na': 2 } ], { undoable: true }); + undoManager.undo(); + undoManager.undo(); + expect(undoManager.canUndo()).to.equal(true); + expect(undoManager.canRedo()).to.equal(true); + + undoManager.clear(doc1); + expect(undoManager.canUndo()).to.equal(true); + expect(undoManager.canRedo()).to.equal(true); + + undoManager.undo(); + expect(undoManager.canUndo()).to.equal(false); + expect(undoManager.canRedo()).to.equal(true); + expect(doc1.data).to.eql({ test: 7 }); + expect(doc2.data).to.eql({ test: 11 }); + + undoManager.redo(); + expect(undoManager.canUndo()).to.equal(true); + expect(undoManager.canRedo()).to.equal(true); + expect(doc1.data).to.eql({ test: 7 }); + expect(doc2.data).to.eql({ test: 13 }); + + undoManager.redo(); + expect(undoManager.canUndo()).to.equal(true); + expect(undoManager.canRedo()).to.equal(false); + expect(doc1.data).to.eql({ test: 7 }); + expect(doc2.data).to.eql({ test: 15 }); + }); + + it('clears the stacks for a specific document on del', function() { + // NOTE we don't support undo/redo on del/create at the moment. + // See undoManager.js for more details. + var undoManager = this.connection.createUndoManager({ composeInterval: -1 }); + var doc1 = this.connection.get('dogs', 'fido'); + var doc2 = this.connection.get('dogs', 'toby'); + doc1.create({ test: 5 }); + doc2.create({ test: 11 }); + doc1.submitOp([ { p: [ 'test' ], 'na': 2 } ], { undoable: true }); + doc2.submitOp([ { p: [ 'test' ], 'na': 2 } ], { undoable: true }); + doc1.submitOp([ { p: [ 'test' ], 'na': 2 } ], { undoable: true }); + doc2.submitOp([ { p: [ 'test' ], 'na': 2 } ], { undoable: true }); + undoManager.undo(); + undoManager.undo(); + expect(undoManager.canUndo()).to.equal(true); + expect(undoManager.canRedo()).to.equal(true); + doc1.del(); + expect(undoManager.canUndo()).to.equal(true); + expect(undoManager.canRedo()).to.equal(true); + doc2.del(); + expect(undoManager.canUndo()).to.equal(false); + expect(undoManager.canRedo()).to.equal(false); + }); + + it('clears the stacks for a specific document on load', function(done) { + var shouldReject = false; + this.backend.use('submit', function(request, next) { + if (shouldReject) return next(request.rejectedError()); + next(); + }); + + var undoManager = this.connection.createUndoManager({ composeInterval: -1 }); + var doc1 = this.connection.get('dogs', 'fido'); + var doc2 = this.connection.get('dogs', 'toby'); + doc1.create([], otRichText.type.uri); + doc2.create([], otRichText.type.uri); + doc1.submitOp([ otRichText.Action.createInsertText('2') ], { undoable: true }); + doc2.submitOp([ otRichText.Action.createInsertText('b') ], { undoable: true }); + doc1.submitOp([ otRichText.Action.createInsertText('1') ], { undoable: true }); + doc2.submitOp([ otRichText.Action.createInsertText('a') ], { undoable: true }); + undoManager.undo(); + undoManager.undo(); + expect(undoManager.canUndo()).to.equal(true); + expect(undoManager.canRedo()).to.equal(true); + + this.connection.whenNothingPending(function() { + shouldReject = true; + doc1.submitOp([ otRichText.Action.createInsertText('!') ], function(err) { + if (err) return done(err); + shouldReject = false; + expect(doc1.data).to.eql([ otRichText.Action.createInsertText('2') ]); + expect(undoManager.canUndo()).to.equal(true); + expect(undoManager.canRedo()).to.equal(true); + + undoManager.undo(); + expect(undoManager.canUndo()).to.equal(false); + expect(undoManager.canRedo()).to.equal(true); + expect(doc1.data).to.eql([ otRichText.Action.createInsertText('2') ]); + expect(doc2.data).to.eql([]); + + undoManager.redo(); + expect(undoManager.canUndo()).to.equal(true); + expect(undoManager.canRedo()).to.equal(true); + expect(doc1.data).to.eql([ otRichText.Action.createInsertText('2') ]); + expect(doc2.data).to.eql([ otRichText.Action.createInsertText('b') ]); + + undoManager.redo(); + expect(undoManager.canUndo()).to.equal(true); + expect(undoManager.canRedo()).to.equal(false); + expect(doc1.data).to.eql([ otRichText.Action.createInsertText('2') ]); + expect(doc2.data).to.eql([ otRichText.Action.createInsertText('ab') ]); + + done(); + }); + }.bind(this)); + }); + + it('clears the stacks for a specific document on doc destroy', function(done) { + var undoManager = this.connection.createUndoManager({ composeInterval: -1 }); + var doc1 = this.connection.get('dogs', 'fido'); + var doc2 = this.connection.get('dogs', 'toby'); + doc1.create({ test: 5 }); + doc2.create({ test: 11 }); + doc1.submitOp([ { p: [ 'test' ], 'na': 2 } ], { undoable: true }); + doc2.submitOp([ { p: [ 'test' ], 'na': 2 } ], { undoable: true }); + doc1.submitOp([ { p: [ 'test' ], 'na': 2 } ], { undoable: true }); + doc2.submitOp([ { p: [ 'test' ], 'na': 2 } ], { undoable: true }); + undoManager.undo(); + undoManager.undo(); + expect(undoManager.canUndo()).to.equal(true); + expect(undoManager.canRedo()).to.equal(true); + doc1.destroy(function(err) { + if (err) return done(err); + expect(undoManager.canUndo()).to.equal(true); + expect(undoManager.canRedo()).to.equal(true); + doc2.destroy(function(err) { + if (err) return done(err); + expect(undoManager.canUndo()).to.equal(false); + expect(undoManager.canRedo()).to.equal(false); + done(); + }); + }); + }); + }); + + it('submits snapshots and supports undo and redo', function() { + var undoManager = this.connection.createUndoManager({ composeInterval: -1 }); + this.doc.create([ otRichText.Action.createInsertText('ghi') ], otRichText.type.uri); + this.doc.submitSnapshot([ otRichText.Action.createInsertText('defghi') ], { undoable: true }); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('defghi') ]); + this.doc.submitSnapshot([ otRichText.Action.createInsertText('abcdefghi') ], { undoable: true }); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abcdefghi') ]); + undoManager.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('defghi') ]); + undoManager.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ghi') ]); + expect(undoManager.canUndo()).to.equal(false); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('defghi') ]); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abcdefghi') ]); + expect(undoManager.canRedo()).to.equal(false); + undoManager.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('defghi') ]); + undoManager.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ghi') ]); + expect(undoManager.canUndo()).to.equal(false); + }); + + it('submits snapshots and composes operations', function() { + var undoManager = this.connection.createUndoManager(); + this.doc.create([ otRichText.Action.createInsertText('ghi') ], otRichText.type.uri); + this.doc.submitSnapshot([ otRichText.Action.createInsertText('defghi') ], { undoable: true }); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('defghi') ]); + this.doc.submitSnapshot([ otRichText.Action.createInsertText('abcdefghi') ], { undoable: true }); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abcdefghi') ]); + undoManager.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ghi') ]); + expect(undoManager.canUndo()).to.equal(false); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abcdefghi') ]); + expect(undoManager.canRedo()).to.equal(false); + undoManager.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ghi') ]); + expect(undoManager.canUndo()).to.equal(false); + }); + + it('submits undoable and non-undoable snapshots', function() { + var undoManager = this.connection.createUndoManager({ composeInterval: -1 }); + this.doc.create([], otRichText.type.uri); + this.doc.submitSnapshot([ otRichText.Action.createInsertText('a') ], { undoable: true }); + this.doc.submitSnapshot([ otRichText.Action.createInsertText('ab') ], { undoable: true }); + this.doc.submitSnapshot([ otRichText.Action.createInsertText('abc') ], { undoable: true }); + this.doc.submitSnapshot([ otRichText.Action.createInsertText('abcd') ], { undoable: true }); + this.doc.submitSnapshot([ otRichText.Action.createInsertText('abcde') ], { undoable: true }); + this.doc.submitSnapshot([ otRichText.Action.createInsertText('abcdef') ], { undoable: true }); + undoManager.undo(); + undoManager.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abcd') ]); + this.doc.submitSnapshot([ otRichText.Action.createInsertText('abc123d') ]); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abc123d') ]); + undoManager.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abc123') ]); + undoManager.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ab123') ]); + undoManager.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('a123') ]); + undoManager.undo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('123') ]); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('a123') ]); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('ab123') ]); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abc123') ]); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abc123d') ]); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abc123de') ]); + undoManager.redo(); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('abc123def') ]); + }); + + it('submits a snapshot without a diffHint', function() { + var undoManager = this.connection.createUndoManager(); + var opCalled = 0; + this.doc.create([ otRichText.Action.createInsertText('aaaa') ], otRichText.type.uri); + this.doc.submitSnapshot([ otRichText.Action.createInsertText('aaaaa') ], { undoable: true }); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('aaaaa') ]); + + this.doc.once('op', function(op) { + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('aaaa') ]); + expect(op).to.eql([ otRichText.Action.createDelete(1) ]); + opCalled++; + }.bind(this)); + undoManager.undo(); + + this.doc.once('op', function(op) { + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('aaaaa') ]); + expect(op).to.eql([ otRichText.Action.createInsertText('a') ]); + opCalled++; + }.bind(this)); + undoManager.redo(); + + expect(opCalled).to.equal(2); + }); + + it('submits a snapshot with a diffHint', function() { + var undoManager = this.connection.createUndoManager(); + var opCalled = 0; + this.doc.create([ otRichText.Action.createInsertText('aaaa') ], otRichText.type.uri); + this.doc.submitSnapshot([ otRichText.Action.createInsertText('aaaaa') ], { undoable: true, diffHint: 2 }); + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('aaaaa') ]); + + this.doc.once('op', function(op) { + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('aaaa') ]); + expect(op).to.eql([ otRichText.Action.createRetain(2), otRichText.Action.createDelete(1) ]); + opCalled++; + }.bind(this)); + undoManager.undo(); + + this.doc.once('op', function(op) { + expect(this.doc.data).to.eql([ otRichText.Action.createInsertText('aaaaa') ]); + expect(op).to.eql([ otRichText.Action.createRetain(2), otRichText.Action.createInsertText('a') ]); + opCalled++; + }.bind(this)); + undoManager.redo(); + + expect(opCalled).to.equal(2); + }); + + it('submits a snapshot (with diff, non-undoable)', function() { + var undoManager = this.connection.createUndoManager(); + this.doc.create(5, invertibleType.typeWithDiff.uri); + this.doc.submitSnapshot(7); + expect(this.doc.data).to.equal(7); + expect(undoManager.canUndo()).to.equal(false); + expect(undoManager.canRedo()).to.equal(false); + }); + + it('submits a snapshot (with diff, undoable)', function() { + var undoManager = this.connection.createUndoManager(); + this.doc.create(5, invertibleType.typeWithDiff.uri); + this.doc.submitSnapshot(7, { undoable: true }); + expect(this.doc.data).to.equal(7); + undoManager.undo(); + expect(this.doc.data).to.equal(5); + undoManager.redo(); + expect(this.doc.data).to.equal(7); + }); + + it('submits a snapshot (with diffX, non-undoable)', function() { + var undoManager = this.connection.createUndoManager(); + this.doc.create(5, invertibleType.typeWithDiffX.uri); + this.doc.submitSnapshot(7); + expect(this.doc.data).to.equal(7); + expect(undoManager.canUndo()).to.equal(false); + expect(undoManager.canRedo()).to.equal(false); + }); + + it('submits a snapshot (with diffX, undoable)', function() { + var undoManager = this.connection.createUndoManager(); + this.doc.create(5, invertibleType.typeWithDiffX.uri); + this.doc.submitSnapshot(7, { undoable: true }); + expect(this.doc.data).to.equal(7); + undoManager.undo(); + expect(this.doc.data).to.equal(5); + undoManager.redo(); + expect(this.doc.data).to.equal(7); + }); + + it('submits a snapshot (with diff and diffX, non-undoable)', function() { + var undoManager = this.connection.createUndoManager(); + this.doc.create(5, invertibleType.typeWithDiffAndDiffX.uri); + this.doc.submitSnapshot(7); + expect(this.doc.data).to.equal(7); + expect(undoManager.canUndo()).to.equal(false); + expect(undoManager.canRedo()).to.equal(false); + }); + + it('submits a snapshot (with diff and diffX, undoable)', function() { + var undoManager = this.connection.createUndoManager(); + this.doc.create(5, invertibleType.typeWithDiffAndDiffX.uri); + this.doc.submitSnapshot(7, { undoable: true }); + expect(this.doc.data).to.equal(7); + undoManager.undo(); + expect(this.doc.data).to.equal(5); + undoManager.redo(); + expect(this.doc.data).to.equal(7); + }); +});