浏览代码

Merge branch 'master' of github.com:ckeditor/ckeditor5-engine

Szymon Cofalik 8 年之前
父节点
当前提交
736da67ab1

+ 41 - 0
packages/ckeditor5-engine/CHANGELOG.md

@@ -1,6 +1,47 @@
 Changelog
 =========
 
+## [0.9.0](https://github.com/ckeditor/ckeditor5-engine/compare/v0.8.0...v0.9.0) (2017-04-05)
+
+### Bug fixes
+
+* Changed `DataController#insertContent()` behavior, so it doesn't clone given nodes. Closes [#869](https://github.com/ckeditor/ckeditor5-engine/issues/869). ([45f0f33](https://github.com/ckeditor/ckeditor5-engine/commit/45f0f33))
+* Empty `AttributeDelta` should not be added to batch. Closes [#875](https://github.com/ckeditor/ckeditor5-engine/issues/875). ([425399b](https://github.com/ckeditor/ckeditor5-engine/commit/425399b))
+* Fixed a bug where `LiveRange` position would be lost when using wrap and unwrap deltas. Closes [#841](https://github.com/ckeditor/ckeditor5-engine/issues/841). ([efe3987](https://github.com/ckeditor/ckeditor5-engine/commit/efe3987))
+* Fixed various issues with the move and unwrap deltas conversion. Closes [#847](https://github.com/ckeditor/ckeditor5-engine/issues/847). ([39c34a5](https://github.com/ckeditor/ckeditor5-engine/commit/39c34a5))
+* Live ranges, selections and markers no longer lose content when using the move delta. Closes [#877](https://github.com/ckeditor/ckeditor5-engine/issues/877). ([e08b019](https://github.com/ckeditor/ckeditor5-engine/commit/e08b019))
+
+  The base algorithm implemented in `Range#_getTransformedByDocumentChange()` will now include all model items between the old and new range boundary. See https://github.com/ckeditor/ckeditor5-engine/issues/877#issuecomment-287740021 for more details.
+* Mutations inserting bogus `<br>` at the end of the block element are filtered out by the mutation observer. Closes [#882](https://github.com/ckeditor/ckeditor5-engine/issues/882). ([3583cae](https://github.com/ckeditor/ckeditor5-engine/commit/3583cae))
+* Renderer should not change the native selection if the one it's about to render is visually similar to the current one. Closes [#887](https://github.com/ckeditor/ckeditor5-engine/issues/887). Closes [#880](https://github.com/ckeditor/ckeditor5-engine/issues/880). ([d8ee5fa](https://github.com/ckeditor/ckeditor5-engine/commit/d8ee5fa))
+* Renderer will unbind DOM elements from view elements when removing them from DOM. Closes [#888](https://github.com/ckeditor/ckeditor5-engine/issues/888). ([86ea5b5](https://github.com/ckeditor/ckeditor5-engine/commit/86ea5b5))
+* Reversed `ReinsertOperation` targets back to same graveyard holder from which the nodes were re-inserted. Closes [#891](https://github.com/ckeditor/ckeditor5-engine/issues/891). ([ea6c881](https://github.com/ckeditor/ckeditor5-engine/commit/ea6c881))
+* View document is now re-rendered after focusing. Closes [#795](https://github.com/ckeditor/ckeditor5-engine/issues/795). ([115a91b](https://github.com/ckeditor/ckeditor5-engine/commit/115a91b))
+* Renderer will deeply unbind DOM elements when they are removed from DOM. Closes [#888](https://github.com/ckeditor/ckeditor5-engine/issues/888). ([0aec182](https://github.com/ckeditor/ckeditor5-engine/commit/0aec182))
+
+### Features
+
+* `DataController#insertContent()` now accepts also model items. Closes [#870](https://github.com/ckeditor/ckeditor5-engine/issues/870). ([d00c973](https://github.com/ckeditor/ckeditor5-engine/commit/d00c973))
+* Added placeholder utility that can be applied to view elements. Closes [#857](https://github.com/ckeditor/ckeditor5-engine/issues/857). ([79b42da](https://github.com/ckeditor/ckeditor5-engine/commit/79b42da))
+* Introduced `dev-utils.DeltaReplayer`. Introduced new logging methods in `dev-utils.enableEngineDebug()`. Closes [#828](https://github.com/ckeditor/ckeditor5-engine/issues/828). ([eb855d9](https://github.com/ckeditor/ckeditor5-engine/commit/eb855d9))
+* Introduced markers serialization. Closes [#787](https://github.com/ckeditor/ckeditor5-engine/issues/787). Closes [#846](https://github.com/ckeditor/ckeditor5-engine/issues/846). ([2e7f75d](https://github.com/ckeditor/ckeditor5-engine/commit/2e7f75d))
+
+### Other changes
+
+* Changed the behavior of `DataController#deleteContent()` in a case of nested elements to better match situations like using <kbd>Backspace</kbd> after a block quotation. Closes [#710](https://github.com/ckeditor/ckeditor5-engine/issues/710). ([42a4429](https://github.com/ckeditor/ckeditor5-engine/commit/42a4429))
+* Default conversion.Mapper position mapping algorithms are now added as callbacks with low priority and are fired only if earlier callbacks did not provide a result. Closes [#884](https://github.com/ckeditor/ckeditor5-engine/issues/884). ([5627993](https://github.com/ckeditor/ckeditor5-engine/commit/5627993))
+* Simplified `SelectionObserver`'s infinite loop check which should improve its stability. Closes [#889](https://github.com/ckeditor/ckeditor5-engine/issues/889). ([8b859fb](https://github.com/ckeditor/ckeditor5-engine/commit/8b859fb))
+
+### BREAKING CHANGES
+
+* Since the default position mapping algorithms are attached with low priority, custom position mapping callbacks added with higher priority won't receive position calculated by default algorithms in data. To execute default position mapping algorithms and use their value, hook custom callback with lower priority.
+* `BuildModelConverter#fromMarkerCollapsed()` is removed. Use `BuildModelConverter#fromMarker()` instead.
+
+### NOTE
+
+* The `insertUIElement()` model to view converter now supports collapsed and non-collapsed ranges.
+
+
 ## [0.8.0](https://github.com/ckeditor/ckeditor5-engine/compare/v0.7.0...v0.8.0) (2017-03-06)
 
 ### Bug fixes

+ 10 - 10
packages/ckeditor5-engine/package.json

@@ -1,24 +1,24 @@
 {
   "name": "@ckeditor/ckeditor5-engine",
-  "version": "0.8.0",
+  "version": "0.9.0",
   "description": "CKEditor 5 Editing Engine",
   "keywords": [
     "CKEditor"
   ],
   "dependencies": {
-    "@ckeditor/ckeditor5-utils": "^0.8.0"
+    "@ckeditor/ckeditor5-utils": "^0.9.0"
   },
   "devDependencies": {
     "@ckeditor/ckeditor5-dev-lint": "^2.0.2",
     "@ckeditor/ckeditor5-basic-styles": "^0.7.1",
-    "@ckeditor/ckeditor5-core": "^0.7.0",
-    "@ckeditor/ckeditor5-editor-classic": "^0.7.1",
-    "@ckeditor/ckeditor5-enter": "^0.8.0",
-    "@ckeditor/ckeditor5-heading": "^0.8.0",
-    "@ckeditor/ckeditor5-list": "^0.5.1",
-    "@ckeditor/ckeditor5-paragraph": "^0.6.1",
-    "@ckeditor/ckeditor5-typing": "^0.8.0",
-    "@ckeditor/ckeditor5-undo": "^0.7.1",
+    "@ckeditor/ckeditor5-core": "^0.8.0",
+    "@ckeditor/ckeditor5-editor-classic": "^0.7.2",
+    "@ckeditor/ckeditor5-enter": "^0.9.0",
+    "@ckeditor/ckeditor5-heading": "^0.9.0",
+    "@ckeditor/ckeditor5-list": "^0.6.0",
+    "@ckeditor/ckeditor5-paragraph": "^0.7.0",
+    "@ckeditor/ckeditor5-typing": "^0.9.0",
+    "@ckeditor/ckeditor5-undo": "^0.8.0",
     "gulp": "^3.9.0",
     "guppy-pre-commit": "^0.4.0"
   },

+ 82 - 26
packages/ckeditor5-engine/src/controller/deletecontent.js

@@ -9,8 +9,8 @@
 
 import LivePosition from '../model/liveposition';
 import Position from '../model/position';
+import Range from '../model/range';
 import Element from '../model/element';
-import compareArrays from '@ckeditor/ckeditor5-utils/src/comparearrays';
 
 /**
  * Deletes content of the selection and merge siblings. The resulting selection is always collapsed.
@@ -65,31 +65,74 @@ export default function deleteContent( selection, batch, options = {} ) {
 	endPos.detach();
 }
 
+// This function is a result of reaching the Ballmer's peak for just the right amount of time.
+// Even I had troubles documenting it after a while and after reading it again I couldn't believe that it really works.
 function mergeBranches( batch, startPos, endPos ) {
-	const endPath = endPos.path;
-	const mergeEnd = Math.min( startPos.path.length - 1, endPath.length - 1 );
-	let mergeDepth = compareArrays( startPos.path, endPath );
-
-	if ( typeof mergeDepth == 'number' ) {
-		for ( ; mergeDepth < mergeEnd; mergeDepth++ ) {
-			const mergePath = startPos.path.slice( 0, mergeDepth );
-			mergePath.push( startPos.path[ mergeDepth ] + 1 );
-
-			const mergePos = new Position( endPos.root, mergePath );
-			const previousNode = mergePos.nodeBefore;
-			const nextNode = mergePos.nodeAfter;
-
-			if ( !checkCanBeMerged( previousNode ) || !checkCanBeMerged( nextNode ) ) {
-				return;
-			}
-
-			if ( nextNode.childCount > 0 ) {
-				batch.merge( mergePos );
-			} else {
-				batch.remove( nextNode );
-			}
+	const startParent = startPos.parent;
+	const endParent = endPos.parent;
+
+	// If both positions ended up in the same parent, then there's nothing more to merge:
+	// <$root><p>x[]</p><p>{}y</p></$root> => <$root><p>xy</p>[]{}</$root>
+	if ( startParent == endParent ) {
+		return;
+	}
+
+	// If one of the positions is a root, then there's nothing more to merge (at least in the current state of implementation).
+	// Theoretically in this case we could unwrap the <p>: <$root>x[]<p>{}y</p></$root>, but we don't need to support it yet
+	// so let's just abort.
+	if ( !startParent.parent || !endParent.parent ) {
+		return;
+	}
+
+	// Check if operations we'll need to do won't need to cross object or limit boundaries.
+	// E.g., we can't merge endParent into startParent in this case:
+	// <limit><startParent>x[]</startParent></limit><endParent>{}</endParent>
+	if ( !checkCanBeMerged( startPos, endPos ) ) {
+		return;
+	}
+
+	// Remember next positions to merge. For example:
+	// <a><b>x[]</b></a><c><d>{}y</d></c>
+	// will become:
+	// <a><b>xy</b>[]</a><c>{}</c>
+	startPos = Position.createAfter( startParent );
+	endPos = Position.createBefore( endParent );
+
+	if ( endParent.isEmpty ) {
+		batch.remove( endParent );
+	} else {
+		// At the moment, next startPos is also the position to which the endParent
+		// needs to be moved:
+		// <a><b>x[]</b></a><c><d>{}y</d></c>
+		// becomes:
+		// <a><b>x</b>[]<d>y</d></a><c>{}</c>
+
+		// Move the end parent only if needed.
+		// E.g. not in this case: <p>ab</p>[]{}<p>cd</p>
+		if ( !endPos.isEqual( startPos ) ) {
+			batch.move( endParent, startPos );
 		}
+
+		// To then become:
+		// <a><b>xy</b>[]</a><c>{}</c>
+		batch.merge( startPos );
 	}
+
+	// Removes empty end ancestors:
+	// <a>fo[o</a><b><a><c>bar]</c></a></b>
+	// becomes:
+	// <a>fo[]</a><b><a>{}</a></b>
+	// So we can remove <a> and <b>.
+	while ( endPos.parent.isEmpty ) {
+		const parentToRemove = endPos.parent;
+
+		endPos = Position.createBefore( parentToRemove );
+
+		batch.remove( parentToRemove );
+	}
+
+	// Continue merging next level.
+	mergeBranches( batch, startPos, endPos );
 }
 
 function shouldAutoparagraph( doc, position ) {
@@ -99,8 +142,21 @@ function shouldAutoparagraph( doc, position ) {
 	return !isTextAllowed && isParagraphAllowed;
 }
 
-function checkCanBeMerged( element ) {
-	const schema = element.document.schema;
+// Check if parents of two positions can be merged by checking if there are no limit/object
+// boundaries between those two positions.
+//
+// E.g. in <bQ><p>x[]</p></bQ><widget><caption>{}</caption></widget>
+// we'll check <p>, <bQ>, <widget> and <caption>.
+// Usually, widget and caption are marked as objects/limits in the schema, so in this case merging will be blocked.
+function checkCanBeMerged( leftPos, rightPos ) {
+	const schema = leftPos.root.document.schema;
+	const rangeToCheck = new Range( leftPos, rightPos );
+
+	for ( const value of rangeToCheck.getWalker() ) {
+		if ( schema.objects.has( value.item.name ) || schema.limits.has( value.item.name ) ) {
+			return false;
+		}
+	}
 
-	return !schema.objects.has( element.name ) && !schema.limits.has( element.name );
+	return true;
 }

+ 135 - 0
packages/ckeditor5-engine/src/dev-utils/deltareplayer.js

@@ -0,0 +1,135 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module engine/dev-utils/deltareplayer
+ */
+
+/* global setTimeout, console */
+
+import DeltaFactory from '../model/delta/deltafactory';
+
+/**
+ * DeltaReplayer is a dev-tool created for easily replaying operations on the document from stringified deltas.
+ */
+export default class DeltaReplayer {
+	/**
+	 * @param {module:engine/model/document~Document} document Document to reply deltas on.
+	 * @param {String} logSeparator Separator between deltas.
+	 * @param {String} stringifiedDeltas Deltas to replay.
+	 */
+	constructor( document, logSeparator, stringifiedDeltas ) {
+		this._document = document;
+		this._logSeparator = logSeparator;
+		this.setStringifiedDeltas( stringifiedDeltas );
+	}
+
+	/**
+	 * Parses given string containing stringified deltas and sets parsed deltas as deltas to reply.
+	 *
+	 * @param {String} stringifiedDeltas Stringified deltas to replay.
+	 */
+	setStringifiedDeltas( stringifiedDeltas ) {
+		if ( stringifiedDeltas === '' ) {
+			this._deltasToReplay = [];
+
+			return;
+		}
+
+		this._deltasToReplay = stringifiedDeltas
+			.split( this._logSeparator )
+			.map( stringifiedDelta => JSON.parse( stringifiedDelta ) );
+	}
+
+	/**
+	 * Returns deltas to reply.
+	 *
+	 * @returns {Array.<module:engine/model/delta/delta~Delta>}
+	 */
+	getDeltasToReplay() {
+		return this._deltasToReplay;
+	}
+
+	/**
+	 * Applies all deltas with delay between actions.
+	 *
+	 * @param {Number} timeInterval Time between applying deltas.
+	 * @returns {Promise}
+	 */
+	play( timeInterval = 1000 ) {
+		const deltaReplayer = this;
+
+		return new Promise( ( res ) => {
+			play();
+
+			function play() {
+				if ( deltaReplayer._deltasToReplay.length === 0 ) {
+					return res();
+				}
+
+				deltaReplayer.applyNextDelta().then( () => {
+					setTimeout( play, timeInterval );
+				}, res );
+			}
+		} );
+	}
+
+	/**
+	 * Applies `numberOfDeltas` deltas, beginning after the last applied delta (or first delta, if no deltas were applied).
+	 *
+	 * @param {Number} numberOfDeltas Number of deltas to apply.
+	 * @returns {Promise}
+	 */
+	applyDeltas( numberOfDeltas ) {
+		if ( numberOfDeltas <= 0 ) {
+			return;
+		}
+
+		return this.applyNextDelta()
+			.then( () => this.applyDeltas( numberOfDeltas - 1 ) )
+			.catch( err => console.warn( err ) );
+	}
+
+	/**
+	 * Applies all deltas to replay at once.
+	 *
+	 * @returns {Promise}
+	 */
+	applyAllDeltas() {
+		return this.applyNextDelta()
+			.then( () => this.applyAllDeltas() )
+			.catch( () => {} );
+	}
+
+	/**
+	 * Applies the next delta to replay.
+	 *
+	 * @returns {Promise}
+	 */
+	applyNextDelta() {
+		const document = this._document;
+
+		return new Promise( ( res, rej ) => {
+			document.enqueueChanges( () => {
+				const jsonDelta = this._deltasToReplay.shift();
+
+				if ( !jsonDelta ) {
+					return rej( new Error( 'No deltas to replay' ) );
+				}
+
+				const delta = DeltaFactory.fromJSON( jsonDelta, this._document );
+
+				const batch = document.batch();
+				batch.addDelta( delta );
+
+				for ( const operation of delta.operations ) {
+					document.applyOperation( operation );
+				}
+
+				res();
+			} );
+		} );
+	}
+}

+ 47 - 2
packages/ckeditor5-engine/src/dev-utils/enableenginedebug.js

@@ -46,11 +46,16 @@ import ViewDocumentFragment from '../view/documentfragment';
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import Editor from '@ckeditor/ckeditor5-core/src/editor/editor';
 
+import DeltaReplayer from './deltareplayer';
+
 const treeDump = Symbol( '_treeDump' );
 
 // Maximum number of stored states of model and view document.
 const maxTreeDumpLength = 20;
 
+// Separator used to separate stringified deltas
+const LOG_SEPARATOR = '\n----------------\n';
+
 // Specified whether debug tools were already enabled.
 let enabled = false;
 
@@ -96,14 +101,17 @@ let log = console.log;
  * @param {Function} [logger] Function used to log messages. By default messages are logged to console.
  * @returns {module:engine/dev-utils/enableenginedebug~DebugPlugin} Plugin to be loaded in the editor.
  */
-export default function enableEngineDebug( logger = console.log ) {
-	log = logger;
+export default function enableEngineDebug( logger ) {
+	if ( logger ) {
+		log = logger;
+	}
 
 	if ( !enabled ) {
 		enabled = true;
 
 		enableLoggingTools();
 		enableDocumentTools();
+		enableReplayerTools();
 	}
 
 	return DebugPlugin;
@@ -448,12 +456,49 @@ function enableLoggingTools() {
 	};
 }
 
+function enableReplayerTools() {
+	const _modelDocumentApplyOperation = ModelDocument.prototype.applyOperation;
+
+	ModelDocument.prototype.applyOperation = function( operation ) {
+		if ( !this._lastDelta ) {
+			this._appliedDeltas = [];
+		} else if ( this._lastDelta !== operation.delta ) {
+			this._appliedDeltas.push( this._lastDelta.toJSON() );
+		}
+
+		this._lastDelta = operation.delta;
+
+		_modelDocumentApplyOperation.call( this, operation );
+	};
+
+	ModelDocument.prototype.getAppliedDeltas = function() {
+		// No deltas has been applied yet, return empty string.
+		if ( !this._lastDelta ) {
+			return '';
+		}
+
+		const appliedDeltas = this._appliedDeltas.concat( this._lastDelta.toJSON() );
+
+		return appliedDeltas.map( JSON.stringify ).join( LOG_SEPARATOR );
+	};
+
+	ModelDocument.prototype.createReplayer = function( stringifiedDeltas ) {
+		return new DeltaReplayer( this, LOG_SEPARATOR, stringifiedDeltas );
+	};
+}
+
 function enableDocumentTools() {
 	const _modelDocumentApplyOperation = ModelDocument.prototype.applyOperation;
 
 	ModelDocument.prototype.applyOperation = function( operation ) {
 		log( 'Applying ' + operation );
 
+		if ( !this._operationLogs ) {
+			this._operationLogs = [];
+		}
+
+		this._operationLogs.push( JSON.stringify( operation.toJSON() ) );
+
 		_modelDocumentApplyOperation.call( this, operation );
 	};
 

+ 176 - 36
packages/ckeditor5-engine/tests/controller/deletecontent.js

@@ -4,6 +4,9 @@
  */
 
 import Document from '../../src/model/document';
+import Position from '../../src/model/position';
+import Range from '../../src/model/range';
+import Element from '../../src/model/element';
 import deleteContent from '../../src/controller/deletecontent';
 import { setData, getData } from '../../src/dev-utils/model';
 
@@ -154,10 +157,16 @@ describe( 'DataController', () => {
 				schema.registerItem( 'paragraph', '$block' );
 				schema.registerItem( 'heading1', '$block' );
 				schema.registerItem( 'pchild' );
+				schema.registerItem( 'pparent' );
 				schema.registerItem( 'image', '$inline' );
 
 				schema.allow( { name: 'pchild', inside: 'paragraph' } );
 				schema.allow( { name: '$text', inside: 'pchild' } );
+
+				schema.allow( { name: 'paragraph', inside: 'pparent' } );
+				schema.allow( { name: 'pparent', inside: '$root' } );
+				schema.allow( { name: '$text', inside: 'pparent' } );
+
 				schema.allow( { name: 'paragraph', attributes: [ 'align' ] } );
 			} );
 
@@ -195,6 +204,9 @@ describe( 'DataController', () => {
 				{ merge: true }
 			);
 
+			// Note: in all these cases we ignore the direction of merge.
+			// If https://github.com/ckeditor/ckeditor5-engine/issues/470 was fixed we could differently treat
+			// forward and backward delete.
 			it( 'merges second element into the first one (different name, backward selection)', () => {
 				setData(
 					doc,
@@ -222,40 +234,9 @@ describe( 'DataController', () => {
 			);
 
 			test(
-				'merges elements when deep nested',
-				'<paragraph>x<pchild>fo[o</pchild></paragraph><paragraph><pchild>b]ar</pchild>y</paragraph>',
-				'<paragraph>x<pchild>fo[]ar</pchild>y</paragraph>',
-				{ merge: true }
-			);
-
-			// For code coverage reasons.
-			test(
-				'merges element when selection is in two consecutive nodes even when it is empty',
-				'<paragraph>foo[</paragraph><paragraph>]bar</paragraph>',
-				'<paragraph>foo[]bar</paragraph>',
-				{ merge: true }
-			);
-
-			// If you disagree with this case please read the notes before this section.
-			test(
-				'merges elements when left end deep nested',
-				'<paragraph>x<pchild>fo[o</pchild></paragraph><paragraph>b]ary</paragraph>',
-				'<paragraph>x<pchild>fo[]</pchild>ary</paragraph>',
-				{ merge: true }
-			);
-
-			// If you disagree with this case please read the notes before this section.
-			test(
-				'merges elements when right end deep nested',
-				'<paragraph>xfo[o</paragraph><paragraph><pchild>b]ar</pchild>y<image></image></paragraph>',
-				'<paragraph>xfo[]<pchild>ar</pchild>y<image></image></paragraph>',
-				{ merge: true }
-			);
-
-			test(
-				'merges elements when more content in the right branch',
-				'<paragraph>xfo[o</paragraph><paragraph>b]a<pchild>r</pchild>y</paragraph>',
-				'<paragraph>xfo[]a<pchild>r</pchild>y</paragraph>',
+				'merges empty element into the first element',
+				'<heading1>f[oo</heading1><paragraph>bar]</paragraph><paragraph>x</paragraph>',
+				'<heading1>f[]</heading1><paragraph>x</paragraph>',
 				{ merge: true }
 			);
 
@@ -281,7 +262,166 @@ describe( 'DataController', () => {
 				expect( spyRemove.called ).to.be.true;
 			} );
 
-			describe( 'object elements', () => {
+			it( 'does not try to move the second block if not needed', () => {
+				setData( doc, '<paragraph>ab[cd</paragraph><paragraph>ef]gh</paragraph>' );
+
+				const batch = doc.batch();
+				const spyMerge = sinon.spy( batch, 'merge' );
+				const spyMove = sinon.spy( batch, 'move' );
+
+				deleteContent( doc.selection, batch, { merge: true } );
+
+				expect( getData( doc ) ).to.equal( '<paragraph>ab[]gh</paragraph>' );
+
+				expect( spyMove.called ).to.be.false;
+				expect( spyMerge.called ).to.be.true;
+			} );
+
+			// Note: in all these cases we ignore the direction of merge.
+			// If https://github.com/ckeditor/ckeditor5-engine/issues/470 was fixed we could differently treat
+			// forward and backward delete.
+			describe( 'with nested elements', () => {
+				test(
+					'merges elements when deep nested',
+					'<paragraph>x<pchild>fo[o</pchild></paragraph><paragraph><pchild>b]ar</pchild>y</paragraph>',
+					'<paragraph>x<pchild>fo[]ar</pchild>y</paragraph>',
+					{ merge: true }
+				);
+
+				it( 'merges elements when deep nested (3rd level)', () => {
+					const root = doc.getRoot();
+
+					// We need to use the raw API due to https://github.com/ckeditor/ckeditor5-engine/issues/905.
+					// <pparent>x<paragraph>x<pchild>fo[o</pchild></paragraph></pparent>
+					// <pparent><paragraph><pchild>b]ar</pchild>y</paragraph>y</pparent>
+
+					root.appendChildren(
+						new Element( 'pparent', null, [
+							'x',
+							new Element( 'paragraph', null, [
+								'x',
+								new Element( 'pchild', null, 'foo' )
+							] )
+						] )
+					);
+
+					root.appendChildren(
+						new Element( 'pparent', null, [
+							new Element( 'paragraph', null, [
+								new Element( 'pchild', null, 'bar' ),
+								'y'
+							] ),
+							'y'
+						] )
+					);
+
+					const range = new Range(
+						new Position( doc.getRoot(), [ 0, 1, 1, 2 ] ), // fo[o
+						new Position( doc.getRoot(), [ 1, 0, 0, 1 ] ) // b]ar
+					);
+
+					doc.selection.setRanges( [ range ] );
+
+					deleteContent( doc.selection, doc.batch(), { merge: true } );
+
+					expect( getData( doc ) )
+						.to.equal( '<pparent>x<paragraph>x<pchild>fo[]ar</pchild>y</paragraph>y</pparent>' );
+				} );
+
+				test(
+					'merges elements when left end deep nested',
+					'<paragraph>x<pchild>fo[o</pchild></paragraph><paragraph>b]ary</paragraph><paragraph>x</paragraph>',
+					'<paragraph>x<pchild>fo[]ary</pchild></paragraph><paragraph>x</paragraph>',
+					{ merge: true }
+				);
+
+				test(
+					'merges elements when right end deep nested',
+					'<paragraph>x</paragraph><paragraph>fo[o</paragraph><paragraph><pchild>b]ar</pchild>x</paragraph>',
+					'<paragraph>x</paragraph><paragraph>fo[]ar</paragraph><paragraph>x</paragraph>',
+					{ merge: true }
+				);
+
+				it( 'merges elements when left end deep nested (3rd level)', () => {
+					const root = doc.getRoot();
+
+					// We need to use the raw API due to https://github.com/ckeditor/ckeditor5-engine/issues/905.
+					// <pparent>x<paragraph>foo<pchild>ba[r</pchild></paragraph></pparent><paragraph>b]om</paragraph>
+
+					root.appendChildren(
+						new Element( 'pparent', null, [
+							'x',
+							new Element( 'paragraph', null, [
+								'foo',
+								new Element( 'pchild', null, 'bar' )
+							] )
+						] )
+					);
+
+					root.appendChildren(
+						new Element( 'paragraph', null, 'bom' )
+					);
+
+					const range = new Range(
+						new Position( doc.getRoot(), [ 0, 1, 3, 2 ] ), // ba[r
+						new Position( doc.getRoot(), [ 1, 1 ] ) // b]om
+					);
+
+					doc.selection.setRanges( [ range ] );
+
+					deleteContent( doc.selection, doc.batch(), { merge: true } );
+
+					expect( getData( doc ) )
+						.to.equal( '<pparent>x<paragraph>foo<pchild>ba[]om</pchild></paragraph></pparent>' );
+				} );
+
+				test(
+					'merges elements when right end deep nested (in an empty container)',
+					'<paragraph>fo[o</paragraph><paragraph><pchild>bar]</pchild></paragraph>',
+					'<paragraph>fo[]</paragraph>',
+					{ merge: true }
+				);
+
+				test(
+					'merges elements when left end deep nested (in an empty container)',
+					'<paragraph><pchild>[foo</pchild></paragraph><paragraph>b]ar</paragraph><paragraph>x</paragraph>',
+					'<paragraph><pchild>[]ar</pchild></paragraph><paragraph>x</paragraph>',
+					{ merge: true }
+				);
+
+				it( 'merges elements when left end deep nested (3rd level)', () => {
+					const root = doc.getRoot();
+
+					// We need to use the raw API due to https://github.com/ckeditor/ckeditor5-engine/issues/905.
+					// <paragraph>fo[o</paragraph><pparent><paragraph><pchild>bar]</pchild></paragraph></pparent>
+
+					root.appendChildren(
+						new Element( 'paragraph', null, 'foo' )
+					);
+
+					root.appendChildren(
+						new Element( 'pparent', null, [
+							new Element( 'paragraph', null, [
+								new Element( 'pchild', null, 'bar' )
+							] )
+						] )
+					);
+
+					const range = new Range(
+						new Position( doc.getRoot(), [ 0, 2 ] ), // f[oo
+						new Position( doc.getRoot(), [ 1, 0, 0, 3 ] ) // bar]
+					);
+
+					doc.selection.setRanges( [ range ] );
+
+					deleteContent( doc.selection, doc.batch(), { merge: true } );
+
+					expect( getData( doc ) )
+						.to.equal( '<paragraph>fo[]</paragraph>' );
+				} );
+			} );
+
+			describe( 'with object elements', () => {
 				beforeEach( () => {
 					const schema = doc.schema;
 
@@ -512,7 +652,7 @@ describe( 'DataController', () => {
 			);
 
 			test(
-				'should delete inside block limit element',
+				'should delete inside block limit element (with merge)',
 				'<blockLimit><paragraph>fo[o</paragraph><paragraph>b]ar</paragraph></blockLimit>',
 				'<blockLimit><paragraph>fo[]ar</paragraph></blockLimit>',
 				{ merge: true }

+ 200 - 0
packages/ckeditor5-engine/tests/dev-utils/deltareplayer.js

@@ -0,0 +1,200 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global console */
+
+import DeltaReplayer from '../../src/dev-utils/deltareplayer';
+import Document from '../../src/model/document';
+
+describe( 'DeltaReplayer', () => {
+	const sandbox = sinon.sandbox.create();
+	let stubs;
+
+	beforeEach( () => {
+		stubs = {
+			consoleWarn: sandbox.stub( console, 'warn' ),
+		};
+	} );
+
+	afterEach( () => {
+		sandbox.restore();
+	} );
+
+	describe( 'constructor()', () => {
+		it( 'should be able to initialize replayer without deltas', () => {
+			const doc = getDocument();
+			const stringifiedDeltas = '';
+			const deltaReplayer = new DeltaReplayer( doc, '---', stringifiedDeltas );
+
+			expect( deltaReplayer.getDeltasToReplay() ).to.deep.equal( [] );
+		} );
+
+		it( 'should be able to initialize replayer with deltas', () => {
+			const doc = getDocument();
+			const delta = getFirstDelta();
+
+			const deltaReplayer = new DeltaReplayer( doc, '---', JSON.stringify( delta ) );
+
+			expect( deltaReplayer.getDeltasToReplay() ).to.deep.equal( [ delta ] );
+		} );
+	} );
+
+	describe( 'applyNextDelta()', () => {
+		it( 'should remove first delta from stack', () => {
+			const doc = getDocument();
+			const delta = getFirstDelta();
+
+			const deltaReplayer = new DeltaReplayer( doc, '---', JSON.stringify( delta ) );
+
+			return deltaReplayer.applyNextDelta().then( () => {
+				expect( deltaReplayer.getDeltasToReplay() ).to.deep.equal( [] );
+			} );
+		} );
+
+		it( 'should apply first delta on the document', () => {
+			const doc = getDocument();
+			const delta = getFirstDelta();
+
+			const deltaReplayer = new DeltaReplayer( doc, '---', JSON.stringify( delta ) );
+
+			return deltaReplayer.applyNextDelta().then( () => {
+				expect( Array.from( doc.getRoot().getChildren() ).length ).to.equal( 1 );
+			} );
+		} );
+
+		it( 'should throw an error if 0 deltas are provided', () => {
+			const doc = getDocument();
+			const deltaReplayer = new DeltaReplayer( doc, '---', '' );
+
+			return deltaReplayer.applyNextDelta().then( () => {
+				throw new Error( 'This should throw an error' );
+			}, ( err ) => {
+				expect( err instanceof Error ).to.equal( true );
+				expect( err.message ).to.equal( 'No deltas to replay' );
+			} );
+		} );
+	} );
+
+	describe( 'applyAllDeltas()', () => {
+		it( 'should apply all deltas on the document', () => {
+			const doc = getDocument();
+
+			const stringifiedDeltas = [ getFirstDelta(), getSecondDelta() ]
+				.map( d => JSON.stringify( d ) )
+				.join( '---' );
+
+			const deltaReplayer = new DeltaReplayer( doc, '---', stringifiedDeltas );
+
+			return deltaReplayer.applyAllDeltas().then( () => {
+				expect( Array.from( doc.getRoot().getChildren() ).length ).to.equal( 2 );
+				expect( deltaReplayer.getDeltasToReplay().length ).to.equal( 0 );
+			} );
+		} );
+	} );
+
+	describe( 'applyDeltas()', () => {
+		it( 'should apply certain number of deltas on the document', () => {
+			const doc = getDocument();
+
+			const stringifiedDeltas = [ getFirstDelta(), getSecondDelta() ]
+				.map( d => JSON.stringify( d ) )
+				.join( '---' );
+
+			const deltaReplayer = new DeltaReplayer( doc, '---', stringifiedDeltas );
+
+			return deltaReplayer.applyDeltas( 1 ).then( () => {
+				expect( Array.from( doc.getRoot().getChildren() ).length ).to.equal( 1 );
+				expect( deltaReplayer.getDeltasToReplay().length ).to.equal( 1 );
+			} );
+		} );
+
+		it( 'should not throw an error if the number of deltas is lower than number of expected deltas to replay', () => {
+			const doc = getDocument();
+
+			const stringifiedDeltas = [ getFirstDelta(), getSecondDelta() ]
+				.map( d => JSON.stringify( d ) )
+				.join( '---' );
+
+			const deltaReplayer = new DeltaReplayer( doc, '---', stringifiedDeltas );
+
+			return deltaReplayer.applyDeltas( 3 ).then( () => {
+				expect( Array.from( doc.getRoot().getChildren() ).length ).to.equal( 2 );
+				expect( deltaReplayer.getDeltasToReplay().length ).to.equal( 0 );
+				sinon.assert.calledWithExactly( stubs.consoleWarn, new Error( 'No deltas to replay' ) );
+			} );
+		} );
+	} );
+
+	describe( 'play()', () => {
+		it( 'should play deltas with time interval', () => {
+			const doc = getDocument();
+
+			const stringifiedDeltas = [ getFirstDelta(), getSecondDelta() ]
+				.map( d => JSON.stringify( d ) )
+				.join( '---' );
+
+			const deltaReplayer = new DeltaReplayer( doc, '---', stringifiedDeltas );
+
+			return deltaReplayer.play( 0 ).then( () => {
+				expect( deltaReplayer.getDeltasToReplay().length ).to.equal( 0 );
+			} );
+		} );
+
+		it( 'should work with default time interval', () => {
+			const doc = getDocument();
+
+			const deltaReplayer = new DeltaReplayer( doc, '---', '' );
+
+			return deltaReplayer.play();
+		} );
+	} );
+} );
+
+function getDocument() {
+	const doc = new Document();
+	doc.createRoot( 'main' );
+
+	return doc;
+}
+
+function getFirstDelta() {
+	return {
+		operations: [ {
+			baseVersion: 0,
+			position: {
+				root: 'main',
+				path: [ 0 ]
+			},
+			nodes: [ {
+				name: 'heading1',
+				children: [ {
+					data: 'The great world of open Web standards'
+				} ]
+			} ],
+			__className: 'engine.model.operation.InsertOperation'
+		} ],
+		__className: 'engine.model.delta.InsertDelta'
+	};
+}
+
+function getSecondDelta() {
+	return {
+		operations: [ {
+			baseVersion: 1,
+			position: {
+				root: 'main',
+				path: [ 1 ]
+			},
+			nodes: [ {
+				name: 'heading2',
+				children: [ {
+					data: 'The great world of open Web standards'
+				} ]
+			} ],
+			__className: 'engine.model.operation.InsertOperation'
+		} ],
+		__className: 'engine.model.delta.InsertDelta'
+	};
+}

+ 51 - 0
packages/ckeditor5-engine/tests/dev-utils/enableenginedebug.js

@@ -713,6 +713,57 @@ describe( 'debug tools', () => {
 		} );
 	} );
 
+	describe( 'should provide methods for delta replayer', () => {
+		it( 'getAppliedDeltas()', () => {
+			const modelDoc = new ModelDocument();
+
+			expect( modelDoc.getAppliedDeltas() ).to.equal( '' );
+
+			const otherRoot = modelDoc.createRoot( '$root', 'otherRoot' );
+			const firstEle = new ModelElement( 'paragraph' );
+			const removedEle = new ModelElement( 'paragraph', null, [ new ModelText( 'foo' ) ] );
+
+			otherRoot.appendChildren( [ firstEle, removedEle ] );
+
+			const delta = new MergeDelta();
+			const move = new MoveOperation( ModelPosition.createAt( removedEle, 0 ), 3, ModelPosition.createAt( firstEle, 0 ), 0 );
+			const remove = new RemoveOperation( ModelPosition.createBefore( removedEle ), 1, 1 );
+
+			delta.addOperation( move );
+			delta.addOperation( remove );
+
+			modelDoc.applyOperation( move );
+			modelDoc.applyOperation( remove );
+
+			const stringifiedDeltas = modelDoc.getAppliedDeltas();
+
+			expect( stringifiedDeltas ).to.equal( JSON.stringify( delta.toJSON() ) );
+		} );
+
+		it( 'createReplayer()', () => {
+			const modelDoc = new ModelDocument();
+
+			const otherRoot = modelDoc.createRoot( '$root', 'otherRoot' );
+			const firstEle = new ModelElement( 'paragraph' );
+			const removedEle = new ModelElement( 'paragraph', null, [ new ModelText( 'foo' ) ] );
+
+			otherRoot.appendChildren( [ firstEle, removedEle ] );
+
+			const delta = new MergeDelta();
+			const move = new MoveOperation( ModelPosition.createAt( removedEle, 0 ), 3, ModelPosition.createAt( firstEle, 0 ), 0 );
+			const remove = new RemoveOperation( ModelPosition.createBefore( removedEle ), 1, 1 );
+
+			delta.addOperation( move );
+			delta.addOperation( remove );
+
+			const stringifiedDeltas = JSON.stringify( delta.toJSON() );
+
+			const deltaReplayer = modelDoc.createReplayer( stringifiedDeltas );
+
+			expect( deltaReplayer.getDeltasToReplay() ).to.deep.equal( [ JSON.parse( stringifiedDeltas ) ] );
+		} );
+	} );
+
 	function expectLog( expectedLogMsg ) {
 		expect( log.calledWithExactly( expectedLogMsg ) ).to.be.true;
 		log.reset();