Explorar el Código

Merge pull request #584 from ckeditor/t/583

T/583
Szymon Cofalik hace 9 años
padre
commit
781c35fd64
Se han modificado 44 ficheros con 1957 adiciones y 453 borrados
  1. 193 19
      packages/ckeditor5-engine/src/conversion/mapper.js
  2. 2 2
      packages/ckeditor5-engine/src/conversion/model-selection-to-view-converters.js
  3. 65 23
      packages/ckeditor5-engine/src/conversion/model-to-view-converters.js
  4. 64 23
      packages/ckeditor5-engine/src/conversion/modelconversiondispatcher.js
  5. 4 5
      packages/ckeditor5-engine/src/datacontroller.js
  6. 2 0
      packages/ckeditor5-engine/src/dataprocessor/xmldataprocessor.js
  7. 2 1
      packages/ckeditor5-engine/src/editingcontroller.js
  8. 2 2
      packages/ckeditor5-engine/src/model/delta/attributedelta.js
  9. 2 16
      packages/ckeditor5-engine/src/model/delta/renamedelta.js
  10. 1 1
      packages/ckeditor5-engine/src/model/documentfragment.js
  11. 0 1
      packages/ckeditor5-engine/src/model/element.js
  12. 7 5
      packages/ckeditor5-engine/src/model/liverange.js
  13. 7 0
      packages/ckeditor5-engine/src/model/liveselection.js
  14. 2 2
      packages/ckeditor5-engine/src/model/operation/attributeoperation.js
  15. 2 2
      packages/ckeditor5-engine/src/model/operation/operation.js
  16. 125 0
      packages/ckeditor5-engine/src/model/operation/renameoperation.js
  17. 53 0
      packages/ckeditor5-engine/src/model/operation/transform.js
  18. 9 0
      packages/ckeditor5-engine/src/model/position.js
  19. 25 3
      packages/ckeditor5-engine/src/model/schema.js
  20. 2 2
      packages/ckeditor5-engine/src/view/containerelement.js
  21. 1 1
      packages/ckeditor5-engine/src/view/position.js
  22. 4 4
      packages/ckeditor5-engine/src/view/treewalker.js
  23. 160 54
      packages/ckeditor5-engine/src/view/writer.js
  24. 165 27
      packages/ckeditor5-engine/tests/conversion/mapper.js
  25. 2 2
      packages/ckeditor5-engine/tests/conversion/model-selection-to-view-converters.js
  26. 120 1
      packages/ckeditor5-engine/tests/conversion/model-to-view-converters.js
  27. 32 3
      packages/ckeditor5-engine/tests/conversion/modelconversiondispatcher.js
  28. 1 0
      packages/ckeditor5-engine/tests/dataprocessor/xmldataprocessor.js
  29. 12 1
      packages/ckeditor5-engine/tests/editingcontroller.js
  30. 2 2
      packages/ckeditor5-engine/tests/model/delta/transform/attributedelta.js
  31. 2 2
      packages/ckeditor5-engine/tests/model/delta/transform/weakinsertdelta.js
  32. 5 4
      packages/ckeditor5-engine/tests/model/liveselection.js
  33. 2 2
      packages/ckeditor5-engine/tests/model/operation/attributeoperation.js
  34. 122 0
      packages/ckeditor5-engine/tests/model/operation/renameoperation.js
  35. 319 0
      packages/ckeditor5-engine/tests/model/operation/transform.js
  36. 6 0
      packages/ckeditor5-engine/tests/model/position.js
  37. 29 0
      packages/ckeditor5-engine/tests/model/schema/schema.js
  38. 1 1
      packages/ckeditor5-engine/tests/view/position.js
  39. 0 121
      packages/ckeditor5-engine/tests/view/writer/breakAt.js
  40. 232 0
      packages/ckeditor5-engine/tests/view/writer/breakAttributes.js
  41. 80 0
      packages/ckeditor5-engine/tests/view/writer/breakContainer.js
  42. 0 117
      packages/ckeditor5-engine/tests/view/writer/breakrange.js
  43. 4 4
      packages/ckeditor5-engine/tests/view/writer/mergeAttributes.js
  44. 87 0
      packages/ckeditor5-engine/tests/view/writer/mergeContainers.js

+ 193 - 19
packages/ckeditor5-engine/src/conversion/mapper.js

@@ -4,17 +4,29 @@
  */
 
 import ModelPosition from '../model/position.js';
-import ViewPosition from '../view/position.js';
 import ModelRange from '../model/range.js';
+
+import ViewPosition from '../view/position.js';
 import ViewRange from '../view/range.js';
 import ViewText from '../view/text.js';
 
+import EmitterMixin from '../../utils/emittermixin.js';
+import mix from '../../utils/mix.js';
+
 /**
  * Maps elements and positions between {@link engine.view.Document view} and {@link engine.model model}.
  *
  * Mapper use bound elements to find corresponding elements and positions, so, to get proper results,
  * all model elements should be {@link engine.conversion.Mapper#bindElements bound}.
  *
+ * To map complex model to/from view relations, you may provide custom callbacks for
+ * {@link engine.conversion.Mapper#event:modelToViewPosition modelToViewPosition event} and
+ * {@link engine.conversion.Mapper#event:viewToModelPosition viewToModelPosition event} that are fired whenever
+ * a position mapping request occurs. Those events are fired by {@link engine.conversion.Mapper#toViewPosition toViewPosition}
+ * and {@link engine.conversion.Mapper#toModelPosition toModelPosition} methods. `Mapper` adds it's own default callbacks
+ * with `'lowest'` priority. To override default `Mapper` mapping, add custom callback with higher priority and
+ * stop the event.
+ *
  * @memberOf engine.conversion
  */
 export default class Mapper {
@@ -37,6 +49,37 @@ export default class Mapper {
 		 * @member {WeakMap} engine.conversion.Mapper#_viewToModelMapping
 		 */
 		this._viewToModelMapping = new WeakMap();
+
+		/**
+		 * A map containing callbacks between view element names and functions evaluating length of view elements
+		 * in model.
+		 *
+		 * @private
+		 * @member {Map} engine.conversion.Mapper#_viewToModelLengthCallbacks
+		 */
+		this._viewToModelLengthCallbacks = new Map();
+
+		// Add default callback for model to view position mapping.
+		this.on( 'modelToViewPosition', ( evt, data ) => {
+			let viewContainer = this._modelToViewMapping.get( data.modelPosition.parent );
+
+			data.viewPosition = this._findPositionIn( viewContainer, data.modelPosition.offset );
+		}, 'lowest' );
+
+		// Add default callback for view to model position mapping.
+		this.on( 'viewToModelPosition', ( evt, data ) => {
+			let viewBlock = data.viewPosition.parent;
+			let modelParent = this._viewToModelMapping.get( viewBlock );
+
+			while ( !modelParent ) {
+				viewBlock = viewBlock.parent;
+				modelParent = this._viewToModelMapping.get( viewBlock );
+			}
+
+			let modelOffset = this._toModelOffset( data.viewPosition.parent, data.viewPosition.offset, viewBlock );
+
+			data.modelPosition = ModelPosition.createFromParentAndOffset( modelParent, modelOffset );
+		}, 'lowest' );
 	}
 
 	/**
@@ -53,6 +96,40 @@ export default class Mapper {
 		this._viewToModelMapping.set( viewElement, modelElement );
 	}
 
+	/**
+	 * Unbinds given {@link engine.view.Element view element} from the map.
+	 *
+	 * @param {engine.view.Element} viewElement View element to unbind.
+	 */
+	unbindViewElement( viewElement ) {
+		const modelElement = this.toModelElement( viewElement );
+
+		this._unbindElements( modelElement, viewElement );
+	}
+
+	/**
+	 * Unbinds given {@link engine.model.Element model element} from the map.
+	 *
+	 * @param {engine.model.Element} modelElement Model element to unbind.
+	 */
+	unbindModelElement( modelElement ) {
+		const viewElement = this.toViewElement( modelElement );
+
+		this._unbindElements( modelElement, viewElement );
+	}
+
+	/**
+	 * Removes binding between given elements.
+	 *
+	 * @private
+	 * @param {engine.model.Element} modelElement Model element to unbind.
+	 * @param {engine.view.Element} viewElement View element to unbind.
+	 */
+	_unbindElements( modelElement, viewElement ) {
+		this._viewToModelMapping.delete( viewElement );
+		this._modelToViewMapping.delete( modelElement );
+	}
+
 	/**
 	 * Removes all model to view and view to model bindings.
 	 */
@@ -104,33 +181,70 @@ export default class Mapper {
 	/**
 	 * Gets the corresponding model position.
 	 *
+	 * @fires engine.conversion.Mapper#event:viewToModelPosition
 	 * @param {engine.view.Position} viewPosition View position.
 	 * @returns {engine.model.Position} Corresponding model position.
 	 */
 	toModelPosition( viewPosition ) {
-		let viewBlock = viewPosition.parent;
-		let modelParent =  this._viewToModelMapping.get( viewBlock );
-
-		while ( !modelParent ) {
-			viewBlock = viewBlock.parent;
-			modelParent = this._viewToModelMapping.get( viewBlock );
-		}
+		const data = {
+			viewPosition: viewPosition,
+			modelPosition: null
+		};
 
-		let modelOffset = this._toModelOffset( viewPosition.parent, viewPosition.offset, viewBlock );
+		this.fire( 'viewToModelPosition', data );
 
-		return ModelPosition.createFromParentAndOffset( modelParent, modelOffset );
+		return data.modelPosition;
 	}
 
 	/**
 	 * Gets the corresponding view position.
 	 *
+	 * @fires engine.conversion.Mapper#event:modelToViewPosition
 	 * @param {engine.model.Position} modelPosition Model position.
 	 * @returns {engine.view.Position} Corresponding view position.
 	 */
 	toViewPosition( modelPosition ) {
-		let viewContainer = this._modelToViewMapping.get( modelPosition.parent );
+		const data = {
+			viewPosition: null,
+			modelPosition: modelPosition
+		};
 
-		return this._findPositionIn( viewContainer, modelPosition.offset );
+		this.fire( 'modelToViewPosition', data );
+
+		return data.viewPosition;
+	}
+
+	/**
+	 * Registers a callback that evaluates the length in the model of a view element with given name.
+	 *
+	 * The callback is fired with one argument, which is a view element instance. The callback is expected to return
+	 * a number representing the length of view element in model.
+	 *
+	 *		// List item in view may contain nested list, which have other list items. In model though,
+	 *		// the lists are represented by flat structure. Because of those differences, length of list view element
+	 *		// may be greater than one. In the callback it's checked how many nested list items are in evaluated list item.
+	 *
+	 *		function getViewListItemLength( element ) {
+	 *			let length = 1;
+	 *
+	 *			for ( let child of element.getChildren() ) {
+	 *				if ( child.name == 'ul' || child.name == 'ol' ) {
+	 *					for ( let item of child.getChildren() ) {
+	 *						length += getViewListItemLength( item );
+	 *					}
+	 *				}
+	 *			}
+	 *
+	 *			return length;
+	 *		}
+	 *
+	 *		mapper.registerViewToModelLength( 'li', getViewListItemLength );
+	 *
+	 * @param {String} viewElementName Name of view element for which callback is registered.
+	 * @param {Function} lengthCallback Function return a length of view element instance in model.
+	 */
+	registerViewToModelLength( viewElementName, lengthCallback ) {
+		this._viewToModelLengthCallbacks.set( viewElementName, lengthCallback );
 	}
 
 	/**
@@ -180,22 +294,27 @@ export default class Mapper {
 	/**
 	 * Gets the length of the view element in the model.
 	 *
+	 * The length is calculated as follows:
+	 * * length of a {@link engine.view.Text text node} is equal to the length of it's {@link engine.view.Text#data data},
+	 * * length of a mapped {@link engine.view.Element element} is equal to it's {@link engine.view.Element#modelLength modelLength},
+	 * * length of a not-mapped {@link engine.view.Element element} is equal to the length of it's children.
+	 *
 	 * Examples:
 	 *
-	 *		foo          -> 3 // Length of the text is the length of data.
-	 *		<b>foo</b>   -> 3 // Length of the element which has no corresponding model element is a length of its children.
-	 *		<p>foo</p>   -> 1 // Length of the element which has corresponding model element is always 1.
+	 *		foo                     -> 3 // Text length is equal to it's data length.
+	 *		<p>foo</p>              -> 1 // Length of an element which is mapped is equal to modelLength, 1 by default.
+	 *		<b>foo</b>              -> 3 // Length of an element which is not mapped is a length of its children.
+	 *		<div><p>x</p><p>y</p>   -> 2 // Assuming that <div> is not mapped and <p> are mapped.
 	 *
 	 * @private
 	 * @param {engine.view.Element} viewNode View node.
 	 * @returns {Number} Length of the node in the tree model.
 	 */
 	_getModelLength( viewNode ) {
-		// If we need mapping to be more flexible this method may fire event, so every feature may define the relation
-		// between length in the model to the length in the view, for example if one element in the model creates two
-		// elements in the view. Now I can not find any example where such feature would be useful.
 		if ( this._viewToModelMapping.has( viewNode ) ) {
-			return 1;
+			const callback = this._viewToModelLengthCallbacks.get( viewNode.name );
+
+			return callback ? callback( viewNode ) : 1;
 		} else if ( viewNode instanceof ViewText ) {
 			return viewNode.data.length;
 		} else {
@@ -298,3 +417,58 @@ export default class Mapper {
 		return viewPosition;
 	}
 }
+
+mix( Mapper, EmitterMixin );
+
+/**
+ * Fired for each model-to-view position mapping request. The purpose of this event is to enable custom model-to-view position
+ * mapping. Callbacks added to this event take {@link engine.model.Position model position} and are expected to calculate
+ * {@link engine.view.Position view position}. Calculated view position should be added as `viewPosition` value in
+ * `data` object that is passed as one of parameters to the event callback.
+ *
+ * 		// Assume that "captionedImage" model element is converted to <img> and following <span> elements in view,
+ * 		// and the model element is bound to <img> element. Force mapping model positions inside "captionedImage" to that <span> element.
+ *		mapper.on( 'modelToViewPosition', ( evt, mapper, modelPosition, data ) => {
+ *			const positionParent = modelPosition.parent;
+ *
+ *			if ( positionParent.name == 'captionedImage' ) {
+ *				const viewImg = mapper.toViewElement( positionParent );
+ *				const viewCaption = viewImg.nextSibling; // The <span> element.
+ *
+ *				data.viewPosition = new ViewPosition( viewCaption, modelPosition.offset );
+ *				evt.stop();
+ *			}
+ *		} );
+ *
+ * **Note:** these callbacks are called **very often**. For efficiency reasons, it is advised to use them only when position
+ * mapping between given model and view elements is unsolvable using just elements mapping and default algorithm. Also,
+ * the condition that checks if special case scenario happened should be as simple as possible.
+ *
+ * @event engine.conversion.Mapper.modelToViewPosition
+ * @param {engine.model.Position} modelPosition Model position to be mapped.
+ * @param {Object} data Data pipeline object that can store and pass data between callbacks. The callback should add
+ * `viewPosition` value to that object with calculated {@link engine.view.Position view position}.
+ */
+
+/**
+ * Fired for each view-to-model position mapping request. See {@link engine.conversion.Mapper#event:modelToViewPosition}.
+ *
+ * 		// See example in `modelToViewPosition` event description.
+ * 		// This custom mapping will map positions from <span> element next to <img> to the "captionedImage" element.
+ *		mapper.on( 'viewToModelPosition', ( evt, mapper, viewPosition, data ) => {
+ *			const positionParent = viewPosition.parent;
+ *
+ *			if ( positionParent.hasClass( 'image-caption' ) ) {
+ *				const viewImg = positionParent.previousSibling;
+ *				const modelImg = mapper.toModelElement( viewImg );
+ *
+ *				data.modelPosition = new ModelPosition( modelImg, viewPosition.offset );
+ *				evt.stop();
+ *			}
+ *		} );
+ *
+ * @event engine.conversion.Mapper.viewToModelPosition
+ * @param {engine.view.Position} viewPosition View position to be mapped.
+ * @param {Object} data Data pipeline object that can store and pass data between callbacks. The callback should add
+ * `modelPosition` value to that object with calculated {@link engine.model.Position model position}.
+ */

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

@@ -84,7 +84,7 @@ export function convertCollapsedSelection() {
 
 		const modelPosition = selection.getFirstPosition();
 		const viewPosition = conversionApi.mapper.toViewPosition( modelPosition );
-		const brokenPosition = viewWriter.breakAt( viewPosition );
+		const brokenPosition = viewWriter.breakAttributes( viewPosition );
 
 		conversionApi.viewSelection.removeAllRanges();
 		conversionApi.viewSelection.addRange( new ViewRange( brokenPosition, brokenPosition ) );
@@ -202,7 +202,7 @@ export function clearAttributes() {
 			if ( range.isCollapsed ) {
 				// Position might be in the node removed by the view writer.
 				if ( range.end.parent.document ) {
-					viewWriter.mergeAt( range.start );
+					viewWriter.mergeAttributes( range.start );
 				}
 			}
 		}

+ 65 - 23
packages/ckeditor5-engine/src/conversion/model-to-view-converters.js

@@ -3,8 +3,9 @@
  * For licensing, see LICENSE.md.
  */
 
-import ModelTreeWalker from '../model/treewalker.js';
 import ModelRange from '../model/range.js';
+import ModelPosition from '../model/position.js';
+import ModelElement from '../model/element.js';
 
 import ViewElement from '../view/element.js';
 import ViewText from '../view/text.js';
@@ -283,26 +284,20 @@ export function unwrap( elementCreator ) {
  * @returns {Function} Move event converter.
  */
 export function move() {
-	return ( evt, data, conversionApi ) => {
-		const walker = new ModelTreeWalker( { boundaries: data.range, shallow: true } );
+	return ( evt, data, consumable, conversionApi ) => {
+		if ( consumable.consume( data.item, 'move' ) ) {
+			const sourceModelRange = ModelRange.createFromPositionAndShift( data.sourcePosition, data.item.offsetSize );
+			const sourceViewRange = conversionApi.mapper.toViewRange( sourceModelRange );
 
-		let length = 0;
+			const targetViewPosition = conversionApi.mapper.toViewPosition( data.targetPosition );
 
-		for ( let value of walker ) {
-			length += value.length;
+			viewWriter.move( sourceViewRange, targetViewPosition );
 		}
-
-		const sourceModelRange = ModelRange.createFromPositionAndShift( data.sourcePosition, length );
-
-		const sourceViewRange = conversionApi.mapper.toViewRange( sourceModelRange );
-		const targetViewPosition = conversionApi.mapper.toViewPosition( data.range.start );
-
-		viewWriter.move( sourceViewRange, targetViewPosition );
 	};
 }
 
 /**
- * Function factory, creates a default model-to-view converter for nodes remove changes.
+ * Function factory, creates a default model-to-view converter for node remove changes.
  *
  *		modelDispatcher.on( 'remove', remove() );
  *
@@ -311,19 +306,66 @@ export function move() {
  * @returns {Function} Remove event converter.
  */
 export function remove() {
-	return ( evt, data, conversionApi ) => {
-		const walker = new ModelTreeWalker( { boundaries: data.range, shallow: true } );
+	return ( evt, data, consumable, conversionApi ) => {
+		if ( consumable.consume( data.item, 'remove' ) ) {
+			const sourceModelRange = ModelRange.createFromPositionAndShift( data.sourcePosition, data.item.offsetSize );
+			const sourceViewRange = conversionApi.mapper.toViewRange( sourceModelRange );
 
-		let length = 0;
+			viewWriter.remove( sourceViewRange );
 
-		for ( let value of walker ) {
-			length += value.length;
+			conversionApi.mapper.unbindModelElement( data.item );
 		}
+	};
+}
 
-		const sourceModelRange = ModelRange.createFromPositionAndShift( data.sourcePosition, length );
-		const sourceViewRange = conversionApi.mapper.toViewRange( sourceModelRange );
-
-		viewWriter.remove( sourceViewRange );
+/**
+ * Function factory, creates default model-to-view converter for elements which name has changed.
+ *
+ *		modelDispatcher.on( 'rename', rename() );
+ *
+ * This converter re-uses converters added for `insert`, `move` and `remove` change types.
+ *
+ * @external engine.conversion.modelToView
+ * @function engine.conversion.modelToView.rename
+ * @fires engine.conversion.ModelConversionDispatcher#event:insert
+ * @fires engine.conversion.ModelConversionDispatcher#event:move
+ * @fires engine.conversion.ModelConversionDispatcher#event:remove
+ * @returns {Function}
+ */
+export function rename() {
+	return ( evt, data, consumable, conversionApi ) => {
+		if ( consumable.test( data.element, 'rename' ) ) {
+			// Create fake model element that will represent "old version" of renamed element.
+			const fakeElement = new ModelElement( data.oldName, data.element.getAttributes() );
+			// Append the fake element to model document to enable making range on it.
+			data.element.parent.insertChildren( data.element.index, fakeElement );
+
+			// Check what was bound to renamed element.
+			const oldViewElement = conversionApi.mapper.toViewElement( data.element );
+			// Unbind renamed element.
+			conversionApi.mapper.unbindModelElement( data.element );
+			// Bind view element to the fake element.
+			conversionApi.mapper.bindElements( fakeElement, oldViewElement );
+
+			// The range that includes only the renamed element. Will be used to insert an empty element in the view.
+			const insertRange = ModelRange.createFromParentsAndOffsets( data.element.parent, data.element.startOffset, data.element, 0 );
+
+			// Move source position and range of moved nodes. Will be used to move nodes from original view element to renamed one.
+			const moveSourcePosition = ModelPosition.createAt( fakeElement, 0 );
+			const moveRange = ModelRange.createIn( data.element );
+
+			// Remove range containing the fake element. Will be used to remove original view element from the view.
+			const removeRange = ModelRange.createOn( fakeElement );
+
+			// Start the conversion. Use already defined converters by firing insertion, move and remove conversion
+			// on correct ranges / positions.
+			conversionApi.dispatcher.convertInsertion( insertRange );
+			conversionApi.dispatcher.convertMove( moveSourcePosition, moveRange );
+			conversionApi.dispatcher.convertRemove( removeRange.start, removeRange );
+
+			// Cleanup.
+			fakeElement.remove();
+		}
 	};
 }
 

+ 64 - 23
packages/ckeditor5-engine/src/conversion/modelconversiondispatcher.js

@@ -5,6 +5,7 @@
 
 import Consumable from './modelconsumable.js';
 import Range from '../model/range.js';
+import Position from '../model/position.js';
 import TextProxy from '../model/textproxy.js';
 import EmitterMixin from '../../utils/emittermixin.js';
 import mix from '../../utils/mix.js';
@@ -113,7 +114,7 @@ export default class ModelConversionDispatcher {
 		 *
 		 * @member {Object} engine.conversion.ModelConversionDispatcher#conversionApi
 		 */
-		this.conversionApi = extend( {}, conversionApi );
+		this.conversionApi = extend( { dispatcher: this }, conversionApi );
 	}
 
 	/**
@@ -125,6 +126,7 @@ export default class ModelConversionDispatcher {
 	 * @fires engine.conversion.ModelConversionDispatcher#insert
 	 * @fires engine.conversion.ModelConversionDispatcher#move
 	 * @fires engine.conversion.ModelConversionDispatcher#remove
+	 * @fires engine.conversion.ModelConversionDispatcher#rename
 	 * @fires engine.conversion.ModelConversionDispatcher#addAttribute
 	 * @fires engine.conversion.ModelConversionDispatcher#removeAttribute
 	 * @fires engine.conversion.ModelConversionDispatcher#changeAttribute
@@ -146,6 +148,8 @@ export default class ModelConversionDispatcher {
 			this.convertRemove( data.sourcePosition, data.range );
 		} else if ( type == 'addAttribute' || type == 'removeAttribute' || type == 'changeAttribute' ) {
 			this.convertAttribute( type, data.range, data.key, data.oldValue, data.newValue );
+		} else if ( type == 'rename' ) {
+			this.convertRename( data.element, data.oldName );
 		}
 	}
 
@@ -195,12 +199,20 @@ export default class ModelConversionDispatcher {
 	 * @param {engine.model.Range} range Moved range (after move).
 	 */
 	convertMove( sourcePosition, range ) {
-		const data = {
-			sourcePosition: sourcePosition,
-			range: range
-		};
+		const consumable = this._createConsumableForRange( range, 'move' );
+
+		// Fire a separate event for each top-most node and text fragment contained in the range.
+		const items = Array.from( range.getItems( { shallow: true } ) ).reverse();
 
-		this.fire( 'move', data, this.conversionApi );
+		for ( let item of items ) {
+			const data = {
+				sourcePosition: sourcePosition.getShiftedBy( item.startOffset - range.start.offset ),
+				targetPosition: Position.createAt( range.start ),
+				item: item
+			};
+
+			this._testAndFire( 'move', data, consumable );
+		}
 	}
 
 	/**
@@ -211,12 +223,19 @@ export default class ModelConversionDispatcher {
 	 * @param {engine.model.Range} range Removed range (after remove, in {@link engine.model.Document#graveyard graveyard root}).
 	 */
 	convertRemove( sourcePosition, range ) {
-		const data = {
-			sourcePosition: sourcePosition,
-			range: range
-		};
+		const consumable = this._createConsumableForRange( range, 'remove' );
 
-		this.fire( 'remove', data, this.conversionApi );
+		// Fire a separate event for each top-most node and text fragment contained in the range.
+		const items = Array.from( range.getItems( { shallow: true } ) ).reverse();
+
+		for ( let item of items ) {
+			const data = {
+				sourcePosition: sourcePosition.getShiftedBy( item.startOffset - range.start.offset ),
+				item: item
+			};
+
+			this._testAndFire( 'remove', data, consumable );
+		}
 	}
 
 	/**
@@ -233,7 +252,7 @@ export default class ModelConversionDispatcher {
 	 */
 	convertAttribute( type, range, key, oldValue, newValue ) {
 		// Create a list with attributes to consume.
-		const consumable = this._createAttributeConsumable( type, range, key );
+		const consumable = this._createConsumableForRange( range, type + ':' + key );
 
 		// Create a separate attribute event for each node in the range.
 		for ( let value of range ) {
@@ -251,6 +270,21 @@ export default class ModelConversionDispatcher {
 		}
 	}
 
+	/**
+	 * Fires rename event with data based on passed values.
+	 *
+	 * @fires engine.conversion.ModelConversionDispatcher#event:rename
+	 * @param {engine.view.Element} element Renamed element.
+	 * @param {String} oldName Name of the renamed element before it was renamed.
+	 */
+	convertRename( element, oldName ) {
+		const consumable = new Consumable();
+		consumable.add( element, 'rename' );
+
+		const data = { element, oldName };
+		this.fire( 'rename:' + element.name + ':' + oldName, data, consumable, this.conversionApi );
+	}
+
 	/**
 	 * Fires events for given {@link engine.model.Selection selection} to start selection conversion.
 	 *
@@ -302,22 +336,18 @@ export default class ModelConversionDispatcher {
 	}
 
 	/**
-	 * Creates {@link engine.conversion.ModelConsumable} with values to consume from given range, assuming that
-	 * given range has just had it's attributes changed.
+	 * Creates {@link engine.conversion.ModelConsumable} with values of given `type` for each item from given `range`.
 	 *
 	 * @private
-	 * @param {String} type Change type. Possible values: `addAttribute`, `removeAttribute`, `changeAttribute`.
-	 * @param {engine.conversion.Range} range Changed range.
-	 * @param {String} key Attribute key.
+	 * @param {engine.conversion.Range} range Affected range.
+	 * @param {String} type Consumable type.
 	 * @returns {engine.conversion.ModelConsumable} Values to consume.
 	 */
-	_createAttributeConsumable( type, range, key ) {
+	_createConsumableForRange( range, type ) {
 		const consumable = new Consumable();
 
-		for ( let value of range ) {
-			const item = value.item;
-
-			consumable.add( item, type + ':' + key );
+		for ( let item of range.getItems() ) {
+			consumable.add( item, type );
 		}
 
 		return consumable;
@@ -360,7 +390,7 @@ export default class ModelConversionDispatcher {
 			return;
 		}
 
-		if ( type === 'insert' ) {
+		if ( type === 'insert' || type === 'remove' || type == 'move' ) {
 			if ( data.item instanceof TextProxy ) {
 				// Example: insert:$text.
 				this.fire( type + ':$text', data, consumable, this.conversionApi );
@@ -415,6 +445,17 @@ export default class ModelConversionDispatcher {
 	 * @param {Object} conversionApi Conversion interface to be used by callback, passed in `ModelConversionDispatcher` constructor.
 	 */
 
+	/**
+	 * Fired for renamed element.
+	 *
+	 * @event engine.conversion.ModelConversionDispatcher.rename
+	 * @param {Object} data Additional information about the change.
+	 * @param {engine.model.Element} data.element Renamed element.
+	 * @param {String} data.oldName Old name of the renamed element.
+	 * @param {engine.conversion.ModelConsumable} consumable Values to consume.
+	 * @param {Object} conversionApi Conversion interface to be used by callback, passed in `ModelConversionDispatcher` constructor.
+	 */
+
 	/**
 	 * Fired when attribute has been added on a node.
 	 *

+ 4 - 5
packages/ckeditor5-engine/src/datacontroller.js

@@ -57,10 +57,9 @@ export default class DataController {
 		 * cleared directly after data are converted. However, the mapper is defined as class property, because
 		 * it needs to be passed to the `ModelConversionDispatcher` as a conversion API.
 		 *
-		 * @private
 		 * @member {engine.conversion.Mapper} engine.DataController#_mapper
 		 */
-		this._mapper = new Mapper();
+		this.mapper = new Mapper();
 
 		/**
 		 * Model to view conversion dispatcher used by the {@link engine.DataController#get get method}.
@@ -76,7 +75,7 @@ export default class DataController {
 		 * @member {engine.conversion.ModelConversionDispatcher} engine.DataController#modelToView
 		 */
 		this.modelToView = new ModelConversionDispatcher( {
-			mapper: this._mapper
+			mapper: this.mapper
 		} );
 		this.modelToView.on( 'insert:$text', insertText(), 'lowest' );
 
@@ -121,11 +120,11 @@ export default class DataController {
 
 		// model -> view
 		const viewDocumentFragment = new ViewDocumentFragment();
-		this._mapper.bindElements( modelRoot, viewDocumentFragment );
+		this.mapper.bindElements( modelRoot, viewDocumentFragment );
 
 		this.modelToView.convertInsertion( modelRange );
 
-		this._mapper.clearBindings();
+		this.mapper.clearBindings();
 
 		// view -> data
 		return this.processor.toData( viewDocumentFragment );

+ 2 - 0
packages/ckeditor5-engine/src/dataprocessor/xmldataprocessor.js

@@ -3,6 +3,8 @@
  * For licensing, see LICENSE.md.
  */
 
+/* globals DOMParser, document */
+
 import BasicHtmlWriter from './basichtmlwriter.js';
 import DomConverter from '../view/domconverter.js';
 import { NBSP_FILLER } from '../view/filler.js';

+ 2 - 1
packages/ckeditor5-engine/src/editingcontroller.js

@@ -6,7 +6,7 @@
 import ViewDocument from './view/document.js';
 import Mapper from './conversion/mapper.js';
 import ModelConversionDispatcher from './conversion/modelconversiondispatcher.js';
-import { insertText, remove, move } from './conversion/model-to-view-converters.js';
+import { insertText, remove, move, rename } from './conversion/model-to-view-converters.js';
 import { convertSelectionChange } from './conversion/view-selection-to-model-converters.js';
 import {
 	convertRangeSelection,
@@ -101,6 +101,7 @@ export default class EditingController {
 		this.modelToView.on( 'insert:$text', insertText(), 'lowest' );
 		this.modelToView.on( 'remove', remove(), 'low' );
 		this.modelToView.on( 'move', move(), 'low' );
+		this.modelToView.on( 'rename', rename(), 'low' );
 
 		// Attach default selection converters.
 		this.modelToView.on( 'selection', clearAttributes(), 'low' );

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

@@ -163,7 +163,7 @@ function changeItem( batch, doc, key, value, item ) {
 				range = new Range( Position.createBefore( item ), Position.createAfter( item ) );
 			}
 
-			operation = new AttributeOperation( range, key, previousValue || null, value || null, doc.version );
+			operation = new AttributeOperation( range, key, previousValue, value, doc.version );
 		}
 
 		delta.addOperation( operation );
@@ -214,7 +214,7 @@ function changeRange( batch, doc, attributeKey, attributeValue, range ) {
 
 	function addOperation() {
 		let range = new Range( lastSplitPosition, position );
-		const operation = new AttributeOperation( range, attributeKey, attributeValueBefore || null, attributeValue || null, doc.version );
+		const operation = new AttributeOperation( range, attributeKey, attributeValueBefore, attributeValue, doc.version );
 
 		delta.addOperation( operation );
 		doc.applyOperation( operation );

+ 2 - 16
packages/ckeditor5-engine/src/model/delta/renamedelta.js

@@ -6,9 +6,7 @@
 import Delta from './delta.js';
 import DeltaFactory from './deltafactory.js';
 import { register } from '../batch.js';
-import InsertOperation from '../operation/insertoperation.js';
-import RemoveOperation from '../operation/removeoperation.js';
-import MoveOperation from '../operation/moveoperation.js';
+import RenameOperation from '../operation/renameoperation.js';
 import Element from '../element.js';
 import Position from '../position.js';
 import CKEditorError from '../../../utils/ckeditorerror.js';
@@ -61,21 +59,9 @@ register( 'rename', function( newName, element ) {
 	const delta = new RenameDelta();
 	this.addDelta( delta );
 
-	const newElement = new Element( newName );
-
-	apply(
-		this, delta,
-		new InsertOperation( Position.createAfter( element ), newElement, this.document.version )
-	);
-
-	apply(
-		this, delta,
-		new MoveOperation( Position.createAt( element ), element.maxOffset, Position.createAt( newElement ), this.document.version )
-	);
-
 	apply(
 		this, delta,
-		new RemoveOperation( Position.createBefore( element ), 1, this.document.version )
+		new RenameOperation( Position.createBefore( element ), element.name, newName, this.document.version )
 	);
 
 	return this;

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

@@ -18,7 +18,7 @@ export default class DocumentFragment {
 	/**
 	 * Creates an empty `DocumentFragment`.
 	 *
-	 * @param {engine.model.Node|Iterable.<engine.model.Node>} children Nodes to be contained inside the `DocumentFragment`.
+	 * @param {engine.model.Node|Iterable.<engine.model.Node>} [children] Nodes to be contained inside the `DocumentFragment`.
 	 */
 	constructor( children ) {
 		/**

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

@@ -31,7 +31,6 @@ export default class Element extends Node {
 		/**
 		 * Element name.
 		 *
-		 * @readonly
 		 * @member {String} engine.model.Element#name
 		 */
 		this.name = name;

+ 7 - 5
packages/ckeditor5-engine/src/model/liverange.js

@@ -83,12 +83,16 @@ export default class LiveRange extends Range {
  */
 function bindWithDocument() {
 	/*jshint validthis: true */
+	// Operation types handled by LiveRange (these are operations that change model tree structure).
+	const supportedTypes = new Set( [ 'insert', 'move', 'remove', 'reinsert' ] );
 
 	this.listenTo(
 		this.root.document,
 		'change',
 		( event, type, changes ) => {
-			transform.call( this, type, changes.range, changes.sourcePosition );
+			if ( supportedTypes.has( type ) ) {
+				transform.call( this, type, changes.range, changes.sourcePosition );
+			}
 		}
 	);
 }
@@ -146,10 +150,8 @@ function transform( type, range, position ) {
 			break;
 	}
 
-	if ( updated ) {
-		this.start = updated.start;
-		this.end = updated.end;
-	}
+	this.start = updated.start;
+	this.end = updated.end;
 }
 
 mix( LiveRange, EmitterMixin );

+ 7 - 0
packages/ckeditor5-engine/src/model/liveselection.js

@@ -105,6 +105,13 @@ export default class LiveSelection extends Selection {
 		return super.getFirstRange() || this._getDefaultRange();
 	}
 
+	/**
+	 * @inheritDoc
+	 */
+	getLastRange() {
+		return super.getLastRange() || this._getDefaultRange();
+	}
+
 	/**
 	 * @inheritDoc
 	 */

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

@@ -60,7 +60,7 @@ export default class AttributeOperation extends Operation {
 		 * @readonly
 		 * @member {*} engine.model.operation.AttributeOperation#oldValue
 		 */
-		this.oldValue = oldValue;
+		this.oldValue = oldValue === undefined ? null : oldValue;
 
 		/**
 		 * New value of the attribute with given key or `null`, if operation should remove attribute.
@@ -68,7 +68,7 @@ export default class AttributeOperation extends Operation {
 		 * @readonly
 		 * @member {*} engine.model.operation.AttributeOperation#newValue
 		 */
-		this.newValue = newValue;
+		this.newValue = newValue === undefined ? null : newValue;
 	}
 
 	/**

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

@@ -67,8 +67,8 @@ export default class Operation {
 		 *
 		 * @protected
 		 * @method engine.model.operation.Operation#_execute
-		 * @returns {Object} Object with additional information about the applied changes. Always has `range`
-		 * property containing changed nodes. May have additional properties depending on the operation type.
+		 * @returns {Object} Object with additional information about the applied changes. It properties depends on the
+		 * operation type.
 		 */
 	}
 

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

@@ -0,0 +1,125 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Operation from './operation.js';
+import Element from '../element.js';
+import CKEditorError from '../../../utils/ckeditorerror.js';
+import Position from '../position.js';
+
+/**
+ * Operation to change element's name.
+ *
+ * Using this class you can change element's name.
+ *
+ * @memberOf engine.model.operation
+ * @extends engine.model.operation.Operation
+ */
+export default class RenameOperation extends Operation {
+	/**
+	 * Creates an operation that changes element's name.
+	 *
+	 * @param {engine.model.Position} position Position before an element to change.
+	 * @param {String} oldName Current name of the element.
+	 * @param {String} newName New name for the element.
+	 * @param {Number} baseVersion {@link engine.model.Document#version} on which the operation can be applied.
+	 */
+	constructor( position, oldName, newName, baseVersion ) {
+		super( baseVersion );
+
+		/**
+		 * Position before an element to change.
+		 *
+		 * @member {engine.model.Position} engine.model.operation.RenameOperation#position
+		 */
+		this.position = position;
+
+		/**
+		 * Current name of the element.
+		 *
+		 * @member {String} engine.model.operation.RenameOperation#oldName
+		 */
+		this.oldName = oldName;
+
+		/**
+		 * New name for the element.
+		 *
+		 * @member {String} engine.model.operation.RenameOperation#newName
+		 */
+		this.newName = newName;
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	get type() {
+		return 'rename';
+	}
+
+	/**
+	 * @inheritDoc
+	 * @returns {engine.model.operation.RenameOperation}
+	 */
+	clone() {
+		return new RenameOperation( Position.createFromPosition( this.position ), this.oldName, this.newName, this.baseVersion );
+	}
+
+	/**
+	 * @inheritDoc
+	 * @returns {engine.model.operation.RenameOperation}
+	 */
+	getReversed() {
+		return new RenameOperation( Position.createFromPosition( this.position ), this.newName, this.oldName, this.baseVersion + 1 );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	_execute() {
+		// Validation.
+		const element = this.position.nodeAfter;
+
+		if ( !( element instanceof Element ) ) {
+			/**
+			 * Given position is invalid or node after it is not instance of Element.
+			 *
+			 * @error rename-operation-wrong-position
+			 */
+			throw new CKEditorError(
+				'rename-operation-wrong-position: Given position is invalid or node after it is not an instance of Element.'
+			);
+		} else if ( element.name !== this.oldName ) {
+			/**
+			 * Element to change has different name than operation's old name.
+			 *
+			 * @error rename-operation-wrong-name
+			 */
+			throw new CKEditorError(
+				'rename-operation-wrong-name: Element to change has different name than operation\'s old name.'
+			);
+		}
+
+		element.name = this.newName;
+
+		return { element, oldName: this.oldName };
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	static get className() {
+		return 'engine.model.operation.RenameOperation';
+	}
+
+	/**
+	 * Creates `RenameOperation` object from deserialized object, i.e. from parsed JSON string.
+	 *
+	 * @param {Object} json Deserialized JSON object.
+	 * @param {engine.model.Document} document Document on which this operation will be applied.
+	 * @returns {engine.model.operation.AttributeOperation}
+	 */
+	static fromJSON( json, document ) {
+		return new RenameOperation( Position.fromJSON( json.position, document ), json.oldName, json.newName, json.baseVersion );
+	}
+}

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

@@ -6,6 +6,7 @@
 import InsertOperation from './insertoperation.js';
 import AttributeOperation from './attributeoperation.js';
 import RootAttributeOperation from './rootattributeoperation.js';
+import RenameOperation from './renameoperation.js';
 import MoveOperation from './moveoperation.js';
 import RemoveOperation from './removeoperation.js';
 import NoOperation from './nooperation.js';
@@ -72,6 +73,8 @@ const ot = {
 
 		RootAttributeOperation: doNotUpdate,
 
+		RenameOperation: doNotUpdate,
+
 		// Transforms InsertOperation `a` by MoveOperation `b`. Accepts a flag stating whether `a` is more important
 		// than `b` when it comes to resolving conflicts. Returns results as an array of operations.
 		MoveOperation( a, b, isStrong ) {
@@ -134,6 +137,8 @@ const ot = {
 
 		RootAttributeOperation: doNotUpdate,
 
+		RenameOperation: doNotUpdate,
+
 		// Transforms AttributeOperation `a` by MoveOperation `b`. Returns results as an array of operations.
 		MoveOperation( a, b ) {
 			// Convert MoveOperation properties into a range.
@@ -212,9 +217,51 @@ const ot = {
 			return [ a.clone() ];
 		},
 
+		RenameOperation: doNotUpdate,
+
 		MoveOperation: doNotUpdate
 	},
 
+	RenameOperation: {
+		// Transforms RenameOperation `a` by InsertOperation `b`. Returns results as an array of operations.
+		InsertOperation( a, b ) {
+			// Clone the operation, we don't want to alter the original operation.
+			const clone = a.clone();
+
+			// Transform this operation's position.
+			clone.position = clone.position._getTransformedByInsertion( b.position, b.nodes.maxOffset, true );
+
+			return [ clone ];
+		},
+
+		AttributeOperation: doNotUpdate,
+
+		RootAttributeOperation: doNotUpdate,
+
+		// Transforms RenameOperation `a` by RenameOperation `b`. Accepts a flag stating whether `a` is more important
+		// than `b` when it comes to resolving conflicts. Returns results as an array of operations.
+		RenameOperation( a, b, isStrong ) {
+			// Clone the operation, we don't want to alter the original operation.
+			const clone = a.clone();
+
+			if ( a.position.isEqual( b.position ) && !isStrong ) {
+				return [ new NoOperation( a.baseVersion ) ];
+			}
+
+			return [ clone ];
+		},
+
+		// Transforms RenameOperation `a` by MoveOperation `b`. Returns results as an array of operations.
+		MoveOperation( a, b ) {
+			const clone = a.clone();
+			const isSticky = clone.position.isEqual( b.sourcePosition );
+
+			clone.position = clone.position._getTransformedByMove( b.sourcePosition, b.targetPosition, b.howMany, true, isSticky );
+
+			return [ clone ];
+		}
+	},
+
 	MoveOperation: {
 		// Transforms MoveOperation `a` by InsertOperation `b`. Accepts a flag stating whether `a` is more important
 		// than `b` when it comes to resolving conflicts. Returns results as an array of operations.
@@ -241,6 +288,8 @@ const ot = {
 
 		RootAttributeOperation: doNotUpdate,
 
+		RenameOperation: doNotUpdate,
+
 		// Transforms MoveOperation `a` by MoveOperation `b`. Accepts a flag stating whether `a` is more important
 		// than `b` when it comes to resolving conflicts. Returns results as an array of operations.
 		MoveOperation( a, b, isStrong ) {
@@ -379,6 +428,8 @@ function transform( a, b, isStrong ) {
 		group = ot.AttributeOperation;
 	} else if ( a instanceof RootAttributeOperation ) {
 		group = ot.RootAttributeOperation;
+	} else if ( a instanceof RenameOperation ) {
+		group = ot.RenameOperation;
 	} else if ( a instanceof MoveOperation ) {
 		group = ot.MoveOperation;
 	} else {
@@ -392,6 +443,8 @@ function transform( a, b, isStrong ) {
 			algorithm = group.AttributeOperation;
 		} else if ( b instanceof RootAttributeOperation ) {
 			algorithm = group.RootAttributeOperation;
+		} else if ( b instanceof RenameOperation ) {
+			algorithm = group.RenameOperation;
 		} else if ( b instanceof MoveOperation ) {
 			algorithm = group.MoveOperation;
 		} else {

+ 9 - 0
packages/ckeditor5-engine/src/model/position.js

@@ -250,6 +250,15 @@ export default class Position {
 		return this.path.slice( 0, -1 );
 	}
 
+	/**
+	 * Returns ancestors array of this position, that is this position's parent and it's ancestors.
+	 *
+	 * @returns {Array.<engine.model.Item>} Array with ancestors.
+	 */
+	getAncestors() {
+		return this.parent.getAncestors( { includeNode: true, parentFirst: true } );
+	}
+
 	/**
 	 * Returns a new instance of `Position`, that has same {@link engine.model.Position#parent parent} but it's offset
 	 * is shifted by `shift` value (can be a negative value).

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

@@ -438,7 +438,7 @@ export default class Schema {
 			/**
 			 * Item with specified name already exists in schema.
 			 *
-			 * @error schema-item-exists
+			 * @error model-schema-item-exists
 			 */
 			throw new CKEditorError( 'model-schema-item-exists: Item with specified name already exists in schema.' );
 		}
@@ -447,7 +447,7 @@ export default class Schema {
 			/**
 			 * Item with specified name does not exist in schema.
 			 *
-			 * @error schema-no-item
+			 * @error model-schema-no-item
 			 */
 			throw new CKEditorError( 'model-schema-no-item: Item with specified name does not exist in schema.' );
 		}
@@ -464,6 +464,28 @@ export default class Schema {
 		this._extensionChains.set( itemName, chain );
 	}
 
+	/**
+	 * Checks whether item of given name is extending item of another given name.
+	 *
+	 * @param {String} childItemName Name of the child item.
+	 * @param {String} parentItemName Name of the parent item.
+	 * @returns {Boolean} `true` if child item extends parent item, `false` otherwise.
+	 */
+	itemExtends( childItemName, parentItemName ) {
+		if ( !this.hasItem( childItemName ) || !this.hasItem( parentItemName ) ) {
+			/**
+			 * Item with specified name does not exist in schema.
+			 *
+			 * @error model-schema-no-item
+			 */
+			throw new CKEditorError( 'model-schema-no-item: Item with specified name does not exist in schema.' );
+		}
+
+		const chain = this._extensionChains.get( childItemName );
+
+		return chain.some( itemName => itemName == parentItemName );
+	}
+
 	/**
 	 * Returns {@link engine.model.SchemaItem schema item} that was registered in the schema under given name.
 	 * If item has not been found, throws error.
@@ -477,7 +499,7 @@ export default class Schema {
 			/**
 			 * Item with specified name does not exist in schema.
 			 *
-			 * @error schema-no-item
+			 * @error model-schema-no-item
 			 */
 			throw new CKEditorError( 'model-schema-no-item: Item with specified name does not exist in schema.' );
 		}

+ 2 - 2
packages/ckeditor5-engine/src/view/containerelement.js

@@ -20,14 +20,14 @@ import Element from './element.js';
  * to put caret inside it, even if the container is empty.
  *
  * Secondly, {@link engine.view.writer view writer} uses this information.
- * Nodes {@link engine.view.writer.breakAt breaking} and {@link engine.view.writer.mergeAt merging}
+ * Nodes {@link engine.view.writer.breakAttributes breaking} and {@link engine.view.writer.mergeAttributes merging}
  * is performed only in a bounds of a container nodes.
  *
  * For instance if `<p>` is an container and `<b>` is attribute:
  *
  *		<p><b>fo^o</b></p>
  *
- * {@link engine.view.writer.breakAt breakAt} will create:
+ * {@link engine.view.writer.breakAttributes breakAttributes} will create:
  *
  *		<p><b>fo</b><b>o</b></p>
  *

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

@@ -141,7 +141,7 @@ export default class Position {
 	 * @returns {Array} Array with ancestors.
 	 */
 	getAncestors() {
-		return this.parent.getAncestors().concat( this.parent );
+		return this.parent.getAncestors( { includeNode: true, parentFirst: true } );
 	}
 
 	/**

+ 4 - 4
packages/ckeditor5-engine/src/view/treewalker.js

@@ -370,7 +370,7 @@ export default class TreeWalker {
 		if ( item instanceof TextProxy ) {
 			// Position is at the end of Text.
 			if ( item.offsetInText + item.data.length == item.textNode.data.length ) {
-				if ( this.direction == 'forward' ) {
+				if ( this.direction == 'forward' && !( this.boundaries && this.boundaries.end.isEqual( this.position ) ) ) {
 					nextPosition = Position.createAfter( item.textNode );
 					// When we change nextPosition of returned value we need also update walker current position.
 					this.position = nextPosition;
@@ -381,12 +381,12 @@ export default class TreeWalker {
 
 			// Position is at the begining ot the text.
 			if ( item.offsetInText === 0 ) {
-				if ( this.direction == 'forward' ) {
-					previousPosition = Position.createBefore( item.textNode );
-				} else {
+				if ( this.direction == 'backward' && !( this.boundaries && this.boundaries.start.isEqual( this.position ) ) ) {
 					nextPosition = Position.createBefore( item.textNode );
 					// When we change nextPosition of returned value we need also update walker current position.
 					this.position = nextPosition;
+				} else {
+					previousPosition = Position.createBefore( item.textNode );
 				}
 			}
 		}

+ 160 - 54
packages/ckeditor5-engine/src/view/writer.js

@@ -19,9 +19,10 @@ import isIterable from '../../utils/isiterable.js';
  */
 
 export default {
-	breakAt,
-	breakRange,
-	mergeAt,
+	breakAttributes,
+	breakContainer,
+	mergeAttributes,
+	mergeContainers,
 	insert,
 	remove,
 	move,
@@ -31,51 +32,108 @@ export default {
 };
 
 /**
- * Breaks attribute nodes at provided position. It breaks `attribute` nodes inside `container` node.
+ * Breaks attribute nodes at provided position or at boundaries of provided range. It breaks attribute elements inside
+ * up to a container element.
  *
  * In following examples `<p>` is a container, `<b>` and `<u>` are attribute nodes:
  *
  *		<p>foo<b><u>bar{}</u></b></p> -> <p>foo<b><u>bar</u></b>[]</p>
  *		<p>foo<b><u>{}bar</u></b></p> -> <p>foo{}<b><u>bar</u></b></p>
  *		<p>foo<b><u>b{}ar</u></b></p> -> <p>foo<b><u>b</u></b>[]<b><u>ar</u></b></p>
+ *		<p><b>fo{o</b><u>ba}r</u></p> -> <p><b>fo</b><b>o</b><u>ba</u><u>r</u></b></p>
  *
- * Note that {@link engine.view.DocumentFragment DocumentFragment} is treated like a container.
+ * **Note:** {@link engine.view.DocumentFragment DocumentFragment} is treated like a container.
+ *
+ * **Note:** Difference between {@link engine.view.writer.breakAttributes breakAttributes} and
+ * {@link engine.view.writer.breakContainer breakContainer} is that `breakAttributes` breaks all
+ * {@link engine.view.AttributeElement attribute elements} that are ancestors of given `position`, up to the first
+ * encountered {@link engine.view.ContainerElement container element}. `breakContainer` assumes that given `position`
+ * is directly in container element and breaks that container element.
+ *
+ * Throws {@link utils.CKEditorError CKEditorError} `view-writer-invalid-range-container` when {@link engine.view.Range#start start}
+ * and {@link engine.view.Range#end end} positions of a passed range are not placed inside same parent container.
  *
  * @see engine.view.AttributeElement
  * @see engine.view.ContainerElement
- * @function engine.view.writer.breakAt
- * @param {engine.view.Position} position Position where to break attributes.
- * @returns {engine.view.Position} New position after breaking the attributes.
+ * @see engine.view.writer.breakContainer
+ * @function engine.view.writer.breakAttributes
+ * @param {engine.view.Position|engine.view.Range} positionOrRange Position where to break attribute elements.
+ * @returns {engine.view.Position|engine.view.Range} New position or range, after breaking the attribute elements.
  */
-export function breakAt( position ) {
-	return _breakAt( position, false );
+export function breakAttributes( positionOrRange ) {
+	if ( positionOrRange instanceof Position ) {
+		return _breakAttributes( positionOrRange );
+	} else {
+		return _breakAttributesRange( positionOrRange );
+	}
 }
 
 /**
- * Uses {@link engine.view.writer.breakAt breakAt} method to break attributes on
- * {@link engine.view.Range#start start} and {@link engine.view.Range#end end} positions of
- * provided {@link engine.view.Range Range}.
+ * Breaks {@link engine.view.ContainerElement container view element} into two, at the given position. Position
+ * has to be directly inside container element and cannot be in root. Does not break if position is at the beginning
+ * or at the end of it's parent element.
  *
- * Throws {@link utils.CKEditorError CKEditorError} `view-writer-invalid-range-container` when
- * {@link engine.view.Range#start start} and {@link engine.view.Range#end end} positions are not placed inside
- * same parent container.
+ *		<p>foo^bar</p> -> <p>foo</p><p>bar</p>
+ *		<div><p>foo</p>^<p>bar</p></div> -> <div><p>foo</p></div><div><p>bar</p></div>
+ *		<p>^foobar</p> -> ^<p>foobar</p>
+ *		<p>foobar^</p> -> <p>foobar</p>^
  *
- * Note that {@link engine.view.DocumentFragment DocumentFragment} is treated like a container.
+ * **Note:** Difference between {@link engine.view.writer.breakAttributes breakAttributes} and
+ * {@link engine.view.writer.breakContainer breakContainer} is that `breakAttributes` breaks all
+ * {@link engine.view.AttributeElement attribute elements} that are ancestors of given `position`, up to the first
+ * encountered {@link engine.view.ContainerElement container element}. `breakContainer` assumes that given `position`
+ * is directly in container element and breaks that container element.
  *
- * @see engine.view.writer.breakAt
- * @function engine.view.writer.breakRange
- * @param {engine.view.Range} range Range which `start` and `end` positions will be used to break attributes.
- * @returns {engine.view.Range} New range with boundaries located at break positions.
+ * @see engine.view.AttributeElement
+ * @see engine.view.ContainerElement
+ * @see engine.view.writer.breakAttributes
+ * @function engine.view.writer.breakContainer
+ * @param {engine.view.Position} position Position where to break element.
+ * @returns {engine.view.Position} Position between broken elements. If element has not been broken, the returned position
+ * is placed either before it or after it.
  */
-export function breakRange( range ) {
-	return _breakRange( range );
+export function breakContainer( position ) {
+	const element = position.parent;
+
+	if ( !( element instanceof ContainerElement ) ) {
+		/**
+		 * Trying to break an element which is not a container element.
+		 *
+		 * @error view-writer-break-non-container-element
+		 */
+		throw new CKEditorError( 'view-writer-break-non-container-element: Trying to break an element which is not a container element.' );
+	}
+
+	if ( !element.parent ) {
+		/**
+		 * Trying to break root element.
+		 *
+		 * @error view-writer-break-root
+		 */
+		throw new CKEditorError( 'view-writer-break-root: Trying to break root element.' );
+	}
+
+	if ( position.isAtStart ) {
+		return Position.createBefore( element );
+	} else if ( !position.isAtEnd ) {
+		const newElement = element.clone( false );
+
+		insert( Position.createAfter( element ), newElement );
+
+		const sourceRange = new Range( position, Position.createAt( element, 'end' ) );
+		const targetPosition = new Position( newElement, 0 );
+
+		move( sourceRange, targetPosition );
+	}
+
+	return Position.createAfter( element );
 }
 
 /**
- * Merges attribute nodes. It also merges text nodes if needed.
- * Only {@link engine.view.AttributeElement#isSimilar similar} `attribute` nodes can be merged.
+ * Merges {@link engine.view.AttributeElement attribute elements}. It also merges text nodes if needed.
+ * Only {@link engine.view.AttributeElement#isSimilar similar} attribute elements can be merged.
  *
- * In following examples `<p>` is a container and `<b>` is an attribute node:
+ * In following examples `<p>` is a container and `<b>` is an attribute element:
  *
  *		<p>foo[]bar</p> -> <p>foo{}bar</p>
  *		<p><b>foo</b>[]<b>bar</b> -> <p><b>foo{}bar</b></b>
@@ -86,13 +144,19 @@ export function breakRange( range ) {
  *		<p><b>[]</b></p> -> <p>[]</p>
  *		<p><b>foo</b><i>[]</i><b>bar</b></p> -> <p><b>foo{}bar</b></p>
  *
+ * **Note:** Difference between {@link engine.view.writer.mergeAttributes mergeAttributes} and
+ * {@link engine.view.writer.mergeContainers mergeContainers} is that `mergeAttributes` merges two
+ * {@link engine.view.AttributeElement attribute elements} or {@link engine.view.Text text nodes}
+ * while `mergeContainer` merges two {@link engine.view.ContainerElement container elements}.
+ *
  * @see engine.view.AttributeElement
  * @see engine.view.ContainerElement
- * @function engine.view.writer.mergeAt
+ * @see engine.view.writer.mergeContainers
+ * @function engine.view.writer.mergeAttributes
  * @param {engine.view.Position} position Merge position.
  * @returns {engine.view.Position} Position after merge.
  */
-export function mergeAt( position ) {
+export function mergeAttributes( position ) {
 	const positionOffset = position.offset;
 	const positionParent = position.parent;
 
@@ -107,7 +171,7 @@ export function mergeAt( position ) {
 		const offset = positionParent.index;
 		positionParent.remove();
 
-		return mergeAt( new Position( parent, offset ) );
+		return mergeAttributes( new Position( parent, offset ) );
 	}
 
 	const nodeBefore = positionParent.getChild( positionOffset - 1 );
@@ -137,12 +201,54 @@ export function mergeAt( position ) {
 
 		// New position is located inside the first node, before new nodes.
 		// Call this method recursively to merge again if needed.
-		return mergeAt( new Position( nodeBefore, count ) );
+		return mergeAttributes( new Position( nodeBefore, count ) );
 	}
 
 	return position;
 }
 
+/**
+ * Merges two {@link engine.view.ContainerElement container elements} that are before and after given position.
+ * Precisely, the element after the position is removed and it's contents are moved to element before the position.
+ *
+ *		<p>foo</p>^<p>bar</p> -> <p>foo^bar</p>
+ *		<div>foo</div>^<p>bar</p> -> <div>foo^bar</div>
+ *
+ * **Note:** Difference between {@link engine.view.writer.mergeAttributes mergeAttributes} and
+ * {@link engine.view.writer.mergeContainers mergeContainers} is that `mergeAttributes` merges two
+ * {@link engine.view.AttributeElement attribute elements} or {@link engine.view.Text text nodes}
+ * while `mergeContainer` merges two {@link engine.view.ContainerElement container elements}.
+ *
+ * @see engine.view.AttributeElement
+ * @see engine.view.ContainerElement
+ * @see engine.view.writer.mergeAttributes
+ * @function engine.view.writer.mergeContainers
+ * @param {engine.view.Position} position Merge position.
+ * @returns {engine.view.Position} Position after merge.
+ */
+export function mergeContainers( position ) {
+	const prev = position.nodeBefore;
+	const next = position.nodeAfter;
+
+	if ( !prev || !next || !( prev instanceof ContainerElement ) || !( next instanceof ContainerElement ) ) {
+		/**
+		 * Element before and after given position cannot be merged.
+		 *
+		 * @error view-writer-merge-containers-invalid-position
+		 */
+		throw new CKEditorError( 'view-writer-merge-containers-invalid-position: ' +
+			'Element before and after given position cannot be merged.' );
+	}
+
+	const lastChild = prev.getChild( prev.childCount - 1 );
+	const newPosition = lastChild instanceof Text ? Position.createAt( lastChild, 'end' ) : Position.createAt( prev, 'end' );
+
+	move( Range.createIn( next ), Position.createAt( prev, 'end' ) );
+	remove( Range.createOn( next ) );
+
+	return newPosition;
+}
+
 /**
  * Insert node or nodes at specified position. Takes care about breaking attributes before insertion
  * and merging them afterwards.
@@ -176,11 +282,11 @@ export function insert( position, nodes ) {
 		throw new CKEditorError( 'view-writer-invalid-position-container' );
 	}
 
-	const insertionPosition = _breakAt( position, true );
+	const insertionPosition = _breakAttributes( position, true );
 
 	const length = container.insertChildren( insertionPosition.offset, nodes );
 	const endPosition = insertionPosition.getShiftedBy( length );
-	const start = mergeAt( insertionPosition );
+	const start = mergeAttributes( insertionPosition );
 
 	// When no nodes were inserted - return collapsed range.
 	if ( length === 0 ) {
@@ -191,7 +297,7 @@ export function insert( position, nodes ) {
 			endPosition.offset--;
 		}
 
-		const end = mergeAt( endPosition );
+		const end = mergeAttributes( endPosition );
 
 		return new Range( start, end );
 	}
@@ -218,7 +324,7 @@ export function remove( range ) {
 	}
 
 	// Break attributes at range start and end.
-	const { start: breakStart, end: breakEnd } = _breakRange( range, true );
+	const { start: breakStart, end: breakEnd } = _breakAttributesRange( range, true );
 	const parentContainer = breakStart.parent;
 
 	const count = breakEnd.offset - breakStart.offset;
@@ -227,7 +333,7 @@ export function remove( range ) {
 	const removed = parentContainer.removeChildren( breakStart.offset, count );
 
 	// Merge after removing.
-	const mergePosition = mergeAt( breakStart );
+	const mergePosition = mergeAttributes( breakStart );
 	range.start = mergePosition;
 	range.end = Position.createFromPosition( mergePosition );
 
@@ -301,7 +407,7 @@ export function wrap( range, attribute ) {
 	}
 
 	// Break attributes at range start and end.
-	const { start: breakStart, end: breakEnd } = _breakRange( range, true );
+	const { start: breakStart, end: breakEnd } = _breakAttributesRange( range, true );
 	const parentContainer = breakStart.parent;
 
 	// Unwrap children located between break points.
@@ -311,13 +417,13 @@ export function wrap( range, attribute ) {
 	const newRange = wrapChildren( parentContainer, unwrappedRange.start.offset, unwrappedRange.end.offset, attribute );
 
 	// Merge attributes at the both ends and return a new range.
-	const start = mergeAt( newRange.start );
+	const start = mergeAttributes( newRange.start );
 
 	// If start position was merged - move end position back.
 	if ( !start.isEqual( newRange.start ) ) {
 		newRange.end.offset--;
 	}
-	const end = mergeAt( newRange.end );
+	const end = mergeAttributes( newRange.end );
 
 	return new Range( start, end );
 }
@@ -425,20 +531,20 @@ export function unwrap( range, attribute ) {
 	}
 
 	// Break attributes at range start and end.
-	const { start: breakStart, end: breakEnd } = _breakRange( range, true );
+	const { start: breakStart, end: breakEnd } = _breakAttributesRange( range, true );
 	const parentContainer = breakStart.parent;
 
 	// Unwrap children located between break points.
 	const newRange = unwrapChildren( parentContainer, breakStart.offset, breakEnd.offset, attribute );
 
 	// Merge attributes at the both ends and return a new range.
-	const start = mergeAt( newRange.start );
+	const start = mergeAttributes( newRange.start );
 
 	// If start position was merged - move end position back.
 	if ( !start.isEqual( newRange.start ) ) {
 		newRange.end.offset--;
 	}
-	const end = mergeAt( newRange.end );
+	const end = mergeAttributes( newRange.end );
 
 	return new Range( start, end );
 }
@@ -463,7 +569,7 @@ function getParentContainer( position ) {
 	return parent;
 }
 
-// Function used by both public breakRange (without splitting text nodes) and by other methods (with
+// Function used by both public breakAttributes (without splitting text nodes) and by other methods (with
 // splitting text nodes).
 //
 // @param {engine.view.Range} range Range which `start` and `end` positions will be used to break attributes.
@@ -471,7 +577,7 @@ function getParentContainer( position ) {
 // container element. This behavior will result in incorrect view state, but is needed by other view writing methods
 // which then fixes view state. Defaults to `false`.
 // @returns {engine.view.Range} New range with located at break positions.
-function _breakRange( range, forceSplitText = false ) {
+function _breakAttributesRange( range, forceSplitText = false ) {
 	const rangeStart = range.start;
 	const rangeEnd = range.end;
 
@@ -479,14 +585,14 @@ function _breakRange( range, forceSplitText = false ) {
 
 	// Break at the collapsed position. Return new collapsed range.
 	if ( range.isCollapsed ) {
-		const position = _breakAt( range.start, forceSplitText );
+		const position = _breakAttributes( range.start, forceSplitText );
 
 		return new Range( position, position );
 	}
 
-	const breakEnd = _breakAt( rangeEnd, forceSplitText );
+	const breakEnd = _breakAttributes( rangeEnd, forceSplitText );
 	const count = breakEnd.parent.childCount;
-	const breakStart = _breakAt( rangeStart, forceSplitText );
+	const breakStart = _breakAttributes( rangeStart, forceSplitText );
 
 	// Calculate new break end offset.
 	breakEnd.offset += breakEnd.parent.childCount - count;
@@ -494,7 +600,7 @@ function _breakRange( range, forceSplitText = false ) {
 	return new Range( breakStart, breakEnd );
 }
 
-// Function used by public breakAt (without splitting text nodes) and by other methods (with
+// Function used by public breakAttributes (without splitting text nodes) and by other methods (with
 // splitting text nodes).
 //
 // @param {engine.view.Position} position Position where to break attributes.
@@ -502,7 +608,7 @@ function _breakRange( range, forceSplitText = false ) {
 // container element. This behavior will result in incorrect view state, but is needed by other view writing methods
 // which then fixes view state. Defaults to `false`.
 // @returns {engine.view.Position} New position after breaking the attributes.
-function _breakAt( position, forceSplitText = false ) {
+function _breakAttributes( position, forceSplitText = false ) {
 	const positionOffset = position.offset;
 	const positionParent = position.parent;
 
@@ -518,7 +624,7 @@ function _breakAt( position, forceSplitText = false ) {
 
 	// Break text and start again in new position.
 	if ( positionParent instanceof Text ) {
-		return _breakAt( breakTextNode( position ), forceSplitText );
+		return _breakAttributes( breakTextNode( position ), forceSplitText );
 	}
 
 	const length = positionParent.childCount;
@@ -529,7 +635,7 @@ function _breakAt( position, forceSplitText = false ) {
 	if ( positionOffset == length ) {
 		const newPosition = new Position( positionParent.parent, positionParent.index + 1 );
 
-		return _breakAt( newPosition, forceSplitText );
+		return _breakAttributes( newPosition, forceSplitText );
 	} else
 	// <p>foo<b><u>{}bar</u></b></p>
 	// <p>foo<b>[]<u>bar</u></b></p>
@@ -537,7 +643,7 @@ function _breakAt( position, forceSplitText = false ) {
 	if ( positionOffset === 0 ) {
 		const newPosition = new Position( positionParent.parent, positionParent.index );
 
-		return _breakAt( newPosition, forceSplitText );
+		return _breakAttributes( newPosition, forceSplitText );
 	}
 	// <p>foo<b><u>b{}ar</u></b></p>
 	// <p>foo<b><u>b[]ar</u></b></p>
@@ -562,7 +668,7 @@ function _breakAt( position, forceSplitText = false ) {
 		// Create new position to work on.
 		const newPosition = new Position( positionParent.parent, offsetAfter );
 
-		return _breakAt( newPosition, forceSplitText );
+		return _breakAttributes( newPosition, forceSplitText );
 	}
 }
 
@@ -621,7 +727,7 @@ function unwrapChildren( parent, startOffset, endOffset, attribute ) {
 			continue;
 		}
 
-		const newPosition = mergeAt( position );
+		const newPosition = mergeAttributes( position );
 
 		// If nodes were merged - other merge offsets will change.
 		if ( !newPosition.isEqual( position ) ) {
@@ -679,7 +785,7 @@ function wrapChildren( parent, startOffset, endOffset, attribute ) {
 			continue;
 		}
 
-		const newPosition = mergeAt( position );
+		const newPosition = mergeAttributes( position );
 
 		// If nodes were merged - other merge offsets will change.
 		if ( !newPosition.isEqual( position ) ) {

+ 165 - 27
packages/ckeditor5-engine/tests/conversion/mapper.js

@@ -54,7 +54,57 @@ describe( 'Mapper', () => {
 		} );
 	} );
 
+	describe( 'unbindModelElement', () => {
+		it( 'should remove binding between given model element and view element that it was bound to', () => {
+			const viewA = new ViewElement( 'a' );
+			const modelA = new ModelElement( 'a' );
+
+			const mapper = new Mapper();
+			mapper.bindElements( modelA, viewA );
+
+			expect( mapper.toModelElement( viewA ) ).to.equal( modelA );
+			expect( mapper.toViewElement( modelA ) ).to.equal( viewA );
+
+			mapper.unbindModelElement( modelA );
+
+			expect( mapper.toModelElement( viewA ) ).to.be.undefined;
+			expect( mapper.toViewElement( modelA ) ).to.be.undefined;
+		} );
+	} );
+
+	describe( 'unbindViewElement', () => {
+		it( 'should remove binding between given view element and model element that it was bound to', () => {
+			const viewA = new ViewElement( 'a' );
+			const modelA = new ModelElement( 'a' );
+
+			const mapper = new Mapper();
+			mapper.bindElements( modelA, viewA );
+
+			expect( mapper.toModelElement( viewA ) ).to.equal( modelA );
+			expect( mapper.toViewElement( modelA ) ).to.equal( viewA );
+
+			mapper.unbindViewElement( viewA );
+
+			expect( mapper.toModelElement( viewA ) ).to.be.undefined;
+			expect( mapper.toViewElement( modelA ) ).to.be.undefined;
+		} );
+	} );
+
 	describe( 'Standard mapping', () => {
+		function createToViewTest( modelElement, modelOffset, viewElement, viewOffset ) {
+			const modelPosition = ModelPosition.createFromParentAndOffset( modelElement, modelOffset );
+			const viewPosition = mapper.toViewPosition( modelPosition );
+			expect( viewPosition.parent ).to.equal( viewElement );
+			expect( viewPosition.offset ).to.equal( viewOffset );
+		}
+
+		function createToModelTest( viewElement, viewOffset, modelElement, modelOffset ) {
+			const viewPosition = new ViewPosition( viewElement, viewOffset );
+			const modelPosition = mapper.toModelPosition( viewPosition );
+			expect( modelPosition.parent ).to.equal( modelElement );
+			expect( modelPosition.offset ).to.equal( modelOffset );
+		}
+
 		let modelDiv, modelP, modelImg;
 
 		let viewDiv, viewP, viewB, viewI, viewU, viewSup, viewImg;
@@ -62,7 +112,7 @@ describe( 'Mapper', () => {
 
 		let mapper;
 
-		before( () => {
+		beforeEach( () => {
 			// Tree Model:
 			//
 			// <div>             ---> modelDiv
@@ -157,12 +207,21 @@ describe( 'Mapper', () => {
 		} );
 
 		describe( 'toModelPosition', () => {
-			function createToModelTest( viewElement, viewOffset, modelElement, modelOffset ) {
-				const viewPosition = new ViewPosition( viewElement, viewOffset );
-				const modelPosition = mapper.toModelPosition( viewPosition );
-				expect( modelPosition.parent ).to.equal( modelElement );
-				expect( modelPosition.offset ).to.equal( modelOffset );
-			}
+			it( 'should fire viewToModelPosition event and return value calculated in callback to that event', () => {
+				const viewPosition = new ViewPosition( viewDiv, 0 );
+				const stub = {};
+
+				mapper.on( 'viewToModelPosition', ( evt, data ) => {
+					expect( data.viewPosition ).to.equal( viewPosition );
+
+					data.modelPosition = stub;
+					evt.stop();
+				} );
+
+				const result = mapper.toModelPosition( viewPosition );
+
+				expect( result ).to.equal( stub );
+			} );
 
 			it( 'should transform viewDiv 0', () => createToModelTest( viewDiv, 0, modelDiv, 0 ) );
 			it( 'should transform viewDiv 1', () => createToModelTest( viewDiv, 1, modelDiv, 1 ) );
@@ -221,12 +280,21 @@ describe( 'Mapper', () => {
 		} );
 
 		describe( 'toViewPosition', () => {
-			function createToViewTest( modelElement, modelOffset, viewElement, viewOffset ) {
-				const modelPosition = ModelPosition.createFromParentAndOffset( modelElement, modelOffset );
-				const viewPosition = mapper.toViewPosition( modelPosition );
-				expect( viewPosition.parent ).to.equal( viewElement );
-				expect( viewPosition.offset ).to.equal( viewOffset );
-			}
+			it( 'should fire modelToViewPosition event and return value calculated in callback to that event', () => {
+				const modelPosition = new ModelPosition( modelDiv, [ 0 ] );
+				const stub = {};
+
+				mapper.on( 'modelToViewPosition', ( evt, data ) => {
+					expect( data.modelPosition ).to.equal( modelPosition );
+
+					data.viewPosition = stub;
+					evt.stop();
+				} );
+
+				const result = mapper.toViewPosition( modelPosition );
+
+				expect( result ).to.equal( stub );
+			} );
 
 			it( 'should transform modelDiv 0', () => createToViewTest( modelDiv, 0, viewTextX, 0 ) );
 			it( 'should transform modelDiv 1', () => createToViewTest( modelDiv, 1, viewTextX, 1 ) );
@@ -272,6 +340,20 @@ describe( 'Mapper', () => {
 	} );
 
 	describe( 'Widget mapping', () => {
+		function createToViewTest( modelElement, modelOffset, viewElement, viewOffset ) {
+			const modelPosition = ModelPosition.createFromParentAndOffset( modelElement, modelOffset );
+			const viewPosition = mapper.toViewPosition( modelPosition );
+			expect( viewPosition.parent ).to.equal( viewElement );
+			expect( viewPosition.offset ).to.equal( viewOffset );
+		}
+
+		function createToModelTest( viewElement, viewOffset, modelElement, modelOffset ) {
+			const viewPosition = new ViewPosition( viewElement, viewOffset );
+			const modelPosition = mapper.toModelPosition( viewPosition );
+			expect( modelPosition.parent ).to.equal( modelElement );
+			expect( modelPosition.offset ).to.equal( modelOffset );
+		}
+
 		let modelDiv, modelWidget, modelImg, modelCaption;
 
 		let viewDiv, viewWidget, viewMask, viewWrapper, viewImg, viewCaption;
@@ -350,13 +432,6 @@ describe( 'Mapper', () => {
 		} );
 
 		describe( 'toModelPosition', () => {
-			function createToModelTest( viewElement, viewOffset, modelElement, modelOffset ) {
-				const viewPosition = new ViewPosition( viewElement, viewOffset );
-				const modelPosition = mapper.toModelPosition( viewPosition );
-				expect( modelPosition.parent ).to.equal( modelElement );
-				expect( modelPosition.offset ).to.equal( modelOffset );
-			}
-
 			it( 'should transform viewDiv 0', () => createToModelTest( viewDiv, 0, modelDiv, 0 ) );
 			it( 'should transform viewDiv 1', () => createToModelTest( viewDiv, 1, modelDiv, 1 ) );
 			it( 'should transform viewDiv 2', () => createToModelTest( viewDiv, 2, modelDiv, 2 ) );
@@ -381,13 +456,6 @@ describe( 'Mapper', () => {
 		} );
 
 		describe( 'toViewPosition', () => {
-			function createToViewTest( modelElement, modelOffset, viewElement, viewOffset ) {
-				const modelPosition = ModelPosition.createFromParentAndOffset( modelElement, modelOffset );
-				const viewPosition = mapper.toViewPosition( modelPosition );
-				expect( viewPosition.parent ).to.equal( viewElement );
-				expect( viewPosition.offset ).to.equal( viewOffset );
-			}
-
 			it( 'should transform modelDiv 0', () => createToViewTest( modelDiv, 0, viewTextX, 0 ) );
 			it( 'should transform modelDiv 1', () => createToViewTest( modelDiv, 1, viewTextX, 1 ) );
 			it( 'should transform modelDiv 2', () => createToViewTest( modelDiv, 2, viewTextZZ, 0 ) );
@@ -402,4 +470,74 @@ describe( 'Mapper', () => {
 			it( 'should transform modelCaption 3', () => createToViewTest( modelCaption, 3, viewTextFOO, 3 ) );
 		} );
 	} );
+
+	describe( 'List mapping (test registerViewToModelLengthCallback)', () => {
+		function createToModelTest( viewElement, viewOffset, modelElement, modelOffset ) {
+			const viewPosition = new ViewPosition( viewElement, viewOffset );
+			const modelPosition = mapper.toModelPosition( viewPosition );
+			expect( modelPosition.parent ).to.equal( modelElement );
+			expect( modelPosition.offset ).to.equal( modelOffset );
+		}
+
+		let mapper;
+
+		let modelRoot;
+		let modelListItem1, modelListItem2;
+		let modelListItem11, modelListItem12;
+
+		let viewRoot;
+		let viewList, viewListNested;
+		let viewListItem1, viewListItem2;
+		let viewListItem11, viewListItem12;
+
+		before( () => {
+			modelListItem1 = new ModelElement( 'listItem', null, new ModelText( 'aaa' ) );
+			modelListItem11 = new ModelElement( 'listItem', null, new ModelText( 'bbb' ) );
+			modelListItem12 = new ModelElement( 'listItem', null, new ModelText( 'ccc' ) );
+			modelListItem2 = new ModelElement( 'listItem', null, new ModelText( 'xxx' ) );
+
+			modelRoot = new ModelRootElement( 'root', null, [ modelListItem1, modelListItem11, modelListItem12, modelListItem2 ] );
+
+			viewListItem11 = new ViewElement( 'li', null, new ViewText( 'bbb' ) );
+			viewListItem12 = new ViewElement( 'li', null, new ViewText( 'ccc' ) );
+			viewListNested = new ViewElement( 'ul', null, [ viewListItem11, viewListItem12 ] );
+
+			viewListItem1 = new ViewElement( 'li', null, [ new ViewText( 'aaa' ), viewListNested ] );
+			viewListItem2 = new ViewElement( 'li', null, new ViewText( 'ddd' ) );
+			viewList = new ViewElement( 'ul', null, [ viewListItem1, viewListItem2 ] );
+
+			viewRoot = new ViewElement( 'div', null, viewList );
+
+			mapper = new Mapper();
+			mapper.bindElements( modelRoot, viewRoot );
+			mapper.bindElements( modelListItem1, viewListItem1 );
+			mapper.bindElements( modelListItem11, viewListItem11 );
+			mapper.bindElements( modelListItem12, viewListItem12 );
+			mapper.bindElements( modelListItem2, viewListItem2 );
+
+			function getViewListItemLength( element ) {
+				let length = 1;
+
+				for ( let child of element.getChildren() ) {
+					if ( child.name == 'ul' || child.name == 'ol' ) {
+						for ( let item of child.getChildren() ) {
+							length += getViewListItemLength( item );
+						}
+					}
+				}
+
+				return length;
+			}
+
+			mapper.registerViewToModelLength( 'li', getViewListItemLength );
+		} );
+
+		describe( 'toModelPosition', () => {
+			it( 'should transform viewRoot 0', () => createToModelTest( viewRoot, 0, modelRoot, 0 ) );
+			it( 'should transform viewRoot 1', () => createToModelTest( viewRoot, 1, modelRoot, 4 ) );
+			it( 'should transform viewList 0', () => createToModelTest( viewList, 0, modelRoot, 0 ) );
+			it( 'should transform viewList 1', () => createToModelTest( viewList, 1, modelRoot, 3 ) );
+			it( 'should transform viewList 2', () => createToModelTest( viewList, 2, modelRoot, 4 ) );
+		} );
+	} );
 } );

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

@@ -13,7 +13,7 @@ import ModelPosition from '/ckeditor5/engine/model/position.js';
 import ViewDocument from '/ckeditor5/engine/view/document.js';
 import ViewContainerElement from '/ckeditor5/engine/view/containerelement.js';
 import ViewAttributeElement from '/ckeditor5/engine/view/attributeelement.js';
-import { mergeAt } from '/ckeditor5/engine/view/writer.js';
+import { mergeAttributes } from '/ckeditor5/engine/view/writer.js';
 
 import Mapper from '/ckeditor5/engine/conversion/mapper.js';
 import ModelConversionDispatcher from '/ckeditor5/engine/conversion/modelconversiondispatcher.js';
@@ -296,7 +296,7 @@ describe( 'clean-up', () => {
 			);
 
 			// Remove <b></b> manually.
-			mergeAt( viewSelection.getFirstPosition() );
+			mergeAttributes( viewSelection.getFirstPosition() );
 
 			const modelRange = ModelRange.createFromParentsAndOffsets( modelRoot, 1, modelRoot, 1 );
 			modelDoc.selection.setRanges( [ modelRange ] );

+ 120 - 1
packages/ckeditor5-engine/tests/conversion/model-to-view-converters.js

@@ -27,7 +27,8 @@ import {
 	wrap,
 	unwrap,
 	move,
-	remove
+	remove,
+	rename
 } from '/ckeditor5/engine/conversion/model-to-view-converters.js';
 
 import { createRangeOnElementOnly } from '/tests/engine/model/_utils/utils.js';
@@ -335,6 +336,33 @@ describe( 'move', () => {
 		expect( viewToString( viewRoot ) ).to.equal( '<div><div>bar</div><div>foo<img></img>xxyy</div></div>' );
 	} );
 
+	it( 'should not execute if value was already consumed', () => {
+		const modelDivA = new ModelElement( 'div', null, new ModelText( 'foo' ) );
+		const modelDivB = new ModelElement( 'div', null, new ModelText( 'xxyy' ) );
+
+		modelRoot.appendChildren( [ modelDivA, modelDivB ] );
+		dispatcher.on( 'insert:div', insertElement( new ViewContainerElement( 'div' ) ) );
+		dispatcher.on( 'insert:$text', insertText() );
+		dispatcher.on( 'move', move() );
+		dispatcher.on( 'move', ( evt, data, consumable ) => {
+			consumable.consume( data.item, 'move' );
+		}, 'high' );
+
+		dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+
+		expect( viewToString( viewRoot ) ).to.equal( '<div><div>foo</div><div>xxyy</div></div>' );
+
+		const removedNodes = modelDivA.removeChildren( 0, 1 );
+		modelDivB.insertChildren( 0, removedNodes );
+
+		dispatcher.convertMove(
+			ModelPosition.createFromParentAndOffset( modelDivA, 0 ),
+			ModelRange.createFromParentsAndOffsets( modelDivB, 0, modelDivB, 3 )
+		);
+
+		expect( viewToString( viewRoot ) ).to.equal( '<div><div>foo</div><div>xxyy</div></div>' );
+	} );
+
 	it( 'should support unicode', () => {
 		const modelDivA = new ModelElement( 'div', null, 'நிலைக்கு' );
 		const modelDivB = new ModelElement( 'div' );
@@ -387,6 +415,32 @@ describe( 'remove', () => {
 		expect( viewToString( viewRoot ) ).to.equal( '<div><div>bar</div></div>' );
 	} );
 
+	it( 'should not execute if value was already consumed', () => {
+		const modelDiv = new ModelElement( 'div', null, new ModelText( 'foo' ) );
+
+		modelRoot.appendChildren( modelDiv );
+		dispatcher.on( 'insert:div', insertElement( new ViewContainerElement( 'div' ) ) );
+		dispatcher.on( 'insert:$text', insertText() );
+		dispatcher.on( 'remove', remove() );
+		dispatcher.on( 'remove', ( evt, data, consumable ) => {
+			consumable.consume( data.item, 'remove' );
+		}, 'high' );
+
+		dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+
+		expect( viewToString( viewRoot ) ).to.equal( '<div><div>foo</div></div>' );
+
+		const removedNodes = modelDiv.removeChildren( 0, 1 );
+		modelDoc.graveyard.insertChildren( 0, removedNodes );
+
+		dispatcher.convertRemove(
+			ModelPosition.createFromParentAndOffset( modelDiv, 0 ),
+			ModelRange.createFromParentsAndOffsets( modelDoc.graveyard, 0, modelDoc.graveyard, 3 )
+		);
+
+		expect( viewToString( viewRoot ) ).to.equal( '<div><div>foo</div></div>' );
+	} );
+
 	it( 'should support unicode', () => {
 		const modelDiv = new ModelElement( 'div', null, 'நிலைக்கு' );
 
@@ -410,3 +464,68 @@ describe( 'remove', () => {
 		expect( viewToString( viewRoot ) ).to.equal( '<div><div>கு</div></div>' );
 	} );
 } );
+
+describe( 'rename', () => {
+	const oldName = 'oldName';
+	const newName = 'newName';
+
+	let element, converters;
+
+	beforeEach( () => {
+		converters = {
+			insertText: insertText(),
+			insert:	insertElement( ( data ) => new ViewContainerElement( data.item.name ) ),
+			move: move(),
+			remove: remove(),
+			rename: rename()
+		};
+
+		sinon.spy( converters, 'insert' );
+		sinon.spy( converters, 'move' );
+		sinon.spy( converters, 'remove' );
+
+		element = new ModelElement( oldName, null, new ModelText( 'foo' ) );
+		modelRoot.appendChildren( element );
+
+		dispatcher.on( 'insert:$text', converters.insertText );
+		dispatcher.on( 'insert', converters.insert );
+		dispatcher.on( 'move', converters.move );
+		dispatcher.on( 'remove', converters.remove );
+		dispatcher.on( 'rename', converters.rename );
+
+		dispatcher.convertInsertion( ModelRange.createOn( element ) );
+
+		element.name = newName;
+	} );
+
+	afterEach( () => {
+		converters.insert.restore();
+		converters.move.restore();
+		converters.remove.restore();
+	} );
+
+	it( 'should enable default rename conversion, that uses already registered callbacks', () => {
+		expect( viewRoot.getChild( 0 ).name ).to.equal( 'oldName' );
+		dispatcher.convertRename( element, oldName );
+
+		// Called twice, first time when renamed element was originally inserted to model and converted to view.
+		expect( converters.insert.calledTwice ).to.be.true;
+		expect( converters.move.calledOnce ).to.be.true;
+		expect( converters.remove.calledOnce ).to.be.true;
+
+		expect( viewRoot.getChild( 0 ).name ).to.equal( 'newName' );
+		expect( viewRoot.getChild( 0 ).getChild( 0 ).data ).to.equal( 'foo' );
+	} );
+
+	it( 'should not execute if converted value was already consumed', () => {
+		dispatcher.on( 'rename', ( evt, data, consumable ) => {
+			consumable.consume( data.element, 'rename' );
+		}, 'high' );
+
+		dispatcher.on( 'rename', ( evt, data ) => {
+			expect( data.fakeElement ).to.be.undefined;
+		} );
+
+		dispatcher.convertRename( element, oldName );
+	} );
+} );

+ 32 - 3
packages/ckeditor5-engine/tests/conversion/modelconversiondispatcher.js

@@ -111,6 +111,16 @@ describe( 'ModelConversionDispatcher', () => {
 			expect( cbRemove.called );
 		} );
 
+		it( 'should fire rename callback for rename changes', () => {
+			const cbRename = sinon.spy();
+
+			dispatcher.on( 'rename', cbRename );
+
+			doc.batch().rename( 'figure', image );
+
+			expect( cbRename.called );
+		} );
+
 		it( 'should fire addAttribute callbacks for add attribute change', () => {
 			const cbAddText = sinon.spy();
 			const cbAddImage = sinon.spy();
@@ -284,7 +294,7 @@ describe( 'ModelConversionDispatcher', () => {
 			const loggedEvents = [];
 
 			dispatcher.on( 'move', ( evt, data ) => {
-				const log = 'move:' + data.sourcePosition.path + ':' + data.range.start.path + ':' + data.range.end.path;
+				const log = 'move:' + data.sourcePosition.path + ':' + data.targetPosition.path + ':' + data.item.offsetSize;
 				loggedEvents.push( log );
 			} );
 
@@ -303,13 +313,32 @@ describe( 'ModelConversionDispatcher', () => {
 			const loggedEvents = [];
 
 			dispatcher.on( 'remove', ( evt, data ) => {
-				const log = 'remove:' + data.sourcePosition.path + ':' + data.range.start.path + ':' + data.range.end.path;
+				const log = 'remove:' + data.sourcePosition.path + ':' + data.item.offsetSize;
 				loggedEvents.push( log );
 			} );
 
 			dispatcher.convertRemove( ModelPosition.createFromParentAndOffset( root , 3 ), range );
 
-			expect( loggedEvents ).to.deep.equal( [ 'remove:3:0:3' ] );
+			expect( loggedEvents ).to.deep.equal( [ 'remove:3:3' ] );
+		} );
+	} );
+
+	describe( 'convertRename', () => {
+		it( 'should fire rename event with correct name, consumable, and renamed element and it\'s old name in data', ( done ) => {
+			const oldName = 'oldName';
+			const element = new ModelElement( oldName );
+			element.name = 'newName';
+
+			dispatcher.on( 'rename', ( evt, data, consumable ) => {
+				expect( evt.name ).to.equal( 'rename:newName:oldName' );
+				expect( data.element ).to.equal( element );
+				expect( data.oldName ).to.equal( oldName );
+				expect( consumable.test( data.element, 'rename' ) ).to.be.true;
+
+				done();
+			} );
+
+			dispatcher.convertRename( element, oldName );
 		} );
 	} );
 

+ 1 - 0
packages/ckeditor5-engine/tests/dataprocessor/xmldataprocessor.js

@@ -4,6 +4,7 @@
  */
 
 /* bender-tags: browser-only */
+/* globals window, setTimeout */
 
 import XmlDataProcessor from '/ckeditor5/engine/dataprocessor/xmldataprocessor.js';
 import xssTemplates from '/tests/engine/dataprocessor/_utils/xsstemplates.js';

+ 12 - 1
packages/ckeditor5-engine/tests/editingcontroller.js

@@ -115,12 +115,13 @@ describe( 'EditingController', () => {
 			viewRoot = editing.createRoot( domRoot );
 
 			model.schema.registerItem( 'paragraph', '$block' );
+			model.schema.registerItem( 'div', '$block' );
 			buildModelConverter().for( editing.modelToView ).fromElement( 'paragraph' ).toElement( 'p' );
+			buildModelConverter().for( editing.modelToView ).fromElement( 'div' ).toElement( 'div' );
 		} );
 
 		beforeEach( () => {
 			// Note: The below code is highly overcomplicated due to #455.
-
 			model.selection.removeAllRanges();
 			modelRoot.removeChildren( 0, modelRoot.childCount );
 
@@ -157,6 +158,16 @@ describe( 'EditingController', () => {
 			expect( getViewData( editing.view ) ).to.equal( '<p>f</p><p>{}oo</p><p></p><p>bar</p>' );
 		} );
 
+		it( 'should convert rename', () => {
+			expect( getViewData( editing.view ) ).to.equal( '<p>f{}oo</p><p></p><p>bar</p>' );
+
+			model.enqueueChanges( () => {
+				model.batch().rename( 'div', modelRoot.getChild( 0 ) );
+			} );
+
+			expect( getViewData( editing.view ) ).to.equal( '<div>f{}oo</div><p></p><p>bar</p>' );
+		} );
+
 		it( 'should convert delete', () => {
 			model.enqueueChanges( () => {
 				model.batch().remove(

+ 2 - 2
packages/ckeditor5-engine/tests/model/delta/transform/attributedelta.js

@@ -90,7 +90,7 @@ describe( 'transform', () => {
 							type: AttributeOperation,
 							range: new Range( new Position( root, [ 3, 3, 0 ] ), new Position( root, [ 3, 3, 1 ] ) ),
 							key: 'key',
-							oldValue: undefined,
+							oldValue: null,
 							newValue: 'new',
 							baseVersion: baseVersion + 2
 						},
@@ -106,7 +106,7 @@ describe( 'transform', () => {
 							type: AttributeOperation,
 							range: new Range( new Position( root, [ 3, 3, 3 ] ), new Position( root, [ 3, 3, 5 ] ) ),
 							key: 'key',
-							oldValue: undefined,
+							oldValue: null,
 							newValue: 'new',
 							baseVersion: baseVersion + 4
 						}

+ 2 - 2
packages/ckeditor5-engine/tests/model/delta/transform/weakinsertdelta.js

@@ -83,7 +83,7 @@ describe( 'transform', () => {
 							type: AttributeOperation,
 							range: new Range( new Position( root, [ 3, 3, 0 ] ), new Position( root, [ 3, 3, 1 ] ) ),
 							key: 'key',
-							oldValue: undefined,
+							oldValue: null,
 							newValue: 'new',
 							baseVersion: baseVersion + 1
 						},
@@ -99,7 +99,7 @@ describe( 'transform', () => {
 							type: AttributeOperation,
 							range: new Range( new Position( root, [ 3, 3, 4 ] ), new Position( root, [ 3, 3, 6 ] ) ),
 							key: 'key',
-							oldValue: undefined,
+							oldValue: null,
 							newValue: 'new',
 							baseVersion: baseVersion + 3
 						}

+ 5 - 4
packages/ckeditor5-engine/tests/model/liveselection.js

@@ -265,11 +265,12 @@ describe( 'LiveSelection', () => {
 		} );
 	} );
 
-	describe( 'getFirstPosition', () => {
-		it( 'should return start position of default range if no ranges were added', () => {
-			const firstPosition = selection.getFirstPosition();
+	describe( 'getLastRange', () => {
+		it( 'should return default range if no ranges were added', () => {
+			const lastRange = selection.getLastRange();
 
-			expect( firstPosition.isEqual( new Position( root, [ 0, 0 ] ) ) );
+			expect( lastRange.start.isEqual( new Position( root, [ 0, 0 ] ) ) );
+			expect( lastRange.end.isEqual( new Position( root, [ 0, 0 ] ) ) );
 		} );
 	} );
 

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

@@ -41,7 +41,7 @@ describe( 'AttributeOperation', () => {
 				new Range( new Position( root, [ 0 ] ), new Position( root, [ 2 ] ) ),
 				'key',
 				'oldValue',
-				null,
+				undefined, // `undefined` should also be accepted as a value, it is same as `null`.
 				doc.version
 			);
 
@@ -68,7 +68,7 @@ describe( 'AttributeOperation', () => {
 			new AttributeOperation(
 				new Range( new Position( root, [ 0 ] ), new Position( root, [ 2 ] ) ),
 				'isNew',
-				null,
+				undefined, // `undefined` should also be accepted as a value, it is same as `null`.
 				true,
 				doc.version
 			)

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

@@ -0,0 +1,122 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: model, operation */
+
+import Document from '/ckeditor5/engine/model/document.js';
+import Element from '/ckeditor5/engine/model/element.js';
+import RenameOperation from '/ckeditor5/engine/model/operation/renameoperation.js';
+import Position from '/ckeditor5/engine/model/position.js';
+import CKEditorError from '/ckeditor5/utils/ckeditorerror.js';
+import { jsonParseStringify, wrapInDelta } from '/tests/engine/model/_utils/utils.js';
+
+describe( 'RenameOperation', () => {
+	const oldName = 'oldName';
+	const newName = 'newName';
+
+	let doc, root, element, position;
+
+	beforeEach( () => {
+		doc = new Document();
+		root = doc.createRoot();
+
+		element = new Element( oldName );
+		root.appendChildren( element );
+
+		position = Position.createBefore( element );
+	} );
+
+	it( 'should have type equal to rename', () => {
+		const op = new RenameOperation( position, oldName, newName, doc.version );
+
+		expect( op.type ).to.equal( 'rename' );
+	} );
+
+	it( 'should change name of given element', () => {
+		const op = new RenameOperation( position, oldName, newName, doc.version );
+
+		doc.applyOperation( wrapInDelta( op ) );
+
+		expect( element.name ).to.equal( newName );
+	} );
+
+	it( 'should create a RenameOperation as a reverse', () => {
+		const op = new RenameOperation( position, oldName, newName, doc.version );
+		const reverse = op.getReversed();
+
+		expect( reverse ).to.be.an.instanceof( RenameOperation );
+		expect( reverse.baseVersion ).to.equal( 1 );
+		expect( reverse.position.isEqual( position ) ).to.be.true;
+		expect( reverse.oldName ).to.equal( newName );
+		expect( reverse.newName ).to.equal( oldName );
+	} );
+
+	it( 'should undo renaming element by applying reverse operation', () => {
+		const op = new RenameOperation( position, oldName, newName, doc.version );
+		const reverse = op.getReversed();
+
+		doc.applyOperation( wrapInDelta( op ) );
+		doc.applyOperation( wrapInDelta( reverse ) );
+
+		expect( doc.version ).to.equal( 2 );
+		expect( element.name ).to.equal( oldName );
+	} );
+
+	it( 'should throw an error if position is not before an element', () => {
+		const op = new RenameOperation( Position.createAt( root, 'end' ), oldName, newName, doc.version );
+
+		expect( () => {
+			doc.applyOperation( wrapInDelta( op ) );
+		} ).to.throw( CKEditorError, /rename-operation-wrong-position/ );
+	} );
+
+	it( 'should throw an error if oldName is different than renamed element name', () => {
+		const op = new RenameOperation( position, 'foo', newName, doc.version );
+
+		expect( () => {
+			doc.applyOperation( wrapInDelta( op ) );
+		} ).to.throw( CKEditorError, /rename-operation-wrong-name/ );
+	} );
+
+	it( 'should create a RenameOperation with the same parameters when cloned', () => {
+		const op = new RenameOperation( Position.createAt( root, 'end' ), oldName, newName, doc.version );
+		const clone = op.clone();
+
+		// New instance rather than a pointer to the old instance.
+		expect( clone ).not.to.be.equal( op );
+
+		expect( clone ).to.be.instanceof( RenameOperation );
+		expect( clone.baseVersion ).to.equal( op.baseVersion );
+		expect( clone.position.isEqual( op.position ) ).to.be.true;
+		expect( clone.oldName ).to.equal( oldName );
+		expect( clone.newName ).to.equal( newName );
+	} );
+
+	describe( 'toJSON', () => {
+		it( 'should create proper serialized object', () => {
+			const op = new RenameOperation( Position.createAt( root, 'end' ), oldName, newName, doc.version );
+			const serialized = jsonParseStringify( op );
+
+			expect( serialized ).to.deep.equal( {
+				__className: 'engine.model.operation.RenameOperation',
+				baseVersion: 0,
+				position: jsonParseStringify( op.position ),
+				newName: 'newName',
+				oldName: 'oldName'
+			} );
+		} );
+	} );
+
+	describe( 'fromJSON', () => {
+		it( 'should create proper AttributeOperation from json object', () => {
+			const op = new RenameOperation( Position.createAt( root, 'end' ), oldName, newName, doc.version );
+
+			const serialized = jsonParseStringify( op );
+			const deserialized = RenameOperation.fromJSON( serialized, doc );
+
+			expect( deserialized ).to.deep.equal( op );
+		} );
+	} );
+} );

+ 319 - 0
packages/ckeditor5-engine/tests/model/operation/transform.js

@@ -18,6 +18,7 @@ import AttributeOperation from '/ckeditor5/engine/model/operation/attributeopera
 import RootAttributeOperation from '/ckeditor5/engine/model/operation/rootattributeoperation.js';
 import MoveOperation from '/ckeditor5/engine/model/operation/moveoperation.js';
 import RemoveOperation from '/ckeditor5/engine/model/operation/removeoperation.js';
+import RenameOperation from '/ckeditor5/engine/model/operation/renameoperation.js';
 import NoOperation from '/ckeditor5/engine/model/operation/nooperation.js';
 
 describe( 'transform', () => {
@@ -403,6 +404,17 @@ describe( 'transform', () => {
 				expectOperation( transOp[ 0 ], expected );
 			} );
 		} );
+
+		describe( 'by RenameOperation', () => {
+			it( 'no position update', () => {
+				let transformBy = new RenameOperation( new Position( root, [ 0, 2, 0 ] ), 'oldName', 'newName', baseVersion );
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+		} );
 	} );
 
 	describe( 'AttributeOperation', () => {
@@ -1087,6 +1099,17 @@ describe( 'transform', () => {
 					expectOperation( transOp[ 0 ], expected );
 				} );
 			} );
+
+			describe( 'by RenameOperation', () => {
+				it( 'no position update', () => {
+					let transformBy = new RenameOperation( new Position( root, [ 1, 0 ] ), 'oldName', 'newName', baseVersion );
+
+					let transOp = transform( op, transformBy );
+
+					expect( transOp.length ).to.equal( 1 );
+					expectOperation( transOp[ 0 ], expected );
+				} );
+			} );
 		} );
 
 		// Some extra cases for a AttributeOperation that operates on single tree level range.
@@ -1544,6 +1567,17 @@ describe( 'transform', () => {
 				expectOperation( transOp[ 0 ], expected );
 			} );
 		} );
+
+		describe( 'by RenameOperation', () => {
+			it( 'no position update', () => {
+				let transformBy = new RenameOperation( new Position( root, [ 0 ] ), 'oldName', 'newName', baseVersion );
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+		} );
 	} );
 
 	describe( 'MoveOperation', () => {
@@ -2647,6 +2681,17 @@ describe( 'transform', () => {
 				expectOperation( transOp[ 0 ], expected );
 			} );
 		} );
+
+		describe( 'by RenameOperation', () => {
+			it( 'no position update', () => {
+				let transformBy = new RenameOperation( new Position( root, [ 2, 2, 4 ] ), 'oldName', 'newName', baseVersion );
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+		} );
 	} );
 
 	describe( 'NoOperation', () => {
@@ -2737,5 +2782,279 @@ describe( 'transform', () => {
 				expectOperation( transOp[ 0 ], expected );
 			} );
 		} );
+
+		describe( 'by RenameOperation', () => {
+			it( 'no position update', () => {
+				let transformBy = new RenameOperation( new Position( root, [ 0, 2, 0 ] ), 'oldName', 'newName', baseVersion );
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+		} );
+	} );
+
+	describe( 'RenameOperation', () => {
+		beforeEach( () => {
+			const position = new Position( root, [ 0, 2, 2 ] );
+
+			op = new RenameOperation( position, 'oldName', 'newName', baseVersion );
+
+			expected = {
+				position: position,
+				oldName: 'oldName',
+				newName: 'newName',
+				baseVersion: baseVersion + 1
+			};
+		} );
+
+		describe( 'by InsertOperation', () => {
+			it( 'target before renamed element: offset update', () => {
+				let transformBy = new InsertOperation(
+					new Position( root, [ 0, 2, 1 ] ),
+					[ nodeA, nodeB ],
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+
+				expected.position.offset = 4;
+
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'target after renamed element: no change', () => {
+				let transformBy = new InsertOperation(
+					new Position( root, [ 0, 2, 3 ] ),
+					[ nodeA, nodeB ],
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'target before a node on path to renamed element: path update', () => {
+				let transformBy = new InsertOperation(
+					new Position( root, [ 0, 1 ] ),
+					[ nodeA, nodeB ],
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+
+				expected.position.path = [ 0, 4, 2 ];
+
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'target after a node on path to renamed element: no change', () => {
+				let transformBy = new InsertOperation(
+					new Position( root, [ 0, 3 ] ),
+					[ nodeA, nodeB ],
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+		} );
+
+		describe( 'by AttributeOperation', () => {
+			it( 'no operation update', () => {
+				let transformBy = new AttributeOperation(
+					new Range(
+						new Position( root, [ 0, 2, 1 ] ),
+						new Position( root, [ 1, 3 ] )
+					),
+					'foo',
+					'bar',
+					'xyz',
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+		} );
+
+		describe( 'by RootAttributeOperation', () => {
+			it( 'no operation update', () => {
+				let transformBy = new RootAttributeOperation(
+					root,
+					'foo',
+					null,
+					'bar',
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+		} );
+
+		describe( 'by RenameOperation', () => {
+			it( 'different element: no change', () => {
+				let transformBy = new RenameOperation(
+					new Position( root, [ 0, 2, 1 ] ),
+					'foo',
+					'bar',
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'same element and is important: convert to NoOperation', () => {
+				let transformBy = new RenameOperation(
+					new Position( root, [ 0, 2, 2 ] ),
+					'oldName',
+					'otherName',
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy, false );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], {
+					type: NoOperation,
+					baseVersion: baseVersion + 1
+				} );
+			} );
+
+			it( 'same element and is not important: no change', () => {
+				let transformBy = new RenameOperation(
+					new Position( root, [ 0, 2, 2 ] ),
+					'oldName',
+					'otherName',
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy, true );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+		} );
+
+		describe( 'by MoveOperation', () => {
+			it( 'moved range before renamed element: update offset', () => {
+				let transformBy = new MoveOperation(
+					new Position( root, [ 0, 2, 0 ] ),
+					2,
+					new Position( root, [ 2, 5 ] ),
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+
+				expected.position.offset = 0;
+
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'moved range before an element on path to renamed element: update path', () => {
+				let transformBy = new MoveOperation(
+					new Position( root, [ 0, 0 ] ),
+					2,
+					new Position( root, [ 2, 5 ] ),
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+
+				expected.position.path = [ 0, 0, 2 ];
+
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'moved range contains renamed element: update path', () => {
+				let transformBy = new MoveOperation(
+					new Position( root, [ 0, 2, 1 ] ),
+					3,
+					new Position( root, [ 2, 5 ] ),
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+
+				expected.position.path = [ 2, 6 ];
+
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'moved range contains renamed element parent: updated path', () => {
+				let transformBy = new MoveOperation(
+					new Position( root, [ 0, 1 ] ),
+					3,
+					new Position( root, [ 2, 5 ] ),
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+
+				expected.position.path = [ 2, 6, 2 ];
+
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'move target before renamed element: update offset', () => {
+				let transformBy = new MoveOperation(
+					new Position( root, [ 2, 5 ] ),
+					2,
+					new Position( root, [ 0, 2, 1 ] ),
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+
+				expected.position.offset = 4;
+
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'move target before an element on path to renamed element: update path', () => {
+				let transformBy = new MoveOperation(
+					new Position( root, [ 0, 2, 0 ] ),
+					2,
+					new Position( root, [ 2, 5 ] ),
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+
+				expected.position.offset = 0;
+
+				expectOperation( transOp[ 0 ], expected );
+			} );
+		} );
 	} );
 } );

+ 6 - 0
packages/ckeditor5-engine/tests/model/position.js

@@ -503,6 +503,12 @@ describe( 'position', () => {
 		} );
 	} );
 
+	describe( 'getAncestors', () => {
+		it( 'should return position parent element and it\'s ancestors', () => {
+			expect( new Position( root, [ 1, 1, 1 ] ).getAncestors() ).to.deep.equal( [ li2, ul, root ] );
+		} );
+	} );
+
 	describe( 'compareWith', () => {
 		it( 'should return same if positions are same', () => {
 			const position = new Position( root, [ 1, 2, 3 ] );

+ 29 - 0
packages/ckeditor5-engine/tests/model/schema/schema.js

@@ -316,6 +316,35 @@ describe( 'check', () => {
 	} );
 } );
 
+describe( 'itemExtends', () => {
+	it( 'should return true if given item extends another given item', () => {
+		schema.registerItem( 'div', '$block' );
+		schema.registerItem( 'myDiv', 'div' );
+
+		expect( schema.itemExtends( 'div', '$block' ) ).to.be.true;
+		expect( schema.itemExtends( 'myDiv', 'div' ) ).to.be.true;
+		expect( schema.itemExtends( 'myDiv', '$block' ) ).to.be.true;
+	} );
+
+	it( 'should return false if given item does not extend another given item', () => {
+		schema.registerItem( 'div' );
+		schema.registerItem( 'myDiv', 'div' );
+
+		expect( schema.itemExtends( 'div', '$block' ) ).to.be.false;
+		expect( schema.itemExtends( 'div', 'myDiv' ) ).to.be.false;
+	} );
+
+	it( 'should throw if one or both given items are not registered in schema', () => {
+		expect( () => {
+			schema.itemExtends( 'foo', '$block' );
+		} ).to.throw( CKEditorError, /model-schema-no-item/ );
+
+		expect( () => {
+			schema.itemExtends( '$block', 'foo' );
+		} ).to.throw( CKEditorError, /model-schema-no-item/ );
+	} );
+} );
+
 describe( '_normalizeQueryPath', () => {
 	it( 'should normalize string with spaces to an array of strings', () => {
 		expect( Schema._normalizeQueryPath( '$root div strong' ) ).to.deep.equal( [ '$root', 'div', 'strong' ] );

+ 1 - 1
packages/ckeditor5-engine/tests/view/position.js

@@ -123,7 +123,7 @@ describe( 'Position', () => {
 			const div = new Element( 'div', null, p );
 			const docFrag = new DocumentFragment( div );
 
-			expect( new Position( foo, 1 ).getAncestors() ).to.deep.equal( [ docFrag, div, p, foo ] );
+			expect( new Position( foo, 1 ).getAncestors() ).to.deep.equal( [ foo, p, div, docFrag ] );
 		} );
 	} );
 

+ 0 - 121
packages/ckeditor5-engine/tests/view/writer/breakAt.js

@@ -1,121 +0,0 @@
-/**
- * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
-
-/* bender-tags: view, browser-only */
-
-import { breakAt } from '/ckeditor5/engine/view/writer.js';
-import { stringify, parse } from '/tests/engine/_utils/view.js';
-
-describe( 'writer', () => {
-	/**
-	 * Executes test using `parse` and `stringify` utils functions. Uses range delimiters `[]{}` to create and
-	 * test break position.
-	 *
-	 * @param {String} input
-	 * @param {String} expected
-	 */
-	function test( input, expected ) {
-		let { view, selection } = parse( input );
-
-		const newPosition = breakAt( selection.getFirstPosition() );
-		expect( stringify( view.root, newPosition, { showType: true, showPriority: true } ) ).to.equal( expected );
-	}
-
-	describe( 'breakAt', () => {
-		it( 'should not break text nodes if they are not in attribute elements - middle', () => {
-			test(
-				'<container:p>foo{}bar</container:p>',
-				'<container:p>foo{}bar</container:p>'
-			);
-		} );
-
-		it( 'should not break text nodes if they are not in attribute elements - beginning', () => {
-			test(
-				'<container:p>{}foobar</container:p>',
-				'<container:p>{}foobar</container:p>'
-			);
-		} );
-
-		it( 'should not break text nodes if they are not in attribute elements #2 - end', () => {
-			test(
-				'<container:p>foobar{}</container:p>',
-				'<container:p>foobar{}</container:p>'
-			);
-		} );
-
-		it( 'should split attribute element', () => {
-			test(
-				'<container:p><attribute:b view-priority="1">foo{}bar</attribute:b></container:p>',
-				'<container:p>' +
-					'<attribute:b view-priority="1">foo</attribute:b>[]<attribute:b view-priority="1">bar</attribute:b>' +
-				'</container:p>'
-			);
-		} );
-
-		it( 'should move from beginning of the nested text node to the container', () => {
-			test(
-				'<container:p>' +
-					'<attribute:b view-priority="1"><attribute:u view-priority="1">{}foobar</attribute:u></attribute:b>' +
-				'</container:p>',
-				'<container:p>' +
-					'[]<attribute:b view-priority="1"><attribute:u view-priority="1">foobar</attribute:u></attribute:b>' +
-				'</container:p>'
-			);
-		} );
-
-		it( 'should stick selection in text node if it is in container', () => {
-			test(
-				'<container:p>foo{}<attribute:b view-priority="1">bar</attribute:b></container:p>',
-				'<container:p>foo{}<attribute:b view-priority="1">bar</attribute:b></container:p>'
-			);
-		} );
-
-		it( 'should split nested attributes', () => {
-			test(
-				'<container:p>' +
-					'<attribute:b view-priority="1"><attribute:u view-priority="1">foo{}bar</attribute:u></attribute:b>' +
-				'</container:p>',
-				'<container:p>' +
-					'<attribute:b view-priority="1">' +
-						'<attribute:u view-priority="1">' +
-							'foo' +
-						'</attribute:u>' +
-					'</attribute:b>' +
-					'[]' +
-					'<attribute:b view-priority="1">' +
-						'<attribute:u view-priority="1">' +
-							'bar' +
-						'</attribute:u>' +
-					'</attribute:b>' +
-				'</container:p>'
-			);
-		} );
-
-		it( 'should move from end of the nested text node to the container', () => {
-			test(
-				'<container:p>' +
-					'<attribute:b view-priority="1"><attribute:u view-priority="1">foobar{}</attribute:u></attribute:b>' +
-				'</container:p>',
-				'<container:p>' +
-					'<attribute:b view-priority="1"><attribute:u view-priority="1">foobar</attribute:u></attribute:b>[]' +
-				'</container:p>'
-			);
-		} );
-
-		it( 'should split attribute element directly in document fragment', () => {
-			test(
-				'<attribute:b view-priority="1">foo{}bar</attribute:b>',
-				'<attribute:b view-priority="1">foo</attribute:b>[]<attribute:b view-priority="1">bar</attribute:b>'
-			);
-		} );
-
-		it( 'should not split text directly in document fragment', () => {
-			test(
-				'foo{}bar',
-				'foo{}bar'
-			);
-		} );
-	} );
-} );

+ 232 - 0
packages/ckeditor5-engine/tests/view/writer/breakAttributes.js

@@ -0,0 +1,232 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: view, browser-only */
+
+import { breakAttributes } from '/ckeditor5/engine/view/writer.js';
+import { stringify, parse } from '/tests/engine/_utils/view.js';
+import ContainerElement from '/ckeditor5/engine/view/containerelement.js';
+import AttributeElement from '/ckeditor5/engine/view/attributeelement.js';
+import Range from '/ckeditor5/engine/view/range.js';
+import CKEditorError from '/ckeditor5/utils/ckeditorerror.js';
+
+describe( 'writer', () => {
+	describe( 'breakAttributes', () => {
+		describe( 'break position', () => {
+			/**
+			 * Executes test using `parse` and `stringify` utils functions. Uses range delimiters `[]{}` to create and
+			 * test break position.
+			 *
+			 * @param {String} input
+			 * @param {String} expected
+			 */
+			function test( input, expected ) {
+				let { view, selection } = parse( input );
+
+				const newPosition = breakAttributes( selection.getFirstPosition() );
+				expect( stringify( view.root, newPosition, {
+					showType: true,
+					showPriority: true
+				} ) ).to.equal( expected );
+			}
+
+			it( 'should not break text nodes if they are not in attribute elements - middle', () => {
+				test(
+					'<container:p>foo{}bar</container:p>',
+					'<container:p>foo{}bar</container:p>'
+				);
+			} );
+
+			it( 'should not break text nodes if they are not in attribute elements - beginning', () => {
+				test(
+					'<container:p>{}foobar</container:p>',
+					'<container:p>{}foobar</container:p>'
+				);
+			} );
+
+			it( 'should not break text nodes if they are not in attribute elements #2 - end', () => {
+				test(
+					'<container:p>foobar{}</container:p>',
+					'<container:p>foobar{}</container:p>'
+				);
+			} );
+
+			it( 'should split attribute element', () => {
+				test(
+					'<container:p><attribute:b view-priority="1">foo{}bar</attribute:b></container:p>',
+					'<container:p>' +
+					'<attribute:b view-priority="1">foo</attribute:b>[]<attribute:b view-priority="1">bar</attribute:b>' +
+					'</container:p>'
+				);
+			} );
+
+			it( 'should move from beginning of the nested text node to the container', () => {
+				test(
+					'<container:p>' +
+					'<attribute:b view-priority="1"><attribute:u view-priority="1">{}foobar</attribute:u></attribute:b>' +
+					'</container:p>',
+					'<container:p>' +
+					'[]<attribute:b view-priority="1"><attribute:u view-priority="1">foobar</attribute:u></attribute:b>' +
+					'</container:p>'
+				);
+			} );
+
+			it( 'should stick selection in text node if it is in container', () => {
+				test(
+					'<container:p>foo{}<attribute:b view-priority="1">bar</attribute:b></container:p>',
+					'<container:p>foo{}<attribute:b view-priority="1">bar</attribute:b></container:p>'
+				);
+			} );
+
+			it( 'should split nested attributes', () => {
+				test(
+					'<container:p>' +
+					'<attribute:b view-priority="1"><attribute:u view-priority="1">foo{}bar</attribute:u></attribute:b>' +
+					'</container:p>',
+					'<container:p>' +
+					'<attribute:b view-priority="1">' +
+					'<attribute:u view-priority="1">' +
+					'foo' +
+					'</attribute:u>' +
+					'</attribute:b>' +
+					'[]' +
+					'<attribute:b view-priority="1">' +
+					'<attribute:u view-priority="1">' +
+					'bar' +
+					'</attribute:u>' +
+					'</attribute:b>' +
+					'</container:p>'
+				);
+			} );
+
+			it( 'should move from end of the nested text node to the container', () => {
+				test(
+					'<container:p>' +
+					'<attribute:b view-priority="1"><attribute:u view-priority="1">foobar{}</attribute:u></attribute:b>' +
+					'</container:p>',
+					'<container:p>' +
+					'<attribute:b view-priority="1"><attribute:u view-priority="1">foobar</attribute:u></attribute:b>[]' +
+					'</container:p>'
+				);
+			} );
+
+			it( 'should split attribute element directly in document fragment', () => {
+				test(
+					'<attribute:b view-priority="1">foo{}bar</attribute:b>',
+					'<attribute:b view-priority="1">foo</attribute:b>[]<attribute:b view-priority="1">bar</attribute:b>'
+				);
+			} );
+
+			it( 'should not split text directly in document fragment', () => {
+				test(
+					'foo{}bar',
+					'foo{}bar'
+				);
+			} );
+		} );
+
+		describe( 'break range', () => {
+			/**
+			 * Executes test using `parse` and `stringify` utils functions.
+			 *
+			 * @param {String} input
+			 * @param {String} expected
+			 */
+			function test( input, expected ) {
+				let { view, selection } = parse( input );
+
+				const newRange = breakAttributes( selection.getFirstRange() );
+				expect( stringify( view.root, newRange, { showType: true } ) ).to.equal( expected );
+			}
+
+			it( 'should throw when range placed in two containers', () => {
+				const p1 = new ContainerElement( 'p' );
+				const p2 = new ContainerElement( 'p' );
+
+				expect( () => {
+					breakAttributes( Range.createFromParentsAndOffsets( p1, 0, p2, 0 ) );
+				} ).to.throw( CKEditorError, 'view-writer-invalid-range-container' );
+			} );
+
+			it( 'should throw when range has no parent container', () => {
+				const el = new AttributeElement( 'b' );
+
+				expect( () => {
+					breakAttributes( Range.createFromParentsAndOffsets( el, 0, el, 0 ) );
+				} ).to.throw( CKEditorError, 'view-writer-invalid-range-container' );
+			} );
+
+			it( 'should not break text nodes if they are not in attribute elements', () => {
+				test(
+					'<container:p>foo{}bar</container:p>',
+					'<container:p>foo{}bar</container:p>'
+				);
+			} );
+
+			it( 'should break at collapsed range and return collapsed one', () => {
+				test(
+					'<container:p><attribute:b>foo{}bar</attribute:b></container:p>',
+					'<container:p><attribute:b>foo</attribute:b>[]<attribute:b>bar</attribute:b></container:p>'
+				);
+			} );
+
+			it( 'should break inside text node #1', () => {
+				test(
+					'<container:p><attribute:b>foo{bar}baz</attribute:b></container:p>',
+					'<container:p><attribute:b>foo</attribute:b>[<attribute:b>bar</attribute:b>]<attribute:b>baz</attribute:b></container:p>'
+				);
+			} );
+
+			it( 'should break inside text node #2', () => {
+				test(
+					'<container:p><attribute:b>foo{barbaz}</attribute:b></container:p>',
+					'<container:p><attribute:b>foo</attribute:b>[<attribute:b>barbaz</attribute:b>]</container:p>'
+				);
+			} );
+
+			it( 'should break inside text node #3', () => {
+				test(
+					'<container:p><attribute:b>foo{barbaz]</attribute:b></container:p>',
+					'<container:p><attribute:b>foo</attribute:b>[<attribute:b>barbaz</attribute:b>]</container:p>'
+				);
+			} );
+
+			it( 'should break inside text node #4', () => {
+				test(
+					'<container:p><attribute:b>{foo}barbaz</attribute:b></container:p>',
+					'<container:p>[<attribute:b>foo</attribute:b>]<attribute:b>barbaz</attribute:b></container:p>'
+				);
+			} );
+
+			it( 'should break inside text node #5', () => {
+				test(
+					'<container:p><attribute:b>[foo}barbaz</attribute:b></container:p>',
+					'<container:p>[<attribute:b>foo</attribute:b>]<attribute:b>barbaz</attribute:b></container:p>'
+				);
+			} );
+
+			it( 'should break placed inside different nodes', () => {
+				test(
+					'<container:p>foo{bar<attribute:b>baz}qux</attribute:b></container:p>',
+					'<container:p>foo{bar<attribute:b>baz</attribute:b>]<attribute:b>qux</attribute:b></container:p>'
+				);
+			} );
+
+			it( 'should split attribute element directly in document fragment', () => {
+				test(
+					'<attribute:b>fo{ob}ar</attribute:b>',
+					'<attribute:b>fo</attribute:b>[<attribute:b>ob</attribute:b>]<attribute:b>ar</attribute:b>'
+				);
+			} );
+
+			it( 'should not split text directly in document fragment', () => {
+				test(
+					'foo{}bar',
+					'foo{}bar'
+				);
+			} );
+		} );
+	} );
+} );

+ 80 - 0
packages/ckeditor5-engine/tests/view/writer/breakContainer.js

@@ -0,0 +1,80 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: view, browser-only */
+
+import { breakContainer } from '/ckeditor5/engine/view/writer.js';
+import { stringify, parse } from '/tests/engine/_utils/view.js';
+import CKEditorError from '/ckeditor5/utils/ckeditorerror.js';
+import ContainerElement from '/ckeditor5/engine/view/containerelement.js';
+import Position from '/ckeditor5/engine/view/position.js';
+
+describe( 'writer', () => {
+	/**
+	 * Executes test using `parse` and `stringify` utils functions. Uses range delimiters `[]{}` to create and
+	 * test break position.
+	 *
+	 * @param {String} input
+	 * @param {String} expected
+	 */
+	function test( input, expected ) {
+		let { view, selection } = parse( input );
+
+		const newPosition = breakContainer( selection.getFirstPosition() );
+		expect( stringify( view.root, newPosition, { showType: true, showPriority: false } ) ).to.equal( expected );
+	}
+
+	describe( 'breakContainer', () => {
+		it( 'break inside element - should break container element at given position', () => {
+			test(
+				'<container:div>' +
+					'<container:p>' +
+						'<attribute:b>foo</attribute:b>[]<attribute:u>bar</attribute:u>' +
+					'</container:p>' +
+				'</container:div>',
+
+				'<container:div>' +
+					'<container:p>' +
+						'<attribute:b>foo</attribute:b>' +
+					'</container:p>' +
+					'[]<container:p>' +
+						'<attribute:u>bar</attribute:u>' +
+					'</container:p>' +
+				'</container:div>'
+			);
+		} );
+
+		it( 'break at start of element - should not break container and place returned position before element', () => {
+			test(
+				'<container:div><container:p>[]foobar</container:p></container:div>',
+				'<container:div>[]<container:p>foobar</container:p></container:div>'
+			);
+		} );
+
+		it( 'break at the end of element - should not break container and place returned position after element', () => {
+			test(
+				'<container:div><container:p>foobar[]</container:p></container:div>',
+				'<container:div><container:p>foobar</container:p>[]</container:div>'
+			);
+		} );
+
+		it( 'should throw if position parent is not container', () => {
+			let { selection } = parse( '<container:div>foo{}bar</container:div>' );
+
+			expect( () => {
+				breakContainer( selection.getFirstPosition() );
+			} ).to.throw( CKEditorError, /view-writer-break-non-container-element/ );
+		} );
+
+		it( 'should throw if position parent is root', () => {
+			const element = new ContainerElement( 'div' );
+			const position = Position.createAt( element, 0 );
+
+			expect( () => {
+				breakContainer( position );
+			} ).to.throw( CKEditorError, /view-writer-break-root/ );
+		} );
+	} );
+} );

+ 0 - 117
packages/ckeditor5-engine/tests/view/writer/breakrange.js

@@ -1,117 +0,0 @@
-/**
- * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
-
-/* bender-tags: view, browser-only */
-
-import { breakRange } from '/ckeditor5/engine/view/writer.js';
-import ContainerElement from '/ckeditor5/engine/view/containerelement.js';
-import AttributeElement from '/ckeditor5/engine/view/attributeelement.js';
-import Range from '/ckeditor5/engine/view/range.js';
-import { stringify, parse } from '/tests/engine/_utils/view.js';
-import CKEditorError from '/ckeditor5/utils/ckeditorerror.js';
-
-describe( 'writer', () => {
-	/**
-	 * Executes test using `parse` and `stringify` utils functions.
-	 *
-	 * @param {String} input
-	 * @param {String} expected
-	 */
-	function test( input, expected ) {
-		let { view, selection } = parse( input );
-
-		const newRange = breakRange( selection.getFirstRange() );
-		expect( stringify( view.root, newRange, { showType: true } ) ).to.equal( expected );
-	}
-
-	describe( 'breakRange', () => {
-		it( 'should throw when range placed in two containers', () => {
-			const p1 = new ContainerElement( 'p' );
-			const p2 = new ContainerElement( 'p' );
-
-			expect( () => {
-				breakRange( Range.createFromParentsAndOffsets( p1, 0, p2, 0 ) );
-			} ).to.throw( CKEditorError, 'view-writer-invalid-range-container' );
-		} );
-
-		it( 'should throw when range has no parent container', () => {
-			const el = new AttributeElement( 'b' );
-
-			expect( () => {
-				breakRange( Range.createFromParentsAndOffsets( el, 0, el, 0 ) );
-			} ).to.throw( CKEditorError, 'view-writer-invalid-range-container' );
-		} );
-
-		it( 'should not break text nodes if they are not in attribute elements', () => {
-			test(
-				'<container:p>foo{}bar</container:p>',
-				'<container:p>foo{}bar</container:p>'
-			);
-		} );
-
-		it( 'should break at collapsed range and return collapsed one', () => {
-			test(
-				'<container:p><attribute:b>foo{}bar</attribute:b></container:p>',
-				'<container:p><attribute:b>foo</attribute:b>[]<attribute:b>bar</attribute:b></container:p>'
-			);
-		} );
-
-		it( 'should break inside text node #1', () => {
-			test(
-				'<container:p><attribute:b>foo{bar}baz</attribute:b></container:p>',
-				'<container:p><attribute:b>foo</attribute:b>[<attribute:b>bar</attribute:b>]<attribute:b>baz</attribute:b></container:p>'
-			);
-		} );
-
-		it( 'should break inside text node #2', () => {
-			test(
-				'<container:p><attribute:b>foo{barbaz}</attribute:b></container:p>',
-				'<container:p><attribute:b>foo</attribute:b>[<attribute:b>barbaz</attribute:b>]</container:p>'
-			);
-		} );
-
-		it( 'should break inside text node #3', () => {
-			test(
-				'<container:p><attribute:b>foo{barbaz]</attribute:b></container:p>',
-				'<container:p><attribute:b>foo</attribute:b>[<attribute:b>barbaz</attribute:b>]</container:p>'
-			);
-		} );
-
-		it( 'should break inside text node #4', () => {
-			test(
-				'<container:p><attribute:b>{foo}barbaz</attribute:b></container:p>',
-				'<container:p>[<attribute:b>foo</attribute:b>]<attribute:b>barbaz</attribute:b></container:p>'
-			);
-		} );
-
-		it( 'should break inside text node #5', () => {
-			test(
-				'<container:p><attribute:b>[foo}barbaz</attribute:b></container:p>',
-				'<container:p>[<attribute:b>foo</attribute:b>]<attribute:b>barbaz</attribute:b></container:p>'
-			);
-		} );
-
-		it( 'should break placed inside different nodes', () => {
-			test(
-				'<container:p>foo{bar<attribute:b>baz}qux</attribute:b></container:p>',
-				'<container:p>foo{bar<attribute:b>baz</attribute:b>]<attribute:b>qux</attribute:b></container:p>'
-			);
-		} );
-
-		it( 'should split attribute element directly in document fragment', () => {
-			test(
-				'<attribute:b>fo{ob}ar</attribute:b>',
-				'<attribute:b>fo</attribute:b>[<attribute:b>ob</attribute:b>]<attribute:b>ar</attribute:b>'
-			);
-		} );
-
-		it( 'should not split text directly in document fragment', () => {
-			test(
-				'foo{}bar',
-				'foo{}bar'
-			);
-		} );
-	} );
-} );

+ 4 - 4
packages/ckeditor5-engine/tests/view/writer/mergeAt.js → packages/ckeditor5-engine/tests/view/writer/mergeAttributes.js

@@ -5,7 +5,7 @@
 
 /* bender-tags: view, browser-only */
 
-import { mergeAt } from '/ckeditor5/engine/view/writer.js';
+import { mergeAttributes } from '/ckeditor5/engine/view/writer.js';
 import ContainerElement from '/ckeditor5/engine/view/containerelement.js';
 import Text from '/ckeditor5/engine/view/text.js';
 import Position from '/ckeditor5/engine/view/position.js';
@@ -21,11 +21,11 @@ describe( 'writer', () => {
 	 */
 	function test( input, expected ) {
 		const { view, selection } = parse( input );
-		const newPosition = mergeAt( selection.getFirstPosition() );
+		const newPosition = mergeAttributes( selection.getFirstPosition() );
 		expect( stringify( view, newPosition, { showType: true, showPriority: true } ) ).to.equal( expected );
 	}
 
-	describe( 'mergeAt', () => {
+	describe( 'mergeAttributes', () => {
 		it( 'should not merge if inside text node', () => {
 			test( '<container:p>fo{}bar</container:p>', '<container:p>fo{}bar</container:p>' );
 		} );
@@ -65,7 +65,7 @@ describe( 'writer', () => {
 			const p = new ContainerElement( 'p', null, [ t1, t2 ] );
 			const position = new Position( p, 1 );
 
-			const newPosition = mergeAt( position );
+			const newPosition = mergeAttributes( position );
 			expect( stringify( p, newPosition ) ).to.equal( '<p>foo{}bar</p>' );
 		} );
 

+ 87 - 0
packages/ckeditor5-engine/tests/view/writer/mergeContainers.js

@@ -0,0 +1,87 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: view, browser-only */
+
+import { mergeContainers } from '/ckeditor5/engine/view/writer.js';
+import { stringify, parse } from '/tests/engine/_utils/view.js';
+import CKEditorError from '/ckeditor5/utils/ckeditorerror.js';
+
+describe( 'writer', () => {
+	/**
+	 * Executes test using `parse` and `stringify` utils functions. Uses range delimiters `[]{}` to create and
+	 * test break position.
+	 *
+	 * @param {String} input
+	 * @param {String} expected
+	 */
+	function test( input, expected ) {
+		let { view, selection } = parse( input );
+
+		const newPosition = mergeContainers( selection.getFirstPosition() );
+		expect( stringify( view.root, newPosition, { showType: true, showPriority: false } ) ).to.equal( expected );
+	}
+
+	describe( 'mergeContainers', () => {
+		it( 'should merge two container elements - position between elements', () => {
+			test(
+				'<container:div>' +
+					'<attribute:b>foo</attribute:b>' +
+				'</container:div>' +
+				'[]<container:div>' +
+					'<attribute:u>bar</attribute:u>' +
+				'</container:div>',
+
+				'<container:div><attribute:b>foo</attribute:b>[]<attribute:u>bar</attribute:u></container:div>'
+			);
+		} );
+
+		it( 'should merge two container elements - position in text', () => {
+			test(
+				'<container:div>foo</container:div>[]<container:div>bar</container:div>',
+				'<container:div>foo{}bar</container:div>'
+			);
+		} );
+
+		it( 'should merge two different container elements', () => {
+			test(
+				'<container:div>foo</container:div>[]<container:p>bar</container:p>',
+				'<container:div>foo{}bar</container:div>'
+			);
+		} );
+
+		it( 'should throw if there is no element before position', () => {
+			let { selection } = parse( '[]<container:div>foobar</container:div>' );
+
+			expect( () => {
+				mergeContainers( selection.getFirstPosition() );
+			} ).to.throw( CKEditorError, /view-writer-merge-containers-invalid-position/ );
+		} );
+
+		it( 'should throw if there is no element after position', () => {
+			let { selection } = parse( '<container:div>foobar</container:div>[]' );
+
+			expect( () => {
+				mergeContainers( selection.getFirstPosition() );
+			} ).to.throw( CKEditorError, /view-writer-merge-containers-invalid-position/ );
+		} );
+
+		it( 'should throw if element before position is not a container element', () => {
+			let { selection } = parse( '<attribute:u>foo</attribute:u>[]<container:div>bar</container:div>' );
+
+			expect( () => {
+				mergeContainers( selection.getFirstPosition() );
+			} ).to.throw( CKEditorError, /view-writer-merge-containers-invalid-position/ );
+		} );
+
+		it( 'should throw if element after position is not a container element', () => {
+			let { selection } = parse( '<container:div>foo</container:div>[]<attribute:u>bar</attribute:u>' );
+
+			expect( () => {
+				mergeContainers( selection.getFirstPosition() );
+			} ).to.throw( CKEditorError, /view-writer-merge-containers-invalid-position/ );
+		} );
+	} );
+} );