From c055ebf65cee4fbe80bae3a593d633654c9345d6 Mon Sep 17 00:00:00 2001 From: Szymon Cofalik Date: Fri, 18 Mar 2016 16:06:41 +0100 Subject: [PATCH] Undo Feature initial commit. --- src/undocommand.js | 89 +++++++++++++++ src/undofeature.js | 71 ++++++++++++ tests/undocommand.js | 267 +++++++++++++++++++++++++++++++++++++++++++ tests/undofeature.js | 88 ++++++++++++++ 4 files changed, 515 insertions(+) create mode 100644 src/undocommand.js create mode 100644 src/undofeature.js create mode 100644 tests/undocommand.js create mode 100644 tests/undofeature.js diff --git a/src/undocommand.js b/src/undocommand.js new file mode 100644 index 0000000..5909f43 --- /dev/null +++ b/src/undocommand.js @@ -0,0 +1,89 @@ +/** + * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +'use strict'; + +import Command from '../command/command.js'; + +/** + * Undo command stores batches in itself and is able to and apply reverted versions of them on the document. + * + * undo.UndoCommand + */ +export default class UndoCommand extends Command { + /** + * @see core.command.Command + * @param {core.Editor} editor + */ + constructor( editor ) { + super( editor ); + + /** + * Batches which are saved by the command. They can be reversed. + * + * @private + * @member {Array.} core.command.UndoCommand#_batchStack + */ + this._batchStack = []; + } + + /** + * Stores a batch in the command. Stored batches can be then reverted. + * + * @param {core.teeModel.Batch} batch Batch to add. + */ + addBatch( batch ) { + this._batchStack.push( batch ); + } + + /** + * Removes all batches from the stack. + */ + clearStack() { + this._batchStack = []; + } + + /** + * Checks whether this command should be enabled. Command is enabled when it has any batches in its stack. + * + * @private + * @returns {Boolean} + */ + _checkEnabled() { + return this._batchStack.length > 0; + } + + /** + * Executes the command: reverts a {@link core.treeModel.Batch batch} added to the command's stack, + * applies it on the document and removes the batch from the stack. + * + * Fires `undo` event with reverted batch as a parameter. + * + * @private + * @param {Number} [batchIndex] If set, batch under the given index on the stack will be reverted and removed. + * If not set, or invalid, the last added batch will be reverted and removed. + */ + _doExecute( batchIndex ) { + batchIndex = this._batchStack[ batchIndex ] ? batchIndex : this._batchStack.length - 1; + + const undoBatch = this._batchStack.splice( batchIndex, 1 )[ 0 ]; + const undoDeltas = undoBatch.deltas.slice(); + + undoDeltas.reverse(); + + for ( let undoDelta of undoDeltas ) { + const undoDeltaReversed = undoDelta.getReversed(); + const updatedDeltas = this.editor.document.history.updateDelta( undoDeltaReversed ); + + for ( let delta of updatedDeltas ) { + for ( let operation of delta.operations ) { + this.editor.document.applyOperation( operation ); + } + } + } + + this.fire( 'undo', undoBatch ); + } +} diff --git a/src/undofeature.js b/src/undofeature.js new file mode 100644 index 0000000..ec837be --- /dev/null +++ b/src/undofeature.js @@ -0,0 +1,71 @@ +/** + * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +'use strict'; + +import Feature from '../feature.js'; +import UndoCommand from './undocommand.js'; + +/** + * Undo feature. + * + * Undo features brings in possibility to undo and re-do changes done in Tree Model by deltas through Batch API. + */ +export default class UndoFeature extends Feature { + constructor( editor ) { + super( editor ); + + /** + * Undo command which manages undo {@link core.treeModel.Batch batches} stack (history). + * Created and registered during {@link undo.UndoFeature#init feature initialization}. + * + * @private + * @member {undo.UndoCommand} undo.UndoFeature#_undoCommand + */ + this._undoCommand = null; + + /** + * Undo command which manages redo {@link core.treeModel.Batch batches} stack (history). + * Created and registered during {@link undo.UndoFeature#init feature initialization}. + * + * @private + * @member {undo.UndoCommand} undo.UndoFeature#_redoCommand + */ + this._redoCommand = null; + } + + /** + * Initializes undo feature. + */ + init() { + // Create commands. + this._redoCommand = new UndoCommand( this.editor ); + this._undoCommand = new UndoCommand( this.editor ); + + // Register command to the editor. + this.editor.commands.set( 'redo', this._redoCommand ); + this.editor.commands.set( 'undo', this._undoCommand ); + + // Whenever new batch is created add it to undo history and clear redo history. + this.listenTo( this.editor.document, 'batch', ( evt, batch ) => { + this._undoCommand.addBatch( batch ); + this._redoCommand.clearStack(); + } ); + + // Whenever batch is reverted by undo command, add it to redo history. + this._undoCommand.listenTo( this._redoCommand, 'undo', ( evt, batch ) => { + this._undoCommand.addBatch( batch ); + } ); + + // Whenever batch is reverted by redo command, add it to undo history. + this._redoCommand.listenTo( this._undoCommand, 'undo', ( evt, batch ) => { + this._redoCommand.addBatch( batch ); + } ); + } + + destroy() { + this.stopListening(); + } +} diff --git a/tests/undocommand.js b/tests/undocommand.js new file mode 100644 index 0000000..71ad5d7 --- /dev/null +++ b/tests/undocommand.js @@ -0,0 +1,267 @@ +/** + * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +'use strict'; + +import Editor from '/ckeditor5/editor.js'; +import Position from '/ckeditor5/core/treemodel/position.js'; +import Range from '/ckeditor5/core/treemodel/range.js'; +import UndoCommand from '/ckeditor5/undo/undocommand.js'; + +let element, editor, doc, root, undo; + +beforeEach( () => { + element = document.createElement( 'div' ); + document.body.appendChild( element ); + + editor = new Editor( element ); + undo = new UndoCommand( editor ); + + doc = editor.document; + root = doc.createRoot( 'root' ); +} ); + +afterEach( () => { + undo.destroy(); +} ); + +describe( 'UndoCommand', () => { + describe( 'constructor', () => { + it( 'should create undo command with empty batch stack', () => { + expect( undo._batchStack.length ).to.equal( 0 ); + } ); + } ); + + describe( 'addBatch', () => { + it( 'should add a batch to command stack', () => { + const batch = doc.batch(); + undo.addBatch( batch ); + + expect( undo._batchStack.length ).to.equal( 1 ); + expect( undo._batchStack[ 0 ] ).to.equal( batch ); + } ); + } ); + + describe( 'clearStack', () => { + it( 'should remove all batches from the stack', () => { + undo.addBatch( doc.batch() ); + undo.clearStack(); + + expect( undo._batchStack.length ).to.equal( 0 ); + } ); + } ); + + describe( '_checkEnabled', () => { + it( 'should return false if there are no batches in command stack', () => { + expect( undo._checkEnabled() ).to.be.false; + } ); + + it( 'should return true if there are batches in command stack', () => { + undo.addBatch( doc.batch() ); + + expect( undo._checkEnabled() ).to.be.true; + } ); + } ); + + describe( '_doExecute', () => { + const p = pos => new Position( root, [ pos ] ); + const r = ( a, b ) => new Range( p( a ), p( b ) ); + + let batch0, batch1, batch2, batch3; + + beforeEach( () => { + /* + [root] + */ + batch0 = doc.batch().insert( p( 0 ), 'foobar' ); + /* + [root] + - f + - o + - o + - b + - a + - r + */ + batch1 = doc.batch().setAttr( 'key', 'value', r( 2, 4 ) ); + /* + [root] + - f + - o + - o {key: value} + - b {key: value} + - a + - r + */ + batch2 = doc.batch().move( r( 1, 3 ), p( 6 ) ); + /* + [root] + - f + - b {key: value} + - a + - r + - o + - o {key: value} + */ + batch3 = doc.batch().wrap( r( 1, 4 ), 'p' ); + /* + [root] + - f + - [p] + - b {key: value} + - a + - r + - o + - o {key: value} + */ + batch2.move( r( 0, 1 ), p( 3 ) ); + /* + [root] + - [p] + - b {key: value} + - a + - r + - o + - f + - o {key: value} + */ + + undo.addBatch( batch0 ); + undo.addBatch( batch1 ); + undo.addBatch( batch2 ); + undo.addBatch( batch3 ); + } ); + + it( 'should revert changes done by deltas from the batch that was most recently added to the command stack', () => { + undo._doExecute(); + + // Wrap is removed: + /* + [root] + - b {key: value} + - a + - r + - o + - f + - o {key: value} + */ + + expect( Array.from( root._children._nodes.map( node => node.text ) ).join( '' ) ).to.equal( 'barofo' ); + expect( root.getChild( 0 ).getAttribute( 'key' ) ).to.equal( 'value' ); + expect( root.getChild( 5 ).getAttribute( 'key' ) ).to.equal( 'value' ); + + undo._doExecute(); + + // Two moves are removed: + /* + [root] + - f + - o + - o {key: value} + - b {key: value} + - a + - r + */ + + expect( Array.from( root._children._nodes.map( node => node.text ) ).join( '' ) ).to.equal( 'foobar' ); + expect( root.getChild( 2 ).getAttribute( 'key' ) ).to.equal( 'value' ); + expect( root.getChild( 3 ).getAttribute( 'key' ) ).to.equal( 'value' ); + + undo._doExecute(); + + // Set attribute is undone: + /* + [root] + - f + - o + - o + - b + - a + - r + */ + + expect( Array.from( root._children._nodes.map( node => node.text ) ).join( '' ) ).to.equal( 'foobar' ); + expect( root.getChild( 2 ).hasAttribute( 'key' ) ).to.be.false; + expect( root.getChild( 3 ).hasAttribute( 'key' ) ).to.be.false; + + undo._doExecute(); + + // Insert is undone: + /* + [root] + */ + + expect( root.getChildCount() ).to.equal( 0 ); + } ); + + it( 'should revert changes done by deltas from given batch, if parameter was passed (test: revert set attribute)', () => { + undo._doExecute( 1 ); + // Remove attribute: + /* + [root] + - [p] + - b + - a + - r + - o + - f + - o + */ + + expect( root.getChild( 0 ).name ).to.equal( 'p' ); + expect( Array.from( root.getChild( 0 )._children._nodes.map( node => node.text ) ).join( '' ) ).to.equal( 'bar' ); + expect( root.getChild( 0 ).getChild( 0 ).hasAttribute( 'key' ) ).to.be.false; + expect( root.getChild( 2 ).hasAttribute( 'key' ) ).to.be.false; + expect( root.getChild( 3 ).hasAttribute( 'key' ) ).to.be.false; + } ); + + it( 'should revert changes done by deltas from given batch, if parameter was passed (test: revert insert foobar)', () => { + undo._doExecute( 0 ); + // Remove foobar: + /* + [root] + - [p] + */ + + // The `P` element wasn't removed because it wasn`t added by undone batch. + // It would be perfect if the `P` got removed aswell because wrapping was on removed nodes. + // But this would need a lot of logic / hardcoded ifs or a post-fixer. + expect( root.getChildCount() ).to.equal( 1 ); + expect( root.getChild( 0 ).name ).to.equal( 'p' ); + + undo._doExecute( 0 ); + // Remove attributes. + // This does nothing in the `root` because attributes were set on nodes that already got removed. + // But those nodes should change in they graveyard and we can check them there. + + expect( root.getChildCount() ).to.equal( 1 ); + expect( root.getChild( 0 ).name ).to.equal( 'p' ); + + expect( doc.graveyard.getChildCount() ).to.equal( 6 ); + // TODO: This one does not work because nodes are moved inside graveyard... + // TODO: Perfect situation would be if the nodes are moved to graveyard in the order they are in original tree. + // expect( Array.from( doc.graveyard._children._nodes.map( node => node.text ) ).join( '' ) ).to.equal( 'barofo' ); + for ( let char of doc.graveyard._children ) { + expect( char.hasAttribute( 'key' ) ).to.be.false; + } + + // Let's undo wrapping. This should leave us with empty root. + undo._doExecute( 1 ); + expect( root.getChildCount() ).to.equal( 0 ); + } ); + + it( 'should fire undo event with the undone batch', () => { + const batch = doc.batch(); + const spy = sinon.spy(); + + undo.on( 'undo', spy ); + + undo._doExecute(); + + expect( spy.calledOnce ).to.be.true; + expect( spy.calledWith( batch ) ); + } ); + } ); +} ); diff --git a/tests/undofeature.js b/tests/undofeature.js new file mode 100644 index 0000000..d559af9 --- /dev/null +++ b/tests/undofeature.js @@ -0,0 +1,88 @@ +/** + * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +'use strict'; + +import Editor from '/ckeditor5/editor.js'; +import Position from '/ckeditor5/core/treemodel/position.js'; +import UndoFeature from '/ckeditor5/undo/undofeature.js'; + +let element, editor, undo, batch, doc, root; + +beforeEach( () => { + element = document.createElement( 'div' ); + document.body.appendChild( element ); + + editor = new Editor( element ); + undo = new UndoFeature( editor ); + undo.init(); + + doc = editor.document; + batch = doc.batch(); + root = doc.createRoot( 'root' ); +} ); + +afterEach( () => { + undo.destroy(); +} ); + +describe( 'UndoFeature', () => { + it( 'should register undo command and redo command', () => { + expect( editor.commands.get( 'undo' ) ).to.equal( undo._undoCommand ); + expect( editor.commands.get( 'redo' ) ).to.equal( undo._redoCommand ); + } ); + + it( 'should add a batch to undo command whenever a new batch is applied to the document', () => { + sinon.spy( undo._undoCommand, 'addBatch' ); + + expect( undo._undoCommand.addBatch.called ).to.be.false; + + batch.insert( new Position( root, [ 0 ] ), 'foobar' ); + + expect( undo._undoCommand.addBatch.calledOnce ).to.be.true; + + batch.insert( new Position( root, [ 0 ] ), 'foobar' ); + + expect( undo._undoCommand.addBatch.calledOnce ).to.be.true; + } ); + + it( 'should add a batch to redo command whenever a batch is undone by undo command', () => { + batch.insert( new Position( root, [ 0 ] ), 'foobar' ); + + sinon.spy( undo._redoCommand, 'addBatch' ); + + undo._undoCommand.fire( 'undo', batch ); + + expect( undo._redoCommand.addBatch.calledOnce ).to.be.true; + expect( undo._redoCommand.addBatch.calledWith( batch ) ).to.be.true; + } ); + + it( 'should add a batch to undo command whenever a batch is redone by redo command', () => { + batch.insert( new Position( root, [ 0 ] ), 'foobar' ); + + sinon.spy( undo._undoCommand, 'addBatch' ); + + undo._redoCommand.fire( 'undo', batch ); + + expect( undo._undoCommand.addBatch.calledOnce ).to.be.true; + expect( undo._undoCommand.addBatch.calledWith( batch ) ).to.be.true; + } ); + + it( 'should clear redo command stack whenever a new batch is applied to the document', () => { + sinon.spy( undo._redoCommand, 'clearStack' ); + + batch.insert( new Position( root, [ 0 ] ), 'foobar' ); + + expect( undo._redoCommand.clearStack.calledOnce ).to.be.true; + } ); + + it( 'should stop listening when destroyed', () => { + sinon.spy( undo, 'stopListening' ); + + undo.destroy(); + + expect( undo.stopListening.called ).to.be.true; + } ); +} );