Przeglądaj źródła

Splited Batch to Writer and Batch.

Oskar Wróbel 8 lat temu
rodzic
commit
27cec9d042

+ 6 - 878
packages/ckeditor5-engine/src/model/batch.js

@@ -7,41 +7,8 @@
  * @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';
-
 /**
- * `Batch` instance groups document changes ({@link module:engine/model/delta/delta~Delta deltas}). All deltas grouped in a single `Batch`
+ * `Batch` instance groups model changes ({@link module:engine/model/delta/delta~Delta deltas}). All deltas grouped in a single `Batch`
  * can be reverted together, so you can think about `Batch` as of a single undo step. If you want to extend given undo step you
  * can call another method on the same `Batch` object. If you want to create a separate undo step you can create a new `Batch`.
  *
@@ -59,25 +26,17 @@ import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
  */
 export default class Batch {
 	/**
-	 * Creates `Batch` instance. Not recommended to use directly, use {@link module:engine/model/document~Document#batch} instead.
+	 * Creates `Batch` instance. Not recommended to use directly, use {@link module:engine/model~model#change} or
+	 * {@link module:engine/model~model#enqueueChanges} instead.
 	 *
-	 * @param {module:engine/model/document~Document} document Document which this Batch changes.
 	 * @param {'transparent'|'default'} [type='default'] Type of the batch.
 	 */
-	constructor( document, type = 'default' ) {
-		/**
-		 * Document which this batch changes.
-		 *
-		 * @readonly
-		 * @member {module:engine/model/document~Document} module:engine/model/batch~Batch#document
-		 */
-		this.document = document;
-
+	constructor( type = 'default' ) {
 		/**
 		 * Array of deltas which compose this batch.
 		 *
 		 * @readonly
-		 * @member {Array.<module:engine/model/delta/delta~Delta>} module:engine/model/batch~Batch#deltas
+		 * @type {Array.<module:engine/model/delta/delta~Delta>}
 		 */
 		this.deltas = [];
 
@@ -89,7 +48,7 @@ export default class Batch {
 		 * * `'transparent'` - batch that should be ignored by other features, i.e. initial batch or collaborative editing changes.
 		 *
 		 * @readonly
-		 * @member {'transparent'|'default'} module:engine/model/batch~Batch#type
+		 * @type {'transparent'|'default'}
 		 */
 		this.type = type;
 	}
@@ -129,835 +88,4 @@ 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 );
-	}
-}
-
-// 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;
-	}
-
-	return false;
 }

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

@@ -269,7 +269,7 @@ export default class Document {
 	 */
 	getNearestSelectionRange( position, direction = 'both' ) {
 		// Return collapsed range if provided position is valid.
-		if ( this.schema.check( { name: '$text', inside: position } ) ) {
+		if ( this.model.schema.check( { name: '$text', inside: position } ) ) {
 			return new Range( position );
 		}
 

+ 913 - 0
packages/ckeditor5-engine/src/model/writer.js

@@ -0,0 +1,913 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module engine/model/writer
+ */
+
+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';
+
+/**
+ * `Batch` instance groups document changes ({@link module:engine/model/delta/delta~Delta deltas}). All deltas grouped in a single `Batch`
+ * can be reverted together, so you can think about `Batch` as of a single undo step. If you want to extend given undo step you
+ * can call another method on the same `Batch` object. If you want to create a separate undo step you can create a new `Batch`.
+ *
+ * For example to create two separate undo steps you can call:
+ *
+ *		doc.batch().insert( 'foo', firstPosition );
+ *		doc.batch().insert( 'bar', secondPosition );
+ *
+ * To create a single undo step:
+ *
+ *		const batch = doc.batch();
+ *		writer.insert( 'foo', firstPosition );
+ *		writer.insert( 'bar', secondPosition );
+ *
+ */
+export default class Writer {
+	/**
+	 * @param {module:engine/model~Model} model
+	 * @param {module:engine/model/batch~Batch} batch
+	 */
+	constructor( model, batch ) {
+		/**
+		 * @type {module:engine/model~Model}
+		 */
+		this.model = model;
+
+		/**
+		 * @protected
+		 * @type {module:engine/model/batch~Batch}
+		 */
+		this._batch = batch;
+	}
+
+	/**
+	 * Creates a new {@link module:engine/model/text~Text text node}.
+	 *
+	 *		writer.createText( 'foo' );
+	 *		writer.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}.
+	 *
+	 *		writer.createElement( 'paragraph' );
+	 *		writer.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 = writer.createElement( 'paragraph' );
+	 *		writer.insert( paragraph, position );
+	 *
+	 * Instead of using position you can use parent and offset:
+	 *
+	 * 		const text = writer.createText( 'foo' );
+	 *		writer.insert( text, paragraph, 5 );
+	 *
+	 * You can also use `end` instead of the offset to insert at the end:
+	 *
+	 * 		const text = writer.createText( 'foo' );
+	 *		writer.insert( text, paragraph, 'end' );
+	 *
+	 * Or insert before or after another element:
+	 *
+	 * 		const paragraph = writer.createElement( 'paragraph' );
+	 *		writer.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.model.document.version );
+
+		this._batch.addDelta( delta );
+		delta.addOperation( insert );
+		this.model.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:
+	 *
+	 *		writer.insertText( 'foo', position );
+	 *		writer.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:
+	 *
+	 * 		writer.insertText( 'foo', paragraph, 5 ); // inserts in paragraph, at offset 5
+	 *		writer.insertText( 'foo', paragraph, 'end' ); // inserts at the end of the paragraph
+	 *		writer.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:
+	 *
+	 *		writer.insertElement( 'paragraph', position );
+	 *		writer.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:
+	 *
+	 * 		writer.insertElement( 'paragraph', paragraph, 5 ); // inserts in paragraph, at offset 5
+	 *		writer.insertElement( 'paragraph', blockquote, 'end' ); // insets at the end of the blockquote
+	 *		writer.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 = writer.createElement( 'paragraph' );
+	 *		writer.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:
+	 *
+	 *		writer.appendText( 'foo', paragraph );
+	 *		writer.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:
+	 *
+	 *		writer.appendElement( 'paragraph', root );
+	 *		writer.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}.
+	 *
+	 *		writer.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.
+	 *
+	 *		writer.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:
+	 *
+	 * 		writer.move( sourceRange, paragraph, 5 ); // moves all items in the range to the paragraph at offset 5
+	 *		writer.move( sourceRange, blockquote, 'end' ); // moves all items in the range at the end of the blockquote
+	 *		writer.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 writer-move-invalid-range
+			 */
+			throw new CKEditorError( 'writer-move-invalid-range: Invalid range to move.' );
+		}
+
+		if ( !range.isFlat ) {
+			/**
+			 * Range to move is not flat.
+			 *
+			 * @error writer-move-range-not-flat
+			 */
+			throw new CKEditorError( 'writer-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 writer-move-different-document
+			 */
+			throw new CKEditorError( 'writer-move-different-document: Range is going to be moved between different documents.' );
+		}
+
+		const delta = new MoveDelta();
+		this._batch.addDelta( delta );
+
+		const operation = new MoveOperation( range.start, range.end.offset - range.start.offset, position, this.model.document.version );
+		delta.addOperation( operation );
+		this.model.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._batch.addDelta( delta );
+			let operation;
+
+			if ( position.root.document ) {
+				const graveyard = this.model.document.graveyard;
+				const gyPosition = new Position( graveyard, [ 0 ] );
+
+				operation = new RemoveOperation( position, howMany, gyPosition, this.model.document.version );
+			} else {
+				operation = new DetachOperation( position, howMany, this.model.document.version );
+			}
+
+			delta.addOperation( operation );
+			this.model.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 `writer-merge-no-element-before` or
+	 * `writer-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._batch.addDelta( delta );
+
+		const nodeBefore = position.nodeBefore;
+		const nodeAfter = position.nodeAfter;
+
+		if ( !( nodeBefore instanceof Element ) ) {
+			/**
+			 * Node before merge position must be an element.
+			 *
+			 * @error writer-merge-no-element-before
+			 */
+			throw new CKEditorError( 'writer-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 writer-merge-no-element-after
+			 */
+			throw new CKEditorError( 'writer-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.model.document.version
+		);
+
+		move.isSticky = true;
+		delta.addOperation( move );
+		this.model.applyOperation( move );
+
+		const graveyard = this.model.document.graveyard;
+		const gyPosition = new Position( graveyard, [ 0 ] );
+
+		const remove = new RemoveOperation( position, 1, gyPosition, this.model.document.version );
+		delta.addOperation( remove );
+		this.model.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 writer-rename-not-element-instance
+			 */
+			throw new CKEditorError(
+				'writer-rename-not-element-instance: Trying to rename an object which is not an instance of Element.'
+			);
+		}
+
+		const delta = new RenameDelta();
+		this._batch.addDelta( delta );
+
+		const renameOperation = new RenameOperation( Position.createBefore( element ), element.name, newName, this.model.document.version );
+		delta.addOperation( renameOperation );
+		this.model.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 `writer-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._batch.addDelta( delta );
+
+		const splitElement = position.parent;
+
+		if ( !splitElement.parent ) {
+			/**
+			 * Element with no parent can not be split.
+			 *
+			 * @error writer-split-element-no-parent
+			 */
+			throw new CKEditorError( 'writer-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.model.document.version
+		);
+
+		delta.addOperation( insert );
+		this.model.applyOperation( insert );
+
+		const move = new MoveOperation(
+			position,
+			splitElement.maxOffset - position.offset,
+			Position.createFromParentAndOffset( copy, 0 ),
+			this.model.document.version
+		);
+		move.isSticky = true;
+
+		delta.addOperation( move );
+		this.model.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 writer-wrap-range-not-flat
+			 */
+			throw new CKEditorError( 'writer-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 writer-wrap-element-not-empty
+			 */
+			throw new CKEditorError( 'writer-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 writer-wrap-element-attached
+			 */
+			throw new CKEditorError( 'writer-wrap-element-attached: Element to wrap with is already attached to tree model.' );
+		}
+
+		const delta = new WrapDelta();
+		this._batch.addDelta( delta );
+
+		const insert = new InsertOperation( range.end, element, this.model.document.version );
+		delta.addOperation( insert );
+		this.model.applyOperation( insert );
+
+		const targetPosition = Position.createFromParentAndOffset( element, 0 );
+		const move = new MoveOperation(
+			range.start,
+			range.end.offset - range.start.offset,
+			targetPosition,
+			this.model.document.version
+		);
+		delta.addOperation( move );
+		this.model.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 writer-unwrap-element-no-parent
+			 */
+			throw new CKEditorError( 'writer-unwrap-element-no-parent: Trying to unwrap an element which has no parent.' );
+		}
+
+		const delta = new UnwrapDelta();
+		this._batch.addDelta( delta );
+
+		const sourcePosition = Position.createFromParentAndOffset( element, 0 );
+
+		const move = new MoveOperation(
+			sourcePosition,
+			element.maxOffset,
+			Position.createBefore( element ),
+			this.model.document.version
+		);
+
+		move.isSticky = true;
+		delta.addOperation( move );
+		this.model.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.model.document.graveyard;
+		const gyPosition = new Position( graveyard, [ 0 ] );
+
+		const remove = new RemoveOperation( Position.createBefore( element ), 1, gyPosition, this.model.document.version );
+		delta.addOperation( remove );
+		this.model.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.model.markers.get( name );
+
+		if ( !newRange && !currentMarker ) {
+			/**
+			 * Range parameter is required when adding a new marker.
+			 *
+			 * @error writer-setMarker-no-range
+			 */
+			throw new CKEditorError( 'writer-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.model.markers.has( name ) ) {
+			/**
+			 * Trying to remove marker which does not exist.
+			 *
+			 * @error writer-removeMarker-no-marker
+			 */
+			throw new CKEditorError( 'writer-removeMarker-no-marker: Trying to remove marker which does not exist.' );
+		}
+
+		const oldRange = this.model.markers.get( name ).getRange();
+
+		addMarkerOperation( this, name, oldRange, null );
+	}
+}
+
+// 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/writer~Writer} writer
+// @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( writer, key, value, range ) {
+	const delta = new AttributeDelta();
+	const model = writer.model;
+	const doc = model.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 ) {
+			writer._batch.addDelta( delta );
+		}
+
+		const range = new Range( lastSplitPosition, position );
+		const operation = new AttributeOperation( range, key, valueBefore, value, doc.version );
+
+		delta.addOperation( operation );
+		model.applyOperation( operation );
+	}
+}
+
+// Sets given attribute to the given node. When attribute value is null then attribute will be removed.
+//
+// @private
+// @param {module:engine/model/writer~Writer} writer
+// @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( writer, key, value, item ) {
+	const model = writer.model;
+	const doc = model.document;
+	const previousValue = item.getAttribute( key );
+	let range, operation;
+
+	if ( previousValue != value ) {
+		const delta = item.root === item ? new RootAttributeDelta() : new AttributeDelta();
+		writer._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 );
+		model.applyOperation( operation );
+	}
+}
+
+// Creates and adds marker operation to {@link module:engine/model/delta/delta~Delta delta}.
+//
+// @private
+// @param {module:engine/model/writer~Writer} writer
+// @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( writer, name, oldRange, newRange ) {
+	const model = writer.model;
+	const doc = model.document;
+	const delta = new MarkerDelta();
+
+	const operation = new MarkerOperation( name, oldRange, newRange, model.markers, doc.version );
+
+	writer._batch.addDelta( delta );
+	delta.addOperation( operation );
+	model.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;
+	}
+
+	return false;
+}

+ 10 - 1874
packages/ckeditor5-engine/tests/model/batch.js

@@ -5,33 +5,18 @@
 
 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 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';
-
-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() );
+		it( 'should default be "default"', () => {
+			const batch = new Batch();
 
 			expect( batch.type ).to.equal( 'default' );
 		} );
 
 		it( 'should be set to the value set in constructor', () => {
-			const batch = new Batch( new Document(), 'ignore' );
+			const batch = new Batch( 'ignore' );
 
 			expect( batch.type ).to.equal( 'ignore' );
 		} );
@@ -39,7 +24,7 @@ describe( 'Batch', () => {
 
 	describe( 'baseVersion', () => {
 		it( 'should return base version of first delta from the batch', () => {
-			const batch = new Batch( new Document() );
+			const batch = new Batch();
 			const delta = new Delta();
 			const operation = new Operation( 2 );
 			delta.addOperation( operation );
@@ -49,7 +34,7 @@ describe( 'Batch', () => {
 		} );
 
 		it( 'should return null if there are no deltas in batch', () => {
-			const batch = new Batch( new Document() );
+			const batch = new Batch();
 
 			expect( batch.baseVersion ).to.be.null;
 		} );
@@ -57,7 +42,7 @@ describe( 'Batch', () => {
 
 	describe( 'addDelta()', () => {
 		it( 'should add delta to the batch', () => {
-			const batch = new Batch( new Document() );
+			const batch = new Batch();
 			const deltaA = new Delta();
 			const deltaB = new Delta();
 			batch.addDelta( deltaA );
@@ -71,14 +56,13 @@ describe( 'Batch', () => {
 
 	describe( 'getOperations()', () => {
 		it( 'should return collection of operations from all deltas', () => {
-			const doc = new Document();
-			const batch = new Batch( doc );
+			const batch = new Batch();
 			const deltaA = new Delta();
 			const deltaB = new Delta();
 			const ops = [
-				new Operation( doc.version ),
-				new Operation( doc.version + 1 ),
-				new Operation( doc.version + 2 )
+				new Operation( 0 ),
+				new Operation( 1 ),
+				new Operation( 2 )
 			];
 
 			batch.addDelta( deltaA );
@@ -91,1852 +75,4 @@ describe( 'Batch', () => {
 			expect( batch.getOperations() ).to.have.property( 'next' );
 		} );
 	} );
-
-	describe( 'createText()', () => {
-		let doc, batch;
-
-		beforeEach( () => {
-			doc = new Document();
-			batch = doc.batch();
-		} );
-
-		it( 'should create text node', () => {
-			const text = batch.createText( 'foo' );
-
-			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;
-		} );
-	} );
 } );

+ 1799 - 0
packages/ckeditor5-engine/tests/model/writer.js

@@ -0,0 +1,1799 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Model from '../../src/model/model';
+import Writer from '../../src/model/writer';
+import Batch from '../../src/model/batch';
+import InsertDelta from '../../src/model/delta/insertdelta';
+import WeakInsertDelta from '../../src/model/delta/weakinsertdelta';
+
+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';
+
+import count from '@ckeditor/ckeditor5-utils/src/count';
+import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
+
+import { getNodesAndText } from '../../tests/model/_utils/utils';
+
+describe( 'Writer', () => {
+	let writer, model, batch, doc;
+
+	beforeEach( () => {
+		model = new Model();
+		batch = new Batch();
+		writer = new Writer( model, batch );
+		doc = model.document;
+	} );
+
+	describe( 'constructor()', () => {
+		it( 'should have model instance', () => {
+			expect( writer.model ).to.instanceof( Model );
+		} );
+	} );
+
+	describe( 'createText()', () => {
+		it( 'should create text node', () => {
+			const text = writer.createText( 'foo' );
+
+			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 = writer.createText( 'foo', { foo: 'bar', biz: 'baz' } );
+
+			expect( Array.from( text.getAttributes() ) ).to.deep.equal( [ [ 'foo', 'bar' ], [ 'biz', 'baz' ] ] );
+		} );
+	} );
+
+	describe( 'createElement()', () => {
+		it( 'should create element', () => {
+			const element = writer.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 = writer.createText( 'foo', { foo: 'bar', biz: 'baz' } );
+
+			expect( Array.from( element.getAttributes() ) ).to.deep.equal( [ [ 'foo', 'bar' ], [ 'biz', 'baz' ] ] );
+		} );
+	} );
+
+	describe( 'createDocumentFragment()', () => {
+		it( 'should create element', () => {
+			const element = writer.createDocumentFragment();
+
+			expect( element ).to.instanceof( DocumentFragment );
+		} );
+	} );
+
+	describe( 'insert()', () => {
+		it( 'should insert node at given position', () => {
+			const parent = writer.createDocumentFragment();
+			const child = writer.createElement( 'child' );
+			const textChild = writer.createText( 'textChild' );
+
+			writer.insert( child, new Position( parent, [ 0 ] ) );
+			writer.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 = writer.createDocumentFragment();
+			const child1 = writer.createElement( 'child' );
+			const child2 = writer.createElement( 'child' );
+
+			writer.insert( child1, parent );
+			writer.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 = writer.createDocumentFragment();
+			const child1 = writer.createElement( 'child' );
+			const child2 = writer.createElement( 'child' );
+
+			writer.insert( child1, parent );
+			writer.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 = writer.createDocumentFragment();
+			const child1 = writer.createElement( 'child' );
+			const child2 = writer.createElement( 'child' );
+			const child3 = writer.createElement( 'child' );
+
+			writer.insert( child3, parent );
+			writer.insert( child1, parent );
+			writer.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 = writer.createDocumentFragment();
+			const child1 = writer.createElement( 'child' );
+			const child2 = writer.createElement( 'child' );
+			const child3 = writer.createElement( 'child' );
+
+			writer.insert( child3, parent );
+			writer.insert( child1, parent );
+			writer.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 = writer.createDocumentFragment();
+			const child1 = writer.createElement( 'child' );
+			const child2 = writer.createElement( 'child' );
+			const child3 = writer.createElement( 'child' );
+
+			writer.insert( child3, parent );
+			writer.insert( child1, parent );
+			writer.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 = writer.createDocumentFragment();
+			const element = writer.createElement( 'child' );
+
+			const spy = sinon.spy( model, 'applyOperation' );
+
+			writer.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 = writer.createDocumentFragment();
+			const text = writer.createText( 'child' );
+
+			const spy = sinon.spy( model, 'applyOperation' );
+
+			writer.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 = writer.createElement( 'parent' );
+			const parent2 = writer.createElement( 'parent' );
+			const node = writer.createText( 'foo' );
+
+			writer.insert( node, parent1 );
+			writer.insert( parent1, rootA );
+			writer.insert( parent2, rootA );
+
+			const spy = sinon.spy( model, 'applyOperation' );
+
+			writer.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 = writer.createText( 'foo' );
+
+			writer.insert( node, rootA );
+
+			const spy = sinon.spy( model, 'applyOperation' );
+
+			writer.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 = writer.createDocumentFragment();
+			const parent1 = writer.createElement( 'parent' );
+			const parent2 = writer.createElement( 'parent' );
+			const node = writer.createText( 'foo' );
+
+			writer.insert( node, parent1 );
+			writer.insert( parent1, docFragA );
+			writer.insert( parent2, docFragA );
+
+			const spy = sinon.spy( model, 'applyOperation' );
+
+			writer.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 = writer.createDocumentFragment();
+			const node = writer.createText( 'foo' );
+
+			writer.insert( node, root );
+
+			const spy = sinon.spy( model, 'applyOperation' );
+
+			writer.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 = writer.createDocumentFragment();
+			const docFragB = writer.createDocumentFragment();
+			const node = writer.createText( 'foo' );
+
+			writer.insert( node, docFragA );
+
+			const spy = sinon.spy( model, 'applyOperation' );
+
+			writer.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 = writer.createDocumentFragment();
+
+			writer.appendText( 'abcd', root );
+			writer.appendElement( 'p', docFrag );
+			writer.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 );
+
+			writer.insert( docFrag, new Position( root, [ 2 ] ) );
+
+			expect( Array.from( model.markers ).length ).to.equal( 1 );
+
+			const range = model.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 = writer.createDocumentFragment();
+
+			writer.appendText( 'abcd', root );
+			writer.appendElement( 'p', docFrag );
+			writer.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 );
+
+			model.on( 'change', spy );
+
+			writer.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()', () => {
+		it( 'should create and insert text node with attributes at given position', () => {
+			const parent = writer.createDocumentFragment();
+
+			writer.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 = writer.createDocumentFragment();
+
+			writer.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 = writer.createDocumentFragment();
+
+			writer.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 = writer.createDocumentFragment();
+
+			writer.insert( writer.createElement( 'child' ), parent );
+
+			writer.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 = writer.createDocumentFragment();
+
+			writer.insert( writer.createElement( 'child' ), parent );
+
+			writer.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 = writer.createDocumentFragment();
+
+			writer.insert( writer.createElement( 'child' ), parent );
+			writer.insert( writer.createElement( 'child' ), parent );
+
+			writer.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 = writer.createDocumentFragment();
+			const child1 = writer.createElement( 'child' );
+			const child2 = writer.createElement( 'child' );
+
+			writer.insert( child1, parent );
+			writer.insert( child2, parent, 'end' );
+
+			writer.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 = writer.createDocumentFragment();
+			const child1 = writer.createElement( 'child' );
+			const child2 = writer.createElement( 'child' );
+
+			writer.insert( child1, parent );
+			writer.insert( child2, parent, 'end' );
+
+			writer.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 = writer.createDocumentFragment();
+			const spy = sinon.spy( model, 'applyOperation' );
+
+			writer.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()', () => {
+		it( 'should create and insert element with attributes at given position', () => {
+			const parent = writer.createDocumentFragment();
+
+			writer.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 = writer.createDocumentFragment();
+
+			writer.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 = writer.createDocumentFragment();
+
+			writer.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 = writer.createDocumentFragment();
+
+			writer.insert( writer.createElement( 'child' ), parent );
+
+			writer.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 = writer.createDocumentFragment();
+
+			writer.insert( writer.createElement( 'child' ), parent );
+
+			writer.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 = writer.createDocumentFragment();
+
+			writer.insert( writer.createElement( 'child1' ), parent );
+			writer.insert( writer.createElement( 'child2' ), parent, 'end' );
+
+			writer.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 = writer.createDocumentFragment();
+			const child1 = writer.createElement( 'child1' );
+			const child2 = writer.createElement( 'child2' );
+
+			writer.insert( child1, parent );
+			writer.insert( child2, parent, 'end' );
+
+			writer.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 = writer.createDocumentFragment();
+			const child1 = writer.createElement( 'child1' );
+			const child2 = writer.createElement( 'child2' );
+
+			writer.insert( child1, parent );
+			writer.insert( child2, parent, 'end' );
+
+			writer.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 = writer.createDocumentFragment();
+			const spy = sinon.spy( model, 'applyOperation' );
+
+			writer.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()', () => {
+		it( 'should insert element at the end of the parent', () => {
+			const parent = writer.createDocumentFragment();
+			const childText = writer.createText( 'foo' );
+			const childElement = writer.createElement( 'foo' );
+
+			writer.append( childText, parent );
+			writer.append( childElement, parent );
+
+			expect( Array.from( parent ) ).to.deep.equal( [ childText, childElement ] );
+		} );
+
+		it( 'should create proper delta', () => {
+			const parent = writer.createDocumentFragment();
+			const text = writer.createText( 'foo' );
+
+			const spy = sinon.spy( model, 'applyOperation' );
+
+			writer.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 = writer.createElement( 'parent' );
+			const parent2 = writer.createElement( 'parent' );
+			const node = writer.createText( 'foo' );
+
+			writer.insert( node, parent1 );
+			writer.insert( parent1, rootA );
+			writer.insert( parent2, rootA );
+
+			const spy = sinon.spy( model, 'applyOperation' );
+
+			writer.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 = writer.createText( 'foo' );
+
+			writer.insert( node, rootA );
+
+			const spy = sinon.spy( model, 'applyOperation' );
+
+			writer.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 = writer.createDocumentFragment();
+			const parent1 = writer.createElement( 'parent' );
+			const parent2 = writer.createElement( 'parent' );
+			const node = writer.createText( 'foo' );
+
+			writer.insert( node, parent1 );
+			writer.insert( parent1, docFragA );
+			writer.insert( parent2, docFragA );
+
+			const spy = sinon.spy( model, 'applyOperation' );
+
+			writer.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 = writer.createDocumentFragment();
+			const node = writer.createText( 'foo' );
+
+			writer.insert( node, root );
+
+			const spy = sinon.spy( model, 'applyOperation' );
+
+			writer.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 = writer.createDocumentFragment();
+			const docFragB = writer.createDocumentFragment();
+			const node = writer.createText( 'foo' );
+
+			writer.insert( node, docFragA );
+
+			const spy = sinon.spy( model, 'applyOperation' );
+
+			writer.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()', () => {
+		it( 'should create and insert text node with attributes at the end of the parent', () => {
+			const parent = writer.createDocumentFragment();
+
+			writer.appendText( 'foo', { bar: 'biz' }, parent );
+			writer.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 = writer.createDocumentFragment();
+
+			writer.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 = writer.createDocumentFragment();
+
+			writer.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 = writer.createDocumentFragment();
+			const spy = sinon.spy( model, 'applyOperation' );
+
+			writer.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()', () => {
+		it( 'should create and insert element with attributes at the end of the parent', () => {
+			const parent = writer.createDocumentFragment();
+
+			writer.appendElement( 'foo', { bar: 'biz' }, parent );
+			writer.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 = writer.createDocumentFragment();
+
+			writer.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 = writer.createDocumentFragment();
+
+			writer.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 = writer.createDocumentFragment();
+			const spy = sinon.spy( model, 'applyOperation' );
+
+			writer.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 root, spy;
+
+		beforeEach( () => {
+			root = doc.createRoot();
+		} );
+
+		describe( 'change attribute on node', () => {
+			let node, text;
+
+			beforeEach( () => {
+				node = writer.createElement( 'p', { a: 1 } );
+				text = writer.createText( 'c', { a: 1 } );
+
+				writer.append( node, root );
+				writer.append( text, root );
+
+				spy = sinon.spy( model, 'applyOperation' );
+			} );
+
+			describe( 'setAttribute', () => {
+				it( 'should create the attribute on element', () => {
+					writer.setAttribute( 'b', 2, node );
+					expect( spy.callCount ).to.equal( 1 );
+					expect( node.getAttribute( 'b' ) ).to.equal( 2 );
+				} );
+
+				it( 'should change the attribute of element', () => {
+					writer.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', () => {
+					writer.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', () => {
+					writer.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', () => {
+					writer.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', () => {
+					writer.removeAttribute( 'a', node );
+					expect( spy.callCount ).to.equal( 1 );
+					expect( node.getAttribute( 'a' ) ).to.be.undefined;
+				} );
+
+				it( 'should remove the attribute from character', () => {
+					writer.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', () => {
+					writer.removeAttribute( 'b', node );
+					expect( spy.callCount ).to.equal( 0 );
+				} );
+			} );
+		} );
+
+		describe( 'change attribute on range', () => {
+			beforeEach( () => {
+				const element = writer.createElement( 'e', { a: 2 } );
+
+				writer.appendText( 'xxx', { a: 1 }, root );
+				writer.appendText( 'xxx', root );
+				writer.appendText( 'xxx', { a: 1 }, root );
+				writer.appendText( 'xxx', { a: 2 }, root );
+				writer.appendText( 'xxx', root );
+				writer.appendText( 'xxx', { a: 1 }, root );
+				writer.appendText( 'xxx', element );
+				writer.append( element, root );
+				writer.appendText( 'xxx', root );
+
+				spy = sinon.spy( model, '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 writer._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', () => {
+					writer.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', () => {
+					writer.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', () => {
+					writer.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', () => {
+					writer.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', () => {
+					writer.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', () => {
+					writer.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 ] )
+					);
+
+					writer.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 ] )
+					);
+
+					writer.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 ] )
+					);
+
+					writer.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', () => {
+					writer.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', () => {
+					writer.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', () => {
+					writer.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', () => {
+					writer.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', () => {
+					writer.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', () => {
+					writer.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', () => {
+					writer.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', () => {
+					writer.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 ] )
+					);
+
+					writer.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', () => {
+					writer.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', () => {
+					writer.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', () => {
+					writer.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 = writer.createElement( 'p', { a: 3 } );
+				spy = sinon.spy( model, 'applyOperation' );
+			} );
+
+			describe( 'setAttribute', () => {
+				it( 'should create the attribute on root', () => {
+					writer.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', () => {
+					writer.setAttribute( 'b', 2, p );
+					expect( spy.callCount ).to.equal( 1 );
+					expect( p.getAttribute( 'b' ) ).to.equal( 2 );
+				} );
+
+				it( 'should change the attribute of root', () => {
+					writer.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', () => {
+					writer.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', () => {
+					writer.setAttribute( 'a', 1, root );
+					expect( spy.callCount ).to.equal( 1 );
+					writer.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', () => {
+					writer.setAttribute( 'a', 1, p );
+					expect( spy.callCount ).to.equal( 1 );
+					writer.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', () => {
+					writer.setAttribute( 'a', 1, root );
+					writer.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', () => {
+					writer.removeAttribute( 'b', root );
+					expect( spy.callCount ).to.equal( 0 );
+				} );
+			} );
+
+			describe( 'clearAttributes', () => {
+				it( 'should clear attributes from range', () => {
+					writer.appendText( 'xxx', { a: 1, b: 2, c: 3 }, root );
+					writer.appendText( 'xxx', root );
+					writer.appendText( 'xxx', { a: 1 }, root );
+					writer.appendText( 'xxx', { b: 2 }, root );
+					writer.appendText( 'xxx', root );
+					writer.appendElement( 'e', { a: 1 }, root );
+					writer.appendText( 'xxx', root );
+
+					const range = Range.createIn( root );
+
+					writer.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 = writer.createElement( 'x', { a: 1, b: 2, c: 3 }, root );
+
+					expect( Array.from( element.getAttributeKeys() ).length ).to.equal( 3 );
+
+					writer.clearAttributes( element );
+
+					expect( Array.from( element.getAttributeKeys() ).length ).to.equal( 0 );
+				} );
+
+				it( 'should clear attributes on root element', () => {
+					writer.setAttributes( { a: 1, b: 2, c: 3 }, root );
+
+					expect( Array.from( root.getAttributeKeys() ).length ).to.equal( 3 );
+
+					writer.clearAttributes( root );
+
+					expect( Array.from( root.getAttributeKeys() ).length ).to.equal( 0 );
+				} );
+
+				it( 'should do nothing if there are no attributes', () => {
+					const element = writer.createElement( 'x' );
+
+					expect( Array.from( element.getAttributeKeys() ).length ).to.equal( 0 );
+
+					writer.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 ] );
+
+			writer.setAttribute( 'a', 1, nodeA );
+
+			expect( writer._batch.deltas.length ).to.equal( 0 );
+
+			writer.removeAttribute( 'x', Range.createIn( root ) );
+
+			expect( writer._batch.deltas.length ).to.equal( 0 );
+		} );
+	} );
+
+	describe( 'setAttributes()', () => {
+		let frag, item;
+
+		beforeEach( () => {
+			frag = writer.createDocumentFragment();
+			item = writer.createText( 'xxx', { b: 2, c: 3 } );
+
+			writer.appendText( 'xxx', { a: 1 }, frag );
+			writer.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( writer, 'setAttribute' );
+
+			writer.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( writer, 'setAttribute' );
+
+			writer.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( writer, 'setAttribute' );
+
+			writer.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( writer, 'setAttribute' );
+
+			writer.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 root, p1, p2;
+
+		beforeEach( () => {
+			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', () => {
+			writer.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( () => {
+				writer.merge( new Position( root, [ 2 ] ) );
+			} ).to.throw( CKEditorError, /^writer-merge-no-element-after/ );
+		} );
+
+		it( 'should throw if there is no element before', () => {
+			expect( () => {
+				writer.merge( new Position( root, [ 0, 2 ] ) );
+			} ).to.throw( CKEditorError, /^writer-merge-no-element-before/ );
+		} );
+	} );
+
+	describe( 'move()', () => {
+		let root, range, div, p;
+
+		beforeEach( () => {
+			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 ] ) );
+		} );
+
+		it( 'should move flat range of nodes', () => {
+			writer.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( () => {
+				writer.move( root.getChild( 0 ), new Position( root, [ 1, 3 ] ) );
+			} ).to.throw( CKEditorError, /^writer-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( () => {
+				writer.move( notFlatRange, new Position( root, [ 1, 3 ] ) );
+			} ).to.throw( CKEditorError, /^writer-move-range-not-flat/ );
+		} );
+
+		it( 'should throw if range is going to be moved to the other document', () => {
+			const docFrag = writer.createDocumentFragment();
+
+			expect( () => {
+				writer.move( range, docFrag );
+			} ).to.throw( CKEditorError, /^writer-move-different-document/ );
+		} );
+	} );
+
+	describe( 'remove()', () => {
+		let div, p, range;
+
+		beforeEach( () => {
+			div = writer.createElement( 'div' );
+			writer.appendText( 'foobar', div );
+
+			p = writer.createElement( 'p' );
+			writer.appendText( 'abcxyz', p );
+
+			writer.insertElement( 'p', div );
+			writer.appendElement( 'p', div );
+
+			writer.insertText( 'gggg', new Position( div, [ 0, 0 ] ) );
+			writer.insertText( 'hhhh', new Position( div, [ 7, 0 ] ) );
+		} );
+
+		describe( 'remove from document', () => {
+			let root;
+
+			beforeEach( () => {
+				root = doc.createRoot();
+				writer.append( div, root );
+				writer.append( p, root );
+
+				// Reset batch inside a writer.
+				batch = new Batch();
+				writer = new Writer( model, 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', () => {
+				writer.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', () => {
+				writer.remove( p.getChild( 0 ) );
+
+				expect( getNodesAndText( Range.createOn( p ) ) ).to.equal( 'PP' );
+			} );
+
+			it( 'should remove any range of nodes', () => {
+				writer.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', () => {
+				writer.remove( range );
+
+				expect( writer._batch.deltas.length ).to.equal( 2 );
+				expect( writer._batch.deltas[ 0 ].operations.length ).to.equal( 1 );
+				expect( writer._batch.deltas[ 1 ].operations.length ).to.equal( 1 );
+			} );
+
+			it( 'should use RemoveOperation', () => {
+				writer.remove( div );
+
+				expect( writer._batch.deltas[ 0 ].operations[ 0 ].type ).to.equal( 'remove' );
+			} );
+		} );
+
+		describe( 'remove from document fragment', () => {
+			let frag;
+
+			beforeEach( () => {
+				frag = writer.createDocumentFragment();
+				writer.append( div, frag );
+				writer.append( p, frag );
+
+				// Reset batch in writer.
+				batch = new Batch();
+				writer = new Writer( model, 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', () => {
+				writer.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', () => {
+				writer.remove( p.getChild( 0 ) );
+
+				expect( getNodesAndText( Range.createOn( p ) ) ).to.equal( 'PP' );
+			} );
+
+			it( 'should remove any range of nodes', () => {
+				writer.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', () => {
+				writer.remove( range );
+
+				expect( writer._batch.deltas.length ).to.equal( 2 );
+				expect( writer._batch.deltas[ 0 ].operations.length ).to.equal( 1 );
+				expect( writer._batch.deltas[ 1 ].operations.length ).to.equal( 1 );
+			} );
+
+			it( 'should use DetachOperation', () => {
+				writer.remove( div );
+
+				expect( writer._batch.deltas[ 0 ].operations[ 0 ].type ).to.equal( 'detach' );
+			} );
+		} );
+	} );
+
+	describe( 'rename()', () => {
+		let root;
+
+		beforeEach( () => {
+			root = doc.createRoot();
+
+			const p = new Element( 'p', null, new Text( 'abc' ) );
+			root.appendChildren( p );
+
+			writer.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( () => {
+				writer.rename( new Text( 'abc' ), 'h' );
+			} ).to.throw( CKEditorError, /^writer-rename-not-element-instance/ );
+		} );
+	} );
+
+	describe( 'split()', () => {
+		let root, p;
+
+		beforeEach( () => {
+			root = doc.createRoot();
+
+			p = new Element( 'p', { key: 'value' }, new Text( 'foobar' ) );
+
+			root.insertChildren( 0, p );
+		} );
+
+		it( 'should split foobar to foo and bar', () => {
+			writer.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', () => {
+			writer.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( () => {
+				writer.split( new Position( root, [ 0 ] ) );
+			} ).to.throw( CKEditorError, /^writer-split-element-no-parent/ );
+		} );
+
+		it( 'should throw if we try to split an element with no parent', () => {
+			expect( () => {
+				const element = writer.createElement( 'p' );
+
+				writer.split( new Position( element, [ 0 ] ) );
+			} ).to.throw( CKEditorError, /^writer-split-element-no-parent/ );
+		} );
+
+		it( 'should throw if we try to split a document fragment', () => {
+			expect( () => {
+				const documentFragment = writer.createDocumentFragment();
+
+				writer.split( new Position( documentFragment, [ 0 ] ) );
+			} ).to.throw( CKEditorError, /^writer-split-element-no-parent/ );
+		} );
+	} );
+
+	describe( 'wrap()', () => {
+		let root, range;
+
+		beforeEach( () => {
+			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' );
+			writer.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', () => {
+			writer.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( () => {
+				writer.wrap( notFlatRange, 'p' );
+			} ).to.throw( CKEditorError, /^writer-wrap-range-not-flat/ );
+		} );
+
+		it( 'should throw if element to wrap with has children #1', () => {
+			const p = new Element( 'p', [], new Text( 'a' ) );
+
+			expect( () => {
+				writer.wrap( range, p );
+			} ).to.throw( CKEditorError, /^writer-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( () => {
+				writer.wrap( range, p );
+			} ).to.throw( CKEditorError, /^writer-wrap-element-attached/ );
+		} );
+	} );
+
+	describe( 'unwrap()', () => {
+		let root, p;
+
+		beforeEach( () => {
+			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', () => {
+			writer.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( () => {
+				writer.unwrap( element );
+			} ).to.throw( CKEditorError, /^writer-unwrap-element-no-parent/ );
+		} );
+	} );
+
+	describe( 'setMarker()', () => {
+		let root, range;
+
+		beforeEach( () => {
+			root = doc.createRoot();
+			root.appendChildren( new Text( 'foo' ) );
+			range = Range.createIn( root );
+		} );
+
+		it( 'should add marker to the document marker collection', () => {
+			writer.setMarker( 'name', range );
+
+			expect( model.markers.get( 'name' ).getRange().isEqual( range ) ).to.be.true;
+		} );
+
+		it( 'should update marker in the document marker collection', () => {
+			writer.setMarker( 'name', range );
+
+			const range2 = Range.createFromParentsAndOffsets( root, 0, root, 0 );
+			writer.setMarker( 'name', range2 );
+
+			expect( model.markers.get( 'name' ).getRange().isEqual( range2 ) ).to.be.true;
+		} );
+
+		it( 'should accept marker instance', () => {
+			writer.setMarker( 'name', range );
+			const marker = model.markers.get( 'name' );
+			const range2 = Range.createFromParentsAndOffsets( root, 0, root, 0 );
+
+			writer.setMarker( marker, range2 );
+			const op = writer._batch.deltas[ 0 ].operations[ 0 ];
+
+			expect( model.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 = model.markers.set( 'name', range );
+
+			sinon.spy( model, 'fire' );
+
+			model.on( 'change', ( evt, type, changes ) => {
+				if ( type == 'marker' ) {
+					expect( changes.type ).to.equal( 'set' );
+					expect( changes.name ).to.equal( 'name' );
+				}
+			} );
+
+			writer.setMarker( marker );
+			const op = writer._batch.deltas[ 0 ].operations[ 0 ];
+
+			expect( model.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( () => {
+				writer.setMarker( 'name' );
+			} ).to.throw( CKEditorError, /^writer-setMarker-no-range/ );
+		} );
+	} );
+
+	describe( 'removeMarker()', () => {
+		let root, range;
+
+		beforeEach( () => {
+			root = doc.createRoot();
+			root.appendChildren( new Text( 'foo' ) );
+			range = Range.createIn( root );
+		} );
+
+		it( 'should remove marker from the document marker collection', () => {
+			writer.setMarker( 'name', range );
+			writer.removeMarker( 'name' );
+
+			expect( model.markers.get( 'name' ) ).to.be.null;
+		} );
+
+		it( 'should throw when trying to remove non existing marker', () => {
+			expect( () => {
+				writer.removeMarker( 'name' );
+			} ).to.throw( CKEditorError, /^writer-removeMarker-no-marker/ );
+		} );
+
+		it( 'should accept marker instance', () => {
+			writer.setMarker( 'name', range );
+			const marker = model.markers.get( 'name' );
+
+			writer.removeMarker( marker );
+
+			expect( model.markers.get( 'name' ) ).to.be.null;
+		} );
+	} );
+} );