8
0
Просмотр исходного кода

Merge pull request #63 from ckeditor/ckeditor5-engine/remove-refactor

Internal: Undo algorithms were simplified thanks to changes in the model. See https://github.com/ckeditor/ckeditor5-engine/pull/977.
Piotrek Koszuliński 8 лет назад
Родитель
Сommit
f86eae8fcf

+ 53 - 5
packages/ckeditor5-undo/src/basecommand.js

@@ -79,6 +79,7 @@ export default class BaseCommand extends Command {
 	 * @protected
 	 * @param {Array.<module:engine/model/range~Range>} ranges Ranges to be restored.
 	 * @param {Boolean} isBackward A flag describing whether the restored range was selected forward or backward.
+	 * @param {Array.<module:engine/model/delta/delta~Delta>} deltas Deltas which has been applied since selection has been stored.
 	 */
 	_restoreSelection( ranges, isBackward, deltas ) {
 		const document = this.editor.document;
@@ -109,19 +110,65 @@ export default class BaseCommand extends Command {
 			document.selection.setRanges( selectionRanges, isBackward );
 		}
 	}
+
+	/**
+	 * Undoes a batch by reversing that batch, transforming reversed batch and finally applying it.
+	 * This is a helper method for {@link #execute}.
+	 *
+	 * @protected
+	 * @param {module:engine/model/batch~Batch} batchToUndo The batch to be undone.
+	 */
+	_undo( batchToUndo ) {
+		const document = this.editor.document;
+
+		// All changes done by the command execution will be saved as one batch.
+		const undoingBatch = document.batch();
+		this._createdBatches.add( undoingBatch );
+
+		const deltasToUndo = batchToUndo.deltas.slice();
+		deltasToUndo.reverse();
+
+		// We will process each delta from `batchToUndo`, in reverse order. If there were deltas A, B and C in undone batch,
+		// we need to revert them in reverse order, so first C' (reversed C), then B', then A'.
+		for ( const deltaToUndo of deltasToUndo ) {
+			// Keep in mind that transformation algorithms return arrays. That's because the transformation might result in multiple
+			// deltas, so we need arrays to handle them. To simplify algorithms, it is better to always operate on arrays.
+			const nextBaseVersion = deltaToUndo.baseVersion + deltaToUndo.operations.length;
+
+			// Reverse delta from the history.
+			const historyDeltas = Array.from( document.history.getDeltas( nextBaseVersion ) );
+			const transformedSets = document.transformDeltas( [ deltaToUndo.getReversed() ], historyDeltas, true );
+			const reversedDeltas = transformedSets.deltasA;
+
+			// After reversed delta has been transformed by all history deltas, apply it.
+			for ( const delta of reversedDeltas ) {
+				// Fix base version.
+				delta.baseVersion = document.version;
+
+				// Before applying, add the delta to the `undoingBatch`.
+				undoingBatch.addDelta( delta );
+
+				// Now, apply all operations of the delta.
+				for ( const operation of delta.operations ) {
+					document.applyOperation( operation );
+				}
+
+				document.history.setDeltaAsUndone( deltaToUndo, delta );
+			}
+		}
+
+		return undoingBatch;
+	}
 }
 
-// Transforms given range `range` by deltas from `document` history, starting from a delta with given `baseVersion`.
+// Transforms given range `range` by given `deltas`.
 // Returns an array containing one or more ranges, which are result of the transformation.
 function transformSelectionRange( range, deltas ) {
-	// The range will be transformed by history deltas that happened after the selection got stored.
-	// Note, that at this point, the document history is already updated by undo command execution. We will
-	// not transform the range by deltas that got undone or their reversing counterparts.
 	const transformed = transformRangesByDeltas( [ range ], deltas );
 
 	// After `range` 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.
-	// First, we have to sort those ranges because they don't have to be in an order.
+	// First, we have to sort those ranges to assure that they are in order.
 	transformed.sort( ( a, b ) => a.start.isBefore( b.start ) ? -1 : 1 );
 
 	// Then, we check if two consecutive ranges are touching.
@@ -130,6 +177,7 @@ function transformSelectionRange( range, deltas ) {
 		const b = transformed[ i ];
 
 		if ( a.end.isTouching( b.start ) ) {
+			// And join them together if they are.
 			a.end = b.end;
 			transformed.splice( i, 1 );
 			i--;

+ 5 - 68
packages/ckeditor5-undo/src/redocommand.js

@@ -8,16 +8,14 @@
  */
 
 import BaseCommand from './basecommand';
-import deltaTransform from '@ckeditor/ckeditor5-engine/src/model/delta/transform';
 
 /**
  * The redo command stores {@link module:engine/model/batch~Batch batches} that were used to undo a batch by
  * {@link module:undo/undocommand~UndoCommand}. It is able to redo a previously undone batch by reversing the undoing
- * batches created by `UndoCommand`. The reversed batch is also transformed by batches from
- * {@link module:engine/model/document~Document#history history} that happened after it and are not other redo batches.
+ * batches created by `UndoCommand`. The reversed batch is transformed by all the batches from
+ * {@link module:engine/model/document~Document#history history} that happened after the reversed undo batch.
  *
- * The redo command also takes care of restoring the {@link module:engine/model/document~Document#selection document selection}
- * to the state before an undone batch was applied.
+ * The redo command also takes care of restoring the {@link module:engine/model/document~Document#selection document selection}.
  *
  * @extends module:undo/basecommand~BaseCommand
  */
@@ -38,73 +36,12 @@ export default class RedoCommand extends BaseCommand {
 		this.editor.document.enqueueChanges( () => {
 			const lastDelta = item.batch.deltas[ item.batch.deltas.length - 1 ];
 			const nextBaseVersion = lastDelta.baseVersion + lastDelta.operations.length;
-
-			// Selection state is from the moment after undo happened. It needs to be transformed by all the deltas
-			// that happened after the selection state got saved. Unfortunately it is tricky, because those deltas
-			// are already compressed in the history (they are removed).
-			// Because of that we will transform the selection only by non-redo deltas
-			const deltas = Array.from( this.editor.document.history.getDeltas( nextBaseVersion ) ).filter( delta => {
-				return !this._createdBatches.has( delta.batch );
-			} );
+			const deltas = this.editor.document.history.getDeltas( nextBaseVersion );
 
 			this._restoreSelection( item.selection.ranges, item.selection.isBackward, deltas );
-			this._redo( item.batch );
+			this._undo( item.batch );
 		} );
 
 		this.refresh();
 	}
-
-	/**
-	 * Redoes a batch by reversing the batch that has undone it, transforming that batch and applying it. This is
-	 * a helper method for {@link #execute}.
-	 *
-	 * @private
-	 * @param {module:engine/model/batch~Batch} storedBatch The batch whose deltas will be reversed, transformed and applied.
-	 */
-	_redo( storedBatch ) {
-		const document = this.editor.document;
-
-		// All changes done by the command execution will be saved as one batch.
-		const redoingBatch = document.batch();
-		this._createdBatches.add( redoingBatch );
-
-		const deltasToRedo = storedBatch.deltas.slice();
-		deltasToRedo.reverse();
-
-		// We will process each delta from `storedBatch`, in reverse order. If there was deltas A, B and C in stored batch,
-		// we need to revert them in reverse order, so first reverse C, then B, then A.
-		for ( const deltaToRedo of deltasToRedo ) {
-			// Keep in mind that all algorithms return arrays. That's because the transformation might result in multiple
-			// deltas, so we need arrays to handle them anyway. To simplify algorithms, it is better to always have arrays
-			// in mind. For simplicity reasons, we will use singular form in descriptions and names.
-
-			const nextBaseVersion = deltaToRedo.baseVersion + deltaToRedo.operations.length;
-
-			// As stated above, convert delta to array of deltas.
-			let reversedDelta = [ deltaToRedo.getReversed() ];
-
-			// 1. Transform that delta by deltas from history that happened after it.
-			// Omit deltas from "redo" batches, because reversed delta already bases on them. Transforming by them
-			// again will result in incorrect deltas.
-			for ( const historyDelta of document.history.getDeltas( nextBaseVersion ) ) {
-				if ( !this._createdBatches.has( historyDelta.batch ) ) {
-					reversedDelta = deltaTransform.transformDeltaSets( reversedDelta, [ historyDelta ], true ).deltasA;
-				}
-			}
-
-			// 2. After reversed delta has been transformed by all history deltas, apply it.
-			for ( const delta of reversedDelta ) {
-				// Fix base version.
-				delta.baseVersion = document.version;
-
-				// Before applying, add the delta to the `redoingBatch`.
-				redoingBatch.addDelta( delta );
-
-				// Now, apply all operations of the delta.
-				for ( const operation of delta.operations ) {
-					document.applyOperation( operation );
-				}
-			}
-		}
-	}
 }

+ 46 - 72
packages/ckeditor5-undo/src/undo.js

@@ -30,9 +30,9 @@ import redoIcon from '../theme/icons/redo.svg';
  *
  *		  History                           Undo stack
  *		===========             ==================================
- *		[delta A1]              [batch A with selection before A1]
- *		[delta B1]              [batch B with selection before B1]
- *		[delta B2]              [batch C with selection before C1]
+ *		[delta A1]                          [batch A]
+ *		[delta B1]                          [batch B]
+ *		[delta B2]                          [batch C]
  *		[delta C1]
  *		[delta C2]
  *		[delta B3]
@@ -50,80 +50,54 @@ import redoIcon from '../theme/icons/redo.svg';
  * bases on up-to-date document state, so it can be applied to the document.
  *
  *		  History                           Undo stack
- *		===========             ==================================
- *		[delta A1 ]             [batch A with selection before A1]
- *		[delta B1 ]             [batch B with selection before B1]
- *		[delta B2 ]             [   processing undoing batch C   ]
- *		[delta C1 ]
- *		[delta C2 ]
- *		[delta B3 ]
- *		[delta C3 ]
- *		[delta C3r]
+ *		=============             ==================================
+ *		[ delta A1  ]                      [  batch A  ]
+ *		[ delta B1  ]                      [  batch B  ]
+ *		[ delta B2  ]             [   processing undoing batch C   ]
+ *		[ delta C1  ]
+ *		[ delta C2  ]
+ *		[ delta B3  ]
+ *		[ delta C3  ]
+ *		[ delta C3r ]
  *
  * Next is delta `C2`, reversed to `C2r`. `C2r` bases on `C2`, so it bases on the wrong document state. It needs to be
  * transformed by deltas from history that happened after it, so it "knows" about them. Let us assume that `C2' = C2r * B3 * C3 * C3r`,
- * where `*` means "transformed by". As can be seen, `C2r` is transformed by a delta which is undone afterwards anyway.
- * This brings two problems: lower effectiveness (obvious) and incorrect results. Bad results come from the fact that
- * operational transformation algorithms assume there is no connection between two transformed operations when resolving
- * conflicts, which is true for example for collaborative editing, but is not true for the undo algorithm.
- *
- * To prevent both problems, `History` introduces an API to {@link module:engine/model/history~History#removeDelta remove}
- * deltas from history. It is used to remove undone and undoing deltas after they are applied. It feels right &mdash; since when a
- * delta is undone or reversed, it is "removed" and there should be no sign of it in the history (fig. 1).
- *
- * Notes:
- *
- * * `---` symbolizes a removed delta.
- * * `'` symbolizes a reversed delta that was later transformed.
- *
- *		History (fig. 1)            History (fig. 2)            History (fig. 3)
- *		================            ================            ================
- *		   [delta A1]                  [delta A1]                  [delta A1]
- *		   [delta B1]                  [delta B1]                  [delta B1]
- *		   [delta B2]                  [delta B2]                  [delta B2]
- *		   [delta C1]                  [delta C1]                  [---C1---]
- *		   [delta C2]                  [---C2---]                  [---C2---]
- *		   [delta B3]                  [delta B3]                  [delta B3]
- *		   [---C3---]                  [---C3---]                  [---C3---]
- *		   [---C3r--]                  [---C3r--]                  [---C3r--]
- *		                               [---C2'--]                  [---C2'--]
- *		                                                           [---C1'--]
- *
- * `C2r` can now be transformed only by `B3` and both `C2'` and `C2` can be removed (fig. 2). Same with `C1` (fig. 3).
- *
- * But what about that selection? For batch `C`, undo feature remembers the selection just before `C1` was applied. It can be
- * visualized between delta `B2` and `B3` (see fig. 3). As can be seen, some operations were applied to the document since the selection
- * state was remembered. Setting the document selection as it was remembered would be incorrect. It feels natural that
- * the selection state should also be transformed by deltas from history. The same pattern applies as with transforming deltas &mdash;
- * ranges should not be transformed by undone and undoing deltas. Thankfully, those deltas are already removed from history.
- *
- * Unfortunately, a problem appears with delta `B3`. It still remembers the context of deltas `C2` and `C1` on which it bases.
- * It is an obvious error &mdash; transforming by that delta would lead to incorrect results or "repeating" history would
- * produce a different document than the actual one.
- *
- * To prevent this situation, `B3` needs to also be {@link module:engine/model/history~History#updateDelta updated} in history.
- * It should be kept in a state that "does not remember" deltas that were removed from history. It is easily
- * achieved while transforming the reversed delta. For example, when `C2r` is transformed by `B3`, at the same time `B3` is
- * transformed by `C2r`. Transforming `B3` that remembers `C2` by a delta reversing `C2` effectively makes `B3` "forget" about `C2`.
- * By doing these transformations you effectively make `B3` base on `B2` which is the correct state of history (fig. 4).
- *
- *		     History (fig. 4)                         History (fig. 5)
- *		===========================            ===============================
- *		        [delta A1]                               [---A1---]
- *		        [delta B1]                         [delta B1 "without A1"]
- *		        [delta B2]                         [delta B2 "without A1"]
- *		        [---C1---]                               [---C1---]
- *		        [---C2---]                               [---C2---]
- *		[delta B3 "without C2, C1"]            [delta B3 "without C2, C1, A1"]
- *		        [---C3---]                               [---C3---]
- *		        [---C3r--]                               [---C3r--]
- *		        [---C2'--]                               [---C2'--]
- *		        [---C1'--]                               [---C1'--]
- *		                                                 [---A1'--]
+ * where `*` means "transformed by". Rest of deltas from that batch are processed in the same fashion.
+ *
+ *		  History                           Undo stack                                     Redo stack
+ *		=============             ==================================             ==================================
+ *		[ delta A1  ]                      [  batch A  ]                                  [ batch Cr ]
+ *		[ delta B1  ]                      [  batch B  ]
+ *		[ delta B2  ]
+ *		[ delta C1  ]
+ *		[ delta C2  ]
+ *		[ delta B3  ]
+ *		[ delta C3  ]
+ *		[ delta C3r ]
+ *		[ delta C2' ]
+ *		[ delta C1' ]
  *
  * Selective undo works on the same basis, however, instead of undoing the last batch in the undo stack, any batch can be undone.
- * The same algorithm applies: deltas from a batch (i.e. `A1`) are reversed and then transformed by deltas stored in history,
- * simultaneously updating them. Then deltas are applied to the document and removed from history (fig. 5).
+ * The same algorithm applies: deltas from a batch (i.e. `A1`) are reversed and then transformed by deltas stored in history.
+ *
+ * Redo also is very similar to undo. It has its own stack that is filled with undoing (reversed batches). Deltas from
+ * batch that is re-done are reversed-back, transformed in proper order and applied to the document.
+ *
+ *		  History                           Undo stack                                     Redo stack
+ *		=============             ==================================             ==================================
+ *		[ delta A1  ]                      [  batch A  ]
+ *		[ delta B1  ]                      [  batch B  ]
+ *		[ delta B2  ]                      [ batch Crr ]
+ *		[ delta C1  ]
+ *		[ delta C2  ]
+ *		[ delta B3  ]
+ *		[ delta C3  ]
+ *		[ delta C3r ]
+ *		[ delta C2' ]
+ *		[ delta C1' ]
+ *		[ delta C1'r]
+ *		[ delta C2'r]
+ *		[ delta C3rr]
  *
  * @extends module:core/plugin~Plugin
  */

+ 3 - 138
packages/ckeditor5-undo/src/undocommand.js

@@ -7,16 +7,14 @@
  * @module undo/undocommand
  */
 
-import { default as BaseCommand, transformRangesByDeltas } from './basecommand';
-import deltaTransform from '@ckeditor/ckeditor5-engine/src/model/delta/transform';
+import BaseCommand from './basecommand';
 
 /**
  * The undo command stores {@link module:engine/model/batch~Batch batches} applied to the
  * {@link module:engine/model/document~Document document} and is able to undo a batch by reversing it and transforming by
- * other batches from {@link module:engine/model/document~Document#history history} that happened after the reversed batch.
+ * batches from {@link module:engine/model/document~Document#history history} that happened after the reversed batch.
  *
- * The undo command also takes care of restoring the {@link module:engine/model/document~Document#selection document selection}
- * to the state before the undone batch was applied.
+ * The undo command also takes care of restoring the {@link module:engine/model/document~Document#selection document selection}.
  *
  * @extends module:undo/basecommand~BaseCommand
  */
@@ -49,139 +47,6 @@ export default class UndoCommand extends BaseCommand {
 
 		this.refresh();
 	}
-
-	/**
-	 * Returns an index in {@link module:undo/basecommand~BaseCommand#_stack} pointing to the item that is storing a
-	 * batch that has a given {@link module:engine/model/batch~Batch#baseVersion}.
-	 *
-	 * @private
-	 * @param {Number} baseVersion The base version of the batch to find.
-	 * @returns {Number|null}
-	 */
-	_getItemIndexFromBaseVersion( baseVersion ) {
-		for ( let i = 0; i < this._stack.length; i++ ) {
-			if ( this._stack[ i ].batch.baseVersion == baseVersion ) {
-				return i;
-			}
-		}
-
-		return null;
-	}
-
-	/**
-	 * Undoes a batch by reversing a batch from history, transforming that reversed batch and applying it. This is
-	 * a helper method for {@link #execute}.
-	 *
-	 * @private
-	 * @param {module:engine/model/batch~Batch} batchToUndo A batch whose deltas will be reversed, transformed and applied.
-	 */
-	_undo( batchToUndo ) {
-		const document = this.editor.document;
-
-		// All changes done by the command execution will be saved as one batch.
-		const undoingBatch = document.batch();
-		this._createdBatches.add( undoingBatch );
-
-		const history = document.history;
-		const deltasToUndo = batchToUndo.deltas.slice();
-		deltasToUndo.reverse();
-
-		// We will process each delta from `batchToUndo`, in reverse order. If there was deltas A, B and C in undone batch,
-		// we need to revert them in reverse order, so first reverse C, then B, then A.
-		for ( const deltaToUndo of deltasToUndo ) {
-			// Keep in mind that all algorithms return arrays. That's because the transformation might result in multiple
-			// deltas, so we need arrays to handle them anyway. To simplify algorithms, it is better to always have arrays
-			// in mind. For simplicity reasons, we will use singular form in descriptions and names.
-			const baseVersion = deltaToUndo.baseVersion;
-			const nextBaseVersion = baseVersion + deltaToUndo.operations.length;
-
-			// 1. Get updated version of the delta from the history.
-			// Batch stored in the undo command might have an outdated version of the delta that should be undone.
-			// To prevent errors, we will take an updated version of it from the history, basing on delta's `baseVersion`.
-			const updatedDeltaToUndo = history.getDelta( baseVersion );
-
-			// This is a safe valve in case of not finding delta to undo in history. This may come up if that delta
-			// got updated into no deltas, or removed from history.
-			if ( updatedDeltaToUndo === null ) {
-				continue;
-			}
-
-			// 2. Reverse delta from the history.
-			updatedDeltaToUndo.reverse();
-			let reversedDelta = [];
-
-			for ( const delta of updatedDeltaToUndo ) {
-				reversedDelta.push( delta.getReversed() );
-			}
-
-			// Stores history deltas transformed by `deltaToUndo`. Will be used later for updating document history.
-			const updatedHistoryDeltas = {};
-
-			// 3. Transform reversed delta by history deltas that happened after delta to undo. We have to bring
-			// reversed delta to the current state of document. While doing this, we will also update history deltas
-			// to the state which "does not remember" delta that we undo.
-			for ( const historyDelta of history.getDeltas( nextBaseVersion ) ) {
-				// 3.1. Transform selection range stored with history batch by reversed delta.
-				// It is important to keep stored selection ranges updated. As we are removing and updating deltas in the history,
-				// selection ranges would base on outdated history state.
-				const itemIndex = this._getItemIndexFromBaseVersion( historyDelta.baseVersion );
-
-				// `itemIndex` will be `null` for `historyDelta` if it is not the first delta in it's batch.
-				// This is fine, because we want to transform each selection only once, before transforming reversed delta
-				// by the first delta of the batch connected with the ranges.
-				if ( itemIndex !== null ) {
-					this._stack[ itemIndex ].selection.ranges = transformRangesByDeltas(
-						this._stack[ itemIndex ].selection.ranges, reversedDelta
-					);
-				}
-
-				// 3.2. Transform reversed delta by history delta and vice-versa.
-				const results = deltaTransform.transformDeltaSets( reversedDelta, [ historyDelta ], true );
-
-				reversedDelta = results.deltasA;
-				const updatedHistoryDelta = results.deltasB;
-
-				// 3.3. Store updated history delta. Later, it will be updated in `history`.
-				if ( !updatedHistoryDeltas[ historyDelta.baseVersion ] ) {
-					updatedHistoryDeltas[ historyDelta.baseVersion ] = [];
-				}
-
-				const mergedHistoryDeltas = updatedHistoryDeltas[ historyDelta.baseVersion ].concat( updatedHistoryDelta );
-				updatedHistoryDeltas[ historyDelta.baseVersion ] = mergedHistoryDeltas;
-			}
-
-			// 4. After reversed delta has been transformed by all history deltas, apply it.
-			for ( const delta of reversedDelta ) {
-				// Fix base version.
-				delta.baseVersion = document.version;
-
-				// Before applying, add the delta to the `undoingBatch`.
-				undoingBatch.addDelta( delta );
-
-				// Now, apply all operations of the delta.
-				for ( const operation of delta.operations ) {
-					document.applyOperation( operation );
-				}
-			}
-
-			// 5. Remove reversed delta from the history.
-			history.removeDelta( baseVersion );
-
-			// And all deltas that are reversing it.
-			// So the history looks like both original and reversing deltas never happened.
-			// That's why we have to update history deltas - some of them might have been basing on deltas that we are now removing.
-			for ( const delta of reversedDelta ) {
-				history.removeDelta( delta.baseVersion );
-			}
-
-			// 6. Update history deltas in history.
-			for ( const historyBaseVersion in updatedHistoryDeltas ) {
-				history.updateDelta( Number( historyBaseVersion ), updatedHistoryDeltas[ historyBaseVersion ] );
-			}
-		}
-
-		return undoingBatch;
-	}
 }
 
 /**

+ 23 - 101
packages/ckeditor5-undo/tests/undocommand.js

@@ -8,7 +8,6 @@ import Range from '@ckeditor/ckeditor5-engine/src/model/range';
 import Position from '@ckeditor/ckeditor5-engine/src/model/position';
 import Text from '@ckeditor/ckeditor5-engine/src/model/text';
 import UndoCommand from '../src/undocommand';
-import AttributeDelta from '@ckeditor/ckeditor5-engine/src/model/delta/attributedelta';
 import { itemAt, getText } from '@ckeditor/ckeditor5-engine/tests/model/_utils/utils';
 
 describe( 'UndoCommand', () => {
@@ -151,7 +150,9 @@ describe( 'UndoCommand', () => {
 				expect( itemAt( root, 2 ).getAttribute( 'key' ) ).to.equal( 'value' );
 				expect( itemAt( root, 3 ).getAttribute( 'key' ) ).to.equal( 'value' );
 
-				expect( editor.document.selection.getFirstRange().isEqual( r( 1, 3 ) ) ).to.be.true;
+				// Since selection restoring is not 100% accurate, selected range is not perfectly correct
+				// with what is expected in comment above. The correct result would be if range was [ 1 ] - [ 3 ].
+				expect( editor.document.selection.getFirstRange().isEqual( r( 0, 3 ) ) ).to.be.true;
 				expect( editor.document.selection.isBackward ).to.be.false;
 
 				undo.execute();
@@ -242,24 +243,22 @@ describe( 'UndoCommand', () => {
 				expect( editor.document.selection.getFirstRange().isEqual( r( 1, 1 ) ) ).to.be.true;
 				expect( editor.document.selection.isBackward ).to.be.false;
 
-				expect( doc.graveyard.getChild( 0 ).maxOffset ).to.equal( 6 );
+				expect( doc.graveyard.maxOffset ).to.equal( 6 );
 
 				for ( const char of doc.graveyard._children ) {
 					expect( char.hasAttribute( 'key' ) ).to.be.false;
 				}
 
-				// Let's undo wrapping. This should leave us with empty root.
+				// Let's undo wrapping. This will remove the P element and leave us with empty root.
 				undo.execute( batch3 );
 				expect( root.maxOffset ).to.equal( 0 );
 
-				// Once again transformed range ends up in the graveyard.
 				expect( editor.document.selection.getFirstRange().isEqual( r( 0, 0 ) ) ).to.be.true;
 				expect( editor.document.selection.isBackward ).to.be.false;
 			} );
 		} );
 
-		// Some tests to ensure 100% CC and proper behavior in edge cases.
-		describe( 'edge cases', () => {
+		it( 'merges touching ranges when restoring selection', () => {
 			function getCaseText( root ) {
 				let text = '';
 
@@ -271,104 +270,27 @@ describe( 'UndoCommand', () => {
 				return text;
 			}
 
-			it( 'correctly handles deltas in compressed history that were earlier updated into multiple deltas ' +
-				'(or split when undoing)', () => {
-				// In this case we assume that one of the deltas in compressed history was updated to two deltas.
-				// This is a tricky edge case because it is almost impossible to come up with convincing scenario that produces it.
-				// At the moment of writing this test and comment, only Undo feature uses `CompressedHistory#updateDelta`.
-				// Because deltas that "stays" in history are transformed with `isStrong` flag set to `false`, `MoveOperation`
-				// won't get split and `AttributeDelta` can hold multiple `AttributeOperation` in it. So using most common deltas
-				// (`InsertDelta`, `RemoveDelta`, `MoveDelta`, `AttributeDelta`) and undo it's impossible to get to this edge case.
-				// Still there might be some weird scenarios connected with OT / Undo / Collaborative Editing / other deltas /
-				// fancy 3rd party plugin where it may come up, so it's better to be safe than sorry.
-
-				root.appendChildren( new Text( 'abcdef' ) );
-				expect( getCaseText( root ) ).to.equal( 'abcdef' );
-
-				editor.document.selection.setRanges( [ r( 1, 4 ) ] );
-				const batch0 = doc.batch();
-				undo.addBatch( batch0 );
-				batch0.move( r( 1, 4 ), p( 5 ) );
-				expect( getCaseText( root ) ).to.equal( 'aebcdf' );
-
-				editor.document.selection.setRanges( [ r( 1, 1 ) ] );
-				const batch1 = doc.batch();
-				undo.addBatch( batch1 );
-				batch1.remove( r( 0, 1 ) );
-				expect( getCaseText( root ) ).to.equal( 'ebcdf' );
-
-				editor.document.selection.setRanges( [ r( 0, 3 ) ] );
-				const batch2 = doc.batch();
-				undo.addBatch( batch2 );
-				batch2.setAttribute( r( 0, 3 ), 'uppercase', true );
-				expect( getCaseText( root ) ).to.equal( 'EBCdf' );
-
-				undo.execute( batch0 );
-				expect( getCaseText( root ) ).to.equal( 'BCdEf' );
-
-				// Let's simulate splitting the delta by updating the history by hand.
-				const attrHistoryDelta = doc.history.getDelta( 2 )[ 0 ];
-				const attrDelta1 = new AttributeDelta();
-				attrDelta1.addOperation( attrHistoryDelta.operations[ 0 ] );
-				const attrDelta2 = new AttributeDelta();
-				attrDelta2.addOperation( attrHistoryDelta.operations[ 1 ] );
-				doc.history.updateDelta( 2, [ attrDelta1, attrDelta2 ] );
-
-				undo.execute( batch1 );
-				// After this execution, undo algorithm should update both `attrDelta1` and `attrDelta2` with new
-				// versions, that have incremented offsets.
-				expect( getCaseText( root ) ).to.equal( 'aBCdEf' );
-
-				undo.execute( batch2 );
-				// This execution checks whether undo algorithm correctly updated deltas in previous execution
-				// and also whether it correctly "reads" both deltas from history.
-				expect( getCaseText( root ) ).to.equal( 'abcdef' );
-			} );
-
-			it( 'merges touching ranges when restoring selection', () => {
-				root.appendChildren( new Text( 'abcdef' ) );
-				expect( getCaseText( root ) ).to.equal( 'abcdef' );
-
-				editor.document.selection.setRanges( [ r( 1, 4 ) ] );
-				const batch0 = doc.batch();
-				undo.addBatch( batch0 );
-				batch0.setAttribute( r( 1, 4 ), 'uppercase', true );
-				expect( getCaseText( root ) ).to.equal( 'aBCDef' );
-
-				editor.document.selection.setRanges( [ r( 3, 4 ) ] );
-				const batch1 = doc.batch();
-				undo.addBatch( batch1 );
-				batch1.move( r( 3, 4 ), p( 1 ) );
-				expect( getCaseText( root ) ).to.equal( 'aDBCef' );
+			root.appendChildren( new Text( 'abcdef' ) );
+			expect( getCaseText( root ) ).to.equal( 'abcdef' );
 
-				undo.execute( batch0 );
+			editor.document.selection.setRanges( [ r( 1, 4 ) ] );
+			const batch0 = doc.batch();
+			undo.addBatch( batch0 );
+			batch0.setAttribute( r( 1, 4 ), 'uppercase', true );
+			expect( getCaseText( root ) ).to.equal( 'aBCDef' );
 
-				// After undo-attr: acdbef <--- "cdb" should be selected, it would look weird if only "cd" or "b" is selected
-				// but the whole unbroken part "cdb" changed attribute.
-				expect( getCaseText( root ) ).to.equal( 'adbcef' );
-				expect( editor.document.selection.getFirstRange().isEqual( r( 1, 4 ) ) ).to.be.true;
-			} );
+			editor.document.selection.setRanges( [ r( 3, 4 ) ] );
+			const batch1 = doc.batch();
+			undo.addBatch( batch1 );
+			batch1.move( r( 3, 4 ), p( 1 ) );
+			expect( getCaseText( root ) ).to.equal( 'aDBCef' );
 
-			it( 'does nothing (and not crashes) if delta to undo is no longer in history', () => {
-				// Also an edgy situation but it may come up if other plugins use `CompressedHistory` API.
-				root.appendChildren( new Text( 'abcdef' ) );
-				expect( getCaseText( root ) ).to.equal( 'abcdef' );
+			undo.execute( batch0 );
 
-				editor.document.selection.setRanges( [ r( 0, 1 ) ] );
-				const batch0 = doc.batch();
-				undo.addBatch( batch0 );
-				batch0.setAttribute( r( 0, 1 ), 'uppercase', true );
-				expect( getCaseText( root ) ).to.equal( 'Abcdef' );
-
-				doc.history.removeDelta( 0 );
-				root.getChild( 0 ).removeAttribute( 'uppercase' );
-				expect( getCaseText( root ) ).to.equal( 'abcdef' );
-
-				undo.execute();
-
-				// Nothing happened. We are still alive.
-				expect( getCaseText( root ) ).to.equal( 'abcdef' );
-			} );
+			// After undo-attr: acdbef <--- "cdb" should be selected, it would look weird if only "cd" or "b" is selected
+			// but the whole unbroken part "cdb" changed attribute.
+			expect( getCaseText( root ) ).to.equal( 'adbcef' );
+			expect( editor.document.selection.getFirstRange().isEqual( r( 1, 4 ) ) ).to.be.true;
 		} );
 	} );
 } );

+ 587 - 214
packages/ckeditor5-undo/tests/undoengine-integration.js

@@ -9,6 +9,10 @@ import Position from '@ckeditor/ckeditor5-engine/src/model/position';
 import Element from '@ckeditor/ckeditor5-engine/src/model/element';
 import UndoEngine from '../src/undoengine';
 
+import DeleteCommand from '@ckeditor/ckeditor5-typing/src/deletecommand';
+import InputCommand from '@ckeditor/ckeditor5-typing/src/inputcommand';
+import EnterCommand from '@ckeditor/ckeditor5-enter/src/entercommand';
+
 import { setData, getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 
 describe( 'UndoEngine integration', () => {
@@ -18,6 +22,11 @@ describe( 'UndoEngine integration', () => {
 		return ModelTestEditor.create( { plugins: [ UndoEngine ] } )
 			.then( newEditor => {
 				editor = newEditor;
+
+				editor.commands.add( 'delete', new DeleteCommand( editor, 'backward' ) );
+				editor.commands.add( 'enter', new EnterCommand( editor ) );
+				editor.commands.add( 'input', new InputCommand( editor, 5 ) );
+
 				doc = editor.document;
 				doc.schema.registerItem( 'p', '$block' );
 				root = doc.getRoot();
@@ -40,312 +49,676 @@ describe( 'UndoEngine integration', () => {
 		expect( editor.commands.get( 'undo' ).isEnabled ).to.be.false;
 	}
 
-	describe( 'UndoEngine integration', () => {
-		describe( 'adding and removing content', () => {
-			it( 'add and undo', () => {
-				input( '<p>fo[]o</p><p>bar</p>' );
+	function redoDisabled() {
+		expect( editor.commands.get( 'redo' ).isEnabled ).to.be.false;
+	}
 
-				doc.batch().insert( doc.selection.getFirstPosition(), 'zzz' );
-				output( '<p>fozzz[]o</p><p>bar</p>' );
+	describe( 'adding and removing content', () => {
+		it( 'add and undo', () => {
+			input( '<p>fo[]o</p><p>bar</p>' );
 
-				editor.execute( 'undo' );
-				output( '<p>fo[]o</p><p>bar</p>' );
+			doc.batch().insert( doc.selection.getFirstPosition(), 'zzz' );
+			output( '<p>fozzz[]o</p><p>bar</p>' );
 
-				undoDisabled();
-			} );
+			editor.execute( 'undo' );
+			output( '<p>fo[]o</p><p>bar</p>' );
 
-			it( 'multiple adding and undo', () => {
-				input( '<p>fo[]o</p><p>bar</p>' );
+			undoDisabled();
+		} );
 
-				doc.batch()
-					.insert( doc.selection.getFirstPosition(), 'zzz' )
-					.insert( new Position( root, [ 1, 0 ] ), 'xxx' );
-				output( '<p>fozzz[]o</p><p>xxxbar</p>' );
+		it( 'multiple adding and undo', () => {
+			input( '<p>fo[]o</p><p>bar</p>' );
 
-				setSelection( [ 1, 0 ], [ 1, 0 ] );
-				doc.batch().insert( doc.selection.getFirstPosition(), 'yyy' );
-				output( '<p>fozzzo</p><p>yyy[]xxxbar</p>' );
+			doc.batch()
+				.insert( doc.selection.getFirstPosition(), 'zzz' )
+				.insert( new Position( root, [ 1, 0 ] ), 'xxx' );
+			output( '<p>fozzz[]o</p><p>xxxbar</p>' );
 
-				editor.execute( 'undo' );
-				output( '<p>fozzzo</p><p>[]xxxbar</p>' );
+			setSelection( [ 1, 0 ], [ 1, 0 ] );
+			doc.batch().insert( doc.selection.getFirstPosition(), 'yyy' );
+			output( '<p>fozzzo</p><p>yyy[]xxxbar</p>' );
 
-				editor.execute( 'undo' );
-				output( '<p>fo[]o</p><p>bar</p>' );
+			editor.execute( 'undo' );
+			output( '<p>fozzzo</p><p>[]xxxbar</p>' );
 
-				undoDisabled();
-			} );
+			editor.execute( 'undo' );
+			output( '<p>fo[]o</p><p>bar</p>' );
 
-			it( 'multiple adding mixed with undo', () => {
-				input( '<p>fo[]o</p><p>bar</p>' );
+			undoDisabled();
+		} );
 
-				doc.batch().insert( doc.selection.getFirstPosition(), 'zzz' );
-				output( '<p>fozzz[]o</p><p>bar</p>' );
+		it( 'multiple adding mixed with undo', () => {
+			input( '<p>fo[]o</p><p>bar</p>' );
 
-				setSelection( [ 1, 0 ], [ 1, 0 ] );
-				doc.batch().insert( doc.selection.getFirstPosition(), 'yyy' );
-				output( '<p>fozzzo</p><p>yyy[]bar</p>' );
+			doc.batch().insert( doc.selection.getFirstPosition(), 'zzz' );
+			output( '<p>fozzz[]o</p><p>bar</p>' );
 
-				editor.execute( 'undo' );
-				output( '<p>fozzzo</p><p>[]bar</p>' );
+			setSelection( [ 1, 0 ], [ 1, 0 ] );
+			doc.batch().insert( doc.selection.getFirstPosition(), 'yyy' );
+			output( '<p>fozzzo</p><p>yyy[]bar</p>' );
 
-				setSelection( [ 0, 0 ], [ 0, 0 ] );
-				doc.batch().insert( doc.selection.getFirstPosition(), 'xxx' );
-				output( '<p>xxx[]fozzzo</p><p>bar</p>' );
+			editor.execute( 'undo' );
+			output( '<p>fozzzo</p><p>[]bar</p>' );
 
-				editor.execute( 'undo' );
-				output( '<p>[]fozzzo</p><p>bar</p>' );
+			setSelection( [ 0, 0 ], [ 0, 0 ] );
+			doc.batch().insert( doc.selection.getFirstPosition(), 'xxx' );
+			output( '<p>xxx[]fozzzo</p><p>bar</p>' );
 
-				editor.execute( 'undo' );
-				output( '<p>fo[]o</p><p>bar</p>' );
+			editor.execute( 'undo' );
+			output( '<p>[]fozzzo</p><p>bar</p>' );
 
-				undoDisabled();
-			} );
+			editor.execute( 'undo' );
+			output( '<p>fo[]o</p><p>bar</p>' );
 
-			it( 'multiple remove and undo', () => {
-				input( '<p>[]foo</p><p>bar</p>' );
+			undoDisabled();
+		} );
 
-				doc.batch().remove( Range.createFromPositionAndShift( doc.selection.getFirstPosition(), 2 ) );
-				output( '<p>[]o</p><p>bar</p>' );
+		it( 'multiple remove and undo', () => {
+			input( '<p>[]foo</p><p>bar</p>' );
 
-				setSelection( [ 1, 1 ], [ 1, 1 ] );
-				doc.batch().remove( Range.createFromPositionAndShift( doc.selection.getFirstPosition(), 2 ) );
-				output( '<p>o</p><p>b[]</p>' );
+			doc.batch().remove( Range.createFromPositionAndShift( doc.selection.getFirstPosition(), 2 ) );
+			output( '<p>[]o</p><p>bar</p>' );
 
-				editor.execute( 'undo' );
-				// Here is an edge case that selection could be before or after `ar`.
-				output( '<p>o</p><p>b[]ar</p>' );
+			setSelection( [ 1, 1 ], [ 1, 1 ] );
+			doc.batch().remove( Range.createFromPositionAndShift( doc.selection.getFirstPosition(), 2 ) );
+			output( '<p>o</p><p>b[]</p>' );
 
-				editor.execute( 'undo' );
-				// As above.
-				output( '<p>[]foo</p><p>bar</p>' );
+			editor.execute( 'undo' );
+			// Here is an edge case that selection could be before or after `ar`.
+			output( '<p>o</p><p>bar[]</p>' );
 
-				undoDisabled();
-			} );
+			editor.execute( 'undo' );
+			// As above.
+			output( '<p>fo[]o</p><p>bar</p>' );
 
-			it( 'add and remove different parts and undo', () => {
-				input( '<p>fo[]o</p><p>bar</p>' );
+			undoDisabled();
+		} );
 
-				doc.batch().insert( doc.selection.getFirstPosition(), 'zzz' );
-				output( '<p>fozzz[]o</p><p>bar</p>' );
+		it( 'add and remove different parts and undo', () => {
+			input( '<p>fo[]o</p><p>bar</p>' );
 
-				setSelection( [ 1, 2 ], [ 1, 2 ] );
-				doc.batch().remove( Range.createFromPositionAndShift( new Position( root, [ 1, 1 ] ), 1 ) );
-				output( '<p>fozzzo</p><p>b[]r</p>' );
+			doc.batch().insert( doc.selection.getFirstPosition(), 'zzz' );
+			output( '<p>fozzz[]o</p><p>bar</p>' );
 
-				editor.execute( 'undo' );
-				output( '<p>fozzzo</p><p>ba[]r</p>' );
+			setSelection( [ 1, 2 ], [ 1, 2 ] );
+			doc.batch().remove( Range.createFromPositionAndShift( new Position( root, [ 1, 1 ] ), 1 ) );
+			output( '<p>fozzzo</p><p>b[]r</p>' );
 
-				editor.execute( 'undo' );
-				output( '<p>fo[]o</p><p>bar</p>' );
+			editor.execute( 'undo' );
+			output( '<p>fozzzo</p><p>ba[]r</p>' );
 
-				undoDisabled();
-			} );
+			editor.execute( 'undo' );
+			output( '<p>fo[]o</p><p>bar</p>' );
 
-			it( 'add and remove same part and undo', () => {
-				input( '<p>fo[]o</p><p>bar</p>' );
+			undoDisabled();
+		} );
 
-				doc.batch().insert( doc.selection.getFirstPosition(), 'zzz' );
-				output( '<p>fozzz[]o</p><p>bar</p>' );
+		it( 'add and remove same part and undo', () => {
+			input( '<p>fo[]o</p><p>bar</p>' );
 
-				doc.batch().remove( Range.createFromPositionAndShift( new Position( root, [ 0, 2 ] ), 3 ) );
-				output( '<p>fo[]o</p><p>bar</p>' );
+			doc.batch().insert( doc.selection.getFirstPosition(), 'zzz' );
+			output( '<p>fozzz[]o</p><p>bar</p>' );
 
-				editor.execute( 'undo' );
-				output( '<p>fozzz[]o</p><p>bar</p>' );
+			doc.batch().remove( Range.createFromPositionAndShift( new Position( root, [ 0, 2 ] ), 3 ) );
+			output( '<p>fo[]o</p><p>bar</p>' );
 
-				editor.execute( 'undo' );
-				output( '<p>fo[]o</p><p>bar</p>' );
+			editor.execute( 'undo' );
+			output( '<p>fozzz[]o</p><p>bar</p>' );
 
-				undoDisabled();
-			} );
+			editor.execute( 'undo' );
+			output( '<p>fo[]o</p><p>bar</p>' );
+
+			undoDisabled();
 		} );
+	} );
 
-		describe( 'moving', () => {
-			it( 'move same content twice then undo', () => {
-				input( '<p>f[o]z</p><p>bar</p>' );
+	describe( 'moving', () => {
+		it( 'move same content twice then undo', () => {
+			input( '<p>f[o]z</p><p>bar</p>' );
 
-				doc.batch().move( doc.selection.getFirstRange(), new Position( root, [ 1, 0 ] ) );
-				output( '<p>fz</p><p>[o]bar</p>' );
+			doc.batch().move( doc.selection.getFirstRange(), new Position( root, [ 1, 0 ] ) );
+			output( '<p>fz</p><p>[o]bar</p>' );
 
-				doc.batch().move( doc.selection.getFirstRange(), new Position( root, [ 0, 2 ] ) );
-				output( '<p>fz[o]</p><p>bar</p>' );
+			doc.batch().move( doc.selection.getFirstRange(), new Position( root, [ 0, 2 ] ) );
+			output( '<p>fz[o]</p><p>bar</p>' );
 
-				editor.execute( 'undo' );
-				output( '<p>fz</p><p>[o]bar</p>' );
+			editor.execute( 'undo' );
+			output( '<p>fz</p><p>[o]bar</p>' );
 
-				editor.execute( 'undo' );
-				output( '<p>f[o]z</p><p>bar</p>' );
+			editor.execute( 'undo' );
+			output( '<p>f[o]z</p><p>bar</p>' );
 
-				undoDisabled();
-			} );
+			undoDisabled();
+		} );
 
-			it( 'move content and new parent then undo', () => {
-				input( '<p>f[o]z</p><p>bar</p>' );
+		it( 'move content and new parent then undo', () => {
+			input( '<p>f[o]z</p><p>bar</p>' );
 
-				doc.batch().move( doc.selection.getFirstRange(), new Position( root, [ 1, 0 ] ) );
-				output( '<p>fz</p><p>[o]bar</p>' );
+			doc.batch().move( doc.selection.getFirstRange(), new Position( root, [ 1, 0 ] ) );
+			output( '<p>fz</p><p>[o]bar</p>' );
 
-				setSelection( [ 1 ], [ 2 ] );
-				doc.batch().move( doc.selection.getFirstRange(), new Position( root, [ 0 ] ) );
-				output( '[<p>obar</p>]<p>fz</p>' );
+			setSelection( [ 1 ], [ 2 ] );
+			doc.batch().move( doc.selection.getFirstRange(), new Position( root, [ 0 ] ) );
+			output( '[<p>obar</p>]<p>fz</p>' );
 
-				editor.execute( 'undo' );
-				output( '<p>fz</p>[<p>obar</p>]' );
+			editor.execute( 'undo' );
+			output( '<p>fz</p>[<p>obar</p>]' );
 
-				editor.execute( 'undo' );
-				output( '<p>f[o]z</p><p>bar</p>' );
+			editor.execute( 'undo' );
+			output( '<p>f[o]z</p><p>bar</p>' );
 
-				undoDisabled();
-			} );
+			undoDisabled();
 		} );
+	} );
 
-		describe( 'attributes with other', () => {
-			it( 'attributes then insert inside then undo', () => {
-				input( '<p>fo[ob]ar</p>' );
+	describe( 'attributes with other', () => {
+		it( 'attributes then insert inside then undo', () => {
+			input( '<p>fo[ob]ar</p>' );
 
-				doc.batch().setAttribute( doc.selection.getFirstRange(), 'bold', true );
-				output( '<p>fo[<$text bold="true">ob</$text>]ar</p>' );
+			doc.batch().setAttribute( doc.selection.getFirstRange(), 'bold', true );
+			output( '<p>fo[<$text bold="true">ob</$text>]ar</p>' );
 
-				setSelection( [ 0, 3 ], [ 0, 3 ] );
-				doc.batch().insert( doc.selection.getFirstPosition(), 'zzz' );
-				output( '<p>fo<$text bold="true">o</$text>zzz<$text bold="true">[]b</$text>ar</p>' );
-				expect( doc.selection.getAttribute( 'bold' ) ).to.true;
+			setSelection( [ 0, 3 ], [ 0, 3 ] );
+			doc.batch().insert( doc.selection.getFirstPosition(), 'zzz' );
+			output( '<p>fo<$text bold="true">o</$text>zzz<$text bold="true">[]b</$text>ar</p>' );
+			expect( doc.selection.getAttribute( 'bold' ) ).to.true;
 
-				editor.execute( 'undo' );
-				output( '<p>fo<$text bold="true">o[]b</$text>ar</p>' );
-				expect( doc.selection.getAttribute( 'bold' ) ).to.true;
+			editor.execute( 'undo' );
+			output( '<p>fo<$text bold="true">o[]b</$text>ar</p>' );
+			expect( doc.selection.getAttribute( 'bold' ) ).to.true;
 
-				editor.execute( 'undo' );
-				output( '<p>fo[ob]ar</p>' );
+			editor.execute( 'undo' );
+			output( '<p>fo[ob]ar</p>' );
 
-				undoDisabled();
-			} );
+			undoDisabled();
 		} );
+	} );
 
-		describe( 'wrapping, unwrapping, merging, splitting', () => {
-			it( 'wrap and undo', () => {
-				doc.schema.allow( { name: '$text', inside: '$root' } );
-				input( 'fo[zb]ar' );
+	describe( 'wrapping, unwrapping, merging, splitting', () => {
+		it( 'wrap and undo', () => {
+			doc.schema.allow( { name: '$text', inside: '$root' } );
+			input( 'fo[zb]ar' );
 
-				doc.batch().wrap( doc.selection.getFirstRange(), 'p' );
-				output( 'fo<p>[zb]</p>ar' );
+			doc.batch().wrap( doc.selection.getFirstRange(), 'p' );
+			output( 'fo<p>[zb]</p>ar' );
 
-				editor.execute( 'undo' );
-				output( 'fo[zb]ar' );
+			editor.execute( 'undo' );
+			output( 'fo[zb]ar' );
 
-				undoDisabled();
-			} );
+			undoDisabled();
+		} );
 
-			it( 'wrap, move and undo', () => {
-				doc.schema.allow( { name: '$text', inside: '$root' } );
-				input( 'fo[zb]ar' );
+		it( 'wrap, move and undo', () => {
+			doc.schema.allow( { name: '$text', inside: '$root' } );
+			input( 'fo[zb]ar' );
 
-				doc.batch().wrap( doc.selection.getFirstRange(), 'p' );
-				// Would be better if selection was inside P.
-				output( 'fo<p>[zb]</p>ar' );
+			doc.batch().wrap( doc.selection.getFirstRange(), 'p' );
+			// Would be better if selection was inside P.
+			output( 'fo<p>[zb]</p>ar' );
 
-				setSelection( [ 2, 0 ], [ 2, 1 ] );
-				doc.batch().move( doc.selection.getFirstRange(), new Position( root, [ 0 ] ) );
-				output( '[z]fo<p>b</p>ar' );
+			setSelection( [ 2, 0 ], [ 2, 1 ] );
+			doc.batch().move( doc.selection.getFirstRange(), new Position( root, [ 0 ] ) );
+			output( '[z]fo<p>b</p>ar' );
 
-				editor.execute( 'undo' );
-				output( 'fo<p>[z]b</p>ar' );
+			editor.execute( 'undo' );
+			output( 'fo<p>[z]b</p>ar' );
 
-				editor.execute( 'undo' );
-				output( 'fo[zb]ar' );
+			editor.execute( 'undo' );
+			output( 'fo[zb]ar' );
 
-				undoDisabled();
-			} );
+			undoDisabled();
+		} );
 
-			it( 'unwrap and undo', () => {
-				input( '<p>foo[]bar</p>' );
+		it( 'unwrap and undo', () => {
+			input( '<p>foo[]bar</p>' );
 
-				doc.batch().unwrap( doc.selection.getFirstPosition().parent );
-				output( 'foo[]bar' );
+			doc.batch().unwrap( doc.selection.getFirstPosition().parent );
+			output( 'foo[]bar' );
 
-				editor.execute( 'undo' );
-				output( '<p>foo[]bar</p>' );
+			editor.execute( 'undo' );
+			output( '<p>foo[]bar</p>' );
 
-				undoDisabled();
-			} );
+			undoDisabled();
+		} );
 
-			it( 'merge and undo', () => {
-				input( '<p>foo</p><p>[]bar</p>' );
+		it( 'merge and undo', () => {
+			input( '<p>foo</p><p>[]bar</p>' );
 
-				doc.batch().merge( new Position( root, [ 1 ] ) );
-				// Because selection is stuck with <p> it ends up in graveyard. We have to manually move it to correct node.
-				setSelection( [ 0, 3 ], [ 0, 3 ] );
-				output( '<p>foo[]bar</p>' );
+			doc.batch().merge( new Position( root, [ 1 ] ) );
+			// Because selection is stuck with <p> it ends up in graveyard. We have to manually move it to correct node.
+			setSelection( [ 0, 3 ], [ 0, 3 ] );
+			output( '<p>foo[]bar</p>' );
 
-				editor.execute( 'undo' );
-				output( '<p>foo</p><p>[]bar</p>' );
+			editor.execute( 'undo' );
+			output( '<p>foo</p><p>bar[]</p>' );
 
-				undoDisabled();
-			} );
+			undoDisabled();
+		} );
 
-			it( 'split and undo', () => {
-				input( '<p>foo[]bar</p>' );
+		it( 'split and undo', () => {
+			input( '<p>foo[]bar</p>' );
 
-				doc.batch().split( doc.selection.getFirstPosition() );
-				// Because selection is stuck with <p> it ends up in wrong node. We have to manually move it to correct node.
-				setSelection( [ 1, 0 ], [ 1, 0 ] );
-				output( '<p>foo</p><p>[]bar</p>' );
+			doc.batch().split( doc.selection.getFirstPosition() );
+			// Because selection is stuck with <p> it ends up in wrong node. We have to manually move it to correct node.
+			setSelection( [ 1, 0 ], [ 1, 0 ] );
+			output( '<p>foo</p><p>[]bar</p>' );
 
-				editor.execute( 'undo' );
-				output( '<p>foo[]bar</p>' );
+			editor.execute( 'undo' );
+			output( '<p>foobar[]</p>' );
 
-				undoDisabled();
-			} );
+			undoDisabled();
 		} );
+	} );
 
-		describe( 'other edge cases', () => {
-			it( 'deleteContent between two nodes', () => {
-				input( '<p>fo[o</p><p>b]ar</p>' );
+	// Restoring selection in those examples may be completely off.
+	describe( 'multiple enters, deletes and typing', () => {
+		function split( path ) {
+			setSelection( path.slice(), path.slice() );
+			editor.execute( 'enter' );
+		}
 
-				editor.data.deleteContent( doc.selection, doc.batch() );
-				output( '<p>fo[]ar</p>' );
+		function merge( path ) {
+			const selPath = path.slice();
+			selPath.push( 0 );
+			setSelection( selPath, selPath.slice() );
+			editor.execute( 'delete' );
+		}
 
-				editor.execute( 'undo' );
-				output( '<p>fo[o</p><p>b]ar</p>' );
-			} );
+		function type( path, text ) {
+			setSelection( path.slice(), path.slice() );
+			editor.execute( 'input', { text } );
+		}
 
-			// Related to ckeditor5-engine#891 and ckeditor5-list#51.
-			it( 'change attribute of removed node then undo and redo', () => {
-				const gy = doc.graveyard;
-				const batch = doc.batch();
-				const p = new Element( 'p' );
+		function remove( path ) {
+			setSelection( path.slice(), path.slice() );
+			editor.execute( 'delete' );
+		}
 
-				root.appendChildren( p );
+		it( 'split, split, split', () => {
+			input( '<p>12345678</p>' );
 
-				batch.remove( p );
-				batch.setAttribute( p, 'bold', true );
+			split( [ 0, 3 ] );
+			output( '<p>123</p><p>[]45678</p>' );
 
-				editor.execute( 'undo' );
-				editor.execute( 'redo' );
+			split( [ 1, 4 ] );
+			output( '<p>123</p><p>4567</p><p>[]8</p>' );
 
-				expect( p.root ).to.equal( gy );
-				expect( p.getAttribute( 'bold' ) ).to.be.true;
-			} );
+			split( [ 1, 2 ] );
+			output( '<p>123</p><p>45</p><p>[]67</p><p>8</p>' );
 
-			// Related to ckeditor5-engine#891.
-			it( 'change attribute of removed node then undo and redo', () => {
-				const gy = doc.graveyard;
-				const batch = doc.batch();
-				const p1 = new Element( 'p' );
-				const p2 = new Element( 'p' );
-				const p3 = new Element( 'p' );
+			editor.execute( 'undo' );
+			output( '<p>123</p><p>4567[]</p><p>8</p>' );
 
-				root.appendChildren( [ p1, p2 ] );
+			editor.execute( 'undo' );
+			output( '<p>123</p><p>45678[]</p>' );
 
-				batch.remove( p1 ).remove( p2 ).insert( new Position( root, [ 0 ] ), p3 );
+			editor.execute( 'undo' );
+			output( '<p>12345678[]</p>' );
 
-				editor.execute( 'undo' );
-				editor.execute( 'redo' );
+			undoDisabled();
 
-				expect( p1.root ).to.equal( gy );
-				expect( p2.root ).to.equal( gy );
-				expect( p3.root ).to.equal( root );
-			} );
+			editor.execute( 'redo' );
+			output( '<p>123[]</p><p>45678</p>' );
+
+			editor.execute( 'redo' );
+			output( '<p>123</p><p>4567[]</p><p>8</p>' );
+
+			editor.execute( 'redo' );
+			output( '<p>123</p><p>45[]</p><p>67</p><p>8</p>' );
+
+			redoDisabled();
+		} );
+
+		it( 'merge, merge, merge', () => {
+			input( '<p>123</p><p>45</p><p>67</p><p>8</p>' );
+
+			merge( [ 1 ] );
+			output( '<p>123[]45</p><p>67</p><p>8</p>' );
+
+			merge( [ 2 ] );
+			output( '<p>12345</p><p>67[]8</p>' );
+
+			merge( [ 1 ] );
+			output( '<p>12345[]678</p>' );
+
+			editor.execute( 'undo' );
+			output( '<p>12345</p><p>678[]</p>' );
+
+			editor.execute( 'undo' );
+			output( '<p>12345</p><p>67</p><p>8[]</p>' );
+
+			editor.execute( 'undo' );
+			output( '<p>123</p><p>45[]</p><p>67</p><p>8</p>' );
+
+			undoDisabled();
+
+			editor.execute( 'redo' );
+			output( '<p>12345[]</p><p>67</p><p>8</p>' );
+
+			editor.execute( 'redo' );
+			output( '<p>12345</p><p>678[]</p>' );
+
+			editor.execute( 'redo' );
+			output( '<p>1234567[]8</p>' );
+
+			redoDisabled();
+		} );
+
+		it( 'split, merge, split, merge (same position)', () => {
+			input( '<p>12345678</p>' );
+
+			split( [ 0, 3 ] );
+			output( '<p>123</p><p>[]45678</p>' );
+
+			merge( [ 1 ] );
+			output( '<p>123[]45678</p>' );
+
+			split( [ 0, 3 ] );
+			output( '<p>123</p><p>[]45678</p>' );
+
+			merge( [ 1 ] );
+			output( '<p>123[]45678</p>' );
+
+			editor.execute( 'undo' );
+			output( '<p>123</p><p>45678[]</p>' );
+
+			editor.execute( 'undo' );
+			output( '<p>12345678[]</p>' );
+
+			editor.execute( 'undo' );
+			output( '<p>123</p><p>45678[]</p>' );
+
+			editor.execute( 'undo' );
+			output( '<p>12345678[]</p>' );
+
+			undoDisabled();
+
+			editor.execute( 'redo' );
+			output( '<p>123[]</p><p>45678</p>' );
+
+			editor.execute( 'redo' );
+			output( '<p>12345678[]</p>' );
+
+			editor.execute( 'redo' );
+			output( '<p>123[]</p><p>45678</p>' );
+
+			editor.execute( 'redo' );
+			output( '<p>12345678[]</p>' );
+
+			redoDisabled();
+		} );
+
+		it( 'split, split, split, merge, merge, merge', () => {
+			input( '<p>12345678</p>' );
+
+			split( [ 0, 3 ] );
+			output( '<p>123</p><p>[]45678</p>' );
+
+			split( [ 1, 4 ] );
+			output( '<p>123</p><p>4567</p><p>[]8</p>' );
+
+			split( [ 1, 2 ] );
+			output( '<p>123</p><p>45</p><p>[]67</p><p>8</p>' );
+
+			merge( [ 1 ] );
+			output( '<p>123[]45</p><p>67</p><p>8</p>' );
+
+			merge( [ 2 ] );
+			output( '<p>12345</p><p>67[]8</p>' );
+
+			merge( [ 1 ] );
+			output( '<p>12345[]678</p>' );
+
+			editor.execute( 'undo' );
+			output( '<p>12345</p><p>678[]</p>' );
+
+			editor.execute( 'undo' );
+			output( '<p>12345</p><p>67</p><p>8[]</p>' );
+
+			editor.execute( 'undo' );
+			output( '<p>123</p><p>45[]</p><p>67</p><p>8</p>' );
+
+			editor.execute( 'undo' );
+			output( '<p>123</p><p>4567[]</p><p>8</p>' );
+
+			editor.execute( 'undo' );
+			output( '<p>123</p><p>45678[]</p>' );
+
+			editor.execute( 'undo' );
+			output( '<p>12345678[]</p>' );
+
+			undoDisabled();
+
+			editor.execute( 'redo' );
+			output( '<p>123[]</p><p>45678</p>' );
+
+			editor.execute( 'redo' );
+			output( '<p>123</p><p>4567[]</p><p>8</p>' );
+
+			editor.execute( 'redo' );
+			output( '<p>123</p><p>45[]</p><p>67</p><p>8</p>' );
+
+			editor.execute( 'redo' );
+			output( '<p>12345[]</p><p>67</p><p>8</p>' );
+
+			editor.execute( 'redo' );
+			output( '<p>12345</p><p>678[]</p>' );
+
+			editor.execute( 'redo' );
+			output( '<p>1234567[]8</p>' );
+
+			redoDisabled();
+		} );
+
+		it( 'split, split, merge, split, merge (different order)', () => {
+			input( '<p>12345678</p>' );
+
+			split( [ 0, 3 ] );
+			output( '<p>123</p><p>[]45678</p>' );
+
+			split( [ 1, 2 ] );
+			output( '<p>123</p><p>45</p><p>[]678</p>' );
+
+			merge( [ 1 ] );
+			output( '<p>123[]45</p><p>678</p>' );
+
+			split( [ 1, 1 ] );
+			output( '<p>12345</p><p>6</p><p>[]78</p>' );
+
+			merge( [ 1 ] );
+			output( '<p>12345[]6</p><p>78</p>' );
+
+			editor.execute( 'undo' );
+			output( '<p>12345</p><p>6[]</p><p>78</p>' );
+
+			editor.execute( 'undo' );
+			output( '<p>12345</p><p>678[]</p>' );
+
+			editor.execute( 'undo' );
+			output( '<p>123</p><p>45[]</p><p>678</p>' );
+
+			editor.execute( 'undo' );
+			output( '<p>123</p><p>45678[]</p>' );
+
+			editor.execute( 'undo' );
+			output( '<p>12345678[]</p>' );
+
+			undoDisabled();
+
+			editor.execute( 'redo' );
+			output( '<p>123[]</p><p>45678</p>' );
+
+			editor.execute( 'redo' );
+			output( '<p>123</p><p>45[]</p><p>678</p>' );
+
+			editor.execute( 'redo' );
+			output( '<p>12345[]</p><p>678</p>' );
+
+			editor.execute( 'redo' );
+			output( '<p>12345</p><p>6[]</p><p>78</p>' );
+
+			editor.execute( 'redo' );
+			output( '<p>123456[]</p><p>78</p>' );
+
+			redoDisabled();
+		} );
+
+		it( 'split, remove, split, merge, merge', () => {
+			input( '<p>12345678</p>' );
+
+			split( [ 0, 3 ] );
+			output( '<p>123</p><p>[]45678</p>' );
+
+			remove( [ 1, 4 ] );
+			remove( [ 1, 3 ] );
+			output( '<p>123</p><p>45[]8</p>' );
+
+			split( [ 1, 1 ] );
+			output( '<p>123</p><p>4</p><p>[]58</p>' );
+
+			merge( [ 1 ] );
+			output( '<p>123[]4</p><p>58</p>' );
+
+			merge( [ 1 ] );
+			output( '<p>1234[]58</p>' );
+
+			editor.execute( 'undo' );
+			output( '<p>1234</p><p>58[]</p>' );
+
+			editor.execute( 'undo' );
+			output( '<p>123</p><p>4[]</p><p>58</p>' );
+
+			editor.execute( 'undo' );
+			output( '<p>123</p><p>458[]</p>' );
+
+			editor.execute( 'undo' );
+			output( '<p>123</p><p>4567[]8</p>' );
+
+			editor.execute( 'undo' );
+			output( '<p>12345678[]</p>' );
+
+			undoDisabled();
+
+			editor.execute( 'redo' );
+			output( '<p>123[]</p><p>45678</p>' );
+
+			editor.execute( 'redo' );
+			output( '<p>123</p><p>458[]</p>' );
+
+			editor.execute( 'redo' );
+			output( '<p>123</p><p>4[]</p><p>58</p>' );
+
+			editor.execute( 'redo' );
+			output( '<p>1234[]</p><p>58</p>' );
+
+			editor.execute( 'redo' );
+			output( '<p>12345[]8</p>' );
+
+			redoDisabled();
+		} );
+
+		it( 'split, typing, split, merge, merge', () => {
+			input( '<p>12345678</p>' );
+
+			split( [ 0, 3 ] );
+			output( '<p>123</p><p>[]45678</p>' );
+
+			type( [ 1, 4 ], 'x' );
+			type( [ 1, 5 ], 'y' );
+			output( '<p>123</p><p>4567xy[]8</p>' );
+
+			split( [ 1, 2 ] );
+			output( '<p>123</p><p>45</p><p>[]67xy8</p>' );
+
+			merge( [ 1 ] );
+			output( '<p>123[]45</p><p>67xy8</p>' );
+
+			merge( [ 1 ] );
+			output( '<p>12345[]67xy8</p>' );
+
+			editor.execute( 'undo' );
+			output( '<p>12345</p><p>67xy8[]</p>' );
+
+			editor.execute( 'undo' );
+			output( '<p>123</p><p>45[]</p><p>67xy8</p>' );
+
+			editor.execute( 'undo' );
+			output( '<p>123</p><p>4567xy8[]</p>' );
+
+			editor.execute( 'undo' );
+			output( '<p>123</p><p>4567[]8</p>' );
+
+			editor.execute( 'undo' );
+			output( '<p>12345678[]</p>' );
+
+			undoDisabled();
+
+			editor.execute( 'redo' );
+			output( '<p>123[]</p><p>45678</p>' );
+
+			editor.execute( 'redo' );
+			output( '<p>123</p><p>4567xy8[]</p>' );
+
+			editor.execute( 'redo' );
+			output( '<p>123</p><p>45[]</p><p>67xy8</p>' );
+
+			editor.execute( 'redo' );
+			output( '<p>12345[]</p><p>67xy8</p>' );
+
+			editor.execute( 'redo' );
+			output( '<p>1234567[]xy8</p>' );
+
+			redoDisabled();
+		} );
+	} );
+
+	describe( 'other edge cases', () => {
+		it( 'deleteContent between two nodes', () => {
+			input( '<p>fo[o</p><p>b]ar</p>' );
+
+			editor.data.deleteContent( doc.selection, doc.batch() );
+			output( '<p>fo[]ar</p>' );
+
+			editor.execute( 'undo' );
+			output( '<p>fo[o</p><p>b]ar</p>' );
+		} );
+
+		// Related to ckeditor5-engine#891 and ckeditor5-list#51.
+		it( 'change attribute of removed node then undo and redo', () => {
+			const gy = doc.graveyard;
+			const batch = doc.batch();
+			const p = new Element( 'p' );
+
+			root.appendChildren( p );
+
+			batch.remove( p );
+			batch.setAttribute( p, 'bold', true );
+
+			editor.execute( 'undo' );
+			editor.execute( 'redo' );
+
+			expect( p.root ).to.equal( gy );
+			expect( p.getAttribute( 'bold' ) ).to.be.true;
+		} );
+
+		// Related to ckeditor5-engine#891.
+		it( 'change attribute of removed node then undo and redo', () => {
+			const gy = doc.graveyard;
+			const batch = doc.batch();
+			const p1 = new Element( 'p' );
+			const p2 = new Element( 'p' );
+			const p3 = new Element( 'p' );
+
+			root.appendChildren( [ p1, p2 ] );
+
+			batch.remove( p1 ).remove( p2 ).insert( new Position( root, [ 0 ] ), p3 );
+
+			editor.execute( 'undo' );
+			editor.execute( 'redo' );
+
+			expect( p1.root ).to.equal( gy );
+			expect( p2.root ).to.equal( gy );
+			expect( p3.root ).to.equal( root );
 		} );
 	} );
 } );