this.loadXML(xml);
this.undoStack = [];
this.redoStack = [];
+ this._transactionStack = [];
this._transformationLevel = 0;
this._nodeMethods = {};
this._transformationLevel++;
toret = transformation.run({beUndoable:this._transformationLevel === 1});
if(this._transformationLevel === 1 && !this._undoInProgress) {
- this.undoStack.push(transformation);
+ if(this._transactionInProgress) {
+ this._transactionStack.push(transformation);
+ } else {
+ this.undoStack.push(transformation);
+ }
}
if(!this._undoInProgress && this._transformationLevel === 1) {
this.redoStack = [];
}
},
undo: function() {
- var transformation = this.undoStack.pop();
- if(transformation) {
+ var transformationObject = this.undoStack.pop(),
+ doc = this,
+ transformations, stopAt;
+
+ if(transformationObject) {
this._undoInProgress = true;
- transformation.undo();
+
+ if(_.isArray(transformationObject)) {
+ // We will modify this array in a minute so make sure we work on a copy.
+ transformations = transformationObject.slice(0);
+ } else {
+ // Lets normalize single transformation to a transaction containing one transformation.
+ transformations = [transformationObject];
+ }
+
+ if(transformations.length > 1) {
+ // In case of real transactions we don't want to run undo on all of transformations if we don't have to.
+ stopAt = undefined;
+ transformations.some(function(t, idx) {
+ if(!t.undo && t.getChangeRoot().sameNode(doc.root)) {
+ stopAt = idx;
+ return true; //break
+ }
+ });
+ if(stopAt !== undefined) {
+ // We will get away with undoing only this transformations as the one at stopAt reverses the whole document.
+ transformations = transformations.slice(0, stopAt+1);
+ }
+ }
+
+ transformations.reverse();
+ transformations.forEach(function(t) {
+ t.undo();
+ });
+
this._undoInProgress = false;
- this.redoStack.push(transformation);
+ this.redoStack.push(transformationObject);
}
},
redo: function() {
- var transformation = this.redoStack.pop();
- if(transformation) {
+ var transformationObject = this.redoStack.pop(),
+ transformations;
+ if(transformationObject) {
this._transformationLevel++;
- transformation.run({beUndoable: true});
+ transformations = _.isArray(transformationObject) ? transformationObject : [transformationObject];
+ transformations.forEach(function(t) {
+ t.run({beUndoable: true});
+ });
this._transformationLevel--;
- this.undoStack.push(transformation);
+ this.undoStack.push(transformationObject);
+ }
+ },
+
+ startTransaction: function() {
+ if(this._transactionInProgress) {
+ throw new Error('Nested transactions not supported!');
+ }
+ this._transactionInProgress = true;
+ },
+ endTransaction: function() {
+ if(!this._transactionInProgress) {
+ throw new Error('End of transaction requested, but there is no transaction in progress!');
}
+ this._transactionInProgress = false;
+ this.undoStack.push(this._transactionStack);
+ this._transactionStack = [];
},
getNodeByPath: function(path) {
expect(doc.root.getAttr('outerAttr')).to.equal('test2');
});
+
+ describe('Transactions', function() {
+ it('allows to undo/redo series of transformations at once', function() {
+ var doc = getDocumentFromXML('<div></div>');
+
+ doc.registerExtension({
+ elementNode: {transformations: {
+ test: function(v) {
+ this.setAttr('test', v);
+ }
+ }}
+ });
+
+ doc.startTransaction();
+ doc.root.test('1');
+ doc.root.test('2');
+ doc.root.test('3');
+ doc.endTransaction();
+
+ doc.undo();
+ expect(doc.root.getAttr('test'), '1');
+ doc.redo();
+ expect(doc.root.getAttr('test'), '3');
+ doc.undo();
+ expect(doc.root.getAttr('test'), '1');
+ doc.redo();
+ expect(doc.root.getAttr('test'), '3');
+ });
+
+ it('doesn\'t break on optimizations', function() {
+ // This is a smoke test checking if optimizations made to transaction undoing
+ // doesnt't break anything.
+ var doc = getDocumentFromXML('<div smart="1" unaware="1"></div>');
+
+ doc.registerExtension({
+ elementNode: {transformations: {
+ unaware: function(v) {
+ this.setAttr('unware', v);
+ },
+ smart: {
+ impl: function(t, v) {
+ t.oldVal = this.getAttr('smart');
+ this.setAttr('smart', v);
+ },
+ undo: function(t) {
+ this.setAttr('smart', t.oldVal);
+ }
+ }
+ }}
+ });
+
+ doc.startTransaction();
+ doc.root.smart('2');
+ doc.root.unaware('2');
+ doc.root.smart('3');
+ doc.root.unaware('3');
+ doc.endTransaction();
+
+ doc.undo();
+
+ expect(doc.root.getAttr('smart')).to.equal('1');
+ expect(doc.root.getAttr('unaware')).to.equal('1');
+ });
+ });
});
});
run: function(options) {
var changeRoot;
if(!desc.undo && options.beUndoable) {
- if(desc.getChangeRoot) {
- changeRoot = desc.getChangeRoot.call(this);
- if(!changeRoot) {
- throw new Error(
- 'Transformation {name} returned invalid change root value'
- .replace('{name}', name)
- );
- }
- } else {
- changeRoot = this.document.root;
+ changeRoot = this.getChangeRoot();
+ if(!changeRoot) {
+ throw new Error(
+ 'Transformation {name} returned invalid change root value'
+ .replace('{name}', name)
+ );
}
- this.snapshot = changeRoot.clone();
this.changeRootPath = changeRoot.getPath();
+ this.snapshot = changeRoot.clone();
}
var argsToPass = desc.undo ? [this].concat(this.args) : this.args;
var toret = desc.impl.apply(this.context, argsToPass);
this.document.getNodeByPath(this.changeRootPath).replaceWith(this.snapshot);
}
},
+ getChangeRoot: desc.getChangeRoot || function() {
+ return this.document.root;
+ }
});
return GenericTransformation;