Ver código fonte

Merge branch 'master' into t/903-new

Piotrek Koszuliński 8 anos atrás
pai
commit
ed404b8f02
100 arquivos alterados com 4494 adições e 2632 exclusões
  1. 14 0
      packages/ckeditor5-engine/CHANGELOG.md
  2. 14 14
      packages/ckeditor5-engine/package.json
  3. 13 10
      packages/ckeditor5-engine/src/controller/datacontroller.js
  4. 2 2
      packages/ckeditor5-engine/src/controller/deletecontent.js
  5. 10 12
      packages/ckeditor5-engine/src/controller/getselectedcontent.js
  6. 4 4
      packages/ckeditor5-engine/src/controller/insertcontent.js
  7. 13 10
      packages/ckeditor5-engine/src/conversion/buildviewconverter.js
  8. 2 8
      packages/ckeditor5-engine/src/conversion/view-to-model-converters.js
  9. 33 30
      packages/ckeditor5-engine/src/conversion/viewconversiondispatcher.js
  10. 185 127
      packages/ckeditor5-engine/src/dev-utils/enableenginedebug.js
  11. 10 11
      packages/ckeditor5-engine/src/dev-utils/model.js
  12. 16 1
      packages/ckeditor5-engine/src/dev-utils/view.js
  13. 862 54
      packages/ckeditor5-engine/src/model/batch.js
  14. 0 149
      packages/ckeditor5-engine/src/model/delta/attributedelta.js
  15. 0 1
      packages/ckeditor5-engine/src/model/delta/basic-deltas.js
  16. 0 50
      packages/ckeditor5-engine/src/model/delta/insertdelta.js
  17. 0 85
      packages/ckeditor5-engine/src/model/delta/markerdelta.js
  18. 0 65
      packages/ckeditor5-engine/src/model/delta/mergedelta.js
  19. 0 41
      packages/ckeditor5-engine/src/model/delta/movedelta.js
  20. 0 38
      packages/ckeditor5-engine/src/model/delta/removedelta.js
  21. 0 37
      packages/ckeditor5-engine/src/model/delta/renamedelta.js
  22. 30 0
      packages/ckeditor5-engine/src/model/delta/rootattributedelta.js
  23. 0 55
      packages/ckeditor5-engine/src/model/delta/splitdelta.js
  24. 0 51
      packages/ckeditor5-engine/src/model/delta/unwrapdelta.js
  25. 0 40
      packages/ckeditor5-engine/src/model/delta/weakinsertdelta.js
  26. 0 65
      packages/ckeditor5-engine/src/model/delta/wrapdelta.js
  27. 11 9
      packages/ckeditor5-engine/src/model/document.js
  28. 1 1
      packages/ckeditor5-engine/src/model/documentfragment.js
  29. 5 5
      packages/ckeditor5-engine/src/model/documentselection.js
  30. 2 0
      packages/ckeditor5-engine/src/model/element.js
  31. 1 1
      packages/ckeditor5-engine/src/model/item.jsdoc
  32. 8 3
      packages/ckeditor5-engine/src/model/operation/attributeoperation.js
  33. 87 0
      packages/ckeditor5-engine/src/model/operation/detachoperation.js
  34. 8 3
      packages/ckeditor5-engine/src/model/operation/insertoperation.js
  35. 23 0
      packages/ckeditor5-engine/src/model/operation/markeroperation.js
  36. 13 2
      packages/ckeditor5-engine/src/model/operation/moveoperation.js
  37. 12 0
      packages/ckeditor5-engine/src/model/operation/nooperation.js
  38. 10 0
      packages/ckeditor5-engine/src/model/operation/operation.js
  39. 31 0
      packages/ckeditor5-engine/src/model/operation/reinsertoperation.js
  40. 35 0
      packages/ckeditor5-engine/src/model/operation/removeoperation.js
  41. 5 0
      packages/ckeditor5-engine/src/model/operation/renameoperation.js
  42. 8 0
      packages/ckeditor5-engine/src/model/operation/rootattributeoperation.js
  43. 33 54
      packages/ckeditor5-engine/src/model/operation/utils.js
  44. 3 10
      packages/ckeditor5-engine/src/model/schema.js
  45. 3 1
      packages/ckeditor5-engine/src/model/text.js
  46. 1 1
      packages/ckeditor5-engine/src/model/textproxy.js
  47. 3 1
      packages/ckeditor5-engine/src/view/domconverter.js
  48. 1 1
      packages/ckeditor5-engine/src/view/placeholder.js
  49. 24 16
      packages/ckeditor5-engine/tests/controller/datacontroller.js
  50. 14 6
      packages/ckeditor5-engine/tests/controller/editingcontroller.js
  51. 34 34
      packages/ckeditor5-engine/tests/controller/getselectedcontent.js
  52. 4 2
      packages/ckeditor5-engine/tests/controller/insertcontent.js
  53. 23 32
      packages/ckeditor5-engine/tests/conversion/advanced-converters.js
  54. 7 6
      packages/ckeditor5-engine/tests/conversion/buildmodelconverter.js
  55. 41 33
      packages/ckeditor5-engine/tests/conversion/buildviewconverter.js
  56. 14 14
      packages/ckeditor5-engine/tests/conversion/model-selection-to-view-converters.js
  57. 16 33
      packages/ckeditor5-engine/tests/conversion/model-to-view-converters.js
  58. 26 18
      packages/ckeditor5-engine/tests/conversion/modelconversiondispatcher.js
  59. 13 9
      packages/ckeditor5-engine/tests/conversion/view-to-model-converters.js
  60. 24 15
      packages/ckeditor5-engine/tests/conversion/viewconversiondispatcher.js
  61. 82 8
      packages/ckeditor5-engine/tests/dev-utils/enableenginedebug.js
  62. 12 12
      packages/ckeditor5-engine/tests/dev-utils/model.js
  63. 18 0
      packages/ckeditor5-engine/tests/dev-utils/view.js
  64. 22 0
      packages/ckeditor5-engine/tests/manual/nestededitable.css
  65. 1 1
      packages/ckeditor5-engine/tests/manual/nestededitable.js
  66. 0 25
      packages/ckeditor5-engine/tests/manual/nestededitable.scss
  67. 1 1
      packages/ckeditor5-engine/tests/manual/tickets/475/1.js
  68. 1868 42
      packages/ckeditor5-engine/tests/model/batch.js
  69. 1 405
      packages/ckeditor5-engine/tests/model/delta/attributedelta.js
  70. 0 88
      packages/ckeditor5-engine/tests/model/delta/insertdelta.js
  71. 0 111
      packages/ckeditor5-engine/tests/model/delta/markerdelta.js
  72. 0 62
      packages/ckeditor5-engine/tests/model/delta/mergedelta.js
  73. 0 67
      packages/ckeditor5-engine/tests/model/delta/movedelta.js
  74. 0 71
      packages/ckeditor5-engine/tests/model/delta/removedelta.js
  75. 0 46
      packages/ckeditor5-engine/tests/model/delta/renamedelta.js
  76. 12 0
      packages/ckeditor5-engine/tests/model/delta/rootattributedelta.js
  77. 0 78
      packages/ckeditor5-engine/tests/model/delta/splitdelta.js
  78. 0 50
      packages/ckeditor5-engine/tests/model/delta/unwrapdelta.js
  79. 0 50
      packages/ckeditor5-engine/tests/model/delta/weakinsertdelta.js
  80. 0 83
      packages/ckeditor5-engine/tests/model/delta/wrapdelta.js
  81. 34 2
      packages/ckeditor5-engine/tests/model/document/document.js
  82. 5 5
      packages/ckeditor5-engine/tests/model/documentselection.js
  83. 1 1
      packages/ckeditor5-engine/tests/model/markercollection.js
  84. 45 0
      packages/ckeditor5-engine/tests/model/operation/attributeoperation.js
  85. 71 0
      packages/ckeditor5-engine/tests/model/operation/detachoperation.js
  86. 24 0
      packages/ckeditor5-engine/tests/model/operation/insertoperation.js
  87. 20 0
      packages/ckeditor5-engine/tests/model/operation/markeroperation.js
  88. 28 0
      packages/ckeditor5-engine/tests/model/operation/moveoperation.js
  89. 4 0
      packages/ckeditor5-engine/tests/model/operation/nooperation.js
  90. 26 0
      packages/ckeditor5-engine/tests/model/operation/operationfactory.js
  91. 35 0
      packages/ckeditor5-engine/tests/model/operation/reinsertoperation.js
  92. 31 0
      packages/ckeditor5-engine/tests/model/operation/removeoperation.js
  93. 27 0
      packages/ckeditor5-engine/tests/model/operation/renameoperation.js
  94. 28 0
      packages/ckeditor5-engine/tests/model/operation/rootattributeoperation.js
  95. 33 56
      packages/ckeditor5-engine/tests/model/operation/utils.js
  96. 4 27
      packages/ckeditor5-engine/tests/model/schema/schema.js
  97. 4 4
      packages/ckeditor5-engine/tests/model/selection.js
  98. 280 54
      packages/ckeditor5-engine/tests/view/document/jumpoveruielement.js
  99. 18 6
      packages/ckeditor5-engine/tests/view/domconverter/domconverter.js
  100. 7 7
      packages/ckeditor5-engine/tests/view/observer/focusobserver.js

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

@@ -1,6 +1,20 @@
 Changelog
 =========
 
+## [1.0.0-alpha.2](https://github.com/ckeditor/ckeditor5-engine/compare/v1.0.0-alpha.1...v1.0.0-alpha.2) (2017-11-14)
+
+### Bug fixes
+
+* `model.Range` will now be extended if it was collapsed and it was transformed by insertion. Closes [#1159](https://github.com/ckeditor/ckeditor5-engine/issues/1159). ([5f020b0](https://github.com/ckeditor/ckeditor5-engine/commit/5f020b0))
+* Prevent adding inline filler to non-editable content. Closes [#1170](https://github.com/ckeditor/ckeditor5-engine/issues/1170). ([07a01b1](https://github.com/ckeditor/ckeditor5-engine/commit/07a01b1))
+* The `deleteContent()` algorithm will use merging to "remove" empty element which will ensure a better conflict resolution on collaborative editing. Closes [#1161](https://github.com/ckeditor/ckeditor5-engine/issues/1161). ([0dd29d4](https://github.com/ckeditor/ckeditor5-engine/commit/0dd29d4))
+
+### Other changes
+
+* Removed the `renderer-skipped-selection-rendering` warning since it doesn't bring any value. Closes [#1158](https://github.com/ckeditor/ckeditor5-engine/issues/1158). ([4a5a5d1](https://github.com/ckeditor/ckeditor5-engine/commit/4a5a5d1))
+* The `removeHighlight()` function now accepts descriptor id instead of a `HighlightDescriptor` object. Closes [#1164](https://github.com/ckeditor/ckeditor5-engine/issues/1164). ([7bde6f7](https://github.com/ckeditor/ckeditor5-engine/commit/7bde6f7))
+
+
 ## [1.0.0-alpha.1](https://github.com/ckeditor/ckeditor5-engine/compare/v0.11.0...v1.0.0-alpha.1) (2017-10-03)
 
 ### Bug fixes

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

@@ -1,28 +1,28 @@
 {
   "name": "@ckeditor/ckeditor5-engine",
-  "version": "1.0.0-alpha.1",
+  "version": "1.0.0-alpha.2",
   "description": "CKEditor 5 editing engine.",
   "keywords": [
     "ckeditor5",
     "ckeditor5-lib"
   ],
   "dependencies": {
-    "@ckeditor/ckeditor5-utils": "^1.0.0-alpha.1"
+    "@ckeditor/ckeditor5-utils": "^1.0.0-alpha.2"
   },
   "devDependencies": {
-    "@ckeditor/ckeditor5-basic-styles": "^1.0.0-alpha.1",
-    "@ckeditor/ckeditor5-core": "^1.0.0-alpha.1",
-    "@ckeditor/ckeditor5-editor-classic": "^1.0.0-alpha.1",
-    "@ckeditor/ckeditor5-enter": "^1.0.0-alpha.1",
-    "@ckeditor/ckeditor5-essentials": "^1.0.0-alpha.1",
-    "@ckeditor/ckeditor5-heading": "^1.0.0-alpha.1",
-    "@ckeditor/ckeditor5-list": "^1.0.0-alpha.1",
-    "@ckeditor/ckeditor5-paragraph": "^1.0.0-alpha.1",
-    "@ckeditor/ckeditor5-typing": "^1.0.0-alpha.1",
-    "@ckeditor/ckeditor5-undo": "^1.0.0-alpha.1",
-    "@ckeditor/ckeditor5-widget": "^1.0.0-alpha.1",
+    "@ckeditor/ckeditor5-basic-styles": "^1.0.0-alpha.2",
+    "@ckeditor/ckeditor5-core": "^1.0.0-alpha.2",
+    "@ckeditor/ckeditor5-editor-classic": "^1.0.0-alpha.2",
+    "@ckeditor/ckeditor5-enter": "^1.0.0-alpha.2",
+    "@ckeditor/ckeditor5-essentials": "^1.0.0-alpha.2",
+    "@ckeditor/ckeditor5-heading": "^1.0.0-alpha.2",
+    "@ckeditor/ckeditor5-list": "^1.0.0-alpha.2",
+    "@ckeditor/ckeditor5-paragraph": "^1.0.0-alpha.2",
+    "@ckeditor/ckeditor5-typing": "^1.0.0-alpha.2",
+    "@ckeditor/ckeditor5-undo": "^1.0.0-alpha.2",
+    "@ckeditor/ckeditor5-widget": "^1.0.0-alpha.2",
     "eslint": "^4.8.0",
-    "eslint-config-ckeditor5": "^1.0.6",
+    "eslint-config-ckeditor5": "^1.0.7",
     "husky": "^0.14.3",
     "lint-staged": "^4.2.3"
   },

+ 13 - 10
packages/ckeditor5-engine/src/controller/datacontroller.js

@@ -21,7 +21,6 @@ import { convertText, convertToModelFragment } from '../conversion/view-to-model
 import ViewDocumentFragment from '../view/documentfragment';
 
 import ModelRange from '../model/range';
-import ModelPosition from '../model/position';
 import ModelElement from '../model/element';
 
 import insertContent from './insertcontent';
@@ -196,9 +195,10 @@ export default class DataController {
 			this.model.selection.clearAttributes();
 
 			// Initial batch should be ignored by features like undo, etc.
-			this.model.batch( 'transparent' )
-				.remove( ModelRange.createIn( modelRoot ) )
-				.insert( ModelPosition.createAt( modelRoot, 0 ), this.parse( data ) );
+			const batch = this.model.batch( 'transparent' );
+
+			batch.remove( ModelRange.createIn( modelRoot ) );
+			batch.insert( this.parse( data, batch ), modelRoot );
 		} );
 	}
 
@@ -208,16 +208,17 @@ export default class DataController {
 	 *
 	 * @see #set
 	 * @param {String} data Data to parse.
+	 * @param {module:engine/model/batch~Batch} batch Batch to which the deltas will be added.
 	 * @param {String} [context='$root'] Base context in which the view will be converted to the model. See:
 	 * {@link module:engine/conversion/viewconversiondispatcher~ViewConversionDispatcher#convert}.
 	 * @returns {module:engine/model/documentfragment~DocumentFragment} Parsed data.
 	 */
-	parse( data, context = '$root' ) {
+	parse( data, batch, context = '$root' ) {
 		// data -> view
 		const viewDocumentFragment = this.processor.toView( data );
 
 		// view -> model
-		return this.toModel( viewDocumentFragment, context );
+		return this.toModel( viewDocumentFragment, batch, context );
 	}
 
 	/**
@@ -231,12 +232,13 @@ export default class DataController {
 	 *
 	 * @param {module:engine/view/element~Element|module:engine/view/documentfragment~DocumentFragment} viewElementOrFragment
 	 * Element or document fragment which content will be converted.
+	 * @param {module:engine/model/batch~Batch} batch Batch to which the deltas will be added.
 	 * @param {String} [context='$root'] Base context in which the view will be converted to the model. See:
 	 * {@link module:engine/conversion/viewconversiondispatcher~ViewConversionDispatcher#convert}.
 	 * @returns {module:engine/model/documentfragment~DocumentFragment} Output document fragment.
 	 */
-	toModel( viewElementOrFragment, context = '$root' ) {
-		return this.viewToModel.convert( viewElementOrFragment, { context: [ context ] } );
+	toModel( viewElementOrFragment, batch, context = '$root' ) {
+		return this.viewToModel.convert( viewElementOrFragment, batch, { context: [ context ] } );
 	}
 
 	/**
@@ -293,10 +295,11 @@ export default class DataController {
 	 *
 	 * @fires module:engine/controller/datacontroller~DataController#getSelectedContent
 	 * @param {module:engine/model/selection~Selection} selection The selection of which content will be retrieved.
+	 * @param {module:engine/model/batch~Batch} batch Batch to which deltas will be added.
 	 * @returns {module:engine/model/documentfragment~DocumentFragment} Document fragment holding the clone of the selected content.
 	 */
-	getSelectedContent( selection ) {
-		return getSelectedContent( selection );
+	getSelectedContent( selection, batch ) {
+		return getSelectedContent( selection, batch );
 	}
 
 	/**

+ 2 - 2
packages/ckeditor5-engine/src/controller/deletecontent.js

@@ -128,7 +128,7 @@ function mergeBranches( batch, startPos, endPos ) {
 		// <a><b>x[]</b></a><c><d>{}y</d></c>
 		// becomes:
 		// <a><b>x</b>[]<d>y</d></a><c>{}</c>
-		batch.move( endParent, startPos );
+		batch.insert( endParent, startPos );
 	}
 
 	// Merge two siblings:
@@ -181,7 +181,7 @@ function checkCanBeMerged( leftPos, rightPos ) {
 
 function insertParagraph( batch, position, selection ) {
 	const paragraph = new Element( 'paragraph' );
-	batch.insert( position, paragraph );
+	batch.insert( paragraph, position );
 
 	selection.setCollapsedAt( paragraph );
 }

+ 10 - 12
packages/ckeditor5-engine/src/controller/getselectedcontent.js

@@ -7,11 +7,8 @@
  * @module engine/controller/getselectedcontent
  */
 
-import DocumentFragment from '../model/documentfragment';
 import Range from '../model/range';
 import Position from '../model/position';
-import Text from '../model/text';
-import { remove } from '../model/writer';
 
 /**
  * Gets a clone of the selected content.
@@ -25,10 +22,11 @@ import { remove } from '../model/writer';
  *		<quote><h>st</h></quote><p>se</p>
  *
  * @param {module:engine/model/selection~Selection} selection The selection of which content will be returned.
+ * @param {module:engine/model/batch~Batch} batch Batch to which deltas will be added.
  * @returns {module:engine/model/documentfragment~DocumentFragment}
  */
-export default function getSelectedContent( selection ) {
-	const frag = new DocumentFragment();
+export default function getSelectedContent( selection, batch ) {
+	const frag = batch.createDocumentFragment();
 	const range = selection.getFirstRange();
 
 	if ( !range || range.isCollapsed ) {
@@ -69,9 +67,9 @@ export default function getSelectedContent( selection ) {
 	// Clone the whole contents.
 	for ( const item of flatSubtreeRange.getItems( { shallow: true } ) ) {
 		if ( item.is( 'textProxy' ) ) {
-			frag.appendChildren( new Text( item.data, item.getAttributes() ) );
+			batch.appendText( item.data, item.getAttributes(), frag );
 		} else {
-			frag.appendChildren( item.clone( true ) );
+			batch.append( item.clone( true ), frag );
 		}
 	}
 
@@ -97,8 +95,8 @@ export default function getSelectedContent( selection ) {
 		const leftExcessRange = new Range( Position.createAt( frag ), newRange.start );
 		const rightExcessRange = new Range( newRange.end, Position.createAt( frag, 'end' ) );
 
-		removeRangeContent( rightExcessRange );
-		removeRangeContent( leftExcessRange );
+		removeRangeContent( rightExcessRange, batch );
+		removeRangeContent( leftExcessRange, batch );
 	}
 
 	return frag;
@@ -106,7 +104,7 @@ export default function getSelectedContent( selection ) {
 
 // After https://github.com/ckeditor/ckeditor5-engine/issues/690 is fixed,
 // this function will, most likely, be able to rewritten using getMinimalFlatRanges().
-function removeRangeContent( range ) {
+function removeRangeContent( range, batch ) {
 	const parentsToCheck = [];
 
 	Array.from( range.getItems( { direction: 'backward' } ) )
@@ -128,7 +126,7 @@ function removeRangeContent( range ) {
 		.forEach( itemRange => {
 			parentsToCheck.push( itemRange.start.parent );
 
-			remove( itemRange );
+			batch.remove( itemRange );
 		} );
 
 	// Remove ancestors of the removed items if they turned to be empty now
@@ -141,7 +139,7 @@ function removeRangeContent( range ) {
 
 			parent = parent.parent;
 
-			remove( removeRange );
+			batch.remove( removeRange );
 		}
 	} );
 }

+ 4 - 4
packages/ckeditor5-engine/src/controller/insertcontent.js

@@ -229,7 +229,7 @@ class Insertion {
 		// If the node is a text and bare text is allowed in current position it means that the node
 		// contains disallowed attributes and we have to remove them.
 		else if ( this.schema.check( { name: '$text', inside: this.position } ) ) {
-			this.schema.removeDisallowedAttributes( [ node ], this.position );
+			this.schema.removeDisallowedAttributes( [ node ], this.position, this.batch );
 			this._handleNode( node, context );
 		}
 		// If text is not allowed, try autoparagraphing.
@@ -256,7 +256,7 @@ class Insertion {
 
 		const livePos = LivePosition.createFromPosition( this.position );
 
-		this.batch.insert( this.position, node );
+		this.batch.insert( node, this.position );
 
 		this.position = Position.createFromPosition( livePos );
 		livePos.detach();
@@ -341,7 +341,7 @@ class Insertion {
 	 * @param {Object} context
 	 */
 	_tryAutoparagraphing( node, context ) {
-		const paragraph = new Element( 'paragraph' );
+		const paragraph = this.batch.createElement( 'paragraph' );
 
 		// Do not autoparagraph if the paragraph won't be allowed there,
 		// cause that would lead to an infinite loop. The paragraph would be rejected in
@@ -350,7 +350,7 @@ class Insertion {
 			// When node is a text and is disallowed by schema it means that contains disallowed attributes
 			// and we need to remove them.
 			if ( node.is( 'text' ) && !this._checkIsAllowed( node, [ paragraph ] ) ) {
-				this.schema.removeDisallowedAttributes( [ node ], [ paragraph ] );
+				this.schema.removeDisallowedAttributes( [ node ], [ paragraph ], this.batch );
 			}
 
 			if ( this._checkIsAllowed( node, [ paragraph ] ) ) {

+ 13 - 10
packages/ckeditor5-engine/src/conversion/buildviewconverter.js

@@ -8,9 +8,6 @@
  */
 
 import Matcher from '../view/matcher';
-import ModelElement from '../model/element';
-import ModelPosition from '../model/position';
-import modelWriter from '../model/writer';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 import isIterable from '@ckeditor/ckeditor5-utils/src/isiterable';
 
@@ -266,13 +263,15 @@ class ViewConverterBuilder {
 	 *		buildViewConverter().for( dispatcher ).fromElement( 'p' ).toElement( 'paragraph' );
 	 *		buildViewConverter().for( dispatcher )
 	 *			.fromElement( 'img' )
-	 *			.toElement( ( viewElement ) => new ModelElement( 'image', { src: viewElement.getAttribute( 'src' ) } );
+	 *			.toElement( ( viewElement, batch ) => batch.createElement( 'image', { src: viewElement.getAttribute( 'src' ) } ) );
 	 *
 	 * @param {String|Function} element Model element name or model element creator function.
 	 */
 	toElement( element ) {
 		function eventCallbackGen( from ) {
 			return ( evt, data, consumable, conversionApi ) => {
+				const batch = conversionApi.batch;
+
 				// There is one callback for all patterns in the matcher.
 				// This will be usually just one pattern but we support matchers with many patterns too.
 				const matchAll = from.matcher.matchAll( data.input );
@@ -285,7 +284,7 @@ class ViewConverterBuilder {
 				// Now, for every match between matcher and actual element, we will try to consume the match.
 				for ( const match of matchAll ) {
 					// Create model element basing on creator function or element name.
-					const modelElement = element instanceof Function ? element( data.input ) : new ModelElement( element );
+					const modelElement = element instanceof Function ? element( data.input, batch ) : batch.createElement( element );
 
 					// Do not convert if element building function returned falsy value.
 					if ( !modelElement ) {
@@ -310,8 +309,10 @@ class ViewConverterBuilder {
 
 					// Convert children of converted view element and append them to `modelElement`.
 					const modelChildren = conversionApi.convertChildren( data.input, consumable, data );
-					const insertPosition = ModelPosition.createAt( modelElement, 'end' );
-					modelWriter.insert( insertPosition, modelChildren );
+
+					for ( const child of Array.from( modelChildren ) ) {
+						batch.append( child, modelElement );
+					}
 
 					// Remove created `modelElement` from the parents stack.
 					data.context.pop();
@@ -433,7 +434,9 @@ class ViewConverterBuilder {
 	 */
 	toMarker( creator ) {
 		function eventCallbackGen( from ) {
-			return ( evt, data, consumable ) => {
+			return ( evt, data, consumable, conversionApi ) => {
+				const batch = conversionApi.batch;
+
 				// There is one callback for all patterns in the matcher.
 				// This will be usually just one pattern but we support matchers with many patterns too.
 				const matchAll = from.matcher.matchAll( data.input );
@@ -450,7 +453,7 @@ class ViewConverterBuilder {
 					modelElement = creator( data.input );
 				// When there is no creator then create model element basing on data from view element.
 				} else {
-					modelElement = new ModelElement( '$marker', { 'data-name': data.input.getAttribute( 'data-name' ) } );
+					modelElement = batch.createElement( '$marker', { 'data-name': data.input.getAttribute( 'data-name' ) } );
 				}
 
 				// Check if model element is correct (has proper name and property).
@@ -525,7 +528,7 @@ function setAttributeOn( toChange, attribute, data, conversionApi ) {
 	};
 
 	if ( conversionApi.schema.check( schemaQuery ) ) {
-		toChange.setAttribute( attribute.key, attribute.value );
+		conversionApi.batch.setAttribute( attribute.key, attribute.value, toChange );
 	}
 }
 

+ 2 - 8
packages/ckeditor5-engine/src/conversion/view-to-model-converters.js

@@ -3,10 +3,6 @@
  * For licensing, see LICENSE.md.
  */
 
-import ModelDocumentFragment from '../model/documentfragment';
-import ModelText from '../model/text';
-import { normalizeNodes } from '../model/writer';
-
 /**
  * Contains {@link module:engine/view/view view} to {@link module:engine/model/model model} converters for
  * {@link module:engine/conversion/viewconversiondispatcher~ViewConversionDispatcher}.
@@ -33,9 +29,7 @@ export function convertToModelFragment() {
 	return ( evt, data, consumable, conversionApi ) => {
 		// Second argument in `consumable.consume` is discarded for ViewDocumentFragment but is needed for ViewElement.
 		if ( !data.output && consumable.consume( data.input, { name: true } ) ) {
-			const convertedChildren = conversionApi.convertChildren( data.input, consumable, data );
-
-			data.output = new ModelDocumentFragment( normalizeNodes( convertedChildren ) );
+			data.output = conversionApi.convertChildren( data.input, consumable, data );
 		}
 	};
 }
@@ -54,7 +48,7 @@ export function convertText() {
 
 		if ( conversionApi.schema.check( schemaQuery ) ) {
 			if ( consumable.consume( data.input ) ) {
-				data.output = new ModelText( data.input.data );
+				data.output = conversionApi.batch.createText( data.input.data );
 			}
 		}
 	};

+ 33 - 30
packages/ckeditor5-engine/src/conversion/viewconversiondispatcher.js

@@ -13,11 +13,9 @@ import ModelPosition from '../model/position';
 import ModelTreeWalker from '../model/treewalker';
 import ModelNode from '../model/node';
 import ModelDocumentFragment from '../model/documentfragment';
-import { remove } from '../model/writer';
 
 import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin';
 import mix from '@ckeditor/ckeditor5-utils/src/mix';
-import extend from '@ckeditor/ckeditor5-utils/src/lib/lodash/extend';
 import log from '@ckeditor/ckeditor5-utils/src/log';
 
 /**
@@ -93,7 +91,7 @@ import log from '@ckeditor/ckeditor5-utils/src/log';
  *		// Fire conversion.
  *		// Always take care where the converted model structure will be appended to. If this `viewDocumentFragment`
  *		// is going to be appended directly to a '$root' element, use that in `context`.
- *		viewDispatcher.convert( viewDocumentFragment, { context: [ '$root' ] } );
+ *		viewDispatcher.convert( viewDocumentFragment, batch, { context: [ '$root' ] } );
  *
  * Before each conversion process, `ViewConversionDispatcher` fires {@link ~ViewConversionDispatcher#event:viewCleanup}
  * event which can be used to prepare tree view for conversion.
@@ -118,12 +116,15 @@ export default class ViewConversionDispatcher {
 		 *
 		 * @member {module:engine/conversion/viewconversiondispatcher~ViewConversionApi}
 		 */
-		this.conversionApi = extend( {}, conversionApi );
+		this.conversionApi = Object.assign( {}, conversionApi );
 
 		// `convertItem` and `convertChildren` are bound to this `ViewConversionDispatcher` instance and
 		// set on `conversionApi`. This way only a part of `ViewConversionDispatcher` API is exposed.
 		this.conversionApi.convertItem = this._convertItem.bind( this );
 		this.conversionApi.convertChildren = this._convertChildren.bind( this );
+
+		// Batch used for conversion. Is passed to #convert method and removed at the and of the conversion.
+		this.conversionApi.batch = null;
 	}
 
 	/**
@@ -134,13 +135,17 @@ export default class ViewConversionDispatcher {
 	 * @fires documentFragment
 	 * @param {module:engine/view/documentfragment~DocumentFragment|module:engine/view/element~Element} viewItem
 	 * Part of the view to be converted.
-	 * @param {Object} [additionalData] Additional data to be passed in `data` argument when firing `ViewConversionDispatcher`
+	 * @param {module:engine/model/batch~Batch} batch Batch to which the deltas will be added.
+	 * @param {Object} additionalData Additional data to be passed in `data` argument when firing `ViewConversionDispatcher`
 	 * events. See also {@link ~ViewConversionDispatcher#event:element element event}.
 	 * @returns {module:engine/model/documentfragment~DocumentFragment} Model data that is a result of the conversion process
 	 * wrapped in `DocumentFragment`. Converted marker elements will be set as that document fragment's
 	 * {@link module:engine/model/documentfragment~DocumentFragment#markers static markers map}.
 	 */
-	convert( viewItem, additionalData = {} ) {
+	convert( viewItem, batch, additionalData ) {
+		// Store batch in current conversion as conversionApi, will be removed at the end of this conversion.
+		this.conversionApi.batch = batch;
+
 		this.fire( 'viewCleanup', viewItem );
 
 		const consumable = ViewConsumable.createFrom( viewItem );
@@ -149,16 +154,19 @@ export default class ViewConversionDispatcher {
 		// We can get a null here if conversion failed (see _convertItem())
 		// or simply if an item could not be converted (e.g. due to the schema).
 		if ( !conversionResult ) {
-			return new ModelDocumentFragment();
+			return batch.createDocumentFragment();
 		}
 
 		// When conversion result is not a document fragment we need to wrap it in document fragment.
 		if ( !conversionResult.is( 'documentFragment' ) ) {
-			conversionResult = new ModelDocumentFragment( [ conversionResult ] );
+			const docFrag = batch.createDocumentFragment();
+
+			batch.append( conversionResult, docFrag );
+			conversionResult = docFrag;
 		}
 
 		// Extract temporary markers elements from model and set as static markers collection.
-		conversionResult.markers = extractMarkersFromModelFragment( conversionResult );
+		conversionResult.markers = extractMarkersFromModelFragment( conversionResult, batch );
 
 		return conversionResult;
 	}
@@ -168,7 +176,7 @@ export default class ViewConversionDispatcher {
 	 * @see module:engine/conversion/viewconversiondispatcher~ViewConversionApi#convertItem
 	 */
 	_convertItem( input, consumable, additionalData = {} ) {
-		const data = extend( {}, additionalData, {
+		const data = Object.assign( {}, additionalData, {
 			input,
 			output: null
 		} );
@@ -203,24 +211,19 @@ export default class ViewConversionDispatcher {
 	 * @private
 	 * @see module:engine/conversion/viewconversiondispatcher~ViewConversionApi#convertChildren
 	 */
-	_convertChildren( input, consumable, additionalData = {} ) {
-		// Get all children of view input item.
-		const viewChildren = Array.from( input.getChildren() );
-
-		// 1. Map those children to model.
-		// 2. Filter out items that has not been converted or for which conversion returned wrong result (for those warning is logged).
-		// 3. Extract children from document fragments to flatten results.
-		const convertedChildren = viewChildren
-			.map( viewChild => this._convertItem( viewChild, consumable, additionalData ) )
-			.filter( converted => converted instanceof ModelNode || converted instanceof ModelDocumentFragment )
-			.reduce( ( result, filtered ) => {
-				return result.concat(
-					filtered.is( 'documentFragment' ) ? Array.from( filtered.getChildren() ) : filtered
-				);
-			}, [] );
-
-		// Normalize array to model document fragment.
-		return new ModelDocumentFragment( convertedChildren );
+	_convertChildren( input, consumable, additionalData ) {
+		const batch = this.conversionApi.batch;
+		const documentFragment = batch.createDocumentFragment();
+
+		for ( const viewChild of Array.from( input.getChildren() ) ) {
+			const modelChild = this._convertItem( viewChild, consumable, additionalData );
+
+			if ( modelChild instanceof ModelNode || modelChild instanceof ModelDocumentFragment ) {
+				batch.append( modelChild, documentFragment );
+			}
+		}
+
+		return documentFragment;
 	}
 
 	/**
@@ -278,7 +281,7 @@ mix( ViewConversionDispatcher, EmitterMixin );
 //
 // @param {module:engine/view/documentfragment~DocumentFragment|module:engine/view/node~Node} modelItem Fragment of model.
 // @returns {Map<String, module:engine/model/range~Range>} List of static markers.
-function extractMarkersFromModelFragment( modelItem ) {
+function extractMarkersFromModelFragment( modelItem, batch ) {
 	const markerElements = new Set();
 	const markers = new Map();
 
@@ -310,7 +313,7 @@ function extractMarkersFromModelFragment( modelItem ) {
 		}
 
 		// Remove marker element from DocumentFragment.
-		remove( ModelRange.createOn( markerElement ) );
+		batch.remove( markerElement );
 	}
 
 	return markers;

+ 185 - 127
packages/ckeditor5-engine/src/dev-utils/enableenginedebug.js

@@ -18,6 +18,7 @@ import ModelTextProxy from '../model/textproxy';
 import ModelElement from '../model/element';
 import Operation from '../model/operation/operation';
 import AttributeOperation from '../model/operation/attributeoperation';
+import DetachOperation from '../model/operation/detachoperation';
 import InsertOperation from '../model/operation/insertoperation';
 import MarkerOperation from '../model/operation/markeroperation';
 import MoveOperation from '../model/operation/moveoperation';
@@ -25,12 +26,13 @@ import NoOperation from '../model/operation/nooperation';
 import RenameOperation from '../model/operation/renameoperation';
 import RootAttributeOperation from '../model/operation/rootattributeoperation';
 import Delta from '../model/delta/delta';
-import { default as AttributeDelta, RootAttributeDelta } from '../model/delta/attributedelta';
+import AttributeDelta from '../model/delta/attributedelta';
 import InsertDelta from '../model/delta/insertdelta';
 import MarkerDelta from '../model/delta/markerdelta';
 import MergeDelta from '../model/delta/mergedelta';
 import MoveDelta from '../model/delta/movedelta';
 import RenameDelta from '../model/delta/renamedelta';
+import RootAttributeDelta from '../model/delta/rootattributedelta';
 import SplitDelta from '../model/delta/splitdelta';
 import UnwrapDelta from '../model/delta/unwrapdelta';
 import WrapDelta from '../model/delta/wrapdelta';
@@ -50,6 +52,46 @@ import Editor from '@ckeditor/ckeditor5-core/src/editor/editor';
 
 import clone from '@ckeditor/ckeditor5-utils/src/lib/lodash/clone';
 
+// Sandbox class allows creating mocks of the functions and restoring these mocks to the original values.
+class Sandbox {
+	constructor() {
+		// An array that contains functions which restore the original values of mocked objects.
+		// @private
+		// @type {Array.<Function>}
+		this._restores = [];
+	}
+
+	// Creates a new mock.
+	//
+	// @param {Object} object Object to mock.
+	// @param {String} methodName Function to mock.
+	// @param {Function} fakeMethod Function that will be executed.
+	mock( object, methodName, fakeMethod ) {
+		const originalMethod = object[ methodName ];
+
+		object[ methodName ] = fakeMethod;
+
+		this._restores.unshift( () => {
+			if ( originalMethod ) {
+				object[ methodName ] = originalMethod;
+			} else {
+				delete object[ methodName ];
+			}
+		} );
+	}
+
+	// Restores all mocked functions.
+	restore() {
+		for ( const restore of this._restores ) {
+			restore();
+		}
+
+		this._restores = [];
+	}
+}
+
+const sandbox = new Sandbox();
+
 const treeDump = Symbol( '_treeDump' );
 
 // Maximum number of stored states of model and view document.
@@ -118,60 +160,68 @@ export default function enableEngineDebug( _logger = console ) {
 	return DebugPlugin;
 }
 
+/**
+ * Restores all methods that have been overwritten.
+ */
+export function disableEngineDebug() {
+	sandbox.restore();
+	enabled = false;
+}
+
 function enableLoggingTools() {
-	ModelPosition.prototype.toString = function() {
+	sandbox.mock( ModelPosition.prototype, 'toString', function() {
 		return `${ this.root } [ ${ this.path.join( ', ' ) } ]`;
-	};
+	} );
 
-	ModelPosition.prototype.log = function() {
+	sandbox.mock( ModelPosition.prototype, 'log', function() {
 		logger.log( 'ModelPosition: ' + this );
-	};
+	} );
 
-	ModelRange.prototype.toString = function() {
+	sandbox.mock( ModelRange.prototype, 'toString', function() {
 		return `${ this.root } [ ${ this.start.path.join( ', ' ) } ] - [ ${ this.end.path.join( ', ' ) } ]`;
-	};
+	} );
 
-	ModelRange.prototype.log = function() {
+	sandbox.mock( ModelRange.prototype, 'log', function() {
 		logger.log( 'ModelRange: ' + this );
-	};
+	} );
 
-	ModelText.prototype.toString = function() {
+	sandbox.mock( ModelText.prototype, 'toString', function() {
 		return `#${ this.data }`;
-	};
+	} );
 
-	ModelText.prototype.logExtended = function() {
+	sandbox.mock( ModelText.prototype, 'logExtended', function() {
 		logger.log( `ModelText: ${ this }, attrs: ${ mapString( this.getAttributes() ) }` );
-	};
+	} );
 
-	ModelText.prototype.log = function() {
+	sandbox.mock( ModelText.prototype, 'log', function() {
 		logger.log( 'ModelText: ' + this );
-	};
+	} );
 
-	ModelTextProxy.prototype.toString = function() {
+	sandbox.mock( ModelTextProxy.prototype, 'toString', function() {
 		return `#${ this.data }`;
-	};
+	} );
 
-	ModelTextProxy.prototype.logExtended = function() {
+	sandbox.mock( ModelTextProxy.prototype, 'logExtended', function() {
 		logger.log( `ModelTextProxy: ${ this }, attrs: ${ mapString( this.getAttributes() ) }` );
-	};
+	} );
 
-	ModelTextProxy.prototype.log = function() {
+	sandbox.mock( ModelTextProxy.prototype, 'log', function() {
 		logger.log( 'ModelTextProxy: ' + this );
-	};
+	} );
 
-	ModelElement.prototype.toString = function() {
+	sandbox.mock( ModelElement.prototype, 'toString', function() {
 		return `<${ this.rootName || this.name }>`;
-	};
+	} );
 
-	ModelElement.prototype.log = function() {
+	sandbox.mock( ModelElement.prototype, 'log', function() {
 		logger.log( 'ModelElement: ' + this );
-	};
+	} );
 
-	ModelElement.prototype.logExtended = function() {
+	sandbox.mock( ModelElement.prototype, 'logExtended', function() {
 		logger.log( `ModelElement: ${ this }, ${ this.childCount } children, attrs: ${ mapString( this.getAttributes() ) }` );
-	};
+	} );
 
-	ModelElement.prototype.logAll = function() {
+	sandbox.mock( ModelElement.prototype, 'logAll', function() {
 		logger.log( '--------------------' );
 
 		this.logExtended();
@@ -180,9 +230,9 @@ function enableLoggingTools() {
 		for ( const child of this.getChildren() ) {
 			child.log();
 		}
-	};
+	} );
 
-	ModelElement.prototype.printTree = function( level = 0 ) {
+	sandbox.mock( ModelElement.prototype, 'printTree', function( level = 0 ) {
 		let string = '';
 
 		string += '\t'.repeat( level ) + `<${ this.rootName || this.name }${ mapToTags( this.getAttributes() ) }>`;
@@ -212,29 +262,29 @@ function enableLoggingTools() {
 		string += `</${ this.rootName || this.name }>`;
 
 		return string;
-	};
+	} );
 
-	ModelElement.prototype.logTree = function() {
+	sandbox.mock( ModelElement.prototype, 'logTree', function() {
 		logger.log( this.printTree() );
-	};
+	} );
 
-	ModelRootElement.prototype.toString = function() {
+	sandbox.mock( ModelRootElement.prototype, 'toString', function() {
 		return this.rootName;
-	};
+	} );
 
-	ModelRootElement.prototype.log = function() {
+	sandbox.mock( ModelRootElement.prototype, 'log', function() {
 		logger.log( 'ModelRootElement: ' + this );
-	};
+	} );
 
-	ModelDocumentFragment.prototype.toString = function() {
+	sandbox.mock( ModelDocumentFragment.prototype, 'toString', function() {
 		return 'documentFragment';
-	};
+	} );
 
-	ModelDocumentFragment.prototype.log = function() {
+	sandbox.mock( ModelDocumentFragment.prototype, 'log', function() {
 		logger.log( 'ModelDocumentFragment: ' + this );
-	};
+	} );
 
-	ModelDocumentFragment.prototype.printTree = function() {
+	sandbox.mock( ModelDocumentFragment.prototype, 'printTree', function() {
 		let string = 'ModelDocumentFragment: [';
 
 		for ( const child of this.getChildren() ) {
@@ -258,58 +308,66 @@ function enableLoggingTools() {
 		string += '\n]';
 
 		return string;
-	};
+	} );
 
-	ModelDocumentFragment.prototype.logTree = function() {
+	sandbox.mock( ModelDocumentFragment.prototype, 'logTree', function() {
 		logger.log( this.printTree() );
-	};
+	} );
 
-	Operation.prototype.log = function() {
+	sandbox.mock( Operation.prototype, 'log', function() {
 		logger.log( this.toString() );
-	};
+	} );
 
-	AttributeOperation.prototype.toString = function() {
+	sandbox.mock( AttributeOperation.prototype, 'toString', function() {
 		return getClassName( this ) + `( ${ this.baseVersion } ): ` +
 			`"${ this.key }": ${ JSON.stringify( this.oldValue ) } -> ${ JSON.stringify( this.newValue ) }, ${ this.range }`;
-	};
+	} );
+
+	sandbox.mock( DetachOperation.prototype, 'toString', function() {
+		const range = ModelRange.createFromPositionAndShift( this.sourcePosition, this.howMany );
+		const nodes = Array.from( range.getItems() );
+		const nodeString = nodes.length > 1 ? `[ ${ nodes.length } ]` : nodes[ 0 ];
+
+		return getClassName( this ) + `( ${ this.baseVersion } ): ${ nodeString } -> ${ range }`;
+	} );
 
-	InsertOperation.prototype.toString = function() {
+	sandbox.mock( InsertOperation.prototype, 'toString', function() {
 		const nodeString = this.nodes.length > 1 ? `[ ${ this.nodes.length } ]` : this.nodes.getNode( 0 );
 
 		return getClassName( this ) + `( ${ this.baseVersion } ): ${ nodeString } -> ${ this.position }`;
-	};
+	} );
 
-	MarkerOperation.prototype.toString = function() {
+	sandbox.mock( MarkerOperation.prototype, 'toString', function() {
 		return getClassName( this ) + `( ${ this.baseVersion } ): ` +
 			`"${ this.name }": ${ this.oldRange } -> ${ this.newRange }`;
-	};
+	} );
 
-	MoveOperation.prototype.toString = function() {
+	sandbox.mock( MoveOperation.prototype, 'toString', function() {
 		const range = ModelRange.createFromPositionAndShift( this.sourcePosition, this.howMany );
 
 		return getClassName( this ) + `( ${ this.baseVersion } ): ` +
 			`${ range } -> ${ this.targetPosition }${ this.isSticky ? ' (sticky)' : '' }`;
-	};
+	} );
 
-	NoOperation.prototype.toString = function() {
+	sandbox.mock( NoOperation.prototype, 'toString', function() {
 		return `NoOperation( ${ this.baseVersion } )`;
-	};
+	} );
 
-	RenameOperation.prototype.toString = function() {
+	sandbox.mock( RenameOperation.prototype, 'toString', function() {
 		return getClassName( this ) + `( ${ this.baseVersion } ): ` +
 			`${ this.position }: "${ this.oldName }" -> "${ this.newName }"`;
-	};
+	} );
 
-	RootAttributeOperation.prototype.toString = function() {
+	sandbox.mock( RootAttributeOperation.prototype, 'toString', function() {
 		return getClassName( this ) + `( ${ this.baseVersion } ): ` +
 			`"${ this.key }": ${ JSON.stringify( this.oldValue ) } -> ${ JSON.stringify( this.newValue ) }, ${ this.root.rootName }`;
-	};
+	} );
 
-	Delta.prototype.log = function() {
+	sandbox.mock( Delta.prototype, 'log', function() {
 		logger.log( this.toString() );
-	};
+	} );
 
-	Delta.prototype.logAll = function() {
+	sandbox.mock( Delta.prototype, 'logAll', function() {
 		logger.log( '--------------------' );
 
 		this.log();
@@ -317,9 +375,9 @@ function enableLoggingTools() {
 		for ( const op of this.operations ) {
 			op.log();
 		}
-	};
+	} );
 
-	Delta.prototype._saveHistory = function( itemToSave ) {
+	sandbox.mock( Delta.prototype, '_saveHistory', function( itemToSave ) {
 		const history = itemToSave.before.history ? itemToSave.before.history : [];
 
 		itemToSave.before = clone( itemToSave.before );
@@ -331,11 +389,11 @@ function enableLoggingTools() {
 		itemToSave.transformedBy = JSON.stringify( itemToSave.transformedBy );
 
 		this.history = history.concat( itemToSave );
-	};
+	} );
 
 	const _deltaTransformTransform = deltaTransform.transform;
 
-	deltaTransform.transform = function( a, b, context ) {
+	sandbox.mock( deltaTransform, 'transform', function( a, b, context ) {
 		let results;
 
 		try {
@@ -359,36 +417,36 @@ function enableLoggingTools() {
 		}
 
 		return results;
-	};
+	} );
 
-	AttributeDelta.prototype.toString = function() {
+	sandbox.mock( AttributeDelta.prototype, 'toString', function() {
 		return getClassName( this ) + `( ${ this.baseVersion } ): ` +
 			`"${ this.key }": -> ${ JSON.stringify( this.value ) }, ${ this.range }, ${ this.operations.length } ops`;
-	};
+	} );
 
-	InsertDelta.prototype.toString = function() {
+	sandbox.mock( InsertDelta.prototype, 'toString', function() {
 		const op = this._insertOperation;
 		const nodeString = op.nodes.length > 1 ? `[ ${ op.nodes.length } ]` : op.nodes.getNode( 0 );
 
 		return getClassName( this ) + `( ${ this.baseVersion } ): ${ nodeString } -> ${ op.position }`;
-	};
+	} );
 
-	MarkerDelta.prototype.toString = function() {
+	sandbox.mock( MarkerDelta.prototype, 'toString', function() {
 		const op = this.operations[ 0 ];
 
 		return getClassName( this ) + `( ${ this.baseVersion } ): ` +
 			`"${ op.name }": ${ op.oldRange } -> ${ op.newRange }`;
-	};
+	} );
 
-	MergeDelta.prototype.toString = function() {
+	sandbox.mock( MergeDelta.prototype, 'toString', function() {
 		return getClassName( this ) + `( ${ this.baseVersion } ): ` +
 			( this.position ?
 				this.position.toString() :
 				`(move from ${ this.operations[ 0 ].sourcePosition })`
 			);
-	};
+	} );
 
-	MoveDelta.prototype.toString = function() {
+	sandbox.mock( MoveDelta.prototype, 'toString', function() {
 		const opStrings = [];
 
 		for ( const op of this.operations ) {
@@ -399,67 +457,67 @@ function enableLoggingTools() {
 
 		return getClassName( this ) + `( ${ this.baseVersion } ): ` +
 			opStrings.join( '; ' );
-	};
+	} );
 
-	RenameDelta.prototype.toString = function() {
+	sandbox.mock( RenameDelta.prototype, 'toString', function() {
 		const op = this.operations[ 0 ];
 
 		return getClassName( this ) + `( ${ this.baseVersion } ): ` +
 			`${ op.position }: "${ op.oldName }" -> "${ op.newName }"`;
-	};
+	} );
 
-	RootAttributeDelta.prototype.toString = function() {
+	sandbox.mock( RootAttributeDelta.prototype, 'toString', function() {
 		const op = this.operations[ 0 ];
 
 		return getClassName( this ) + `( ${ this.baseVersion } ): ` +
 			`"${ op.key }": ${ JSON.stringify( op.oldValue ) } -> ${ JSON.stringify( op.newValue ) }, ${ op.root.rootName }`;
-	};
+	} );
 
-	SplitDelta.prototype.toString = function() {
+	sandbox.mock( SplitDelta.prototype, 'toString', function() {
 		return getClassName( this ) + `( ${ this.baseVersion } ): ` +
 			( this.position ?
 				this.position.toString() :
 				`(clone to ${ this._cloneOperation.position || this._cloneOperation.targetPosition })`
 			);
-	};
+	} );
 
-	UnwrapDelta.prototype.toString = function() {
+	sandbox.mock( UnwrapDelta.prototype, 'toString', function() {
 		return getClassName( this ) + `( ${ this.baseVersion } ): ` +
 			this.position.toString();
-	};
+	} );
 
-	WrapDelta.prototype.toString = function() {
+	sandbox.mock( WrapDelta.prototype, 'toString', function() {
 		const wrapElement = this._insertOperation.nodes.getNode( 0 );
 
 		return getClassName( this ) + `( ${ this.baseVersion } ): ` +
 			`${ this.range } -> ${ wrapElement }`;
-	};
+	} );
 
-	ViewText.prototype.toString = function() {
+	sandbox.mock( ViewText.prototype, 'toString', function() {
 		return `#${ this.data }`;
-	};
+	} );
 
-	ViewText.prototype.logExtended = function() {
+	sandbox.mock( ViewText.prototype, 'logExtended', function() {
 		logger.log( 'ViewText: ' + this );
-	};
+	} );
 
-	ViewText.prototype.log = function() {
+	sandbox.mock( ViewText.prototype, 'log', function() {
 		logger.log( 'ViewText: ' + this );
-	};
+	} );
 
-	ViewTextProxy.prototype.toString = function() {
+	sandbox.mock( ViewTextProxy.prototype, 'toString', function() {
 		return `#${ this.data }`;
-	};
+	} );
 
-	ViewTextProxy.prototype.logExtended = function() {
+	sandbox.mock( ViewTextProxy.prototype, 'logExtended', function() {
 		logger.log( 'ViewTextProxy: ' + this );
-	};
+	} );
 
-	ViewTextProxy.prototype.log = function() {
+	sandbox.mock( ViewTextProxy.prototype, 'log', function() {
 		logger.log( 'ViewTextProxy: ' + this );
-	};
+	} );
 
-	ViewElement.prototype.printTree = function( level = 0 ) {
+	sandbox.mock( ViewElement.prototype, 'printTree', function( level = 0 ) {
 		let string = '';
 
 		string += '\t'.repeat( level ) + `<${ this.name }${ mapToTags( this.getAttributes() ) }>`;
@@ -479,13 +537,13 @@ function enableLoggingTools() {
 		string += `</${ this.name }>`;
 
 		return string;
-	};
+	} );
 
-	ViewElement.prototype.logTree = function() {
+	sandbox.mock( ViewElement.prototype, 'logTree', function() {
 		logger.log( this.printTree() );
-	};
+	} );
 
-	ViewDocumentFragment.prototype.printTree = function() {
+	sandbox.mock( ViewDocumentFragment.prototype, 'printTree', function() {
 		let string = 'ViewDocumentFragment: [';
 
 		for ( const child of this.getChildren() ) {
@@ -499,17 +557,17 @@ function enableLoggingTools() {
 		string += '\n]';
 
 		return string;
-	};
+	} );
 
-	ViewDocumentFragment.prototype.logTree = function() {
+	sandbox.mock( ViewDocumentFragment.prototype, 'logTree', function() {
 		logger.log( this.printTree() );
-	};
+	} );
 }
 
 function enableReplayerTools() {
 	const _modelDocumentApplyOperation = ModelDocument.prototype.applyOperation;
 
-	ModelDocument.prototype.applyOperation = function( operation ) {
+	sandbox.mock( ModelDocument.prototype, 'applyOperation', function( operation ) {
 		if ( !this._lastDelta ) {
 			this._appliedDeltas = [];
 		} else if ( this._lastDelta !== operation.delta ) {
@@ -519,9 +577,9 @@ function enableReplayerTools() {
 		this._lastDelta = operation.delta;
 
 		_modelDocumentApplyOperation.call( this, operation );
-	};
+	} );
 
-	ModelDocument.prototype.getAppliedDeltas = function() {
+	sandbox.mock( ModelDocument.prototype, 'getAppliedDeltas', function() {
 		// No deltas has been applied yet, return empty string.
 		if ( !this._lastDelta ) {
 			return '';
@@ -530,17 +588,17 @@ function enableReplayerTools() {
 		const appliedDeltas = this._appliedDeltas.concat( this._lastDelta );
 
 		return appliedDeltas.map( JSON.stringify ).join( LOG_SEPARATOR );
-	};
+	} );
 
-	ModelDocument.prototype.createReplayer = function( stringifiedDeltas ) {
+	sandbox.mock( ModelDocument.prototype, 'createReplayer', function( stringifiedDeltas ) {
 		return new DeltaReplayer( this, LOG_SEPARATOR, stringifiedDeltas );
-	};
+	} );
 }
 
 function enableDocumentTools() {
 	const _modelDocumentApplyOperation = ModelDocument.prototype.applyOperation;
 
-	ModelDocument.prototype.applyOperation = function( operation ) {
+	sandbox.mock( ModelDocument.prototype, 'applyOperation', function( operation ) {
 		logger.log( 'Applying ' + operation );
 
 		if ( !this._operationLogs ) {
@@ -550,34 +608,34 @@ function enableDocumentTools() {
 		this._operationLogs.push( JSON.stringify( operation.toJSON() ) );
 
 		_modelDocumentApplyOperation.call( this, operation );
-	};
+	} );
 
-	ModelDocument.prototype.log = function( version = null ) {
+	sandbox.mock( ModelDocument.prototype, 'log', function( version = null ) {
 		version = version === null ? this.version : version;
 
 		logDocument( this, version );
-	};
+	} );
 
-	ViewDocument.prototype.log = function( version ) {
+	sandbox.mock( ViewDocument.prototype, 'log', function( version ) {
 		logDocument( this, version );
-	};
+	} );
 
-	Editor.prototype.logModel = function( version = null ) {
+	sandbox.mock( Editor.prototype, 'logModel', function( version = null ) {
 		version = version === null ? this.document.version : version;
 
 		this.document.log( version );
-	};
+	} );
 
-	Editor.prototype.logView = function( version ) {
+	sandbox.mock( Editor.prototype, 'logView', function( version ) {
 		this.editing.view.log( version );
-	};
+	} );
 
-	Editor.prototype.logDocuments = function( version = null ) {
+	sandbox.mock( Editor.prototype, 'logDocuments', function( version = null ) {
 		version = version === null ? this.document.version : version;
 
 		this.logModel( version );
 		this.logView( version );
-	};
+	} );
 
 	function logDocument( document, version ) {
 		logger.log( '--------------------' );

+ 10 - 11
packages/ckeditor5-engine/src/dev-utils/model.js

@@ -20,7 +20,6 @@ import ModelSelection from '../model/selection';
 import ModelDocumentFragment from '../model/documentfragment';
 import ModelElement from '../model/element';
 import ModelText from '../model/text';
-import modelWriter from '../model/writer';
 
 import ViewConversionDispatcher from '../conversion/viewconversiondispatcher';
 import ViewSelection from '../view/selection';
@@ -96,8 +95,10 @@ export function setData( document, data, options = {} ) {
 	let modelDocumentFragment, selection;
 	const modelRoot = document.getRoot( options.rootName || 'main' );
 
+	const batch = document.batch( options.batchType || 'transparent' );
+
 	// Parse data string to model.
-	const parsedResult = setData._parse( data, document.schema, {
+	const parsedResult = setData._parse( data, document.schema, batch, {
 		lastRangeBackward: options.lastRangeBackward,
 		selectionAttributes: options.selectionAttributes,
 		context: [ modelRoot.name ]
@@ -113,9 +114,8 @@ export function setData( document, data, options = {} ) {
 
 	document.enqueueChanges( () => {
 		// Replace existing model in document by new one.
-		document.batch( options.batchType || 'transparent' )
-			.remove( ModelRange.createIn( modelRoot ) )
-			.insert( ModelPosition.createAt( modelRoot, 0 ), modelDocumentFragment );
+		batch.remove( ModelRange.createIn( modelRoot ) );
+		batch.insert( modelDocumentFragment, modelRoot );
 
 		// Clean up previous document selection.
 		document.selection.clearAttributes();
@@ -242,7 +242,8 @@ export function stringify( node, selectionOrPositionOrRange = null ) {
  *
  * @param {String} data HTML-like string to be parsed.
  * @param {module:engine/model/schema~Schema} schema Schema instance uses by converters for element validation.
- * @param {Object} options Additional configuration.
+ * @param {module:engine/model/batch~Batch} batch Batch used for conversion.
+ * @param {Object} [options={}] Additional configuration.
  * @param {Array<Object>} [options.selectionAttributes] List of attributes which will be passed to the selection.
  * @param {Boolean} [options.lastRangeBackward=false] If set to true last range will be added as backward.
  * @param {module:engine/model/schema~SchemaPath} [options.context=[ '$root' ]] The conversion context.
@@ -251,7 +252,7 @@ export function stringify( node, selectionOrPositionOrRange = null ) {
  * module:engine/model/documentfragment~DocumentFragment|Object} Returns parsed model node or
  * object with two fields `model` and `selection` when selection ranges were included in data to parse.
  */
-export function parse( data, schema, options = {} ) {
+export function parse( data, schema, batch, options = {} ) {
 	const mapper = new Mapper();
 
 	// Replace not accepted by XML `$text` tag name by valid one `model-text-with-attributes`.
@@ -282,7 +283,7 @@ export function parse( data, schema, options = {} ) {
 	viewToModel.on( 'text', convertToModelText() );
 
 	// Convert view to model.
-	let model = viewToModel.convert( viewDocumentFragment.root, { context: options.context || [ '$root' ] } );
+	let model = viewToModel.convert( viewDocumentFragment.root, batch, { context: options.context || [ '$root' ] } );
 
 	// If root DocumentFragment contains only one element - return that element.
 	if ( model.is( 'documentFragment' ) && model.childCount == 1 ) {
@@ -322,9 +323,7 @@ export function parse( data, schema, options = {} ) {
 
 function convertToModelFragment() {
 	return ( evt, data, consumable, conversionApi ) => {
-		const convertedChildren = conversionApi.convertChildren( data.input, consumable, data );
-
-		data.output = new ModelDocumentFragment( modelWriter.normalizeNodes( convertedChildren ) );
+		data.output = conversionApi.convertChildren( data.input, consumable, data );
 		conversionApi.mapper.bindElements( data.output, data.input );
 
 		evt.stop();

+ 16 - 1
packages/ckeditor5-engine/src/dev-utils/view.js

@@ -827,7 +827,22 @@ class ViewStringify {
 		const keys = [ ...element.getAttributeKeys() ].sort();
 
 		for ( const attribute of keys ) {
-			attributes.push( `${ attribute }="${ element.getAttribute( attribute ) }"` );
+			let attributeValue;
+
+			if ( attribute === 'class' ) {
+				attributeValue = [ ...element.getClassNames() ]
+					.sort()
+					.join( ' ' );
+			} else if ( attribute === 'style' ) {
+				attributeValue = [ ...element.getStyleNames() ]
+					.sort()
+					.map( style => `${ style }:${ element.getStyle( style ) }` )
+					.join( ';' );
+			} else {
+				attributeValue = element.getAttribute( attribute );
+			}
+
+			attributes.push( `${ attribute }="${ attributeValue }"` );
 		}
 
 		return attributes.join( ' ' );

+ 862 - 54
packages/ckeditor5-engine/src/model/batch.js

@@ -7,6 +7,37 @@
  * @module engine/model/batch
  */
 
+import AttributeDelta from './delta/attributedelta';
+import InsertDelta from './delta/insertdelta';
+import MarkerDelta from './delta/markerdelta';
+import MergeDelta from './delta/mergedelta';
+import MoveDelta from './delta/movedelta';
+import RemoveDelta from './delta/removedelta';
+import RenameDelta from './delta/renamedelta';
+import RootAttributeDelta from './delta/rootattributedelta';
+import SplitDelta from './delta/splitdelta';
+import UnwrapDelta from './delta/unwrapdelta';
+import WeakInsertDelta from './delta/weakinsertdelta';
+import WrapDelta from './delta/wrapdelta';
+
+import AttributeOperation from './operation/attributeoperation';
+import DetachOperation from './operation/detachoperation';
+import InsertOperation from './operation/insertoperation';
+import MarkerOperation from './operation/markeroperation';
+import MoveOperation from './operation/moveoperation';
+import RemoveOperation from './operation/removeoperation';
+import RenameOperation from './operation/renameoperation';
+import RootAttributeOperation from './operation/rootattributeoperation';
+
+import DocumentFragment from './documentfragment';
+import Text from './text';
+import Element from './element';
+import RootElement from './rootelement';
+import Position from './position';
+import Range from './range.js';
+
+import toMap from '@ckeditor/ckeditor5-utils/src/tomap';
+
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 
 /**
@@ -16,18 +47,15 @@ import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
  *
  * For example to create two separate undo steps you can call:
  *
- *		doc.batch().insert( firstPosition, 'foo' );
- *		doc.batch().insert( secondPosition, 'bar' );
+ *		doc.batch().insert( 'foo', firstPosition );
+ *		doc.batch().insert( 'bar', secondPosition );
  *
  * To create a single undo step:
  *
  *		const batch = doc.batch();
- *		batch.insert( firstPosition, 'foo' );
- *		batch.insert( secondPosition, 'bar' );
- *
- * Note that all document modification methods (insert, remove, split, etc.) are chainable so you can shorten code to:
+ *		batch.insert( 'foo', firstPosition );
+ *		batch.insert( 'bar', secondPosition );
  *
- *		doc.batch().insert( firstPosition, 'foo' ).insert( secondPosition, 'bar' );
  */
 export default class Batch {
 	/**
@@ -101,55 +129,835 @@ export default class Batch {
 			yield* delta.operations;
 		}
 	}
+
+	/**
+	 * Creates a new {@link module:engine/model/text~Text text node}.
+	 *
+	 *		batch.createText( 'foo' );
+	 *		batch.createText( 'foo', { 'bold': true } );
+	 *
+	 * @param {String} data Text data.
+	 * @param {Object} [attributes] Text attributes.
+	 * @returns {module:engine/model/text~Text} Created text node.
+	 */
+	createText( data, attributes ) {
+		return new Text( data, attributes );
+	}
+
+	/**
+	 * Creates a new {@link module:engine/model/element~Element element}.
+	 *
+	 *		batch.createElement( 'paragraph' );
+	 *		batch.createElement( 'paragraph', { 'alignment': 'center' } );
+	 *
+	 * @param {String} name Name of the element.
+	 * @param {Object} [attributes] Elements attributes.
+	 * @returns {module:engine/model/element~Element} Created element.
+	 */
+	createElement( name, attributes ) {
+		return new Element( name, attributes );
+	}
+
+	/**
+	 * Creates a new {@link module:engine/model/documentfragment~DocumentFragment document fragment}.
+	 *
+	 * @returns {module:engine/model/documentfragment~DocumentFragment} Created document fragment.
+	 */
+	createDocumentFragment() {
+		return new DocumentFragment();
+	}
+
+	/**
+	 * Inserts item on given position.
+	 *
+	 *		const paragraph = batch.createElement( 'paragraph' );
+	 *		batch.insert( paragraph, position );
+	 *
+	 * Instead of using position you can use parent and offset:
+	 *
+	 * 		const text = batch.createText( 'foo' );
+	 *		batch.insert( text, paragraph, 5 );
+	 *
+	 * You can also use `end` instead of the offset to insert at the end:
+	 *
+	 * 		const text = batch.createText( 'foo' );
+	 *		batch.insert( text, paragraph, 'end' );
+	 *
+	 * Or insert before or after another element:
+	 *
+	 * 		const paragraph = batch.createElement( 'paragraph' );
+	 *		batch.insert( paragraph, anotherParagraph, 'after' );
+	 *
+	 * These parameters works the same way as {@link module:engine/model/position~Position.createAt}.
+	 *
+	 * Note that if the item already has parent it will be removed from the previous parent.
+	 *
+	 * If you want to move {@link module:engine/model/range~Range range} instead of an
+	 * {@link module:engine/model/item~Item item} use {@link module:engine/model/batch~Batch#move move}.
+	 *
+	 * @param {module:engine/model/item~Item|module:engine/model/documentfragment~DocumentFragment}
+	 * item Item or document fragment to insert.
+	 * @param {module:engine/model/item~Item|module:engine/model/position~Position} itemOrPosition
+	 * @param {Number|'end'|'before'|'after'} [offset=0] Offset or one of the flags. Used only when
+	 * second parameter is a {@link module:engine/model/item~Item model item}.
+	 */
+	insert( item, itemOrPosition, offset ) {
+		const position = Position.createAt( itemOrPosition, offset );
+
+		// For text that has no parent we need to make a WeakInsert.
+		const delta = item instanceof Text && !item.parent ? new WeakInsertDelta() : new InsertDelta();
+
+		// If item has a parent already.
+		if ( item.parent ) {
+			// We need to check if item is going to be inserted within the same document.
+			if ( isSameTree( item.root, position.root ) ) {
+				// If it's we just need to move it.
+				this.move( Range.createOn( item ), position );
+
+				return;
+			}
+			// If it isn't the same root.
+			else {
+				// We need to remove this item from old position first.
+				this.remove( item );
+			}
+		}
+
+		const insert = new InsertOperation( position, item, this.document.version );
+
+		this.addDelta( delta );
+		delta.addOperation( insert );
+		this.document.applyOperation( insert );
+
+		// When element is a DocumentFragment we need to move its markers to Document#markers.
+		if ( item instanceof DocumentFragment ) {
+			for ( const [ markerName, markerRange ] of item.markers ) {
+				// We need to migrate marker range from DocumentFragment to Document.
+				const rangeRootPosition = Position.createAt( markerRange.root );
+				const range = new Range(
+					markerRange.start._getCombined( rangeRootPosition, position ),
+					markerRange.end._getCombined( rangeRootPosition, position )
+				);
+
+				this.setMarker( markerName, range );
+			}
+		}
+	}
+
+	/**
+	 * Creates and inserts text on given position. You can optionally set text attributes:
+	 *
+	 *		batch.insertText( 'foo', position );
+	 *		batch.insertText( 'foo', { 'bold': true }, position );
+	 *
+	 * Instead of using position you can use parent and offset or define that text should be inserted at the end
+	 * or before or after other node:
+	 *
+	 * 		batch.insertText( 'foo', paragraph, 5 ); // inserts in paragraph, at offset 5
+	 *		batch.insertText( 'foo', paragraph, 'end' ); // inserts at the end of the paragraph
+	 *		batch.insertText( 'foo', image, 'after' ); // inserts after image
+	 *
+	 * These parameters works the same way as {@link module:engine/model/position~Position.createAt}.
+	 *
+	 * @param {String} data Text data.
+	 * @param {Object} [attributes] Text attributes.
+	 * @param {module:engine/model/item~Item|module:engine/model/position~Position} itemOrPosition
+	 * @param {Number|'end'|'before'|'after'} [offset=0] Offset or one of the flags. Used only when
+	 * third parameter is a {@link module:engine/model/item~Item model item}.
+	 */
+	insertText( text, attributes, itemOrPosition, offset ) {
+		if ( attributes instanceof DocumentFragment || attributes instanceof Element || attributes instanceof Position ) {
+			this.insert( this.createText( text ), attributes, itemOrPosition );
+		} else {
+			this.insert( this.createText( text, attributes ), itemOrPosition, offset );
+		}
+	}
+
+	/**
+	 * Creates and inserts element on given position. You can optionally set attributes:
+	 *
+	 *		batch.insertElement( 'paragraph', position );
+	 *		batch.insertElement( 'paragraph', { 'alignment': 'center' }, position );
+	 *
+	 * Instead of using position you can use parent and offset or define that text should be inserted at the end
+	 * or before or after other node:
+	 *
+	 * 		batch.insertElement( 'paragraph', paragraph, 5 ); // inserts in paragraph, at offset 5
+	 *		batch.insertElement( 'paragraph', blockquote, 'end' ); // insets at the end of the blockquote
+	 *		batch.insertElement( 'paragraph', image, 'after' ); // inserts after image
+	 *
+	 * These parameters works the same way as {@link module:engine/model/position~Position.createAt}.
+	 *
+	 * @param {String} name Name of the element.
+	 * @param {Object} [attributes] Elements attributes.
+	 * @param {module:engine/model/item~Item|module:engine/model/position~Position} itemOrPosition
+	 * @param {Number|'end'|'before'|'after'} [offset=0] Offset or one of the flags. Used only when
+	 * third parameter is a {@link module:engine/model/item~Item model item}.
+	 */
+	insertElement( name, attributes, itemOrPosition, offset ) {
+		if ( attributes instanceof DocumentFragment || attributes instanceof Element || attributes instanceof Position ) {
+			this.insert( this.createElement( name ), attributes, itemOrPosition );
+		} else {
+			this.insert( this.createElement( name, attributes ), itemOrPosition, offset );
+		}
+	}
+
+	/**
+	 * Inserts item at the end of the given parent.
+	 *
+	 *		const paragraph = batch.createElement( 'paragraph' );
+	 *		batch.append( paragraph, root );
+	 *
+	 * Note that if the item already has parent it will be removed from the previous parent.
+	 *
+	 * If you want to move {@link module:engine/model/range~Range range} instead of an
+	 * {@link module:engine/model/item~Item item} use {@link module:engine/model/batch~Batch#move move}.
+	 *
+	 * @param {module:engine/model/item~Item|module:engine/model/documentfragment~DocumentFragment}
+	 * item Item or document fragment to insert.
+	 * @param {module:engine/model/element~Element|module:engine/model/documentfragment~DocumentFragment} parent
+	 */
+	append( item, parent ) {
+		this.insert( item, parent, 'end' );
+	}
+
+	/**
+	 * Creates text node and inserts it at the end of the parent. You can optionally set text attributes:
+	 *
+	 *		batch.appendText( 'foo', paragraph );
+	 *		batch.appendText( 'foo', { 'bold': true }, paragraph );
+	 *
+	 * @param {String} text Text data.
+	 * @param {Object} [attributes] Text attributes.
+	 * @param {module:engine/model/element~Element|module:engine/model/documentfragment~DocumentFragment} parent
+	 */
+	appendText( text, attributes, parent ) {
+		if ( attributes instanceof DocumentFragment || attributes instanceof Element ) {
+			this.insert( this.createText( text ), attributes, 'end' );
+		} else {
+			this.insert( this.createText( text, attributes ), parent, 'end' );
+		}
+	}
+
+	/**
+	 * Creates element and inserts it at the end of the parent. You can optionally set attributes:
+	 *
+	 *		batch.appendElement( 'paragraph', root );
+	 *		batch.appendElement( 'paragraph', { 'alignment': 'center' }, root );
+	 *
+	 * @param {String} name Name of the element.
+	 * @param {Object} [attributes] Elements attributes.
+	 * @param {module:engine/model/element~Element|module:engine/model/documentfragment~DocumentFragment} parent
+	 */
+	appendElement( name, attributes, parent ) {
+		if ( attributes instanceof DocumentFragment || attributes instanceof Element ) {
+			this.insert( this.createElement( name ), attributes, 'end' );
+		} else {
+			this.insert( this.createElement( name, attributes ), parent, 'end' );
+		}
+	}
+
+	/**
+	 * Sets value of the attribute with given key on a {@link module:engine/model/item~Item model item}
+	 * or on a {@link module:engine/model/range~Range range}.
+	 *
+	 * @param {String} key Attribute key.
+	 * @param {*} value Attribute new value.
+	 * @param {module:engine/model/item~Item|module:engine/model/range~Range} itemOrRange
+	 * Model item or range on which the attribute will be set.
+	 */
+	setAttribute( key, value, itemOrRange ) {
+		if ( itemOrRange instanceof Range ) {
+			setAttributeToRange( this, key, value, itemOrRange );
+		} else {
+			setAttributeToItem( this, key, value, itemOrRange );
+		}
+	}
+
+	/**
+	 * Sets values of attributes on a {@link module:engine/model/item~Item model item}
+	 * or on a {@link module:engine/model/range~Range range}.
+	 *
+	 *		batch.setAttributes( {
+	 *			'bold': true,
+	 *			'italic': true
+	 *		}, range );
+	 *
+	 * @param {Object} attributes Attributes keys and values.
+	 * @param {module:engine/model/item~Item|module:engine/model/range~Range} itemOrRange
+	 * Model item or range on which the attributes will be set.
+	 */
+	setAttributes( attributes, itemOrRange ) {
+		for ( const [ key, val ] of toMap( attributes ) ) {
+			this.setAttribute( key, val, itemOrRange );
+		}
+	}
+
+	/**
+	 * Removes an attribute with given key from a {@link module:engine/model/item~Item model item}
+	 * or from a {@link module:engine/model/range~Range range}.
+	 *
+	 * @param {String} key Attribute key.
+	 * @param {module:engine/model/item~Item|module:engine/model/range~Range} itemOrRange
+	 * Model item or range from which the attribute will be removed.
+	 */
+	removeAttribute( key, itemOrRange ) {
+		if ( itemOrRange instanceof Range ) {
+			setAttributeToRange( this, key, null, itemOrRange );
+		} else {
+			setAttributeToItem( this, key, null, itemOrRange );
+		}
+	}
+
+	/**
+	 * Removes all attributes from all elements in the range or from the given item.
+	 *
+	 * @param {module:engine/model/item~Item|module:engine/model/range~Range} itemOrRange
+	 * Model item or range from which all attributes will be removed.
+	 */
+	clearAttributes( itemOrRange ) {
+		const removeAttributesFromItem = item => {
+			for ( const attribute of item.getAttributeKeys() ) {
+				this.removeAttribute( attribute, item );
+			}
+		};
+
+		if ( !( itemOrRange instanceof Range ) ) {
+			removeAttributesFromItem( itemOrRange );
+		} else {
+			for ( const item of itemOrRange.getItems() ) {
+				removeAttributesFromItem( item );
+			}
+		}
+	}
+
+	/**
+	 * Moves all items in the source range to the target position.
+	 *
+	 *		batch.move( sourceRange, targetPosition );
+	 *
+	 * Instead of the target position you can use parent and offset or define that range should be moved to the end
+	 * or before or after chosen item:
+	 *
+	 * 		batch.move( sourceRange, paragraph, 5 ); // moves all items in the range to the paragraph at offset 5
+	 *		batch.move( sourceRange, blockquote, 'end' ); // moves all items in the range at the end of the blockquote
+	 *		batch.move( sourceRange, image, 'after' ); // moves all items in the range after the image
+	 *
+	 * These parameters works the same way as {@link module:engine/model/position~Position.createAt}.
+	 *
+	 * Note that items can be moved only within the same tree. It means that you can move items within the same root
+	 * (element or document fragment) or between {@link module:engine/model/document~Document#roots documents roots},
+	 * but you can not move items from document fragment to the document or from one detached element to another. Use
+	 * {@link module:engine/model/batch~Batch#insert} in such cases.
+	 *
+	 * @param {module:engine/model/range~Range} range Source range.
+	 * @param {module:engine/model/item~Item|module:engine/model/position~Position} itemOrPosition
+	 * @param {Number|'end'|'before'|'after'} [offset=0] Offset or one of the flags. Used only when
+	 * second parameter is a {@link module:engine/model/item~Item model item}.
+	 */
+	move( range, itemOrPosition, offset ) {
+		if ( !( range instanceof Range ) ) {
+			/**
+			 * Invalid range to move.
+			 *
+			 * @error batch-move-invalid-range
+			 */
+			throw new CKEditorError( 'batch-move-invalid-range: Invalid range to move.' );
+		}
+
+		if ( !range.isFlat ) {
+			/**
+			 * Range to move is not flat.
+			 *
+			 * @error batch-move-range-not-flat
+			 */
+			throw new CKEditorError( 'batch-move-range-not-flat: Range to move is not flat.' );
+		}
+
+		const position = Position.createAt( itemOrPosition, offset );
+
+		if ( !isSameTree( range.root, position.root ) ) {
+			/**
+			 * Range is going to be moved within not the same document. Please use
+			 * {@link module:engine/model/batch~Batch#insert insert} instead.
+			 *
+			 * @error batch-move-different-document
+			 */
+			throw new CKEditorError( 'batch-move-different-document: Range is going to be moved between different documents.' );
+		}
+
+		const delta = new MoveDelta();
+		this.addDelta( delta );
+
+		const operation = new MoveOperation( range.start, range.end.offset - range.start.offset, position, this.document.version );
+		delta.addOperation( operation );
+		this.document.applyOperation( operation );
+	}
+
+	/**
+	 * Removes given model {@link module:engine/model/item~Item item} or {@link module:engine/model/range~Range range}.
+	 *
+	 * @param {module:engine/model/item~Item|module:engine/model/range~Range} itemOrRange Model item or range to remove.
+	 */
+	remove( itemOrRange ) {
+		const addRemoveDelta = ( position, howMany ) => {
+			const delta = new RemoveDelta();
+			this.addDelta( delta );
+			let operation;
+
+			if ( position.root.document ) {
+				const graveyard = this.document.graveyard;
+				const gyPosition = new Position( graveyard, [ 0 ] );
+
+				operation = new RemoveOperation( position, howMany, gyPosition, this.document.version );
+			} else {
+				operation = new DetachOperation( position, howMany, this.document.version );
+			}
+
+			delta.addOperation( operation );
+			this.document.applyOperation( operation );
+		};
+
+		if ( itemOrRange instanceof Range ) {
+			// The array is reversed, so the ranges to remove are in correct order and do not have to be updated.
+			const ranges = itemOrRange.getMinimalFlatRanges().reverse();
+
+			for ( const flat of ranges ) {
+				addRemoveDelta( flat.start, flat.end.offset - flat.start.offset );
+			}
+		} else {
+			const howMany = itemOrRange.is( 'text' ) ? itemOrRange.offsetSize : 1;
+
+			addRemoveDelta( Position.createBefore( itemOrRange ), howMany );
+		}
+	}
+
+	/**
+	 * Merges two siblings at the given position.
+	 *
+	 * Node before and after the position have to be an element. Otherwise `batch-merge-no-element-before` or
+	 * `batch-merge-no-element-after` error will be thrown.
+	 *
+	 * @param {module:engine/model/position~Position} position Position of merge.
+	 */
+	merge( position ) {
+		const delta = new MergeDelta();
+		this.addDelta( delta );
+
+		const nodeBefore = position.nodeBefore;
+		const nodeAfter = position.nodeAfter;
+
+		if ( !( nodeBefore instanceof Element ) ) {
+			/**
+			 * Node before merge position must be an element.
+			 *
+			 * @error batch-merge-no-element-before
+			 */
+			throw new CKEditorError( 'batch-merge-no-element-before: Node before merge position must be an element.' );
+		}
+
+		if ( !( nodeAfter instanceof Element ) ) {
+			/**
+			 * Node after merge position must be an element.
+			 *
+			 * @error batch-merge-no-element-after
+			 */
+			throw new CKEditorError( 'batch-merge-no-element-after: Node after merge position must be an element.' );
+		}
+
+		const positionAfter = Position.createFromParentAndOffset( nodeAfter, 0 );
+		const positionBefore = Position.createFromParentAndOffset( nodeBefore, nodeBefore.maxOffset );
+
+		const move = new MoveOperation(
+			positionAfter,
+			nodeAfter.maxOffset,
+			positionBefore,
+			this.document.version
+		);
+
+		move.isSticky = true;
+		delta.addOperation( move );
+		this.document.applyOperation( move );
+
+		const graveyard = this.document.graveyard;
+		const gyPosition = new Position( graveyard, [ 0 ] );
+
+		const remove = new RemoveOperation( position, 1, gyPosition, this.document.version );
+		delta.addOperation( remove );
+		this.document.applyOperation( remove );
+	}
+
+	/**
+	 * Renames given element.
+	 *
+	 * @param {module:engine/model/element~Element} element The element to rename.
+	 * @param {String} newName New element name.
+	 */
+	rename( element, newName ) {
+		if ( !( element instanceof Element ) ) {
+			/**
+			 * Trying to rename an object which is not an instance of Element.
+			 *
+			 * @error batch-rename-not-element-instance
+			 */
+			throw new CKEditorError( 'batch-rename-not-element-instance: Trying to rename an object which is not an instance of Element.' );
+		}
+
+		const delta = new RenameDelta();
+		this.addDelta( delta );
+
+		const renameOperation = new RenameOperation( Position.createBefore( element ), element.name, newName, this.document.version );
+		delta.addOperation( renameOperation );
+		this.document.applyOperation( renameOperation );
+	}
+
+	/**
+	 * Splits an element at the given position.
+	 *
+	 * The element needs to have a parent. It cannot be a root element nor document fragment.
+	 * The `batch-split-element-no-parent` error will be thrown if you try to split an element with no parent.
+	 *
+	 * @param {module:engine/model/position~Position} position Position of split.
+	 */
+	split( position ) {
+		const delta = new SplitDelta();
+		this.addDelta( delta );
+
+		const splitElement = position.parent;
+
+		if ( !splitElement.parent ) {
+			/**
+			 * Element with no parent can not be split.
+			 *
+			 * @error batch-split-element-no-parent
+			 */
+			throw new CKEditorError( 'batch-split-element-no-parent: Element with no parent can not be split.' );
+		}
+
+		const copy = new Element( splitElement.name, splitElement.getAttributes() );
+
+		const insert = new InsertOperation(
+			Position.createAfter( splitElement ),
+			copy,
+			this.document.version
+		);
+
+		delta.addOperation( insert );
+		this.document.applyOperation( insert );
+
+		const move = new MoveOperation(
+			position,
+			splitElement.maxOffset - position.offset,
+			Position.createFromParentAndOffset( copy, 0 ),
+			this.document.version
+		);
+		move.isSticky = true;
+
+		delta.addOperation( move );
+		this.document.applyOperation( move );
+	}
+
+	/**
+	 * Wraps given range with given element or with a new element with specified name, if string has been passed.
+	 *
+	 * **Note:** range to wrap should be a "flat range" (see {@link module:engine/model/range~Range#isFlat}). If not, error will be thrown.
+	 *
+	 * @param {module:engine/model/range~Range} range Range to wrap.
+	 * @param {module:engine/model/element~Element|String} elementOrString Element or name of element to wrap the range with.
+	 */
+	wrap( range, elementOrString ) {
+		if ( !range.isFlat ) {
+			/**
+			 * Range to wrap is not flat.
+			 *
+			 * @error batch-wrap-range-not-flat
+			 */
+			throw new CKEditorError( 'batch-wrap-range-not-flat: Range to wrap is not flat.' );
+		}
+
+		const element = elementOrString instanceof Element ? elementOrString : new Element( elementOrString );
+
+		if ( element.childCount > 0 ) {
+			/**
+			 * Element to wrap with is not empty.
+			 *
+			 * @error batch-wrap-element-not-empty
+			 */
+			throw new CKEditorError( 'batch-wrap-element-not-empty: Element to wrap with is not empty.' );
+		}
+
+		if ( element.parent !== null ) {
+			/**
+			 * Element to wrap with is already attached to a tree model.
+			 *
+			 * @error batch-wrap-element-attached
+			 */
+			throw new CKEditorError( 'batch-wrap-element-attached: Element to wrap with is already attached to tree model.' );
+		}
+
+		const delta = new WrapDelta();
+		this.addDelta( delta );
+
+		const insert = new InsertOperation( range.end, element, this.document.version );
+		delta.addOperation( insert );
+		this.document.applyOperation( insert );
+
+		const targetPosition = Position.createFromParentAndOffset( element, 0 );
+		const move = new MoveOperation(
+			range.start,
+			range.end.offset - range.start.offset,
+			targetPosition,
+			this.document.version
+		);
+		delta.addOperation( move );
+		this.document.applyOperation( move );
+	}
+
+	/**
+	 * Unwraps children of the given element – all its children are moved before it and then the element is removed.
+	 * Throws error if you try to unwrap an element which does not have a parent.
+	 *
+	 * @param {module:engine/model/element~Element} element Element to unwrap.
+	 */
+	unwrap( element ) {
+		if ( element.parent === null ) {
+			/**
+			 * Trying to unwrap an element which has no parent.
+			 *
+			 * @error batch-unwrap-element-no-parent
+			 */
+			throw new CKEditorError( 'batch-unwrap-element-no-parent: Trying to unwrap an element which has no parent.' );
+		}
+
+		const delta = new UnwrapDelta();
+		this.addDelta( delta );
+
+		const sourcePosition = Position.createFromParentAndOffset( element, 0 );
+
+		const move = new MoveOperation(
+			sourcePosition,
+			element.maxOffset,
+			Position.createBefore( element ),
+			this.document.version
+		);
+
+		move.isSticky = true;
+		delta.addOperation( move );
+		this.document.applyOperation( move );
+
+		// Computing new position because we moved some nodes before `element`.
+		// If we would cache `Position.createBefore( element )` we remove wrong node.
+		const graveyard = this.document.graveyard;
+		const gyPosition = new Position( graveyard, [ 0 ] );
+
+		const remove = new RemoveOperation( Position.createBefore( element ), 1, gyPosition, this.document.version );
+		delta.addOperation( remove );
+		this.document.applyOperation( remove );
+	}
+
+	/**
+	 * Adds or updates {@link module:engine/model/markercollection~Marker marker} with given name to given `range`.
+	 *
+	 * If passed name is a name of already existing marker (or {@link module:engine/model/markercollection~Marker Marker} instance
+	 * is passed), `range` parameter may be omitted. In this case marker will not be updated in
+	 * {@link module:engine/model/document~Document#markers document marker collection}. However the marker will be added to
+	 * the document history. This may be important for other features, like undo. From document history point of view, it will
+	 * look like the marker was created and added to the document at the moment when it is set using this method.
+	 *
+	 * This is useful if the marker is created before it can be added to document history (e.g. a feature creating the marker
+	 * is waiting for additional data, etc.). In this case, the marker may be first created directly through
+	 * {@link module:engine/model/markercollection~MarkerCollection MarkerCollection API} and only later added using `Batch` API.
+	 *
+	 * @param {module:engine/model/markercollection~Marker|String} markerOrName Marker or marker name to add or update.
+	 * @param {module:engine/model/range~Range} [newRange] Marker range.
+	 */
+	setMarker( markerOrName, newRange ) {
+		const name = typeof markerOrName == 'string' ? markerOrName : markerOrName.name;
+		const currentMarker = this.document.markers.get( name );
+
+		if ( !newRange && !currentMarker ) {
+			/**
+			 * Range parameter is required when adding a new marker.
+			 *
+			 * @error batch-setMarker-no-range
+			 */
+			throw new CKEditorError( 'batch-setMarker-no-range: Range parameter is required when adding a new marker.' );
+		}
+
+		const currentRange = currentMarker ? currentMarker.getRange() : null;
+
+		if ( !newRange ) {
+			// If `newRange` is not given, treat this as synchronizing existing marker.
+			// Create `MarkerOperation` with `oldRange` set to `null`, so reverse operation will remove the marker.
+			addMarkerOperation( this, name, null, currentRange );
+		} else {
+			// Just change marker range.
+			addMarkerOperation( this, name, currentRange, newRange );
+		}
+	}
+
+	/**
+	 * Removes given {@link module:engine/model/markercollection~Marker marker} or marker with given name.
+	 *
+	 * @param {module:engine/model/markercollection~Marker|String} markerOrName Marker or marker name to remove.
+	 */
+	removeMarker( markerOrName ) {
+		const name = typeof markerOrName == 'string' ? markerOrName : markerOrName.name;
+
+		if ( !this.document.markers.has( name ) ) {
+			/**
+			 * Trying to remove marker which does not exist.
+			 *
+			 * @error batch-removeMarker-no-marker
+			 */
+			throw new CKEditorError( 'batch-removeMarker-no-marker: Trying to remove marker which does not exist.' );
+		}
+
+		const oldRange = this.document.markers.get( name ).getRange();
+
+		addMarkerOperation( this, name, oldRange, null );
+	}
 }
 
-/**
- * Function to register batch methods. To make code scalable `Batch` do not have modification
- * methods built in. They can be registered using this method.
- *
- * This method checks if there is no naming collision and throws `batch-register-taken` if the method name
- * is already taken.
- *
- * Besides that no magic happens here, the method is added to the `Batch` class prototype.
- *
- * For example:
- *
- *		Batch.register( 'insert', function( position, nodes ) {
- *			// You can use a class inheriting from `Delta` if that class should handle OT in a special way.
- *			const delta = new Delta();
- *
- *			// Add delta to the Batch instance. It is important to add a delta to the batch before applying any operation.
- *			this.addDelta( delta );
- *
- *			// Create operations which should be components of this delta.
- *			const operation = new InsertOperation( position, nodes, this.document.version );
- *
- *			// Add operation to the delta. It is important to add operation before applying it.
- *			delta.addOperation( operation );
- *
- *			// Remember to apply every operation, no magic, you need to do it manually.
- *			this.document.applyOperation( operation );
- *
- *			// Make this method chainable.
- *			return this;
- *		} );
- *
- * @method module:engine/model/batch~Batch.register
- * @param {String} name Method name.
- * @param {Function} creator Method body.
- */
-export function register( name, creator ) {
-	if ( Batch.prototype[ name ] ) {
-		/**
-		 * This batch method name is already taken.
-		 *
-		 * @error batch-register-taken
-		 * @param {String} name
-		 */
-		throw new CKEditorError(
-			'model-batch-register-taken: This batch method name is already taken.',
-			{ name } );
+// Sets given attribute to each node in given range. When attribute value is null then attribute will be removed.
+//
+// Because attribute operation needs to have the same attribute value on the whole range, this function splits
+// the range into smaller parts.
+//
+// @private
+// @param {module:engine/model/batch~Batch} batch
+// @param {String} key Attribute key.
+// @param {*} value Attribute new value.
+// @param {module:engine/model/range~Range} range Model range on which the attribute will be set.
+function setAttributeToRange( batch, key, value, range ) {
+	const delta = new AttributeDelta();
+	const doc = batch.document;
+
+	// Position of the last split, the beginning of the new range.
+	let lastSplitPosition = range.start;
+
+	// Currently position in the scanning range. Because we need value after the position, it is not a current
+	// position of the iterator but the previous one (we need to iterate one more time to get the value after).
+	let position;
+
+	// Value before the currently position.
+	let valueBefore;
+
+	// Value after the currently position.
+	let valueAfter;
+
+	for ( const val of range ) {
+		valueAfter = val.item.getAttribute( key );
+
+		// At the first run of the iterator the position in undefined. We also do not have a valueBefore, but
+		// because valueAfter may be null, valueBefore may be equal valueAfter ( undefined == null ).
+		if ( position && valueBefore != valueAfter ) {
+			// if valueBefore == value there is nothing to change, so we add operation only if these values are different.
+			if ( valueBefore != value ) {
+				addOperation();
+			}
+
+			lastSplitPosition = position;
+		}
+
+		position = val.nextPosition;
+		valueBefore = valueAfter;
+	}
+
+	// Because position in the loop is not the iterator position (see let position comment), the last position in
+	// the while loop will be last but one position in the range. We need to check the last position manually.
+	if ( position instanceof Position && position != lastSplitPosition && valueBefore != value ) {
+		addOperation();
+	}
+
+	function addOperation() {
+		// Add delta to the batch only if there is at least operation in the delta. Add delta only once.
+		if ( delta.operations.length === 0 ) {
+			batch.addDelta( delta );
+		}
+
+		const range = new Range( lastSplitPosition, position );
+		const operation = new AttributeOperation( range, key, valueBefore, value, doc.version );
+
+		delta.addOperation( operation );
+		doc.applyOperation( operation );
+	}
+}
+
+// Sets given attribute to the given node. When attribute value is null then attribute will be removed.
+//
+// @private
+// @param {module:engine/model/batch~Batch} batch
+// @param {String} key Attribute key.
+// @param {*} value Attribute new value.
+// @param {module:engine/model/item~Item} item Model item on which the attribute will be set.
+function setAttributeToItem( batch, key, value, item ) {
+	const doc = batch.document;
+	const previousValue = item.getAttribute( key );
+	let range, operation;
+
+	if ( previousValue != value ) {
+		const delta = item.root === item ? new RootAttributeDelta() : new AttributeDelta();
+		batch.addDelta( delta );
+
+		if ( item.root === item ) {
+			// If we change attributes of root element, we have to use `RootAttributeOperation`.
+			operation = new RootAttributeOperation( item, key, previousValue, value, doc.version );
+		} else {
+			if ( item.is( 'element' ) ) {
+				// If we change the attribute of the element, we do not want to change attributes of its children, so
+				// the end of the range cannot be after the closing tag, it should be inside that element, before any of
+				// it's children, so the range will contain only the opening tag.
+				range = new Range( Position.createBefore( item ), Position.createFromParentAndOffset( item, 0 ) );
+			} else {
+				// If `item` is text proxy, we create a range from the beginning to the end of that text proxy, to change
+				// all characters represented by it.
+				range = new Range( Position.createBefore( item ), Position.createAfter( item ) );
+			}
+
+			operation = new AttributeOperation( range, key, previousValue, value, doc.version );
+		}
+
+		delta.addOperation( operation );
+		doc.applyOperation( operation );
+	}
+}
+
+// Creates and adds marker operation to {@link module:engine/model/delta/delta~Delta delta}.
+//
+// @private
+// @param {module:engine/model/batch~Batch} batch
+// @param {String} name Marker name.
+// @param {module:engine/model/range~Range} oldRange Marker range before the change.
+// @param {module:engine/model/range~Range} newRange Marker range after the change.
+function addMarkerOperation( batch, name, oldRange, newRange ) {
+	const doc = batch.document;
+	const delta = new MarkerDelta();
+
+	const operation = new MarkerOperation( name, oldRange, newRange, doc.markers, doc.version );
+
+	batch.addDelta( delta );
+	delta.addOperation( operation );
+	doc.applyOperation( operation );
+}
+
+// Returns `true` if both root elements are the same element or both are documents root elements.
+//
+// Elements in the same tree can be moved (for instance you can move element form one documents root to another, or
+// within the same document fragment), but when element supposed to be moved from document fragment to the document, or
+// to another document it should be removed and inserted to avoid problems with OT. This is because features like undo or
+// collaboration may track changes on the document but ignore changes on detached fragments and should not get
+// unexpected `move` operation.
+function isSameTree( rootA, rootB ) {
+	// If it is the same root this is the same tree.
+	if ( rootA === rootB ) {
+		return true;
+	}
+
+	// If both roots are documents root it is operation within the document what we still treat as the same tree.
+	if ( rootA instanceof RootElement && rootB instanceof RootElement ) {
+		return true;
 	}
 
-	Batch.prototype[ name ] = creator;
+	return false;
 }

+ 0 - 149
packages/ckeditor5-engine/src/model/delta/attributedelta.js

@@ -9,11 +9,7 @@
 
 import Delta from './delta';
 import DeltaFactory from './deltafactory';
-import { register } from '../batch';
-import AttributeOperation from '../operation/attributeoperation';
-import RootAttributeOperation from '../operation/rootattributeoperation';
 import NoOperation from '../operation/nooperation';
-import Position from '../position';
 import Range from '../range';
 
 /**
@@ -112,149 +108,4 @@ export default class AttributeDelta extends Delta {
 	}
 }
 
-/**
- * To provide specific OT behavior and better collisions solving, methods to change attributes
- * ({@link module:engine/model/batch~Batch#setAttribute} and {@link module:engine/model/batch~Batch#removeAttribute})
- * use `RootAttributeDelta` class which inherits from the `Delta` class and may
- * overwrite some methods.
- *
- * @extends module:engine/model/delta/delta~Delta
- */
-export class RootAttributeDelta extends Delta {
-	/**
-	 * @inheritDoc
-	 */
-	static get className() {
-		return 'engine.model.delta.RootAttributeDelta';
-	}
-}
-
-/**
- * Sets value of the attribute with given key on a {@link module:engine/model/item~Item model item}
- * or on a {@link module:engine/model/range~Range range}.
- *
- * @chainable
- * @method module:engine/model/batch~Batch#setAttribute
- * @param {module:engine/model/item~Item|module:engine/model/range~Range} itemOrRange
- * Model item or range on which the attribute will be set.
- * @param {String} key Attribute key.
- * @param {*} value Attribute new value.
- */
-register( 'setAttribute', function( itemOrRange, key, value ) {
-	attribute( this, key, value, itemOrRange );
-
-	return this;
-} );
-
-/**
- * Removes an attribute with given key from a {@link module:engine/model/item~Item model item}
- * or from a {@link module:engine/model/range~Range range}.
- *
- * @chainable
- * @param {module:engine/model/item~Item|module:engine/model/range~Range} itemOrRange
- * Model item or range from which the attribute will be removed.
- * @method module:engine/model/batch~Batch#removeAttribute
- * @param {String} key Attribute key.
- */
-register( 'removeAttribute', function( itemOrRange, key ) {
-	attribute( this, key, null, itemOrRange );
-
-	return this;
-} );
-
-function attribute( batch, key, value, itemOrRange ) {
-	if ( itemOrRange instanceof Range ) {
-		changeRange( batch, batch.document, key, value, itemOrRange );
-	} else {
-		changeItem( batch, batch.document, key, value, itemOrRange );
-	}
-}
-
-function changeItem( batch, doc, key, value, item ) {
-	const previousValue = item.getAttribute( key );
-	let range, operation;
-
-	const delta = item.is( 'rootElement' ) ? new RootAttributeDelta() : new AttributeDelta();
-
-	if ( previousValue != value ) {
-		batch.addDelta( delta );
-
-		if ( item.is( 'rootElement' ) ) {
-			// If we change attributes of root element, we have to use `RootAttributeOperation`.
-			operation = new RootAttributeOperation( item, key, previousValue, value, doc.version );
-		} else {
-			if ( item.is( 'element' ) ) {
-				// If we change the attribute of the element, we do not want to change attributes of its children, so
-				// the end of the range cannot be after the closing tag, it should be inside that element, before any of
-				// it's children, so the range will contain only the opening tag.
-				range = new Range( Position.createBefore( item ), Position.createFromParentAndOffset( item, 0 ) );
-			} else {
-				// If `item` is text proxy, we create a range from the beginning to the end of that text proxy, to change
-				// all characters represented by it.
-				range = new Range( Position.createBefore( item ), Position.createAfter( item ) );
-			}
-
-			operation = new AttributeOperation( range, key, previousValue, value, doc.version );
-		}
-
-		delta.addOperation( operation );
-		doc.applyOperation( operation );
-	}
-}
-
-// Because attribute operation needs to have the same attribute value on the whole range, this function splits the range
-// into smaller parts.
-function changeRange( batch, doc, attributeKey, attributeValue, range ) {
-	const delta = new AttributeDelta();
-
-	// Position of the last split, the beginning of the new range.
-	let lastSplitPosition = range.start;
-
-	// Currently position in the scanning range. Because we need value after the position, it is not a current
-	// position of the iterator but the previous one (we need to iterate one more time to get the value after).
-	let position,
-		// Value before the currently position.
-		attributeValueBefore,
-		// Value after the currently position.
-		attributeValueAfter;
-
-	for ( const value of range ) {
-		attributeValueAfter = value.item.getAttribute( attributeKey );
-
-		// At the first run of the iterator the position in undefined. We also do not have a attributeValueBefore, but
-		// because attributeValueAfter may be null, attributeValueBefore may be equal attributeValueAfter ( undefined == null ).
-		if ( position && attributeValueBefore != attributeValueAfter ) {
-			// if attributeValueBefore == attributeValue there is nothing to change, so we add operation only if these values are different.
-			if ( attributeValueBefore != attributeValue ) {
-				addOperation();
-			}
-
-			lastSplitPosition = position;
-		}
-
-		position = value.nextPosition;
-		attributeValueBefore = attributeValueAfter;
-	}
-
-	// Because position in the loop is not the iterator position (see let position comment), the last position in
-	// the while loop will be last but one position in the range. We need to check the last position manually.
-	if ( position instanceof Position && position != lastSplitPosition && attributeValueBefore != attributeValue ) {
-		addOperation();
-	}
-
-	function addOperation() {
-		// Add delta to the batch only if there is at least operation in the delta. Add delta only once.
-		if ( delta.operations.length === 0 ) {
-			batch.addDelta( delta );
-		}
-
-		const range = new Range( lastSplitPosition, position );
-		const operation = new AttributeOperation( range, attributeKey, attributeValueBefore, attributeValue, doc.version );
-
-		delta.addOperation( operation );
-		doc.applyOperation( operation );
-	}
-}
-
 DeltaFactory.register( AttributeDelta );
-DeltaFactory.register( RootAttributeDelta );

+ 0 - 1
packages/ckeditor5-engine/src/model/delta/basic-deltas.js

@@ -13,7 +13,6 @@
 
 // Import default suite of deltas so a feature have to include only Batch class file.
 import './attributedelta';
-import './insertdelta';
 import './mergedelta';
 import './movedelta';
 import './removedelta';

+ 0 - 50
packages/ckeditor5-engine/src/model/delta/insertdelta.js

@@ -10,13 +10,6 @@
 import Delta from './delta';
 import RemoveDelta from './removedelta';
 import DeltaFactory from './deltafactory';
-import InsertOperation from '../operation/insertoperation';
-import { register } from '../batch';
-import { normalizeNodes } from './../writer';
-
-import DocumentFragment from '../documentfragment';
-import Range from '../../model/range.js';
-import Position from '../../model/position.js';
 
 /**
  * To provide specific OT behavior and better collisions solving, the {@link module:engine/model/batch~Batch#insert Batch#insert} method
@@ -78,47 +71,4 @@ export default class InsertDelta extends Delta {
 	}
 }
 
-/**
- * Inserts a node or nodes at the given position.
- *
- * When inserted element is a {@link module:engine/model/documentfragment~DocumentFragment} and has markers its markers will
- * be set to {@link module:engine/model/document~Document#markers}.
- *
- * @chainable
- * @method module:engine/model/batch~Batch#insert
- * @param {module:engine/model/position~Position} position Position of insertion.
- * @param {module:engine/model/node~NodeSet} nodes The list of nodes to be inserted.
- */
-register( 'insert', function( position, nodes ) {
-	const normalizedNodes = normalizeNodes( nodes );
-
-	// If nothing is inserted do not create delta and operation.
-	if ( normalizedNodes.length === 0 ) {
-		return this;
-	}
-
-	const delta = new InsertDelta();
-	const insert = new InsertOperation( position, normalizedNodes, this.document.version );
-
-	this.addDelta( delta );
-	delta.addOperation( insert );
-	this.document.applyOperation( insert );
-
-	// When element is a DocumentFragment we need to move its markers to Document#markers.
-	if ( nodes instanceof DocumentFragment ) {
-		for ( const [ markerName, markerRange ] of nodes.markers ) {
-			// We need to migrate marker range from DocumentFragment to Document.
-			const rangeRootPosition = Position.createAt( markerRange.root );
-			const range = new Range(
-				markerRange.start._getCombined( rangeRootPosition, position ),
-				markerRange.end._getCombined( rangeRootPosition, position )
-			);
-
-			this.setMarker( markerName, range );
-		}
-	}
-
-	return this;
-} );
-
 DeltaFactory.register( InsertDelta );

+ 0 - 85
packages/ckeditor5-engine/src/model/delta/markerdelta.js

@@ -9,9 +9,6 @@
 
 import Delta from './delta';
 import DeltaFactory from './deltafactory';
-import { register } from '../batch';
-import MarkerOperation from '../operation/markeroperation';
-import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 
 /**
  * To provide specific OT behavior and better collisions solving, the {@link module:engine/model/batch~Batch#setMarker Batch#setMarker}
@@ -46,86 +43,4 @@ export default class MarkerDelta extends Delta {
 	}
 }
 
-/**
- * Adds or updates {@link module:engine/model/markercollection~Marker marker} with given name to given `range`.
- *
- * If passed name is a name of already existing marker (or {@link module:engine/model/markercollection~Marker Marker} instance
- * is passed), `range` parameter may be omitted. In this case marker will not be updated in
- * {@link module:engine/model/document~Document#markers document marker collection}. However the marker will be added to
- * the document history. This may be important for other features, like undo. From document history point of view, it will
- * look like the marker was created and added to the document at the moment when it is set using this method.
- *
- * This is useful if the marker is created before it can be added to document history (e.g. a feature creating the marker
- * is waiting for additional data, etc.). In this case, the marker may be first created directly through
- * {@link module:engine/model/markercollection~MarkerCollection MarkerCollection API} and only later added using `Batch` API.
- *
- * @chainable
- * @method module:engine/model/batch~Batch#setMarker
- * @param {module:engine/model/markercollection~Marker|String} markerOrName Marker or marker name to add or update.
- * @param {module:engine/model/range~Range} [newRange] Marker range.
- */
-register( 'setMarker', function( markerOrName, newRange ) {
-	const name = typeof markerOrName == 'string' ? markerOrName : markerOrName.name;
-	const currentMarker = this.document.markers.get( name );
-
-	if ( !newRange && !currentMarker ) {
-		/**
-		 * Range parameter is required when adding a new marker.
-		 *
-		 * @error batch-setMarker-no-range
-		 */
-		throw new CKEditorError( 'batch-setMarker-no-range: Range parameter is required when adding a new marker.' );
-	}
-
-	const currentRange = currentMarker ? currentMarker.getRange() : null;
-
-	if ( !newRange ) {
-		// If `newRange` is not given, treat this as synchronizing existing marker.
-		// Create `MarkerOperation` with `oldRange` set to `null`, so reverse operation will remove the marker.
-		addOperation( this, name, null, currentRange );
-	} else {
-		// Just change marker range.
-		addOperation( this, name, currentRange, newRange );
-	}
-
-	return this;
-} );
-
-/**
- * Removes given {@link module:engine/model/markercollection~Marker marker} or marker with given name.
- *
- * @chainable
- * @method module:engine/model/batch~Batch#removeMarker
- * @param {module:engine/model/markercollection~Marker|String} markerOrName Marker or marker name to remove.
- */
-register( 'removeMarker', function( markerOrName ) {
-	const name = typeof markerOrName == 'string' ? markerOrName : markerOrName.name;
-
-	if ( !this.document.markers.has( name ) ) {
-		/**
-		 * Trying to remove marker which does not exist.
-		 *
-		 * @error batch-removeMarker-no-marker
-		 */
-		throw new CKEditorError( 'batch-removeMarker-no-marker: Trying to remove marker which does not exist.' );
-	}
-
-	const oldRange = this.document.markers.get( name ).getRange();
-
-	addOperation( this, name, oldRange, null );
-
-	return this;
-} );
-
-function addOperation( batch, name, oldRange, newRange ) {
-	const doc = batch.document;
-	const delta = new MarkerDelta();
-
-	const operation = new MarkerOperation( name, oldRange, newRange, doc.markers, doc.version );
-
-	batch.addDelta( delta );
-	delta.addOperation( operation );
-	doc.applyOperation( operation );
-}
-
 DeltaFactory.register( MarkerDelta );

+ 0 - 65
packages/ckeditor5-engine/src/model/delta/mergedelta.js

@@ -10,12 +10,6 @@
 import Delta from './delta';
 import DeltaFactory from './deltafactory';
 import SplitDelta from './splitdelta';
-import { register } from '../batch';
-import Position from '../position';
-import Element from '../element';
-import RemoveOperation from '../operation/removeoperation';
-import MoveOperation from '../operation/moveoperation';
-import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 
 /**
  * To provide specific OT behavior and better collisions solving, {@link module:engine/model/batch~Batch#merge} method
@@ -70,63 +64,4 @@ export default class MergeDelta extends Delta {
 	}
 }
 
-/**
- * Merges two siblings at the given position.
- *
- * Node before and after the position have to be an element. Otherwise `batch-merge-no-element-before` or
- * `batch-merge-no-element-after` error will be thrown.
- *
- * @chainable
- * @method module:engine/model/batch~Batch#merge
- * @param {module:engine/model/position~Position} position Position of merge.
- */
-register( 'merge', function( position ) {
-	const delta = new MergeDelta();
-	this.addDelta( delta );
-
-	const nodeBefore = position.nodeBefore;
-	const nodeAfter = position.nodeAfter;
-
-	if ( !( nodeBefore instanceof Element ) ) {
-		/**
-		 * Node before merge position must be an element.
-		 *
-		 * @error batch-merge-no-element-before
-		 */
-		throw new CKEditorError( 'batch-merge-no-element-before: Node before merge position must be an element.' );
-	}
-
-	if ( !( nodeAfter instanceof Element ) ) {
-		/**
-		 * Node after merge position must be an element.
-		 *
-		 * @error batch-merge-no-element-after
-		 */
-		throw new CKEditorError( 'batch-merge-no-element-after: Node after merge position must be an element.' );
-	}
-
-	const positionAfter = Position.createFromParentAndOffset( nodeAfter, 0 );
-	const positionBefore = Position.createFromParentAndOffset( nodeBefore, nodeBefore.maxOffset );
-
-	const move = new MoveOperation(
-		positionAfter,
-		nodeAfter.maxOffset,
-		positionBefore,
-		this.document.version
-	);
-
-	move.isSticky = true;
-	delta.addOperation( move );
-	this.document.applyOperation( move );
-
-	const graveyard = this.document.graveyard;
-	const gyPosition = new Position( graveyard, [ 0 ] );
-
-	const remove = new RemoveOperation( position, 1, gyPosition, this.document.version );
-	delta.addOperation( remove );
-	this.document.applyOperation( remove );
-
-	return this;
-} );
-
 DeltaFactory.register( MergeDelta );

+ 0 - 41
packages/ckeditor5-engine/src/model/delta/movedelta.js

@@ -9,11 +9,6 @@
 
 import Delta from './delta';
 import DeltaFactory from './deltafactory';
-import { register } from '../batch';
-import MoveOperation from '../operation/moveoperation';
-import Position from '../position';
-import Range from '../range';
-import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 
 /**
  * To provide specific OT behavior and better collisions solving, {@link module:engine/model/batch~Batch#move} method
@@ -86,40 +81,4 @@ export default class MoveDelta extends Delta {
 	}
 }
 
-function addMoveOperation( batch, delta, sourcePosition, howMany, targetPosition ) {
-	const operation = new MoveOperation( sourcePosition, howMany, targetPosition, batch.document.version );
-	delta.addOperation( operation );
-	batch.document.applyOperation( operation );
-}
-
-/**
- * Moves given {@link module:engine/model/item~Item model item} or given range to target position.
- *
- * @chainable
- * @method module:engine/model/batch~Batch#move
- * @param {module:engine/model/item~Item|module:engine/model/range~Range} itemOrRange Model item or range of nodes to move.
- * @param {module:engine/model/position~Position} targetPosition Position where moved nodes will be inserted.
- */
-register( 'move', function( itemOrRange, targetPosition ) {
-	const delta = new MoveDelta();
-	this.addDelta( delta );
-
-	if ( itemOrRange instanceof Range ) {
-		if ( !itemOrRange.isFlat ) {
-			/**
-			 * Range to move is not flat.
-			 *
-			 * @error batch-move-range-not-flat
-			 */
-			throw new CKEditorError( 'batch-move-range-not-flat: Range to move is not flat.' );
-		}
-
-		addMoveOperation( this, delta, itemOrRange.start, itemOrRange.end.offset - itemOrRange.start.offset, targetPosition );
-	} else {
-		addMoveOperation( this, delta, Position.createBefore( itemOrRange ), 1, targetPosition );
-	}
-
-	return this;
-} );
-
 DeltaFactory.register( MoveDelta );

+ 0 - 38
packages/ckeditor5-engine/src/model/delta/removedelta.js

@@ -8,11 +8,7 @@
  */
 
 import MoveDelta from './movedelta';
-import { register } from '../batch';
 import DeltaFactory from './deltafactory';
-import RemoveOperation from '../operation/removeoperation';
-import Position from '../position';
-import Range from '../range';
 
 /**
  * To provide specific OT behavior and better collisions solving, {@link module:engine/model/batch~Batch#remove} method
@@ -29,38 +25,4 @@ export default class RemoveDelta extends MoveDelta {
 	}
 }
 
-function addRemoveDelta( batch, position, howMany ) {
-	const delta = new RemoveDelta();
-	batch.addDelta( delta );
-
-	const graveyard = batch.document.graveyard;
-	const gyPosition = new Position( graveyard, [ 0 ] );
-
-	const operation = new RemoveOperation( position, howMany, gyPosition, batch.document.version );
-	delta.addOperation( operation );
-	batch.document.applyOperation( operation );
-}
-
-/**
- * Removes given {@link module:engine/model/item~Item model item} or given range.
- *
- * @chainable
- * @method module:engine/model/batch~Batch#remove
- * @param {module:engine/model/item~Item|module:engine/model/range~Range} itemOrRange Model item or range to remove.
- */
-register( 'remove', function( itemOrRange ) {
-	if ( itemOrRange instanceof Range ) {
-		// The array is reversed, so the ranges to remove are in correct order and do not have to be updated.
-		const ranges = itemOrRange.getMinimalFlatRanges().reverse();
-
-		for ( const flat of ranges ) {
-			addRemoveDelta( this, flat.start, flat.end.offset - flat.start.offset );
-		}
-	} else {
-		addRemoveDelta( this, Position.createBefore( itemOrRange ), 1 );
-	}
-
-	return this;
-} );
-
 DeltaFactory.register( RemoveDelta );

+ 0 - 37
packages/ckeditor5-engine/src/model/delta/renamedelta.js

@@ -9,11 +9,6 @@
 
 import Delta from './delta';
 import DeltaFactory from './deltafactory';
-import { register } from '../batch';
-import RenameOperation from '../operation/renameoperation';
-import Element from '../element';
-import Position from '../position';
-import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 
 /**
  * To provide specific OT behavior and better collisions solving, the {@link module:engine/model/batch~Batch#rename Batch#rename} method
@@ -44,36 +39,4 @@ export default class RenameDelta extends Delta {
 	}
 }
 
-function apply( batch, delta, operation ) {
-	delta.addOperation( operation );
-	batch.document.applyOperation( operation );
-}
-
-/**
- * Renames given element.
- *
- * @chainable
- * @method module:engine/model/batch~Batch#rename
- * @param {module:engine/model/element~Element} element The element to rename.
- * @param {String} newName New element name.
- */
-register( 'rename', function( element, newName ) {
-	if ( !( element instanceof Element ) ) {
-		/**
-		 * Trying to rename an object which is not an instance of Element.
-		 *
-		 * @error batch-rename-not-element-instance
-		 */
-		throw new CKEditorError( 'batch-rename-not-element-instance: Trying to rename an object which is not an instance of Element.' );
-	}
-
-	const delta = new RenameDelta();
-	this.addDelta( delta );
-
-	const renameOperation = new RenameOperation( Position.createBefore( element ), element.name, newName, this.document.version );
-	apply( this, delta, renameOperation );
-
-	return this;
-} );
-
 DeltaFactory.register( RenameDelta );

+ 30 - 0
packages/ckeditor5-engine/src/model/delta/rootattributedelta.js

@@ -0,0 +1,30 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module engine/model/delta/rootattributedelta
+ */
+
+import Delta from './delta';
+import DeltaFactory from './deltafactory';
+
+/**
+ * To provide specific OT behavior and better collisions solving, methods to change attributes
+ * ({@link module:engine/model/batch~Batch#setAttribute} and {@link module:engine/model/batch~Batch#removeAttribute})
+ * use `RootAttributeDelta` class which inherits from the `Delta` class and may
+ * overwrite some methods.
+ *
+ * @extends module:engine/model/delta/delta~Delta
+ */
+export default class RootAttributeDelta extends Delta {
+	/**
+	 * @inheritDoc
+	 */
+	static get className() {
+		return 'engine.model.delta.RootAttributeDelta';
+	}
+}
+
+DeltaFactory.register( RootAttributeDelta );

+ 0 - 55
packages/ckeditor5-engine/src/model/delta/splitdelta.js

@@ -9,12 +9,7 @@
 
 import Delta from './delta';
 import DeltaFactory from './deltafactory';
-import { register } from '../batch';
-import Position from '../position';
-import Element from '../element';
-import InsertOperation from '../operation/insertoperation';
 import MoveOperation from '../operation/moveoperation';
-import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 import MergeDelta from '../delta/mergedelta';
 
 /**
@@ -85,54 +80,4 @@ export default class SplitDelta extends Delta {
 	}
 }
 
-/**
- * Splits an element at the given position.
- *
- * The element cannot be a root element, as root element cannot be split. The `batch-split-root` error will be thrown if
- * you try to split the root element.
- *
- * @chainable
- * @method module:engine/model/batch~Batch#split
- * @param {module:engine/model/position~Position} position Position of split.
- */
-register( 'split', function( position ) {
-	const delta = new SplitDelta();
-	this.addDelta( delta );
-
-	const splitElement = position.parent;
-
-	if ( !splitElement.parent ) {
-		/**
-		 * Root element can not be split.
-		 *
-		 * @error batch-split-root
-		 */
-		throw new CKEditorError( 'batch-split-root: Root element can not be split.' );
-	}
-
-	const copy = new Element( splitElement.name, splitElement.getAttributes() );
-
-	const insert = new InsertOperation(
-		Position.createAfter( splitElement ),
-		copy,
-		this.document.version
-	);
-
-	delta.addOperation( insert );
-	this.document.applyOperation( insert );
-
-	const move = new MoveOperation(
-		position,
-		splitElement.maxOffset - position.offset,
-		Position.createFromParentAndOffset( copy, 0 ),
-		this.document.version
-	);
-	move.isSticky = true;
-
-	delta.addOperation( move );
-	this.document.applyOperation( move );
-
-	return this;
-} );
-
 DeltaFactory.register( SplitDelta );

+ 0 - 51
packages/ckeditor5-engine/src/model/delta/unwrapdelta.js

@@ -10,11 +10,6 @@
 import Delta from './delta';
 import DeltaFactory from './deltafactory';
 import WrapDelta from './wrapdelta';
-import { register } from '../batch';
-import Position from '../position';
-import RemoveOperation from '../operation/removeoperation';
-import MoveOperation from '../operation/moveoperation';
-import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 
 /**
  * To provide specific OT behavior and better collisions solving, {@link module:engine/model/batch~Batch#merge} method
@@ -64,50 +59,4 @@ export default class UnwrapDelta extends Delta {
 	}
 }
 
-/**
- * Unwraps children of the given element – all its children are moved before it and then the element is removed.
- * Throws error if you try to unwrap an element which does not have a parent.
- *
- * @chainable
- * @method module:engine/model/batch~Batch#unwrap
- * @param {module:engine/model/element~Element} position Element to unwrap.
- */
-register( 'unwrap', function( element ) {
-	if ( element.parent === null ) {
-		/**
-		 * Trying to unwrap an element which has no parent.
-		 *
-		 * @error batch-unwrap-element-no-parent
-		 */
-		throw new CKEditorError( 'batch-unwrap-element-no-parent: Trying to unwrap an element which has no parent.' );
-	}
-
-	const delta = new UnwrapDelta();
-	this.addDelta( delta );
-
-	const sourcePosition = Position.createFromParentAndOffset( element, 0 );
-
-	const move = new MoveOperation(
-		sourcePosition,
-		element.maxOffset,
-		Position.createBefore( element ),
-		this.document.version
-	);
-
-	move.isSticky = true;
-	delta.addOperation( move );
-	this.document.applyOperation( move );
-
-	// Computing new position because we moved some nodes before `element`.
-	// If we would cache `Position.createBefore( element )` we remove wrong node.
-	const graveyard = this.document.graveyard;
-	const gyPosition = new Position( graveyard, [ 0 ] );
-
-	const remove = new RemoveOperation( Position.createBefore( element ), 1, gyPosition, this.document.version );
-	delta.addOperation( remove );
-	this.document.applyOperation( remove );
-
-	return this;
-} );
-
 DeltaFactory.register( UnwrapDelta );

+ 0 - 40
packages/ckeditor5-engine/src/model/delta/weakinsertdelta.js

@@ -8,10 +8,7 @@
  */
 
 import InsertDelta from './insertdelta';
-import { register } from '../batch';
 import DeltaFactory from './deltafactory';
-import InsertOperation from '../operation/insertoperation';
-import { normalizeNodes } from './../writer';
 
 /**
  * To provide specific OT behavior and better collisions solving, the {@link module:engine/model/batch~Batch#insert} method
@@ -28,41 +25,4 @@ export default class WeakInsertDelta extends InsertDelta {
 	}
 }
 
-/**
- * Inserts a node or nodes at given position. {@link module:engine/model/batch~Batch#weakInsert weakInsert} is commonly used for actions
- * like typing or plain-text paste (without formatting). There are two differences between
- * {@link module:engine/model/batch~Batch#insert insert} and {@link module:engine/model/batch~Batch#weakInsert weakInsert}:
- *
- * * When using `weakInsert`, inserted nodes will have same attributes as the current attributes of
- * {@link module:engine/model/document~Document#selection document selection}.
- * * If {@link module:engine/model/operation/insertoperation~InsertOperation insert operation} position is inside a range changed by
- * {@link module:engine/model/operation/attributeoperation~AttributeOperation attribute operation},
- * the attribute operation is split into two operations.
- * Thanks to this, attribute change "omits" the inserted nodes. The correct behavior for `WeakInsertDelta` is that
- * {@link module:engine/model/operation/attributeoperation~AttributeOperation AttributeOperation} does not "break" and also
- * applies attributes for inserted nodes. This behavior has to be reflected during
- * {@link module:engine/model/delta/transform~transform delta transformation}.
- *
- * @chainable
- * @method module:engine/model/batch~Batch#weakInsert
- * @param {module:engine/model/position~Position} position Position of insertion.
- * @param {module:engine/model/node~NodeSet} nodes The list of nodes to be inserted.
- */
-register( 'weakInsert', function( position, nodes ) {
-	const delta = new WeakInsertDelta();
-	this.addDelta( delta );
-
-	nodes = normalizeNodes( nodes );
-
-	for ( const node of nodes ) {
-		node.setAttributesTo( this.document.selection.getAttributes() );
-	}
-
-	const operation = new InsertOperation( position, nodes, this.document.version );
-	delta.addOperation( operation );
-	this.document.applyOperation( operation );
-
-	return this;
-} );
-
 DeltaFactory.register( WeakInsertDelta );

+ 0 - 65
packages/ckeditor5-engine/src/model/delta/wrapdelta.js

@@ -10,13 +10,7 @@
 import Delta from './delta';
 import DeltaFactory from './deltafactory';
 import UnwrapDelta from './unwrapdelta';
-import { register } from '../batch';
-import Position from '../position';
 import Range from '../range';
-import Element from '../element';
-import InsertOperation from '../operation/insertoperation';
-import MoveOperation from '../operation/moveoperation';
-import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 
 /**
  * To provide specific OT behavior and better collisions solving, {@link module:engine/model/batch~Batch#merge} method
@@ -91,63 +85,4 @@ export default class WrapDelta extends Delta {
 	}
 }
 
-/**
- * Wraps given range with given element or with a new element with specified name, if string has been passed.
- * **Note:** range to wrap should be a "flat range" (see {@link module:engine/model/range~Range#isFlat}). If not, error will be thrown.
- *
- * @chainable
- * @method module:engine/model/batch~Batch#wrap
- * @param {module:engine/model/range~Range} range Range to wrap.
- * @param {module:engine/model/element~Element|String} elementOrString Element or name of element to wrap the range with.
- */
-register( 'wrap', function( range, elementOrString ) {
-	if ( !range.isFlat ) {
-		/**
-		 * Range to wrap is not flat.
-		 *
-		 * @error batch-wrap-range-not-flat
-		 */
-		throw new CKEditorError( 'batch-wrap-range-not-flat: Range to wrap is not flat.' );
-	}
-
-	const element = elementOrString instanceof Element ? elementOrString : new Element( elementOrString );
-
-	if ( element.childCount > 0 ) {
-		/**
-		 * Element to wrap with is not empty.
-		 *
-		 * @error batch-wrap-element-not-empty
-		 */
-		throw new CKEditorError( 'batch-wrap-element-not-empty: Element to wrap with is not empty.' );
-	}
-
-	if ( element.parent !== null ) {
-		/**
-		 * Element to wrap with is already attached to a tree model.
-		 *
-		 * @error batch-wrap-element-attached
-		 */
-		throw new CKEditorError( 'batch-wrap-element-attached: Element to wrap with is already attached to tree model.' );
-	}
-
-	const delta = new WrapDelta();
-	this.addDelta( delta );
-
-	const insert = new InsertOperation( range.end, element, this.document.version );
-	delta.addOperation( insert );
-	this.document.applyOperation( insert );
-
-	const targetPosition = Position.createFromParentAndOffset( element, 0 );
-	const move = new MoveOperation(
-		range.start,
-		range.end.offset - range.start.offset,
-		targetPosition,
-		this.document.version
-	);
-	delta.addOperation( move );
-	this.document.applyOperation( move );
-
-	return this;
-} );
-
 DeltaFactory.register( WrapDelta );

+ 11 - 9
packages/ckeditor5-engine/src/model/document.js

@@ -37,7 +37,9 @@ const graveyardName = '$graveyard';
  * All changes in the document are done by {@link module:engine/model/operation/operation~Operation operations}. To create operations in
  * a simple way, use the {@link module:engine/model/batch~Batch} API, for example:
  *
- *		doc.batch().insert( position, nodes ).split( otherPosition );
+ *		const batch = doc.batch();
+ *		batch.insert( node, position );
+ *		batch.split( otherPosition );
  *
  * @see module:engine/model/document~Document#batch
  * @mixes module:utils/emittermixin~EmitterMixin
@@ -163,11 +165,11 @@ export default class Document {
 
 		const changes = operation._execute();
 
-		this.version++;
-
-		this.history.addDelta( operation.delta );
-
-		this.fire( 'change', operation.type, changes, operation.delta.batch, operation.delta.type );
+		if ( operation.isDocumentOperation ) {
+			this.version++;
+			this.history.addDelta( operation.delta );
+			this.fire( 'change', operation.type, changes, operation.delta.batch, operation.delta.type );
+		}
 	}
 
 	/**
@@ -419,7 +421,7 @@ export default class Document {
 	/**
 	 * Fired when document changes by applying an operation.
 	 *
-	 * There are 5 types of change:
+	 * There are a few types of change:
 	 *
 	 * * 'insert' when nodes are inserted,
 	 * * 'remove' when nodes are removed,
@@ -497,7 +499,7 @@ function* combineWalkers( backward, forward ) {
 
 			if ( !step.done ) {
 				done = false;
-				yield{
+				yield {
 					walker: backward,
 					value: step.value
 				};
@@ -509,7 +511,7 @@ function* combineWalkers( backward, forward ) {
 
 			if ( !step.done ) {
 				done = false;
-				yield{
+				yield {
 					walker: forward,
 					value: step.value
 				};

+ 1 - 1
packages/ckeditor5-engine/src/model/documentfragment.js

@@ -18,7 +18,7 @@ import isIterable from '@ckeditor/ckeditor5-utils/src/isiterable';
  *
  * DocumentFragment has own {@link module:engine/model/markercollection~MarkerCollection}. Markers from this collection
  * will be set to the {@link module:engine/model/document~Document#markers document markers} by a
- * {@link module:engine/model/writer~writer.insert} function.
+ * {@linkTODO module:engine/model/writer~writer.insert} function.
  */
 export default class DocumentFragment {
 	/**

+ 5 - 5
packages/ckeditor5-engine/src/model/documentselection.js

@@ -543,7 +543,7 @@ export default class DocumentSelection extends Selection {
 	_removeStoredAttribute( key ) {
 		const storeKey = DocumentSelection._getStoreAttributeKey( key );
 
-		this._document.batch().removeAttribute( this.anchor.parent, storeKey );
+		this._document.batch().removeAttribute( storeKey, this.anchor.parent );
 	}
 
 	/**
@@ -556,7 +556,7 @@ export default class DocumentSelection extends Selection {
 	_storeAttribute( key, value ) {
 		const storeKey = DocumentSelection._getStoreAttributeKey( key );
 
-		this._document.batch().setAttribute( this.anchor.parent, storeKey, value );
+		this._document.batch().setAttribute( storeKey, value, this.anchor.parent );
 	}
 
 	/**
@@ -572,13 +572,13 @@ export default class DocumentSelection extends Selection {
 		for ( const [ oldKey ] of this._getStoredAttributes() ) {
 			const storeKey = DocumentSelection._getStoreAttributeKey( oldKey );
 
-			batch.removeAttribute( selectionParent, storeKey );
+			batch.removeAttribute( storeKey, selectionParent );
 		}
 
 		for ( const [ key, value ] of attrs ) {
 			const storeKey = DocumentSelection._getStoreAttributeKey( key );
 
-			batch.setAttribute( selectionParent, storeKey, value );
+			batch.setAttribute( storeKey, value, selectionParent );
 		}
 	}
 
@@ -731,7 +731,7 @@ function clearAttributesStoredInElement( changes, batch, document ) {
 		const storedAttributes = Array.from( changeParent.getAttributeKeys() ).filter( key => key.startsWith( storePrefix ) );
 
 		for ( const key of storedAttributes ) {
-			batch.removeAttribute( changeParent, key );
+			batch.removeAttribute( key, changeParent );
 		}
 	} );
 }

+ 2 - 0
packages/ckeditor5-engine/src/model/element.js

@@ -17,6 +17,8 @@ import isIterable from '@ckeditor/ckeditor5-utils/src/isiterable';
  * {@link module:engine/model/element~Element#getChildren child nodes}.
  *
  * **Important**: see {@link module:engine/model/node~Node} to read about restrictions using `Element` and `Node` API.
+ *
+ * @extends {module:engine/model/node~Node}
  */
 export default class Element extends Node {
 	/**

+ 1 - 1
packages/ckeditor5-engine/src/model/item.jsdoc

@@ -8,7 +8,7 @@
  */
 
 /**
- * Item is a {@link module:engine/model/node~Node Node} or {module:engine/model/textproxy~TextProxy TextProxy}.
+ * Item is a {@link module:engine/model/node~Node} or {@link module:engine/model/textproxy~TextProxy}.
  *
  * @typedef {module:engine/model/node~Node|module:engine/model/textproxy~TextProxy} module:engine/model/item~Item
  */

+ 8 - 3
packages/ckeditor5-engine/src/model/operation/attributeoperation.js

@@ -10,7 +10,7 @@
 import Operation from './operation';
 import Range from '../range';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
-import writer from '../writer';
+import { _setAttribute } from './utils';
 import isEqual from '@ckeditor/ckeditor5-utils/src/lib/lodash/isEqual';
 
 /**
@@ -73,6 +73,11 @@ export default class AttributeOperation extends Operation {
 		 * @member {*}
 		 */
 		this.newValue = newValue === undefined ? null : newValue;
+
+		/**
+		 * @inheritDoc
+		 */
+		this.isDocumentOperation = !!this.range.root.document;
 	}
 
 	/**
@@ -116,7 +121,7 @@ export default class AttributeOperation extends Operation {
 				/**
 				 * Changed node has different attribute value than operation's old attribute value.
 				 *
-				 * @error operation-attribute-wrong-old-value
+				 * @error attribute-operation-wrong-old-value
 				 * @param {module:engine/model/item~Item} item
 				 * @param {String} key
 				 * @param {*} value
@@ -146,7 +151,7 @@ export default class AttributeOperation extends Operation {
 		// If value to set is same as old value, don't do anything.
 		if ( !isEqual( this.oldValue, this.newValue ) ) {
 			// Execution.
-			writer.setAttribute( this.range, this.key, this.newValue );
+			_setAttribute( this.range, this.key, this.newValue );
 		}
 
 		return { range: this.range, key: this.key, oldValue: this.oldValue, newValue: this.newValue };

+ 87 - 0
packages/ckeditor5-engine/src/model/operation/detachoperation.js

@@ -0,0 +1,87 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module engine/model/operation/detachoperation
+ */
+
+import Operation from './operation';
+import Position from '../position';
+import Range from '../range';
+import { _remove } from './utils';
+import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
+
+/**
+ * Operation to permanently remove node from detached root.
+ * Note this operation is only a local operation and won't be send to the other clients.
+ *
+ * @extends module:engine/model/operation/operation~Operation
+ */
+export default class DetachOperation extends Operation {
+	/**
+	 * Creates an insert operation.
+	 *
+	 * @param {module:engine/model/position~Position} sourcePosition
+	 * Position before the first {@link module:engine/model/item~Item model item} to move.
+	 * @param {Number} howMany Offset size of moved range. Moved range will start from `sourcePosition` and end at
+	 * `sourcePosition` with offset shifted by `howMany`.
+	 * @param {Number} baseVersion {@link module:engine/model/document~Document#version} on which operation can be applied.
+	 */
+	constructor( sourcePosition, howMany, baseVersion ) {
+		super( baseVersion );
+
+		/**
+		 * Position before the first {@link module:engine/model/item~Item model item} to detach.
+		 *
+		 * @member {module:engine/model/position~Position} #sourcePosition
+		 */
+		this.sourcePosition = Position.createFromPosition( sourcePosition );
+
+		/**
+		 * Offset size of moved range.
+		 *
+		 * @member {Number} #howMany
+		 */
+		this.howMany = howMany;
+
+		/**
+		 * @inheritDoc
+		 */
+		this.isDocumentOperation = false;
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	get type() {
+		return 'detach';
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	_execute() {
+		if ( this.sourcePosition.root.document ) {
+			/**
+			 * Cannot detach document node.
+			 * Use {@link module:engine/model/operation/removeoperation~RemoveOperation remove operation} instead.
+			 *
+			 * @error detach-operation-on-document-node
+			 */
+			throw new CKEditorError( 'detach-operation-on-document-node: Cannot detach document node.' );
+		}
+
+		const nodes = _remove( Range.createFromPositionAndShift( this.sourcePosition, this.howMany ) );
+
+		return { nodes };
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	static get className() {
+		return 'engine.model.operation.DetachOperation';
+	}
+}

+ 8 - 3
packages/ckeditor5-engine/src/model/operation/insertoperation.js

@@ -11,7 +11,7 @@ import Operation from './operation';
 import Position from '../position';
 import NodeList from '../nodelist';
 import RemoveOperation from './removeoperation';
-import { insert, normalizeNodes } from '../writer';
+import { _insert, _normalizeNodes } from './utils';
 import Text from '../text';
 import Element from '../element';
 
@@ -45,7 +45,12 @@ export default class InsertOperation extends Operation {
 		 * @readonly
 		 * @member {module:engine/model/nodelist~NodeList} module:engine/model/operation/insertoperation~InsertOperation#nodeList
 		 */
-		this.nodes = new NodeList( normalizeNodes( nodes ) );
+		this.nodes = new NodeList( _normalizeNodes( nodes ) );
+
+		/**
+		 * @inheritDoc
+		 */
+		this.isDocumentOperation = !!this.position.root.document;
 	}
 
 	/**
@@ -89,7 +94,7 @@ export default class InsertOperation extends Operation {
 		const originalNodes = this.nodes;
 		this.nodes = new NodeList( [ ...originalNodes ].map( node => node.clone( true ) ) );
 
-		const range = insert( this.position, originalNodes );
+		const range = _insert( this.position, originalNodes );
 
 		return { range };
 	}

+ 23 - 0
packages/ckeditor5-engine/src/model/operation/markeroperation.js

@@ -55,6 +55,11 @@ export default class MarkerOperation extends Operation {
 		 * @member {module:engine/model/markercollection~MarkerCollection}
 		 */
 		this._markers = markers;
+
+		/**
+		 * @inheritDoc
+		 */
+		this.isDocumentOperation = this._isDocumentOperation();
 	}
 
 	/**
@@ -64,6 +69,24 @@ export default class MarkerOperation extends Operation {
 		return 'marker';
 	}
 
+	/**
+	 * Checks if operation is executed on document or document fragment nodes.
+	 *
+	 * @private
+	 */
+	_isDocumentOperation() {
+		if ( this.newRange ) {
+			return !!this.newRange.root.document;
+		}
+
+		if ( this.oldRange ) {
+			return !!this.oldRange.root.document;
+		}
+
+		// This is edge and might happen only on data from the server.
+		return true;
+	}
+
 	/**
 	 * Creates and returns an operation that has the same parameters as this operation.
 	 *

+ 13 - 2
packages/ckeditor5-engine/src/model/operation/moveoperation.js

@@ -12,7 +12,7 @@ import Position from '../position';
 import Range from '../range';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 import compareArrays from '@ckeditor/ckeditor5-utils/src/comparearrays';
-import writer from './../writer';
+import { _move } from './utils';
 
 /**
  * Operation to move a range of {@link module:engine/model/item~Item model items}
@@ -64,6 +64,17 @@ export default class MoveOperation extends Operation {
 		 * @member {Boolean} module:engine/model/operation/moveoperation~MoveOperation#isSticky
 		 */
 		this.isSticky = false;
+
+		/**
+		 * Defines whether operation is executed on attached or detached {@link module:engine/model/item~Item items}.
+		 *
+		 * Note that range cannot be moved within different documents e.g. from docFrag to document root so
+		 * root of source and target positions is always the same.
+		 *
+		 * @readonly
+		 * @member {Boolean} #isDocumentOperation
+		 */
+		this.isDocumentOperation = !!this.targetPosition.root.document;
 	}
 
 	/**
@@ -173,7 +184,7 @@ export default class MoveOperation extends Operation {
 			}
 		}
 
-		const range = writer.move( Range.createFromPositionAndShift( this.sourcePosition, this.howMany ), this.targetPosition );
+		const range = _move( Range.createFromPositionAndShift( this.sourcePosition, this.howMany ), this.targetPosition );
 
 		return {
 			sourcePosition: this.sourcePosition,

+ 12 - 0
packages/ckeditor5-engine/src/model/operation/nooperation.js

@@ -20,6 +20,18 @@ import Operation from './operation';
  * @extends module:engine/model/operation/operation~Operation
  */
 export default class NoOperation extends Operation {
+	/**
+	 * @inheritDoc
+	 */
+	constructor( baseVersion ) {
+		super( baseVersion );
+
+		/**
+		 * @inheritDoc
+		 */
+		this.isDocumentOperation = true;
+	}
+
 	get type() {
 		return 'noop';
 	}

+ 10 - 0
packages/ckeditor5-engine/src/model/operation/operation.js

@@ -45,6 +45,13 @@ export default class Operation {
 		 * @member {module:engine/model/delta/delta~Delta} #delta
 		 */
 
+		/**
+		 * Defines whether operation is executed on attached or detached {@link module:engine/model/item~Item items}.
+		 *
+		 * @readonly
+		 * @member {Boolean} #isDocumentOperation
+		 */
+
 		/**
 		 * Creates and returns an operation that has the same parameters as this operation.
 		 *
@@ -90,6 +97,9 @@ export default class Operation {
 		// Remove parent delta to avoid circular dependencies.
 		delete json.delta;
 
+		// Only document operations are shared with other clients so it is not necessary to keep this information.
+		delete json.isDocumentOperation;
+
 		return json;
 	}
 

+ 31 - 0
packages/ckeditor5-engine/src/model/operation/reinsertoperation.js

@@ -9,6 +9,7 @@
 
 import MoveOperation from './moveoperation';
 import RemoveOperation from './removeoperation';
+import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 
 /**
  * Operation to reinsert previously removed nodes back to the non-graveyard root. This operation acts like
@@ -17,6 +18,21 @@ import RemoveOperation from './removeoperation';
  * and fires different change event.
  */
 export default class ReinsertOperation extends MoveOperation {
+	/**
+	 * @inheritDocs
+	 */
+	constructor( sourcePosition, howMany, targetPosition, baseVersion ) {
+		super( sourcePosition, howMany, targetPosition, baseVersion );
+
+		/**
+		 * Reinsert operation is always executed on attached items.
+		 *
+		 * @readonly
+		 * @member {Boolean}
+		 */
+		this.isDocumentOperation = true;
+	}
+
 	/**
 	 * Position where nodes will be re-inserted.
 	 *
@@ -51,6 +67,21 @@ export default class ReinsertOperation extends MoveOperation {
 		return new RemoveOperation( this.getMovedRangeStart(), this.howMany, newTargetPosition, this.baseVersion + 1 );
 	}
 
+	/**
+	 * @inheritDoc
+	 */
+	_execute() {
+		if ( !this.sourcePosition.root.document ) {
+			throw new CKEditorError( 'reinsert-operation-on-detached-item: Cannot reinsert detached item.' );
+		}
+
+		if ( !this.targetPosition.root.document ) {
+			throw new CKEditorError( 'reinsert-operation-to-detached-parent: Cannot reinsert item to detached parent.' );
+		}
+
+		return super._execute();
+	}
+
 	/**
 	 * @inheritDoc
 	 */

+ 35 - 0
packages/ckeditor5-engine/src/model/operation/removeoperation.js

@@ -9,11 +9,28 @@
 
 import MoveOperation from './moveoperation';
 import ReinsertOperation from './reinsertoperation';
+import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 
 /**
  * Operation to remove a range of nodes.
  */
 export default class RemoveOperation extends MoveOperation {
+	/**
+	 * @inheritDocs
+	 */
+	constructor( sourcePosition, howMany, targetPosition, baseVersion ) {
+		super( sourcePosition, howMany, targetPosition, baseVersion );
+
+		/**
+		 * Remove operation cannot be applied on element that is not inside the document
+		 * so this will always be a document operation.
+		 *
+		 * @readonly
+		 * @member {Boolean}
+		 */
+		this.isDocumentOperation = true;
+	}
+
 	/**
 	 * @inheritDoc
 	 */
@@ -32,6 +49,24 @@ export default class RemoveOperation extends MoveOperation {
 		return new ReinsertOperation( this.getMovedRangeStart(), this.howMany, newTargetPosition, this.baseVersion + 1 );
 	}
 
+	/**
+	 * @inheritDoc
+	 */
+	_execute() {
+		if ( !this.sourcePosition.root.document ) {
+			/**
+			 * Item that is going to be removed needs to be a {@link module:engine/model/document~Document document} child.
+			 * To remove Item from detached document fragment use
+			 * {@link module:engine/model/operation/detachoperation~DetachOperation DetachOperation}.
+			 *
+			 * @error remove-operation-on-detached-item
+			 */
+			throw new CKEditorError( 'remove-operation-on-detached-item: Cannot remove detached item.' );
+		}
+
+		return super._execute();
+	}
+
 	/**
 	 * @inheritDoc
 	 */

+ 5 - 0
packages/ckeditor5-engine/src/model/operation/renameoperation.js

@@ -51,6 +51,11 @@ export default class RenameOperation extends Operation {
 		 * @member {String} module:engine/model/operation/renameoperation~RenameOperation#newName
 		 */
 		this.newName = newName;
+
+		/**
+		 * @inheritDoc
+		 */
+		this.isDocumentOperation = !!this.position.root.document;
 	}
 
 	/**

+ 8 - 0
packages/ckeditor5-engine/src/model/operation/rootattributeoperation.js

@@ -67,8 +67,16 @@ export default class RootAttributeOperation extends Operation {
 		 * @member {*}
 		 */
 		this.newValue = newValue;
+
+		/**
+		 * @inheritDoc
+		 */
+		this.isDocumentOperation = !!this.root.document;
 	}
 
+	/**
+	 * @inheritDoc
+	 */
 	get type() {
 		if ( this.oldValue === null ) {
 			return 'addRootAttribute';

+ 33 - 54
packages/ckeditor5-engine/src/model/writer.js → packages/ckeditor5-engine/src/model/operation/utils.js

@@ -4,54 +4,36 @@
  */
 
 /**
- * @module engine/model/writer
+ * @module engine/model/operation/utils
  */
 
-import Node from './node';
-import Text from './text';
-import TextProxy from './textproxy';
-import Range from './range';
-import DocumentFragment from './documentfragment';
-import NodeList from './nodelist';
+import Node from '../node';
+import Text from '../text';
+import TextProxy from '../textproxy';
+import Range from '../range';
+import DocumentFragment from '../documentfragment';
+import NodeList from '../nodelist';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 
 /**
- * Contains functions used for composing model tree, grouped together under "model writer" name. Those functions
- * are built on top of {@link module:engine/model/node~Node node}, and it's child classes', APIs.
+ * Contains functions used for composing model tree by {@link module:engine/model/operation/operation~Operation operations}.
+ * Those functions are built on top of {@link module:engine/model/node~Node node}, and it's child classes', APIs.
  *
- * Model writer API has multiple advantages and it is highly recommended to use it when changing model tree and nodes:
- * * model writer API {@link module:engine/model/writer~writer.normalizeNodes normalizes inserted nodes}, which means that you can insert
- * not only {@link module:engine/model/node~Node nodes}, but also `String`s, {@link module:engine/model/textproxy~TextProxy text proxies}
- * and
- * {@link module:engine/model/documentfragment~DocumentFragment document fragments},
- * * model writer API operates on {@link module:engine/model/position~Position positions}, which means that you have
- * better control over manipulating model tree as positions operate on offsets rather than indexes,
- * * model writer API automatically merges {@link module:engine/model/text~Text text nodes} with same attributes, which means
- * lower memory usage and better efficiency.
- *
- * @namespace writer
+ * @protected
+ * @namespace utils
  */
-const writer = {
-	insert,
-	remove,
-	move,
-	setAttribute,
-	removeAttribute,
-	normalizeNodes
-};
-
-export default writer;
 
 /**
  * Inserts given nodes at given position.
  *
- * @function module:engine/model/writer~writer.insert
+ * @protected
+ * @function module:engine/model/operation/utils~utils.insert
  * @param {module:engine/model/position~Position} position Position at which nodes should be inserted.
  * @param {module:engine/model/node~NodeSet} nodes Nodes to insert.
  * @returns {module:engine/model/range~Range} Range spanning over inserted elements.
  */
-export function insert( position, nodes ) {
-	nodes = normalizeNodes( nodes );
+export function _insert( position, nodes ) {
+	nodes = _normalizeNodes( nodes );
 
 	// We have to count offset before inserting nodes because they can get merged and we would get wrong offsets.
 	const offset = nodes.reduce( ( sum, node ) => sum + node.offsetSize, 0 );
@@ -75,18 +57,19 @@ export function insert( position, nodes ) {
 /**
  * Removed nodes in given range. Only {@link module:engine/model/range~Range#isFlat flat} ranges are accepted.
  *
- * @function module:engine/model/writer~writer.remove
+ * @protected
+ * @function module:engine/model/operation/utils~utils.remove
  * @param {module:engine/model/range~Range} range Range containing nodes to remove.
  * @returns {Array.<module:engine/model/node~Node>}
  */
-export function remove( range ) {
+export function _remove( range ) {
 	if ( !range.isFlat ) {
 		/**
 		 * Trying to remove a range which starts and ends in different element.
 		 *
-		 * @error model-writer-remove-range-not-flat
+		 * @error operation-utils-remove-range-not-flat
 		 */
-		throw new CKEditorError( 'model-writer-remove-range-not-flat: ' +
+		throw new CKEditorError( 'operation-utils-remove-range-not-flat: ' +
 			'Trying to remove a range which starts and ends in different element.' );
 	}
 
@@ -109,38 +92,42 @@ export function remove( range ) {
 /**
  * Moves nodes in given range to given target position. Only {@link module:engine/model/range~Range#isFlat flat} ranges are accepted.
  *
+ * @protected
+ * @function module:engine/model/operation/utils~utils.move
  * @param {module:engine/model/range~Range} sourceRange Range containing nodes to move.
  * @param {module:engine/model/position~Position} targetPosition Position to which nodes should be moved.
  * @returns {module:engine/model/range~Range} Range containing moved nodes.
  */
-export function move( sourceRange, targetPosition ) {
+export function _move( sourceRange, targetPosition ) {
 	if ( !sourceRange.isFlat ) {
 		/**
 		 * Trying to move a range which starts and ends in different element.
 		 *
-		 * @error model-writer-move-range-not-flat
+		 * @error operation-utils-move-range-not-flat
 		 */
-		throw new CKEditorError( 'model-writer-move-range-not-flat: ' +
+		throw new CKEditorError( 'operation-utils-move-range-not-flat: ' +
 			'Trying to move a range which starts and ends in different element.' );
 	}
 
-	const nodes = this.remove( sourceRange );
+	const nodes = _remove( sourceRange );
 
 	// We have to fix `targetPosition` because model changed after nodes from `sourceRange` got removed and
 	// that change might have an impact on `targetPosition`.
 	targetPosition = targetPosition._getTransformedByDeletion( sourceRange.start, sourceRange.end.offset - sourceRange.start.offset );
 
-	return this.insert( targetPosition, nodes );
+	return _insert( targetPosition, nodes );
 }
 
 /**
  * Sets given attribute on nodes in given range.
  *
+ * @protected
+ * @function module:engine/model/operation/utils~utils.setAttribute
  * @param {module:engine/model/range~Range} range Range containing nodes that should have the attribute set.
  * @param {String} key Key of attribute to set.
  * @param {*} value Attribute value.
  */
-export function setAttribute( range, key, value ) {
+export function _setAttribute( range, key, value ) {
 	// Range might start or end in text nodes, so we have to split them.
 	_splitNodeAtPosition( range.start );
 	_splitNodeAtPosition( range.end );
@@ -166,24 +153,16 @@ export function setAttribute( range, key, value ) {
 	_mergeNodesAtIndex( range.end.parent, range.end.index );
 }
 
-/**
- * Removes given attribute from nodes in given range.
- *
- * @param {module:engine/model/range~Range} range Range containing nodes that should have the attribute removed.
- * @param {String} key Key of attribute to remove.
- */
-export function removeAttribute( range, key ) {
-	this.setAttribute( range, key, null );
-}
-
 /**
  * Normalizes given object or an array of objects to an array of {@link module:engine/model/node~Node nodes}. See
  * {@link module:engine/model/node~NodeSet NodeSet} for details on how normalization is performed.
  *
+ * @protected
+ * @function module:engine/model/operation/utils~utils.normalizeNodes
  * @param {module:engine/model/node~NodeSet} nodes Objects to normalize.
  * @returns {Array.<module:engine/model/node~Node>} Normalized nodes.
  */
-export function normalizeNodes( nodes ) {
+export function _normalizeNodes( nodes ) {
 	const normalized = [];
 
 	if ( !( nodes instanceof Array ) ) {

+ 3 - 10
packages/ckeditor5-engine/src/model/schema.js

@@ -414,14 +414,11 @@ export default class Schema {
 	}
 
 	/**
-	 * Removes disallowed by {@link module:engine/model/schema~Schema schema} attributes from given nodes.
-	 * When {@link module:engine/model/batch~Batch batch} parameter is provided then attributes will be removed
-	 * using that batch, by creating {@link module:engine/model/delta/attributedelta~AttributeDelta attribute deltas}.
-	 * Otherwise, attributes will be removed directly from provided nodes using {@link module:engine/model/node~Node node} API.
+	 * Removes disallowed by {@link module:engine/model/schema~Schema schema} attributes from given nodes..
 	 *
 	 * @param {Iterable.<module:engine/model/node~Node>} nodes Nodes that will be filtered.
 	 * @param {module:engine/model/schema~SchemaPath} inside Path inside which schema will be checked.
-	 * @param {module:engine/model/batch~Batch} [batch] Batch to which the deltas will be added.
+	 * @param {module:engine/model/batch~Batch} batch Batch to which the deltas will be added.
 	 */
 	removeDisallowedAttributes( nodes, inside, batch ) {
 		for ( const node of nodes ) {
@@ -435,11 +432,7 @@ export default class Schema {
 				// TODO: this should be improved to check all combination of attributes.
 				for ( const attribute of node.getAttributeKeys() ) {
 					if ( !this.check( { name, attributes: attribute, inside: queryPath } ) ) {
-						if ( batch ) {
-							batch.removeAttribute( node, attribute );
-						} else {
-							node.removeAttribute( attribute );
-						}
+						batch.removeAttribute( attribute, node );
 					}
 				}
 			}

+ 3 - 1
packages/ckeditor5-engine/src/model/text.js

@@ -15,10 +15,12 @@ import Node from './node';
  * **Important:** see {@link module:engine/model/node~Node} to read about restrictions using `Text` and `Node` API.
  *
  * **Note:** keep in mind that `Text` instances might indirectly got removed from model tree when model is changed.
- * This happens when {@link module:engine/model/writer~writer model writer} is used to change model and the text node is merged with
+ * This happens when {@linkTODO module:engine/model/writer~writer model writer} is used to change model and the text node is merged with
  * another text node. Then, both text nodes are removed and a new text node is inserted into the model. Because of
  * this behavior, keeping references to `Text` is not recommended. Instead, consider creating
  * {@link module:engine/model/liveposition~LivePosition live position} placed before the text node.
+ *
+ * @extends {module:engine/model/node~Node}
  */
 export default class Text extends Node {
 	/**

+ 1 - 1
packages/ckeditor5-engine/src/model/textproxy.js

@@ -28,7 +28,7 @@ import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
  * parameter of methods.
  *
  * **Note:** `TextProxy` is a readonly interface. If you want to perform changes on model data represented by a `TextProxy`
- * use {@link module:engine/model/writer~writer model writer API}.
+ * use {@linkTODO module:engine/model/writer~writer model writer API}.
  *
  * **Note:** `TextProxy` instances are created on the fly, basing on the current state of model. Because of this, it is
  * highly unrecommended to store references to `TextProxy` instances. `TextProxy` instances are not refreshed when

+ 3 - 1
packages/ckeditor5-engine/src/view/domconverter.js

@@ -1061,7 +1061,9 @@ export default class DomConverter {
 
 		const direction = getNext ? 'nextNode' : 'previousNode';
 		const document = node.ownerDocument;
-		const treeWalker = document.createTreeWalker( document.childNodes[ 0 ], NodeFilter.SHOW_TEXT );
+		const topmostParent = getAncestors( node )[ 0 ];
+
+		const treeWalker = document.createTreeWalker( topmostParent, NodeFilter.SHOW_TEXT );
 
 		treeWalker.currentNode = node;
 

+ 1 - 1
packages/ckeditor5-engine/src/view/placeholder.js

@@ -10,7 +10,7 @@
 import extend from '@ckeditor/ckeditor5-utils/src/lib/lodash/extend';
 import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
-import '../../theme/placeholder.scss';
+import '../../theme/placeholder.css';
 
 const listener = {};
 extend( listener, EmitterMixin );

+ 24 - 16
packages/ckeditor5-engine/tests/controller/datacontroller.js

@@ -48,7 +48,7 @@ describe( 'DataController', () => {
 	describe( 'parse', () => {
 		it( 'should set text', () => {
 			schema.allow( { name: '$text', inside: '$root' } );
-			const model = data.parse( '<p>foo<b>bar</b></p>' );
+			const model = data.parse( '<p>foo<b>bar</b></p>', modelDocument.batch() );
 
 			expect( model ).to.instanceof( ModelDocumentFragment );
 			expect( stringify( model ) ).to.equal( 'foobar' );
@@ -59,7 +59,7 @@ describe( 'DataController', () => {
 
 			buildViewConverter().for( data.viewToModel ).fromElement( 'p' ).toElement( 'paragraph' );
 
-			const model = data.parse( '<p>foo<b>bar</b></p>' );
+			const model = data.parse( '<p>foo<b>bar</b></p>', modelDocument.batch() );
 
 			expect( model ).to.instanceof( ModelDocumentFragment );
 			expect( stringify( model ) ).to.equal( '<paragraph>foobar</paragraph>' );
@@ -70,7 +70,7 @@ describe( 'DataController', () => {
 
 			buildViewConverter().for( data.viewToModel ).fromElement( 'p' ).toElement( 'paragraph' );
 
-			const model = data.parse( '<p>foo</p><p>bar</p>' );
+			const model = data.parse( '<p>foo</p><p>bar</p>', modelDocument.batch() );
 
 			expect( model ).to.instanceof( ModelDocumentFragment );
 			expect( stringify( model ) ).to.equal( '<paragraph>foo</paragraph><paragraph>bar</paragraph>' );
@@ -83,20 +83,20 @@ describe( 'DataController', () => {
 			buildViewConverter().for( data.viewToModel ).fromElement( 'p' ).toElement( 'paragraph' );
 			buildViewConverter().for( data.viewToModel ).fromElement( 'b' ).toAttribute( 'bold', true );
 
-			const model = data.parse( '<p>foo<b>bar</b></p>' );
+			const model = data.parse( '<p>foo<b>bar</b></p>', modelDocument.batch() );
 
 			expect( model ).to.instanceof( ModelDocumentFragment );
 			expect( stringify( model ) ).to.equal( '<paragraph>foo<$text bold="true">bar</$text></paragraph>' );
 		} );
 
 		it( 'should parse in the root context by default', () => {
-			const model = data.parse( 'foo' );
+			const model = data.parse( 'foo', modelDocument.batch() );
 
 			expect( stringify( model ) ).to.equal( '' );
 		} );
 
 		it( 'should accept parsing context', () => {
-			const model = data.parse( 'foo', '$block' );
+			const model = data.parse( 'foo', modelDocument.batch(), '$block' );
 
 			expect( stringify( model ) ).to.equal( 'foo' );
 		} );
@@ -111,7 +111,7 @@ describe( 'DataController', () => {
 
 		it( 'should convert content of an element #1', () => {
 			const viewElement = parseView( '<p>foo</p>' );
-			const model = data.toModel( viewElement );
+			const model = data.toModel( viewElement, modelDocument.batch() );
 
 			expect( model ).to.instanceof( ModelDocumentFragment );
 			expect( stringify( model ) ).to.equal( '<paragraph>foo</paragraph>' );
@@ -119,7 +119,7 @@ describe( 'DataController', () => {
 
 		it( 'should convert content of an element #2', () => {
 			const viewFragment = parseView( '<p>foo</p><p>bar</p>' );
-			const model = data.toModel( viewFragment );
+			const model = data.toModel( viewFragment, modelDocument.batch() );
 
 			expect( model ).to.be.instanceOf( ModelDocumentFragment );
 			expect( stringify( model ) ).to.equal( '<paragraph>foo</paragraph><paragraph>bar</paragraph>' );
@@ -134,10 +134,10 @@ describe( 'DataController', () => {
 			const viewFragment = new ViewDocumentFragment( [ parseView( 'foo' ) ] );
 
 			// Model fragment in root.
-			expect( stringify( data.toModel( viewFragment ) ) ).to.equal( '' );
+			expect( stringify( data.toModel( viewFragment, modelDocument.batch() ) ) ).to.equal( '' );
 
 			// Model fragment in inline root.
-			expect( stringify( data.toModel( viewFragment, 'inlineRoot' ) ) ).to.equal( 'foo' );
+			expect( stringify( data.toModel( viewFragment, modelDocument.batch(), 'inlineRoot' ) ) ).to.equal( 'foo' );
 		} );
 	} );
 
@@ -264,6 +264,8 @@ describe( 'DataController', () => {
 	} );
 
 	describe( 'stringify', () => {
+		let batch;
+
 		beforeEach( () => {
 			modelDocument.schema.registerItem( 'paragraph', '$block' );
 			modelDocument.schema.registerItem( 'div' );
@@ -272,16 +274,18 @@ describe( 'DataController', () => {
 			modelDocument.schema.allow( { name: 'div', inside: '$root' } );
 
 			buildModelConverter().for( data.modelToView ).fromElement( 'paragraph' ).toElement( 'p' );
+
+			batch = modelDocument.batch();
 		} );
 
 		it( 'should stringify a content of an element', () => {
-			const modelElement = parseModel( '<div><paragraph>foo</paragraph></div>', modelDocument.schema );
+			const modelElement = parseModel( '<div><paragraph>foo</paragraph></div>', modelDocument.schema, batch );
 
 			expect( data.stringify( modelElement ) ).to.equal( '<p>foo</p>' );
 		} );
 
 		it( 'should stringify a content of a document fragment', () => {
-			const modelDocumentFragment = parseModel( '<paragraph>foo</paragraph><paragraph>bar</paragraph>', modelDocument.schema );
+			const modelDocumentFragment = parseModel( '<paragraph>foo</paragraph><paragraph>bar</paragraph>', modelDocument.schema, batch );
 
 			expect( data.stringify( modelDocumentFragment ) ).to.equal( '<p>foo</p><p>bar</p>' );
 		} );
@@ -299,7 +303,7 @@ describe( 'DataController', () => {
 		} );
 
 		it( 'should convert a content of an element', () => {
-			const modelElement = parseModel( '<div><paragraph>foo</paragraph></div>', modelDocument.schema );
+			const modelElement = parseModel( '<div><paragraph>foo</paragraph></div>', modelDocument.schema, modelDocument.batch() );
 
 			const viewDocumentFragment = data.toView( modelElement );
 
@@ -313,7 +317,11 @@ describe( 'DataController', () => {
 		} );
 
 		it( 'should convert a document fragment', () => {
-			const modelDocumentFragment = parseModel( '<paragraph>foo</paragraph><paragraph>bar</paragraph>', modelDocument.schema );
+			const modelDocumentFragment = parseModel(
+				'<paragraph>foo</paragraph><paragraph>bar</paragraph>',
+				modelDocument.schema,
+				modelDocument.batch()
+			);
 
 			const viewDocumentFragment = data.toView( modelDocumentFragment );
 
@@ -425,7 +433,7 @@ describe( 'DataController', () => {
 
 			data.on( 'getSelectedContent', spy );
 
-			data.getSelectedContent( sel );
+			data.getSelectedContent( sel, modelDocument.batch() );
 
 			expect( spy.calledOnce ).to.be.true;
 		} );
@@ -435,7 +443,7 @@ describe( 'DataController', () => {
 
 			setData( modelDocument, '<paragraph>fo[ob]ar</paragraph>' );
 
-			const content = data.getSelectedContent( modelDocument.selection );
+			const content = data.getSelectedContent( modelDocument.selection, modelDocument.batch() );
 
 			expect( stringify( content ) ).to.equal( 'ob' );
 		} );

+ 14 - 6
packages/ckeditor5-engine/tests/controller/editingcontroller.js

@@ -137,6 +137,8 @@ describe( 'EditingController', () => {
 			editing = new EditingController( model );
 
 			domRoot = document.createElement( 'div' );
+			domRoot.contentEditable = true;
+
 			document.body.appendChild( domRoot );
 			viewRoot = editing.createRoot( domRoot );
 
@@ -155,11 +157,12 @@ describe( 'EditingController', () => {
 				'<paragraph>foo</paragraph>' +
 				'<paragraph></paragraph>' +
 				'<paragraph>bar</paragraph>',
-				model.schema
+				model.schema,
+				model.batch()
 			)._children );
 
 			model.enqueueChanges( () => {
-				model.batch().insert( ModelPosition.createAt( model.getRoot(), 0 ), modelData );
+				model.batch().insert( modelData, model.getRoot() );
 				model.selection.addRange( ModelRange.createFromParentsAndOffsets(
 					modelRoot.getChild( 0 ), 1, modelRoot.getChild( 0 ), 1 ) );
 			} );
@@ -217,12 +220,15 @@ describe( 'EditingController', () => {
 					expect( getModelData( model ) ).to.equal(
 						'<paragraph>foo</paragraph>' +
 						'<paragraph></paragraph>' +
-						'<paragraph>b[a]r</paragraph>' );
+						'<paragraph>b[a]r</paragraph>'
+					);
+
 					done();
 				} );
 			} );
 
 			editing.view.isFocused = true;
+			editing.view.render();
 
 			const domSelection = document.getSelection();
 			domSelection.removeAllRanges();
@@ -375,7 +381,7 @@ describe( 'EditingController', () => {
 
 		it( 'should forward add marker event if content is moved into a marker range', () => {
 			model.enqueueChanges( () => {
-				model.batch().insert( ModelPosition.createAt( model.getRoot(), 'end' ), new ModelElement( 'paragraph' ) );
+				model.batch().appendElement( 'paragraph', model.getRoot() );
 			} );
 
 			const markerRange = ModelRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 3 );
@@ -409,9 +415,11 @@ describe( 'EditingController', () => {
 
 			editing.destroy();
 
+			const batch = model.batch();
+
 			model.enqueueChanges( () => {
-				const modelData = parse( '<paragraph>foo</paragraph>', model.schema ).getChild( 0 );
-				model.batch().insert( ModelPosition.createAt( model.getRoot(), 0 ), modelData );
+				const modelData = parse( '<paragraph>foo</paragraph>', model.schema, batch ).getChild( 0 );
+				batch.insert( modelData, model.getRoot() );
 			} );
 
 			expect( spy.called ).to.be.false;

+ 34 - 34
packages/ckeditor5-engine/tests/controller/getselectedcontent.js

@@ -30,7 +30,7 @@ describe( 'Delete utils', () => {
 			it( 'returns empty fragment for no selection', () => {
 				setData( doc, 'abc' );
 
-				const frag = getSelectedContent( doc.selection );
+				const frag = getSelectedContent( doc.selection, doc.batch() );
 
 				expect( frag ).instanceOf( DocumentFragment );
 				expect( frag.isEmpty ).to.be.true;
@@ -39,7 +39,7 @@ describe( 'Delete utils', () => {
 			it( 'returns empty fragment for empty selection', () => {
 				setData( doc, 'a[]bc' );
 
-				const frag = getSelectedContent( doc.selection );
+				const frag = getSelectedContent( doc.selection, doc.batch() );
 
 				expect( frag ).instanceOf( DocumentFragment );
 				expect( frag.isEmpty ).to.be.true;
@@ -48,7 +48,7 @@ describe( 'Delete utils', () => {
 			it( 'gets one character', () => {
 				setData( doc, 'a[b]c' );
 
-				const frag = getSelectedContent( doc.selection );
+				const frag = getSelectedContent( doc.selection, doc.batch() );
 				const content = stringify( frag );
 
 				expect( frag ).instanceOf( DocumentFragment );
@@ -58,49 +58,49 @@ describe( 'Delete utils', () => {
 			it( 'gets full text', () => {
 				setData( doc, '[abc]' );
 
-				const content = stringify( getSelectedContent( doc.selection ) );
+				const content = stringify( getSelectedContent( doc.selection, doc.batch() ) );
 				expect( content ).to.equal( 'abc' );
 			} );
 
 			it( 'gets text with an attribute', () => {
 				setData( doc, 'xxx<$text bold="true">a[b]c</$text>' );
 
-				const content = stringify( getSelectedContent( doc.selection ) );
+				const content = stringify( getSelectedContent( doc.selection, doc.batch() ) );
 				expect( content ).to.equal( '<$text bold="true">b</$text>' );
 			} );
 
 			it( 'gets text with attributes', () => {
 				setData( doc, 'x<$text bold="true">a[b</$text><$text italic="true">c]d</$text>x' );
 
-				const content = stringify( getSelectedContent( doc.selection ) );
+				const content = stringify( getSelectedContent( doc.selection, doc.batch() ) );
 				expect( content ).to.equal( '<$text bold="true">b</$text><$text italic="true">c</$text>' );
 			} );
 
 			it( 'gets text with and without attribute', () => {
 				setData( doc, '<$text bold="true">a[b</$text>c]d' );
 
-				const content = stringify( getSelectedContent( doc.selection ) );
+				const content = stringify( getSelectedContent( doc.selection, doc.batch() ) );
 				expect( content ).to.equal( '<$text bold="true">b</$text>c' );
 			} );
 
 			it( 'gets text and element', () => {
 				setData( doc, '[ab<image></image>c]' );
 
-				const content = stringify( getSelectedContent( doc.selection ) );
+				const content = stringify( getSelectedContent( doc.selection, doc.batch() ) );
 				expect( content ).to.equal( 'ab<image></image>c' );
 			} );
 
 			it( 'gets one element', () => {
 				setData( doc, 'a[<image></image>]b' );
 
-				const content = stringify( getSelectedContent( doc.selection ) );
+				const content = stringify( getSelectedContent( doc.selection, doc.batch() ) );
 				expect( content ).to.equal( '<image></image>' );
 			} );
 
 			it( 'gets multiple elements', () => {
 				setData( doc, '[<image></image><image></image>]' );
 
-				const content = stringify( getSelectedContent( doc.selection ) );
+				const content = stringify( getSelectedContent( doc.selection, doc.batch() ) );
 				expect( content ).to.equal( '<image></image><image></image>' );
 			} );
 		} );
@@ -128,63 +128,63 @@ describe( 'Delete utils', () => {
 			it( 'gets one character', () => {
 				setData( doc, '<paragraph>a[b]c</paragraph>' );
 
-				const content = stringify( getSelectedContent( doc.selection ) );
+				const content = stringify( getSelectedContent( doc.selection, doc.batch() ) );
 				expect( content ).to.equal( 'b' );
 			} );
 
 			it( 'gets entire paragraph content', () => {
 				setData( doc, '<paragraph>[a<image></image>b]</paragraph>' );
 
-				const content = stringify( getSelectedContent( doc.selection ) );
+				const content = stringify( getSelectedContent( doc.selection, doc.batch() ) );
 				expect( content ).to.equal( 'a<image></image>b' );
 			} );
 
 			it( 'gets two blocks - partial, partial', () => {
 				setData( doc, '<heading1>a[bc</heading1><paragraph>de]f</paragraph>' );
 
-				const content = stringify( getSelectedContent( doc.selection ) );
+				const content = stringify( getSelectedContent( doc.selection, doc.batch() ) );
 				expect( content ).to.equal( '<heading1>bc</heading1><paragraph>de</paragraph>' );
 			} );
 
 			it( 'gets two blocks - full, partial', () => {
 				setData( doc, '<heading1>[abc</heading1><paragraph>de]f</paragraph>' );
 
-				const content = stringify( getSelectedContent( doc.selection ) );
+				const content = stringify( getSelectedContent( doc.selection, doc.batch() ) );
 				expect( content ).to.equal( '<heading1>abc</heading1><paragraph>de</paragraph>' );
 			} );
 
 			it( 'gets two blocks - full, partial 2', () => {
 				setData( doc, '<heading1>[abc</heading1><paragraph>de<image></image>]f</paragraph>' );
 
-				const content = stringify( getSelectedContent( doc.selection ) );
+				const content = stringify( getSelectedContent( doc.selection, doc.batch() ) );
 				expect( content ).to.equal( '<heading1>abc</heading1><paragraph>de<image></image></paragraph>' );
 			} );
 
 			it( 'gets two blocks - full, partial 3', () => {
 				setData( doc, '<heading1>x</heading1><heading1>[abc</heading1><paragraph><image></image>de]f</paragraph>' );
 
-				const content = stringify( getSelectedContent( doc.selection ) );
+				const content = stringify( getSelectedContent( doc.selection, doc.batch() ) );
 				expect( content ).to.equal( '<heading1>abc</heading1><paragraph><image></image>de</paragraph>' );
 			} );
 
 			it( 'gets two blocks - full, partial 4', () => {
 				setData( doc, '<heading1>[abc</heading1><paragraph>de]f<image></image></paragraph>' );
 
-				const content = stringify( getSelectedContent( doc.selection ) );
+				const content = stringify( getSelectedContent( doc.selection, doc.batch() ) );
 				expect( content ).to.equal( '<heading1>abc</heading1><paragraph>de</paragraph>' );
 			} );
 
 			it( 'gets two blocks - partial, full', () => {
 				setData( doc, '<heading1>a[bc</heading1><paragraph>def]</paragraph>' );
 
-				const content = stringify( getSelectedContent( doc.selection ) );
+				const content = stringify( getSelectedContent( doc.selection, doc.batch() ) );
 				expect( content ).to.equal( '<heading1>bc</heading1><paragraph>def</paragraph>' );
 			} );
 
 			it( 'gets two blocks - partial, full 2', () => {
 				setData( doc, '<heading1>a[<image></image>bc</heading1><paragraph>def]</paragraph>' );
 
-				const content = stringify( getSelectedContent( doc.selection ) );
+				const content = stringify( getSelectedContent( doc.selection, doc.batch() ) );
 				expect( content ).to.equal( '<heading1><image></image>bc</heading1><paragraph>def</paragraph>' );
 			} );
 
@@ -192,7 +192,7 @@ describe( 'Delete utils', () => {
 			it( 'gets two blocks - empty, full', () => {
 				setData( doc, '<heading1>abc[</heading1><paragraph>def]</paragraph>' );
 
-				const content = stringify( getSelectedContent( doc.selection ) );
+				const content = stringify( getSelectedContent( doc.selection, doc.batch() ) );
 				expect( content ).to.equal( '<paragraph>def</paragraph>' );
 			} );
 
@@ -200,28 +200,28 @@ describe( 'Delete utils', () => {
 			it( 'gets two blocks - partial, empty', () => {
 				setData( doc, '<heading1>a[bc</heading1><paragraph>]def</paragraph>' );
 
-				const content = stringify( getSelectedContent( doc.selection ) );
+				const content = stringify( getSelectedContent( doc.selection, doc.batch() ) );
 				expect( content ).to.equal( '<heading1>bc</heading1>' );
 			} );
 
 			it( 'gets three blocks', () => {
 				setData( doc, '<heading1>a[bc</heading1><paragraph>x</paragraph><paragraph>de]f</paragraph>' );
 
-				const content = stringify( getSelectedContent( doc.selection ) );
+				const content = stringify( getSelectedContent( doc.selection, doc.batch() ) );
 				expect( content ).to.equal( '<heading1>bc</heading1><paragraph>x</paragraph><paragraph>de</paragraph>' );
 			} );
 
 			it( 'gets block image', () => {
 				setData( doc, '<paragraph>a</paragraph>[<blockImage><caption>Foo</caption></blockImage>]<paragraph>b</paragraph>' );
 
-				const content = stringify( getSelectedContent( doc.selection ) );
+				const content = stringify( getSelectedContent( doc.selection, doc.batch() ) );
 				expect( content ).to.equal( '<blockImage><caption>Foo</caption></blockImage>' );
 			} );
 
 			it( 'gets two blocks', () => {
 				setData( doc, '<paragraph>a</paragraph>[<blockImage></blockImage><blockImage></blockImage>]<paragraph>b</paragraph>' );
 
-				const content = stringify( getSelectedContent( doc.selection ) );
+				const content = stringify( getSelectedContent( doc.selection, doc.batch() ) );
 				expect( content ).to.equal( '<blockImage></blockImage><blockImage></blockImage>' );
 			} );
 
@@ -229,7 +229,7 @@ describe( 'Delete utils', () => {
 			it( 'gets content when multiple text items needs to be removed from the right excess', () => {
 				setData( doc, '<paragraph>a[b</paragraph><paragraph>c]d<$text bold="true">e</$text>f</paragraph>' );
 
-				const content = stringify( getSelectedContent( doc.selection ) );
+				const content = stringify( getSelectedContent( doc.selection, doc.batch() ) );
 				expect( content )
 					.to.equal( '<paragraph>b</paragraph><paragraph>c</paragraph>' );
 			} );
@@ -238,7 +238,7 @@ describe( 'Delete utils', () => {
 			it( 'gets content when multiple text items needs to be removed from the left excess', () => {
 				setData( doc, '<paragraph>a<$text bold="true">b</$text>c[d</paragraph><paragraph>e]f</paragraph>' );
 
-				const content = stringify( getSelectedContent( doc.selection ) );
+				const content = stringify( getSelectedContent( doc.selection, doc.batch() ) );
 				expect( content )
 					.to.equal( '<paragraph>d</paragraph><paragraph>e</paragraph>' );
 			} );
@@ -262,28 +262,28 @@ describe( 'Delete utils', () => {
 			it( 'gets content when ends are equally deeply nested', () => {
 				setData( doc, '<heading1>x</heading1><quote><paragraph>a[bc</paragraph><paragraph>de]f</paragraph></quote>' );
 
-				const content = stringify( getSelectedContent( doc.selection ) );
+				const content = stringify( getSelectedContent( doc.selection, doc.batch() ) );
 				expect( content ).to.equal( '<paragraph>bc</paragraph><paragraph>de</paragraph>' );
 			} );
 
 			it( 'gets content when left end nested deeper', () => {
 				setData( doc, '<quote><paragraph>a[bc</paragraph></quote><paragraph>de]f</paragraph>' );
 
-				const content = stringify( getSelectedContent( doc.selection ) );
+				const content = stringify( getSelectedContent( doc.selection, doc.batch() ) );
 				expect( content ).to.equal( '<quote><paragraph>bc</paragraph></quote><paragraph>de</paragraph>' );
 			} );
 
 			it( 'gets content when left end nested deeper 2', () => {
 				setData( doc, '<quote><paragraph>a[bc</paragraph><heading1>x</heading1></quote><paragraph>de]f</paragraph>' );
 
-				const content = stringify( getSelectedContent( doc.selection ) );
+				const content = stringify( getSelectedContent( doc.selection, doc.batch() ) );
 				expect( content ).to.equal( '<quote><paragraph>bc</paragraph><heading1>x</heading1></quote><paragraph>de</paragraph>' );
 			} );
 
 			it( 'gets content when left end nested deeper 3', () => {
 				setData( doc, '<quote><heading1>x</heading1><paragraph>a[bc</paragraph></quote><paragraph>de]f</paragraph>' );
 
-				const content = stringify( getSelectedContent( doc.selection ) );
+				const content = stringify( getSelectedContent( doc.selection, doc.batch() ) );
 				expect( content ).to.equal( '<quote><paragraph>bc</paragraph></quote><paragraph>de</paragraph>' );
 			} );
 
@@ -291,21 +291,21 @@ describe( 'Delete utils', () => {
 			it( 'gets content when left end nested deeper 4', () => {
 				setData( doc, '<quote><heading1>x[</heading1><paragraph>abc</paragraph></quote><paragraph>de]f</paragraph>' );
 
-				const content = stringify( getSelectedContent( doc.selection ) );
+				const content = stringify( getSelectedContent( doc.selection, doc.batch() ) );
 				expect( content ).to.equal( '<quote><paragraph>abc</paragraph></quote><paragraph>de</paragraph>' );
 			} );
 
 			it( 'gets content when right end nested deeper', () => {
 				setData( doc, '<paragraph>a[bc</paragraph><quote><paragraph>de]f</paragraph></quote>' );
 
-				const content = stringify( getSelectedContent( doc.selection ) );
+				const content = stringify( getSelectedContent( doc.selection, doc.batch() ) );
 				expect( content ).to.equal( '<paragraph>bc</paragraph><quote><paragraph>de</paragraph></quote>' );
 			} );
 
 			it( 'gets content when both ends nested deeper than the middle element', () => {
 				setData( doc, '<quote><heading1>a[bc</heading1></quote><heading1>x</heading1><quote><heading1>de]f</heading1></quote>' );
 
-				const content = stringify( getSelectedContent( doc.selection ) );
+				const content = stringify( getSelectedContent( doc.selection, doc.batch() ) );
 				expect( content )
 					.to.equal( '<quote><heading1>bc</heading1></quote><heading1>x</heading1><quote><heading1>de</heading1></quote>' );
 			} );
@@ -325,7 +325,7 @@ describe( 'Delete utils', () => {
 					'</quote>'
 				);
 
-				const content = stringify( getSelectedContent( doc.selection ) );
+				const content = stringify( getSelectedContent( doc.selection, doc.batch() ) );
 				expect( content )
 					.to.equal( '<paragraph>ar</paragraph>bo' );
 			} );

+ 4 - 2
packages/ckeditor5-engine/tests/controller/insertcontent.js

@@ -756,12 +756,14 @@ describe( 'DataController', () => {
 	//
 	// @param {module:engine/model/item~Item|String} content
 	function insertHelper( content ) {
+		const batch = doc.batch();
+
 		if ( typeof content == 'string' ) {
-			content = parse( content, doc.schema, {
+			content = parse( content, doc.schema, batch, {
 				context: [ '$clipboardHolder' ]
 			} );
 		}
 
-		insertContent( dataController, content, doc.selection );
+		insertContent( dataController, content, doc.selection, batch );
 	}
 } );

+ 23 - 32
packages/ckeditor5-engine/tests/conversion/advanced-converters.js

@@ -4,14 +4,12 @@
  */
 
 import ModelDocument from '../../src/model/document';
-import ModelDocumentFragment from '../../src/model/documentfragment';
 import ModelElement from '../../src/model/element';
 import ModelText from '../../src/model/text';
 import ModelTextProxy from '../../src/model/textproxy';
 import ModelRange from '../../src/model/range';
 import ModelPosition from '../../src/model/position';
 import ModelWalker from '../../src/model/treewalker';
-import modelWriter from '../../src/model/writer';
 
 import ViewElement from '../../src/view/element';
 import ViewContainerElement from '../../src/view/containerelement';
@@ -40,12 +38,13 @@ import { convertToModelFragment, convertText } from '../../src/conversion/view-t
 import { createRangeOnElementOnly } from '../../tests/model/_utils/utils';
 
 describe( 'advanced-converters', () => {
-	let modelDoc, modelRoot, viewRoot, mapper, modelDispatcher, viewDispatcher;
+	let modelDoc, modelRoot, viewRoot, mapper, modelDispatcher, viewDispatcher, batch;
 
 	beforeEach( () => {
 		modelDoc = new ModelDocument();
 		modelRoot = modelDoc.createRoot();
 		viewRoot = new ViewContainerElement( 'div' );
+		batch = modelDoc.batch();
 
 		mapper = new Mapper();
 		mapper.bindElements( modelRoot, viewRoot );
@@ -216,24 +215,18 @@ describe( 'advanced-converters', () => {
 				}
 			};
 
-			const viewImageConverter = function( evt, data, consumable ) {
+			const viewImageConverter = function( evt, data, consumable, conversionApi ) {
 				if ( consumable.consume( data.input, { name: true } ) ) {
-					const modelImage = new ModelElement( 'image' );
-
-					for ( const attributeKey of data.input.getAttributeKeys() ) {
-						modelImage.setAttribute( attributeKey, data.input.getAttribute( attributeKey ) );
-					}
-
-					data.output = modelImage;
+					data.output = conversionApi.batch.createElement( 'image', data.input.getAttributes() );
 				}
 			};
 
 			const viewFigcaptionConverter = function( evt, data, consumable, conversionApi ) {
 				if ( consumable.consume( data.input, { name: true } ) ) {
-					const modelCaption = new ModelElement( 'caption' );
+					const modelCaption = conversionApi.batch.createElement( 'caption' );
 					const children = conversionApi.convertChildren( data.input, consumable );
 
-					modelCaption.appendChildren( children );
+					conversionApi.batch.append( children, modelCaption );
 
 					data.output = modelCaption;
 				}
@@ -249,14 +242,14 @@ describe( 'advanced-converters', () => {
 		} );
 
 		it( 'should convert model images changes without caption to view', () => {
-			const modelElement = new ModelElement( 'image', { src: 'bar.jpg', title: 'bar' } );
-			modelRoot.appendChildren( modelElement );
+			const modelElement = batch.createElement( 'image', { src: 'bar.jpg', title: 'bar' } );
+			batch.append( modelElement, modelRoot );
 			modelDispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><img src="bar.jpg" title="bar"></img></div>' );
 
-			modelElement.setAttribute( 'src', 'new.jpg' );
-			modelElement.removeAttribute( 'title' );
+			batch.setAttribute( 'src', 'new.jpg', modelElement );
+			batch.removeAttribute( 'title', modelElement );
 			modelDispatcher.convertAttribute( 'changeAttribute', createRangeOnElementOnly( modelElement ), 'src', 'bar.jpg', 'new.jpg' );
 			modelDispatcher.convertAttribute( 'removeAttribute', createRangeOnElementOnly( modelElement ), 'title', 'bar', null );
 
@@ -286,7 +279,7 @@ describe( 'advanced-converters', () => {
 
 		it( 'should convert view image to model', () => {
 			const viewElement = new ViewContainerElement( 'img', { src: 'bar.jpg', title: 'bar' } );
-			const modelElement = viewDispatcher.convert( viewElement );
+			const modelElement = viewDispatcher.convert( viewElement, batch );
 
 			expect( modelToString( modelElement ) ).to.equal( '<image src="bar.jpg" title="bar"></image>' );
 		} );
@@ -300,7 +293,7 @@ describe( 'advanced-converters', () => {
 					new ViewContainerElement( 'figcaption', null, new ViewText( 'foobar' ) )
 				]
 			);
-			const modelElement = viewDispatcher.convert( viewElement );
+			const modelElement = viewDispatcher.convert( viewElement, batch );
 
 			expect( modelToString( modelElement ) ).to.equal( '<image src="bar.jpg" title="bar"><caption>foobar</caption></image>' );
 		} );
@@ -485,15 +478,16 @@ describe( 'advanced-converters', () => {
 			expect( viewToString( viewRoot ) ).to.equal( '<div><a href="foo.html" title="Foo title">foo</a></div>' );
 
 			// Let's change link's attributes.
-			modelWriter.setAttribute( range, 'linkHref', 'bar.html' );
-			modelWriter.setAttribute( range, 'linkTitle', 'Bar title' );
+			batch.setAttributes( {
+				linkHref: 'bar.html',
+				linkTitle: 'Bar title'
+			}, range );
 			modelDispatcher.convertAttribute( 'changeAttribute', range, 'linkHref', 'foo.html', 'bar.html' );
 			modelDispatcher.convertAttribute( 'changeAttribute', range, 'linkTitle', 'Foo title', 'Bar title' );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><a href="bar.html" title="Bar title">foo</a></div>' );
 
-			const removed = modelWriter.remove( ModelRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 1 ) );
-			modelDoc.graveyard.appendChildren( removed );
+			batch.remove( ModelRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 1 ) );
 			modelDispatcher.convertRemove(
 				ModelPosition.createFromParentAndOffset( modelRoot, 0 ),
 				ModelRange.createIn( modelDoc.graveyard )
@@ -504,13 +498,13 @@ describe( 'advanced-converters', () => {
 			range = ModelRange.createIn( modelRoot );
 
 			// Let's remove just one attribute.
-			modelWriter.removeAttribute( range, 'linkTitle' );
+			batch.removeAttribute( 'linkTitle', range );
 			modelDispatcher.convertAttribute( 'removeAttribute', range, 'linkTitle', 'Bar title', null );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><a href="bar.html">oo</a></div>' );
 
 			// Let's remove the other attribute.
-			modelWriter.removeAttribute( range, 'linkHref' );
+			batch.removeAttribute( 'linkHref', range );
 			modelDispatcher.convertAttribute( 'removeAttribute', range, 'linkHref', 'bar.html', null );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div>oo</div>' );
@@ -519,7 +513,7 @@ describe( 'advanced-converters', () => {
 		it( 'should convert a view element to model', () => {
 			const viewElement = new ViewAttributeElement( 'a', { href: 'foo.html', title: 'Foo title' }, new ViewText( 'foo' ) );
 
-			const modelText = viewDispatcher.convert( viewElement ).getChild( 0 );
+			const modelText = viewDispatcher.convert( viewElement, batch ).getChild( 0 );
 
 			expect( modelText ).to.be.instanceof( ModelText );
 			expect( modelText.data ).to.equal( 'foo' );
@@ -590,7 +584,7 @@ describe( 'advanced-converters', () => {
 				]
 			);
 
-			const modelElement = viewDispatcher.convert( viewElement );
+			const modelElement = viewDispatcher.convert( viewElement, batch );
 
 			expect( modelToString( modelElement ) ).to.equal( '<quote linkHref="foo.html" linkTitle="Foo source">foo</quote>' );
 		} );
@@ -653,10 +647,7 @@ describe( 'advanced-converters', () => {
 			] )
 		] );
 
-		const model = viewDispatcher.convert( viewTable );
-		const modelFragment = new ModelDocumentFragment( model );
-
-		expect( modelToString( modelFragment ) )
+		expect( modelToString( viewDispatcher.convert( viewTable, batch ) ) )
 			.to.equal( '<paragraph>foo <$text linkHref="bar.html">bar</$text></paragraph><paragraph>abc</paragraph>' );
 	} );
 
@@ -755,7 +746,7 @@ describe( 'advanced-converters', () => {
 				] )
 			] );
 
-			const modelElement = viewDispatcher.convert( viewElement );
+			const modelElement = viewDispatcher.convert( viewElement, batch );
 
 			expect( modelToString( modelElement ) ).to.equal(
 				'<table cellpadding="5" cellspacing="5">' +

+ 7 - 6
packages/ckeditor5-engine/tests/conversion/buildmodelconverter.js

@@ -10,7 +10,6 @@ import ModelElement from '../../src/model/element';
 import ModelText from '../../src/model/text';
 import ModelRange from '../../src/model/range';
 import ModelPosition from '../../src/model/position';
-import modelWriter from '../../src/model/writer';
 
 import ViewDocument from '../../src/view/document';
 import ViewElement from '../../src/view/element';
@@ -70,12 +69,14 @@ function viewToString( item ) {
 }
 
 describe( 'Model converter builder', () => {
-	let dispatcher, mapper, modelDoc, modelRoot, viewDoc, viewRoot, viewSelection;
+	let dispatcher, mapper, modelDoc, modelRoot, viewDoc, viewRoot, viewSelection, batch;
 
 	beforeEach( () => {
 		modelDoc = new ModelDocument();
 		modelRoot = modelDoc.createRoot( 'root', 'root' );
 
+		batch = modelDoc.batch();
+
 		viewDoc = new ViewDocument();
 		viewRoot = viewDoc.createRoot( 'div' );
 		viewSelection = viewDoc.selection;
@@ -145,7 +146,7 @@ describe( 'Model converter builder', () => {
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><strong>foo</strong></div>' );
 
-			modelWriter.removeAttribute( ModelRange.createIn( modelRoot ), 'bold' );
+			batch.removeAttribute( 'bold', modelRoot );
 
 			dispatcher.convertAttribute( 'removeAttribute', ModelRange.createIn( modelRoot ), 'bold', true, null );
 
@@ -162,7 +163,7 @@ describe( 'Model converter builder', () => {
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><strong>foo</strong></div>' );
 
-			modelWriter.removeAttribute( ModelRange.createIn( modelRoot ), 'bold' );
+			batch.removeAttribute( 'bold', modelRoot );
 
 			dispatcher.convertAttribute( 'removeAttribute', ModelRange.createIn( modelRoot ), 'bold', true, null );
 
@@ -179,13 +180,13 @@ describe( 'Model converter builder', () => {
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><em>foo</em></div>' );
 
-			modelWriter.setAttribute( ModelRange.createIn( modelRoot ), 'italic', 'i' );
+			batch.setAttribute( 'italic', 'i', modelRoot );
 
 			dispatcher.convertAttribute( 'changeAttribute', ModelRange.createIn( modelRoot ), 'italic', 'em', 'i' );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><i>foo</i></div>' );
 
-			modelWriter.removeAttribute( ModelRange.createIn( modelRoot ), 'italic' );
+			batch.removeAttribute( 'italic', modelRoot );
 
 			dispatcher.convertAttribute( 'removeAttribute', ModelRange.createIn( modelRoot ), 'italic', 'i', null );
 

+ 41 - 33
packages/ckeditor5-engine/tests/conversion/buildviewconverter.js

@@ -6,6 +6,7 @@
 import buildViewConverter from '../../src/conversion/buildviewconverter';
 
 import ModelSchema from '../../src/model/schema';
+import ModelDocument from '../../src/model/document';
 import ModelDocumentFragment from '../../src/model/documentfragment';
 import ModelElement from '../../src/model/element';
 import ModelTextProxy from '../../src/model/textproxy';
@@ -63,11 +64,15 @@ const textAttributes = [ undefined, 'linkHref', 'linkTitle', 'bold', 'italic', '
 const pAttributes = [ undefined, 'class', 'important', 'theme', 'decorated', 'size' ];
 
 describe( 'View converter builder', () => {
-	let dispatcher, schema, objWithContext;
+	let dispatcher, schema, additionalData, batch;
+
+	const modelDocument = new ModelDocument();
 
 	beforeEach( () => {
+		batch = modelDocument.batch();
+
 		// `additionalData` parameter for `.convert` calls.
-		objWithContext = { context: [ '$root' ] };
+		additionalData = { context: [ '$root' ] };
 
 		schema = new ModelSchema();
 
@@ -95,7 +100,7 @@ describe( 'View converter builder', () => {
 	it( 'should convert from view element to model element', () => {
 		buildViewConverter().for( dispatcher ).fromElement( 'p' ).toElement( 'paragraph' );
 
-		const conversionResult = dispatcher.convert( new ViewContainerElement( 'p', null, new ViewText( 'foo' ) ), objWithContext );
+		const conversionResult = dispatcher.convert( new ViewContainerElement( 'p', null, new ViewText( 'foo' ) ), batch, additionalData );
 
 		expect( modelToString( conversionResult ) ).to.equal( '<paragraph>foo</paragraph>' );
 	} );
@@ -105,7 +110,7 @@ describe( 'View converter builder', () => {
 			.fromElement( 'img' )
 			.toElement( viewElement => new ModelElement( 'image', { src: viewElement.getAttribute( 'src' ) } ) );
 
-		const conversionResult = dispatcher.convert( new ViewContainerElement( 'img', { src: 'foo.jpg' } ), objWithContext );
+		const conversionResult = dispatcher.convert( new ViewContainerElement( 'img', { src: 'foo.jpg' } ), batch, additionalData );
 
 		expect( modelToString( conversionResult ) ).to.equal( '<image src="foo.jpg"></image>' );
 	} );
@@ -114,7 +119,7 @@ describe( 'View converter builder', () => {
 		buildViewConverter().for( dispatcher ).fromElement( 'strong' ).toAttribute( 'bold', true );
 
 		const conversionResult = dispatcher.convert(
-			new ViewAttributeElement( 'strong', null, new ViewText( 'foo' ) ), objWithContext
+			new ViewAttributeElement( 'strong', null, new ViewText( 'foo' ) ), batch, additionalData
 		);
 
 		// Have to check root because result is a ModelText.
@@ -127,7 +132,7 @@ describe( 'View converter builder', () => {
 			.toAttribute( viewElement => ( { key: 'linkHref', value: viewElement.getAttribute( 'href' ) } ) );
 
 		const conversionResult = dispatcher.convert(
-			new ViewAttributeElement( 'a', { href: 'foo.html' }, new ViewText( 'foo' ) ), objWithContext
+			new ViewAttributeElement( 'a', { href: 'foo.html' }, new ViewText( 'foo' ) ), batch, additionalData
 		);
 
 		// Have to check root because result is a ModelText.
@@ -142,7 +147,7 @@ describe( 'View converter builder', () => {
 			.toAttribute( viewElement => ( { key: 'class', value: viewElement.getAttribute( 'class' ) } ) );
 
 		const conversionResult = dispatcher.convert(
-			new ViewContainerElement( 'p', { class: 'myClass' }, new ViewText( 'foo' ) ), objWithContext
+			new ViewContainerElement( 'p', { class: 'myClass' }, new ViewText( 'foo' ) ), batch, additionalData
 		);
 
 		expect( modelToString( conversionResult ) ).to.equal( '<paragraph class="myClass">foo</paragraph>' );
@@ -164,7 +169,7 @@ describe( 'View converter builder', () => {
 			new ViewContainerElement( 'p', { 'data-type': 'foo' }, new ViewText( 'xyz' ) )
 		] );
 
-		const conversionResult = dispatcher.convert( viewStructure, objWithContext );
+		const conversionResult = dispatcher.convert( viewStructure, batch, additionalData );
 
 		expect( modelToString( conversionResult ) ).to.equal(
 			'<paragraph important="true">foo</paragraph>' +
@@ -190,7 +195,7 @@ describe( 'View converter builder', () => {
 			new ViewContainerElement( 'span', { style: 'font-weight:bold; font-size:20px' }, new ViewText( 'ddd' ) )
 		] );
 
-		const conversionResult = dispatcher.convert( viewElement, objWithContext );
+		const conversionResult = dispatcher.convert( viewElement, batch, additionalData );
 
 		expect( modelToString( conversionResult ) ).to.equal( '<paragraph><$text bold="true">aaabbbcccddd</$text></paragraph>' );
 	} );
@@ -207,7 +212,7 @@ describe( 'View converter builder', () => {
 			new ViewText( 'r' )
 		] );
 
-		const conversionResult = dispatcher.convert( viewElement, objWithContext );
+		const conversionResult = dispatcher.convert( viewElement, batch, additionalData );
 
 		const markerSearch = conversionResult.markers.get( 'search' );
 
@@ -229,7 +234,7 @@ describe( 'View converter builder', () => {
 			new ViewText( 'r' )
 		] );
 
-		const conversionResult = dispatcher.convert( viewElement, objWithContext );
+		const conversionResult = dispatcher.convert( viewElement, batch, additionalData );
 
 		const markerSearch = conversionResult.markers.get( 'search' );
 
@@ -255,7 +260,7 @@ describe( 'View converter builder', () => {
 			new ViewText( 'r' )
 		] );
 
-		const conversionResult = dispatcher.convert( viewElement, objWithContext );
+		const conversionResult = dispatcher.convert( viewElement, batch, additionalData );
 
 		const marker1 = conversionResult.markers.get( 'marker1' );
 		const marker2 = conversionResult.markers.get( 'marker2' );
@@ -272,7 +277,7 @@ describe( 'View converter builder', () => {
 
 		const element = new ViewAttributeElement( 'span' );
 
-		const result = dispatcher.convert( element, objWithContext );
+		const result = dispatcher.convert( element, batch, additionalData );
 
 		expect( result ).to.be.instanceof( ModelDocumentFragment );
 		expect( result.childCount ).to.equal( 0 );
@@ -284,7 +289,7 @@ describe( 'View converter builder', () => {
 		const element = new ViewAttributeElement( 'marker', { class: 'search' } );
 
 		expect( () => {
-			dispatcher.convert( element, objWithContext );
+			dispatcher.convert( element, batch, additionalData );
 		} ).to.throw( CKEditorError, /^build-view-converter-invalid-marker/ );
 	} );
 
@@ -296,7 +301,7 @@ describe( 'View converter builder', () => {
 		const element = new ViewAttributeElement( 'marker', { 'data-name': 'search' } );
 
 		expect( () => {
-			dispatcher.convert( element, objWithContext );
+			dispatcher.convert( element, batch, additionalData );
 		} ).to.throw( CKEditorError, /^build-view-converter-invalid-marker/ );
 	} );
 
@@ -308,7 +313,7 @@ describe( 'View converter builder', () => {
 		const element = new ViewAttributeElement( 'marker', { 'data-name': 'search' } );
 
 		expect( () => {
-			dispatcher.convert( element, objWithContext );
+			dispatcher.convert( element, batch, additionalData );
 		} ).to.throw( CKEditorError, /^build-view-converter-invalid-marker/ );
 	} );
 
@@ -325,7 +330,7 @@ describe( 'View converter builder', () => {
 
 		// Not quite megatron.
 		result = dispatcher.convert(
-			new ViewContainerElement( 'span', { class: 'megatron' }, new ViewText( 'foo' ) ), objWithContext
+			new ViewContainerElement( 'span', { class: 'megatron' }, new ViewText( 'foo' ) ), batch, additionalData
 		);
 
 		expect( modelToString( result ) ).to.equal( '<span>foo</span>' );
@@ -333,7 +338,8 @@ describe( 'View converter builder', () => {
 		// Almost a megatron. Missing a head.
 		result = dispatcher.convert(
 			new ViewContainerElement( 'span', { class: 'megatron', body: 'megatron', legs: 'megatron' }, new ViewText( 'foo' ) ),
-			objWithContext
+			batch,
+			additionalData
 		);
 
 		expect( modelToString( result ) ).to.equal( '<span>foo</span>' );
@@ -345,7 +351,8 @@ describe( 'View converter builder', () => {
 				{ class: 'megatron', body: 'megatron', legs: 'megatron', head: 'megatron' },
 				new ViewText( 'foo' )
 			),
-			objWithContext
+			batch,
+			additionalData
 		);
 
 		expect( modelToString( result ) ).to.equal( '<paragraph>foo</paragraph>' );
@@ -357,7 +364,8 @@ describe( 'View converter builder', () => {
 				{ class: 'megatron', body: 'megatron', legs: 'megatron', head: 'megatron' },
 				new ViewText( 'foo' )
 			),
-			objWithContext
+			batch,
+			additionalData
 		);
 
 		expect( modelToString( result ) ).to.equal( '<MEGATRON>foo</MEGATRON>' );
@@ -377,7 +385,7 @@ describe( 'View converter builder', () => {
 			new ViewText( 'foo' )
 		);
 
-		const conversionResult = dispatcher.convert( viewElement, objWithContext );
+		const conversionResult = dispatcher.convert( viewElement, batch, additionalData );
 
 		expect( modelToString( conversionResult ) ).to.equal( '<span transformer="megatron">foo</span>' );
 	} );
@@ -387,7 +395,7 @@ describe( 'View converter builder', () => {
 
 		const viewElement = new ViewAttributeElement( 'strong', null, new ViewText( 'foo' ) );
 
-		const conversionResult = dispatcher.convert( viewElement, objWithContext );
+		const conversionResult = dispatcher.convert( viewElement, batch, additionalData );
 
 		expect( conversionResult.is( 'documentFragment' ) ).to.be.true;
 	} );
@@ -399,7 +407,7 @@ describe( 'View converter builder', () => {
 		buildViewConverter().for( dispatcher ).fromElement( 'p' ).toElement( 'paragraph' );
 
 		const conversionResult = dispatcher.convert(
-			new ViewContainerElement( 'p', { class: 'myClass' }, new ViewText( 'foo' ) ), objWithContext
+			new ViewContainerElement( 'p', { class: 'myClass' }, new ViewText( 'foo' ) ), batch, additionalData
 		);
 
 		// Element converter was fired first even though attribute converter was added first.
@@ -415,7 +423,7 @@ describe( 'View converter builder', () => {
 		let result;
 
 		result = dispatcher.convert(
-			new ViewContainerElement( 'p', { class: 'myClass' }, new ViewText( 'foo' ) ), objWithContext
+			new ViewContainerElement( 'p', { class: 'myClass' }, new ViewText( 'foo' ) ), batch, additionalData
 		);
 
 		expect( modelToString( result ) ).to.equal( '<paragraph class="myClass">foo</paragraph>' );
@@ -425,7 +433,7 @@ describe( 'View converter builder', () => {
 			.toElement( 'customP' );
 
 		result = dispatcher.convert(
-			new ViewContainerElement( 'p', { class: 'myClass' }, new ViewText( 'foo' ) ), objWithContext
+			new ViewContainerElement( 'p', { class: 'myClass' }, new ViewText( 'foo' ) ), batch, additionalData
 		);
 
 		expect( modelToString( result ) ).to.equal( '<customP>foo</customP>' );
@@ -446,7 +454,7 @@ describe( 'View converter builder', () => {
 			.toAttribute( 'size', 'small' );
 
 		const viewElement = new ViewContainerElement( 'p', { class: 'decorated small' }, new ViewText( 'foo' ) );
-		const conversionResult = dispatcher.convert( viewElement, objWithContext );
+		const conversionResult = dispatcher.convert( viewElement, batch, additionalData );
 
 		// P element and it's children got converted by the converter (1) and the converter (1) got fired
 		// because P name was not consumed in converter (2). Converter (3) could consume class="small" because
@@ -469,7 +477,7 @@ describe( 'View converter builder', () => {
 			new ViewContainerElement( 'abcd', null, new ViewText( 'foo' ) )
 		] );
 
-		const conversionResult = dispatcher.convert( viewStructure, objWithContext );
+		const conversionResult = dispatcher.convert( viewStructure, batch, additionalData );
 
 		expect( modelToString( conversionResult ) ).to.equal( '<div class="myClass"><abcd>foo</abcd></div>' );
 	} );
@@ -488,7 +496,7 @@ describe( 'View converter builder', () => {
 			)
 		);
 
-		const conversionResult = dispatcher.convert( viewElement, objWithContext );
+		const conversionResult = dispatcher.convert( viewElement, batch, additionalData );
 
 		expect( modelToString( conversionResult ) ).to.equal( '<paragraph>foo</paragraph>' );
 	} );
@@ -507,7 +515,7 @@ describe( 'View converter builder', () => {
 			)
 		);
 
-		const conversionResult = dispatcher.convert( viewElement, objWithContext );
+		const conversionResult = dispatcher.convert( viewElement, batch, additionalData );
 
 		expect( modelToString( conversionResult ) ).to.equal( '<paragraph>foo</paragraph>' );
 	} );
@@ -521,11 +529,11 @@ describe( 'View converter builder', () => {
 			} );
 
 		const viewElement = new ViewContainerElement( 'p' );
-		let conversionResult = dispatcher.convert( viewElement, objWithContext );
+		let conversionResult = dispatcher.convert( viewElement, batch, additionalData );
 		expect( modelToString( conversionResult ) ).to.equal( '<paragraph></paragraph>' );
 
 		viewElement.setAttribute( 'stop', true );
-		conversionResult = dispatcher.convert( viewElement, objWithContext );
+		conversionResult = dispatcher.convert( viewElement, batch, additionalData );
 
 		expect( conversionResult ).to.be.instanceof( ModelDocumentFragment );
 		expect( conversionResult.childCount ).to.equal( 0 );
@@ -543,11 +551,11 @@ describe( 'View converter builder', () => {
 		} );
 
 		const viewElement = new ViewContainerElement( 'p', { 'data-type': 'foo' } );
-		let conversionResult = dispatcher.convert( viewElement, objWithContext );
+		let conversionResult = dispatcher.convert( viewElement, batch, additionalData );
 		expect( modelToString( conversionResult ) ).to.equal( '<paragraph type="foo"></paragraph>' );
 
 		viewElement.setAttribute( 'data-type', 'stop' );
-		conversionResult = dispatcher.convert( viewElement, objWithContext );
+		conversionResult = dispatcher.convert( viewElement, batch, additionalData );
 		expect( modelToString( conversionResult ) ).to.equal( '<paragraph></paragraph>' );
 	} );
 } );

+ 14 - 14
packages/ckeditor5-engine/tests/conversion/model-selection-to-view-converters.js

@@ -528,9 +528,9 @@ describe( 'model-selection-to-view-converters', () => {
 		beforeEach( () => {
 			function themeElementCreator( themeValue ) {
 				if ( themeValue == 'important' ) {
-					return new ViewAttributeElement( 'strong', { style: 'text-transform:uppercase;' } );
+					return new ViewAttributeElement( 'strong', { style: 'text-transform:uppercase' } );
 				} else if ( themeValue == 'gold' ) {
-					return new ViewAttributeElement( 'span', { style: 'color:yellow;' } );
+					return new ViewAttributeElement( 'span', { style: 'color:yellow' } );
 				}
 			}
 
@@ -545,7 +545,7 @@ describe( 'model-selection-to-view-converters', () => {
 				test(
 					[ 1, 5 ],
 					'fo<$text theme="gold">ob</$text>ar',
-					'f{o<span style="color:yellow;">ob</span>a}r'
+					'f{o<span style="color:yellow">ob</span>a}r'
 				);
 			} );
 
@@ -553,7 +553,7 @@ describe( 'model-selection-to-view-converters', () => {
 				test(
 					[ 2, 4 ],
 					'f<$text theme="gold">ooba</$text>r',
-					'f<span style="color:yellow;">o{ob}a</span>r'
+					'f<span style="color:yellow">o{ob}a</span>r'
 				);
 			} );
 
@@ -561,7 +561,7 @@ describe( 'model-selection-to-view-converters', () => {
 				test(
 					[ 2, 4 ],
 					'fo<$text theme="important">ob</$text>ar',
-					'fo{<strong style="text-transform:uppercase;">ob</strong>}ar'
+					'fo{<strong style="text-transform:uppercase">ob</strong>}ar'
 				);
 			} );
 
@@ -569,7 +569,7 @@ describe( 'model-selection-to-view-converters', () => {
 				test(
 					[ 3, 5 ],
 					'fo<$text theme="important">ob</$text>ar',
-					'fo<strong style="text-transform:uppercase;">o{b</strong>a}r'
+					'fo<strong style="text-transform:uppercase">o{b</strong>a}r'
 				);
 			} );
 		} );
@@ -579,7 +579,7 @@ describe( 'model-selection-to-view-converters', () => {
 				test(
 					[ 3, 3 ],
 					'f<$text theme="gold">ooba</$text>r',
-					'f<span style="color:yellow;">oo{}ba</span>r'
+					'f<span style="color:yellow">oo{}ba</span>r'
 				);
 			} );
 
@@ -587,7 +587,7 @@ describe( 'model-selection-to-view-converters', () => {
 				test(
 					[ 1, 1 ],
 					'foobar',
-					'f<strong style="text-transform:uppercase;">[]</strong>oobar',
+					'f<strong style="text-transform:uppercase">[]</strong>oobar',
 					{ theme: 'important' }
 				);
 			} );
@@ -596,9 +596,9 @@ describe( 'model-selection-to-view-converters', () => {
 				test(
 					[ 3, 3 ],
 					'f<$text theme="gold">ooba</$text>r',
-					'f<span style="color:yellow;">oo</span>' +
-					'<em><span style="color:yellow;">[]</span></em>' +
-					'<span style="color:yellow;">ba</span>r',
+					'f<span style="color:yellow">oo</span>' +
+					'<em><span style="color:yellow">[]</span></em>' +
+					'<span style="color:yellow">ba</span>r',
 					{ italic: true }
 				);
 			} );
@@ -611,9 +611,9 @@ describe( 'model-selection-to-view-converters', () => {
 				test(
 					[ 3, 3 ],
 					'f<$text theme="gold">ooba</$text>r',
-					'f<span style="color:yellow;">oo</span>' +
-					'<strong style="text-transform:uppercase;">[]</strong>' +
-					'<span style="color:yellow;">ba</span>r',
+					'f<span style="color:yellow">oo</span>' +
+					'<strong style="text-transform:uppercase">[]</strong>' +
+					'<span style="color:yellow">ba</span>r',
 					{ theme: 'important' }
 				);
 			} );

+ 16 - 33
packages/ckeditor5-engine/tests/conversion/model-to-view-converters.js

@@ -8,7 +8,6 @@ import ModelElement from '../../src/model/element';
 import ModelText from '../../src/model/text';
 import ModelRange from '../../src/model/range';
 import ModelPosition from '../../src/model/position';
-import modelWriter from '../../src/model/writer';
 
 import ViewElement from '../../src/view/element';
 import ViewContainerElement from '../../src/view/containerelement';
@@ -36,13 +35,15 @@ import {
 import { createRangeOnElementOnly } from '../../tests/model/_utils/utils';
 
 describe( 'model-to-view-converters', () => {
-	let dispatcher, modelDoc, modelRoot, mapper, viewRoot;
+	let dispatcher, modelDoc, modelRoot, mapper, viewRoot, batch;
 
 	beforeEach( () => {
 		modelDoc = new ModelDocument();
 		modelRoot = modelDoc.createRoot();
 		viewRoot = new ViewContainerElement( 'div' );
 
+		batch = modelDoc.batch();
+
 		mapper = new Mapper();
 		mapper.bindElements( modelRoot, viewRoot );
 
@@ -537,7 +538,7 @@ describe( 'model-to-view-converters', () => {
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><p><b>foobar</b></p></div>' );
 
-			modelWriter.removeAttribute( ModelRange.createIn( modelElement ), 'bold' );
+			batch.removeAttribute( 'bold', modelElement );
 
 			dispatcher.convertAttribute( 'removeAttribute', ModelRange.createIn( modelElement ), 'bold', true, null );
 
@@ -564,7 +565,7 @@ describe( 'model-to-view-converters', () => {
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><p><b>foobar</b></p></div>' );
 
-			modelWriter.removeAttribute( ModelRange.createIn( modelElement ), 'style' );
+			batch.removeAttribute( 'style', modelElement );
 
 			dispatcher.convertAttribute( 'removeAttribute', ModelRange.createIn( modelElement ), 'style', 'bold', null );
 
@@ -594,7 +595,7 @@ describe( 'model-to-view-converters', () => {
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><p>x<a href="http://foo.com">foo</a>x</p></div>' );
 
-			modelWriter.setAttribute( ModelRange.createIn( modelElement ), 'link', 'http://foobar.com' );
+			batch.setAttribute( 'link', 'http://foobar.com', modelElement );
 
 			dispatcher.convertAttribute(
 				'changeAttribute',
@@ -622,7 +623,7 @@ describe( 'model-to-view-converters', () => {
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><p>நி<b>லைக்</b>கு</p></div>' );
 
-			modelWriter.removeAttribute( ModelRange.createIn( modelElement ), 'bold' );
+			batch.removeAttribute( 'bold', modelElement );
 
 			dispatcher.convertAttribute( 'removeAttribute', ModelRange.createIn( modelElement ), 'bold', true, null );
 
@@ -665,7 +666,7 @@ describe( 'model-to-view-converters', () => {
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><p><b>foobar</b></p></div>' );
 
-			modelWriter.removeAttribute( ModelRange.createIn( modelElement ), 'bold' );
+			batch.removeAttribute( 'bold', modelElement );
 
 			dispatcher.convertAttribute( 'removeAttribute', ModelRange.createIn( modelElement ), 'bold', true, null );
 
@@ -1011,10 +1012,7 @@ describe( 'model-to-view-converters', () => {
 
 			dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
 
-			modelWriter.move(
-				ModelRange.createFromParentsAndOffsets( modelDiv, 0, modelDiv, 6 ),
-				ModelPosition.createAt( modelDoc.graveyard, 'end' )
-			);
+			batch.remove( ModelRange.createFromParentsAndOffsets( modelDiv, 0, modelDiv, 6 ) );
 
 			dispatcher.convertRemove(
 				ModelPosition.createFromParentAndOffset( modelDiv, 0 ),
@@ -1035,10 +1033,7 @@ describe( 'model-to-view-converters', () => {
 			dispatcher.on( 'remove', remove() );
 
 			// Remove 'b'.
-			modelWriter.move(
-				ModelRange.createFromParentsAndOffsets( modelRoot, 3, modelRoot, 4 ),
-				ModelPosition.createAt( modelDoc.graveyard, 'end' )
-			);
+			batch.remove( ModelRange.createFromParentsAndOffsets( modelRoot, 3, modelRoot, 4 ) );
 
 			dispatcher.convertRemove(
 				ModelPosition.createFromParentAndOffset( modelRoot, 3 ),
@@ -1059,10 +1054,7 @@ describe( 'model-to-view-converters', () => {
 			dispatcher.on( 'remove', remove() );
 
 			// Remove 'o<span></span>b'.
-			modelWriter.move(
-				ModelRange.createFromParentsAndOffsets( modelRoot, 2, modelRoot, 4 ),
-				ModelPosition.createAt( modelDoc.graveyard, 'end' )
-			);
+			batch.remove( ModelRange.createFromParentsAndOffsets( modelRoot, 2, modelRoot, 4 ) );
 
 			dispatcher.convertRemove(
 				ModelPosition.createFromParentAndOffset( modelRoot, 2 ),
@@ -1084,7 +1076,7 @@ describe( 'model-to-view-converters', () => {
 			dispatcher.on( 'remove', remove() );
 
 			// Move <a></a> after "b". Can be e.g. a part of an unwrap delta (move + remove).
-			modelWriter.move(
+			batch.move(
 				ModelRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 1 ),
 				ModelPosition.createAt( modelRoot, 'end' )
 			);
@@ -1111,11 +1103,8 @@ describe( 'model-to-view-converters', () => {
 
 			dispatcher.on( 'remove', remove() );
 
-			// Move <a></a> to graveyard.
-			modelWriter.move(
-				ModelRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 1 ),
-				ModelPosition.createAt( modelDoc.graveyard, 'end' )
-			);
+			// Remove <a></a>.
+			batch.remove( ModelRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 1 ) );
 
 			dispatcher.convertRemove(
 				ModelPosition.createFromParentAndOffset( modelRoot, 0 ),
@@ -1188,10 +1177,7 @@ describe( 'model-to-view-converters', () => {
 
 			dispatcher.on( 'remove', remove() );
 
-			modelWriter.move(
-				ModelRange.createFromParentsAndOffsets( modelRoot, 1, modelRoot, 2 ),
-				ModelPosition.createAt( modelDoc.graveyard, 'end' )
-			);
+			batch.remove( ModelRange.createFromParentsAndOffsets( modelRoot, 1, modelRoot, 2 ) );
 
 			dispatcher.convertRemove(
 				ModelPosition.createFromParentAndOffset( modelRoot, 1 ),
@@ -1215,10 +1201,7 @@ describe( 'model-to-view-converters', () => {
 
 			dispatcher.on( 'remove', remove() );
 
-			modelWriter.move(
-				ModelRange.createFromParentsAndOffsets( modelRoot, 3, modelRoot, 4 ),
-				ModelPosition.createAt( modelDoc.graveyard, 'end' )
-			);
+			batch.remove( ModelRange.createFromParentsAndOffsets( modelRoot, 3, modelRoot, 4 ) );
 
 			dispatcher.convertRemove(
 				ModelPosition.createFromParentAndOffset( modelRoot, 3 ),

+ 26 - 18
packages/ckeditor5-engine/tests/conversion/modelconversiondispatcher.js

@@ -63,8 +63,7 @@ describe( 'ModelConversionDispatcher', () => {
 			dispatcher.on( 'insert:image', cbInsertImage );
 			dispatcher.on( 'addAttribute:key:$text', cbAddAttribute );
 
-			const insertedText = new ModelText( 'foo', { key: 'value' } );
-			doc.batch().insert( ModelPosition.createFromParentAndOffset( root, 0 ), insertedText );
+			doc.batch().insertText( 'foo', { key: 'value' }, root );
 
 			expect( cbInsertText.called ).to.be.true;
 			expect( cbAddAttribute.called ).to.be.true;
@@ -113,13 +112,13 @@ describe( 'ModelConversionDispatcher', () => {
 			dispatcher.on( 'addAttribute:key:$text', cbAddText );
 			dispatcher.on( 'addAttribute:key:image', cbAddImage );
 
-			doc.batch().setAttribute( image, 'key', 'value' );
+			doc.batch().setAttribute( 'key', 'value', image );
 
 			// Callback for adding attribute on text not called.
 			expect( cbAddText.called ).to.be.false;
 			expect( cbAddImage.calledOnce ).to.be.true;
 
-			doc.batch().setAttribute( ModelRange.createFromParentsAndOffsets( root, 3, root, 4 ), 'key', 'value' );
+			doc.batch().setAttribute( 'key', 'value', ModelRange.createFromParentsAndOffsets( root, 3, root, 4 ) );
 
 			expect( cbAddText.calledOnce ).to.be.true;
 			// Callback for adding attribute on image not called this time.
@@ -129,18 +128,21 @@ describe( 'ModelConversionDispatcher', () => {
 		it( 'should fire changeAttribute callbacks for change attribute change', () => {
 			const cbChangeText = sinon.spy();
 			const cbChangeImage = sinon.spy();
+			const batch = doc.batch();
 
 			dispatcher.on( 'changeAttribute:key:$text', cbChangeText );
 			dispatcher.on( 'changeAttribute:key:image', cbChangeImage );
 
-			doc.batch().setAttribute( image, 'key', 'value' ).setAttribute( image, 'key', 'newValue' );
+			batch.setAttribute( 'key', 'value', image );
+			batch.setAttribute( 'key', 'newValue', image );
 
 			// Callback for adding attribute on text not called.
 			expect( cbChangeText.called ).to.be.false;
 			expect( cbChangeImage.calledOnce ).to.be.true;
 
 			const range = ModelRange.createFromParentsAndOffsets( root, 3, root, 4 );
-			doc.batch().setAttribute( range, 'key', 'value' ).setAttribute( range, 'key', 'newValue' );
+			batch.setAttribute( 'key', 'value', range );
+			batch.setAttribute( 'key', 'newValue', range );
 
 			expect( cbChangeText.calledOnce ).to.be.true;
 			// Callback for adding attribute on image not called this time.
@@ -150,18 +152,21 @@ describe( 'ModelConversionDispatcher', () => {
 		it( 'should fire removeAttribute callbacks for remove attribute change', () => {
 			const cbRemoveText = sinon.spy();
 			const cbRemoveImage = sinon.spy();
+			const batch = doc.batch();
 
 			dispatcher.on( 'removeAttribute:key:$text', cbRemoveText );
 			dispatcher.on( 'removeAttribute:key:image', cbRemoveImage );
 
-			doc.batch().setAttribute( image, 'key', 'value' ).removeAttribute( image, 'key' );
+			batch.setAttribute( 'key', 'value', image );
+			batch.removeAttribute( 'key', image );
 
 			// Callback for adding attribute on text not called.
 			expect( cbRemoveText.called ).to.be.false;
 			expect( cbRemoveImage.calledOnce ).to.be.true;
 
 			const range = ModelRange.createFromParentsAndOffsets( root, 3, root, 4 );
-			doc.batch().setAttribute( range, 'key', 'value' ).removeAttribute( range, 'key' );
+			batch.setAttribute( 'key', 'value', range );
+			batch.removeAttribute( 'key', range );
 
 			expect( cbRemoveText.calledOnce ).to.be.true;
 			// Callback for adding attribute on image not called this time.
@@ -182,7 +187,7 @@ describe( 'ModelConversionDispatcher', () => {
 			const gyNode = new ModelElement( 'image' );
 			doc.graveyard.appendChildren( gyNode );
 
-			doc.batch().setAttribute( gyNode, 'key', 'value' );
+			doc.batch().setAttribute( 'key', 'value', gyNode );
 
 			expect( dispatcher.fire.called ).to.be.false;
 		} );
@@ -616,9 +621,10 @@ describe( 'ModelConversionDispatcher', () => {
 
 		it( 'should prepare correct list of consumable values', () => {
 			doc.enqueueChanges( () => {
-				doc.batch()
-					.setAttribute( ModelRange.createIn( root ), 'bold', true )
-					.setAttribute( ModelRange.createFromParentsAndOffsets( root, 4, root, 5 ), 'italic', true );
+				const batch = doc.batch();
+
+				batch.setAttribute( 'bold', true, ModelRange.createIn( root ) );
+				batch.setAttribute( 'italic', true, ModelRange.createFromParentsAndOffsets( root, 4, root, 5 ) );
 			} );
 
 			dispatcher.on( 'selection', ( evt, data, consumable ) => {
@@ -634,9 +640,10 @@ describe( 'ModelConversionDispatcher', () => {
 			sinon.spy( dispatcher, 'fire' );
 
 			doc.enqueueChanges( () => {
-				doc.batch()
-					.setAttribute( ModelRange.createIn( root ), 'bold', true )
-					.setAttribute( ModelRange.createFromParentsAndOffsets( root, 4, root, 5 ), 'italic', true );
+				const batch = doc.batch();
+
+				batch.setAttribute( 'bold', true, ModelRange.createIn( root ) );
+				batch.setAttribute( 'italic', true, ModelRange.createFromParentsAndOffsets( root, 4, root, 5 ) );
 			} );
 
 			dispatcher.convertSelection( doc.selection, [] );
@@ -653,9 +660,10 @@ describe( 'ModelConversionDispatcher', () => {
 			} );
 
 			doc.enqueueChanges( () => {
-				doc.batch()
-					.setAttribute( ModelRange.createIn( root ), 'bold', true )
-					.setAttribute( ModelRange.createFromParentsAndOffsets( root, 4, root, 5 ), 'italic', true );
+				const batch = doc.batch();
+
+				batch.setAttribute( 'bold', true, ModelRange.createIn( root ) );
+				batch.setAttribute( 'italic', true, ModelRange.createFromParentsAndOffsets( root, 4, root, 5 ) );
 			} );
 
 			dispatcher.convertSelection( doc.selection, [] );

+ 13 - 9
packages/ckeditor5-engine/tests/conversion/view-to-model-converters.js

@@ -9,6 +9,7 @@ import ViewDocumentFragment from '../../src/view/documentfragment';
 import ViewText from '../../src/view/text';
 
 import ModelSchema from '../../src/model/schema';
+import ModelDocument from '../../src/model/document';
 import ModelDocumentFragment from '../../src/model/documentfragment';
 import ModelElement from '../../src/model/element';
 import ModelText from '../../src/model/text';
@@ -16,13 +17,16 @@ import ModelText from '../../src/model/text';
 import { convertToModelFragment, convertText } from '../../src/conversion/view-to-model-converters';
 
 describe( 'view-to-model-converters', () => {
-	let dispatcher, schema, objWithContext;
+	let dispatcher, schema, additionalData, batch;
+
+	const modelDocument = new ModelDocument();
 
 	beforeEach( () => {
 		schema = new ModelSchema();
 		schema.registerItem( 'paragraph', '$block' );
 		schema.allow( { name: '$text', inside: '$root' } );
-		objWithContext = { context: [ '$root' ] };
+		batch = modelDocument.batch();
+		additionalData = { context: [ '$root' ] };
 		dispatcher = new ViewConversionDispatcher( { schema } );
 	} );
 
@@ -32,7 +36,7 @@ describe( 'view-to-model-converters', () => {
 
 			dispatcher.on( 'text', convertText() );
 
-			const conversionResult = dispatcher.convert( viewText, objWithContext );
+			const conversionResult = dispatcher.convert( viewText, batch, additionalData );
 
 			expect( conversionResult ).to.be.instanceof( ModelDocumentFragment );
 			expect( conversionResult.getChild( 0 ) ).to.be.instanceof( ModelText );
@@ -51,7 +55,7 @@ describe( 'view-to-model-converters', () => {
 				}
 			} );
 
-			const conversionResult = dispatcher.convert( viewText, objWithContext );
+			const conversionResult = dispatcher.convert( viewText, batch, additionalData );
 
 			expect( conversionResult ).to.be.instanceof( ModelDocumentFragment );
 			expect( conversionResult.getChild( 0 ) ).to.be.instanceof( ModelText );
@@ -64,12 +68,12 @@ describe( 'view-to-model-converters', () => {
 			const viewText = new ViewText( 'foobar' );
 			dispatcher.on( 'text', convertText() );
 
-			let conversionResult = dispatcher.convert( viewText, objWithContext );
+			let conversionResult = dispatcher.convert( viewText, batch, additionalData );
 
 			expect( conversionResult ).to.be.instanceof( ModelDocumentFragment );
 			expect( conversionResult.childCount ).to.equal( 0 );
 
-			conversionResult = dispatcher.convert( viewText, { context: [ '$block' ] } );
+			conversionResult = dispatcher.convert( viewText, batch, { context: [ '$block' ] } );
 
 			expect( conversionResult ).to.be.instanceof( ModelDocumentFragment );
 			expect( conversionResult.childCount ).to.equal( 1 );
@@ -82,7 +86,7 @@ describe( 'view-to-model-converters', () => {
 
 			dispatcher.on( 'text', convertText() );
 
-			const conversionResult = dispatcher.convert( viewText, objWithContext );
+			const conversionResult = dispatcher.convert( viewText, batch, additionalData );
 
 			expect( conversionResult ).to.be.instanceof( ModelDocumentFragment );
 			expect( conversionResult.getChild( 0 ) ).to.be.instanceof( ModelText );
@@ -103,7 +107,7 @@ describe( 'view-to-model-converters', () => {
 			dispatcher.on( 'element', convertToModelFragment() );
 			dispatcher.on( 'documentFragment', convertToModelFragment() );
 
-			const conversionResult = dispatcher.convert( viewFragment, objWithContext );
+			const conversionResult = dispatcher.convert( viewFragment, batch, additionalData );
 
 			expect( conversionResult ).to.be.instanceof( ModelDocumentFragment );
 			expect( conversionResult.maxOffset ).to.equal( 6 );
@@ -128,7 +132,7 @@ describe( 'view-to-model-converters', () => {
 				}
 			} );
 
-			const conversionResult = dispatcher.convert( viewP, objWithContext );
+			const conversionResult = dispatcher.convert( viewP, batch, additionalData );
 
 			expect( conversionResult ).to.be.instanceof( ModelDocumentFragment );
 			expect( conversionResult.getChild( 0 ) ).to.be.instanceof( ModelElement );

+ 24 - 15
packages/ckeditor5-engine/tests/conversion/viewconversiondispatcher.js

@@ -11,6 +11,7 @@ import ViewText from '../../src/view/text';
 import ModelText from '../../src/model/text';
 import ModelElement from '../../src/model/element';
 import ModelDocumentFragment from '../../src/model/documentfragment';
+import ModelDocument from '../../src/model/document';
 import { stringify } from '../../src/dev-utils/model';
 
 import log from '@ckeditor/ckeditor5-utils/src/log';
@@ -35,10 +36,13 @@ describe( 'ViewConversionDispatcher', () => {
 	} );
 
 	describe( 'convert', () => {
-		let dispatcher;
+		let dispatcher, batch;
+
+		const modelDocument = new ModelDocument();
 
 		beforeEach( () => {
 			dispatcher = new ViewConversionDispatcher();
+			batch = modelDocument.batch();
 		} );
 
 		it( 'should fire viewCleanup event on converted view part', () => {
@@ -47,7 +51,7 @@ describe( 'ViewConversionDispatcher', () => {
 			sinon.spy( dispatcher, 'fire' );
 
 			const viewP = new ViewContainerElement( 'p' );
-			dispatcher.convert( viewP );
+			dispatcher.convert( viewP, batch );
 
 			expect( dispatcher.fire.calledWith( 'viewCleanup', viewP ) ).to.be.true;
 		} );
@@ -61,9 +65,9 @@ describe( 'ViewConversionDispatcher', () => {
 
 			sinon.spy( dispatcher, 'fire' );
 
-			dispatcher.convert( viewText );
-			dispatcher.convert( viewElement );
-			dispatcher.convert( viewFragment );
+			dispatcher.convert( viewText, batch );
+			dispatcher.convert( viewElement, batch );
+			dispatcher.convert( viewFragment, batch );
 
 			expect( dispatcher.fire.calledWith( 'text' ) ).to.be.true;
 			expect( dispatcher.fire.calledWith( 'element:p' ) ).to.be.true;
@@ -95,7 +99,7 @@ describe( 'ViewConversionDispatcher', () => {
 			} );
 
 			// Use `additionalData` parameter to check if it was passed to the event.
-			const conversionResult = dispatcher.convert( viewText, { foo: 'bar' } );
+			const conversionResult = dispatcher.convert( viewText, batch, { foo: 'bar' } );
 
 			// Check conversion result.
 			// Result should be wrapped in document fragment.
@@ -130,7 +134,7 @@ describe( 'ViewConversionDispatcher', () => {
 			} );
 
 			// Use `additionalData` parameter to check if it was passed to the event.
-			const conversionResult = dispatcher.convert( viewElement, { foo: 'bar' } );
+			const conversionResult = dispatcher.convert( viewElement, batch, { foo: 'bar' } );
 
 			// Check conversion result.
 			// Result should be wrapped in document fragment.
@@ -164,7 +168,7 @@ describe( 'ViewConversionDispatcher', () => {
 			} );
 
 			// Use `additionalData` parameter to check if it was passed to the event.
-			const conversionResult = dispatcher.convert( viewFragment, { foo: 'bar' } );
+			const conversionResult = dispatcher.convert( viewFragment, batch, { foo: 'bar' } );
 
 			// Check conversion result.
 			expect( conversionResult ).to.be.instanceof( ModelDocumentFragment );
@@ -187,7 +191,7 @@ describe( 'ViewConversionDispatcher', () => {
 				] );
 			} );
 
-			const conversionResult = dispatcher.convert( viewFragment );
+			const conversionResult = dispatcher.convert( viewFragment, batch );
 
 			expect( conversionResult.markers.size ).to.equal( 2 );
 			expect( stringify( conversionResult, conversionResult.markers.get( 'marker1' ) ) ).to.deep.equal( 'fo[ob]ar' );
@@ -197,9 +201,13 @@ describe( 'ViewConversionDispatcher', () => {
 
 	describe( 'conversionApi', () => {
 		let spy, spyP, spyText, viewP, viewText, modelP, modelText, consumableMock, dispatcher,
-			spyNull, spyArray, viewDiv, viewNull, viewArray;
+			spyNull, spyArray, viewDiv, viewNull, viewArray, batch;
+
+		const modelDocument = new ModelDocument();
 
 		beforeEach( () => {
+			batch = modelDocument.batch();
+
 			spy = sinon.spy();
 			spyP = sinon.spy();
 			spyText = sinon.spy();
@@ -260,9 +268,10 @@ describe( 'ViewConversionDispatcher', () => {
 
 					expect( conversionApi.convertItem( viewP, consumableMock, data ) ).to.equal( modelP );
 					expect( conversionApi.convertItem( viewText, consumableMock, data ) ).to.equal( modelText );
+					expect( conversionApi.batch ).to.equal( batch );
 				} );
 
-				dispatcher.convert( new ViewDocumentFragment(), { foo: 'bar' } );
+				dispatcher.convert( new ViewDocumentFragment(), batch, { foo: 'bar' } );
 
 				expect( spy.calledOnce ).to.be.true;
 				expect( spyP.calledOnce ).to.be.true;
@@ -279,7 +288,7 @@ describe( 'ViewConversionDispatcher', () => {
 					expect( conversionApi.convertItem( viewNull ) ).to.equal( null );
 				} );
 
-				dispatcher.convert( new ViewDocumentFragment() );
+				dispatcher.convert( new ViewDocumentFragment(), batch );
 
 				expect( spy.calledOnce ).to.be.true;
 				expect( spyNull.calledOnce ).to.be.true;
@@ -297,7 +306,7 @@ describe( 'ViewConversionDispatcher', () => {
 					expect( conversionApi.convertItem( viewArray ) ).to.equal( null );
 				} );
 
-				dispatcher.convert( new ViewDocumentFragment() );
+				dispatcher.convert( new ViewDocumentFragment(), batch );
 
 				expect( spy.calledOnce ).to.be.true;
 				expect( spyArray.calledOnce ).to.be.true;
@@ -323,7 +332,7 @@ describe( 'ViewConversionDispatcher', () => {
 					expect( result.getChild( 1 ) ).to.equal( modelText );
 				} );
 
-				dispatcher.convert( new ViewDocumentFragment( [ viewP, viewText ] ), { foo: 'bar' } );
+				dispatcher.convert( new ViewDocumentFragment( [ viewP, viewText ] ), batch, { foo: 'bar' } );
 
 				expect( spy.calledOnce ).to.be.true;
 				expect( spyP.calledOnce ).to.be.true;
@@ -344,7 +353,7 @@ describe( 'ViewConversionDispatcher', () => {
 					expect( result.getChild( 1 ) ).to.equal( modelText );
 				} );
 
-				dispatcher.convert( new ViewDocumentFragment( [ viewArray, viewP, viewDiv, viewText, viewNull ] ), { foo: 'bar' } );
+				dispatcher.convert( new ViewDocumentFragment( [ viewArray, viewP, viewDiv, viewText, viewNull ] ), batch, { foo: 'bar' } );
 
 				expect( spy.calledOnce ).to.be.true;
 				expect( spyNull.calledOnce ).to.be.true;

+ 82 - 8
packages/ckeditor5-engine/tests/dev-utils/enableenginedebug.js

@@ -3,7 +3,8 @@
  * For licensing, see LICENSE.md.
  */
 
-import enableEngineDebug from '../../src/dev-utils/enableenginedebug';
+import { default as enableEngineDebug, disableEngineDebug } from '../../src/dev-utils/enableenginedebug';
+import Editor from '@ckeditor/ckeditor5-core/src/editor/editor';
 import StandardEditor from '@ckeditor/ckeditor5-core/src/editor/standardeditor';
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 
@@ -13,6 +14,7 @@ import ModelText from '../../src/model/text';
 import ModelTextProxy from '../../src/model/textproxy';
 import ModelElement from '../../src/model/element';
 import AttributeOperation from '../../src/model/operation/attributeoperation';
+import DetachOperation from '../../src/model/operation/detachoperation';
 import InsertOperation from '../../src/model/operation/insertoperation';
 import MarkerOperation from '../../src/model/operation/markeroperation';
 import MoveOperation from '../../src/model/operation/moveoperation';
@@ -22,12 +24,13 @@ import RootAttributeOperation from '../../src/model/operation/rootattributeopera
 import RemoveOperation from '../../src/model/operation/removeoperation';
 import DeltaFactory from '../../src/model/delta/deltafactory';
 import Delta from '../../src/model/delta/delta';
-import { default as AttributeDelta, RootAttributeDelta } from '../../src/model/delta/attributedelta';
+import AttributeDelta from '../../src/model/delta/attributedelta';
 import InsertDelta from '../../src/model/delta/insertdelta';
 import MarkerDelta from '../../src/model/delta/markerdelta';
 import MergeDelta from '../../src/model/delta/mergedelta';
 import MoveDelta from '../../src/model/delta/movedelta';
 import RenameDelta from '../../src/model/delta/renamedelta';
+import RootAttributeDelta from '../../src/model/delta/rootattributedelta';
 import SplitDelta from '../../src/model/delta/splitdelta';
 import UnwrapDelta from '../../src/model/delta/unwrapdelta';
 import WrapDelta from '../../src/model/delta/wrapdelta';
@@ -41,10 +44,19 @@ import ViewContainerElement from '../../src/view/containerelement';
 import ViewText from '../../src/view/text';
 import ViewTextProxy from '../../src/view/textproxy';
 import ViewDocumentFragment from '../../src/view/documentfragment';
+import ViewElement from '../../src/view/element';
+
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+
+testUtils.createSinonSandbox();
 
 /* global document */
 
 describe( 'enableEngineDebug', () => {
+	afterEach( () => {
+		disableEngineDebug();
+	} );
+
 	it( 'should return plugin class', () => {
 		const result = enableEngineDebug();
 
@@ -60,6 +72,35 @@ describe( 'enableEngineDebug', () => {
 	} );
 } );
 
+describe( 'disableEngineDebug', () => {
+	it( 'restores modified stubs', () => {
+		expect( ModelPosition.prototype.log ).to.equal( undefined, 'Initial value (model/position)' );
+		expect( ModelElement.prototype.printTree ).to.equal( undefined, 'Initial value (model/element)' );
+		expect( Delta.prototype.log ).to.equal( undefined, 'Initial value (model/delta/delta)' );
+		expect( ViewElement.prototype.printTree ).to.equal( undefined, 'Initial value (view/element)' );
+		expect( ModelDocument.prototype.createReplayer ).to.equal( undefined, 'Initial value (model/document)' );
+		expect( Editor.prototype.logDocuments ).to.equal( undefined, 'Initial value (core~editor/editor)' );
+
+		enableEngineDebug();
+
+		expect( ModelPosition.prototype.log ).to.be.a( 'function', 'After enabling engine debug (model/position)' );
+		expect( ModelElement.prototype.printTree ).to.be.a( 'function', 'After enabling engine debug (model/element)' );
+		expect( Delta.prototype.log ).to.be.a( 'function', 'After enabling engine debug (model/delta/delta)' );
+		expect( ViewElement.prototype.printTree ).to.be.a( 'function', 'After enabling engine debug (view/element)' );
+		expect( ModelDocument.prototype.createReplayer ).to.be.a( 'function', 'After enabling engine debug (model/document)' );
+		expect( Editor.prototype.logDocuments ).to.be.a( 'function', 'After enabling engine debug (core~editor/editor)' );
+
+		disableEngineDebug();
+
+		expect( ModelPosition.prototype.log ).to.equal( undefined, 'After disabling engine debug (model/position)' );
+		expect( ModelElement.prototype.printTree ).to.equal( undefined, 'After disabling engine debug (model/element)' );
+		expect( Delta.prototype.log ).to.equal( undefined, 'After disabling engine debug (model/delta/delta)' );
+		expect( ViewElement.prototype.printTree ).to.equal( undefined, 'After disabling engine debug (view/element)' );
+		expect( ModelDocument.prototype.createReplayer ).to.equal( undefined, 'After disabling engine debug (model/document)' );
+		expect( Editor.prototype.logDocuments ).to.equal( undefined, 'After disabling engine debug (core~editor/editor)' );
+	} );
+} );
+
 describe( 'debug tools', () => {
 	let DebugPlugin, log, error;
 
@@ -78,6 +119,10 @@ describe( 'debug tools', () => {
 		DebugPlugin = enableEngineDebug( { log, error } );
 	} );
 
+	after( () => {
+		disableEngineDebug();
+	} );
+
 	afterEach( () => {
 		log.reset();
 	} );
@@ -209,6 +254,39 @@ describe( 'debug tools', () => {
 				expect( log.calledWithExactly( op.toString() ) ).to.be.true;
 			} );
 
+			it( 'DetachOperation (text node)', () => {
+				const op = new DetachOperation( ModelPosition.createAt( modelRoot, 0 ), 3, 0 );
+
+				expect( op.toString() ).to.equal( 'DetachOperation( 0 ): #foo -> main [ 0 ] - [ 3 ]' );
+
+				op.log();
+				expect( log.calledWithExactly( op.toString() ) ).to.be.true;
+			} );
+
+			it( 'DetachOperation (element)', () => {
+				const element = new ModelElement( 'element' );
+				modelRoot.insertChildren( 0, element );
+
+				const op = new DetachOperation( ModelPosition.createBefore( element ), 1, 0 );
+
+				expect( op.toString() ).to.equal( 'DetachOperation( 0 ): <element> -> main [ 0 ] - [ 1 ]' );
+
+				op.log();
+				expect( log.calledWithExactly( op.toString() ) ).to.be.true;
+			} );
+
+			it( 'DetachOperation (multiple nodes)', () => {
+				const element = new ModelElement( 'element' );
+				modelRoot.insertChildren( 0, element );
+
+				const op = new DetachOperation( ModelPosition.createBefore( element ), 2, 0 );
+
+				expect( op.toString() ).to.equal( 'DetachOperation( 0 ): [ 2 ] -> main [ 0 ] - [ 2 ]' );
+
+				op.log();
+				expect( log.calledWithExactly( op.toString() ) ).to.be.true;
+			} );
+
 			it( 'InsertOperation (text node)', () => {
 				const op = new InsertOperation( ModelPosition.createAt( modelRoot, 3 ), [ new ModelText( 'abc' ) ], 0 );
 
@@ -1056,9 +1134,7 @@ describe( 'debug tools', () => {
 				const opB = new InsertOperation( ModelPosition.createAt( root, 0 ), new ModelText( 'a' ), 0 );
 				deltaB.addOperation( opB );
 
-				deltaTransform.defaultTransform = () => {
-					throw new Error();
-				};
+				testUtils.sinon.stub( deltaTransform, 'defaultTransform' ).throws( new Error() );
 
 				expect( () => deltaTransform.transform( deltaA, deltaB, { isStrong: true } ) ).to.throw( Error );
 				expect( error.calledWith( deltaA.toString() + ' (important)' ) ).to.be.true;
@@ -1074,9 +1150,7 @@ describe( 'debug tools', () => {
 				const opB = new InsertOperation( ModelPosition.createAt( root, 0 ), new ModelText( 'a' ), 0 );
 				deltaB.addOperation( opB );
 
-				deltaTransform.defaultTransform = () => {
-					throw new Error();
-				};
+				testUtils.sinon.stub( deltaTransform, 'defaultTransform' ).throws( new Error() );
 
 				expect( () => deltaTransform.transform( deltaA, deltaB, { isStrong: false } ) ).to.throw( Error );
 				expect( error.calledWith( deltaA.toString() ) ).to.be.true;

+ 12 - 12
packages/ckeditor5-engine/tests/dev-utils/model.js

@@ -497,21 +497,21 @@ describe( 'model test utils', () => {
 
 		it( 'throws when invalid XML', () => {
 			expect( () => {
-				parse( '<a><b></a></b>', document.schema );
+				parse( '<a><b></a></b>', document.schema, document.batch() );
 			} ).to.throw( Error, /Parse error/ );
 		} );
 
 		it( 'throws when try to set element not registered in schema', () => {
 			expect( () => {
-				parse( '<xyz></xyz>', document.schema );
+				parse( '<xyz></xyz>', document.schema, document.batch() );
 			} ).to.throw( Error, 'Element \'xyz\' not allowed in context ["$root"].' );
 		} );
 
 		it( 'throws when try to set text directly to $root without registering it', () => {
-			const doc = new Document();
+			const document = new Document();
 
 			expect( () => {
-				parse( 'text', doc.schema );
+				parse( 'text', document.schema, document.batch() );
 			} ).to.throw( Error, 'Element \'$text\' not allowed in context ["$root"].' );
 		} );
 
@@ -521,7 +521,7 @@ describe( 'model test utils', () => {
 			doc.schema.allow( { name: '$text', inside: 'foo' } );
 
 			expect( () => {
-				parse( 'text', doc.schema, { context: [ 'foo' ] } );
+				parse( 'text', doc.schema, doc.batch(), { context: [ 'foo' ] } );
 			} ).to.not.throw();
 		} );
 
@@ -556,7 +556,7 @@ describe( 'model test utils', () => {
 			} );
 
 			it( 'sets selection attributes', () => {
-				const result = parse( 'foo[]bar', document.schema, { selectionAttributes: {
+				const result = parse( 'foo[]bar', document.schema, document.batch(), { selectionAttributes: {
 					bold: true,
 					italic: true
 				} } );
@@ -577,7 +577,7 @@ describe( 'model test utils', () => {
 			} );
 
 			it( 'sets selection with attribute containing an element', () => {
-				const result = parse( 'x[<a></a>]', document.schema, { selectionAttributes: {
+				const result = parse( 'x[<a></a>]', document.schema, document.batch(), { selectionAttributes: {
 					bold: true
 				} } );
 
@@ -586,7 +586,7 @@ describe( 'model test utils', () => {
 			} );
 
 			it( 'sets a backward selection containing an element', () => {
-				const result = parse( 'x[<a></a>]', document.schema, {
+				const result = parse( 'x[<a></a>]', document.schema, document.batch(), {
 					lastRangeBackward: true
 				} );
 
@@ -599,7 +599,7 @@ describe( 'model test utils', () => {
 			} );
 
 			it( 'sets selection within a text with different attributes', () => {
-				const result = parse( '<$text bold="true">fo[o</$text>ba]r', document.schema, {
+				const result = parse( '<$text bold="true">fo[o</$text>ba]r', document.schema, document.batch(), {
 					selectionAttributes: { bold: true }
 				} );
 
@@ -609,13 +609,13 @@ describe( 'model test utils', () => {
 
 			it( 'throws when missing selection start', () => {
 				expect( () => {
-					parse( 'foo]' );
+					parse( 'foo]', document.schema, document.batch() );
 				} ).to.throw( Error );
 			} );
 
 			it( 'throws when missing selection end', () => {
 				expect( () => {
-					parse( '[foo' );
+					parse( '[foo', document.schema, document.batch() );
 				} ).to.throw( Error );
 			} );
 		} );
@@ -623,7 +623,7 @@ describe( 'model test utils', () => {
 		function test( title, options ) {
 			it( title, () => {
 				const output = options.output || options.data;
-				const data = parse( options.data, document.schema );
+				const data = parse( options.data, document.schema, document.batch() );
 				let model, selection;
 
 				if ( data.selection && data.model ) {

+ 18 - 0
packages/ckeditor5-engine/tests/dev-utils/view.js

@@ -355,6 +355,24 @@ describe( 'view test utils', () => {
 			expect( stringify( p, null, { showType: true } ) )
 				.to.equal( '<container:p><ui:span></ui:span></container:p>' );
 		} );
+
+		it( 'should sort classes in specified element', () => {
+			const text = new Text( 'foobar' );
+			const b = new Element( 'b', {
+				class: 'zz xx aa'
+			}, text );
+
+			expect( stringify( b ) ).to.equal( '<b class="aa xx zz">foobar</b>' );
+		} );
+
+		it( 'should sort styles in specified element', () => {
+			const text = new Text( 'foobar' );
+			const i = new Element( 'i', {
+				style: 'text-decoration: underline; font-weight: bold'
+			}, text );
+
+			expect( stringify( i ) ).to.equal( '<i style="font-weight:bold;text-decoration:underline">foobar</i>' );
+		} );
 	} );
 
 	describe( 'parse', () => {

+ 22 - 0
packages/ckeditor5-engine/tests/manual/nestededitable.css

@@ -0,0 +1,22 @@
+/*
+ * Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+@import "@ckeditor/ckeditor5-theme-lark/theme/mixins/_focus.css";
+@import "@ckeditor/ckeditor5-theme-lark/theme/mixins/_shadow.css";
+
+figure {
+	background-color: #f3f3f3;
+	padding: 10px;
+
+	& figcaption {
+		background: white;
+		outline: none;
+
+		&.focused {
+			@mixin ck-focus-ring;
+			@mixin ck-box-shadow var(--ck-inner-shadow);
+		}
+	}
+}

+ 1 - 1
packages/ckeditor5-engine/tests/manual/nestededitable.js

@@ -18,7 +18,7 @@ import ViewEditableElement from '../../src/view/editableelement';
 import { getData } from '../../src/dev-utils/model';
 import global from '@ckeditor/ckeditor5-utils/src/dom/global';
 
-import './nestededitable.scss';
+import './nestededitable.css';
 
 class NestedEditable extends Plugin {
 	init() {

+ 0 - 25
packages/ckeditor5-engine/tests/manual/nestededitable.scss

@@ -1,25 +0,0 @@
-// Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
-// For licensing, see LICENSE.md or http://ckeditor.com/license
-
-@import '~@ckeditor/ckeditor5-theme-lark/theme/helpers/_colors.scss';
-@import '~@ckeditor/ckeditor5-theme-lark/theme/helpers/_shadow.scss';
-@import '~@ckeditor/ckeditor5-theme-lark/theme/helpers/_states.scss';
-
-figure {
-	background-color: #f3f3f3;
-	padding: 10px;
-
-	figcaption {
-		background: white;
-		outline: none;
-
-		&.focused {
-			@include ck-focus-ring();
-			@include ck-box-shadow( $ck-inner-shadow );
-		}
-	}
-}
-
-
-
-

+ 1 - 1
packages/ckeditor5-engine/tests/manual/tickets/475/1.js

@@ -80,7 +80,7 @@ class AutoLinker extends Plugin {
 
 				doc.enqueueChanges( () => {
 					const urlRange = Range.createFromPositionAndShift( livePos, url.length );
-					batch.setAttribute( urlRange, 'link', url );
+					batch.setAttribute( 'link', url, urlRange );
 				} );
 			}
 		} );

+ 1868 - 42
packages/ckeditor5-engine/tests/model/batch.js

@@ -3,22 +3,26 @@
  * For licensing, see LICENSE.md.
  */
 
-import deltas from '../../src/model/delta/basic-deltas'; // eslint-disable-line no-unused-vars
-
-import Document from '../../src/model/document';
-import { default as Batch, register } from '../../src/model/batch';
+import Batch from '../../src/model/batch';
 import Delta from '../../src/model/delta/delta';
+import InsertDelta from '../../src/model/delta/insertdelta';
+import WeakInsertDelta from '../../src/model/delta/weakinsertdelta';
+
 import Operation from '../../src/model/operation/operation';
-import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 
-describe( 'Batch', () => {
-	it( 'should have registered basic methods', () => {
-		const batch = new Batch( new Document() );
+import Document from '../../src/model/document';
+import DocumentFragment from '../../src/model/documentfragment';
+import Element from '../../src/model/element';
+import Text from '../../src/model/text';
+import Position from '../../src/model/position';
+import Range from '../../src/model/range';
 
-		expect( batch.setAttribute ).to.be.a( 'function' );
-		expect( batch.removeAttribute ).to.be.a( 'function' );
-	} );
+import count from '@ckeditor/ckeditor5-utils/src/count';
+import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 
+import { getNodesAndText } from '../../tests/model/_utils/utils';
+
+describe( 'Batch', () => {
 	describe( 'type', () => {
 		it( 'should default to "default"', () => {
 			const batch = new Batch( new Document() );
@@ -33,33 +37,25 @@ describe( 'Batch', () => {
 		} );
 	} );
 
-	describe( 'register', () => {
-		afterEach( () => {
-			delete Batch.prototype.foo;
-		} );
-
-		it( 'should register function to the batch prototype', () => {
-			const spy = sinon.spy();
-
-			register( 'foo', spy );
-
+	describe( 'baseVersion', () => {
+		it( 'should return base version of first delta from the batch', () => {
 			const batch = new Batch( new Document() );
+			const delta = new Delta();
+			const operation = new Operation( 2 );
+			delta.addOperation( operation );
+			batch.addDelta( delta );
 
-			batch.foo();
-
-			expect( spy.calledOnce ).to.be.true;
+			expect( batch.baseVersion ).to.equal( 2 );
 		} );
 
-		it( 'should throw if one try to register the same batch twice', () => {
-			register( 'foo', () => {} );
+		it( 'should return null if there are no deltas in batch', () => {
+			const batch = new Batch( new Document() );
 
-			expect( () => {
-				register( 'foo', () => {} );
-			} ).to.throw( CKEditorError, /^model-batch-register-taken/ );
+			expect( batch.baseVersion ).to.be.null;
 		} );
 	} );
 
-	describe( 'addDelta', () => {
+	describe( 'addDelta()', () => {
 		it( 'should add delta to the batch', () => {
 			const batch = new Batch( new Document() );
 			const deltaA = new Delta();
@@ -73,7 +69,7 @@ describe( 'Batch', () => {
 		} );
 	} );
 
-	describe( 'getOperations', () => {
+	describe( 'getOperations()', () => {
 		it( 'should return collection of operations from all deltas', () => {
 			const doc = new Document();
 			const batch = new Batch( doc );
@@ -96,21 +92,1851 @@ describe( 'Batch', () => {
 		} );
 	} );
 
-	describe( 'baseVersion', () => {
-		it( 'should return base version of first delta from the batch', () => {
-			const batch = new Batch( new Document() );
-			const delta = new Delta();
-			const operation = new Operation( 2 );
-			delta.addOperation( operation );
-			batch.addDelta( delta );
+	describe( 'createText()', () => {
+		let doc, batch;
 
-			expect( batch.baseVersion ).to.equal( 2 );
+		beforeEach( () => {
+			doc = new Document();
+			batch = doc.batch();
 		} );
 
-		it( 'should return null if there are no deltas in batch', () => {
-			const batch = new Batch( new Document() );
+		it( 'should create text node', () => {
+			const text = batch.createText( 'foo' );
 
-			expect( batch.baseVersion ).to.be.null;
+			expect( text ).to.instanceof( Text );
+			expect( text.data ).to.equal( 'foo' );
+			expect( Array.from( text.getAttributes() ) ).to.length( 0 );
+		} );
+
+		it( 'should create text with attributes', () => {
+			const text = batch.createText( 'foo', { foo: 'bar', biz: 'baz' } );
+
+			expect( Array.from( text.getAttributes() ) ).to.deep.equal( [ [ 'foo', 'bar' ], [ 'biz', 'baz' ] ] );
+		} );
+	} );
+
+	describe( 'createElement()', () => {
+		let doc, batch;
+
+		beforeEach( () => {
+			doc = new Document();
+			batch = doc.batch();
+		} );
+
+		it( 'should create element', () => {
+			const element = batch.createElement( 'foo' );
+
+			expect( element ).to.instanceof( Element );
+			expect( element.name ).to.equal( 'foo' );
+			expect( Array.from( element.getAttributes() ) ).to.length( 0 );
+		} );
+
+		it( 'should create element with attributes', () => {
+			const element = batch.createText( 'foo', { foo: 'bar', biz: 'baz' } );
+
+			expect( Array.from( element.getAttributes() ) ).to.deep.equal( [ [ 'foo', 'bar' ], [ 'biz', 'baz' ] ] );
+		} );
+	} );
+
+	describe( 'createDocumentFragment()', () => {
+		let doc, batch;
+
+		beforeEach( () => {
+			doc = new Document();
+			batch = doc.batch();
+		} );
+
+		it( 'should create element', () => {
+			const element = batch.createDocumentFragment();
+
+			expect( element ).to.instanceof( DocumentFragment );
+		} );
+	} );
+
+	describe( 'insert()', () => {
+		let doc, batch;
+
+		beforeEach( () => {
+			doc = new Document();
+			batch = doc.batch();
+		} );
+
+		it( 'should insert node at given position', () => {
+			const parent = batch.createDocumentFragment();
+			const child = batch.createElement( 'child' );
+			const textChild = batch.createText( 'textChild' );
+
+			batch.insert( child, new Position( parent, [ 0 ] ) );
+			batch.insert( textChild, new Position( parent, [ 1 ] ) );
+
+			expect( Array.from( parent ) ).to.deep.equal( [ child, textChild ] );
+		} );
+
+		it( 'should insert node at the beginning of given element', () => {
+			const parent = batch.createDocumentFragment();
+			const child1 = batch.createElement( 'child' );
+			const child2 = batch.createElement( 'child' );
+
+			batch.insert( child1, parent );
+			batch.insert( child2, parent );
+
+			expect( Array.from( parent.getChildren() ) ).to.deep.equal( [ child2, child1 ] );
+		} );
+
+		it( 'should insert node at the end of given element', () => {
+			const parent = batch.createDocumentFragment();
+			const child1 = batch.createElement( 'child' );
+			const child2 = batch.createElement( 'child' );
+
+			batch.insert( child1, parent );
+			batch.insert( child2, parent, 'end' );
+
+			expect( Array.from( parent.getChildren() ) ).to.deep.equal( [ child1, child2 ] );
+		} );
+
+		it( 'should insert node at the given offset of given element', () => {
+			const parent = batch.createDocumentFragment();
+			const child1 = batch.createElement( 'child' );
+			const child2 = batch.createElement( 'child' );
+			const child3 = batch.createElement( 'child' );
+
+			batch.insert( child3, parent );
+			batch.insert( child1, parent );
+			batch.insert( child2, parent, 1 );
+
+			expect( Array.from( parent.getChildren() ) ).to.deep.equal( [ child1, child2, child3 ] );
+		} );
+
+		it( 'should insert node before the given node', () => {
+			const parent = batch.createDocumentFragment();
+			const child1 = batch.createElement( 'child' );
+			const child2 = batch.createElement( 'child' );
+			const child3 = batch.createElement( 'child' );
+
+			batch.insert( child3, parent );
+			batch.insert( child1, parent );
+			batch.insert( child2, child3, 'before' );
+
+			expect( Array.from( parent.getChildren() ) ).to.deep.equal( [ child1, child2, child3 ] );
+		} );
+
+		it( 'should insert node after the given node', () => {
+			const parent = batch.createDocumentFragment();
+			const child1 = batch.createElement( 'child' );
+			const child2 = batch.createElement( 'child' );
+			const child3 = batch.createElement( 'child' );
+
+			batch.insert( child3, parent );
+			batch.insert( child1, parent );
+			batch.insert( child2, child1, 'after' );
+
+			expect( Array.from( parent.getChildren() ) ).to.deep.equal( [ child1, child2, child3 ] );
+		} );
+
+		it( 'should create proper delta for inserting element', () => {
+			const parent = batch.createDocumentFragment();
+			const element = batch.createElement( 'child' );
+
+			const spy = sinon.spy( doc, 'applyOperation' );
+
+			batch.insert( element, parent );
+
+			sinon.assert.calledOnce( spy );
+			expect( spy.lastCall.args[ 0 ].type ).to.equal( 'insert' );
+			expect( spy.lastCall.args[ 0 ].delta ).to.instanceof( InsertDelta );
+			expect( spy.lastCall.args[ 0 ].delta ).to.not.instanceof( WeakInsertDelta );
+			expect( spy.lastCall.args[ 0 ].delta.batch ).to.equal( batch );
+		} );
+
+		it( 'should create proper delta for inserting text', () => {
+			const parent = batch.createDocumentFragment();
+			const text = batch.createText( 'child' );
+
+			const spy = sinon.spy( doc, 'applyOperation' );
+
+			batch.insert( text, parent );
+
+			sinon.assert.calledOnce( spy );
+			expect( spy.lastCall.args[ 0 ].type ).to.equal( 'insert' );
+			expect( spy.lastCall.args[ 0 ].delta ).to.instanceof( WeakInsertDelta );
+			expect( spy.lastCall.args[ 0 ].delta.batch ).to.equal( batch );
+		} );
+
+		it( 'should move element from one parent to the other within the same document (documentA -> documentA)', () => {
+			const rootA = doc.createRoot();
+			const parent1 = batch.createElement( 'parent' );
+			const parent2 = batch.createElement( 'parent' );
+			const node = batch.createText( 'foo' );
+
+			batch.insert( node, parent1 );
+			batch.insert( parent1, rootA );
+			batch.insert( parent2, rootA );
+
+			const spy = sinon.spy( doc, 'applyOperation' );
+
+			batch.insert( node, parent2 );
+
+			// Verify result.
+			expect( Array.from( parent1.getChildren() ) ).to.deep.equal( [] );
+			expect( Array.from( parent2.getChildren() ) ).to.deep.equal( [ node ] );
+
+			// Verify deltas and operations.
+			sinon.assert.calledOnce( spy );
+			expect( spy.firstCall.args[ 0 ].type ).to.equal( 'move' );
+			expect( spy.firstCall.args[ 0 ].delta.batch ).to.equal( batch );
+		} );
+
+		it( 'should move element from one parent to the other within the same document (documentA -> documentB)', () => {
+			const rootA = doc.createRoot( '$root', 'A' );
+			const rootB = doc.createRoot( '$root', 'B' );
+			const node = batch.createText( 'foo' );
+
+			batch.insert( node, rootA );
+
+			const spy = sinon.spy( doc, 'applyOperation' );
+
+			batch.insert( node, rootB );
+
+			// Verify result.
+			expect( Array.from( rootA.getChildren() ) ).to.deep.equal( [] );
+			expect( Array.from( rootB.getChildren() ) ).to.deep.equal( [ node ] );
+
+			// Verify deltas and operations.
+			sinon.assert.calledOnce( spy );
+			expect( spy.firstCall.args[ 0 ].type ).to.equal( 'move' );
+			expect( spy.firstCall.args[ 0 ].delta.batch ).to.equal( batch );
+		} );
+
+		it( 'should move element from one parent to the other within the same document (docFragA -> docFragA)', () => {
+			const docFragA = batch.createDocumentFragment();
+			const parent1 = batch.createElement( 'parent' );
+			const parent2 = batch.createElement( 'parent' );
+			const node = batch.createText( 'foo' );
+
+			batch.insert( node, parent1 );
+			batch.insert( parent1, docFragA );
+			batch.insert( parent2, docFragA );
+
+			const spy = sinon.spy( doc, 'applyOperation' );
+
+			batch.insert( node, parent2 );
+
+			// Verify result.
+			expect( Array.from( parent1.getChildren() ) ).to.deep.equal( [] );
+			expect( Array.from( parent2.getChildren() ) ).to.deep.equal( [ node ] );
+
+			// Verify deltas and operations.
+			sinon.assert.calledOnce( spy );
+			expect( spy.firstCall.args[ 0 ].type ).to.equal( 'move' );
+			expect( spy.firstCall.args[ 0 ].delta.batch ).to.equal( batch );
+		} );
+
+		it( 'should move element from one parent to the other within different document (document -> docFrag)', () => {
+			const root = doc.createRoot();
+			const docFrag = batch.createDocumentFragment();
+			const node = batch.createText( 'foo' );
+
+			batch.insert( node, root );
+
+			const spy = sinon.spy( doc, 'applyOperation' );
+
+			batch.insert( node, docFrag );
+
+			// Verify result.
+			expect( Array.from( root.getChildren() ) ).to.deep.equal( [] );
+			expect( Array.from( docFrag.getChildren() ) ).to.deep.equal( [ node ] );
+
+			// Verify deltas and operations.
+			sinon.assert.calledTwice( spy );
+			expect( spy.firstCall.args[ 0 ].type ).to.equal( 'remove' );
+			expect( spy.firstCall.args[ 0 ].delta.batch ).to.equal( batch );
+			expect( spy.secondCall.args[ 0 ].type ).to.equal( 'insert' );
+			expect( spy.secondCall.args[ 0 ].delta.batch ).to.equal( batch );
+		} );
+
+		it( 'should move element from one parent to the other within different document (docFragA -> docFragB)', () => {
+			const docFragA = batch.createDocumentFragment();
+			const docFragB = batch.createDocumentFragment();
+			const node = batch.createText( 'foo' );
+
+			batch.insert( node, docFragA );
+
+			const spy = sinon.spy( doc, 'applyOperation' );
+
+			batch.insert( node, docFragB );
+
+			// Verify result.
+			expect( Array.from( docFragA ) ).to.deep.equal( [] );
+			expect( Array.from( docFragB ) ).to.deep.equal( [ node ] );
+
+			// Verify deltas and operations.
+			sinon.assert.calledTwice( spy );
+			expect( spy.firstCall.args[ 0 ].type ).to.equal( 'detach' );
+			expect( spy.firstCall.args[ 0 ].delta.batch ).to.equal( batch );
+			expect( spy.secondCall.args[ 0 ].type ).to.equal( 'insert' );
+			expect( spy.secondCall.args[ 0 ].delta.batch ).to.equal( batch );
+		} );
+
+		it( 'should transfer markers from given DocumentFragment', () => {
+			const root = doc.createRoot();
+			const docFrag = batch.createDocumentFragment();
+
+			batch.appendText( 'abcd', root );
+			batch.appendElement( 'p', docFrag );
+			batch.insertText( 'foo bar', new Position( docFrag, [ 0, 0 ] ) );
+
+			const marker = new Range( new Position( docFrag, [ 0, 1 ] ), new Position( docFrag, [ 0, 5 ] ) );
+
+			docFrag.markers.set( 'marker', marker );
+
+			batch.insert( docFrag, new Position( root, [ 2 ] ) );
+
+			expect( Array.from( doc.markers ).length ).to.equal( 1 );
+
+			const range = doc.markers.get( 'marker' ).getRange();
+			expect( range.root ).to.equal( root );
+			expect( range.start.path ).to.deep.equal( [ 2, 1 ] );
+			expect( range.end.path ).to.deep.equal( [ 2, 5 ] );
+		} );
+
+		it( 'should set each marker as a separate operation', () => {
+			const spy = sinon.spy();
+			const root = doc.createRoot();
+			const docFrag = batch.createDocumentFragment();
+
+			batch.appendText( 'abcd', root );
+			batch.appendElement( 'p', docFrag );
+			batch.insertText( 'foo bar', new Position( docFrag, [ 0, 0 ] ) );
+
+			const marker1 = new Range( new Position( docFrag, [ 0, 1 ] ), new Position( docFrag, [ 0, 2 ] ) );
+			const marker2 = new Range( new Position( docFrag, [ 0, 5 ] ), new Position( docFrag, [ 0, 6 ] ) );
+
+			docFrag.markers.set( 'marker1', marker1 );
+			docFrag.markers.set( 'marker2', marker2 );
+
+			doc.on( 'change', spy );
+
+			batch.insert( docFrag, new Position( root, [ 2 ] ) );
+
+			sinon.assert.calledThrice( spy );
+			expect( spy.firstCall.args[ 1 ] ).to.equal( 'insert' );
+			expect( spy.secondCall.args[ 1 ] ).to.equal( 'marker' );
+			expect( spy.thirdCall.args[ 1 ] ).to.equal( 'marker' );
+		} );
+	} );
+
+	describe( 'insertText()', () => {
+		let doc, batch;
+
+		beforeEach( () => {
+			doc = new Document();
+			batch = doc.batch();
+		} );
+
+		it( 'should create and insert text node with attributes at given position', () => {
+			const parent = batch.createDocumentFragment();
+
+			batch.insertText( 'foo', { bar: 'biz' }, new Position( parent, [ 0 ] ) );
+
+			expect( parent.childCount ).to.equal( 1 );
+			expect( parent.getChild( 0 ) ).to.instanceof( Text );
+			expect( parent.getChild( 0 ).data ).to.equal( 'foo' );
+			expect( Array.from( parent.getChild( 0 ).getAttributes() ) ).to.deep.equal( [ [ 'bar', 'biz' ] ] );
+		} );
+
+		it( 'should create and insert text node with no attributes at given position', () => {
+			const parent = batch.createDocumentFragment();
+
+			batch.insertText( 'foo', null, new Position( parent, [ 0 ] ) );
+
+			expect( parent.childCount ).to.equal( 1 );
+			expect( parent.getChild( 0 ) ).to.instanceof( Text );
+			expect( parent.getChild( 0 ).data ).to.equal( 'foo' );
+			expect( Array.from( parent.getChild( 0 ).getAttributes() ) ).to.deep.equal( [] );
+		} );
+
+		it( 'should create and insert text node omitting attributes param', () => {
+			const parent = batch.createDocumentFragment();
+
+			batch.insertText( 'foo', new Position( parent, [ 0 ] ) );
+
+			expect( parent.childCount ).to.equal( 1 );
+			expect( parent.getChild( 0 ) ).to.instanceof( Text );
+			expect( parent.getChild( 0 ).data ).to.equal( 'foo' );
+			expect( Array.from( parent.getChild( 0 ).getAttributes() ) ).to.deep.equal( [] );
+		} );
+
+		it( 'should create and insert text node at the beginning of given element', () => {
+			const parent = batch.createDocumentFragment();
+
+			batch.insert( batch.createElement( 'child' ), parent );
+
+			batch.insertText( 'foo', parent );
+
+			expect( parent.childCount ).to.equal( 2 );
+			expect( parent.getChild( 0 ) ).to.instanceof( Text );
+			expect( parent.getChild( 1 ) ).to.instanceof( Element );
+		} );
+
+		it( 'should create and insert text node at the end of given element', () => {
+			const parent = batch.createDocumentFragment();
+
+			batch.insert( batch.createElement( 'child' ), parent );
+
+			batch.insertText( 'foo', parent, 'end' );
+
+			expect( parent.childCount ).to.equal( 2 );
+			expect( parent.getChild( 0 ) ).to.instanceof( Element );
+			expect( parent.getChild( 1 ) ).to.instanceof( Text );
+		} );
+
+		it( 'should create and insert text node at the given offset of given element', () => {
+			const parent = batch.createDocumentFragment();
+
+			batch.insert( batch.createElement( 'child' ), parent );
+			batch.insert( batch.createElement( 'child' ), parent );
+
+			batch.insertText( 'foo', parent, 1 );
+
+			expect( parent.childCount ).to.equal( 3 );
+			expect( parent.getChild( 0 ) ).to.instanceof( Element );
+			expect( parent.getChild( 1 ) ).to.instanceof( Text );
+			expect( parent.getChild( 2 ) ).to.instanceof( Element );
+		} );
+
+		it( 'should create and insert text node before the given node', () => {
+			const parent = batch.createDocumentFragment();
+			const child1 = batch.createElement( 'child' );
+			const child2 = batch.createElement( 'child' );
+
+			batch.insert( child1, parent );
+			batch.insert( child2, parent, 'end' );
+
+			batch.insertText( 'foo', child2, 'before' );
+
+			expect( parent.childCount ).to.equal( 3 );
+			expect( parent.getChild( 0 ) ).to.instanceof( Element );
+			expect( parent.getChild( 1 ) ).to.instanceof( Text );
+			expect( parent.getChild( 2 ) ).to.instanceof( Element );
+		} );
+
+		it( 'should create and insert text node after the given node', () => {
+			const parent = batch.createDocumentFragment();
+			const child1 = batch.createElement( 'child' );
+			const child2 = batch.createElement( 'child' );
+
+			batch.insert( child1, parent );
+			batch.insert( child2, parent, 'end' );
+
+			batch.insertText( 'foo', child1, 'after' );
+
+			expect( parent.childCount ).to.equal( 3 );
+			expect( parent.getChild( 0 ) ).to.instanceof( Element );
+			expect( parent.getChild( 1 ) ).to.instanceof( Text );
+			expect( parent.getChild( 2 ) ).to.instanceof( Element );
+		} );
+
+		it( 'should create proper delta', () => {
+			const parent = batch.createDocumentFragment();
+			const spy = sinon.spy( doc, 'applyOperation' );
+
+			batch.insertText( 'foo', parent );
+
+			sinon.assert.calledOnce( spy );
+			expect( spy.lastCall.args[ 0 ].type ).to.equal( 'insert' );
+			expect( spy.lastCall.args[ 0 ].delta ).to.instanceof( WeakInsertDelta );
+			expect( spy.lastCall.args[ 0 ].delta.batch ).to.equal( batch );
+		} );
+	} );
+
+	describe( 'insertElement()', () => {
+		let doc, batch;
+
+		beforeEach( () => {
+			doc = new Document();
+			batch = doc.batch();
+		} );
+
+		it( 'should create and insert element with attributes at given position', () => {
+			const parent = batch.createDocumentFragment();
+
+			batch.insertElement( 'foo', { bar: 'biz' }, new Position( parent, [ 0 ] ) );
+
+			expect( parent.childCount ).to.equal( 1 );
+			expect( parent.getChild( 0 ) ).to.instanceof( Element );
+			expect( parent.getChild( 0 ).name ).to.equal( 'foo' );
+			expect( Array.from( parent.getChild( 0 ).getAttributes() ) ).to.deep.equal( [ [ 'bar', 'biz' ] ] );
+		} );
+
+		it( 'should create and insert element with no attributes at given position', () => {
+			const parent = batch.createDocumentFragment();
+
+			batch.insertElement( 'foo', null, new Position( parent, [ 0 ] ) );
+
+			expect( parent.childCount ).to.equal( 1 );
+			expect( parent.getChild( 0 ) ).to.instanceof( Element );
+			expect( parent.getChild( 0 ).name ).to.equal( 'foo' );
+			expect( Array.from( parent.getChild( 0 ).getAttributes() ) ).to.deep.equal( [] );
+		} );
+
+		it( 'should create and insert element with no attributes omitting attributes param', () => {
+			const parent = batch.createDocumentFragment();
+
+			batch.insertElement( 'foo', new Position( parent, [ 0 ] ) );
+
+			expect( parent.childCount ).to.equal( 1 );
+			expect( parent.getChild( 0 ) ).to.instanceof( Element );
+			expect( parent.getChild( 0 ).name ).to.equal( 'foo' );
+			expect( Array.from( parent.getChild( 0 ).getAttributes() ) ).to.deep.equal( [] );
+		} );
+
+		it( 'should create and insert element at the beginning of given element', () => {
+			const parent = batch.createDocumentFragment();
+
+			batch.insert( batch.createElement( 'child' ), parent );
+
+			batch.insertElement( 'foo', parent );
+
+			expect( parent.childCount ).to.equal( 2 );
+			expect( parent.getChild( 0 ).name ).to.equal( 'foo' );
+			expect( parent.getChild( 1 ).name ).to.equal( 'child' );
+		} );
+
+		it( 'should create and insert element at the end of given element', () => {
+			const parent = batch.createDocumentFragment();
+
+			batch.insert( batch.createElement( 'child' ), parent );
+
+			batch.insertElement( 'foo', parent, 'end' );
+
+			expect( parent.childCount ).to.equal( 2 );
+			expect( parent.getChild( 0 ).name ).to.equal( 'child' );
+			expect( parent.getChild( 1 ).name ).to.equal( 'foo' );
+		} );
+
+		it( 'should create and insert element at the given offset of given element', () => {
+			const parent = batch.createDocumentFragment();
+
+			batch.insert( batch.createElement( 'child1' ), parent );
+			batch.insert( batch.createElement( 'child2' ), parent, 'end' );
+
+			batch.insertElement( 'foo', parent, 1 );
+
+			expect( parent.childCount ).to.equal( 3 );
+			expect( parent.getChild( 0 ).name ).to.equal( 'child1' );
+			expect( parent.getChild( 1 ).name ).to.equal( 'foo' );
+			expect( parent.getChild( 2 ).name ).to.equal( 'child2' );
+		} );
+
+		it( 'should create and insert element before the given node', () => {
+			const parent = batch.createDocumentFragment();
+			const child1 = batch.createElement( 'child1' );
+			const child2 = batch.createElement( 'child2' );
+
+			batch.insert( child1, parent );
+			batch.insert( child2, parent, 'end' );
+
+			batch.insertElement( 'foo', child2, 'before' );
+
+			expect( parent.childCount ).to.equal( 3 );
+			expect( parent.getChild( 0 ).name ).to.equal( 'child1' );
+			expect( parent.getChild( 1 ).name ).to.equal( 'foo' );
+			expect( parent.getChild( 2 ).name ).to.equal( 'child2' );
+		} );
+
+		it( 'should create and insert element after the given node', () => {
+			const parent = batch.createDocumentFragment();
+			const child1 = batch.createElement( 'child1' );
+			const child2 = batch.createElement( 'child2' );
+
+			batch.insert( child1, parent );
+			batch.insert( child2, parent, 'end' );
+
+			batch.insertElement( 'foo', child1, 'after' );
+
+			expect( parent.childCount ).to.equal( 3 );
+			expect( parent.getChild( 0 ).name ).to.equal( 'child1' );
+			expect( parent.getChild( 1 ).name ).to.equal( 'foo' );
+			expect( parent.getChild( 2 ).name ).to.equal( 'child2' );
+		} );
+
+		it( 'should create proper delta', () => {
+			const parent = batch.createDocumentFragment();
+			const spy = sinon.spy( doc, 'applyOperation' );
+
+			batch.insertText( 'foo', parent );
+
+			sinon.assert.calledOnce( spy );
+			expect( spy.lastCall.args[ 0 ].type ).to.equal( 'insert' );
+			expect( spy.lastCall.args[ 0 ].delta ).to.instanceof( InsertDelta );
+			expect( spy.lastCall.args[ 0 ].delta.batch ).to.equal( batch );
+		} );
+	} );
+
+	describe( 'append()', () => {
+		let doc, batch;
+
+		beforeEach( () => {
+			doc = new Document();
+			batch = doc.batch();
+		} );
+
+		it( 'should insert element at the end of the parent', () => {
+			const parent = doc.batch().createDocumentFragment();
+			const childText = doc.batch().createText( 'foo' );
+			const childElement = doc.batch().createElement( 'foo' );
+
+			batch.append( childText, parent );
+			batch.append( childElement, parent );
+
+			expect( Array.from( parent ) ).to.deep.equal( [ childText, childElement ] );
+		} );
+
+		it( 'should create proper delta', () => {
+			const parent = batch.createDocumentFragment();
+			const text = batch.createText( 'foo' );
+
+			const spy = sinon.spy( doc, 'applyOperation' );
+
+			batch.append( text, parent );
+
+			sinon.assert.calledOnce( spy );
+			expect( spy.lastCall.args[ 0 ].type ).to.equal( 'insert' );
+			expect( spy.lastCall.args[ 0 ].delta.batch ).to.equal( batch );
+		} );
+
+		it( 'should move element from one parent to the other within the same document (documentA -> documentA)', () => {
+			const rootA = doc.createRoot();
+			const parent1 = batch.createElement( 'parent' );
+			const parent2 = batch.createElement( 'parent' );
+			const node = batch.createText( 'foo' );
+
+			batch.insert( node, parent1 );
+			batch.insert( parent1, rootA );
+			batch.insert( parent2, rootA );
+
+			const spy = sinon.spy( doc, 'applyOperation' );
+
+			batch.append( node, parent2 );
+
+			// Verify result.
+			expect( Array.from( parent1.getChildren() ) ).to.deep.equal( [] );
+			expect( Array.from( parent2.getChildren() ) ).to.deep.equal( [ node ] );
+
+			// Verify deltas and operations.
+			sinon.assert.calledOnce( spy );
+			expect( spy.firstCall.args[ 0 ].type ).to.equal( 'move' );
+			expect( spy.firstCall.args[ 0 ].delta.batch ).to.equal( batch );
+		} );
+
+		it( 'should move element from one parent to the other within the same document (documentA -> documentB)', () => {
+			const rootA = doc.createRoot( '$root', 'A' );
+			const rootB = doc.createRoot( '$root', 'B' );
+			const node = batch.createText( 'foo' );
+
+			batch.insert( node, rootA );
+
+			const spy = sinon.spy( doc, 'applyOperation' );
+
+			batch.append( node, rootB );
+
+			// Verify result.
+			expect( Array.from( rootA.getChildren() ) ).to.deep.equal( [] );
+			expect( Array.from( rootB.getChildren() ) ).to.deep.equal( [ node ] );
+
+			// Verify deltas and operations.
+			sinon.assert.calledOnce( spy );
+			expect( spy.firstCall.args[ 0 ].type ).to.equal( 'move' );
+			expect( spy.firstCall.args[ 0 ].delta.batch ).to.equal( batch );
+		} );
+
+		it( 'should move element from one parent to the other within the same document (docFragA -> docFragA)', () => {
+			const docFragA = batch.createDocumentFragment();
+			const parent1 = batch.createElement( 'parent' );
+			const parent2 = batch.createElement( 'parent' );
+			const node = batch.createText( 'foo' );
+
+			batch.insert( node, parent1 );
+			batch.insert( parent1, docFragA );
+			batch.insert( parent2, docFragA );
+
+			const spy = sinon.spy( doc, 'applyOperation' );
+
+			batch.append( node, parent2 );
+
+			// Verify result.
+			expect( Array.from( parent1.getChildren() ) ).to.deep.equal( [] );
+			expect( Array.from( parent2.getChildren() ) ).to.deep.equal( [ node ] );
+
+			// Verify deltas and operations.
+			sinon.assert.calledOnce( spy );
+			expect( spy.firstCall.args[ 0 ].type ).to.equal( 'move' );
+			expect( spy.firstCall.args[ 0 ].delta.batch ).to.equal( batch );
+		} );
+
+		it( 'should move element from one parent to the other within different document (document -> docFrag)', () => {
+			const root = doc.createRoot();
+			const docFrag = batch.createDocumentFragment();
+			const node = batch.createText( 'foo' );
+
+			batch.insert( node, root );
+
+			const spy = sinon.spy( doc, 'applyOperation' );
+
+			batch.append( node, docFrag );
+
+			// Verify result.
+			expect( Array.from( root.getChildren() ) ).to.deep.equal( [] );
+			expect( Array.from( docFrag.getChildren() ) ).to.deep.equal( [ node ] );
+
+			// Verify deltas and operations.
+			sinon.assert.calledTwice( spy );
+			expect( spy.firstCall.args[ 0 ].type ).to.equal( 'remove' );
+			expect( spy.firstCall.args[ 0 ].delta.batch ).to.equal( batch );
+			expect( spy.secondCall.args[ 0 ].type ).to.equal( 'insert' );
+			expect( spy.secondCall.args[ 0 ].delta.batch ).to.equal( batch );
+		} );
+
+		it( 'should move element from one parent to the other within different document (docFragA -> docFragB)', () => {
+			const docFragA = batch.createDocumentFragment();
+			const docFragB = batch.createDocumentFragment();
+			const node = batch.createText( 'foo' );
+
+			batch.insert( node, docFragA );
+
+			const spy = sinon.spy( doc, 'applyOperation' );
+
+			batch.append( node, docFragB );
+
+			// Verify result.
+			expect( Array.from( docFragA ) ).to.deep.equal( [] );
+			expect( Array.from( docFragB ) ).to.deep.equal( [ node ] );
+
+			// Verify deltas and operations.
+			sinon.assert.calledTwice( spy );
+			expect( spy.firstCall.args[ 0 ].type ).to.equal( 'detach' );
+			expect( spy.firstCall.args[ 0 ].delta.batch ).to.equal( batch );
+			expect( spy.secondCall.args[ 0 ].type ).to.equal( 'insert' );
+			expect( spy.secondCall.args[ 0 ].delta.batch ).to.equal( batch );
+		} );
+	} );
+
+	describe( 'appendText()', () => {
+		let doc, batch;
+
+		beforeEach( () => {
+			doc = new Document();
+			batch = doc.batch();
+		} );
+
+		it( 'should create and insert text node with attributes at the end of the parent', () => {
+			const parent = batch.createDocumentFragment();
+
+			batch.appendText( 'foo', { bar: 'biz' }, parent );
+			batch.appendText( 'bar', { biz: 'bar' }, parent );
+
+			expect( parent.childCount ).to.equal( 2 );
+			expect( parent.getChild( 0 ).data ).to.equal( 'foo' );
+			expect( Array.from( parent.getChild( 0 ).getAttributes() ) ).to.deep.equal( [ [ 'bar', 'biz' ] ] );
+			expect( parent.getChild( 1 ).data ).to.equal( 'bar' );
+			expect( Array.from( parent.getChild( 1 ).getAttributes() ) ).to.deep.equal( [ [ 'biz', 'bar' ] ] );
+		} );
+
+		it( 'should create and insert text node with no attributes at the end of the parent', () => {
+			const parent = batch.createDocumentFragment();
+
+			batch.appendText( 'foo', null, parent );
+
+			expect( parent.childCount ).to.equal( 1 );
+			expect( parent.getChild( 0 ) ).to.instanceof( Text );
+			expect( parent.getChild( 0 ).data ).to.equal( 'foo' );
+			expect( Array.from( parent.getChild( 0 ).getAttributes() ) ).to.deep.equal( [] );
+		} );
+
+		it( 'should create and insert text node with no attributes omitting attributes param', () => {
+			const parent = batch.createDocumentFragment();
+
+			batch.appendText( 'foo', parent );
+
+			expect( parent.childCount ).to.equal( 1 );
+			expect( parent.getChild( 0 ) ).to.instanceof( Text );
+			expect( parent.getChild( 0 ).data ).to.equal( 'foo' );
+			expect( Array.from( parent.getChild( 0 ).getAttributes() ) ).to.deep.equal( [] );
+		} );
+
+		it( 'should create proper delta and operations', () => {
+			const parent = batch.createDocumentFragment();
+			const spy = sinon.spy( doc, 'applyOperation' );
+
+			batch.appendText( 'foo', parent );
+
+			sinon.assert.calledOnce( spy );
+			expect( spy.firstCall.args[ 0 ].type ).to.equal( 'insert' );
+			expect( spy.firstCall.args[ 0 ].delta ).to.instanceof( WeakInsertDelta );
+			expect( spy.firstCall.args[ 0 ].delta.batch ).to.equal( batch );
+		} );
+	} );
+
+	describe( 'appendElement()', () => {
+		let doc, batch;
+
+		beforeEach( () => {
+			doc = new Document();
+			batch = doc.batch();
+		} );
+
+		it( 'should create and insert element with attributes at the end of the parent', () => {
+			const parent = batch.createDocumentFragment();
+
+			batch.appendElement( 'foo', { bar: 'biz' }, parent );
+			batch.appendElement( 'bar', { biz: 'bar' }, parent );
+
+			expect( parent.childCount ).to.equal( 2 );
+			expect( parent.getChild( 0 ).name ).to.equal( 'foo' );
+			expect( Array.from( parent.getChild( 0 ).getAttributes() ) ).to.deep.equal( [ [ 'bar', 'biz' ] ] );
+			expect( parent.getChild( 1 ).name ).to.equal( 'bar' );
+			expect( Array.from( parent.getChild( 1 ).getAttributes() ) ).to.deep.equal( [ [ 'biz', 'bar' ] ] );
+		} );
+
+		it( 'should create and insert element with no attributes at the end of the parent', () => {
+			const parent = batch.createDocumentFragment();
+
+			batch.appendElement( 'foo', null, parent );
+
+			expect( parent.childCount ).to.equal( 1 );
+			expect( parent.getChild( 0 ).name ).to.equal( 'foo' );
+			expect( Array.from( parent.getChild( 0 ).getAttributes() ) ).to.deep.equal( [] );
+		} );
+
+		it( 'should create and insert element with no attributes omitting attributes param', () => {
+			const parent = batch.createDocumentFragment();
+
+			batch.appendElement( 'foo', parent );
+
+			expect( parent.childCount ).to.equal( 1 );
+			expect( parent.getChild( 0 ).name ).to.equal( 'foo' );
+			expect( Array.from( parent.getChild( 0 ).getAttributes() ) ).to.deep.equal( [] );
+		} );
+
+		it( 'should create proper delta and operation', () => {
+			const parent = batch.createDocumentFragment();
+			const spy = sinon.spy( doc, 'applyOperation' );
+
+			batch.appendElement( 'foo', parent );
+
+			sinon.assert.calledOnce( spy );
+			expect( spy.firstCall.args[ 0 ].type ).to.equal( 'insert' );
+			expect( spy.firstCall.args[ 0 ].delta ).to.instanceof( InsertDelta ).to.not.instanceof( WeakInsertDelta );
+			expect( spy.firstCall.args[ 0 ].delta.batch ).to.equal( batch );
+		} );
+	} );
+
+	describe( 'setAttribute() / removeAttribute()', () => {
+		let batch, doc, root, spy;
+
+		beforeEach( () => {
+			doc = new Document();
+			root = doc.createRoot();
+			batch = doc.batch();
+		} );
+
+		describe( 'change attribute on node', () => {
+			let node, text;
+
+			beforeEach( () => {
+				node = batch.createElement( 'p', { a: 1 } );
+				text = batch.createText( 'c', { a: 1 } );
+
+				batch.append( node, root );
+				batch.append( text, root );
+
+				spy = sinon.spy( doc, 'applyOperation' );
+			} );
+
+			describe( 'setAttribute', () => {
+				it( 'should create the attribute on element', () => {
+					batch.setAttribute( 'b', 2, node );
+					expect( spy.callCount ).to.equal( 1 );
+					expect( node.getAttribute( 'b' ) ).to.equal( 2 );
+				} );
+
+				it( 'should change the attribute of element', () => {
+					batch.setAttribute( 'a', 2, node );
+					expect( spy.callCount ).to.equal( 1 );
+					expect( node.getAttribute( 'a' ) ).to.equal( 2 );
+				} );
+
+				it( 'should create the attribute on text node', () => {
+					batch.setAttribute( 'b', 2, text );
+					expect( spy.callCount ).to.equal( 1 );
+					expect( root.getChild( 1 ).getAttribute( 'b' ) ).to.equal( 2 );
+				} );
+
+				it( 'should change the attribute of text node', () => {
+					batch.setAttribute( 'a', 2, text );
+					expect( spy.callCount ).to.equal( 1 );
+					expect( root.getChild( 1 ).getAttribute( 'a' ) ).to.equal( 2 );
+				} );
+
+				it( 'should do nothing if the attribute value is the same', () => {
+					batch.setAttribute( 'a', 1, node );
+					expect( spy.callCount ).to.equal( 0 );
+					expect( node.getAttribute( 'a' ) ).to.equal( 1 );
+				} );
+			} );
+
+			describe( 'removeAttribute', () => {
+				it( 'should remove the attribute from element', () => {
+					batch.removeAttribute( 'a', node );
+					expect( spy.callCount ).to.equal( 1 );
+					expect( node.getAttribute( 'a' ) ).to.be.undefined;
+				} );
+
+				it( 'should remove the attribute from character', () => {
+					batch.removeAttribute( 'a', text );
+					expect( spy.callCount ).to.equal( 1 );
+					expect( root.getChild( 1 ).getAttribute( 'a' ) ).to.be.undefined;
+				} );
+
+				it( 'should do nothing if the attribute is not set', () => {
+					batch.removeAttribute( 'b', node );
+					expect( spy.callCount ).to.equal( 0 );
+				} );
+			} );
+		} );
+
+		describe( 'change attribute on range', () => {
+			beforeEach( () => {
+				const element = batch.createElement( 'e', { a: 2 } );
+
+				batch.appendText( 'xxx', { a: 1 }, root );
+				batch.appendText( 'xxx', root );
+				batch.appendText( 'xxx', { a: 1 }, root );
+				batch.appendText( 'xxx', { a: 2 }, root );
+				batch.appendText( 'xxx', root );
+				batch.appendText( 'xxx', { a: 1 }, root );
+				batch.appendText( 'xxx', element );
+				batch.append( element, root );
+				batch.appendText( 'xxx', root );
+
+				spy = sinon.spy( doc, 'applyOperation' );
+			} );
+
+			function getRange( startIndex, endIndex ) {
+				return new Range(
+					Position.createFromParentAndOffset( root, startIndex ),
+					Position.createFromParentAndOffset( root, endIndex )
+				);
+			}
+
+			function getChangesAttrsCount() {
+				let totalNumber = 0;
+
+				for ( const delta of batch.deltas ) {
+					for ( const operation of delta.operations ) {
+						if ( operation.range ) {
+							totalNumber += count( operation.range.getItems( { singleCharacters: true } ) );
+						}
+					}
+				}
+
+				return totalNumber;
+			}
+
+			function getCompressedAttrs() {
+				// default: 111---111222---1112------
+				const range = Range.createIn( root );
+
+				return Array.from( range.getItems( { singleCharacters: true } ) )
+					.map( item => item.getAttribute( 'a' ) || '-' )
+					.join( '' );
+			}
+
+			describe( 'setAttribute', () => {
+				it( 'should set the attribute on the range', () => {
+					batch.setAttribute( 'a', 3, getRange( 3, 6 ) );
+					expect( spy.callCount ).to.equal( 1 );
+					expect( getChangesAttrsCount() ).to.equal( 3 );
+					expect( getCompressedAttrs() ).to.equal( '111333111222---1112------' );
+				} );
+
+				it( 'should split the operations if parts of the range have different attributes', () => {
+					batch.setAttribute( 'a', 3, getRange( 4, 14 ) );
+					expect( spy.callCount ).to.equal( 4 );
+					expect( getChangesAttrsCount() ).to.equal( 10 );
+					expect( getCompressedAttrs() ).to.equal( '111-3333333333-1112------' );
+				} );
+
+				it( 'should split the operations if parts of the part of the range have the attribute', () => {
+					batch.setAttribute( 'a', 2, getRange( 4, 14 ) );
+					expect( spy.callCount ).to.equal( 3 );
+					expect( getChangesAttrsCount() ).to.equal( 7 );
+					expect( getCompressedAttrs() ).to.equal( '111-2222222222-1112------' );
+				} );
+
+				it( 'should strip the range if the beginning have the attribute', () => {
+					batch.setAttribute( 'a', 1, getRange( 1, 5 ) );
+					expect( spy.callCount ).to.equal( 1 );
+					expect( getChangesAttrsCount() ).to.equal( 2 );
+					expect( getCompressedAttrs() ).to.equal( '11111-111222---1112------' );
+				} );
+
+				it( 'should strip the range if the ending have the attribute', () => {
+					batch.setAttribute( 'a', 1, getRange( 13, 17 ) );
+					expect( spy.callCount ).to.equal( 1 );
+					expect( getChangesAttrsCount() ).to.equal( 2 );
+					expect( getCompressedAttrs() ).to.equal( '111---111222-111112------' );
+				} );
+
+				it( 'should do nothing if the range has attribute', () => {
+					batch.setAttribute( 'a', 1, getRange( 0, 3 ) );
+					expect( spy.callCount ).to.equal( 0 );
+					expect( getCompressedAttrs() ).to.equal( '111---111222---1112------' );
+				} );
+
+				it( 'should not check range\'s start position node when creating operations', () => {
+					const range = new Range(
+						new Position( root, [ 18, 1 ] ),
+						new Position( root, [ 19 ] )
+					);
+
+					batch.setAttribute( 'a', 1, range );
+					expect( spy.callCount ).to.equal( 1 );
+					expect( getChangesAttrsCount() ).to.equal( 2 );
+					expect( getCompressedAttrs() ).to.equal( '111---111222---1112-11---' );
+				} );
+
+				it( 'should not change elements attribute if range contains closing tag', () => {
+					const range = new Range(
+						new Position( root, [ 18, 1 ] ),
+						new Position( root, [ 21 ] )
+					);
+
+					batch.setAttribute( 'a', 1, range );
+					expect( spy.callCount ).to.equal( 1 );
+					expect( getChangesAttrsCount() ).to.equal( 4 );
+					expect( getCompressedAttrs() ).to.equal( '111---111222---1112-1111-' );
+				} );
+
+				it( 'should not create an operation if the range contains only closing tag', () => {
+					const range = new Range(
+						new Position( root, [ 18, 3 ] ),
+						new Position( root, [ 19 ] )
+					);
+
+					batch.setAttribute( 'a', 3, range );
+					expect( spy.callCount ).to.equal( 0 );
+					expect( getCompressedAttrs() ).to.equal( '111---111222---1112------' );
+				} );
+
+				it( 'should not create an operation if is collapsed', () => {
+					batch.setAttribute( 'a', 1, getRange( 3, 3 ) );
+					expect( spy.callCount ).to.equal( 0 );
+					expect( getCompressedAttrs() ).to.equal( '111---111222---1112------' );
+				} );
+
+				it( 'should create a proper operations for the mixed range', () => {
+					batch.setAttribute( 'a', 1, getRange( 0, 20 ) );
+					expect( spy.callCount ).to.equal( 5 );
+					expect( getChangesAttrsCount() ).to.equal( 14 );
+					expect( getCompressedAttrs() ).to.equal( '11111111111111111111111--' );
+				} );
+			} );
+
+			describe( 'removeAttribute', () => {
+				it( 'should remove the attribute on the range', () => {
+					batch.removeAttribute( 'a', getRange( 0, 2 ) );
+					expect( spy.callCount ).to.equal( 1 );
+					expect( getChangesAttrsCount() ).to.equal( 2 );
+					expect( getCompressedAttrs() ).to.equal( '--1---111222---1112------' );
+				} );
+
+				it( 'should split the operations if parts of the range have different attributes', () => {
+					batch.removeAttribute( 'a', getRange( 7, 11 ) );
+					expect( spy.callCount ).to.equal( 2 );
+					expect( getChangesAttrsCount() ).to.equal( 4 );
+					expect( getCompressedAttrs() ).to.equal( '111---1----2---1112------' );
+				} );
+
+				it( 'should split the operations if parts of the part of the range have no attribute', () => {
+					batch.removeAttribute( 'a', getRange( 1, 7 ) );
+					expect( spy.callCount ).to.equal( 2 );
+					expect( getChangesAttrsCount() ).to.equal( 3 );
+					expect( getCompressedAttrs() ).to.equal( '1------11222---1112------' );
+				} );
+
+				it( 'should strip the range if the beginning have no attribute', () => {
+					batch.removeAttribute( 'a', getRange( 4, 12 ) );
+					expect( spy.callCount ).to.equal( 2 );
+					expect( getChangesAttrsCount() ).to.equal( 6 );
+					expect( getCompressedAttrs() ).to.equal( '111------------1112------' );
+				} );
+
+				it( 'should strip the range if the ending have no attribute', () => {
+					batch.removeAttribute( 'a', getRange( 7, 15 ) );
+					expect( spy.callCount ).to.equal( 2 );
+					expect( getChangesAttrsCount() ).to.equal( 5 );
+					expect( getCompressedAttrs() ).to.equal( '111---1--------1112------' );
+				} );
+
+				it( 'should do nothing if the range has no attribute', () => {
+					batch.removeAttribute( 'a', getRange( 4, 5 ) );
+					expect( spy.callCount ).to.equal( 0 );
+					expect( getCompressedAttrs() ).to.equal( '111---111222---1112------' );
+				} );
+
+				it( 'should not check range\'s start position node when creating operations', () => {
+					const range = new Range(
+						new Position( root, [ 18, 3 ] ),
+						new Position( root, [ 19 ] )
+					);
+
+					batch.removeAttribute( 'a', range );
+					expect( spy.callCount ).to.equal( 0 );
+					expect( getChangesAttrsCount() ).to.equal( 0 );
+					expect( getCompressedAttrs() ).to.equal( '111---111222---1112------' );
+				} );
+
+				it( 'should not apply operation twice in the range contains opening and closing tags', () => {
+					batch.removeAttribute( 'a', getRange( 18, 22 ) );
+					expect( spy.callCount ).to.equal( 1 );
+					expect( getChangesAttrsCount() ).to.equal( 1 );
+					expect( getCompressedAttrs() ).to.equal( '111---111222---111-------' );
+				} );
+
+				it( 'should not create an operation if range is collapsed', () => {
+					batch.removeAttribute( 'a', getRange( 3, 3 ) );
+					expect( spy.callCount ).to.equal( 0 );
+					expect( getCompressedAttrs() ).to.equal( '111---111222---1112------' );
+				} );
+
+				it( 'should create a proper operations for the mixed range', () => {
+					batch.removeAttribute( 'a', getRange( 3, 15 ) );
+					expect( spy.callCount ).to.equal( 2 );
+					expect( getChangesAttrsCount() ).to.equal( 6 );
+					expect( getCompressedAttrs() ).to.equal( '111------------1112------' );
+				} );
+			} );
+		} );
+
+		describe( 'change attribute on root element', () => {
+			let p;
+
+			beforeEach( () => {
+				p = batch.createElement( 'p', { a: 3 } );
+				spy = sinon.spy( doc, 'applyOperation' );
+			} );
+
+			describe( 'setAttribute', () => {
+				it( 'should create the attribute on root', () => {
+					batch.setAttribute( 'b', 2, root );
+					expect( spy.callCount ).to.equal( 1 );
+					expect( root.getAttribute( 'b' ) ).to.equal( 2 );
+				} );
+
+				it( 'should create the attribute on detached root', () => {
+					batch.setAttribute( 'b', 2, p );
+					expect( spy.callCount ).to.equal( 1 );
+					expect( p.getAttribute( 'b' ) ).to.equal( 2 );
+				} );
+
+				it( 'should change the attribute of root', () => {
+					batch.setAttribute( 'a', 2, root );
+					expect( spy.callCount ).to.equal( 1 );
+					expect( root.getAttribute( 'a' ) ).to.equal( 2 );
+				} );
+
+				it( 'should change the attribute of detached root', () => {
+					batch.setAttribute( 'a', 2, p );
+					expect( spy.callCount ).to.equal( 1 );
+					expect( p.getAttribute( 'a' ) ).to.equal( 2 );
+				} );
+
+				it( 'should do nothing if the attribute value is the same', () => {
+					batch.setAttribute( 'a', 1, root );
+					expect( spy.callCount ).to.equal( 1 );
+					batch.setAttribute( 'a', 1, root );
+					expect( spy.callCount ).to.equal( 1 );
+					expect( root.getAttribute( 'a' ) ).to.equal( 1 );
+				} );
+
+				it( 'should do nothing if the attribute value is the same on detached root', () => {
+					batch.setAttribute( 'a', 1, p );
+					expect( spy.callCount ).to.equal( 1 );
+					batch.setAttribute( 'a', 1, p );
+					expect( spy.callCount ).to.equal( 1 );
+					expect( p.getAttribute( 'a' ) ).to.equal( 1 );
+				} );
+			} );
+
+			describe( 'removeAttribute', () => {
+				it( 'should remove the attribute from root', () => {
+					batch.setAttribute( 'a', 1, root );
+					batch.removeAttribute( 'a', root );
+
+					expect( spy.callCount ).to.equal( 2 );
+					expect( root.getAttribute( 'a' ) ).to.be.undefined;
+				} );
+
+				it( 'should do nothing if the attribute is not set', () => {
+					batch.removeAttribute( 'b', root );
+					expect( spy.callCount ).to.equal( 0 );
+				} );
+			} );
+
+			describe( 'clearAttributes', () => {
+				it( 'should clear attributes from range', () => {
+					batch.appendText( 'xxx', { a: 1, b: 2, c: 3 }, root );
+					batch.appendText( 'xxx', root );
+					batch.appendText( 'xxx', { a: 1 }, root );
+					batch.appendText( 'xxx', { b: 2 }, root );
+					batch.appendText( 'xxx', root );
+					batch.appendElement( 'e', { a: 1 }, root );
+					batch.appendText( 'xxx', root );
+
+					const range = Range.createIn( root );
+
+					batch.clearAttributes( range );
+
+					let itemsCount = 0;
+
+					for ( const item of range.getItems() ) {
+						itemsCount++;
+						expect( Array.from( item.getAttributeKeys() ).length ).to.equal( 0 );
+					}
+
+					expect( itemsCount ).to.equal( 3 );
+				} );
+
+				it( 'should clear attributes on element', () => {
+					const element = batch.createElement( 'x', { a: 1, b: 2, c: 3 }, root );
+
+					expect( Array.from( element.getAttributeKeys() ).length ).to.equal( 3 );
+
+					batch.clearAttributes( element );
+
+					expect( Array.from( element.getAttributeKeys() ).length ).to.equal( 0 );
+				} );
+
+				it( 'should clear attributes on root element', () => {
+					batch.setAttributes( { a: 1, b: 2, c: 3 }, root );
+
+					expect( Array.from( root.getAttributeKeys() ).length ).to.equal( 3 );
+
+					batch.clearAttributes( root );
+
+					expect( Array.from( root.getAttributeKeys() ).length ).to.equal( 0 );
+				} );
+
+				it( 'should do nothing if there are no attributes', () => {
+					const element = batch.createElement( 'x' );
+
+					expect( Array.from( element.getAttributeKeys() ).length ).to.equal( 0 );
+
+					batch.clearAttributes( element );
+
+					expect( Array.from( element.getAttributeKeys() ).length ).to.equal( 0 );
+				} );
+			} );
+		} );
+
+		it( 'should not add empty delta to the batch', () => {
+			const nodeA = new Element( 'p', { a: 1 } );
+			const nodeB = new Element( 'p', { b: 2 } );
+			root.insertChildren( 0, [ nodeA, nodeB ] );
+
+			batch.setAttribute( 'a', 1, nodeA );
+
+			expect( batch.deltas.length ).to.equal( 0 );
+
+			batch.removeAttribute( 'x', Range.createIn( root ) );
+
+			expect( batch.deltas.length ).to.equal( 0 );
+		} );
+	} );
+
+	describe( 'setAttributes()', () => {
+		let doc, batch, frag, item;
+
+		beforeEach( () => {
+			doc = new Document();
+			batch = doc.batch();
+
+			frag = batch.createDocumentFragment();
+			item = batch.createText( 'xxx', { b: 2, c: 3 } );
+
+			batch.appendText( 'xxx', { a: 1 }, frag );
+			batch.append( item, frag );
+		} );
+
+		it( 'should set attributes one by one on range', () => {
+			const range = Range.createIn( frag );
+
+			// `setAttribute` is a not trivial operation and is deeply tested above, there is no point to duplicate
+			// such a big amount of the same tests, so let's use a spy here.
+			const spy = sinon.spy( batch, 'setAttribute' );
+
+			batch.setAttributes( { a: 3, c: null }, range );
+
+			// Verify result.
+			expect( Array.from( frag.getChild( 0 ).getAttributes() ) ).to.deep.equal( [ [ 'a', 3 ] ] );
+			expect( Array.from( frag.getChild( 1 ).getAttributes() ) ).to.deep.equal( [ [ 'b', 2 ], [ 'a', 3 ] ] );
+
+			// Verify operations
+			sinon.assert.calledTwice( spy );
+			sinon.assert.calledWith( spy.firstCall, 'a', 3, range );
+			sinon.assert.calledWith( spy.secondCall, 'c', null, range );
+		} );
+
+		it( 'should set attributes one by one on range for map as attributes list', () => {
+			const range = Range.createIn( frag );
+
+			// `setAttribute` is a not trivial operation and is deeply tested above, there is no point to duplicate
+			// such a big amount of the same tests, so let's use a spy here.
+			const spy = sinon.spy( batch, 'setAttribute' );
+
+			batch.setAttributes( new Map( [ [ 'a', 3 ], [ 'c', null ] ] ), range );
+
+			// Verify result.
+			expect( Array.from( frag.getChild( 0 ).getAttributes() ) ).to.deep.equal( [ [ 'a', 3 ] ] );
+			expect( Array.from( frag.getChild( 1 ).getAttributes() ) ).to.deep.equal( [ [ 'b', 2 ], [ 'a', 3 ] ] );
+
+			// Verify operations
+			sinon.assert.calledTwice( spy );
+			sinon.assert.calledWith( spy.firstCall, 'a', 3, range );
+			sinon.assert.calledWith( spy.secondCall, 'c', null, range );
+		} );
+
+		it( 'should set attributes one by one on item', () => {
+			// `setAttribute` is a not trivial operation and is deeply tested above, there is no point to duplicate
+			// such a big amount of the same tests, so let's use a spy here.
+			const spy = sinon.spy( batch, 'setAttribute' );
+
+			batch.setAttributes( { a: 3, c: null }, item );
+
+			// Verify result.
+			expect( Array.from( item.getAttributes() ) ).to.deep.equal( [ [ 'b', 2 ], [ 'a', 3 ] ] );
+
+			// Verify operations
+			sinon.assert.calledTwice( spy );
+			sinon.assert.calledWith( spy.firstCall, 'a', 3, item );
+			sinon.assert.calledWith( spy.secondCall, 'c', null, item );
+		} );
+
+		it( 'should set attributes one by one on item for maps as attributes list', () => {
+			// `setAttribute` is a not trivial operation and is deeply tested above, there is no point to duplicate
+			// such a big amount of the same tests, so let's use a spy here.
+			const spy = sinon.spy( batch, 'setAttribute' );
+
+			batch.setAttributes( new Map( [ [ 'a', 3 ], [ 'c', null ] ] ), item );
+
+			// Verify result.
+			expect( Array.from( item.getAttributes() ) ).to.deep.equal( [ [ 'b', 2 ], [ 'a', 3 ] ] );
+
+			// Verify operations
+			sinon.assert.calledTwice( spy );
+			sinon.assert.calledWith( spy.firstCall, 'a', 3, item );
+			sinon.assert.calledWith( spy.secondCall, 'c', null, item );
+		} );
+	} );
+
+	describe( 'merge()', () => {
+		let doc, root, p1, p2;
+
+		beforeEach( () => {
+			doc = new Document();
+			root = doc.createRoot();
+
+			p1 = new Element( 'p', { key1: 'value1' }, new Text( 'foo' ) );
+			p2 = new Element( 'p', { key2: 'value2' }, new Text( 'bar' ) );
+
+			root.insertChildren( 0, [ p1, p2 ] );
+		} );
+
+		it( 'should merge foo and bar into foobar', () => {
+			doc.batch().merge( new Position( root, [ 1 ] ) );
+
+			expect( root.maxOffset ).to.equal( 1 );
+			expect( root.getChild( 0 ).name ).to.equal( 'p' );
+			expect( root.getChild( 0 ).maxOffset ).to.equal( 6 );
+			expect( count( root.getChild( 0 ).getAttributes() ) ).to.equal( 1 );
+			expect( root.getChild( 0 ).getAttribute( 'key1' ) ).to.equal( 'value1' );
+			expect( root.getChild( 0 ).getChild( 0 ).data ).to.equal( 'foobar' );
+		} );
+
+		it( 'should throw if there is no element after', () => {
+			expect( () => {
+				doc.batch().merge( new Position( root, [ 2 ] ) );
+			} ).to.throw( CKEditorError, /^batch-merge-no-element-after/ );
+		} );
+
+		it( 'should throw if there is no element before', () => {
+			expect( () => {
+				doc.batch().merge( new Position( root, [ 0, 2 ] ) );
+			} ).to.throw( CKEditorError, /^batch-merge-no-element-before/ );
+		} );
+	} );
+
+	describe( 'move()', () => {
+		let doc, root, range, div, p, batch;
+
+		beforeEach( () => {
+			doc = new Document();
+			root = doc.createRoot();
+
+			div = new Element( 'div', [], new Text( 'foobar' ) );
+			p = new Element( 'p', [], new Text( 'abcxyz' ) );
+
+			div.insertChildren( 0, [ new Element( 'p', [], new Text( 'gggg' ) ) ] );
+			div.insertChildren( 2, [ new Element( 'p', [], new Text( 'hhhh' ) ) ] );
+
+			root.insertChildren( 0, [ div, p ] );
+
+			range = new Range( new Position( root, [ 0, 3 ] ), new Position( root, [ 0, 7 ] ) );
+
+			batch = doc.batch();
+		} );
+
+		it( 'should move flat range of nodes', () => {
+			batch.move( range, new Position( root, [ 1, 3 ] ) );
+
+			expect( getNodesAndText( Range.createIn( root.getChild( 0 ) ) ) ).to.equal( 'PggggPfoPhhhhP' );
+			expect( getNodesAndText( Range.createIn( root.getChild( 1 ) ) ) ).to.equal( 'abcobarxyz' );
+		} );
+
+		it( 'should throw if object to move is not a range', () => {
+			expect( () => {
+				doc.batch().move( root.getChild( 0 ), new Position( root, [ 1, 3 ] ) );
+			} ).to.throw( CKEditorError, /^batch-move-invalid-range/ );
+		} );
+
+		it( 'should throw if given range is not flat', () => {
+			const notFlatRange = new Range( new Position( root, [ 0, 2, 2 ] ), new Position( root, [ 0, 6 ] ) );
+
+			expect( () => {
+				doc.batch().move( notFlatRange, new Position( root, [ 1, 3 ] ) );
+			} ).to.throw( CKEditorError, /^batch-move-range-not-flat/ );
+		} );
+
+		it( 'should throw if range is going to be moved to the other document', () => {
+			const docFrag = batch.createDocumentFragment();
+
+			expect( () => {
+				doc.batch().move( range, docFrag );
+			} ).to.throw( CKEditorError, /^batch-move-different-document/ );
+		} );
+	} );
+
+	describe( 'remove()', () => {
+		let doc, batch, div, p, range;
+
+		beforeEach( () => {
+			doc = new Document();
+			batch = doc.batch();
+
+			div = batch.createElement( 'div' );
+			batch.appendText( 'foobar', div );
+
+			p = batch.createElement( 'p' );
+			batch.appendText( 'abcxyz', p );
+
+			batch.insertElement( 'p', div );
+			batch.appendElement( 'p', div );
+
+			batch.insertText( 'gggg', new Position( div, [ 0, 0 ] ) );
+			batch.insertText( 'hhhh', new Position( div, [ 7, 0 ] ) );
+		} );
+
+		describe( 'remove from document', () => {
+			let root;
+
+			beforeEach( () => {
+				root = doc.createRoot();
+				batch.append( div, root );
+				batch.append( p, root );
+
+				// Reset batch.
+				batch = doc.batch();
+
+				// Range starts in ROOT > DIV > P > gg|gg.
+				// Range ends in ROOT > DIV > ...|ar.
+				range = new Range( new Position( root, [ 0, 0, 2 ] ), new Position( root, [ 0, 5 ] ) );
+			} );
+
+			it( 'should remove specified node', () => {
+				batch.remove( div );
+
+				expect( root.maxOffset ).to.equal( 1 );
+				expect( root.childCount ).to.equal( 1 );
+				expect( getNodesAndText( Range.createIn( root.getChild( 0 ) ) ) ).to.equal( 'abcxyz' );
+			} );
+
+			it( 'should remove specified text node', () => {
+				batch.remove( p.getChild( 0 ) );
+
+				expect( getNodesAndText( Range.createOn( p ) ) ).to.equal( 'PP' );
+			} );
+
+			it( 'should remove any range of nodes', () => {
+				batch.remove( range );
+
+				expect( getNodesAndText( Range.createIn( root.getChild( 0 ) ) ) ).to.equal( 'PggParPhhhhP' );
+				expect( getNodesAndText( Range.createIn( root.getChild( 1 ) ) ) ).to.equal( 'abcxyz' );
+			} );
+
+			it( 'should create minimal number of remove deltas, each with only one operation', () => {
+				batch.remove( range );
+
+				expect( batch.deltas.length ).to.equal( 2 );
+				expect( batch.deltas[ 0 ].operations.length ).to.equal( 1 );
+				expect( batch.deltas[ 1 ].operations.length ).to.equal( 1 );
+			} );
+
+			it( 'should use RemoveOperation', () => {
+				batch.remove( div );
+
+				expect( batch.deltas[ 0 ].operations[ 0 ].type ).to.equal( 'remove' );
+			} );
+		} );
+
+		describe( 'remove from document fragment', () => {
+			let frag;
+
+			beforeEach( () => {
+				frag = batch.createDocumentFragment();
+				batch.append( div, frag );
+				batch.append( p, frag );
+
+				// Reset batch.
+				batch = doc.batch();
+
+				// Range starts in FRAG > DIV > P > gg|gg.
+				// Range ends in FRAG > DIV > ...|ar.
+				range = new Range( new Position( frag, [ 0, 0, 2 ] ), new Position( frag, [ 0, 5 ] ) );
+			} );
+
+			it( 'should remove specified node', () => {
+				batch.remove( div );
+
+				expect( frag.maxOffset ).to.equal( 1 );
+				expect( frag.childCount ).to.equal( 1 );
+				expect( getNodesAndText( Range.createIn( frag.getChild( 0 ) ) ) ).to.equal( 'abcxyz' );
+			} );
+
+			it( 'should remove specified text node', () => {
+				batch.remove( p.getChild( 0 ) );
+
+				expect( getNodesAndText( Range.createOn( p ) ) ).to.equal( 'PP' );
+			} );
+
+			it( 'should remove any range of nodes', () => {
+				batch.remove( range );
+
+				expect( getNodesAndText( Range.createIn( frag.getChild( 0 ) ) ) ).to.equal( 'PggParPhhhhP' );
+				expect( getNodesAndText( Range.createIn( frag.getChild( 1 ) ) ) ).to.equal( 'abcxyz' );
+			} );
+
+			it( 'should create minimal number of remove deltas, each with only one operation', () => {
+				batch.remove( range );
+
+				expect( batch.deltas.length ).to.equal( 2 );
+				expect( batch.deltas[ 0 ].operations.length ).to.equal( 1 );
+				expect( batch.deltas[ 1 ].operations.length ).to.equal( 1 );
+			} );
+
+			it( 'should use DetachOperation', () => {
+				batch.remove( div );
+
+				expect( batch.deltas[ 0 ].operations[ 0 ].type ).to.equal( 'detach' );
+			} );
+		} );
+	} );
+
+	describe( 'rename()', () => {
+		let doc, root, batch;
+
+		beforeEach( () => {
+			doc = new Document();
+			root = doc.createRoot();
+
+			const p = new Element( 'p', null, new Text( 'abc' ) );
+			root.appendChildren( p );
+
+			batch = doc.batch();
+
+			batch.rename( p, 'h' );
+		} );
+
+		it( 'should rename given element', () => {
+			expect( root.maxOffset ).to.equal( 1 );
+			expect( root.getChild( 0 ) ).to.have.property( 'name', 'h' );
+		} );
+
+		it( 'should throw if not an Element instance is passed', () => {
+			expect( () => {
+				batch.rename( new Text( 'abc' ), 'h' );
+			} ).to.throw( CKEditorError, /^batch-rename-not-element-instance/ );
+		} );
+	} );
+
+	describe( 'split()', () => {
+		let doc, root, p;
+
+		beforeEach( () => {
+			doc = new Document();
+			root = doc.createRoot();
+
+			p = new Element( 'p', { key: 'value' }, new Text( 'foobar' ) );
+
+			root.insertChildren( 0, p );
+		} );
+
+		it( 'should split foobar to foo and bar', () => {
+			doc.batch().split( new Position( root, [ 0, 3 ] ) );
+
+			expect( root.maxOffset ).to.equal( 2 );
+
+			expect( root.getChild( 0 ).name ).to.equal( 'p' );
+			expect( root.getChild( 0 ).maxOffset ).to.equal( 3 );
+			expect( count( root.getChild( 0 ).getAttributes() ) ).to.equal( 1 );
+			expect( root.getChild( 0 ).getAttribute( 'key' ) ).to.equal( 'value' );
+			expect( root.getChild( 0 ).getChild( 0 ).data ).to.equal( 'foo' );
+
+			expect( root.getChild( 1 ).name ).to.equal( 'p' );
+			expect( root.getChild( 1 ).maxOffset ).to.equal( 3 );
+			expect( count( root.getChild( 1 ).getAttributes() ) ).to.equal( 1 );
+			expect( root.getChild( 1 ).getAttribute( 'key' ) ).to.equal( 'value' );
+			expect( root.getChild( 1 ).getChild( 0 ).data ).to.equal( 'bar' );
+		} );
+
+		it( 'should create an empty paragraph if we split at the end', () => {
+			doc.batch().split( new Position( root, [ 0, 6 ] ) );
+
+			expect( root.maxOffset ).to.equal( 2 );
+
+			expect( root.getChild( 0 ).name ).to.equal( 'p' );
+			expect( root.getChild( 0 ).maxOffset ).to.equal( 6 );
+			expect( count( root.getChild( 0 ).getAttributes() ) ).to.equal( 1 );
+			expect( root.getChild( 0 ).getAttribute( 'key' ) ).to.equal( 'value' );
+			expect( root.getChild( 0 ).getChild( 0 ).data ).to.equal( 'foobar' );
+
+			expect( root.getChild( 1 ).name ).to.equal( 'p' );
+			expect( root.getChild( 1 ).maxOffset ).to.equal( 0 );
+			expect( count( root.getChild( 1 ).getAttributes() ) ).to.equal( 1 );
+			expect( root.getChild( 1 ).getAttribute( 'key' ) ).to.equal( 'value' );
+		} );
+
+		it( 'should throw if we try to split a root', () => {
+			expect( () => {
+				doc.batch().split( new Position( root, [ 0 ] ) );
+			} ).to.throw( CKEditorError, /^batch-split-element-no-parent/ );
+		} );
+
+		it( 'should throw if we try to split an element with no parent', () => {
+			const batch = doc.batch();
+
+			expect( () => {
+				const element = batch.createElement( 'p' );
+
+				batch.split( new Position( element, [ 0 ] ) );
+			} ).to.throw( CKEditorError, /^batch-split-element-no-parent/ );
+		} );
+
+		it( 'should throw if we try to split a document fragment', () => {
+			const batch = doc.batch();
+
+			expect( () => {
+				const documentFragment = batch.createDocumentFragment();
+
+				batch.split( new Position( documentFragment, [ 0 ] ) );
+			} ).to.throw( CKEditorError, /^batch-split-element-no-parent/ );
+		} );
+	} );
+
+	describe( 'wrap()', () => {
+		let doc, root, range;
+
+		beforeEach( () => {
+			doc = new Document();
+			root = doc.createRoot();
+
+			root.insertChildren( 0, new Text( 'foobar' ) );
+
+			range = new Range( new Position( root, [ 2 ] ), new Position( root, [ 4 ] ) );
+		} );
+
+		it( 'should wrap flat range with given element', () => {
+			const p = new Element( 'p' );
+			doc.batch().wrap( range, p );
+
+			expect( root.maxOffset ).to.equal( 5 );
+			expect( root.getChild( 0 ).data ).to.equal( 'fo' );
+			expect( root.getChild( 1 ) ).to.equal( p );
+			expect( p.getChild( 0 ).data ).to.equal( 'ob' );
+			expect( root.getChild( 2 ).data ).to.equal( 'ar' );
+		} );
+
+		it( 'should wrap flat range with an element of given name', () => {
+			doc.batch().wrap( range, 'p' );
+
+			expect( root.maxOffset ).to.equal( 5 );
+			expect( root.getChild( 0 ).data ).to.equal( 'fo' );
+			expect( root.getChild( 1 ).name ).to.equal( 'p' );
+			expect( root.getChild( 1 ).getChild( 0 ).data ).to.equal( 'ob' );
+			expect( root.getChild( 2 ).data ).to.equal( 'ar' );
+		} );
+
+		it( 'should throw if range to wrap is not flat', () => {
+			root.insertChildren( 1, [ new Element( 'p', [], new Text( 'xyz' ) ) ] );
+			const notFlatRange = new Range( new Position( root, [ 3 ] ), new Position( root, [ 6, 2 ] ) );
+
+			expect( () => {
+				doc.batch().wrap( notFlatRange, 'p' );
+			} ).to.throw( CKEditorError, /^batch-wrap-range-not-flat/ );
+		} );
+
+		it( 'should throw if element to wrap with has children #1', () => {
+			const p = new Element( 'p', [], new Text( 'a' ) );
+
+			expect( () => {
+				doc.batch().wrap( range, p );
+			} ).to.throw( CKEditorError, /^batch-wrap-element-not-empty/ );
+		} );
+
+		it( 'should throw if element to wrap with has children #2', () => {
+			const p = new Element( 'p' );
+			root.insertChildren( 0, p );
+
+			expect( () => {
+				doc.batch().wrap( range, p );
+			} ).to.throw( CKEditorError, /^batch-wrap-element-attached/ );
+		} );
+	} );
+
+	describe( 'unwrap()', () => {
+		let doc, root, p;
+
+		beforeEach( () => {
+			doc = new Document();
+			root = doc.createRoot();
+
+			p = new Element( 'p', [], new Text( 'xyz' ) );
+			root.insertChildren( 0, [ new Text( 'a' ), p, new Text( 'b' ) ] );
+		} );
+
+		it( 'should unwrap given element', () => {
+			doc.batch().unwrap( p );
+
+			expect( root.maxOffset ).to.equal( 5 );
+			expect( root.getChild( 0 ).data ).to.equal( 'axyzb' );
+		} );
+
+		it( 'should throw if element to unwrap has no parent', () => {
+			const element = new Element( 'p' );
+
+			expect( () => {
+				doc.batch().unwrap( element );
+			} ).to.throw( CKEditorError, /^batch-unwrap-element-no-parent/ );
+		} );
+	} );
+
+	describe( 'setMarker()', () => {
+		let doc, root, range;
+
+		beforeEach( () => {
+			doc = new Document();
+			root = doc.createRoot();
+			root.appendChildren( new Text( 'foo' ) );
+			range = Range.createIn( root );
+		} );
+
+		it( 'should add marker to the document marker collection', () => {
+			doc.batch().setMarker( 'name', range );
+
+			expect( doc.markers.get( 'name' ).getRange().isEqual( range ) ).to.be.true;
+		} );
+
+		it( 'should update marker in the document marker collection', () => {
+			doc.batch().setMarker( 'name', range );
+
+			const range2 = Range.createFromParentsAndOffsets( root, 0, root, 0 );
+			doc.batch().setMarker( 'name', range2 );
+
+			expect( doc.markers.get( 'name' ).getRange().isEqual( range2 ) ).to.be.true;
+		} );
+
+		it( 'should accept marker instance', () => {
+			doc.batch().setMarker( 'name', range );
+			const marker = doc.markers.get( 'name' );
+			const range2 = Range.createFromParentsAndOffsets( root, 0, root, 0 );
+
+			const batch = doc.batch();
+			batch.setMarker( marker, range2 );
+			const op = batch.deltas[ 0 ].operations[ 0 ];
+
+			expect( doc.markers.get( 'name' ).getRange().isEqual( range2 ) ).to.be.true;
+			expect( op.oldRange.isEqual( range ) ).to.be.true;
+			expect( op.newRange.isEqual( range2 ) ).to.be.true;
+		} );
+
+		it( 'should accept empty range parameter if marker instance is passed', () => {
+			const marker = doc.markers.set( 'name', range );
+
+			sinon.spy( doc, 'fire' );
+
+			doc.on( 'change', ( evt, type, changes ) => {
+				if ( type == 'marker' ) {
+					expect( changes.type ).to.equal( 'set' );
+					expect( changes.name ).to.equal( 'name' );
+				}
+			} );
+
+			const batch = doc.batch();
+			batch.setMarker( marker );
+			const op = batch.deltas[ 0 ].operations[ 0 ];
+
+			expect( doc.fire.calledWith( 'change', 'marker' ) ).to.be.true;
+			expect( op.oldRange ).to.be.null;
+			expect( op.newRange.isEqual( range ) ).to.be.true;
+		} );
+
+		it( 'should throw if marker with given name does not exist and range is not passed', () => {
+			expect( () => {
+				doc.batch().setMarker( 'name' );
+			} ).to.throw( CKEditorError, /^batch-setMarker-no-range/ );
+		} );
+	} );
+
+	describe( 'removeMarker()', () => {
+		let doc, root, range;
+
+		beforeEach( () => {
+			doc = new Document();
+			root = doc.createRoot();
+			root.appendChildren( new Text( 'foo' ) );
+			range = Range.createIn( root );
+		} );
+
+		it( 'should remove marker from the document marker collection', () => {
+			doc.batch().setMarker( 'name', range );
+			doc.batch().removeMarker( 'name' );
+
+			expect( doc.markers.get( 'name' ) ).to.be.null;
+		} );
+
+		it( 'should throw when trying to remove non existing marker', () => {
+			expect( () => {
+				doc.batch().removeMarker( 'name' );
+			} ).to.throw( CKEditorError, /^batch-removeMarker-no-marker/ );
+		} );
+
+		it( 'should accept marker instance', () => {
+			doc.batch().setMarker( 'name', range );
+			const marker = doc.markers.get( 'name' );
+
+			doc.batch().removeMarker( marker );
+
+			expect( doc.markers.get( 'name' ) ).to.be.null;
 		} );
 	} );
 } );

+ 1 - 405
packages/ckeditor5-engine/tests/model/delta/attributedelta.js

@@ -3,412 +3,14 @@
  * For licensing, see LICENSE.md.
  */
 
-import count from '@ckeditor/ckeditor5-utils/src/count';
 import Document from '../../../src/model/document';
-import Text from '../../../src/model/text';
 import Range from '../../../src/model/range';
 import Position from '../../../src/model/position';
-import Element from '../../../src/model/element';
-import { default as AttributeDelta, RootAttributeDelta } from '../../../src/model/delta/attributedelta';
+import AttributeDelta from '../../../src/model/delta/attributedelta';
 import AttributeOperation from '../../../src/model/operation/attributeoperation';
 import NoOperation from '../../../src/model/operation/nooperation';
 import { jsonParseStringify } from '../../../tests/model/_utils/utils';
 
-describe( 'Batch', () => {
-	let batch, doc, root;
-
-	const correctDeltaMatcher = sinon.match( operation => {
-		return operation.delta && operation.delta.batch && operation.delta.batch == batch;
-	} );
-
-	beforeEach( () => {
-		doc = new Document();
-		root = doc.createRoot();
-		batch = doc.batch();
-	} );
-
-	function getOperationsCount() {
-		let totalNumber = 0;
-
-		for ( const delta of batch.deltas ) {
-			totalNumber += count( delta.operations );
-		}
-
-		return totalNumber;
-	}
-
-	describe( 'change attribute on node', () => {
-		let node, text;
-
-		beforeEach( () => {
-			node = new Element( 'p', { a: 1 } );
-			text = new Text( 'c', { a: 1 } );
-
-			root.insertChildren( 0, [ node, text ] );
-		} );
-
-		describe( 'setAttribute', () => {
-			it( 'should create the attribute on element', () => {
-				batch.setAttribute( node, 'b', 2 );
-				expect( getOperationsCount() ).to.equal( 1 );
-				expect( node.getAttribute( 'b' ) ).to.equal( 2 );
-			} );
-
-			it( 'should change the attribute of element', () => {
-				batch.setAttribute( node, 'a', 2 );
-				expect( getOperationsCount() ).to.equal( 1 );
-				expect( node.getAttribute( 'a' ) ).to.equal( 2 );
-			} );
-
-			it( 'should create the attribute on text node', () => {
-				batch.setAttribute( text, 'b', 2 );
-				expect( getOperationsCount() ).to.equal( 1 );
-				expect( root.getChild( 1 ).getAttribute( 'b' ) ).to.equal( 2 );
-			} );
-
-			it( 'should change the attribute of text node', () => {
-				batch.setAttribute( text, 'a', 2 );
-				expect( getOperationsCount() ).to.equal( 1 );
-				expect( root.getChild( 1 ).getAttribute( 'a' ) ).to.equal( 2 );
-			} );
-
-			it( 'should do nothing if the attribute value is the same', () => {
-				batch.setAttribute( node, 'a', 1 );
-				expect( getOperationsCount() ).to.equal( 0 );
-				expect( node.getAttribute( 'a' ) ).to.equal( 1 );
-			} );
-
-			it( 'should be chainable', () => {
-				const chain = batch.setAttribute( node, 'b', 2 );
-				expect( chain ).to.equal( batch );
-			} );
-
-			it( 'should add delta to batch and operation to delta before applying operation', () => {
-				sinon.spy( doc, 'applyOperation' );
-				batch.setAttribute( node, 'b', 2 );
-
-				expect( doc.applyOperation.calledWith( correctDeltaMatcher ) ).to.be.true;
-			} );
-		} );
-
-		describe( 'removeAttribute', () => {
-			it( 'should remove the attribute from element', () => {
-				batch.removeAttribute( node, 'a' );
-				expect( getOperationsCount() ).to.equal( 1 );
-				expect( node.getAttribute( 'a' ) ).to.be.undefined;
-			} );
-
-			it( 'should remove the attribute from character', () => {
-				batch.removeAttribute( text, 'a' );
-				expect( getOperationsCount() ).to.equal( 1 );
-				expect( root.getChild( 1 ).getAttribute( 'a' ) ).to.be.undefined;
-			} );
-
-			it( 'should do nothing if the attribute is not set', () => {
-				batch.removeAttribute( node, 'b' );
-				expect( getOperationsCount() ).to.equal( 0 );
-			} );
-
-			it( 'should be chainable', () => {
-				const chain = batch.removeAttribute( node, 'a' );
-				expect( chain ).to.equal( batch );
-			} );
-
-			it( 'should add delta to batch and operation to delta before applying operation', () => {
-				sinon.spy( doc, 'applyOperation' );
-				batch.removeAttribute( node, 'a' );
-
-				expect( doc.applyOperation.calledWith( correctDeltaMatcher ) ).to.be.true;
-			} );
-		} );
-	} );
-
-	describe( 'change attribute on range', () => {
-		beforeEach( () => {
-			root.insertChildren( 0, [
-				new Text( 'xxx', { a: 1 } ),
-				new Text( 'xxx' ),
-				new Text( 'xxx', { a: 1 } ),
-				new Text( 'xxx', { a: 2 } ),
-				new Text( 'xxx' ),
-				new Text( 'xxx', { a: 1 } ),
-				new Element( 'e', { a: 2 }, new Text( 'xxx' ) ),
-				new Text( 'xxx' )
-			] );
-		} );
-
-		function getRange( startIndex, endIndex ) {
-			return new Range(
-				Position.createFromParentAndOffset( root, startIndex ),
-				Position.createFromParentAndOffset( root, endIndex )
-			);
-		}
-
-		function getChangesAttrsCount() {
-			let totalNumber = 0;
-
-			for ( const delta of batch.deltas ) {
-				for ( const operation of delta.operations ) {
-					totalNumber += count( operation.range.getItems( { singleCharacters: true } ) );
-				}
-			}
-
-			return totalNumber;
-		}
-
-		function getCompressedAttrs() {
-			// default: 111---111222---1112------
-			const range = Range.createIn( root );
-
-			return Array.from( range.getItems( { singleCharacters: true } ) )
-				.map( item => item.getAttribute( 'a' ) || '-' )
-				.join( '' );
-		}
-
-		describe( 'setAttribute', () => {
-			it( 'should set the attribute on the range', () => {
-				batch.setAttribute( getRange( 3, 6 ), 'a', 3 );
-				expect( getOperationsCount() ).to.equal( 1 );
-				expect( getChangesAttrsCount() ).to.equal( 3 );
-				expect( getCompressedAttrs() ).to.equal( '111333111222---1112------' );
-			} );
-
-			it( 'should split the operations if parts of the range have different attributes', () => {
-				batch.setAttribute( getRange( 4, 14 ), 'a', 3 );
-				expect( getOperationsCount() ).to.equal( 4 );
-				expect( getChangesAttrsCount() ).to.equal( 10 );
-				expect( getCompressedAttrs() ).to.equal( '111-3333333333-1112------' );
-			} );
-
-			it( 'should split the operations if parts of the part of the range have the attribute', () => {
-				batch.setAttribute( getRange( 4, 14 ), 'a', 2 );
-				expect( getOperationsCount() ).to.equal( 3 );
-				expect( getChangesAttrsCount() ).to.equal( 7 );
-				expect( getCompressedAttrs() ).to.equal( '111-2222222222-1112------' );
-			} );
-
-			it( 'should strip the range if the beginning have the attribute', () => {
-				batch.setAttribute( getRange( 1, 5 ), 'a', 1 );
-				expect( getOperationsCount() ).to.equal( 1 );
-				expect( getChangesAttrsCount() ).to.equal( 2 );
-				expect( getCompressedAttrs() ).to.equal( '11111-111222---1112------' );
-			} );
-
-			it( 'should strip the range if the ending have the attribute', () => {
-				batch.setAttribute( getRange( 13, 17 ), 'a', 1 );
-				expect( getOperationsCount() ).to.equal( 1 );
-				expect( getChangesAttrsCount() ).to.equal( 2 );
-				expect( getCompressedAttrs() ).to.equal( '111---111222-111112------' );
-			} );
-
-			it( 'should do nothing if the range has attribute', () => {
-				batch.setAttribute( getRange( 0, 3 ), 'a', 1 );
-				expect( getOperationsCount() ).to.equal( 0 );
-				expect( getCompressedAttrs() ).to.equal( '111---111222---1112------' );
-			} );
-
-			it( 'should not check range\'s start position node when creating operations', () => {
-				const range = new Range(
-					new Position( root, [ 18, 1 ] ),
-					new Position( root, [ 19 ] )
-				);
-
-				batch.setAttribute( range, 'a', 1 );
-				expect( getOperationsCount() ).to.equal( 1 );
-				expect( getChangesAttrsCount() ).to.equal( 2 );
-				expect( getCompressedAttrs() ).to.equal( '111---111222---1112-11---' );
-			} );
-
-			it( 'should not change elements attribute if range contains closing tag', () => {
-				const range = new Range(
-					new Position( root, [ 18, 1 ] ),
-					new Position( root, [ 21 ] )
-				);
-
-				batch.setAttribute( range, 'a', 1 );
-				expect( getOperationsCount() ).to.equal( 1 );
-				expect( getChangesAttrsCount() ).to.equal( 4 );
-				expect( getCompressedAttrs() ).to.equal( '111---111222---1112-1111-' );
-			} );
-
-			it( 'should not create an operation if the range contains only closing tag', () => {
-				const range = new Range(
-					new Position( root, [ 18, 3 ] ),
-					new Position( root, [ 19 ] )
-				);
-
-				batch.setAttribute( range, 'a', 3 );
-				expect( getOperationsCount() ).to.equal( 0 );
-				expect( getCompressedAttrs() ).to.equal( '111---111222---1112------' );
-			} );
-
-			it( 'should not create an operation if is collapsed', () => {
-				batch.setAttribute( getRange( 3, 3 ), 'a', 1 );
-				expect( getOperationsCount() ).to.equal( 0 );
-				expect( getCompressedAttrs() ).to.equal( '111---111222---1112------' );
-			} );
-
-			it( 'should create a proper operations for the mixed range', () => {
-				batch.setAttribute( getRange( 0, 20 ), 'a', 1 );
-				expect( getOperationsCount() ).to.equal( 5 );
-				expect( getChangesAttrsCount() ).to.equal( 14 );
-				expect( getCompressedAttrs() ).to.equal( '11111111111111111111111--' );
-			} );
-
-			it( 'should be chainable', () => {
-				const chain = batch.setAttribute( getRange( 3, 6 ), 'a', 3 );
-				expect( chain ).to.equal( batch );
-			} );
-
-			it( 'should add delta to batch and operation to delta before applying operation', () => {
-				sinon.spy( doc, 'applyOperation' );
-				batch.setAttribute( getRange( 3, 6 ), 'a', 3 );
-
-				expect( doc.applyOperation.calledWith( correctDeltaMatcher ) ).to.be.true;
-			} );
-		} );
-
-		describe( 'removeAttribute', () => {
-			it( 'should remove the attribute on the range', () => {
-				batch.removeAttribute( getRange( 0, 2 ), 'a' );
-				expect( getOperationsCount() ).to.equal( 1 );
-				expect( getChangesAttrsCount() ).to.equal( 2 );
-				expect( getCompressedAttrs() ).to.equal( '--1---111222---1112------' );
-			} );
-
-			it( 'should split the operations if parts of the range have different attributes', () => {
-				batch.removeAttribute( getRange( 7, 11 ), 'a' );
-				expect( getOperationsCount() ).to.equal( 2 );
-				expect( getChangesAttrsCount() ).to.equal( 4 );
-				expect( getCompressedAttrs() ).to.equal( '111---1----2---1112------' );
-			} );
-
-			it( 'should split the operations if parts of the part of the range have no attribute', () => {
-				batch.removeAttribute( getRange( 1, 7 ), 'a' );
-				expect( getOperationsCount() ).to.equal( 2 );
-				expect( getChangesAttrsCount() ).to.equal( 3 );
-				expect( getCompressedAttrs() ).to.equal( '1------11222---1112------' );
-			} );
-
-			it( 'should strip the range if the beginning have no attribute', () => {
-				batch.removeAttribute( getRange( 4, 12 ), 'a' );
-				expect( getOperationsCount() ).to.equal( 2 );
-				expect( getChangesAttrsCount() ).to.equal( 6 );
-				expect( getCompressedAttrs() ).to.equal( '111------------1112------' );
-			} );
-
-			it( 'should strip the range if the ending have no attribute', () => {
-				batch.removeAttribute( getRange( 7, 15 ), 'a' );
-				expect( getOperationsCount() ).to.equal( 2 );
-				expect( getChangesAttrsCount() ).to.equal( 5 );
-				expect( getCompressedAttrs() ).to.equal( '111---1--------1112------' );
-			} );
-
-			it( 'should do nothing if the range has no attribute', () => {
-				batch.removeAttribute( getRange( 4, 5 ), 'a' );
-				expect( getOperationsCount() ).to.equal( 0 );
-				expect( getCompressedAttrs() ).to.equal( '111---111222---1112------' );
-			} );
-
-			it( 'should not check range\'s start position node when creating operations', () => {
-				const range = new Range(
-					new Position( root, [ 18, 3 ] ),
-					new Position( root, [ 19 ] )
-				);
-
-				batch.removeAttribute( range, 'a' );
-				expect( getOperationsCount() ).to.equal( 0 );
-				expect( getChangesAttrsCount() ).to.equal( 0 );
-				expect( getCompressedAttrs() ).to.equal( '111---111222---1112------' );
-			} );
-
-			it( 'should not apply operation twice in the range contains opening and closing tags', () => {
-				batch.removeAttribute( getRange( 18, 22 ), 'a' );
-				expect( getOperationsCount() ).to.equal( 1 );
-				expect( getChangesAttrsCount() ).to.equal( 1 );
-				expect( getCompressedAttrs() ).to.equal( '111---111222---111-------' );
-			} );
-
-			it( 'should not create an operation if range is collapsed', () => {
-				batch.removeAttribute( getRange( 3, 3 ), 'a' );
-				expect( getOperationsCount() ).to.equal( 0 );
-				expect( getCompressedAttrs() ).to.equal( '111---111222---1112------' );
-			} );
-
-			it( 'should create a proper operations for the mixed range', () => {
-				batch.removeAttribute( getRange( 3, 15 ), 'a' );
-				expect( getOperationsCount() ).to.equal( 2 );
-				expect( getChangesAttrsCount() ).to.equal( 6 );
-				expect( getCompressedAttrs() ).to.equal( '111------------1112------' );
-			} );
-
-			it( 'should be chainable', () => {
-				const chain = batch.removeAttribute( getRange( 0, 2 ), 'a' );
-				expect( chain ).to.equal( batch );
-			} );
-
-			it( 'should add delta to batch and operation to delta before applying operation', () => {
-				sinon.spy( doc, 'applyOperation' );
-				batch.removeAttribute( getRange( 0, 2 ), 'a' );
-
-				expect( doc.applyOperation.calledWith( correctDeltaMatcher ) ).to.be.true;
-			} );
-		} );
-	} );
-
-	describe( 'change attribute on root element', () => {
-		describe( 'setAttribute', () => {
-			it( 'should create the attribute on root', () => {
-				batch.setAttribute( root, 'b', 2 );
-				expect( getOperationsCount() ).to.equal( 1 );
-				expect( root.getAttribute( 'b' ) ).to.equal( 2 );
-			} );
-
-			it( 'should change the attribute of root', () => {
-				batch.setAttribute( root, 'a', 2 );
-				expect( getOperationsCount() ).to.equal( 1 );
-				expect( root.getAttribute( 'a' ) ).to.equal( 2 );
-			} );
-
-			it( 'should do nothing if the attribute value is the same', () => {
-				batch.setAttribute( root, 'a', 1 );
-				expect( getOperationsCount() ).to.equal( 1 );
-				batch.setAttribute( root, 'a', 1 );
-				expect( getOperationsCount() ).to.equal( 1 );
-				expect( root.getAttribute( 'a' ) ).to.equal( 1 );
-			} );
-		} );
-
-		describe( 'removeAttribute', () => {
-			it( 'should remove the attribute from root', () => {
-				batch.setAttribute( root, 'a', 1 );
-				batch.removeAttribute( root, 'a' );
-				expect( getOperationsCount() ).to.equal( 2 );
-				expect( root.getAttribute( 'a' ) ).to.be.undefined;
-			} );
-
-			it( 'should do nothing if the attribute is not set', () => {
-				batch.removeAttribute( root, 'b' );
-				expect( getOperationsCount() ).to.equal( 0 );
-			} );
-		} );
-	} );
-
-	it( 'should not add empty delta to the batch', () => {
-		const nodeA = new Element( 'p', { a: 1 } );
-		const nodeB = new Element( 'p', { b: 2 } );
-		root.insertChildren( 0, [ nodeA, nodeB ] );
-
-		batch.setAttribute( nodeA, 'a', 1 );
-
-		expect( batch.deltas.length ).to.equal( 0 );
-
-		batch.removeAttribute( Range.createIn( root ), 'x' );
-
-		expect( batch.deltas.length ).to.equal( 0 );
-	} );
-} );
-
 describe( 'AttributeDelta', () => {
 	let doc, root, delta;
 
@@ -526,9 +128,3 @@ describe( 'AttributeDelta', () => {
 		expect( json ).not.to.have.property( '_range' );
 	} );
 } );
-
-describe( 'RootAttributeDelta', () => {
-	it( 'should provide proper className', () => {
-		expect( RootAttributeDelta.className ).to.equal( 'engine.model.delta.RootAttributeDelta' );
-	} );
-} );

+ 0 - 88
packages/ckeditor5-engine/tests/model/delta/insertdelta.js

@@ -5,102 +5,14 @@
 
 import Document from '../../../src/model/document';
 import Element from '../../../src/model/element';
-import DocumentFragment from '../../../src/model/documentfragment';
-import Text from '../../../src/model/text';
 import Position from '../../../src/model/position';
-import Range from '../../../src/model/range';
 
 import InsertOperation from '../../../src/model/operation/insertoperation';
-import MarkerOperation from '../../../src/model/operation/markeroperation';
 import InsertDelta from '../../../src/model/delta/insertdelta';
 
 import RemoveDelta from '../../../src/model/delta/removedelta';
 import RemoveOperation from '../../../src/model/operation/removeoperation';
 
-import { stringify } from '../../../src/dev-utils/model';
-
-describe( 'Batch', () => {
-	let doc, root, batch, p, ul, chain;
-
-	beforeEach( () => {
-		doc = new Document();
-		root = doc.createRoot();
-		root.insertChildren( 0, new Text( 'abc' ) );
-
-		batch = doc.batch();
-
-		p = new Element( 'p' );
-		ul = new Element( 'ul' );
-
-		chain = batch.insert( new Position( root, [ 2 ] ), [ p, ul ] );
-	} );
-
-	describe( 'insert', () => {
-		it( 'should insert given nodes at given position', () => {
-			expect( root.childCount ).to.equal( 4 );
-			expect( root.maxOffset ).to.equal( 5 );
-			expect( root.getChild( 1 ) ).to.equal( p );
-			expect( root.getChild( 2 ) ).to.equal( ul );
-		} );
-
-		it( 'should be chainable', () => {
-			expect( chain ).to.equal( batch );
-		} );
-
-		it( 'should add delta to batch and operation to delta before applying operation', () => {
-			sinon.spy( doc, 'applyOperation' );
-			batch.insert( new Position( root, [ 2 ] ), [ p, ul ] );
-
-			const correctDeltaMatcher = sinon.match( operation => {
-				return operation.delta && operation.delta.batch && operation.delta.batch == batch;
-			} );
-
-			expect( doc.applyOperation.calledWith( correctDeltaMatcher ) ).to.be.true;
-		} );
-
-		it( 'should transfer markers from given DocumentFragment', () => {
-			const documentFragment = new DocumentFragment( [ new Element( 'li', null, [ new Text( 'foo bar' ) ] ) ] );
-			const marker = new Range( new Position( documentFragment, [ 0, 1 ] ), new Position( documentFragment, [ 0, 5 ] ) );
-
-			documentFragment.markers.set( 'marker', marker );
-
-			batch.insert( new Position( root, [ 3, 0 ] ), documentFragment );
-
-			expect( Array.from( doc.markers ).length ).to.equal( 1 );
-			expect( stringify( root, doc.markers.get( 'marker' ).getRange() ) ).to.equal( 'ab<p></p><ul><li>f[oo b]ar</li></ul>c' );
-		} );
-
-		it( 'should set each marker as separate operation', () => {
-			sinon.spy( doc, 'applyOperation' );
-
-			const documentFragment = new DocumentFragment( [ new Element( 'li', null, [ new Text( 'foo bar' ) ] ) ] );
-			const marker1 = new Range( new Position( documentFragment, [ 0, 1 ] ), new Position( documentFragment, [ 0, 2 ] ) );
-			const marker2 = new Range( new Position( documentFragment, [ 0, 5 ] ), new Position( documentFragment, [ 0, 6 ] ) );
-
-			documentFragment.markers.set( 'marker1', marker1 );
-			documentFragment.markers.set( 'marker2', marker2 );
-
-			batch.insert( new Position( root, [ 3, 0 ] ), documentFragment );
-
-			expect( doc.applyOperation.calledThrice );
-			expect( doc.applyOperation.firstCall.calledWith( sinon.match( operation => operation instanceof InsertOperation ) ) );
-			expect( doc.applyOperation.secondCall.calledWith( sinon.match( operation => operation instanceof MarkerOperation ) ) );
-			expect( doc.applyOperation.thirdCall.calledWith( sinon.match( operation => operation instanceof MarkerOperation ) ) );
-		} );
-
-		it( 'should not create a delta and an operation if no nodes were inserted', () => {
-			sinon.spy( doc, 'applyOperation' );
-
-			batch = doc.batch();
-
-			batch.insert( new Position( root, [ 0 ] ), [] );
-
-			expect( batch.deltas.length ).to.equal( 0 );
-			expect( doc.applyOperation.called ).to.be.false;
-		} );
-	} );
-} );
-
 describe( 'InsertDelta', () => {
 	let insertDelta, doc, root;
 

+ 0 - 111
packages/ckeditor5-engine/tests/model/delta/markerdelta.js

@@ -5,121 +5,10 @@
 
 import Document from '../../../src/model/document';
 import Range from '../../../src/model/range';
-import Text from '../../../src/model/text';
-import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 
 import MarkerDelta from '../../../src/model/delta/markerdelta';
 import MarkerOperation from '../../../src/model/operation/markeroperation';
 
-describe( 'Batch', () => {
-	let doc, root, range;
-
-	beforeEach( () => {
-		doc = new Document();
-		root = doc.createRoot();
-		root.appendChildren( new Text( 'foo' ) );
-		range = Range.createIn( root );
-	} );
-
-	describe( 'setMarker', () => {
-		it( 'should add marker to the document marker collection', () => {
-			doc.batch().setMarker( 'name', range );
-
-			expect( doc.markers.get( 'name' ).getRange().isEqual( range ) ).to.be.true;
-		} );
-
-		it( 'should update marker in the document marker collection', () => {
-			doc.batch().setMarker( 'name', range );
-
-			const range2 = Range.createFromParentsAndOffsets( root, 0, root, 0 );
-			doc.batch().setMarker( 'name', range2 );
-
-			expect( doc.markers.get( 'name' ).getRange().isEqual( range2 ) ).to.be.true;
-		} );
-
-		it( 'should accept marker instance', () => {
-			doc.batch().setMarker( 'name', range );
-			const marker = doc.markers.get( 'name' );
-			const range2 = Range.createFromParentsAndOffsets( root, 0, root, 0 );
-
-			const batch = doc.batch().setMarker( marker, range2 );
-			const op = batch.deltas[ 0 ].operations[ 0 ];
-
-			expect( doc.markers.get( 'name' ).getRange().isEqual( range2 ) ).to.be.true;
-			expect( op.oldRange.isEqual( range ) ).to.be.true;
-			expect( op.newRange.isEqual( range2 ) ).to.be.true;
-		} );
-
-		it( 'should accept empty range parameter if marker instance is passed', () => {
-			const marker = doc.markers.set( 'name', range );
-
-			sinon.spy( doc, 'fire' );
-
-			doc.on( 'change', ( evt, type, changes ) => {
-				if ( type == 'marker' ) {
-					expect( changes.type ).to.equal( 'set' );
-					expect( changes.name ).to.equal( 'name' );
-				}
-			} );
-
-			const batch = doc.batch().setMarker( marker );
-			const op = batch.deltas[ 0 ].operations[ 0 ];
-
-			expect( doc.fire.calledWith( 'change', 'marker' ) ).to.be.true;
-			expect( op.oldRange ).to.be.null;
-			expect( op.newRange.isEqual( range ) ).to.be.true;
-		} );
-
-		it( 'should throw if marker with given name does not exist and range is not passed', () => {
-			expect( () => {
-				doc.batch().setMarker( 'name' );
-			} ).to.throw( CKEditorError, /^batch-setMarker-no-range/ );
-		} );
-	} );
-
-	describe( 'removeMarker', () => {
-		it( 'should remove marker from the document marker collection', () => {
-			doc.batch().setMarker( 'name', range );
-			doc.batch().removeMarker( 'name' );
-
-			expect( doc.markers.get( 'name' ) ).to.be.null;
-		} );
-
-		it( 'should throw when trying to remove non existing marker', () => {
-			expect( () => {
-				doc.batch().removeMarker( 'name' );
-			} ).to.throw( CKEditorError, /^batch-removeMarker-no-marker/ );
-		} );
-
-		it( 'should accept marker instance', () => {
-			doc.batch().setMarker( 'name', range );
-			const marker = doc.markers.get( 'name' );
-
-			doc.batch().removeMarker( marker );
-
-			expect( doc.markers.get( 'name' ) ).to.be.null;
-		} );
-	} );
-
-	it( 'should be chainable', () => {
-		const batch = doc.batch();
-		const chain = batch.setMarker( 'name', range );
-
-		expect( chain ).to.equal( batch );
-	} );
-
-	it( 'should add delta to batch and operation to delta before applying operation', () => {
-		sinon.spy( doc, 'applyOperation' );
-		const batch = doc.batch().setMarker( 'name', range );
-
-		const correctDeltaMatcher = sinon.match( operation => {
-			return operation.delta && operation.delta.batch && operation.delta.batch == batch;
-		} );
-
-		expect( doc.applyOperation.calledWith( correctDeltaMatcher ) ).to.be.true;
-	} );
-} );
-
 describe( 'MarkerDelta', () => {
 	let markerDelta, doc, root, range;
 

+ 0 - 62
packages/ckeditor5-engine/tests/model/delta/mergedelta.js

@@ -5,9 +5,6 @@
 
 import Document from '../../../src/model/document';
 import Position from '../../../src/model/position';
-import Element from '../../../src/model/element';
-import Text from '../../../src/model/text';
-import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 
 import MergeDelta from '../../../src/model/delta/mergedelta';
 import SplitDelta from '../../../src/model/delta/splitdelta';
@@ -16,65 +13,6 @@ import MoveOperation from '../../../src/model/operation/moveoperation';
 import RemoveOperation from '../../../src/model/operation/removeoperation';
 import ReinsertOperation from '../../../src/model/operation/reinsertoperation';
 
-import count from '@ckeditor/ckeditor5-utils/src/count';
-
-describe( 'Batch', () => {
-	let doc, root, p1, p2, batch;
-
-	beforeEach( () => {
-		doc = new Document();
-		root = doc.createRoot();
-
-		p1 = new Element( 'p', { key1: 'value1' }, new Text( 'foo' ) );
-		p2 = new Element( 'p', { key2: 'value2' }, new Text( 'bar' ) );
-
-		root.insertChildren( 0, [ p1, p2 ] );
-	} );
-
-	describe( 'merge', () => {
-		it( 'should merge foo and bar into foobar', () => {
-			doc.batch().merge( new Position( root, [ 1 ] ) );
-
-			expect( root.maxOffset ).to.equal( 1 );
-			expect( root.getChild( 0 ).name ).to.equal( 'p' );
-			expect( root.getChild( 0 ).maxOffset ).to.equal( 6 );
-			expect( count( root.getChild( 0 ).getAttributes() ) ).to.equal( 1 );
-			expect( root.getChild( 0 ).getAttribute( 'key1' ) ).to.equal( 'value1' );
-			expect( root.getChild( 0 ).getChild( 0 ).data ).to.equal( 'foobar' );
-		} );
-
-		it( 'should throw if there is no element after', () => {
-			expect( () => {
-				doc.batch().merge( new Position( root, [ 2 ] ) );
-			} ).to.throw( CKEditorError, /^batch-merge-no-element-after/ );
-		} );
-
-		it( 'should throw if there is no element before', () => {
-			expect( () => {
-				doc.batch().merge( new Position( root, [ 0, 2 ] ) );
-			} ).to.throw( CKEditorError, /^batch-merge-no-element-before/ );
-		} );
-
-		it( 'should be chainable', () => {
-			batch = doc.batch();
-
-			const chain = batch.merge( new Position( root, [ 1 ] ) );
-			expect( chain ).to.equal( batch );
-		} );
-
-		it( 'should add delta to batch and operation to delta before applying operation', () => {
-			sinon.spy( doc, 'applyOperation' );
-			batch = doc.batch().merge( new Position( root, [ 1 ] ) );
-
-			const correctDeltaMatcher = sinon.match( operation => {
-				return operation.delta && operation.delta.batch && operation.delta.batch == batch;
-			} );
-
-			expect( doc.applyOperation.calledWith( correctDeltaMatcher ) ).to.be.true;
-		} );
-	} );
-} );
-
 describe( 'MergeDelta', () => {
 	let mergeDelta, doc, root;
 

+ 0 - 67
packages/ckeditor5-engine/tests/model/delta/movedelta.js

@@ -3,79 +3,12 @@
  * For licensing, see LICENSE.md.
  */
 
-import { getNodesAndText } from '../../../tests/model/_utils/utils';
 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 Text from '../../../src/model/text';
-import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 
 import MoveDelta from '../../../src/model/delta/movedelta';
 import MoveOperation from '../../../src/model/operation/moveoperation';
 
-describe( 'Batch', () => {
-	let doc, root, div, p, batch, chain;
-
-	beforeEach( () => {
-		doc = new Document();
-		root = doc.createRoot();
-
-		div = new Element( 'div', [], new Text( 'foobar' ) );
-		p = new Element( 'p', [], new Text( 'abcxyz' ) );
-
-		div.insertChildren( 0, [ new Element( 'p', [], new Text( 'gggg' ) ) ] );
-		div.insertChildren( 2, [ new Element( 'p', [], new Text( 'hhhh' ) ) ] );
-
-		root.insertChildren( 0, [ div, p ] );
-
-		batch = doc.batch();
-	} );
-
-	describe( 'move', () => {
-		it( 'should move specified node', () => {
-			batch.move( div, new Position( root, [ 2 ] ) );
-
-			expect( root.maxOffset ).to.equal( 2 );
-			expect( getNodesAndText( Range.createIn( root.getChild( 0 ) ) ) ).to.equal( 'abcxyz' );
-			expect( getNodesAndText( Range.createIn( root.getChild( 1 ) ) ) ).to.equal( 'PggggPfoobarPhhhhP' );
-		} );
-
-		it( 'should move flat range of nodes', () => {
-			const range = new Range( new Position( root, [ 0, 3 ] ), new Position( root, [ 0, 7 ] ) );
-			batch.move( range, new Position( root, [ 1, 3 ] ) );
-
-			expect( getNodesAndText( Range.createIn( root.getChild( 0 ) ) ) ).to.equal( 'PggggPfoPhhhhP' );
-			expect( getNodesAndText( Range.createIn( root.getChild( 1 ) ) ) ).to.equal( 'abcobarxyz' );
-		} );
-
-		it( 'should throw if given range is not flat', () => {
-			const notFlatRange = new Range( new Position( root, [ 0, 2, 2 ] ), new Position( root, [ 0, 6 ] ) );
-
-			expect( () => {
-				doc.batch().move( notFlatRange, new Position( root, [ 1, 3 ] ) );
-			} ).to.throw( CKEditorError, /^batch-move-range-not-flat/ );
-		} );
-
-		it( 'should be chainable', () => {
-			chain = batch.move( div, new Position( root, [ 1, 3 ] ) );
-
-			expect( chain ).to.equal( batch );
-		} );
-
-		it( 'should add delta to batch and operation to delta before applying operation', () => {
-			sinon.spy( doc, 'applyOperation' );
-			batch.move( div, new Position( root, [ 2 ] ) );
-
-			const correctDeltaMatcher = sinon.match( operation => {
-				return operation.delta && operation.delta.batch && operation.delta.batch == batch;
-			} );
-
-			expect( doc.applyOperation.calledWith( correctDeltaMatcher ) ).to.be.true;
-		} );
-	} );
-} );
-
 describe( 'MoveDelta', () => {
 	let moveDelta, doc, root;
 

+ 0 - 71
packages/ckeditor5-engine/tests/model/delta/removedelta.js

@@ -3,79 +3,8 @@
  * For licensing, see LICENSE.md.
  */
 
-import { getNodesAndText } from '../../../tests/model/_utils/utils';
-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 Text from '../../../src/model/text';
 import RemoveDelta from '../../../src/model/delta/removedelta';
 
-describe( 'Batch', () => {
-	let doc, root, div, p, batch, chain, range;
-
-	beforeEach( () => {
-		doc = new Document();
-		root = doc.createRoot();
-
-		div = new Element( 'div', [], new Text( 'foobar' ) );
-		p = new Element( 'p', [], new Text( 'abcxyz' ) );
-
-		div.insertChildren( 0, [ new Element( 'p', [], new Text( 'gggg' ) ) ] );
-		div.insertChildren( 2, [ new Element( 'p', [], new Text( 'hhhh' ) ) ] );
-
-		root.insertChildren( 0, [ div, p ] );
-
-		batch = doc.batch();
-
-		// Range starts in ROOT > DIV > P > gg|gg.
-		// Range ends in ROOT > DIV > ...|ar.
-		range = new Range( new Position( root, [ 0, 0, 2 ] ), new Position( root, [ 0, 5 ] ) );
-	} );
-
-	describe( 'remove', () => {
-		it( 'should remove specified node', () => {
-			batch.remove( div );
-
-			expect( root.maxOffset ).to.equal( 1 );
-			expect( root.childCount ).to.equal( 1 );
-			expect( getNodesAndText( Range.createIn( root.getChild( 0 ) ) ) ).to.equal( 'abcxyz' );
-		} );
-
-		it( 'should remove any range of nodes', () => {
-			batch.remove( range );
-
-			expect( getNodesAndText( Range.createIn( root.getChild( 0 ) ) ) ).to.equal( 'PggParPhhhhP' );
-			expect( getNodesAndText( Range.createIn( root.getChild( 1 ) ) ) ).to.equal( 'abcxyz' );
-		} );
-
-		it( 'should create minimal number of remove deltas, each with only one operation', () => {
-			batch.remove( range );
-
-			expect( batch.deltas.length ).to.equal( 2 );
-			expect( batch.deltas[ 0 ].operations.length ).to.equal( 1 );
-			expect( batch.deltas[ 1 ].operations.length ).to.equal( 1 );
-		} );
-
-		it( 'should be chainable', () => {
-			chain = batch.remove( range );
-
-			expect( chain ).to.equal( batch );
-		} );
-
-		it( 'should add delta to batch and operation to delta before applying operation', () => {
-			sinon.spy( doc, 'applyOperation' );
-			batch.remove( div );
-
-			const correctDeltaMatcher = sinon.match( operation => {
-				return operation.delta && operation.delta.batch && operation.delta.batch == batch;
-			} );
-
-			expect( doc.applyOperation.calledWith( correctDeltaMatcher ) ).to.be.true;
-		} );
-	} );
-} );
-
 describe( 'RemoveDelta', () => {
 	it( 'should provide proper className', () => {
 		expect( RemoveDelta.className ).to.equal( 'engine.model.delta.RemoveDelta' );

+ 0 - 46
packages/ckeditor5-engine/tests/model/delta/renamedelta.js

@@ -6,55 +6,9 @@
 import Document from '../../../src/model/document';
 import Element from '../../../src/model/element';
 import Text from '../../../src/model/text';
-import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 
 import RenameDelta from '../../../src/model/delta/renamedelta';
 
-describe( 'Batch', () => {
-	let doc, root, batch, chain;
-
-	beforeEach( () => {
-		doc = new Document();
-		root = doc.createRoot();
-
-		const p = new Element( 'p', null, new Text( 'abc' ) );
-		root.appendChildren( p );
-
-		batch = doc.batch();
-
-		chain = batch.rename( p, 'h' );
-	} );
-
-	describe( 'rename', () => {
-		it( 'should rename given element', () => {
-			expect( root.maxOffset ).to.equal( 1 );
-			expect( root.getChild( 0 ) ).to.have.property( 'name', 'h' );
-			expect( root.getChild( 0 ) ).to.have.property( 'name', 'h' );
-		} );
-
-		it( 'should throw if not an Element instance is passed', () => {
-			expect( () => {
-				batch.rename( new Text( 'abc' ), 'h' );
-			} ).to.throw( CKEditorError, /^batch-rename-not-element-instance/ );
-		} );
-
-		it( 'should be chainable', () => {
-			expect( chain ).to.equal( batch );
-		} );
-
-		it( 'should add delta to batch and operation to delta before applying operation', () => {
-			sinon.spy( doc, 'applyOperation' );
-			batch.rename( root.getChild( 0 ), 'p' );
-
-			const correctDeltaMatcher = sinon.match( operation => {
-				return operation.delta && operation.delta.batch && operation.delta.batch == batch;
-			} );
-
-			expect( doc.applyOperation.alwaysCalledWith( correctDeltaMatcher ) ).to.be.true;
-		} );
-	} );
-} );
-
 describe( 'RenameDelta', () => {
 	let renameDelta, doc, root;
 

+ 12 - 0
packages/ckeditor5-engine/tests/model/delta/rootattributedelta.js

@@ -0,0 +1,12 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import RootAttributeDelta from '../../../src/model/delta/rootattributedelta';
+
+describe( 'RootAttributeDelta', () => {
+	it( 'should provide proper className', () => {
+		expect( RootAttributeDelta.className ).to.equal( 'engine.model.delta.RootAttributeDelta' );
+	} );
+} );

+ 0 - 78
packages/ckeditor5-engine/tests/model/delta/splitdelta.js

@@ -6,8 +6,6 @@
 import Document from '../../../src/model/document';
 import Position from '../../../src/model/position';
 import Element from '../../../src/model/element';
-import Text from '../../../src/model/text';
-import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 
 import MergeDelta from '../../../src/model/delta/mergedelta';
 import SplitDelta from '../../../src/model/delta/splitdelta';
@@ -17,82 +15,6 @@ import MoveOperation from '../../../src/model/operation/moveoperation';
 import NoOperation from '../../../src/model/operation/nooperation';
 import RemoveOperation from '../../../src/model/operation/removeoperation';
 
-import count from '@ckeditor/ckeditor5-utils/src/count';
-
-describe( 'Batch', () => {
-	let doc, root, p;
-
-	beforeEach( () => {
-		doc = new Document();
-		root = doc.createRoot();
-
-		p = new Element( 'p', { key: 'value' }, new Text( 'foobar' ) );
-
-		root.insertChildren( 0, p );
-	} );
-
-	describe( 'split', () => {
-		it( 'should split foobar to foo and bar', () => {
-			doc.batch().split( new Position( root, [ 0, 3 ] ) );
-
-			expect( root.maxOffset ).to.equal( 2 );
-
-			expect( root.getChild( 0 ).name ).to.equal( 'p' );
-			expect( root.getChild( 0 ).maxOffset ).to.equal( 3 );
-			expect( count( root.getChild( 0 ).getAttributes() ) ).to.equal( 1 );
-			expect( root.getChild( 0 ).getAttribute( 'key' ) ).to.equal( 'value' );
-			expect( root.getChild( 0 ).getChild( 0 ).data ).to.equal( 'foo' );
-
-			expect( root.getChild( 1 ).name ).to.equal( 'p' );
-			expect( root.getChild( 1 ).maxOffset ).to.equal( 3 );
-			expect( count( root.getChild( 1 ).getAttributes() ) ).to.equal( 1 );
-			expect( root.getChild( 1 ).getAttribute( 'key' ) ).to.equal( 'value' );
-			expect( root.getChild( 1 ).getChild( 0 ).data ).to.equal( 'bar' );
-		} );
-
-		it( 'should create an empty paragraph if we split at the end', () => {
-			doc.batch().split( new Position( root, [ 0, 6 ] ) );
-
-			expect( root.maxOffset ).to.equal( 2 );
-
-			expect( root.getChild( 0 ).name ).to.equal( 'p' );
-			expect( root.getChild( 0 ).maxOffset ).to.equal( 6 );
-			expect( count( root.getChild( 0 ).getAttributes() ) ).to.equal( 1 );
-			expect( root.getChild( 0 ).getAttribute( 'key' ) ).to.equal( 'value' );
-			expect( root.getChild( 0 ).getChild( 0 ).data ).to.equal( 'foobar' );
-
-			expect( root.getChild( 1 ).name ).to.equal( 'p' );
-			expect( root.getChild( 1 ).maxOffset ).to.equal( 0 );
-			expect( count( root.getChild( 1 ).getAttributes() ) ).to.equal( 1 );
-			expect( root.getChild( 1 ).getAttribute( 'key' ) ).to.equal( 'value' );
-		} );
-
-		it( 'should throw if we try to split a root', () => {
-			expect( () => {
-				doc.batch().split( new Position( root, [ 0 ] ) );
-			} ).to.throw( CKEditorError, /^batch-split-root/ );
-		} );
-
-		it( 'should be chainable', () => {
-			const batch = doc.batch();
-
-			const chain = batch.split( new Position( root, [ 0, 3 ] ) );
-			expect( chain ).to.equal( batch );
-		} );
-
-		it( 'should add delta to batch and operation to delta before applying operation', () => {
-			sinon.spy( doc, 'applyOperation' );
-			const batch = doc.batch().split( new Position( root, [ 0, 3 ] ) );
-
-			const correctDeltaMatcher = sinon.match( operation => {
-				return operation.delta && operation.delta.batch && operation.delta.batch == batch;
-			} );
-
-			expect( doc.applyOperation.calledWith( correctDeltaMatcher ) ).to.be.true;
-		} );
-	} );
-} );
-
 describe( 'SplitDelta', () => {
 	let splitDelta, doc, root;
 

+ 0 - 50
packages/ckeditor5-engine/tests/model/delta/unwrapdelta.js

@@ -4,10 +4,7 @@
  */
 
 import Document from '../../../src/model/document';
-import Element from '../../../src/model/element';
-import Text from '../../../src/model/text';
 import Position from '../../../src/model/position';
-import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 
 import UnwrapDelta from '../../../src/model/delta/unwrapdelta';
 import WrapDelta from '../../../src/model/delta/wrapdelta';
@@ -16,53 +13,6 @@ import MoveOperation from '../../../src/model/operation/moveoperation';
 import RemoveOperation from '../../../src/model/operation/removeoperation';
 import ReinsertOperation from '../../../src/model/operation/reinsertoperation';
 
-describe( 'Batch', () => {
-	let doc, root, p;
-
-	beforeEach( () => {
-		doc = new Document();
-		root = doc.createRoot();
-
-		p = new Element( 'p', [], new Text( 'xyz' ) );
-		root.insertChildren( 0, [ new Text( 'a' ), p, new Text( 'b' ) ] );
-	} );
-
-	describe( 'unwrap', () => {
-		it( 'should unwrap given element', () => {
-			doc.batch().unwrap( p );
-
-			expect( root.maxOffset ).to.equal( 5 );
-			expect( root.getChild( 0 ).data ).to.equal( 'axyzb' );
-		} );
-
-		it( 'should throw if element to unwrap has no parent', () => {
-			const element = new Element( 'p' );
-
-			expect( () => {
-				doc.batch().unwrap( element );
-			} ).to.throw( CKEditorError, /^batch-unwrap-element-no-parent/ );
-		} );
-
-		it( 'should be chainable', () => {
-			const batch = doc.batch();
-
-			const chain = batch.unwrap( p );
-			expect( chain ).to.equal( batch );
-		} );
-
-		it( 'should add delta to batch and operation to delta before applying operation', () => {
-			sinon.spy( doc, 'applyOperation' );
-			const batch = doc.batch().unwrap( p );
-
-			const correctDeltaMatcher = sinon.match( operation => {
-				return operation.delta && operation.delta.batch && operation.delta.batch == batch;
-			} );
-
-			expect( doc.applyOperation.calledWith( correctDeltaMatcher ) ).to.be.true;
-		} );
-	} );
-} );
-
 describe( 'UnwrapDelta', () => {
 	let unwrapDelta, doc, root;
 

+ 0 - 50
packages/ckeditor5-engine/tests/model/delta/weakinsertdelta.js

@@ -3,58 +3,8 @@
  * For licensing, see LICENSE.md.
  */
 
-import Document from '../../../src/model/document';
-import Position from '../../../src/model/position';
-import Text from '../../../src/model/text';
 import WeakInsertDelta from '../../../src/model/delta/weakinsertdelta';
 
-describe( 'Batch', () => {
-	let doc, root, batch, chain, attrs;
-
-	beforeEach( () => {
-		doc = new Document();
-		root = doc.createRoot();
-
-		root.insertChildren( 0, new Text( 'abc' ) );
-
-		batch = doc.batch();
-
-		attrs = [ [ 'bold', true ], [ 'foo', 'bar' ] ];
-
-		doc.selection.setAttributesTo( attrs );
-
-		chain = batch.weakInsert( new Position( root, [ 2 ] ), 'xyz' );
-	} );
-
-	describe( 'weakInsert', () => {
-		it( 'should insert given nodes at given position', () => {
-			expect( root.maxOffset ).to.equal( 6 );
-			expect( root.getChild( 0 ).data ).to.equal( 'ab' );
-			expect( root.getChild( 1 ).data ).to.equal( 'xyz' );
-			expect( root.getChild( 2 ).data ).to.equal( 'c' );
-		} );
-
-		it( 'should set inserted nodes attributes to same as current selection attributes', () => {
-			expect( Array.from( root.getChild( 1 ).getAttributes() ) ).to.deep.equal( attrs );
-		} );
-
-		it( 'should be chainable', () => {
-			expect( chain ).to.equal( batch );
-		} );
-
-		it( 'should add delta to batch and operation to delta before applying operation', () => {
-			sinon.spy( doc, 'applyOperation' );
-			batch.weakInsert( new Position( root, [ 2 ] ), 'xyz' );
-
-			const correctDeltaMatcher = sinon.match( operation => {
-				return operation.delta && operation.delta.batch && operation.delta.batch == batch;
-			} );
-
-			expect( doc.applyOperation.calledWith( correctDeltaMatcher ) ).to.be.true;
-		} );
-	} );
-} );
-
 describe( 'WeakInsertDelta', () => {
 	it( 'should provide proper className', () => {
 		expect( WeakInsertDelta.className ).to.equal( 'engine.model.delta.WeakInsertDelta' );

+ 0 - 83
packages/ckeditor5-engine/tests/model/delta/wrapdelta.js

@@ -5,10 +5,7 @@
 
 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 Text from '../../../src/model/text';
-import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 
 import WrapDelta from '../../../src/model/delta/wrapdelta';
 import UnwrapDelta from '../../../src/model/delta/unwrapdelta';
@@ -17,86 +14,6 @@ import InsertOperation from '../../../src/model/operation/insertoperation';
 import MoveOperation from '../../../src/model/operation/moveoperation';
 import RemoveOperation from '../../../src/model/operation/removeoperation';
 
-describe( 'Batch', () => {
-	let doc, root, range;
-
-	beforeEach( () => {
-		doc = new Document();
-		root = doc.createRoot();
-
-		root.insertChildren( 0, new Text( 'foobar' ) );
-
-		range = new Range( new Position( root, [ 2 ] ), new Position( root, [ 4 ] ) );
-	} );
-
-	describe( 'wrap', () => {
-		it( 'should wrap flat range with given element', () => {
-			const p = new Element( 'p' );
-			doc.batch().wrap( range, p );
-
-			expect( root.maxOffset ).to.equal( 5 );
-			expect( root.getChild( 0 ).data ).to.equal( 'fo' );
-			expect( root.getChild( 1 ) ).to.equal( p );
-			expect( p.getChild( 0 ).data ).to.equal( 'ob' );
-			expect( root.getChild( 2 ).data ).to.equal( 'ar' );
-		} );
-
-		it( 'should wrap flat range with an element of given name', () => {
-			doc.batch().wrap( range, 'p' );
-
-			expect( root.maxOffset ).to.equal( 5 );
-			expect( root.getChild( 0 ).data ).to.equal( 'fo' );
-			expect( root.getChild( 1 ).name ).to.equal( 'p' );
-			expect( root.getChild( 1 ).getChild( 0 ).data ).to.equal( 'ob' );
-			expect( root.getChild( 2 ).data ).to.equal( 'ar' );
-		} );
-
-		it( 'should throw if range to wrap is not flat', () => {
-			root.insertChildren( 1, [ new Element( 'p', [], new Text( 'xyz' ) ) ] );
-			const notFlatRange = new Range( new Position( root, [ 3 ] ), new Position( root, [ 6, 2 ] ) );
-
-			expect( () => {
-				doc.batch().wrap( notFlatRange, 'p' );
-			} ).to.throw( CKEditorError, /^batch-wrap-range-not-flat/ );
-		} );
-
-		it( 'should throw if element to wrap with has children', () => {
-			const p = new Element( 'p', [], new Text( 'a' ) );
-
-			expect( () => {
-				doc.batch().wrap( range, p );
-			} ).to.throw( CKEditorError, /^batch-wrap-element-not-empty/ );
-		} );
-
-		it( 'should throw if element to wrap with has children', () => {
-			const p = new Element( 'p' );
-			root.insertChildren( 0, p );
-
-			expect( () => {
-				doc.batch().wrap( range, p );
-			} ).to.throw( CKEditorError, /^batch-wrap-element-attached/ );
-		} );
-
-		it( 'should be chainable', () => {
-			const batch = doc.batch();
-
-			const chain = batch.wrap( range, 'p' );
-			expect( chain ).to.equal( batch );
-		} );
-
-		it( 'should add delta to batch and operation to delta before applying operation', () => {
-			sinon.spy( doc, 'applyOperation' );
-			const batch = doc.batch().wrap( range, 'p' );
-
-			const correctDeltaMatcher = sinon.match( operation => {
-				return operation.delta && operation.delta.batch && operation.delta.batch == batch;
-			} );
-
-			expect( doc.applyOperation.calledWith( correctDeltaMatcher ) ).to.be.true;
-		} );
-	} );
-} );
-
 describe( 'WrapDelta', () => {
 	let wrapDelta, doc, root;
 

+ 34 - 2
packages/ckeditor5-engine/tests/model/document/document.js

@@ -110,7 +110,8 @@ describe( 'Document', () => {
 	} );
 
 	describe( 'applyOperation()', () => {
-		it( 'should increase document version, execute operation and fire event with proper data', () => {
+		it( 'should increase document version, execute operation and fire event with proper data ' +
+			'when operation is a document operation', () => {
 			const changeCallback = sinon.spy();
 			const type = 't';
 			const data = { data: 'x' };
@@ -121,6 +122,7 @@ describe( 'Document', () => {
 			const operation = {
 				type,
 				baseVersion: 0,
+				isDocumentOperation: true,
 				_execute: sinon.stub().returns( data )
 			};
 
@@ -131,6 +133,7 @@ describe( 'Document', () => {
 			doc.applyOperation( operation );
 
 			expect( doc.version ).to.equal( 1 );
+			expect( doc.history._deltas.length ).to.equal( 1 );
 			sinon.assert.calledOnce( operation._execute );
 
 			sinon.assert.calledOnce( changeCallback );
@@ -140,6 +143,35 @@ describe( 'Document', () => {
 			expect( changeCallback.args[ 0 ][ 4 ] ).to.equal( delta.type );
 		} );
 
+		it( 'should execute operation, not fire event and not increase document version ' +
+			'when operation is not a document operation', () => {
+			const changeCallback = sinon.spy();
+			const type = 't';
+			const data = { data: 'x' };
+			const batch = new Batch();
+			const delta = new Delta();
+			delta.type = 'type';
+
+			const operation = {
+				type,
+				baseVersion: 0,
+				isDocumentOperation: false,
+				_execute: sinon.stub().returns( data )
+			};
+
+			delta.addOperation( operation );
+			batch.addDelta( delta );
+
+			doc.on( 'change', changeCallback );
+			doc.applyOperation( operation );
+
+			expect( doc.version ).to.equal( 0 );
+			expect( doc.history._deltas.length ).to.equal( 0 );
+			sinon.assert.calledOnce( operation._execute );
+
+			sinon.assert.notCalled( changeCallback );
+		} );
+
 		it( 'should throw an error on the operation base version and the document version is different', () => {
 			const operation = {
 				baseVersion: 1
@@ -149,7 +181,7 @@ describe( 'Document', () => {
 				() => {
 					doc.applyOperation( operation );
 				}
-			).to.throw( CKEditorError, /model-document-applyOperation-wrong-version/ );
+			).to.throw( CKEditorError, /^model-document-applyOperation-wrong-version/ );
 		} );
 	} );
 

+ 5 - 5
packages/ckeditor5-engine/tests/model/documentselection.js

@@ -1012,7 +1012,7 @@ describe( 'DocumentSelection', () => {
 					batchTypes.set( batch, batch.type );
 				} );
 
-				doc.batch().insert( rangeInEmptyP.start, 'x' );
+				doc.batch().insertText( 'x', rangeInEmptyP.start );
 
 				expect( emptyP.hasAttribute( fooStoreAttrKey ) ).to.be.false;
 				expect( emptyP.hasAttribute( abcStoreAttrKey ) ).to.be.false;
@@ -1024,7 +1024,7 @@ describe( 'DocumentSelection', () => {
 				selection.setRanges( [ rangeInEmptyP ] );
 				selection.setAttribute( 'foo', 'bar' );
 
-				doc.batch().move( fullP.getChild( 0 ), rangeInEmptyP.start );
+				doc.batch().move( Range.createOn( fullP.getChild( 0 ) ), rangeInEmptyP.start );
 
 				expect( emptyP.hasAttribute( fooStoreAttrKey ) ).to.be.false;
 			} );
@@ -1048,7 +1048,7 @@ describe( 'DocumentSelection', () => {
 
 				selection.setRanges( [ rangeInFullP ] );
 
-				doc.batch().insert( rangeInEmptyP.start, 'x' );
+				doc.batch().insertText( 'x', rangeInEmptyP.start );
 
 				expect( emptyP.hasAttribute( fooStoreAttrKey ) ).to.be.false;
 			} );
@@ -1076,7 +1076,7 @@ describe( 'DocumentSelection', () => {
 				selection.setAttribute( 'foo', 'bar' );
 
 				doc.enqueueChanges( () => {
-					doc.batch().insert( rangeInEmptyP.start, 'x' );
+					doc.batch().insertText( 'x', rangeInEmptyP.start );
 
 					// `emptyP` still has the attribute, because attribute clearing is in enqueued block.
 					expect( emptyP.hasAttribute( fooStoreAttrKey ) ).to.be.true;
@@ -1109,7 +1109,7 @@ describe( 'DocumentSelection', () => {
 
 				sinon.spy( doc, 'enqueueChanges' );
 
-				doc.batch( 'transparent' ).insert( rangeInEmptyP.start, 'x' );
+				doc.batch( 'transparent' ).insertText( 'x', rangeInEmptyP.start );
 
 				expect( doc.enqueueChanges.called ).to.be.false;
 				expect( emptyP.getAttribute( fooStoreAttrKey ) ).to.equal( 'bar' );

+ 1 - 1
packages/ckeditor5-engine/tests/model/markercollection.js

@@ -225,7 +225,7 @@ describe( 'Marker', () => {
 		expect( marker.getEnd().isEqual( range.end ) ).to.be.true;
 
 		doc.enqueueChanges( () => {
-			doc.batch().insert( Position.createAt( root, 0 ), 'abc' );
+			doc.batch().insertText( 'abc', root );
 		} );
 
 		const updatedRange = Range.createFromParentsAndOffsets( root, 4, root, 5 );

+ 45 - 0
packages/ckeditor5-engine/tests/model/operation/attributeoperation.js

@@ -59,6 +59,35 @@ describe( 'AttributeOperation', () => {
 		} );
 	} );
 
+	describe( 'isDocumentOperation', () => {
+		it( 'should return true when attribute is applied on attached items', () => {
+			const op = new AttributeOperation(
+				new Range( new Position( root, [ 0 ] ), new Position( root, [ 2 ] ) ),
+				'key',
+				'oldValue',
+				'newValue',
+				doc.version
+			);
+
+			expect( op.isDocumentOperation ).to.true;
+		} );
+
+		it( 'should return false when attribute is applied on detached items', () => {
+			const docFrag = doc.batch().createDocumentFragment();
+			doc.batch().appendText( 'abc', null, docFrag );
+
+			const op = new AttributeOperation(
+				Range.createIn( docFrag ),
+				'key',
+				'oldValue',
+				'newValue',
+				doc.version
+			);
+
+			expect( op.isDocumentOperation ).to.false;
+		} );
+	} );
+
 	it( 'should insert attribute to the set of nodes', () => {
 		root.insertChildren( 0, new Text( 'bar' ) );
 
@@ -369,6 +398,22 @@ describe( 'AttributeOperation', () => {
 		expect( root.getChild( 1 ).data ).to.equal( 'bcxyz' );
 	} );
 
+	it( 'should do nothing when attribute value is the same', () => {
+		root.insertChildren( 0, new Text( 'x', { foo: true } ) );
+
+		expect( () => {
+			doc.applyOperation( wrapInDelta(
+				new AttributeOperation(
+					new Range( new Position( root, [ 0 ] ), new Position( root, [ 1 ] ) ),
+					'foo',
+					true,
+					true,
+					doc.version
+				)
+			) );
+		} ).to.not.throw();
+	} );
+
 	describe( 'toJSON', () => {
 		it( 'should create proper serialized object', () => {
 			const range = new Range( new Position( root, [ 0 ] ), new Position( root, [ 2 ] ) );

+ 71 - 0
packages/ckeditor5-engine/tests/model/operation/detachoperation.js

@@ -0,0 +1,71 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Document from '../../../src/model/document';
+import DetachOperation from '../../../src/model/operation/detachoperation';
+import { jsonParseStringify, wrapInDelta } from '../../../tests/model/_utils/utils';
+import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
+import Position from '../../../src/model/position';
+
+describe( 'DetachOperation', () => {
+	let doc, batch, docFrag, element;
+
+	beforeEach( () => {
+		doc = new Document();
+		batch = doc.batch();
+
+		docFrag = batch.createDocumentFragment();
+		element = batch.createElement( 'element' );
+		batch.append( element, docFrag );
+	} );
+
+	it( 'should have type equal to detach', () => {
+		const op = new DetachOperation( Position.createBefore( element ), 1, doc.version );
+
+		expect( op.type ).to.equal( 'detach' );
+	} );
+
+	it( 'should remove given element from parent', () => {
+		const op = new DetachOperation( Position.createBefore( element ), 1, doc.version );
+
+		doc.applyOperation( wrapInDelta( op ) );
+
+		expect( docFrag.childCount ).to.equal( 0 );
+	} );
+
+	it( 'should throw when is executed on element from document', () => {
+		const root = doc.createRoot();
+		const element = batch.createElement( 'element' );
+		batch.append( element, root );
+
+		const op = new DetachOperation( Position.createBefore( element ), 1, doc.version );
+
+		expect( () => {
+			op._execute();
+		} ).to.throw( CKEditorError, /^detach-operation-on-document-node/ );
+	} );
+
+	it( 'should be not a document operation', () => {
+		const op = new DetachOperation( Position.createBefore( element ), 1, doc.version );
+
+		expect( op.isDocumentOperation ).to.false;
+	} );
+
+	describe( 'toJSON', () => {
+		it( 'should create proper json object', () => {
+			const position = Position.createBefore( element );
+			const op = new DetachOperation( position, 1, doc.version );
+
+			const serialized = jsonParseStringify( op );
+
+			expect( serialized ).to.deep.equal( {
+				__className: 'engine.model.operation.DetachOperation',
+				baseVersion: 0,
+				sourcePosition: jsonParseStringify( position ),
+				howMany: 1
+			} );
+		} );
+	} );
+} );

+ 24 - 0
packages/ckeditor5-engine/tests/model/operation/insertoperation.js

@@ -206,6 +206,30 @@ describe( 'InsertOperation', () => {
 		expect( op2.nodes.getNode( 0 ) ).not.to.equal( text );
 	} );
 
+	describe( 'isDocumentOperation', () => {
+		it( 'should return true when element is inserted to the document', () => {
+			const op = new InsertOperation(
+				new Position( root, [ 0 ] ),
+				new Text( 'x' ),
+				doc.version
+			);
+
+			expect( op.isDocumentOperation ).to.true;
+		} );
+
+		it( 'should return false when element is inserted to document fragment', () => {
+			const docFrag = doc.batch().createDocumentFragment();
+
+			const op = new InsertOperation(
+				new Position( docFrag, [ 0 ] ),
+				new Text( 'x' ),
+				doc.version
+			);
+
+			expect( op.isDocumentOperation ).to.false;
+		} );
+	} );
+
 	describe( 'toJSON', () => {
 		it( 'should create proper json object', () => {
 			const position = new Position( root, [ 0 ] );

+ 20 - 0
packages/ckeditor5-engine/tests/model/operation/markeroperation.js

@@ -161,6 +161,26 @@ describe( 'MarkerOperation', () => {
 		expect( clone ).to.deep.equal( op );
 	} );
 
+	describe( 'isDocumentOperation', () => {
+		it( 'should return true when new marker range is added to the document', () => {
+			const op = new MarkerOperation( 'name', null, range, doc.markers, doc.version );
+
+			expect( op.isDocumentOperation ).to.true;
+		} );
+
+		it( 'should return false when marker range is removed from the document', () => {
+			const op = new MarkerOperation( 'name', range, null, doc.markers, doc.version );
+
+			expect( op.isDocumentOperation ).to.true;
+		} );
+
+		it( 'should return true when non-existing marker range is removed from the document', () => {
+			const op = new MarkerOperation( 'name', null, null, doc.markers, doc.version );
+
+			expect( op.isDocumentOperation ).to.true;
+		} );
+	} );
+
 	describe( 'toJSON', () => {
 		it( 'should create proper serialized object', () => {
 			const op = new MarkerOperation( 'name', null, range, doc.markers, doc.version );

+ 28 - 0
packages/ckeditor5-engine/tests/model/operation/moveoperation.js

@@ -267,6 +267,34 @@ describe( 'MoveOperation', () => {
 		expect( clone.baseVersion ).to.equal( baseVersion );
 	} );
 
+	describe( 'isDocumentOperation', () => {
+		it( 'should return root when operation is executed on attached items', () => {
+			const op = new MoveOperation(
+				new Position( root, [ 0, 0 ] ),
+				1,
+				new Position( root, [ 1, 0 ] ),
+				doc.version
+			);
+
+			expect( op.isDocumentOperation ).to.true;
+		} );
+
+		it( 'should return false when operation is executed on detached items', () => {
+			const docFrag = doc.batch().createDocumentFragment();
+
+			doc.batch().appendText( 'abc', null, docFrag );
+
+			const op = new MoveOperation(
+				new Position( docFrag, [ 0 ] ),
+				1,
+				new Position( docFrag, [ 2 ] ),
+				doc.version
+			);
+
+			expect( op.isDocumentOperation ).to.false;
+		} );
+	} );
+
 	describe( 'getMovedRangeStart', () => {
 		it( 'should return move operation target position transformed by removing move operation source range', () => {
 			const sourcePosition = new Position( root, [ 0, 2 ] );

+ 4 - 0
packages/ckeditor5-engine/tests/model/operation/nooperation.js

@@ -37,6 +37,10 @@ describe( 'NoOperation', () => {
 		expect( clone.baseVersion ).to.equal( 0 );
 	} );
 
+	it( 'should be a document operation', () => {
+		expect( noop.isDocumentOperation ).to.true;
+	} );
+
 	describe( 'toJSON', () => {
 		it( 'should create proper json object', () => {
 			const serialized = jsonParseStringify( noop );

+ 26 - 0
packages/ckeditor5-engine/tests/model/operation/operationfactory.js

@@ -0,0 +1,26 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Document from '../../../src/model/document';
+import NoOperation from '../../../src/model/operation/nooperation';
+import OperationFactory from '../../../src/model/operation/operationfactory';
+
+describe( 'OperationFactory', () => {
+	let doc;
+
+	beforeEach( () => {
+		doc = new Document();
+	} );
+
+	it( 'should create operation from JSON', () => {
+		const operation = OperationFactory.fromJSON( {
+			__className: 'engine.model.operation.NoOperation',
+			baseVersion: 0
+		}, doc );
+
+		expect( operation ).to.instanceof( NoOperation );
+		expect( operation.baseVersion ).to.equal( 0 );
+	} );
+} );

+ 35 - 0
packages/ckeditor5-engine/tests/model/operation/reinsertoperation.js

@@ -10,6 +10,7 @@ import MoveOperation from '../../../src/model/operation/moveoperation';
 import Position from '../../../src/model/position';
 import Element from '../../../src/model/element';
 import Text from '../../../src/model/text';
+import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 import { jsonParseStringify, wrapInDelta } from '../../../tests/model/_utils/utils';
 
 describe( 'ReinsertOperation', () => {
@@ -99,6 +100,40 @@ describe( 'ReinsertOperation', () => {
 		expect( graveyard.maxOffset ).to.equal( 2 );
 	} );
 
+	it( 'should be a document operation', () => {
+		expect( operation.isDocumentOperation ).to.true;
+	} );
+
+	it( 'should throw when target position is not in the document', () => {
+		const docFrag = doc.batch().createDocumentFragment();
+
+		operation = new ReinsertOperation(
+			graveyardPosition,
+			1,
+			Position.createAt( docFrag ),
+			doc.version
+		);
+
+		expect( () => {
+			operation._execute();
+		} ).to.throw( CKEditorError, /^reinsert-operation-to-detached-parent/ );
+	} );
+
+	it( 'should throw when source position is not in the document', () => {
+		const docFrag = doc.batch().createDocumentFragment();
+
+		operation = new ReinsertOperation(
+			Position.createAt( docFrag ),
+			1,
+			rootPosition,
+			doc.version
+		);
+
+		expect( () => {
+			operation._execute();
+		} ).to.throw( CKEditorError, /^reinsert-operation-on-detached-item/ );
+	} );
+
 	describe( 'toJSON', () => {
 		it( 'should create proper json object', () => {
 			const serialized = jsonParseStringify( operation );

+ 31 - 0
packages/ckeditor5-engine/tests/model/operation/removeoperation.js

@@ -10,6 +10,7 @@ import MoveOperation from '../../../src/model/operation/moveoperation';
 import Position from '../../../src/model/position';
 import Text from '../../../src/model/text';
 import Element from '../../../src/model/element';
+import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 import { jsonParseStringify, wrapInDelta } from '../../../tests/model/_utils/utils';
 
 describe( 'RemoveOperation', () => {
@@ -140,6 +141,36 @@ describe( 'RemoveOperation', () => {
 		expect( doc.graveyard.getChild( 2 ).name ).to.equal( 'y' );
 	} );
 
+	it( 'should throw when is executed on detached item', () => {
+		const batch = doc.batch();
+		const docFrag = batch.createDocumentFragment();
+		const item = batch.createElement( 'foo' );
+
+		batch.append( item, docFrag );
+
+		const op = new RemoveOperation(
+			new Position( docFrag, [ 0 ] ),
+			1,
+			new Position( doc.graveyard, [ 0 ] ),
+			doc.version
+		);
+
+		expect( () => {
+			op._execute();
+		} ).to.throw( CKEditorError, /^remove-operation-on-detached-item/ );
+	} );
+
+	it( 'should always be a document operation', () => {
+		const op = new RemoveOperation(
+			new Position( root, [ 2 ] ),
+			2,
+			new Position( doc.graveyard, [ 0 ] ),
+			doc.version
+		);
+
+		expect( op.isDocumentOperation ).to.true;
+	} );
+
 	describe( 'toJSON', () => {
 		it( 'should create proper json object', () => {
 			const op = new RemoveOperation(

+ 27 - 0
packages/ckeditor5-engine/tests/model/operation/renameoperation.js

@@ -92,6 +92,33 @@ describe( 'RenameOperation', () => {
 		expect( clone.newName ).to.equal( newName );
 	} );
 
+	it( 'should do nothing when new name is the same as previous', () => {
+		const op = new RenameOperation( position, oldName, oldName, doc.version );
+
+		expect( () => {
+			doc.applyOperation( wrapInDelta( op ) );
+		} ).to.not.throw();
+	} );
+
+	describe( 'isDocumentOperation', () => {
+		it( 'should be true when target item is in the document', () => {
+			const op = new RenameOperation( position, oldName, newName, doc.version );
+
+			expect( op.isDocumentOperation ).to.true;
+		} );
+
+		it( 'should be false when target item is not in the document', () => {
+			const batch = doc.batch();
+			const docFrag = batch.createDocumentFragment();
+
+			batch.appendElement( 'element', null, docFrag );
+
+			const op = new RenameOperation( Position.createAt( docFrag ), oldName, newName, doc.version );
+
+			expect( op.isDocumentOperation ).to.false;
+		} );
+	} );
+
 	describe( 'toJSON', () => {
 		it( 'should create proper serialized object', () => {
 			const op = new RenameOperation( Position.createAt( root, 'end' ), oldName, newName, doc.version );

+ 28 - 0
packages/ckeditor5-engine/tests/model/operation/rootattributeoperation.js

@@ -54,6 +54,34 @@ describe( 'RootAttributeOperation', () => {
 		} );
 	} );
 
+	describe( 'isDocumentOperation', () => {
+		it( 'should be true when root is in the document', () => {
+			const operation = new RootAttributeOperation(
+				root,
+				'isNew',
+				null,
+				true,
+				doc.version
+			);
+
+			expect( operation.isDocumentOperation ).to.true;
+		} );
+
+		it( 'should be false when root is not in the document', () => {
+			const element = doc.batch().createElement( 'element' );
+
+			const operation = new RootAttributeOperation(
+				element,
+				'isNew',
+				null,
+				true,
+				doc.version
+			);
+
+			expect( operation.isDocumentOperation ).to.false;
+		} );
+	} );
+
 	it( 'should add attribute on the root element', () => {
 		doc.applyOperation( wrapInDelta(
 			new RootAttributeOperation(

+ 33 - 56
packages/ckeditor5-engine/tests/model/writer.js → packages/ckeditor5-engine/tests/model/operation/utils.js

@@ -3,21 +3,21 @@
  * For licensing, see LICENSE.md.
  */
 
-import Document from '../../src/model/document';
-import DocumentFragment from '../../src/model/documentfragment';
-import Element from '../../src/model/element';
-import Text from '../../src/model/text';
-import TextProxy from '../../src/model/textproxy';
-import Position from '../../src/model/position';
-import Range from '../../src/model/range';
-import writer from '../../src/model/writer';
-import { getData } from '../../src/dev-utils/model';
+import Document from '../../../src/model/document';
+import DocumentFragment from '../../../src/model/documentfragment';
+import Element from '../../../src/model/element';
+import Text from '../../../src/model/text';
+import TextProxy from '../../../src/model/textproxy';
+import Position from '../../../src/model/position';
+import Range from '../../../src/model/range';
+import * as utils from '../../../src/model/operation/utils';
+import { getData } from '../../../src/dev-utils/model';
 
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 
 let doc, root;
 
-describe( 'writer', () => {
+describe( 'writer utils', () => {
 	beforeEach( () => {
 		doc = new Document();
 		doc.schema.allow( { name: '$text', inside: '$root' } );
@@ -38,19 +38,19 @@ describe( 'writer', () => {
 
 	describe( 'insert', () => {
 		it( 'should insert nodes between nodes', () => {
-			writer.insert( Position.createAt( root, 3 ), [ 'xxx', new Element( 'p' ) ] );
+			utils._insert( Position.createAt( root, 3 ), [ 'xxx', new Element( 'p' ) ] );
 
 			expectData( 'fooxxx<p></p><$text bold="true">bar</$text><image src="img.jpg"></image>xyz' );
 		} );
 
 		it( 'should split text node if nodes at inserted at offset inside text node', () => {
-			writer.insert( Position.createAt( root, 5 ), new Element( 'p' ) );
+			utils._insert( Position.createAt( root, 5 ), new Element( 'p' ) );
 
 			expectData( 'foo<$text bold="true">ba</$text><p></p><$text bold="true">r</$text><image src="img.jpg"></image>xyz' );
 		} );
 
 		it( 'should merge text nodes if possible', () => {
-			writer.insert( Position.createAt( root, 3 ), new Text( 'xxx', { bold: true } ) );
+			utils._insert( Position.createAt( root, 3 ), new Text( 'xxx', { bold: true } ) );
 
 			expectData( 'foo<$text bold="true">xxxbar</$text><image src="img.jpg"></image>xyz' );
 		} );
@@ -59,21 +59,21 @@ describe( 'writer', () => {
 	describe( 'remove', () => {
 		it( 'should remove nodes in given range', () => {
 			const range = Range.createFromParentsAndOffsets( root, 3, root, 6 );
-			writer.remove( range );
+			utils._remove( range );
 
 			expectData( 'foo<image src="img.jpg"></image>xyz' );
 		} );
 
 		it( 'should split text node if range starts or ends inside text node', () => {
 			const range = Range.createFromParentsAndOffsets( root, 1, root, 5 );
-			writer.remove( range );
+			utils._remove( range );
 
 			expectData( 'f<$text bold="true">r</$text><image src="img.jpg"></image>xyz' );
 		} );
 
 		it( 'should merge text nodes if possible', () => {
 			const range = Range.createFromParentsAndOffsets( root, 3, root, 7 );
-			writer.remove( range );
+			utils._remove( range );
 
 			expectData( 'fooxyz' );
 			expect( root.childCount ).to.equal( 1 );
@@ -81,89 +81,66 @@ describe( 'writer', () => {
 
 		it( 'should throw if given range is not flat', () => {
 			expect( () => {
-				writer.remove( new Range( new Position( root, [ 0 ] ), new Position( root, [ 1, 2 ] ) ) );
-			} ).to.throw( CKEditorError, /model-writer-remove-range-not-flat/ );
+				utils._remove( new Range( new Position( root, [ 0 ] ), new Position( root, [ 1, 2 ] ) ) );
+			} ).to.throw( CKEditorError, /operation-utils-remove-range-not-flat/ );
 		} );
 	} );
 
 	describe( 'move', () => {
 		it( 'should move a range of nodes', () => {
 			const range = Range.createFromParentsAndOffsets( root, 3, root, 6 );
-			writer.move( range, Position.createAt( root, 0 ) );
+			utils._move( range, Position.createAt( root, 0 ) );
 
 			expectData( '<$text bold="true">bar</$text>foo<image src="img.jpg"></image>xyz' );
 		} );
 
-		it( 'should use remove and insert methods', () => {
-			sinon.spy( writer, 'remove' );
-			sinon.spy( writer, 'insert' );
-
-			const range = Range.createFromParentsAndOffsets( root, 3, root, 6 );
-			const position = Position.createAt( root, 0 );
-			writer.move( range, position );
-
-			expect( writer.remove.calledWithExactly( range ) ).to.be.true;
-			expect( writer.insert.calledWith( position ) ).to.be.true;
-		} );
-
 		it( 'should correctly move if target position is in same element as moved range, but after range', () => {
 			const range = Range.createFromParentsAndOffsets( root, 3, root, 6 );
-			writer.move( range, Position.createAt( root, 10 ) );
+			utils._move( range, Position.createAt( root, 10 ) );
 
 			expectData( 'foo<image src="img.jpg"></image>xyz<$text bold="true">bar</$text>' );
 		} );
 
 		it( 'should throw if given range is not flat', () => {
 			expect( () => {
-				writer.move( new Range( new Position( root, [ 0 ] ), new Position( root, [ 1, 2 ] ) ), null );
-			} ).to.throw( CKEditorError, /model-writer-move-range-not-flat/ );
+				utils._move( new Range( new Position( root, [ 0 ] ), new Position( root, [ 1, 2 ] ) ), null );
+			} ).to.throw( CKEditorError, /operation-utils-move-range-not-flat/ );
 		} );
 	} );
 
 	describe( 'setAttribute', () => {
 		it( 'should set attribute on given range of nodes', () => {
 			const range = Range.createFromParentsAndOffsets( root, 6, root, 8 );
-			writer.setAttribute( range, 'newAttr', true );
+			utils._setAttribute( range, 'newAttr', true );
 
 			expectData( 'foo<$text bold="true">bar</$text><image newAttr="true" src="img.jpg"></image><$text newAttr="true">x</$text>yz' );
 		} );
 
 		it( 'should remove attribute if null was passed as a value', () => {
 			const range = Range.createFromParentsAndOffsets( root, 6, root, 7 );
-			writer.setAttribute( range, 'src', null );
+			utils._setAttribute( range, 'src', null );
 
 			expectData( 'foo<$text bold="true">bar</$text><image></image>xyz' );
 		} );
 
 		it( 'should merge nodes if possible', () => {
 			const range = Range.createFromParentsAndOffsets( root, 0, root, 3 );
-			writer.setAttribute( range, 'bold', true );
+			utils._setAttribute( range, 'bold', true );
 
 			expectData( '<$text bold="true">foobar</$text><image src="img.jpg"></image>xyz' );
 		} );
 	} );
-
-	describe( 'removeAttribute', () => {
-		it( 'should use setAttribute', () => {
-			sinon.spy( writer, 'setAttribute' );
-
-			const range = Range.createFromParentsAndOffsets( root, 6, root, 7 );
-			writer.removeAttribute( range, 'src' );
-
-			expect( writer.setAttribute.calledWithExactly( range, 'src', null ) ).to.be.true;
-		} );
-	} );
 } );
 
 describe( 'normalizeNodes', () => {
 	it( 'should change single object into an array', () => {
 		const p = new Element( 'p' );
 
-		expect( writer.normalizeNodes( p ) ).to.deep.equal( [ p ] );
+		expect( utils._normalizeNodes( p ) ).to.deep.equal( [ p ] );
 	} );
 
 	it( 'should change strings to text nodes', () => {
-		const text = writer.normalizeNodes( 'abc' )[ 0 ];
+		const text = utils._normalizeNodes( 'abc' )[ 0 ];
 
 		expect( text ).to.be.instanceof( Text );
 		expect( text.data ).to.equal( 'abc' );
@@ -173,7 +150,7 @@ describe( 'normalizeNodes', () => {
 		const textNode = new Text( 'abc' );
 		const textProxy = new TextProxy( textNode, 1, 1 );
 
-		const text = writer.normalizeNodes( textProxy )[ 0 ];
+		const text = utils._normalizeNodes( textProxy )[ 0 ];
 
 		expect( text ).to.be.instanceof( Text );
 		expect( text.data ).to.equal( 'b' );
@@ -182,11 +159,11 @@ describe( 'normalizeNodes', () => {
 	it( 'should not change elements', () => {
 		const p = new Element( 'p' );
 
-		expect( writer.normalizeNodes( p )[ 0 ] ).to.equal( p );
+		expect( utils._normalizeNodes( p )[ 0 ] ).to.equal( p );
 	} );
 
 	it( 'should omit unrecognized objects', () => {
-		expect( writer.normalizeNodes( 1 ) ).to.deep.equal( [] );
+		expect( utils._normalizeNodes( 1 ) ).to.deep.equal( [] );
 	} );
 
 	it( 'should accept arrays', () => {
@@ -194,7 +171,7 @@ describe( 'normalizeNodes', () => {
 		const image = new Element( 'image' );
 		const nodes = [ 'abc', text, image, 1, 'xyz' ];
 
-		const normalized = writer.normalizeNodes( nodes );
+		const normalized = utils._normalizeNodes( nodes );
 
 		expect( normalized[ 0 ] ).to.be.instanceof( Text );
 		expect( normalized[ 1 ] ).to.equal( text );
@@ -203,7 +180,7 @@ describe( 'normalizeNodes', () => {
 	} );
 
 	it( 'should merge text nodes if mergeTextNodes flag is set to true', () => {
-		const normalized = writer.normalizeNodes( [ 'foo', 'bar' ], true );
+		const normalized = utils._normalizeNodes( [ 'foo', 'bar' ], true );
 
 		expect( normalized.length ).to.equal( 1 );
 		expect( normalized[ 0 ].data ).to.equal( 'foobar' );
@@ -216,7 +193,7 @@ describe( 'normalizeNodes', () => {
 			'xyz'
 		];
 
-		const normalized = writer.normalizeNodes( nodes, true );
+		const normalized = utils._normalizeNodes( nodes, true );
 
 		expect( normalized[ 0 ] ).to.be.instanceof( Text );
 		expect( normalized[ 0 ].getAttribute( 'bold' ) ).to.be.true;

+ 4 - 27
packages/ckeditor5-engine/tests/model/schema/schema.js

@@ -793,13 +793,6 @@ describe( 'Schema', () => {
 			} );
 
 			it( 'should filter out disallowed attributes from given nodes', () => {
-				schema.removeDisallowedAttributes( [ text, image ], '$root' );
-
-				expect( Array.from( text.getAttributeKeys() ) ).to.deep.equal( [ 'a' ] );
-				expect( Array.from( image.getAttributeKeys() ) ).to.deep.equal( [ 'b' ] );
-			} );
-
-			it( 'should filter out disallowed attributes from given nodes (batch)', () => {
 				const root = doc.getRoot();
 				const batch = doc.batch();
 
@@ -834,22 +827,6 @@ describe( 'Schema', () => {
 				div = new Element( 'div', [], [ paragraph, bar, imageInDiv ] );
 			} );
 
-			it( 'should filter out disallowed attributes from child nodes', () => {
-				schema.removeDisallowedAttributes( [ div ], '$root' );
-
-				expect( stringify( div ) )
-					.to.equal(
-						'<div>' +
-							'<paragraph>' +
-								'<$text b="1">foo</$text>' +
-								'<image b="1"></image>' +
-							'</paragraph>' +
-							'<$text a="1">bar</$text>' +
-							'<image a="1"></image>' +
-						'</div>'
-					);
-			} );
-
 			it( 'should filter out disallowed attributes from child nodes (batch)', () => {
 				const root = doc.getRoot();
 				const batch = doc.batch();
@@ -893,21 +870,21 @@ describe( 'Schema', () => {
 			} );
 
 			it( 'should accept iterable as nodes', () => {
-				schema.removeDisallowedAttributes( frag.getChildren(), '$root' );
+				schema.removeDisallowedAttributes( frag.getChildren(), '$root', doc.batch() );
 
 				expect( stringify( frag ) )
 					.to.equal( '<$text a="1">foo</$text><paragraph><$text b="1">bar</$text></paragraph>biz' );
 			} );
 
 			it( 'should accept Position as inside', () => {
-				schema.removeDisallowedAttributes( frag.getChildren(), Position.createAt( root ) );
+				schema.removeDisallowedAttributes( frag.getChildren(), Position.createAt( root ), doc.batch() );
 
 				expect( stringify( frag ) )
 					.to.equal( '<$text a="1">foo</$text><paragraph><$text b="1">bar</$text></paragraph>biz' );
 			} );
 
 			it( 'should accept Node as inside', () => {
-				schema.removeDisallowedAttributes( frag.getChildren(), [ root ] );
+				schema.removeDisallowedAttributes( frag.getChildren(), [ root ], doc.batch() );
 
 				expect( stringify( frag ) )
 					.to.equal( '<$text a="1">foo</$text><paragraph><$text b="1">bar</$text></paragraph>biz' );
@@ -920,7 +897,7 @@ describe( 'Schema', () => {
 
 			const image = new Element( 'image', { a: 1, b: 1 } );
 
-			schema.removeDisallowedAttributes( [ image ], '$root' );
+			schema.removeDisallowedAttributes( [ image ], '$root', doc.batch() );
 
 			expect( Array.from( image.getAttributeKeys() ) ).to.deep.equal( [ 'a', 'b' ] );
 		} );

+ 4 - 4
packages/ckeditor5-engine/tests/model/selection.js

@@ -894,14 +894,14 @@ describe( 'Selection', () => {
 		} );
 
 		it( 'should return selected element', () => {
-			const { selection, model } = parse( '<p>foo</p>[<p>bar</p>]<p>baz</p>', schema );
+			const { selection, model } = parse( '<p>foo</p>[<p>bar</p>]<p>baz</p>', schema, doc.batch() );
 			const p = model.getChild( 1 );
 
 			expect( selection.getSelectedElement() ).to.equal( p );
 		} );
 
 		it( 'should return null if there is more than one range', () => {
-			const { selection } = parse( '[<p>foo</p>][<p>bar</p>]<p>baz</p>', schema );
+			const { selection } = parse( '[<p>foo</p>][<p>bar</p>]<p>baz</p>', schema, doc.batch() );
 
 			expect( selection.getSelectedElement() ).to.be.null;
 		} );
@@ -911,13 +911,13 @@ describe( 'Selection', () => {
 		} );
 
 		it( 'should return null if selection is not over single element #1', () => {
-			const { selection } = parse( '<p>foo</p>[<p>bar</p><p>baz}</p>', schema );
+			const { selection } = parse( '<p>foo</p>[<p>bar</p><p>baz}</p>', schema, doc.batch() );
 
 			expect( selection.getSelectedElement() ).to.be.null;
 		} );
 
 		it( 'should return null if selection is not over single element #2', () => {
-			const { selection } = parse( '<p>{bar}</p>', schema );
+			const { selection } = parse( '<p>{bar}</p>', schema, doc.batch() );
 
 			expect( selection.getSelectedElement() ).to.be.null;
 		} );

+ 280 - 54
packages/ckeditor5-engine/tests/view/document/jumpoveruielement.js

@@ -6,12 +6,27 @@
 /* globals document */
 
 import ViewDocument from '../../../src/view/document';
+import UIElement from '../../../src/view/uielement';
+import ViewContainerElement from '../../../src/view/containerelement';
+import ViewAttribtueElement from '../../../src/view/attributeelement';
+import ViewText from '../../../src/view/text';
+import ViewRange from '../../../src/view/range';
 import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
 import createElement from '@ckeditor/ckeditor5-utils/src/dom/createelement';
 import { setData as setViewData } from '../../../src/dev-utils/view';
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
 
 describe( 'Document', () => {
-	let viewDocument, domRoot, domSelection;
+	let viewDocument, domRoot, domSelection, viewRoot, foo, bar, ui, ui2;
+
+	class MyUIElement extends UIElement {
+		render( domDocument ) {
+			const element = super.render( domDocument );
+			element.innerText = this.contents;
+
+			return element;
+		}
+	}
 
 	beforeEach( () => {
 		domRoot = createElement( document, 'div', {
@@ -20,12 +35,20 @@ describe( 'Document', () => {
 		document.body.appendChild( domRoot );
 
 		viewDocument = new ViewDocument();
-		viewDocument.createRoot( domRoot );
+		viewRoot = viewDocument.createRoot( domRoot );
 
 		domSelection = document.getSelection();
 		domSelection.removeAllRanges();
 
 		viewDocument.isFocused = true;
+
+		foo = new ViewText( 'foo' );
+		bar = new ViewText( 'bar' );
+		ui = new MyUIElement( 'span' );
+		ui.contents = 'xxx';
+
+		ui2 = new MyUIElement( 'span' );
+		ui2.contents = 'yyy';
 	} );
 
 	afterEach( () => {
@@ -34,8 +57,7 @@ describe( 'Document', () => {
 		domRoot.parentElement.removeChild( domRoot );
 	} );
 
-	function prepare( view, options ) {
-		setViewData( viewDocument, view );
+	function renderAndFireKeydownEvent( options ) {
 		viewDocument.render();
 
 		const eventData = Object.assign( { keyCode: keyCodes.arrowright, domTarget: viewDocument.domRoots.get( 'main' ) }, options );
@@ -61,130 +83,334 @@ describe( 'Document', () => {
 	describe( 'jump over ui element handler', () => {
 		describe( 'collapsed selection', () => {
 			it( 'do nothing when another key is pressed', () => {
-				prepare( '<container:p>foo<ui:span></ui:span>{}bar</container:p>', { keyCode: keyCodes.arrowleft } );
-				check( 'bar', 0 );
+				// <container:p>foo<ui:span>xxx</ui:span>{}bar</container:p>
+				const p = new ViewContainerElement( 'p', null, [ foo, ui, bar ] );
+				viewRoot.appendChildren( p );
+				viewDocument.selection.setRanges( [ ViewRange.createFromParentsAndOffsets( bar, 0, bar, 0 ) ] );
+
+				renderAndFireKeydownEvent( { keyCode: keyCodes.arrowleft } );
+
+				testUtils.checkAssertions(
+					() => check( 'bar', 0 ),
+					// Safari renders selection at the end of the text node.
+					() => check( 'xxx', 3 )
+				);
 			} );
 
 			it( 'jump over ui element when right arrow is pressed before ui element - directly before ui element', () => {
-				prepare( '<container:p>foo[]<ui:span></ui:span>bar</container:p>' );
-				check( 'P', 2 );
+				// <container:p>foo[]<ui:span>xxx</ui:span>bar</container:p>
+				const p = new ViewContainerElement( 'p', null, [ foo, ui, bar ] );
+				viewRoot.appendChildren( p );
+				viewDocument.selection.setRanges( [ ViewRange.createFromParentsAndOffsets( p, 1, p, 1 ) ] );
+
+				renderAndFireKeydownEvent();
+
+				testUtils.checkAssertions(
+					// <p>foo<span>xxx</span>[]bar</p>
+					() => check( 'P', 2 ),
+					// Safari renders selection at the end of the text node.
+					// <p>foo<span>xxx{}</span>bar</p>
+					() => check( 'xxx', 3 )
+				);
 			} );
 
 			it( 'jump over ui element when right arrow is pressed before ui element - not directly before ui element', () => {
-				prepare( '<container:p>foo{}<ui:span></ui:span>bar</container:p>' );
-				check( 'P', 2 );
+				// <container:p>foo{}<ui:span>xxx</ui:span>bar</container:p>
+				const p = new ViewContainerElement( 'p', null, [ foo, ui, bar ] );
+				viewRoot.appendChildren( p );
+				viewDocument.selection.setRanges( [ ViewRange.createFromParentsAndOffsets( foo, 3, foo, 3 ) ] );
+
+				renderAndFireKeydownEvent();
+
+				testUtils.checkAssertions(
+					// <p>foo<span>xxx</span>[]bar</p>
+					() => check( 'P', 2 ),
+					// Safari renders selection at the end of the text node.
+					// <p>foo<span>xxx{}</span>bar</p>
+					() => check( 'xxx', 3 )
+				);
 			} );
 
 			it( 'jump over multiple ui elements when right arrow is pressed before ui element', () => {
-				prepare( '<container:p>foo{}<ui:span></ui:span><ui:span></ui:span>bar</container:p>' );
-				check( 'P', 3 );
+				// <container:p>foo{}<ui:span>xxx</ui:span><ui:span>yyy</ui:span>bar</container:p>'
+				const p = new ViewContainerElement( 'p', null, [ foo, ui, ui2, bar ] );
+				viewRoot.appendChildren( p );
+				viewDocument.selection.setRanges( [ ViewRange.createFromParentsAndOffsets( foo, 3, foo, 3 ) ] );
+
+				renderAndFireKeydownEvent();
+
+				testUtils.checkAssertions(
+					// <p>foo<span>xxx</span><span>yyy</span>[]bar</p>
+					() => check( 'P', 3 ),
+					// Safari renders selection at the end of the text node.
+					// <p>foo<span>xxx</span><span>yyy{}</span>bar</p>
+					() => check( 'yyy', 3 )
+				);
 			} );
 
 			it( 'jump over ui elements at the end of container element', () => {
-				prepare( '<container:p>foo{}<ui:span></ui:span><ui:span></ui:span></container:p><container:div></container:div>' );
-				check( 'P', 3 );
+				// <container:p>foo{}<ui:span>xxx</ui:span><ui:span>yyy</ui:span></container:p><container:div></container:div>
+				const p = new ViewContainerElement( 'p', null, [ foo, ui, ui2 ] );
+				const div = new ViewContainerElement( 'div' );
+				viewRoot.appendChildren( p );
+				viewRoot.appendChildren( div );
+				viewDocument.selection.setRanges( [ ViewRange.createFromParentsAndOffsets( foo, 3, foo, 3 ) ] );
+
+				renderAndFireKeydownEvent();
+
+				testUtils.checkAssertions(
+					// <p>foo<span>xxx</span><span>yyy</span>[]</p><div></div>
+					() => check( 'P', 3 ),
+					// Safari renders selection at the end of the text node.
+					// <p>foo<span>xxx</span><span>yyy{}</span></p><div></div>
+					() => check( 'yyy', 3 )
+				);
 			} );
 
 			it( 'jump over ui element if selection is in attribute element - case 1', () => {
-				prepare( '<container:p><attribute:b>foo{}</attribute:b><ui:span></ui:span>bar</container:p>' );
-				check( 'P', 2 );
+				// <container:p><attribute:b>foo{}</attribute:b><ui:span>xxx</ui:span>bar</container:p>
+				const b = new ViewAttribtueElement( 'b', null, foo );
+				const p = new ViewContainerElement( 'p', null, [ b, ui, bar ] );
+				viewRoot.appendChildren( p );
+				viewDocument.selection.setRanges( [ ViewRange.createFromParentsAndOffsets( foo, 3, foo, 3 ) ] );
+
+				renderAndFireKeydownEvent();
+
+				testUtils.checkAssertions(
+					// <p><b>foo</b><span>xxx</span>[]bar</p>
+					() => check( 'P', 2 ),
+					// Safari renders selection at the end of the text node.
+					// <p><b>foo</b><span>xxx{}</span>bar</p>
+					() => check( 'xxx', 3 )
+				);
 			} );
 
 			it( 'jump over ui element if selection is in attribute element - case 2', () => {
-				prepare( '<container:p><attribute:b>foo{}</attribute:b><ui:span></ui:span>bar</container:p>' );
-				check( 'P', 2 );
+				// <container:p><attribute:b>foo[]</attribute:b><ui:span>xxx</ui:span>bar</container:p>
+				const b = new ViewAttribtueElement( 'b', null, foo );
+				const p = new ViewContainerElement( 'p', null, [ b, ui, bar ] );
+				viewRoot.appendChildren( p );
+				viewDocument.selection.setRanges( [ ViewRange.createFromParentsAndOffsets( b, 1, b, 1 ) ] );
+
+				renderAndFireKeydownEvent();
+
+				testUtils.checkAssertions(
+					// <p><b>foo</b><span>xxx</span>[]bar</p>
+					() => check( 'P', 2 ),
+					// Safari renders selection at the end of the text node.
+					// <p><b>foo</b><span>xxx{}</span>bar</p>
+					() => check( 'xxx', 3 )
+				);
 			} );
 
 			it( 'jump over ui element if selection is in multiple attribute elements', () => {
-				prepare( '<container:p><attribute:i><attribute:b>foo{}</attribute:b></attribute:i><ui:span></ui:span>bar</container:p>' );
-				check( 'P', 2 );
+				// <container:p>
+				// 		<attribute:i>
+				// 			<attribute:b>foo{}</attribute:b>
+				// 		</attribute:i>
+				// 		<ui:span>
+				//			xxx
+				// 		</ui:span>
+				// 		bar
+				// </container:p>
+				const b = new ViewAttribtueElement( 'b', null, foo );
+				const i = new ViewAttribtueElement( 'i', null, b );
+				const p = new ViewContainerElement( 'p', null, [ i, ui, bar ] );
+
+				viewRoot.appendChildren( p );
+				viewDocument.selection.setRanges( [ ViewRange.createFromParentsAndOffsets( foo, 3, foo, 3 ) ] );
+
+				renderAndFireKeydownEvent();
+
+				testUtils.checkAssertions(
+					// <p><i><b>foo</b></i><span>xxx</span>[]bar</p>
+					() => check( 'P', 2 ),
+					// Safari renders selection at the end of the text node.
+					// <p><i><b>foo</b></i><span>xxx{}</span>bar</p>
+					() => check( 'xxx', 3 )
+				);
 			} );
 
 			it( 'jump over empty attribute elements and ui elements', () => {
-				prepare(
-					'<container:p>' +
-						'foo{}<attribute:b></attribute:b><ui:span></ui:span><ui:span></ui:span><attribute:b></attribute:b>bar' +
-					'</container:p>'
+				// <container:p>' +
+				// 		foo{}
+				// 		<attribute:b></attribute:b>
+				// 		<ui:span>xxx</ui:span>
+				// 		<ui:span>yyy</ui:span>
+				// 		<attribute:b></attribute:b>
+				// 		bar
+				// </container:p>
+				const b1 = new ViewAttribtueElement( 'b' );
+				const b2 = new ViewAttribtueElement( 'b' );
+				const p = new ViewContainerElement( 'p', null, [ foo, b1, ui, ui2, b2, bar ] );
+
+				viewRoot.appendChildren( p );
+				viewDocument.selection.setRanges( [ ViewRange.createFromParentsAndOffsets( foo, 3, foo, 3 ) ] );
+
+				renderAndFireKeydownEvent();
+
+				testUtils.checkAssertions(
+					// <p>foo<b></b><span>xxx</span><span>yyy</span>[]bar</p>
+					() => check( 'P', 5 ),
+					// Safari renders selection at the end of the text node.
+					// <p>foo<b></b><span>xxx</span><span>yyy{}</span>bar</p>
+					() => check( 'yyy', 3 )
 				);
-
-				check( 'P', 5 );
 			} );
 
 			it( 'jump over empty attribute elements and ui elements if shift key is pressed', () => {
-				prepare(
-					'<container:p>' +
-						'foo{}<attribute:b></attribute:b><ui:span></ui:span><ui:span></ui:span><attribute:b></attribute:b>bar' +
-					'</container:p>',
-					{ shiftKey: true }
+				// <container:p>
+				// 		foo{}
+				// 		<attribute:b></attribute:b>
+				// 		<ui:span>xxx</ui:span>
+				// 		<ui:span>yyy</ui:span>
+				// 		<attribute:b></attribute:b>
+				// 		bar
+				// </container:p>
+
+				const b1 = new ViewAttribtueElement( 'b' );
+				const b2 = new ViewAttribtueElement( 'b' );
+				const p = new ViewContainerElement( 'p', null, [ foo, b1, ui, ui2, b2, bar ] );
+
+				viewRoot.appendChildren( p );
+				viewDocument.selection.setRanges( [ ViewRange.createFromParentsAndOffsets( foo, 3, foo, 3 ) ] );
+
+				renderAndFireKeydownEvent( { shiftKey: true } );
+
+				testUtils.checkAssertions(
+					// <p>foo<b></b><span>xxx</span><span>yyy</span><b><b>[]bar</p>
+					() => check( 'P', 5 ),
+					// Safari renders selection at the end of the text node.
+					// <p>foo<b></b><span>xxx</span><span>yyy{}</span><b><b>bar</p>
+					() => check( 'yyy', 3 )
 				);
-
-				check( 'P', 5 );
 			} );
 
 			it( 'do nothing if selection is not directly before ui element', () => {
-				prepare( '<container:p>fo{}o<ui:span></ui:span>bar</container:p>' );
+				setViewData( viewDocument, '<container:p>fo{}o<ui:span></ui:span>bar</container:p>' );
+				renderAndFireKeydownEvent();
+
 				check( 'foo', 2 );
 			} );
 
 			it( 'do nothing if selection is in attribute element but not before ui element', () => {
-				prepare( '<container:p><attribute:b>foo{}</attribute:b>bar</container:p>' );
+				setViewData( viewDocument, '<container:p><attribute:b>foo{}</attribute:b>bar</container:p>' );
+				renderAndFireKeydownEvent();
+
 				check( 'foo', 3 );
 			} );
 
 			it( 'do nothing if selection is before non-empty attribute element', () => {
-				prepare( '<container:p>fo{}<attribute:b>o</attribute:b><ui:span></ui:span>bar</container:p>' );
+				setViewData( viewDocument, '<container:p>fo{}<attribute:b>o</attribute:b><ui:span></ui:span>bar</container:p>' );
+				renderAndFireKeydownEvent();
+
 				check( 'fo', 2 );
 			} );
 
 			it( 'do nothing if selection is before container element - case 1', () => {
-				prepare( '<container:p>foo{}</container:p><ui:span></ui:span><container:div>bar</container:div>' );
+				setViewData( viewDocument, '<container:p>foo{}</container:p><ui:span></ui:span><container:div>bar</container:div>' );
+				renderAndFireKeydownEvent();
+
 				check( 'foo', 3 );
 			} );
 
 			it( 'do nothing if selection is before container element - case 2', () => {
-				prepare( '<container:div>foo{}<container:p></container:p><ui:span></ui:span></container:div>' );
+				setViewData( viewDocument, '<container:div>foo{}<container:p></container:p><ui:span></ui:span></container:div>' );
+				renderAndFireKeydownEvent();
+
 				check( 'foo', 3 );
 			} );
 
 			it( 'do nothing if selection is at the end of last container element', () => {
-				prepare( '<container:p>foo{}</container:p>' );
+				setViewData( viewDocument, '<container:p>foo{}</container:p>' );
+				renderAndFireKeydownEvent();
+
 				check( 'foo', 3 );
 			} );
 		} );
 
 		describe( 'non-collapsed selection', () => {
 			it( 'should do nothing', () => {
-				prepare( '<container:p>f{oo}<ui:span></ui:span>bar</container:p>' );
+				setViewData( viewDocument, '<container:p>f{oo}<ui:span></ui:span>bar</container:p>' );
+				renderAndFireKeydownEvent();
+
 				check( 'foo', 1, 'foo', 3 );
 			} );
 
 			it( 'should do nothing if selection is not before ui element - shift key pressed', () => {
-				prepare( '<container:p>f{o}o<ui:span></ui:span>bar</container:p>', { shiftKey: true } );
+				setViewData( viewDocument, '<container:p>f{o}o<ui:span></ui:span>bar</container:p>' );
+				renderAndFireKeydownEvent( { shiftKey: true } );
+
 				check( 'foo', 1, 'foo', 2 );
 			} );
 
 			it( 'jump over ui element if shift key is pressed', () => {
-				prepare( '<container:p>fo{o}<ui:span></ui:span>bar</container:p>', { shiftKey: true } );
-				check( 'foo', 2, 'P', 2 );
+				// <container:p>fo{o}<ui:span>xxx</ui:span>bar</container:p>
+				const p = new ViewContainerElement( 'p', null, [ foo, ui, bar ] );
+
+				viewRoot.appendChildren( p );
+				viewDocument.selection.setRanges( [ ViewRange.createFromParentsAndOffsets( foo, 2, foo, 3 ) ] );
+
+				renderAndFireKeydownEvent( { shiftKey: true } );
+
+				testUtils.checkAssertions(
+					// <p>fo{o<span>xxx</span>]bar</p>
+					() => check( 'foo', 2, 'P', 2 ),
+					// Safari renders selection at the end of the previous text node.
+					// <p>fo{o<span>xxx}</span>bar</p>
+					() => check( 'foo', 2, 'xxx', 3 )
+				);
 			} );
 
 			it( 'jump over ui element if selection is in multiple attribute elements', () => {
-				prepare(
-					'<container:p><attribute:i><attribute:b>fo{o}</attribute:b></attribute:i><ui:span></ui:span>bar</container:p>',
-					{ shiftKey: true }
+				// <container:p>
+				// 		<attribute:i>
+				// 			<attribute:b>fo{o}</attribute:b>
+				// 		</attribute:i>
+				// 		<ui:span>xxx</ui:span>
+				// 		bar
+				// </container:p>
+				const b = new ViewAttribtueElement( 'b', null, foo );
+				const i = new ViewAttribtueElement( 'i', null, b );
+				const p = new ViewContainerElement( 'p', null, [ i, ui, bar ] );
+				viewRoot.appendChildren( p );
+				viewDocument.selection.setRanges( [ ViewRange.createFromParentsAndOffsets( foo, 2, foo, 3 ) ] );
+
+				renderAndFireKeydownEvent( { shiftKey: true } );
+
+				testUtils.checkAssertions(
+					// <p><i><b>fo{o</b></i><span>xxx</span>]bar</p>
+					() => check( 'foo', 2, 'P', 2 ),
+					// Safari renders selection at the end of the previous text node.
+					// <p><i><b>fo{o</b></i><span>xxx}</span>bar</p>
+					() => check( 'foo', 2, 'xxx', 3 )
 				);
-				check( 'foo', 2, 'P', 2 );
 			} );
 
 			it( 'jump over empty attribute elements and ui elements if shift key is pressed', () => {
-				prepare(
-					'<container:p>' +
-						'fo{o}<attribute:b></attribute:b><ui:span></ui:span><ui:span></ui:span><attribute:b></attribute:b>bar' +
-					'</container:p>',
-					{ shiftKey: true }
+				// <container:p>
+				// 		fo{o}
+				// 		<attribute:b></attribute:b>
+				// 		<ui:span>xxx</ui:span>
+				// 		<ui:span>yyy</ui:span>
+				// 		<attribute:b></attribute:b>
+				// 		bar
+				// </container:p>
+				const b1 = new ViewAttribtueElement( 'b' );
+				const b2 = new ViewAttribtueElement( 'b' );
+				const p = new ViewContainerElement( 'p', null, [ foo, b1, ui, ui2, b2, bar ] );
+				viewRoot.appendChildren( p );
+				viewDocument.selection.setRanges( [ ViewRange.createFromParentsAndOffsets( foo, 2, foo, 3 ) ] );
+
+				renderAndFireKeydownEvent( { shiftKey: true } );
+
+				testUtils.checkAssertions(
+					// <p>fo{o<b></b><span>xxx</span><span>yyy</span><b></b>]bar</p>
+					() => check( 'foo', 2, 'P', 5 ),
+					// Safari renders selection at the end of the previous text node.
+					// <p>fo{o<b></b><span>xxx</span><span>yyy}</span><b></b>bar</p>
+					() => check( 'foo', 2, 'yyy', 3 )
 				);
-
-				check( 'foo', 2, 'P', 5 );
 			} );
 		} );
 

+ 18 - 6
packages/ckeditor5-engine/tests/view/domconverter/domconverter.js

@@ -51,8 +51,11 @@ describe( 'DomConverter', () => {
 		} );
 
 		afterEach( () => {
+			converter.unbindDomElement( domEditable );
 			document.body.removeChild( domEditableParent );
 			viewDocument.destroy();
+
+			document.body.focus();
 		} );
 
 		it( 'should call focus on corresponding DOM editable', () => {
@@ -226,6 +229,10 @@ describe( 'DomConverter', () => {
 			document.body.appendChild( domP );
 		} );
 
+		afterEach( () => {
+			domP.remove();
+		} );
+
 		it( 'should return true for correct dom selection', () => {
 			// <p>INLINE_FILLER{foo}<span></span></p>.
 			const sel1 = domSelection( domFillerTextNode, INLINE_FILLER_LENGTH, domFillerTextNode, INLINE_FILLER_LENGTH + 3 );
@@ -272,22 +279,27 @@ describe( 'DomConverter', () => {
 			} );
 
 			it( 'if anchor or focus is directly inside dom element that represents view ui element', () => {
+				// Set text indside ui element to put selection there.
+				domUiSpan.innerText = 'xxx';
 				// Tests forward and backward selection.
-				// <p>INLINE_FILLER{foo<span-ui>]<span-container></span></span></p>.
-				const sel1 = domSelection( domFillerTextNode, INLINE_FILLER_LENGTH + 3, domUiSpan, 0 );
+				// <p>INLINE_FILLER{foo<span-ui>xxx]<span-container></span></span></p>.
+				const sel1 = domSelection( domFillerTextNode, INLINE_FILLER_LENGTH, domUiSpan, 1 );
+
 				expect( converter.isDomSelectionCorrect( sel1 ) ).to.be.false;
 
-				const sel2 = domSelection( domUiSpan, 0, domFillerTextNode, INLINE_FILLER_LENGTH + 3 );
+				const sel2 = domSelection( domUiSpan, 1, domFillerTextNode, INLINE_FILLER_LENGTH );
 				expect( converter.isDomSelectionCorrect( sel2 ) ).to.be.false;
 			} );
 
 			it( 'if anchor or focus is inside deep ui element structure (not directly in ui element)', () => {
+				// Set text indside ui element to put selection there.
+				domUiDeepSpan.innerText = 'xxx';
 				// Tests forward and backward selection.
-				// <p>INLINE_FILLER{foo<span-ui><span-container>]</span></span></p>.
-				const sel1 = domSelection( domFillerTextNode, INLINE_FILLER_LENGTH + 3, domUiDeepSpan, 0 );
+				// <p>INLINE_FILLER{foo<span-ui><span-container>xxx]</span></span></p>.
+				const sel1 = domSelection( domFillerTextNode, INLINE_FILLER_LENGTH, domUiDeepSpan, 1 );
 				expect( converter.isDomSelectionCorrect( sel1 ) ).to.be.false;
 
-				const sel2 = domSelection( domUiDeepSpan, 0, domFillerTextNode, INLINE_FILLER_LENGTH + 3 );
+				const sel2 = domSelection( domUiDeepSpan, 1, domFillerTextNode, INLINE_FILLER_LENGTH );
 				expect( converter.isDomSelectionCorrect( sel2 ) ).to.be.false;
 			} );
 		} );

+ 7 - 7
packages/ckeditor5-engine/tests/view/observer/focusobserver.js

@@ -3,7 +3,7 @@
  * For licensing, see LICENSE.md.
  */
 
-/* globals document, window */
+/* globals document */
 import FocusObserver from '../../../src/view/observer/focusobserver';
 import ViewDocument from '../../../src/view/document';
 import ViewRange from '../../../src/view/range';
@@ -142,7 +142,7 @@ describe( 'FocusObserver', () => {
 	} );
 
 	describe( 'integration test', () => {
-		let viewDocument, domRoot, observer, domSelection;
+		let viewDocument, domRoot, observer;
 
 		beforeEach( () => {
 			domRoot = document.createElement( 'div' );
@@ -152,16 +152,14 @@ describe( 'FocusObserver', () => {
 			viewDocument.createRoot( domRoot );
 
 			observer = viewDocument.getObserver( FocusObserver );
-			domSelection = window.getSelection();
 		} );
 
-		it( 'should render document after selectionChange event', done => {
+		it( 'should always render document after selectionChange event', done => {
 			const selectionChangeSpy = sinon.spy();
 			const renderSpy = sinon.spy();
 
 			setData( viewDocument, '<div contenteditable="true">foo bar</div>' );
 			viewDocument.render();
-			const domEditable = domRoot.childNodes[ 0 ];
 
 			viewDocument.on( 'selectionChange', selectionChangeSpy );
 			viewDocument.on( 'render', renderSpy, { priority: 'low' } );
@@ -171,8 +169,10 @@ describe( 'FocusObserver', () => {
 				done();
 			}, { priority: 'low' } );
 
-			observer.onDomEvent( { type: 'focus', target: domEditable } );
-			domSelection.collapse( domEditable, 0 );
+			// Mock selectionchange event after focus event. Render called by focus observer should be fired after
+			// async selection change.
+			viewDocument.fire( 'focus' );
+			viewDocument.fire( 'selectionChange' );
 		} );
 
 		it( 'should render without selectionChange event', done => {

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff