diff --git a/src/undo.js b/src/undo.js new file mode 100644 index 0000000..b99ee4f --- /dev/null +++ b/src/undo.js @@ -0,0 +1,80 @@ +/** + * @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. + * + * @memberOf undo + */ +export default class Undo extends Feature { + constructor( editor ) { + super( editor ); + + /** + * Undo command which manages undo {@link engine.treeModel.Batch batches} stack (history). + * Created and registered during {@link undo.Undo#init feature initialization}. + * + * @private + * @member {undo.UndoCommand} undo.Undo#_undoCommand + */ + this._undoCommand = null; + + /** + * Undo command which manages redo {@link engine.treeModel.Batch batches} stack (history). + * Created and registered during {@link undo.Undo#init feature initialization}. + * + * @private + * @member {undo.UndoCommand} undo.Undo#_redoCommand + */ + this._redoCommand = null; + + /** + * Keeps track of which batch has already been added to undo manager. + * + * @private + * @member {WeakSet.} undo.Undo#_batchRegistry + */ + this._batchRegistry = new WeakSet(); + } + + /** + * @inheritDoc + */ + 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 ); + + this.listenTo( this.editor.document, 'change', ( evt, type, changes, batch ) => { + // Whenever a new batch is created add it to the undo history and clear redo history. + if ( batch && !this._batchRegistry.has( batch ) ) { + this._batchRegistry.add( batch ); + this._undoCommand.addBatch( batch ); + this._redoCommand.clearStack(); + } + } ); + + // Whenever batch is reverted by undo command, add it to redo history. + this.listenTo( this._redoCommand, 'revert', ( evt, batch ) => { + this._undoCommand.addBatch( batch ); + } ); + + // Whenever batch is reverted by redo command, add it to undo history. + this.listenTo( this._undoCommand, 'revert', ( evt, batch ) => { + this._redoCommand.addBatch( batch ); + } ); + } +} diff --git a/src/undocommand.js b/src/undocommand.js new file mode 100644 index 0000000..9236d90 --- /dev/null +++ b/src/undocommand.js @@ -0,0 +1,211 @@ +/** + * @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. + * + * @memberOf undo + */ +export default class UndoCommand extends Command { + constructor( editor ) { + super( editor ); + + /** + * Items that are pairs of: + * + * * batches which are saved by the command and, + * * model selection state at the moment of saving the batch. + * + * @private + * @member {Array} undo.UndoCommand#_items + */ + this._items = []; + } + + /** + * Stores a batch in the command. Stored batches can be then reverted. + * + * @param {engine.treeModel.Batch} batch Batch to add. + */ + addBatch( batch ) { + const selection = { + ranges: Array.from( this.editor.document.selection.getRanges() ), + isBackward: this.editor.document.selection.isBackward + }; + + this._items.push( { batch, selection } ); + this.refreshState(); + } + + /** + * Removes all batches from the stack. + */ + clearStack() { + this._items = []; + this.refreshState(); + } + + /** + * @inheritDoc + */ + _checkEnabled() { + return this._items.length > 0; + } + + /** + * Executes the command: reverts a {@link engine.treeModel.Batch batch} added to the command's stack, + * applies it on the document and removes the batch from the stack. + * + * @protected + * @fires undo.undoCommand#event:revert + * @param {engine.treeModel.Batch} [batch] If set, batch that should be undone. If not set, the last added batch will be undone. + */ + _doExecute( batch ) { + let batchIndex; + + // If batch is not given, set `batchIndex` to the last index in command stack. + // If it is given, find it on the stack. + if ( !batch ) { + batchIndex = this._items.length - 1; + } else { + batchIndex = this._items.findIndex( item => item.batch == batch ); + } + + const undoItem = this._items.splice( batchIndex, 1 )[ 0 ]; + + // Get the batch to undo. + const undoBatch = undoItem.batch; + const undoDeltas = undoBatch.deltas.slice(); + // Deltas have to be applied in reverse order, so if batch did A B C, it has to do reversed C, reversed B, reversed A. + undoDeltas.reverse(); + + // Reverse the deltas from the batch, transform them, apply them. + for ( let undoDelta of undoDeltas ) { + const undoDeltaReversed = undoDelta.getReversed(); + const updatedDeltas = this.editor.document.history.getTransformedDelta( undoDeltaReversed ); + + for ( let delta of updatedDeltas ) { + for ( let operation of delta.operations ) { + this.editor.document.applyOperation( operation ); + } + } + } + + // Get the selection state stored with this batch. + const selectionState = undoItem.selection; + + // Take all selection ranges that were stored with undone batch. + const ranges = selectionState.ranges; + + // The ranges will be transformed by deltas from history that took place + // after the selection got stored. + const deltas = this.editor.document.history.getDeltas( undoBatch.deltas[ 0 ].baseVersion ); + + // This will keep the transformed ranges. + const transformedRanges = []; + + for ( let originalRange of ranges ) { + // We create `transformed` array. At the beginning it will have only the original range. + // During transformation the original range will change or even break into smaller ranges. + // After the range is broken into two ranges, we have to transform both of those ranges separately. + // For that reason, we keep all transformed ranges in one array and operate on it. + let transformed = [ originalRange ]; + + for ( let delta of deltas ) { + for ( let operation of delta.operations ) { + // We look through all operations from all deltas. + + for ( let t = 0; t < transformed.length; t++ ) { + // We transform every range by every operation. + // We keep current state of transformation in `transformed` array and update it. + let result; + + switch ( operation.type ) { + case 'insert': + result = transformed[ t ].getTransformedByInsertion( + operation.position, + operation.nodeList.length, + true + ); + break; + + case 'move': + case 'remove': + case 'reinsert': + result = transformed[ t ].getTransformedByMove( + operation.sourcePosition, + operation.targetPosition, + operation.howMany, + true + ); + break; + } + + // If we have a transformation result, we substitute it in `transformed` array with + // the range that got transformed. Keep in mind that the result is an array + // and may contain multiple ranges. + if ( result ) { + transformed.splice( t, 1, ...result ); + + // Fix iterator. + t = t + result.length - 1; + } + } + } + } + + // After `originalRange` got transformed, we have an array of ranges. Some of those + // ranges may be "touching" -- they can be next to each other and could be merged. + // Let's do this. First, we have to sort those ranges because they don't have to be + // in an order. + transformed.sort( ( a, b ) => a.start.isBefore( b.start ) ? -1 : 1 ); + + // Then we check if two consecutive ranges are touching. We can do it pair by pair + // in one dimensional loop because ranges are sorted. + for ( let i = 1 ; i < transformed.length; i++ ) { + let a = transformed[ i - 1 ]; + let b = transformed[ i ]; + + if ( a.end.isTouching( b.start ) ) { + a.end = b.end; + transformed.splice( i, 1 ); + i--; + } + } + + // For each `originalRange` from `ranges`, we take only one transformed range. + // This is because we want to prevent situation where single-range selection + // got transformed to mulit-range selection. We will take the first range that + // is not in the graveyard. + const transformedRange = transformed.find( + ( range ) => range.start.root != this.editor.document.graveyard + ); + + if ( transformedRange ) { + transformedRanges.push( transformedRange ); + } + } + + // `transformedRanges` may be empty if all ranges ended up in graveyard. + // If that is the case, do not restore selection. + if ( transformedRanges.length ) { + this.editor.document.selection.setRanges( transformedRanges, selectionState.isBackward ); + } + + this.refreshState(); + this.fire( 'revert', undoBatch ); + } +} + +/** + * Fired after `UndoCommand` reverts a batch. + * + * @event undo.UndoCommand#revert + * @param {engine.treeModel.Batch} undoBatch The batch instance that got reverted. + */ diff --git a/tests/undo.js b/tests/undo.js new file mode 100644 index 0000000..ccf39e4 --- /dev/null +++ b/tests/undo.js @@ -0,0 +1,332 @@ +/** + * @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 ModelDocument from '/ckeditor5/engine/treemodel/document.js'; +import Range from '/ckeditor5/engine/treemodel/range.js'; +import Position from '/ckeditor5/engine/treemodel/position.js'; +import Undo from '/ckeditor5/undo/undo.js'; +import Creator from '/ckeditor5/creator/creator.js'; + +import { getData, setData } from '/tests/engine/_utils/model.js'; + +// import deleteContents from '/ckeditor5/engine/treemodel/composer/deletecontents.js'; + +let element, editor, doc, root; + +beforeEach( () => { + element = document.createElement( 'div' ); + document.body.appendChild( element ); + + doc = new ModelDocument(); + root = doc.createRoot( 'root' ); + + editor = new Editor( element, { + creator: Creator, + features: [ Undo ] + } ); + + editor.document = doc; + + return editor.init(); +} ); + +function setSelection( pathA, pathB ) { + doc.selection.setRanges( [ new Range( new Position( root, pathA ), new Position( root, pathB ) ) ] ); +} + +function input( input ) { + setData( doc, 'root', input ); +} + +function output( output ) { + expect( getData( doc, 'root', { selection: true } ) ).to.equal( output ); +} + +function undoDisabled() { + expect( editor.commands.get( 'undo' ).isEnabled ).to.be.false; +} + +describe( 'undo integration', () => { + describe( 'adding and removing content', () => { + it( 'add and undo', () => { + input( '

foo

bar

' ); + + doc.batch().insert( doc.selection.getFirstPosition(), 'zzz' ); + output( '

fozzzo

bar

' ); + + editor.execute( 'undo' ); + output( '

foo

bar

' ); + + undoDisabled(); + } ); + + it( 'multiple adding and undo', () => { + input( '

foo

bar

' ); + + doc.batch() + .insert( doc.selection.getFirstPosition(), 'zzz' ) + .insert( new Position( root, [ 1, 0 ] ), 'xxx' ); + output( '

fozzzo

xxxbar

' ); + + setSelection( [ 1, 0 ], [ 1, 0 ] ); + doc.batch().insert( doc.selection.getFirstPosition(), 'yyy' ); + output( '

fozzzo

yyyxxxbar

' ); + + editor.execute( 'undo' ); + output( '

fozzzo

xxxbar

' ); + + editor.execute( 'undo' ); + output( '

foo

bar

' ); + + undoDisabled(); + } ); + + it( 'multiple adding mixed with undo', () => { + input( '

foo

bar

' ); + + doc.batch().insert( doc.selection.getFirstPosition(), 'zzz' ); + output( '

fozzzo

bar

' ); + + setSelection( [ 1, 0 ], [ 1, 0 ] ); + doc.batch().insert( doc.selection.getFirstPosition(), 'yyy' ); + output( '

fozzzo

yyybar

' ); + + editor.execute( 'undo' ); + output( '

fozzzo

bar

' ); + + setSelection( [ 0, 0 ], [ 0, 0 ] ); + doc.batch().insert( doc.selection.getFirstPosition(), 'xxx' ); + output( '

xxxfozzzo

bar

' ); + + editor.execute( 'undo' ); + output( '

fozzzo

bar

' ); + + editor.execute( 'undo' ); + output( '

foo

bar

' ); + + undoDisabled(); + } ); + + it( 'multiple remove and undo', () => { + input( '

foo

bar

' ); + + doc.batch().remove( Range.createFromPositionAndShift( doc.selection.getFirstPosition(), 2 ) ); + output( '

o

bar

' ); + + setSelection( [ 1, 1 ], [ 1, 1 ] ); + doc.batch().remove( Range.createFromPositionAndShift( doc.selection.getFirstPosition(), 2 ) ); + output( '

o

b

' ); + + editor.execute( 'undo' ); + // Here is an edge case that selection could be before or after `ar` but selection always ends up after. + output( '

o

bar

' ); + + editor.execute( 'undo' ); + // As above. + output( '

foo

bar

' ); + + undoDisabled(); + } ); + + it( 'add and remove different parts and undo', () => { + input( '

foo

bar

' ); + + doc.batch().insert( doc.selection.getFirstPosition(), 'zzz' ); + output( '

fozzzo

bar

' ); + + setSelection( [ 1, 2 ], [ 1, 2 ] ); + doc.batch().remove( Range.createFromPositionAndShift( new Position( root, [ 1, 1 ] ) , 1 ) ); + output( '

fozzzo

br

' ); + + editor.execute( 'undo' ); + output( '

fozzzo

bar

' ); + + editor.execute( 'undo' ); + output( '

foo

bar

' ); + + undoDisabled(); + } ); + + //it( 'add and remove same part and undo', () => { + // // This test case fails because some operations are transformed to NoOperations incorrectly. + // input( '

foo

bar

' ); + // + // doc.batch().insert( doc.selection.getFirstPosition(), 'zzz' ); + // output( '

fozzzo

bar

' ); + // + // doc.batch().remove( Range.createFromPositionAndShift( new Position( root, [ 0, 2 ] ) , 3 ) ); + // output( '

foo

bar

' ); + // + // editor.execute( 'undo' ); + // output( '

fozzzo

bar

' ); + // + // editor.execute( 'undo' ); + // output( '

foo

bar

' ); + // + // undoDisabled(); + //} ); + } ); + + describe( 'moving', () => { + //it( 'move same content twice then undo', () => { + // // This test case fails because some operations are transformed to NoOperations incorrectly. + // input( '

foz

bar

' ); + // + // doc.batch().move( doc.selection.getFirstRange(), new Position( root, [ 1, 0 ] ) ); + // output( '

fz

obar

' ); + // + // doc.batch().move( doc.selection.getFirstRange(), new Position( root, [ 0, 2 ] ) ); + // output( '

fzo

bar

' ); + // + // editor.execute( 'undo' ); + // output( '

fz

obar

' ); + // + // editor.execute( 'undo' ); + // output( '

foz

bar

' ); + // + // undoDisabled(); + //} ); + + it( 'move content and new parent then undo', () => { + input( '

foz

bar

' ); + + doc.batch().move( doc.selection.getFirstRange(), new Position( root, [ 1, 0 ] ) ); + output( '

fz

obar

' ); + + setSelection( [ 1 ], [ 2 ] ); + doc.batch().move( doc.selection.getFirstRange(), new Position( root, [ 0 ] ) ); + output( '

obar

fz

' ); + + editor.execute( 'undo' ); + output( '

fz

obar

' ); + + editor.execute( 'undo' ); + output( '

foz

bar

' ); + + undoDisabled(); + } ); + } ); + + describe( 'attributes with other', () => { + it( 'attributes then insert inside then undo', () => { + input( '

foobar

' ); + + doc.batch().setAttr( 'bold', true, doc.selection.getFirstRange() ); + output( '

fo<$text bold=true>obar

' ); + + setSelection( [ 0, 3 ], [ 0, 3 ] ); + doc.batch().insert( doc.selection.getFirstPosition(), 'zzz' ); + output( '

fo<$text bold=true>ozzz<$text bold=true>bar

' ); + + editor.execute( 'undo' ); + output( '

fo<$text bold=true>obar

' ); + + editor.execute( 'undo' ); + output( '

foobar

' ); + + undoDisabled(); + } ); + } ); + + describe( 'wrapping, unwrapping, merging, splitting', () => { + it( 'wrap and undo', () => { + input( 'fozbar' ); + + doc.batch().wrap( doc.selection.getFirstRange(), 'p' ); + output( 'fo

zb

ar' ); + + editor.execute( 'undo' ); + output( 'fozbar' ); + + undoDisabled(); + } ); + + //it( 'wrap, move and undo', () => { + // input( 'fozbar' ); + // + // doc.batch().wrap( doc.selection.getFirstRange(), 'p' ); + // output( 'fo

zb

ar' ); + // + // setSelection( [ 2, 0 ], [ 2, 1 ] ); + // doc.batch().move( doc.selection.getFirstRange(), new Position( root, [ 0 ] ) ); + // output( 'zfo

b

ar' ); + // + // editor.execute( 'undo' ); + // output( 'fo

zb

ar' ); + // + // // This test case fails here for unknown reason, but "z" letter magically disappears. + // // AssertionError: expected 'fobar' to equal 'fozbar' + // editor.execute( 'undo' ); + // output( 'fozbar' ); + // + // undoDisabled(); + //} ); + + it( 'unwrap and undo', () => { + input( '

foobar

' ); + + doc.batch().unwrap( doc.selection.getFirstPosition().parent ); + output( 'foobar' ); + + editor.execute( 'undo' ); + output( '

foobar

' ); + + undoDisabled(); + } ); + + //it( 'merge and undo', () => { + // input( '

foo

bar

' ); + // + // doc.batch().merge( new Position( root, [ 1 ] ) ); + // // This test fails here because selection is stuck with

element and ends up in graveyard. + // // AssertionError: expected '

foobar

' to equal '

foobar

' + // output( '

foobar

' ); + // + // editor.execute( 'undo' ); + // // This test fails because when selection is transformed it is first in empty

but when + // // "bar" is inserted, it gets moved to the right. + // // AssertionError: expected '

foo

bar

' to equal '

foo

bar

' + // output( '

foo

bar

' ); + // + // undoDisabled(); + //} ); + + //it( 'split and undo', () => { + // input( '

foobar

' ); + // + // doc.batch().split( doc.selection.getFirstPosition() ); + // // This test fails because selection ends up in wrong node after splitting. + // // AssertionError: expected '

foo

bar

' to equal '

foo

bar

' + // output( '

foo

bar

' ); + // + // editor.execute( 'undo' ); + // // This test fails because selection after transforming ends up after inserted test. + // // AssertionError: expected '

foobar

' to equal '

foobar

' + // output( '

foobar

' ); + // + // undoDisabled(); + //} ); + } ); + + describe( 'other edge cases', () => { + //it( 'deleteContents between two nodes', () => { + // input( '

foo

bar

' ); + // + // deleteContents( doc.batch(), doc.selection, { merge: true } ); + // output( '

foar

' ); + // + // // This test case fails because of OT problems. + // // When the batch is undone, first MergeDelta is reversed to SplitDelta and it is undone. + // // Then RemoveOperations are reversed to ReinsertOperation. + // // Unfortunately, ReinsertOperation that inserts "o" points to the same position were split happened. + // // Then, when ReinsertOperation is transformed by operations of SplitDelta, it ends up in wrong

. + // editor.execute( 'undo' ); + // output( '

foo

bar

' ); + //} ); + } ); +} ); diff --git a/tests/undocommand.js b/tests/undocommand.js new file mode 100644 index 0000000..e5e9b5e --- /dev/null +++ b/tests/undocommand.js @@ -0,0 +1,302 @@ +/** + * @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 ModelDocument from '/ckeditor5/engine/treemodel/document.js'; +import Range from '/ckeditor5/engine/treemodel/range.js'; +import Position from '/ckeditor5/engine/treemodel/position.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 = new ModelDocument(); + editor.document = doc; + + root = doc.createRoot( 'root' ); +} ); + +afterEach( () => { + undo.destroy(); +} ); + +describe( 'UndoCommand', () => { + describe( 'constructor', () => { + it( 'should create undo command with empty batch stack', () => { + expect( undo._checkEnabled() ).to.be.false; + } ); + } ); + + describe( 'clearStack', () => { + it( 'should remove all batches from the stack', () => { + undo.addBatch( doc.batch() ); + expect( undo._checkEnabled() ).to.be.true; + + undo.clearStack(); + expect( undo._checkEnabled() ).to.be.false; + } ); + } ); + + 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( '_execute', () => { + const p = pos => new Position( root, [].concat( pos ) ); + const r = ( a, b ) => new Range( p( a ), p( b ) ); + + let batch0, batch1, batch2, batch3; + + beforeEach( () => { + /* + [root] + - {} + */ + editor.document.selection.setRanges( [ r( 0, 0 ) ] ); + batch0 = doc.batch(); + undo.addBatch( batch0 ); + batch0.insert( p( 0 ), 'foobar' ); + /* + [root] + - f + - o + - o + - b + - a + - r{} + */ + // Let's make things spicy and this time, make a backward selection. + editor.document.selection.setRanges( [ r( 2, 4 ) ], true ); + batch1 = doc.batch(); + undo.addBatch( batch1 ); + batch1.setAttr( 'key', 'value', r( 2, 4 ) ); + /* + [root] + - f + - o + - {o (key: value) + - b} (key: value) + - a + - r + */ + editor.document.selection.setRanges( [ r( 1, 3 ) ] ); + batch2 = doc.batch(); + undo.addBatch( batch2 ); + batch2.move( r( 1, 3 ), p( 6 ) ); + /* + [root] + - f + - b (key: value) + - a + - r + - {o + - o} (key: value) + */ + editor.document.selection.setRanges( [ r( 1, 4 ) ] ); + batch3 = doc.batch(); + undo.addBatch( batch3 ); + batch3.wrap( r( 1, 4 ), 'p' ); + /* + [root] + - f + - [p] + - {b (key: value) + - a + - r} + - o + - o (key: value) + */ + editor.document.selection.setRanges( [ r( 0, 1 ) ] ); + batch2.move( r( 0, 1 ), p( 3 ) ); + /* + [root] + - [p] + - b (key: value) + - a + - r + - o + - f + - o{} (key: value) + */ + editor.document.selection.setRanges( [ r( 4, 4 ) ] ); + } ); + + it( 'should revert changes done by deltas from the batch that was most recently added to the command stack', () => { + undo._execute(); + + // Selection is restored. 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' ); + + expect( editor.document.selection.getRanges().next().value.isEqual( r( 0, 3 ) ) ).to.be.true; + expect( editor.document.selection.isBackward ).to.be.false; + + undo._execute(); + + // 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' ); + + expect( editor.document.selection.getRanges().next().value.isEqual( r( 1, 3 ) ) ).to.be.true; + expect( editor.document.selection.isBackward ).to.be.false; + + undo._execute(); + + // 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; + + expect( editor.document.selection.getRanges().next().value.isEqual( r( 2, 4 ) ) ).to.be.true; + expect( editor.document.selection.isBackward ).to.be.true; + + undo._execute(); + + // Insert is undone: + /* + [root] + */ + + expect( root.getChildCount() ).to.equal( 0 ); + expect( editor.document.selection.getRanges().next().value.isEqual( r( 0, 0 ) ) ).to.be.true; + } ); + + it( 'should revert changes done by deltas from given batch, if parameter was passed (test: revert set attribute)', () => { + // editor.document.selection.setRanges( [ r( [ 0, 1 ], [ 0, 2 ] ) ] ); + + undo._execute( batch1 ); + // 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; + + // Selection is only partially restored because the range got broken. + // The selection would have to best on letter "b" and letter "o", but it is set only on letter "b". + expect( editor.document.selection.getRanges().next().value.isEqual( r( [ 0, 0 ], [ 0, 1 ] ) ) ).to.be.true; + expect( editor.document.selection.isBackward ).to.be.true; + } ); + + it( 'should revert changes done by deltas from given batch, if parameter was passed (test: revert insert foobar)', () => { + undo._execute( batch0 ); + // 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' ); + + // Because P element was inserted in the middle of removed text and it was not removed, + // the selection is set after it. + expect( editor.document.selection.getRanges().next().value.isEqual( r( 1, 1 ) ) ).to.be.true; + expect( editor.document.selection.isBackward ).to.be.false; + + undo._execute( batch1 ); + // 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' ); + + // Operations for undoing that batch were working on graveyard so document selection should not change. + expect( editor.document.selection.getRanges().next().value.isEqual( r( 1, 1 ) ) ).to.be.true; + expect( editor.document.selection.isBackward ).to.be.false; + + expect( doc.graveyard.getChildCount() ).to.equal( 6 ); + + 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._execute( batch3 ); + expect( root.getChildCount() ).to.equal( 0 ); + + // Once again transformed range ends up in the graveyard. + // So we do not restore it. But since Selection is a LiveRange itself it will update + // because the node before it (P element) got removed. + expect( editor.document.selection.getRanges().next().value.isEqual( r( 0, 0 ) ) ).to.be.true; + expect( editor.document.selection.isBackward ).to.be.false; + } ); + + it( 'should fire undo event with the undone batch', () => { + const batch = doc.batch(); + const spy = sinon.spy(); + + undo.on( 'revert', spy ); + + undo._execute(); + + 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..0ac46d6 --- /dev/null +++ b/tests/undofeature.js @@ -0,0 +1,83 @@ +/** + * @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 ModelDocument from '/ckeditor5/engine/treemodel/document.js'; +import Position from '/ckeditor5/engine/treemodel/position.js'; +import UndoFeature from '/ckeditor5/undo/undo.js'; + +let element, editor, undo, batch, doc, root; + +beforeEach( () => { + element = document.createElement( 'div' ); + document.body.appendChild( element ); + + editor = new Editor( element ); + + doc = new ModelDocument(); + editor.document = doc; + batch = doc.batch(); + root = doc.createRoot( 'root' ); + + undo = new UndoFeature( editor ); + undo.init(); +} ); + +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( 'revert', 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( 'revert', 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; + } ); +} );