浏览代码

Changed: Aligned code to correctly use new `model.Differ` and refactored `conversion.ModelConversionDispatcher`.

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

+ 1 - 1
packages/ckeditor5-engine/src/controller/datacontroller.js

@@ -167,7 +167,7 @@ export default class DataController {
 		const viewDocumentFragment = new ViewDocumentFragment();
 		this.mapper.bindElements( modelElementOrFragment, viewDocumentFragment );
 
-		this.modelToView.convertInsertion( modelRange );
+		this.modelToView.convertInsert( modelRange );
 
 		this.mapper.clearBindings();
 

+ 43 - 19
packages/ckeditor5-engine/src/controller/editingcontroller.js

@@ -7,6 +7,7 @@
  * @module engine/controller/editingcontroller
  */
 
+import ModelDiffer from '../model/differ';
 import ViewDocument from '../view/document';
 import Mapper from '../conversion/mapper';
 import ModelConversionDispatcher from '../conversion/modelconversiondispatcher';
@@ -64,10 +65,9 @@ export default class EditingController {
 		this.mapper = new Mapper();
 
 		/**
-		 * Model to view conversion dispatcher, which converts changes from the model to
-		 * {@link #view editing view}.
+		 * Model to view conversion dispatcher, which converts changes from the model to {@link #view the editing view}.
 		 *
-		 * To attach model to view converter to the editing pipeline you need to add lister to this property:
+		 * To attach model-to-view converter to the editing pipeline you need to add a listener to this dispatcher:
 		 *
 		 *		editing.modelToView( 'insert:$element', customInsertConverter );
 		 *
@@ -83,36 +83,60 @@ export default class EditingController {
 			viewSelection: this.view.selection
 		} );
 
-		// Convert changes in model to view.
-		this.listenTo( this.model.document, 'change', ( evt, type, changes ) => {
-			this.modelToView.convertChange( type, changes );
-		}, { priority: 'low' } );
+		// Model differ object. It's role is to buffer changes done on model and then calculates a diff of those changes.
+		// The diff is then passed to model conversion dispatcher which generates proper events and kicks-off conversion.
+		const modelDiffer = new ModelDiffer();
 
-		// Convert model selection to view.
-		this.listenTo( this.model.document, 'changesDone', () => {
-			const selection = this.model.document.selection;
+		// Before an operation is applied on model, buffer the change in differ.
+		this.listenTo( this.model, 'applyOperation', ( evt, args ) => {
+			const operation = args[ 0 ];
 
-			this.modelToView.convertSelection( selection );
-			this.view.render();
-		}, { priority: 'low' } );
+			if ( operation.isDocumentOperation ) {
+				modelDiffer.bufferOperation( operation );
+			}
+		}, { priority: 'high' } );
 
-		// Convert model markers changes.
+		// Buffer marker changes.
+		// This is not covered in buffering operations because markers may change outside of them (when they
+		// are modified using `model.document.markers` collection, not through `MarkerOperation`).
 		this.listenTo( this.model.markers, 'add', ( evt, marker ) => {
-			this.modelToView.convertMarker( 'addMarker', marker.name, marker.getRange() );
+			// Whenever a new marker is added, buffer that change.
+			modelDiffer.bufferMarkerChange( marker.name, null, marker.getRange() );
+
+			// Whenever marker changes, buffer that.
+			marker.on( 'change', ( evt, oldRange ) => {
+				modelDiffer.bufferMarkerChange( marker.name, oldRange, marker.getRange() );
+			} );
 		} );
 
 		this.listenTo( this.model.markers, 'remove', ( evt, marker ) => {
-			this.modelToView.convertMarker( 'removeMarker', marker.name, marker.getRange() );
+			// Whenever marker is removed, buffer that change.
+			modelDiffer.bufferMarkerChange( marker.name, marker.getRange(), null );
 		} );
 
-		// Convert view selection to model.
+		// When all changes are done, get the model diff containing all the changes and convert them to view and then render to DOM.
+		this.listenTo( this.model, 'changesDone', () => {
+			// Convert changes stored in `modelDiffer`.
+			this.modelToView.convertChanges( modelDiffer );
+
+			// Reset model diff object. When next operation is applied, new diff will be created.
+			modelDiffer.reset();
+
+			// After the view is ready, convert selection from model to view.
+			this.modelToView.convertSelection( this.model.document.selection );
+
+			// When everything is converted to the view, render it to DOM.
+			this.view.render();
+		}, { priority: 'low' } );
+
+		// Convert selection from view to model.
 		this.listenTo( this.view, 'selectionChange', convertSelectionChange( this.model, this.mapper ) );
 
-		// Attach default content converters.
+		// Attach default model converters.
 		this.modelToView.on( 'insert:$text', insertText(), { priority: 'lowest' } );
 		this.modelToView.on( 'remove', remove(), { priority: 'low' } );
 
-		// Attach default selection converters.
+		// Attach default model selection converters.
 		this.modelToView.on( 'selection', clearAttributes(), { priority: 'low' } );
 		this.modelToView.on( 'selection', clearFakeSelection(), { priority: 'low' } );
 		this.modelToView.on( 'selection', convertRangeSelection(), { priority: 'low' } );

+ 11 - 18
packages/ckeditor5-engine/src/conversion/buildmodelconverter.js

@@ -10,13 +10,12 @@
 import {
 	insertElement,
 	insertUIElement,
-	setAttribute,
-	removeAttribute,
 	removeUIElement,
-	wrapItem,
-	unwrapItem,
+	changeAttribute,
+	wrap,
 	highlightText,
-	highlightElement
+	highlightElement,
+	removeHighlight
 } from './model-to-view-converters';
 
 import { convertSelectionAttribute, convertSelectionMarker } from './model-selection-to-view-converters';
@@ -254,15 +253,13 @@ class ModelConverterBuilder {
 
 				dispatcher.on( 'insert:' + this._from.name, insertElement( element ), { priority } );
 			} else if ( this._from.type == 'attribute' ) {
-				// From model attribute to view element -> wrap and unwrap.
+				// From model attribute to view element -> wrap.
 				element = typeof element == 'string' ? new ViewAttributeElement( element ) : element;
 
-				dispatcher.on( 'addAttribute:' + this._from.key, wrapItem( element ), { priority } );
-				dispatcher.on( 'changeAttribute:' + this._from.key, wrapItem( element ), { priority } );
-				dispatcher.on( 'removeAttribute:' + this._from.key, unwrapItem( element ), { priority } );
-
+				dispatcher.on( 'attribute:' + this._from.key, wrap( element ), { priority } );
 				dispatcher.on( 'selectionAttribute:' + this._from.key, convertSelectionAttribute( element ), { priority } );
-			} else { // From marker to element.
+			} else {
+				// From marker to element.
 				const priority = this._from.priority === null ? 'normal' : this._from.priority;
 
 				element = typeof element == 'string' ? new ViewUIElement( element ) : element;
@@ -326,12 +323,10 @@ class ModelConverterBuilder {
 		}
 
 		for ( const dispatcher of this._dispatchers ) {
-			// Separate converters for converting texts and elements inside marker's range.
 			dispatcher.on( 'addMarker:' + this._from.name, highlightText( highlightDescriptor ), { priority } );
 			dispatcher.on( 'addMarker:' + this._from.name, highlightElement( highlightDescriptor ), { priority } );
 
-			dispatcher.on( 'removeMarker:' + this._from.name, highlightText( highlightDescriptor ), { priority } );
-			dispatcher.on( 'removeMarker:' + this._from.name, highlightElement( highlightDescriptor ), { priority } );
+			dispatcher.on( 'removeMarker:' + this._from.name, removeHighlight( highlightDescriptor ), { priority } );
 
 			dispatcher.on( 'selectionMarker:' + this._from.name, convertSelectionMarker( highlightDescriptor ), { priority } );
 		}
@@ -383,7 +378,7 @@ class ModelConverterBuilder {
 
 		if ( !keyOrCreator ) {
 			// If `keyOrCreator` is not set, we assume default behavior which is 1:1 attribute re-write.
-			// This is also a default behavior for `setAttribute` converter when no attribute creator is passed.
+			// This is also a default behavior for `changeAttribute` converter when no attribute creator is passed.
 			attributeCreator = undefined;
 		} else if ( typeof keyOrCreator == 'string' ) {
 			// `keyOrCreator` is an attribute key.
@@ -407,9 +402,7 @@ class ModelConverterBuilder {
 		for ( const dispatcher of this._dispatchers ) {
 			const options = { priority: this._from.priority || 'normal' };
 
-			dispatcher.on( 'addAttribute:' + this._from.key, setAttribute( attributeCreator ), options );
-			dispatcher.on( 'changeAttribute:' + this._from.key, setAttribute( attributeCreator ), options );
-			dispatcher.on( 'removeAttribute:' + this._from.key, removeAttribute( attributeCreator ), options );
+			dispatcher.on( 'attribute:' + this._from.key, changeAttribute( attributeCreator ), options );
 		}
 	}
 }

+ 208 - 279
packages/ckeditor5-engine/src/conversion/model-to-view-converters.js

@@ -3,17 +3,16 @@
  * For licensing, see LICENSE.md.
  */
 
+import ModelRange from '../model/range';
+
 import ViewElement from '../view/element';
 import ViewAttributeElement from '../view/attributeelement';
 import ViewText from '../view/text';
 import ViewRange from '../view/range';
-import ViewPosition from '../view/position';
-import ViewTreeWalker from '../view/treewalker';
 import viewWriter from '../view/writer';
-import ModelRange from '../model/range';
 
 /**
- * Contains {@link module:engine/model/model model} to {@link module:engine/view/view view} converters for
+ * Contains model to view converters for
  * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher}.
  *
  * @module engine/conversion/model-to-view-converters
@@ -92,6 +91,34 @@ export function insertText() {
 }
 
 /**
+ * Function factory, creates a default model-to-view converter for node remove changes.
+ *
+ *		modelDispatcher.on( 'remove', remove() );
+ *
+ * @returns {Function} Remove event converter.
+ */
+export function remove() {
+	return ( evt, data, conversionApi ) => {
+		// Find view range start position by mapping model position at which the remove happened.
+		const viewStart = conversionApi.mapper.toViewPosition( data.position );
+
+		const modelEnd = data.position.getShiftedBy( data.length );
+		const viewEnd = conversionApi.mapper.toViewPosition( modelEnd, true );
+
+		const viewRange = new ViewRange( viewStart, viewEnd );
+
+		// Trim the range to remove in case some UI elements are on the view range boundaries.
+		const removed = viewWriter.remove( viewRange.getTrimmed() );
+
+		// After the range is removed, unbind all view elements from the model.
+		// Range inside view document fragment is used to unbind deeply.
+		for ( const child of ViewRange.createIn( removed ).getItems() ) {
+			conversionApi.mapper.unbindViewElement( child );
+		}
+	};
+}
+
+/**
  * Function factory, creates a converter that converts marker adding change to the view ui element.
  * The view ui element that will be added to the view depends on passed parameter. See {@link ~insertElement}.
  * In a case of collapsed range element will not wrap range but separate elements will be placed at the beginning
@@ -108,15 +135,17 @@ export function insertUIElement( elementCreator ) {
 	return ( evt, data, consumable, conversionApi ) => {
 		let viewStartElement, viewEndElement;
 
+		// Create two view elements. One will be inserted at the beginning of marker, one at the end.
+		// If marker is collapsed, only "opening" element will be inserted.
 		if ( elementCreator instanceof ViewElement ) {
 			viewStartElement = elementCreator.clone( true );
 			viewEndElement = elementCreator.clone( true );
 		} else {
 			data.isOpening = true;
-			viewStartElement = elementCreator( data, consumable, conversionApi );
+			viewStartElement = elementCreator( data, conversionApi );
 
 			data.isOpening = false;
-			viewEndElement = elementCreator( data, consumable, conversionApi );
+			viewEndElement = elementCreator( data, conversionApi );
 		}
 
 		if ( !viewStartElement || !viewEndElement ) {
@@ -124,91 +153,77 @@ export function insertUIElement( elementCreator ) {
 		}
 
 		const markerRange = data.markerRange;
-		const eventName = evt.name;
-
-		// Marker that is collapsed has consumable build differently that non-collapsed one.
-		// For more information see `addMarker` and `removeMarker` events description.
-		// If marker's range is collapsed - check if it can be consumed.
-		if ( markerRange.isCollapsed && !consumable.consume( markerRange, eventName ) ) {
-			return;
-		}
-
-		// If marker's range is not collapsed - consume all items inside.
-		for ( const value of markerRange ) {
-			if ( !consumable.consume( value.item, eventName ) ) {
-				return;
-			}
-		}
-
 		const mapper = conversionApi.mapper;
 
+		// Add "opening" element.
 		viewWriter.insert( mapper.toViewPosition( markerRange.start ), viewStartElement );
 
+		// Add "closing" element only if range is not collapsed.
 		if ( !markerRange.isCollapsed ) {
 			viewWriter.insert( mapper.toViewPosition( markerRange.end ), viewEndElement );
 		}
+
+		evt.stop();
 	};
 }
 
 /**
- * Function factory, creates a converter that converts set/change attribute changes from the model to the view. Attributes
- * from model are converted to the view element attributes in the view. You may provide a custom function to generate a
- * key-value attribute pair to add/change. If not provided, model attributes will be converted to view elements attributes
- * on 1-to-1 basis.
- *
- * **Note:** Provided attribute creator should always return the same `key` for given attribute from the model.
- *
- * The converter automatically consumes corresponding value from consumables list and stops the event (see
- * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher}).
- *
- *		modelDispatcher.on( 'addAttribute:customAttr:myElem', setAttribute( ( data ) => {
- *			// Change attribute key from `customAttr` to `class` in view.
- *			const key = 'class';
- *			let value = data.attributeNewValue;
- *
- *			// Force attribute value to 'empty' if the model element is empty.
- *			if ( data.item.childCount === 0 ) {
- *				value = 'empty';
- *			}
- *
- *			// Return key-value pair.
- *			return { key, value };
- *		} ) );
+ * Function factory, creates a default model-to-view converter for removing {@link module:engine/view/uielement~UIElement ui element}
+ * basing on marker remove change.
  *
- * @param {Function} [attributeCreator] Function returning an object with two properties: `key` and `value`, which
- * represents attribute key and attribute value to be set on a {@link module:engine/view/element~Element view element}.
- * The function is passed all the parameters of the
- * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher#event:addAttribute}
- * or {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher#event:changeAttribute} event.
- * @returns {Function} Set/change attribute converter.
+ * @param {module:engine/view/uielement~UIElement|Function} elementCreator View ui element, or function returning
+ * a view ui element, which will be used as a pattern when look for element to remove at the marker start position.
+ * @returns {Function} Remove ui element converter.
  */
-export function setAttribute( attributeCreator ) {
-	attributeCreator = attributeCreator || ( ( value, key ) => ( { value, key } ) );
+export function removeUIElement( elementCreator ) {
+	return ( evt, data, conversionApi ) => {
+		let viewStartElement, viewEndElement;
 
-	return ( evt, data, consumable, conversionApi ) => {
-		if ( !consumable.consume( data.item, eventNameToConsumableType( evt.name ) ) ) {
+		// Create two view elements. One will be used to remove "opening element", the other for "closing element".
+		// If marker was collapsed, only "opening" element will be removed.
+		if ( elementCreator instanceof ViewElement ) {
+			viewStartElement = elementCreator.clone( true );
+			viewEndElement = elementCreator.clone( true );
+		} else {
+			data.isOpening = true;
+			viewStartElement = elementCreator( data, conversionApi );
+
+			data.isOpening = false;
+			viewEndElement = elementCreator( data, conversionApi );
+		}
+
+		if ( !viewStartElement || !viewEndElement ) {
 			return;
 		}
 
-		const { key, value } = attributeCreator( data.attributeNewValue, data.attributeKey, data, consumable, conversionApi );
+		const markerRange = data.markerRange;
+
+		// When removing the ui elements, we map the model range to view twice, because that view range
+		// may change after the first clearing.
+		if ( !markerRange.isCollapsed ) {
+			viewWriter.clear( conversionApi.mapper.toViewRange( markerRange ).getEnlarged(), viewEndElement );
+		}
+
+		// Remove "opening" element.
+		viewWriter.clear( conversionApi.mapper.toViewRange( markerRange ).getEnlarged(), viewStartElement );
 
-		conversionApi.mapper.toViewElement( data.item ).setAttribute( key, value );
+		evt.stop();
 	};
 }
 
 /**
- * Function factory, creates a converter that converts remove attribute changes from the model to the view. Removes attributes
- * that were converted to the view element attributes in the view. You may provide a custom function to generate a
- * key-value attribute pair to remove. If not provided, model attributes will be removed from view elements on 1-to-1 basis.
+ * Function factory, creates a converter that converts set/change/remove attribute changes from the model to the view.
  *
- * **Note:** Provided attribute creator should always return the same `key` for given attribute from the model.
+ * Attributes from model are converted to the view element attributes in the view. You may provide a custom function to generate
+ * a key-value attribute pair to add/change/remove. If not provided, model attributes will be converted to view elements
+ * attributes on 1-to-1 basis.
  *
- * **Note:** You can use the same attribute creator as in {@link module:engine/conversion/model-to-view-converters~setAttribute}.
+ * **Note:** Provided attribute creator should always return the same `key` for given attribute from the model.
  *
  * The converter automatically consumes corresponding value from consumables list and stops the event (see
  * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher}).
  *
- *		modelDispatcher.on( 'removeAttribute:customAttr:myElem', removeAttribute( ( data ) => {
+ *		modelDispatcher.on( 'attribute:customAttr:myElem', changeAttribute( ( data ) => {
  *			// Change attribute key from `customAttr` to `class` in view.
  *			const key = 'class';
  *			let value = data.attributeNewValue;
@@ -223,31 +238,35 @@ export function setAttribute( attributeCreator ) {
  *		} ) );
  *
  * @param {Function} [attributeCreator] Function returning an object with two properties: `key` and `value`, which
- * represents attribute key and attribute value to be removed from {@link module:engine/view/element~Element view element}.
+ * represents attribute key and attribute value to be set on a {@link module:engine/view/element~Element view element}.
  * The function is passed all the parameters of the
- * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher#event:addAttribute addAttribute event}
- * or {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher#event:changeAttribute changeAttribute event}.
- * @returns {Function} Remove attribute converter.
+ * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher#event:attribute} event.
+ * @returns {Function} Set/change attribute converter.
  */
-export function removeAttribute( attributeCreator ) {
-	attributeCreator = attributeCreator || ( ( value, key ) => ( { key } ) );
+export function changeAttribute( attributeCreator ) {
+	attributeCreator = attributeCreator || ( ( value, key ) => ( { value, key } ) );
 
 	return ( evt, data, consumable, conversionApi ) => {
 		if ( !consumable.consume( data.item, eventNameToConsumableType( evt.name ) ) ) {
 			return;
 		}
 
-		const { key } = attributeCreator( data.attributeOldValue, data.attributeKey, data, consumable, conversionApi );
+		const { key, value } = attributeCreator( data.attributeNewValue, data.attributeKey, data, consumable, conversionApi );
 
-		conversionApi.mapper.toViewElement( data.item ).removeAttribute( key );
+		if ( data.attributeNewValue !== null ) {
+			conversionApi.mapper.toViewElement( data.item ).setAttribute( key, value );
+		} else {
+			conversionApi.mapper.toViewElement( data.item ).removeAttribute( key );
+		}
 	};
 }
 
 /**
- * Function factory, creates a converter that converts set/change attribute changes from the model to the view. In this case,
- * model attributes are converted to a view element that will be wrapping view nodes which corresponding model nodes had
- * the attribute set. This is useful for attributes like `bold`, which may be set on text nodes in model but are
- * represented as an element in the view:
+ * Function factory, creates a converter that converts set/change/remove attribute changes from the model to the view.
+ *
+ * Attributes from model are converted to a view element that will be wrapping those view nodes that are bound to
+ * model elements having given attribute. This is useful for attributes like `bold`, which may be set on text nodes in model
+ * but are represented as an element in the view:
  *
  *		[paragraph]              MODEL ====> VIEW        <p>
  *			|- a {bold: true}                             |- <b>
@@ -256,7 +275,7 @@ export function removeAttribute( attributeCreator ) {
  *
  * The wrapping node depends on passed parameter. If {@link module:engine/view/element~Element} was passed, it will be cloned and
  * the copy will become the wrapping element. If `Function` is provided, it is passed attribute value and then all the parameters of the
- * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher#event:addAttribute addAttribute event}.
+ * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher#event:attribute attribute event}.
  * It's expected that the function returns a {@link module:engine/view/element~Element}.
  * The result of the function will be the wrapping element.
  * When provided `Function` does not return element, then will be no conversion.
@@ -264,19 +283,26 @@ export function removeAttribute( attributeCreator ) {
  * The converter automatically consumes corresponding value from consumables list, stops the event (see
  * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher}).
  *
- *		modelDispatcher.on( 'addAttribute:bold', wrapItem( new ViewAttributeElement( 'strong' ) ) );
+ *		modelDispatcher.on( 'attribute:bold', wrapItem( new ViewAttributeElement( 'strong' ) ) );
  *
  * @param {module:engine/view/element~Element|Function} elementCreator View element, or function returning a view element, which will
  * be used for wrapping.
  * @returns {Function} Set/change attribute converter.
  */
-export function wrapItem( elementCreator ) {
+export function wrap( elementCreator ) {
 	return ( evt, data, consumable, conversionApi ) => {
-		const viewElement = ( elementCreator instanceof ViewElement ) ?
+		// Recreate current wrapping node. It will be used to unwrap view range if the attribute value has changed
+		// or the attribute was removed.
+		const oldViewElement = ( elementCreator instanceof ViewElement ) ?
+			elementCreator.clone( true ) :
+			elementCreator( data.attributeOldValue, data, consumable, conversionApi );
+
+		// Create node to wrap with.
+		const newViewElement = ( elementCreator instanceof ViewElement ) ?
 			elementCreator.clone( true ) :
 			elementCreator( data.attributeNewValue, data, consumable, conversionApi );
 
-		if ( !viewElement ) {
+		if ( !oldViewElement && !newViewElement ) {
 			return;
 		}
 
@@ -286,167 +312,61 @@ export function wrapItem( elementCreator ) {
 
 		let viewRange = conversionApi.mapper.toViewRange( data.range );
 
-		// If this is a change event (because old value is not empty) and the creator is a function (so
-		// it may create different view elements basing on attribute value) we have to create
-		// view element basing on old value and unwrap it before wrapping with a newly created view element.
-		if ( data.attributeOldValue !== null && !( elementCreator instanceof ViewElement ) ) {
-			const oldViewElement = elementCreator( data.attributeOldValue, data, consumable, conversionApi );
+		// First, unwrap the range from current wrapper.
+		if ( data.attributeOldValue !== null ) {
 			viewRange = viewWriter.unwrap( viewRange, oldViewElement );
 		}
 
-		viewWriter.wrap( viewRange, viewElement );
-	};
-}
-
-/**
- * Function factory, creates a converter that converts remove attribute changes from the model to the view. It assumes, that
- * attributes from model were converted to elements in the view. This converter will unwrap view nodes from corresponding
- * view element if given attribute was removed.
- *
- * The view element type that will be unwrapped depends on passed parameter.
- * If {@link module:engine/view/element~Element} was passed, it will be used to look for similar element in the view for unwrapping.
- * If `Function` is provided, it is passed all the parameters of the
- * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher#event:addAttribute addAttribute event}.
- * It's expected that the function returns a {@link module:engine/view/element~Element}.
- * The result of the function will be used to look for similar element in the view for unwrapping.
- *
- * The converter automatically consumes corresponding value from consumables list, stops the event (see
- * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher}) and bind model and view elements.
- *
- *		modelDispatcher.on( 'removeAttribute:bold', unwrapItem( new ViewAttributeElement( 'strong' ) ) );
- *
- * @see module:engine/conversion/model-to-view-converters~wrapItem
- * @param {module:engine/view/element~Element|Function} elementCreator View element, or function returning a view element, which will
- * be used for unwrapping.
- * @returns {Function} Remove attribute converter.
- */
-export function unwrapItem( elementCreator ) {
-	return ( evt, data, consumable, conversionApi ) => {
-		const viewElement = ( elementCreator instanceof ViewElement ) ?
-			elementCreator.clone( true ) :
-			elementCreator( data.attributeOldValue, data, consumable, conversionApi );
-
-		if ( !viewElement ) {
-			return;
-		}
-
-		if ( !consumable.consume( data.item, eventNameToConsumableType( evt.name ) ) ) {
-			return;
+		// Then wrap with the new wrapper.
+		if ( data.attributeNewValue !== null ) {
+			viewWriter.wrap( viewRange, newViewElement );
 		}
-
-		const viewRange = conversionApi.mapper.toViewRange( data.range );
-
-		viewWriter.unwrap( viewRange, viewElement );
 	};
 }
 
 /**
- * Function factory, creates a default model-to-view converter for node remove changes.
- *
- *		modelDispatcher.on( 'remove', remove() );
- *
- * @returns {Function} Remove event converter.
- */
-export function remove() {
-	return ( evt, data, consumable, conversionApi ) => {
-		if ( !consumable.consume( data.item, 'remove' ) ) {
-			return;
-		}
-
-		// We cannot map non-existing positions from model to view. Since a range was removed
-		// from the model, we cannot recreate that range and map it to view, because
-		// end of that range is incorrect.
-		// Instead we will use `data.sourcePosition` as this is the last correct model position and
-		// it is a position before the removed item. Then, we will calculate view range to remove "manually".
-		let viewPosition = conversionApi.mapper.toViewPosition( data.sourcePosition );
-		let viewRange;
-
-		if ( data.item.is( 'element' ) ) {
-			// Note: in remove conversion we cannot use model-to-view element mapping because `data.item` may be
-			// already mapped to another element (this happens when move change is converted).
-			// In this case however, `viewPosition` is the position before view element that corresponds to removed model element.
-			//
-			// First, fix the position. Traverse the tree forward until the container element is found. The `viewPosition`
-			// may be before a ui element, before attribute element or at the end of text element.
-			viewPosition = viewPosition.getLastMatchingPosition( value => !value.item.is( 'containerElement' ) );
-
-			if ( viewPosition.parent.is( 'text' ) && viewPosition.isAtEnd ) {
-				viewPosition = ViewPosition.createAfter( viewPosition.parent );
-			}
-
-			viewRange = ViewRange.createOn( viewPosition.nodeAfter );
-		} else {
-			// If removed item is a text node, we need to traverse view tree to find the view range to remove.
-			// Range to remove will start `viewPosition` and should contain amount of characters equal to the amount of removed characters.
-			const viewRangeEnd = _shiftViewPositionByCharacters( viewPosition, data.item.offsetSize );
-			viewRange = new ViewRange( viewPosition, viewRangeEnd );
-		}
-
-		// Trim the range to remove in case some UI elements are on the view range boundaries.
-		viewWriter.remove( viewRange.getTrimmed() );
-
-		// Unbind this element only if it was moved to graveyard.
-		// The dispatcher#remove event will also be fired if the element was moved to another place (remove+insert are fired).
-		// Let's say that <b> is moved before <a>. The view will be changed like this:
-		//
-		// 1) start:    <a></a><b></b>
-		// 2) insert:   <b (new)></b><a></a><b></b>
-		// 3) remove:   <b (new)></b><a></a>
-		//
-		// If we'll unbind the <b> element in step 3 we'll also lose binding of the <b (new)> element in the view,
-		// because unbindModelElement() cancels both bindings – (model <b> => view <b (new)>) and (view <b (new)> => model <b>).
-		// We can't lose any of these.
-		//
-		// See #847.
-		if ( data.item.root.rootName == '$graveyard' ) {
-			conversionApi.mapper.unbindModelElement( data.item );
-		}
-	};
-}
-
-/**
- * Function factory, creates converter that converts all texts inside marker's range. Converter wraps each text with
+ * Function factory, creates converter that converts text inside marker's range. Converter wraps the text with
  * {@link module:engine/view/attributeelement~AttributeElement} created from provided descriptor.
- * See {link module:engine/conversion/model-to-view-converters~highlightDescriptorToAttributeElement}.
+ * See {link module:engine/conversion/model-to-view-converters~createViewElementFromHighlightDescriptor}.
  *
  * @param {module:engine/conversion/model-to-view-converters~HighlightDescriptor|Function} highlightDescriptor
  * @return {Function}
  */
 export function highlightText( highlightDescriptor ) {
 	return ( evt, data, consumable, conversionApi ) => {
-		const descriptor = typeof highlightDescriptor == 'function' ?
-			highlightDescriptor( data, consumable, conversionApi ) :
-			highlightDescriptor;
+		if ( data.markerRange.isCollapsed ) {
+			return;
+		}
 
 		const modelItem = data.item;
 
-		if ( !descriptor || data.markerRange.isCollapsed || !modelItem.is( 'textProxy' ) ) {
+		if ( !modelItem.is( 'textProxy' ) ) {
 			return;
 		}
 
-		if ( !consumable.consume( modelItem, evt.name ) ) {
+		const descriptor = _prepareDescriptor( highlightDescriptor, data, conversionApi );
+
+		if ( !descriptor ) {
 			return;
 		}
 
-		if ( !descriptor.id ) {
-			descriptor.id = data.markerName;
+		if ( !consumable.consume( modelItem, evt.name ) ) {
+			return;
 		}
 
 		const viewElement = createViewElementFromHighlightDescriptor( descriptor );
 		const viewRange = conversionApi.mapper.toViewRange( data.range );
 
-		if ( evt.name.split( ':' )[ 0 ] == 'addMarker' ) {
-			viewWriter.wrap( viewRange, viewElement );
-		} else {
-			viewWriter.unwrap( viewRange, viewElement );
-		}
+		viewWriter.wrap( viewRange, viewElement );
 	};
 }
 
 /**
- * Converter function factory. Creates a function which applies the marker's highlight to all elements inside a marker's range.
- * The converter checks if an element has the addHighlight and removeHighlight functions stored as
- * {@link module:engine/view/element~Element#setCustomProperty custom properties} and if so use them to apply the highlight.
+ * Converter function factory. Creates a function which applies the marker's highlight to an element inside the marker's range.
+ *
+ * The converter checks if an element has `addHighlight` function stored as
+ * {@link module:engine/view/element~Element#setCustomProperty custom property} and, if so, uses it to apply the highlight.
+ *
  * In such case converter will consume all element's children, assuming that they were handled by element itself.
  * If the highlight descriptor will not provide priority, priority `10` will be used as default, to be compliant with
  * {@link module:engine/conversion/model-to-view-converters~highlightText} method which uses default priority of
@@ -461,33 +381,29 @@ export function highlightText( highlightDescriptor ) {
  */
 export function highlightElement( highlightDescriptor ) {
 	return ( evt, data, consumable, conversionApi ) => {
-		const descriptor = typeof highlightDescriptor == 'function' ?
-			highlightDescriptor( data, consumable, conversionApi ) :
-			highlightDescriptor;
+		if ( data.markerRange.isCollapsed ) {
+			return;
+		}
 
 		const modelItem = data.item;
 
-		if ( !descriptor || data.markerRange.isCollapsed || !modelItem.is( 'element' ) ) {
+		if ( !modelItem.is( 'element' ) ) {
 			return;
 		}
 
-		if ( !consumable.test( data.item, evt.name ) ) {
-			return;
-		}
+		const descriptor = _prepareDescriptor( highlightDescriptor, data, conversionApi );
 
-		if ( !descriptor.priority ) {
-			descriptor.priority = 10;
+		if ( !descriptor ) {
+			return;
 		}
 
-		if ( !descriptor.id ) {
-			descriptor.id = data.markerName;
+		if ( !consumable.test( modelItem, evt.name ) ) {
+			return;
 		}
 
 		const viewElement = conversionApi.mapper.toViewElement( modelItem );
-		const addMarker = evt.name.split( ':' )[ 0 ] == 'addMarker';
-		const highlightHandlingMethod = addMarker ? 'addHighlight' : 'removeHighlight';
 
-		if ( viewElement && viewElement.getCustomProperty( highlightHandlingMethod ) ) {
+		if ( viewElement && viewElement.getCustomProperty( 'addHighlight' ) ) {
 			// Consume element itself.
 			consumable.consume( data.item, evt.name );
 
@@ -496,65 +412,98 @@ export function highlightElement( highlightDescriptor ) {
 				consumable.consume( value.item, evt.name );
 			}
 
-			viewElement.getCustomProperty( highlightHandlingMethod )( viewElement, addMarker ? descriptor : descriptor.id );
+			viewElement.getCustomProperty( 'addHighlight' )( viewElement, descriptor );
 		}
 	};
 }
 
 /**
- * Function factory, creates a default model-to-view converter for removing {@link module:engine/view/uielement~UIElement ui element}
- * basing on marker remove change.
+ * Function factory, creates a converter that converts model marker add/change/remove to the view.
  *
- * @param {module:engine/view/uielement~UIElement|Function} elementCreator View ui element, or function returning
- * a view ui element, which will be used as a pattern when look for element to remove at the marker start position.
- * @returns {Function} Remove ui element converter.
+ * The result of this conversion is different for text nodes and elements.
+ *
+ * Text nodes are wrapped with {@link module:engine/view/attributeelement~AttributeElement} created from provided
+ * highlight descriptor. See {link module:engine/conversion/model-to-view-converters~highlightDescriptorToAttributeElement}.
+ *
+ * For elements, the converter checks if an element has `addHighlight` and `removeHighlight` functions stored as
+ * {@link module:engine/view/element~Element#setCustomProperty custom properties}. If so, it uses them to apply the highlight.
+ * In such case, children of that element will not be converted. When `addHighlight` and `removeHighlight` are not present,
+ * element is not converted in any special way, instead converter will proceed to convert element's child nodes. Most
+ * common case is that the element will be given a special class, style or attribute basing on the descriptor.
+ *
+ * If the highlight descriptor will not provide `priority` property, `10` will be used.
+ *
+ * If the highlight descriptor will not provide `id` property, name of the marker will be used.
+ *
+ * @param {module:engine/conversion/model-to-view-converters~HighlightDescriptor|Function} highlightDescriptor
+ * @return {Function}
  */
-export function removeUIElement( elementCreator ) {
-	return ( evt, data, consumable, conversionApi ) => {
-		let viewStartElement, viewEndElement;
-
-		if ( elementCreator instanceof ViewElement ) {
-			viewStartElement = elementCreator.clone( true );
-			viewEndElement = elementCreator.clone( true );
-		} else {
-			data.isOpening = true;
-			viewStartElement = elementCreator( data, consumable, conversionApi );
-
-			data.isOpening = false;
-			viewEndElement = elementCreator( data, consumable, conversionApi );
-		}
-
-		if ( !viewStartElement || !viewEndElement ) {
+export function removeHighlight( highlightDescriptor ) {
+	return ( evt, data, conversionApi ) => {
+		// This conversion makes sense only for non-collapsed range.
+		if ( data.markerRange.isCollapsed ) {
 			return;
 		}
 
-		const markerRange = data.markerRange;
-		const eventName = evt.name;
+		const descriptor = _prepareDescriptor( highlightDescriptor, data, conversionApi );
 
-		// If marker's range is collapsed - check if it can be consumed.
-		if ( markerRange.isCollapsed && !consumable.consume( markerRange, eventName ) ) {
+		if ( !descriptor ) {
 			return;
 		}
 
-		// Check if all items in the range can be consumed, and consume them.
-		for ( const value of markerRange ) {
-			if ( !consumable.consume( value.item, eventName ) ) {
-				return;
+		const viewRange = conversionApi.mapper.toViewRange( data.markerRange );
+
+		// Retrieve all items in the affected range. We will process them and remove highlight from them appropriately.
+		const items = new Set( viewRange.getItems() );
+
+		// First, iterate through all items and remove highlight from those container elements that have custom highlight handling.
+		for ( const item of items ) {
+			if ( item.is( 'containerElement' ) && item.getCustomProperty( 'removeHighlight' ) ) {
+				item.getCustomProperty( 'removeHighlight' )( item, descriptor.id );
+
+				// If container element had custom handling, remove all it's children from further processing.
+				for ( const descendant of ViewRange.createIn( item ) ) {
+					items.delete( descendant );
+				}
 			}
 		}
 
-		const viewRange = conversionApi.mapper.toViewRange( markerRange );
-
-		// First remove closing element.
-		viewWriter.clear( viewRange.getEnlarged(), viewEndElement );
+		// Then, iterate through all other items. Look for text nodes and unwrap them. Start from the end
+		// to prevent errors when view structure changes when unwrapping (and, for example, some attributes are merged).
+		const viewHighlightElement = createViewElementFromHighlightDescriptor( descriptor );
 
-		// If closing and opening elements are not the same then remove opening element.
-		if ( !viewStartElement.isSimilar( viewEndElement ) ) {
-			viewWriter.clear( viewRange.getEnlarged(), viewStartElement );
+		for ( const item of Array.from( items ).reverse() ) {
+			if ( item.is( 'textProxy' ) ) {
+				viewWriter.unwrap( ViewRange.createOn( item ), viewHighlightElement );
+			}
 		}
 	};
 }
 
+// Helper function for `highlight`. Prepares the actual descriptor object using value passed to the converter.
+function _prepareDescriptor( highlightDescriptor, data, conversionApi ) {
+	// If passed descriptor is a creator function, call it. If not, just use passed value.
+	const descriptor = typeof highlightDescriptor == 'function' ?
+		highlightDescriptor( data, conversionApi ) :
+		highlightDescriptor;
+
+	if ( !descriptor ) {
+		return null;
+	}
+
+	// Apply default descriptor priority.
+	if ( !descriptor.priority ) {
+		descriptor.priority = 10;
+	}
+
+	// Default descriptor id is marker name.
+	if ( !descriptor.id ) {
+		descriptor.id = data.markerName;
+	}
+
+	return descriptor;
+}
+
 /**
  * Returns the consumable type that is to be consumed in an event, basing on that event name.
  *
@@ -567,26 +516,6 @@ export function eventNameToConsumableType( evtName ) {
 	return parts[ 0 ] + ':' + parts[ 1 ];
 }
 
-// Helper function that shifts given view `position` in a way that returned position is after `howMany` characters compared
-// to the original `position`.
-// Because in view there might be view ui elements splitting text nodes, we cannot simply use `ViewPosition#getShiftedBy()`.
-function _shiftViewPositionByCharacters( position, howMany ) {
-	// Create a walker that will walk the view tree starting from given position and walking characters one-by-one.
-	const walker = new ViewTreeWalker( { startPosition: position, singleCharacters: true } );
-	// We will count visited characters and return the position after `howMany` characters.
-	let charactersFound = 0;
-
-	for ( const value of walker ) {
-		if ( value.type == 'text' ) {
-			charactersFound++;
-
-			if ( charactersFound == howMany ) {
-				return walker.position;
-			}
-		}
-	}
-}
-
 /**
  * Creates `span` {@link module:engine/view/attributeelement~AttributeElement view attribute element} from information
  * provided by {@link module:engine/conversion/model-to-view-converters~HighlightDescriptor} object. If priority

+ 3 - 0
packages/ckeditor5-engine/src/dev-utils/enableenginedebug.js

@@ -667,6 +667,9 @@ class DebugPlugin extends Plugin {
 
 		modelDocument.on( 'change', () => {
 			dumpTrees( modelDocument, modelDocument.version );
+		}, { priority: 'lowest' } );
+
+		modelDocument.on( 'changesDone', () => {
 			dumpTrees( viewDocument, modelDocument.version );
 		}, { priority: 'lowest' } );
 	}

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

@@ -33,7 +33,7 @@ import {
 	convertCollapsedSelection,
 	convertSelectionAttribute
 } from '../conversion/model-selection-to-view-converters';
-import { insertText, insertElement, wrapItem } from '../conversion/model-to-view-converters';
+import { insertText, insertElement, wrap } from '../conversion/model-to-view-converters';
 import isPlainObject from '@ckeditor/ckeditor5-utils/src/lib/lodash/isPlainObject';
 
 /**
@@ -198,7 +198,7 @@ export function stringify( node, selectionOrPositionOrRange = null ) {
 	mapper.bindElements( node.root, viewDocumentFragment );
 
 	modelToView.on( 'insert:$text', insertText() );
-	modelToView.on( 'addAttribute', wrapItem( ( value, data ) => {
+	modelToView.on( 'attribute', wrap( ( value, data ) => {
 		if ( data.item.is( 'textProxy' ) ) {
 			return new ViewAttributeElement( 'model-text-with-attributes', { [ data.attributeKey ]: stringifyAttributeValue( value ) } );
 		}
@@ -216,7 +216,7 @@ export function stringify( node, selectionOrPositionOrRange = null ) {
 	} ) );
 
 	// Convert model to view.
-	modelToView.convertInsertion( range );
+	modelToView.convertInsert( range );
 
 	// Convert model selection to view selection.
 	if ( selection ) {

+ 91 - 98
packages/ckeditor5-engine/tests/controller/editingcontroller.js

@@ -17,8 +17,6 @@ import buildModelConverter from '../../src/conversion/buildmodelconverter';
 
 import Model from '../../src/model/model';
 import ModelPosition from '../../src/model/position';
-import ModelElement from '../../src/model/element';
-import ModelText from '../../src/model/text';
 import ModelRange from '../../src/model/range';
 import ModelDocumentFragment from '../../src/model/documentfragment';
 
@@ -146,6 +144,7 @@ describe( 'EditingController', () => {
 			model.schema.registerItem( 'div', '$block' );
 			buildModelConverter().for( editing.modelToView ).fromElement( 'paragraph' ).toElement( 'p' );
 			buildModelConverter().for( editing.modelToView ).fromElement( 'div' ).toElement( 'div' );
+			buildModelConverter().for( editing.modelToView ).fromMarker( 'marker' ).toHighlight( {} );
 
 			// Note: The below code is highly overcomplicated due to #455.
 			model.document.selection.removeAllRanges();
@@ -160,10 +159,12 @@ describe( 'EditingController', () => {
 				model.schema
 			)._children );
 
-			model.enqueueChange( writer => {
+			model.change( writer => {
 				writer.insert( modelData, model.document.getRoot() );
+
 				model.document.selection.addRange( ModelRange.createFromParentsAndOffsets(
-					modelRoot.getChild( 0 ), 1, modelRoot.getChild( 0 ), 1 ) );
+					modelRoot.getChild( 0 ), 1, modelRoot.getChild( 0 ), 1 )
+				);
 			} );
 		} );
 
@@ -180,8 +181,9 @@ describe( 'EditingController', () => {
 		it( 'should convert split', () => {
 			expect( getViewData( editing.view ) ).to.equal( '<p>f{}oo</p><p></p><p>bar</p>' );
 
-			model.enqueueChange( writer => {
+			model.change( writer => {
 				writer.split( model.document.selection.getFirstPosition() );
+
 				model.document.selection.setRanges( [
 					ModelRange.createFromParentsAndOffsets(	modelRoot.getChild( 1 ), 0, modelRoot.getChild( 1 ), 0 )
 				] );
@@ -193,7 +195,7 @@ describe( 'EditingController', () => {
 		it( 'should convert rename', () => {
 			expect( getViewData( editing.view ) ).to.equal( '<p>f{}oo</p><p></p><p>bar</p>' );
 
-			model.enqueueChange( writer => {
+			model.change( writer => {
 				writer.rename( modelRoot.getChild( 0 ), 'div' );
 			} );
 
@@ -201,10 +203,11 @@ describe( 'EditingController', () => {
 		} );
 
 		it( 'should convert delete', () => {
-			model.enqueueChange( writer => {
+			model.change( writer => {
 				writer.remove(
 					ModelRange.createFromPositionAndShift( model.document.selection.getFirstPosition(), 1 )
 				);
+
 				model.document.selection.setRanges( [
 					ModelRange.createFromParentsAndOffsets( modelRoot.getChild( 0 ), 1, modelRoot.getChild( 0 ), 1 )
 				] );
@@ -239,7 +242,7 @@ describe( 'EditingController', () => {
 		} );
 
 		it( 'should convert collapsed selection', () => {
-			model.enqueueChange( () => {
+			model.change( () => {
 				model.document.selection.setRanges( [
 					ModelRange.createFromParentsAndOffsets( modelRoot.getChild( 2 ), 1, modelRoot.getChild( 2 ), 1 )
 				] );
@@ -249,7 +252,7 @@ describe( 'EditingController', () => {
 		} );
 
 		it( 'should convert not collapsed selection', () => {
-			model.enqueueChange( () => {
+			model.change( () => {
 				model.document.selection.setRanges( [
 					ModelRange.createFromParentsAndOffsets( modelRoot.getChild( 2 ), 1, modelRoot.getChild( 2 ), 2 )
 				] );
@@ -259,7 +262,7 @@ describe( 'EditingController', () => {
 		} );
 
 		it( 'should clear previous selection', () => {
-			model.enqueueChange( () => {
+			model.change( () => {
 				model.document.selection.setRanges( [
 					ModelRange.createFromParentsAndOffsets( modelRoot.getChild( 2 ), 1, modelRoot.getChild( 2 ), 1 )
 				] );
@@ -267,7 +270,7 @@ describe( 'EditingController', () => {
 
 			expect( getViewData( editing.view ) ).to.equal( '<p>foo</p><p></p><p>b{}ar</p>' );
 
-			model.enqueueChange( () => {
+			model.change( () => {
 				model.document.selection.setRanges( [
 					ModelRange.createFromParentsAndOffsets( modelRoot.getChild( 2 ), 2, modelRoot.getChild( 2 ), 2 )
 				] );
@@ -276,127 +279,116 @@ describe( 'EditingController', () => {
 			expect( getViewData( editing.view ) ).to.equal( '<p>foo</p><p></p><p>ba{}r</p>' );
 		} );
 
-		it( 'should forward marker events to model conversion dispatcher', () => {
-			const range = ModelRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 1 );
-			const markerStub = {
-				name: 'name',
-				getRange: () => range
-			};
+		it( 'should convert adding marker', () => {
+			const range = new ModelRange( new ModelPosition( modelRoot, [ 0, 1 ] ), new ModelPosition( modelRoot, [ 2, 2 ] ) );
 
-			sinon.spy( editing.modelToView, 'convertMarker' );
+			model.change( () => {
+				model.markers.set( 'marker', range );
+			} );
 
-			model.markers.fire( 'add', markerStub );
+			expect( getViewData( editing.view, { withoutSelection: true } ) )
+				.to.equal( '<p>f<span>oo</span></p><p></p><p><span>ba</span>r</p>' );
+		} );
 
-			expect( editing.modelToView.convertMarker.calledWithExactly( 'addMarker', 'name', range ) ).to.be.true;
+		it( 'should convert removing marker', () => {
+			const range = new ModelRange( new ModelPosition( modelRoot, [ 0, 1 ] ), new ModelPosition( modelRoot, [ 2, 2 ] ) );
 
-			model.markers.fire( 'remove', markerStub );
+			model.change( () => {
+				model.markers.set( 'marker', range );
+			} );
 
-			expect( editing.modelToView.convertMarker.calledWithExactly( 'removeMarker', 'name', range ) ).to.be.true;
+			model.change( () => {
+				model.markers.remove( 'marker' );
+			} );
 
-			editing.modelToView.convertMarker.restore();
+			expect( getViewData( editing.view, { withoutSelection: true } ) )
+				.to.equal( '<p>foo</p><p></p><p>bar</p>' );
 		} );
 
-		it( 'should forward add marker event if content is inserted into a marker range', () => {
-			const markerRange = ModelRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 3 );
-			const innerRange = ModelRange.createFromParentsAndOffsets( modelRoot, 1, modelRoot, 2 );
+		it( 'should convert changing marker', () => {
+			const range = new ModelRange( new ModelPosition( modelRoot, [ 0, 1 ] ), new ModelPosition( modelRoot, [ 2, 2 ] ) );
 
-			model.markers.set( 'name', markerRange );
-
-			sinon.spy( editing.modelToView, 'convertMarker' );
+			model.change( () => {
+				model.markers.set( 'marker', range );
+			} );
 
-			editing.modelToView.convertInsertion( innerRange );
+			const range2 = new ModelRange( new ModelPosition( modelRoot, [ 0, 0 ] ), new ModelPosition( modelRoot, [ 0, 2 ] ) );
 
-			expect( editing.modelToView.convertMarker.calledWithExactly( 'addMarker', 'name', innerRange ) ).to.be.true;
+			model.change( () => {
+				model.markers.set( 'marker', range2 );
+			} );
 
-			editing.modelToView.convertMarker.restore();
+			expect( getViewData( editing.view, { withoutSelection: true } ) )
+				.to.equal( '<p><span>fo</span>o</p><p></p><p>bar</p>' );
 		} );
 
-		describe( 'should forward add marker event if inserted content has a marker', () => {
-			let element, outerRange;
-
-			beforeEach( () => {
-				element = new ModelElement( 'paragraph', null, new ModelText( 'foo' ) );
-				modelRoot.appendChildren( element );
+		it( 'should convert insertion into marker', () => {
+			const range = new ModelRange( new ModelPosition( modelRoot, [ 0, 1 ] ), new ModelPosition( modelRoot, [ 2, 2 ] ) );
 
-				outerRange = ModelRange.createOn( element );
-
-				sinon.spy( editing.modelToView, 'convertMarker' );
+			model.change( () => {
+				model.markers.set( 'marker', range );
 			} );
 
-			afterEach( () => {
-				editing.modelToView.convertMarker.restore();
+			model.change( writer => {
+				writer.insertText( 'xyz', new ModelPosition( modelRoot, [ 1, 0 ] ) );
 			} );
 
-			it( 'marker strictly contained', () => {
-				const markerRange = ModelRange.createFromParentsAndOffsets( element, 1, element, 2 );
-				model.markers.set( 'name', markerRange );
+			expect( getViewData( editing.view, { withoutSelection: true } ) )
+				.to.equal( '<p>f<span>oo</span></p><p><span>xyz</span></p><p><span>ba</span>r</p>' );
+		} );
 
-				editing.modelToView.convertInsertion( outerRange );
-				expect( editing.modelToView.convertMarker.calledWithExactly( 'addMarker', 'name', markerRange ) ).to.be.true;
-			} );
+		it( 'should convert move to marker', () => {
+			const range = new ModelRange( new ModelPosition( modelRoot, [ 0, 1 ] ), new ModelPosition( modelRoot, [ 2, 2 ] ) );
 
-			it( 'marker starts at same position', () => {
-				const markerRange = ModelRange.createFromParentsAndOffsets( element, 0, element, 2 );
-				model.markers.set( 'name', markerRange );
-				editing.modelToView.convertInsertion( outerRange );
-				expect( editing.modelToView.convertMarker.calledWithExactly( 'addMarker', 'name', markerRange ) ).to.be.true;
+			model.change( () => {
+				model.markers.set( 'marker', range );
 			} );
 
-			it( 'marker ends at same position', () => {
-				const markerRange = ModelRange.createFromParentsAndOffsets( element, 1, element, 3 );
-				model.markers.set( 'name', markerRange );
-				editing.modelToView.convertInsertion( outerRange );
-				expect( editing.modelToView.convertMarker.calledWithExactly( 'addMarker', 'name', markerRange ) ).to.be.true;
+			model.change( writer => {
+				writer.move(
+					new ModelRange( new ModelPosition( modelRoot, [ 2, 2 ] ), new ModelPosition( modelRoot, [ 2, 3 ] ) ),
+					new ModelPosition( modelRoot, [ 0, 3 ] )
+				);
 			} );
 
-			it( 'marker is same as range', () => {
-				const markerRange = ModelRange.createFromParentsAndOffsets( element, 0, element, 3 );
-				model.markers.set( 'name', markerRange );
-				editing.modelToView.convertInsertion( outerRange );
-				expect( editing.modelToView.convertMarker.calledWithExactly( 'addMarker', 'name', markerRange ) ).to.be.true;
-			} );
+			expect( getViewData( editing.view, { withoutSelection: true } ) )
+				.to.equal( '<p>f<span>oor</span></p><p></p><p><span>ba</span></p>' );
 		} );
 
-		it( 'should not start marker conversion if content is not inserted into any marker range', () => {
-			const markerRange = ModelRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 3 );
-			const insertRange = ModelRange.createFromParentsAndOffsets( modelRoot, 6, modelRoot, 8 );
-			const consumableMock = {
-				consume: () => true,
-				test: () => true
-			};
-
-			model.markers.set( 'name', markerRange );
-
-			sinon.spy( editing.modelToView, 'convertMarker' );
-
-			editing.modelToView.fire( 'insert', {
-				range: insertRange
-			}, consumableMock, { dispatcher: editing.modelToView } );
-
-			expect( editing.modelToView.convertMarker.called ).to.be.false;
-
-			editing.modelToView.convertMarker.restore();
-		} );
+		it( 'should convert move from marker', () => {
+			const range = new ModelRange( new ModelPosition( modelRoot, [ 0, 1 ] ), new ModelPosition( modelRoot, [ 2, 2 ] ) );
 
-		it( 'should forward add marker event if content is moved into a marker range', () => {
-			model.enqueueChange( writer => {
-				writer.appendElement( 'paragraph', model.document.getRoot() );
+			model.change( () => {
+				model.markers.set( 'marker', range );
 			} );
 
-			const markerRange = ModelRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 3 );
+			model.change( writer => {
+				writer.move(
+					new ModelRange( new ModelPosition( modelRoot, [ 0, 1 ] ), new ModelPosition( modelRoot, [ 0, 3 ] ) ),
+					new ModelPosition( modelRoot, [ 2, 3 ] )
+				);
+			} );
 
-			model.markers.set( 'name', markerRange );
+			expect( getViewData( editing.view, { withoutSelection: true } ) )
+				.to.equal( '<p>f</p><p></p><p><span>ba</span>roo</p>' );
+		} );
 
-			sinon.spy( editing.modelToView, 'convertMarker' );
+		it( 'should convert the whole marker move', () => {
+			const range = new ModelRange( new ModelPosition( modelRoot, [ 0, 1 ] ), new ModelPosition( modelRoot, [ 0, 3 ] ) );
 
-			editing.modelToView.convertMove(
-				ModelPosition.createAt( modelRoot, 3 ),
-				ModelRange.createOn( modelRoot.getChild( 1 ) )
-			);
+			model.change( () => {
+				model.markers.set( 'marker', range );
+			} );
 
-			expect( editing.modelToView.convertMarker.calledWith( 'addMarker', 'name' ) ).to.be.true;
+			model.change( writer => {
+				writer.move(
+					new ModelRange( new ModelPosition( modelRoot, [ 0, 0 ] ), new ModelPosition( modelRoot, [ 0, 3 ] ) ),
+					new ModelPosition( modelRoot, [ 1, 0 ] )
+				);
+			} );
 
-			editing.modelToView.convertMarker.restore();
+			expect( getViewData( editing.view, { withoutSelection: true } ) )
+				.to.equal( '<p></p><p>f<span>oo</span></p><p>bar</p>' );
 		} );
 	} );
 
@@ -414,8 +406,9 @@ describe( 'EditingController', () => {
 
 			editing.destroy();
 
-			model.enqueueChange( writer => {
+			model.change( writer => {
 				const modelData = parse( '<paragraph>foo</paragraph>', model.schema ).getChild( 0 );
+
 				writer.insert( modelData, model.document.getRoot() );
 			} );
 

+ 109 - 326
packages/ckeditor5-engine/tests/conversion/advanced-converters.js

@@ -10,8 +10,6 @@ import ModelTextProxy from '../../src/model/textproxy';
 import ModelRange from '../../src/model/range';
 import ModelPosition from '../../src/model/position';
 import ModelWalker from '../../src/model/treewalker';
-import ModelWriter from '../../src/model/writer';
-import Batch from '../../src/model/batch';
 
 import ViewElement from '../../src/view/element';
 import ViewContainerElement from '../../src/view/containerelement';
@@ -21,47 +19,34 @@ import viewWriter from '../../src/view/writer';
 import ViewPosition from '../../src/view/position';
 import ViewRange from '../../src/view/range';
 
-import Mapper from '../../src/conversion/mapper';
-import ModelConversionDispatcher from '../../src/conversion/modelconversiondispatcher';
+import EditingController from '../../src/controller/editingcontroller';
+
 import ViewConversionDispatcher from '../../src/conversion/viewconversiondispatcher';
 
 import {
 	insertElement,
-	insertText,
-	setAttribute,
-	removeAttribute,
-	wrapItem,
-	unwrapItem,
-	remove,
-	eventNameToConsumableType
+	changeAttribute,
+	wrap
 } from '../../src/conversion/model-to-view-converters';
 import { convertToModelFragment, convertText } from '../../src/conversion/view-to-model-converters';
 
-import { createRangeOnElementOnly } from '../../tests/model/_utils/utils';
-
 describe( 'advanced-converters', () => {
-	let model, modelDoc, modelRoot, viewRoot, mapper, modelDispatcher, viewDispatcher, modelWriter;
+	let model, modelDoc, modelRoot, viewRoot, modelDispatcher, viewDispatcher;
 
 	beforeEach( () => {
 		model = new Model();
 		modelDoc = model.document;
 		modelRoot = modelDoc.createRoot();
-		viewRoot = new ViewContainerElement( 'div' );
 
-		mapper = new Mapper();
-		mapper.bindElements( modelRoot, viewRoot );
+		const editing = new EditingController( model );
 
-		modelDispatcher = new ModelConversionDispatcher( model, { mapper } );
-		// Schema is mocked up because we don't care about it in those tests.
-		viewDispatcher = new ViewConversionDispatcher( model, { schema: { check: () => true } } );
+		viewRoot = editing.createRoot( 'div' );
 
-		modelDispatcher.on( 'insert:$text', insertText() );
-		modelDispatcher.on( 'remove', remove() );
+		viewDispatcher = new ViewConversionDispatcher( model, { schema: { check: () => true } } );
 		viewDispatcher.on( 'text', convertText() );
 		viewDispatcher.on( 'documentFragment', convertToModelFragment() );
 
-		// We need to create a model writer to modify model tree in tests.
-		modelWriter = new ModelWriter( model, new Batch() );
+		modelDispatcher = editing.modelToView;
 	} );
 
 	function viewAttributesToString( item ) {
@@ -131,179 +116,6 @@ describe( 'advanced-converters', () => {
 		return result;
 	}
 
-	// Converter for custom `image` element that might have a `caption` element inside which changes
-	// how the image is displayed in the view:
-	//
-	// Model:
-	//
-	// [image {src="foo.jpg" title="foo"}]
-	//   └─ [caption]
-	//       ├─ f
-	//       ├─ o
-	//       └─ o
-	//
-	// [image {src="bar.jpg" title="bar"}]
-	//
-	// View:
-	//
-	// <figure>
-	//   ├─ <img src="foo.jpg" title="foo" />
-	//   └─ <caption>
-	//       └─ foo
-	//
-	// <img src="bar.jpg" title="bar" />
-	describe( 'image with caption converters', () => {
-		beforeEach( () => {
-			const modelImageConverter = function( evt, data, consumable, conversionApi ) {
-				// First, consume the `image` element.
-				consumable.consume( data.item, 'insert' );
-
-				// Just create normal image element for the view.
-				// Maybe it will be "decorated" later.
-				const viewImage = new ViewContainerElement( 'img' );
-				const insertPosition = conversionApi.mapper.toViewPosition( data.range.start );
-
-				// Check if the `image` element has children.
-				if ( data.item.childCount > 0 ) {
-					const modelCaption = data.item.getChild( 0 );
-
-					// `modelCaption` insertion change is consumed from consumable values.
-					// It will not be converted by other converters, but it's children (probably some text) will be.
-					// Through mapping, converters for text will know where to insert contents of `modelCaption`.
-					if ( consumable.consume( modelCaption, 'insert' ) ) {
-						const viewCaption = new ViewContainerElement( 'figcaption' );
-
-						const viewImageHolder = new ViewContainerElement( 'figure', null, [ viewImage, viewCaption ] );
-
-						conversionApi.mapper.bindElements( modelCaption, viewCaption );
-						conversionApi.mapper.bindElements( data.item, viewImageHolder );
-						viewWriter.insert( insertPosition, viewImageHolder );
-					}
-				} else {
-					conversionApi.mapper.bindElements( data.item, viewImage );
-					viewWriter.insert( insertPosition, viewImage );
-				}
-
-				evt.stop();
-			};
-
-			const modelImageAttributesConverter = function( evt, data, consumable, conversionApi ) {
-				if ( data.item.name != 'image' ) {
-					return;
-				}
-
-				let viewElement = conversionApi.mapper.toViewElement( data.item );
-
-				if ( viewElement.name == 'figure' ) {
-					viewElement = viewElement.getChild( 0 );
-				}
-
-				consumable.consume( data.item, eventNameToConsumableType( evt.name ) );
-
-				if ( !data.attributeNewValue ) {
-					viewElement.removeAttribute( data.attributeKey );
-				} else {
-					viewElement.setAttribute( data.attributeKey, data.attributeNewValue );
-				}
-
-				evt.stop();
-			};
-
-			const viewFigureConverter = function( evt, data, consumable, conversionApi ) {
-				if ( consumable.consume( data.input, { name: true } ) ) {
-					const modelImage = conversionApi.convertItem( data.input.getChild( 0 ), consumable );
-					const modelCaption = conversionApi.convertItem( data.input.getChild( 1 ), consumable );
-
-					modelImage.appendChildren( modelCaption );
-
-					data.output = modelImage;
-				}
-			};
-
-			const viewImageConverter = function( evt, data, consumable, conversionApi ) {
-				if ( consumable.consume( data.input, { name: true } ) ) {
-					data.output = conversionApi.writer.createElement( 'image', data.input.getAttributes() );
-				}
-			};
-
-			const viewFigcaptionConverter = function( evt, data, consumable, conversionApi ) {
-				if ( consumable.consume( data.input, { name: true } ) ) {
-					const modelCaption = conversionApi.writer.createElement( 'caption' );
-					const children = conversionApi.convertChildren( data.input, consumable );
-
-					conversionApi.writer.append( children, modelCaption );
-
-					data.output = modelCaption;
-				}
-			};
-
-			modelDispatcher.on( 'insert:image', modelImageConverter );
-			modelDispatcher.on( 'addAttribute', modelImageAttributesConverter );
-			modelDispatcher.on( 'changeAttribute', modelImageAttributesConverter );
-			modelDispatcher.on( 'removeAttribute', modelImageAttributesConverter );
-			viewDispatcher.on( 'element:figure', viewFigureConverter );
-			viewDispatcher.on( 'element:img', viewImageConverter );
-			viewDispatcher.on( 'element:figcaption', viewFigcaptionConverter );
-		} );
-
-		it( 'should convert model images changes without caption to view', () => {
-			const modelElement = new ModelElement( 'image', { src: 'bar.jpg', title: 'bar' } );
-			modelRoot.appendChildren( modelElement );
-			modelDispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
-
-			expect( viewToString( viewRoot ) ).to.equal( '<div><img src="bar.jpg" title="bar"></img></div>' );
-
-			modelElement.setAttribute( 'src', 'new.jpg' );
-			modelElement.removeAttribute( 'title' );
-			modelDispatcher.convertAttribute( 'changeAttribute', createRangeOnElementOnly( modelElement ), 'src', 'bar.jpg', 'new.jpg' );
-			modelDispatcher.convertAttribute( 'removeAttribute', createRangeOnElementOnly( modelElement ), 'title', 'bar', null );
-
-			expect( viewToString( viewRoot ) ).to.equal( '<div><img src="new.jpg"></img></div>' );
-		} );
-
-		it( 'should convert model images changes with caption to view', () => {
-			const modelElement = new ModelElement( 'image', { src: 'foo.jpg', title: 'foo' }, [
-				new ModelElement( 'caption', {}, new ModelText( 'foobar' ) )
-			] );
-			modelRoot.appendChildren( [ modelElement ] );
-			modelDispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
-
-			expect( viewToString( viewRoot ) ).to.equal(
-				'<div><figure><img src="foo.jpg" title="foo"></img><figcaption>foobar</figcaption></figure></div>'
-			);
-
-			modelElement.setAttribute( 'src', 'new.jpg' );
-			modelElement.removeAttribute( 'title' );
-			modelDispatcher.convertAttribute( 'changeAttribute', createRangeOnElementOnly( modelElement ), 'src', 'bar.jpg', 'new.jpg' );
-			modelDispatcher.convertAttribute( 'removeAttribute', createRangeOnElementOnly( modelElement ), 'title', 'bar', null );
-
-			expect( viewToString( viewRoot ) ).to.equal(
-				'<div><figure><img src="new.jpg"></img><figcaption>foobar</figcaption></figure></div>'
-			);
-		} );
-
-		it( 'should convert view image to model', () => {
-			const viewElement = new ViewContainerElement( 'img', { src: 'bar.jpg', title: 'bar' } );
-			const modelElement = viewDispatcher.convert( viewElement );
-
-			expect( modelToString( modelElement ) ).to.equal( '<image src="bar.jpg" title="bar"></image>' );
-		} );
-
-		it( 'should convert view figure to model', () => {
-			const viewElement = new ViewContainerElement(
-				'figure',
-				null,
-				[
-					new ViewContainerElement( 'img', { src: 'bar.jpg', title: 'bar' } ),
-					new ViewContainerElement( 'figcaption', null, new ViewText( 'foobar' ) )
-				]
-			);
-			const modelElement = viewDispatcher.convert( viewElement );
-
-			expect( modelToString( modelElement ) ).to.equal( '<image src="bar.jpg" title="bar"><caption>foobar</caption></image>' );
-		} );
-	} );
-
 	// Converter overwrites default attribute converter for `linkHref` and `linkTitle` attribute is set on `quote` element.
 	//
 	// Model:
@@ -326,46 +138,11 @@ describe( 'advanced-converters', () => {
 	//	 └─ foo
 	describe( 'custom attribute handling for given element', () => {
 		beforeEach( () => {
-			// NORMAL LINK MODEL TO VIEW CONVERTERS
-			modelDispatcher.on( 'addAttribute:linkHref', wrapItem( value => new ViewAttributeElement( 'a', { href: value } ) ) );
-			modelDispatcher.on( 'addAttribute:linkTitle', wrapItem( value => new ViewAttributeElement( 'a', { title: value } ) ) );
-
-			const changeLinkAttribute = function( elementCreator ) {
-				return ( evt, data, consumable, conversionApi ) => {
-					consumable.consume( data.item, eventNameToConsumableType( evt.name ) );
-
-					const viewRange = conversionApi.mapper.toViewRange( data.range );
-					const viewOldA = elementCreator( data.attributeOldValue );
-					const viewNewA = elementCreator( data.attributeNewValue );
-
-					viewWriter.unwrap( viewRange, viewOldA, evt.priority );
-					viewWriter.wrap( viewRange, viewNewA, evt.priority );
+			// Normal model-to-view converters for links.
+			modelDispatcher.on( 'attribute:linkHref', wrap( value => new ViewAttributeElement( 'a', { href: value } ) ) );
+			modelDispatcher.on( 'attribute:linkTitle', wrap( value => new ViewAttributeElement( 'a', { title: value } ) ) );
 
-					evt.stop();
-				};
-			};
-
-			modelDispatcher.on(
-				'changeAttribute:linkHref',
-				changeLinkAttribute( value => new ViewAttributeElement( 'a', { href: value } ) )
-			);
-
-			modelDispatcher.on(
-				'changeAttribute:linkTitle',
-				changeLinkAttribute( value => new ViewAttributeElement( 'a', { title: value } ) )
-			);
-
-			modelDispatcher.on(
-				'removeAttribute:linkHref',
-				unwrapItem( value => new ViewAttributeElement( 'a', { href: value } ) )
-			);
-
-			modelDispatcher.on(
-				'removeAttribute:linkTitle',
-				unwrapItem( value => new ViewAttributeElement( 'a', { title: value } ) )
-			);
-
-			// NORMAL LINK VIEW TO MODEL CONVERTERS
+			// Normal view-to-model converters for links.
 			viewDispatcher.on( 'element:a', ( evt, data, consumable, conversionApi ) => {
 				if ( consumable.consume( data.input, { name: true, attribute: 'href' } ) ) {
 					if ( !data.output ) {
@@ -390,7 +167,7 @@ describe( 'advanced-converters', () => {
 				}
 			} );
 
-			// QUOTE MODEL TO VIEW CONVERTERS
+			// Model-to-view converter for quote element.
 			modelDispatcher.on( 'insert:quote', ( evt, data, consumable, conversionApi ) => {
 				consumable.consume( data.item, 'insert' );
 
@@ -399,56 +176,63 @@ describe( 'advanced-converters', () => {
 
 				conversionApi.mapper.bindElements( data.item, viewElement );
 				viewWriter.insert( viewPosition, viewElement );
+			}, { priority: 'high' } );
 
-				if ( consumable.consume( data.item, 'addAttribute:linkHref' ) ) {
-					const viewA = new ViewAttributeElement(
-						'a', { href: data.item.getAttribute( 'linkHref' ) }, new ViewText( 'see source' )
-					);
-
-					if ( consumable.consume( data.item, 'addAttribute:linkTitle' ) ) {
-						viewA.setAttribute( 'title', data.item.getAttribute( 'linkTitle' ) );
-					}
+			modelDispatcher.on( 'attribute:linkHref:quote', linkHrefOnQuoteConverter, { priority: 'high' } );
+			modelDispatcher.on( 'attribute:linkTitle:quote', linkTitleOnQuoteConverter, { priority: 'high' } );
 
-					viewWriter.insert( new ViewPosition( viewElement, viewElement.childCount ), viewA );
+			function linkHrefOnQuoteConverter( evt, data, consumable, conversionApi ) {
+				if ( !consumable.consume( data.item, 'attribute:linkHref' ) ) {
+					return;
 				}
 
-				evt.stop();
-			}, { priority: 'high' } );
+				const viewQuote = conversionApi.mapper.toViewElement( data.item );
 
-			const modelChangeLinkAttrQuoteConverter = function( evt, data, consumable, conversionApi ) {
-				const viewKey = data.attributeKey.substr( 4 ).toLowerCase();
+				if ( data.attributeNewValue === null ) {
+					// Attribute was removed -> remove the view link.
+					const viewLink = viewQuote.getChild( viewQuote.childCount - 1 );
 
-				consumable.consume( data.item, eventNameToConsumableType( evt.name ) );
+					viewWriter.remove( ViewRange.createOn( viewLink ) );
 
-				const viewElement = conversionApi.mapper.toViewElement( data.item );
-				const viewA = viewElement.getChild( viewElement.childCount - 1 );
+					consumable.consume( data.item, 'attribute:linkTitle' );
+				} else if ( data.attributeOldValue === null ) {
+					// Attribute was added -> add the view link.
+					const viewLink = new ViewAttributeElement(
+						'a', { href: data.item.getAttribute( 'linkHref' ) }, new ViewText( 'see source' )
+					);
 
-				if ( data.attributeNewValue !== null ) {
-					viewA.setAttribute( viewKey, data.attributeNewValue );
+					if ( consumable.consume( data.item, 'attribute:linkTitle' ) && data.item.getAttribute( 'linkTitle' ) !== null ) {
+						viewLink.setAttribute( 'title', data.item.getAttribute( 'linkTitle' ) );
+					}
+
+					viewWriter.insert( new ViewPosition( viewQuote, viewQuote.childCount ), viewLink );
 				} else {
-					viewA.removeAttribute( viewKey );
+					// Attribute has changed -> change the existing view link.
+					const viewLink = viewQuote.getChild( viewQuote.childCount - 1 );
+					viewLink.setAttribute( 'href', data.attributeNewValue );
 				}
+			}
 
-				evt.stop();
-			};
-
-			modelDispatcher.on( 'changeAttribute:linkHref:quote', modelChangeLinkAttrQuoteConverter, { priority: 'high' } );
-			modelDispatcher.on( 'changeAttribute:linkTitle:quote', modelChangeLinkAttrQuoteConverter, { priority: 'high' } );
-
-			modelDispatcher.on( 'removeAttribute:linkHref:quote', ( evt, data, consumable, conversionApi ) => {
-				consumable.consume( data.item, eventNameToConsumableType( evt.name ) );
+			function linkTitleOnQuoteConverter( evt, data, consumable, conversionApi ) {
+				if ( !consumable.consume( data.item, 'attribute:linkTitle' ) ) {
+					return;
+				}
 
-				const viewElement = conversionApi.mapper.toViewElement( data.item );
-				const viewA = viewElement.getChild( viewElement.childCount - 1 );
-				const aIndex = viewA.index;
+				const viewQuote = conversionApi.mapper.toViewElement( data.item );
+				const viewLink = viewQuote.getChild( viewQuote.childCount - 1 );
 
-				viewWriter.remove( ViewRange.createFromParentsAndOffsets( viewElement, aIndex, viewElement, aIndex + 1 ) );
+				if ( !viewLink ) {
+					return;
+				}
 
-				evt.stop();
-			}, { priority: 'high' } );
-			modelDispatcher.on( 'removeAttribute:linkTitle:quote', modelChangeLinkAttrQuoteConverter, { priority: 'high' } );
+				if ( data.attributeNewValue === null ) {
+					viewLink.removeAttribute( 'title' );
+				} else {
+					viewLink.setAttribute( 'title', data.attributeNewValue );
+				}
+			}
 
-			// QUOTE VIEW TO MODEL CONVERTERS
+			// View-to-model converter for quote element.
 			viewDispatcher.on( 'element:blockquote', ( evt, data, consumable, conversionApi ) => {
 				if ( consumable.consume( data.input, { name: true } ) ) {
 					data.output = new ModelElement( 'quote' );
@@ -474,43 +258,47 @@ describe( 'advanced-converters', () => {
 
 		it( 'should convert model text with linkHref and linkTitle to view', () => {
 			const modelText = new ModelText( 'foo', { linkHref: 'foo.html', linkTitle: 'Foo title' } );
-			modelRoot.appendChildren( modelText );
 
-			let range = ModelRange.createIn( modelRoot );
+			// Let's insert text with link attributes.
+			model.change( writer => {
+				writer.insert(
+					modelText,
+					new ModelPosition( modelRoot, [ 0 ] )
+				);
+			} );
 
-			modelDispatcher.convertInsertion( range );
+			let range = ModelRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 3 );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><a href="foo.html" title="Foo title">foo</a></div>' );
 
 			// Let's change link's attributes.
-			modelWriter.setAttributes( {
-				linkHref: 'bar.html',
-				linkTitle: 'Bar title'
-			}, range );
-			modelDispatcher.convertAttribute( 'changeAttribute', range, 'linkHref', 'foo.html', 'bar.html' );
-			modelDispatcher.convertAttribute( 'changeAttribute', range, 'linkTitle', 'Foo title', 'Bar title' );
+			model.change( writer => {
+				writer.setAttribute( 'linkHref', 'bar.html', range );
+				writer.setAttribute( 'linkTitle', 'Bar title', range );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><a href="bar.html" title="Bar title">foo</a></div>' );
 
-			modelWriter.remove( ModelRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 1 ) );
-			modelDispatcher.convertRemove(
-				ModelPosition.createFromParentAndOffset( modelRoot, 0 ),
-				ModelRange.createIn( modelDoc.graveyard )
-			);
+			// Let's remove a letter from the link.
+			model.change( writer => {
+				writer.remove( ModelRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 1 ) );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><a href="bar.html" title="Bar title">oo</a></div>' );
 
-			range = ModelRange.createIn( modelRoot );
-
 			// Let's remove just one attribute.
-			modelWriter.removeAttribute( 'linkTitle', range );
-			modelDispatcher.convertAttribute( 'removeAttribute', range, 'linkTitle', 'Bar title', null );
+			model.change( writer => {
+				range = ModelRange.createIn( modelRoot );
+				writer.removeAttribute( 'linkTitle', range );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><a href="bar.html">oo</a></div>' );
 
 			// Let's remove the other attribute.
-			modelWriter.removeAttribute( 'linkHref', range );
-			modelDispatcher.convertAttribute( 'removeAttribute', range, 'linkHref', 'bar.html', null );
+			model.change( writer => {
+				range = ModelRange.createIn( modelRoot );
+				writer.removeAttribute( 'linkHref', range );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div>oo</div>' );
 		} );
@@ -527,46 +315,45 @@ describe( 'advanced-converters', () => {
 		} );
 
 		it( 'should convert quote model element with linkHref and linkTitle attribute to view', () => {
+			modelDispatcher.on( 'attribute:bold', wrap( new ViewAttributeElement( 'strong' ) ) );
+
 			const modelElement = new ModelElement( 'quote', { linkHref: 'foo.html', linkTitle: 'Foo source' }, new ModelText( 'foo' ) );
-			modelRoot.appendChildren( modelElement );
-			modelDispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+
+			// Let's insert a quote element with link attribute.
+			model.change( writer => {
+				writer.insert(
+					modelElement,
+					new ModelPosition( modelRoot, [ 0 ] )
+				);
+			} );
 
 			let expected = '<div><blockquote>foo<a href="foo.html" title="Foo source">see source</a></blockquote></div>';
 			expect( viewToString( viewRoot ) ).to.equal( expected );
 
-			modelDispatcher.on( 'addAttribute:bold', wrapItem( new ViewAttributeElement( 'strong' ) ) );
-			modelDispatcher.on( 'changeAttribute:bold', wrapItem( new ViewAttributeElement( 'strong' ) ) );
-			modelDispatcher.on( 'removeAttribute:bold', unwrapItem( new ViewAttributeElement( 'strong' ) ) );
-
-			modelElement.appendChildren( new ModelText( 'bar', { bold: true } ) );
-			modelDispatcher.convertInsertion( ModelRange.createFromParentsAndOffsets( modelElement, 3, modelElement, 6 ) );
+			// And insert some additional content into it.
+			model.change( writer => {
+				writer.insert(
+					new ModelText( 'bar', { bold: true } ),
+					new ModelPosition( modelRoot, [ 0, 3 ] )
+				);
+			} );
 
 			expected = '<div><blockquote>foo<strong>bar</strong><a href="foo.html" title="Foo source">see source</a></blockquote></div>';
 			expect( viewToString( viewRoot ) ).to.equal( expected );
 
-			modelElement.removeAttribute( 'linkTitle' );
-			modelElement.setAttribute( 'linkHref', 'bar.html' );
-
-			modelDispatcher.convertAttribute(
-				'removeAttribute',
-				createRangeOnElementOnly( modelElement ),
-				'linkTitle',
-				'Foo source',
-				null
-			);
-			modelDispatcher.convertAttribute(
-				'changeAttribute',
-				createRangeOnElementOnly( modelElement ),
-				'linkHref',
-				'foo.html',
-				'bar.html'
-			);
+			// Let's change some attributes.
+			model.change( writer => {
+				writer.removeAttribute( 'linkTitle', modelElement );
+				writer.setAttribute( 'linkHref', 'bar.html', modelElement );
+			} );
 
 			expected = '<div><blockquote>foo<strong>bar</strong><a href="bar.html">see source</a></blockquote></div>';
 			expect( viewToString( viewRoot ) ).to.equal( expected );
 
-			modelElement.removeAttribute( 'linkHref' );
-			modelDispatcher.convertAttribute( 'removeAttribute', ModelRange.createIn( modelRoot ), 'linkHref', 'bar.html', null );
+			// Let's remove the only attribute connected with link.
+			model.change( writer => {
+				writer.removeAttribute( 'linkHref', modelElement );
+			} );
 
 			expected = '<div><blockquote>foo<strong>bar</strong></blockquote></div>';
 			expect( viewToString( viewRoot ) ).to.equal( expected );
@@ -662,9 +449,7 @@ describe( 'advanced-converters', () => {
 		beforeEach( () => {
 			// "Universal" converters
 			modelDispatcher.on( 'insert', insertElement( data => new ViewContainerElement( data.item.name ) ), { priority: 'lowest' } );
-			modelDispatcher.on( 'addAttribute', setAttribute(), { priority: 'lowest' } );
-			modelDispatcher.on( 'changeAttribute', setAttribute(), { priority: 'lowest' } );
-			modelDispatcher.on( 'removeAttribute', removeAttribute(), { priority: 'lowest' } );
+			modelDispatcher.on( 'attribute', changeAttribute(), { priority: 'lowest' } );
 
 			viewDispatcher.on( 'element', ( evt, data, consumable, conversionApi ) => {
 				if ( consumable.consume( data.input, { name: true } ) ) {
@@ -682,9 +467,7 @@ describe( 'advanced-converters', () => {
 
 			// "Real" converters -- added with higher priority. Should overwrite the "universal" converters.
 			modelDispatcher.on( 'insert:image', insertElement( new ViewContainerElement( 'img' ) ) );
-			modelDispatcher.on( 'addAttribute:bold', wrapItem( new ViewAttributeElement( 'strong' ) ) );
-			modelDispatcher.on( 'changeAttribute:bold', wrapItem( new ViewAttributeElement( 'strong' ) ) );
-			modelDispatcher.on( 'removeAttribute:bold', unwrapItem( new ViewAttributeElement( 'strong' ) ) );
+			modelDispatcher.on( 'attribute:bold', wrap( new ViewAttributeElement( 'strong' ) ) );
 
 			viewDispatcher.on( 'element:img', ( evt, data, consumable ) => {
 				if ( consumable.consume( data.input, { name: true } ) ) {
@@ -725,7 +508,7 @@ describe( 'advanced-converters', () => {
 			] );
 
 			modelRoot.appendChildren( modelElement );
-			modelDispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+			modelDispatcher.convertInsert( ModelRange.createIn( modelRoot ) );
 
 			expect( viewToString( viewRoot ) ).to.equal(
 				'<div>' +

+ 159 - 166
packages/ckeditor5-engine/tests/conversion/buildmodelconverter.js

@@ -11,27 +11,13 @@ import ModelText from '../../src/model/text';
 import ModelRange from '../../src/model/range';
 import ModelPosition from '../../src/model/position';
 
-import ViewDocument from '../../src/view/document';
 import ViewElement from '../../src/view/element';
 import ViewContainerElement from '../../src/view/containerelement';
 import ViewAttributeElement from '../../src/view/attributeelement';
 import ViewUIElement from '../../src/view/uielement';
 import ViewText from '../../src/view/text';
 
-import Mapper from '../../src/conversion/mapper';
-import ModelConversionDispatcher from '../../src/conversion/modelconversiondispatcher';
-
-import {
-	insertText,
-	remove
-} from '../../src/conversion/model-to-view-converters';
-
-import {
-	convertCollapsedSelection,
-	clearAttributes
-} from '../../src/conversion/model-selection-to-view-converters';
-
-import { createRangeOnElementOnly } from '../../tests/model/_utils/utils';
+import EditingController from '../../src/controller/editingcontroller';
 
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 
@@ -69,38 +55,31 @@ function viewToString( item ) {
 }
 
 describe( 'Model converter builder', () => {
-	let dispatcher, mapper, modelDoc, modelRoot, viewDoc, viewRoot, viewSelection, model;
+	let dispatcher, modelDoc, modelRoot, viewRoot, viewSelection, model, controller;
 
 	beforeEach( () => {
 		model = new Model();
 		modelDoc = model.document;
-		modelRoot = modelDoc.createRoot( 'root', 'root' );
+		modelRoot = modelDoc.createRoot();
 
-		viewDoc = new ViewDocument();
-		viewRoot = viewDoc.createRoot( 'div' );
-		viewSelection = viewDoc.selection;
+		controller = new EditingController( model );
+		controller.createRoot( 'div' );
 
-		mapper = new Mapper();
-		mapper.bindElements( modelRoot, viewRoot );
+		dispatcher = controller.modelToView;
 
-		dispatcher = new ModelConversionDispatcher( model, { mapper, viewSelection } );
-
-		dispatcher.on( 'insert:$text', insertText() );
-		dispatcher.on( 'remove', remove() );
-	} );
+		viewRoot = controller.view.getRoot();
+		viewSelection = controller.view.selection;
 
-	afterEach( () => {
-		viewDoc.destroy();
+		buildModelConverter().for( dispatcher ).fromElement( 'paragraph' ).toElement( 'p' );
 	} );
 
 	describe( 'model element to view element conversion', () => {
 		it( 'using passed view element name', () => {
-			buildModelConverter().for( dispatcher ).fromElement( 'paragraph' ).toElement( 'p' );
-
 			const modelElement = new ModelElement( 'paragraph', null, new ModelText( 'foobar' ) );
-			modelRoot.appendChildren( modelElement );
 
-			dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+			model.change( writer => {
+				writer.insert( modelElement, ModelPosition.createAt( modelRoot, 0 ) );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
 		} );
@@ -109,9 +88,10 @@ describe( 'Model converter builder', () => {
 			buildModelConverter().for( dispatcher ).fromElement( 'image' ).toElement( new ViewContainerElement( 'img' ) );
 
 			const modelElement = new ModelElement( 'image' );
-			modelRoot.appendChildren( modelElement );
 
-			dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+			model.change( writer => {
+				writer.insert( modelElement, ModelPosition.createAt( modelRoot, 0 ) );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><img></img></div>' );
 		} );
@@ -122,32 +102,30 @@ describe( 'Model converter builder', () => {
 				.toElement( data => new ViewContainerElement( 'h' + data.item.getAttribute( 'level' ) ) );
 
 			const modelElement = new ModelElement( 'header', { level: 2 }, new ModelText( 'foobar' ) );
-			modelRoot.appendChildren( modelElement );
 
-			dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+			model.change( writer => {
+				writer.insert( modelElement, ModelPosition.createAt( modelRoot, 0 ) );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><h2>foobar</h2></div>' );
 		} );
 	} );
 
 	describe( 'model attribute to view element conversion', () => {
-		beforeEach( () => {
-			buildModelConverter().for( dispatcher ).fromElement( 'paragraph' ).toElement( 'p' );
-		} );
-
 		it( 'using passed view element name', () => {
 			buildModelConverter().for( dispatcher ).fromAttribute( 'bold' ).toElement( 'strong' );
 
 			const modelElement = new ModelText( 'foo', { bold: true } );
-			modelRoot.appendChildren( modelElement );
 
-			dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+			model.change( writer => {
+				writer.insert( modelElement, ModelPosition.createAt( modelRoot, 0 ) );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><strong>foo</strong></div>' );
 
-			modelRoot.removeAttribute( 'bold' );
-
-			dispatcher.convertAttribute( 'removeAttribute', ModelRange.createIn( modelRoot ), 'bold', true, null );
+			model.change( writer => {
+				writer.removeAttribute( 'bold', modelElement );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div>foo</div>' );
 		} );
@@ -156,15 +134,16 @@ describe( 'Model converter builder', () => {
 			buildModelConverter().for( dispatcher ).fromAttribute( 'bold' ).toElement( new ViewAttributeElement( 'strong' ) );
 
 			const modelElement = new ModelText( 'foo', { bold: true } );
-			modelRoot.appendChildren( modelElement );
 
-			dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+			model.change( writer => {
+				writer.insert( modelElement, ModelPosition.createAt( modelRoot, 0 ) );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><strong>foo</strong></div>' );
 
-			modelRoot.removeAttribute( 'bold' );
-
-			dispatcher.convertAttribute( 'removeAttribute', ModelRange.createIn( modelRoot ), 'bold', true, null );
+			model.change( writer => {
+				writer.removeAttribute( 'bold', modelElement );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div>foo</div>' );
 		} );
@@ -173,43 +152,38 @@ describe( 'Model converter builder', () => {
 			buildModelConverter().for( dispatcher ).fromAttribute( 'italic' ).toElement( value => new ViewAttributeElement( value ) );
 
 			const modelElement = new ModelText( 'foo', { italic: 'em' } );
-			modelRoot.appendChildren( modelElement );
 
-			dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+			model.change( writer => {
+				writer.insert( modelElement, ModelPosition.createAt( modelRoot, 0 ) );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><em>foo</em></div>' );
 
-			modelRoot.setAttribute( 'italic', 'i' );
-
-			dispatcher.convertAttribute( 'changeAttribute', ModelRange.createIn( modelRoot ), 'italic', 'em', 'i' );
+			model.change( writer => {
+				writer.setAttribute( 'italic', 'i', modelElement );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><i>foo</i></div>' );
 
-			modelRoot.removeAttribute( 'italic' );
-
-			dispatcher.convertAttribute( 'removeAttribute', ModelRange.createIn( modelRoot ), 'italic', 'i', null );
+			model.change( writer => {
+				writer.removeAttribute( 'italic', modelElement );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div>foo</div>' );
 		} );
 
 		it( 'selection conversion', () => {
-			// This test requires collapsed range selection converter (breaking attributes)  and clearing "artifacts".
-			dispatcher.on( 'selection', clearAttributes() );
-			dispatcher.on( 'selection', convertCollapsedSelection() );
-
 			// Model converter builder should add selection converter.
 			buildModelConverter().for( dispatcher ).fromAttribute( 'italic' ).toElement( value => new ViewAttributeElement( value ) );
 
-			modelRoot.appendChildren( new ModelText( 'foo', { italic: 'em' } ) );
-
-			// Set collapsed selection after "f".
-			const position = new ModelPosition( modelRoot, [ 1 ] );
-			modelDoc.selection.setRanges( [ new ModelRange( position, position ) ] );
-			modelDoc.selection._updateAttributes();
+			model.change( writer => {
+				writer.insert( new ModelText( 'foo', { italic: 'em' } ), ModelPosition.createAt( modelRoot, 0 ) );
 
-			// Convert stuff.
-			dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
-			dispatcher.convertSelection( modelDoc.selection, [] );
+				// Set collapsed selection after "f".
+				const position = new ModelPosition( modelRoot, [ 1 ] );
+				modelDoc.selection.setRanges( [ new ModelRange( position, position ) ] );
+				modelDoc.selection._updateAttributes();
+			} );
 
 			// Check if view structure is ok.
 			expect( viewToString( viewRoot ) ).to.equal( '<div><em>foo</em></div>' );
@@ -222,8 +196,9 @@ describe( 'Model converter builder', () => {
 			expect( ranges[ 0 ].start.offset ).to.equal( 1 );
 
 			// Change selection attribute, convert it.
-			modelDoc.selection.setAttribute( 'italic', 'i' );
-			dispatcher.convertSelection( modelDoc.selection, [] );
+			model.change( () => {
+				modelDoc.selection.setAttribute( 'italic', 'i' );
+			} );
 
 			// Check if view structure has changed.
 			expect( viewToString( viewRoot ) ).to.equal( '<div><em>f</em><i></i><em>oo</em></div>' );
@@ -236,16 +211,18 @@ describe( 'Model converter builder', () => {
 			expect( ranges[ 0 ].start.offset ).to.equal( 0 );
 
 			// Some more tests checking how selection attributes changes are converted:
-			modelDoc.selection.removeAttribute( 'italic' );
-			dispatcher.convertSelection( modelDoc.selection, [] );
+			model.change( () => {
+				modelDoc.selection.removeAttribute( 'italic' );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><em>f</em><em>oo</em></div>' );
 			ranges = Array.from( viewSelection.getRanges() );
 			expect( ranges[ 0 ].start.parent.name ).to.equal( 'div' );
 			expect( ranges[ 0 ].start.offset ).to.equal( 1 );
 
-			modelDoc.selection.setAttribute( 'italic', 'em' );
-			dispatcher.convertSelection( modelDoc.selection, [] );
+			model.change( () => {
+				modelDoc.selection.setAttribute( 'italic', 'em' );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><em>foo</em></div>' );
 			ranges = Array.from( viewSelection.getRanges() );
@@ -257,27 +234,26 @@ describe( 'Model converter builder', () => {
 	} );
 
 	describe( 'model attribute to view attribute conversion', () => {
-		beforeEach( () => {
-			buildModelConverter().for( dispatcher ).fromElement( 'paragraph' ).toElement( 'p' );
-		} );
-
 		it( 'using default 1-to-1 conversion', () => {
 			buildModelConverter().for( dispatcher ).fromAttribute( 'class' ).toAttribute();
 
 			const modelElement = new ModelElement( 'paragraph', { class: 'myClass' }, new ModelText( 'foobar' ) );
-			modelRoot.appendChildren( modelElement );
 
-			dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+			model.change( writer => {
+				writer.insert( modelElement, ModelPosition.createAt( modelRoot, 0 ) );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><p class="myClass">foobar</p></div>' );
 
-			modelElement.setAttribute( 'class', 'newClass' );
-			dispatcher.convertAttribute( 'changeAttribute', createRangeOnElementOnly( modelElement ), 'class', 'myClass', 'newClass' );
+			model.change( writer => {
+				writer.setAttribute( 'class', 'newClass', modelElement );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><p class="newClass">foobar</p></div>' );
 
-			modelElement.removeAttribute( 'class' );
-			dispatcher.convertAttribute( 'removeAttribute', createRangeOnElementOnly( modelElement ), 'class', 'newClass', null );
+			model.change( writer => {
+				writer.removeAttribute( 'class', modelElement );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
 		} );
@@ -286,19 +262,22 @@ describe( 'Model converter builder', () => {
 			buildModelConverter().for( dispatcher ).fromAttribute( 'theme' ).toAttribute( 'class' );
 
 			const modelElement = new ModelElement( 'paragraph', { theme: 'abc' }, new ModelText( 'foobar' ) );
-			modelRoot.appendChildren( modelElement );
 
-			dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+			model.change( writer => {
+				writer.insert( modelElement, ModelPosition.createAt( modelRoot, 0 ) );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><p class="abc">foobar</p></div>' );
 
-			modelElement.setAttribute( 'theme', 'xyz' );
-			dispatcher.convertAttribute( 'changeAttribute', createRangeOnElementOnly( modelElement ), 'theme', 'abc', 'xyz' );
+			model.change( writer => {
+				writer.setAttribute( 'theme', 'xyz', modelElement );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><p class="xyz">foobar</p></div>' );
 
-			modelElement.removeAttribute( 'theme' );
-			dispatcher.convertAttribute( 'removeAttribute', createRangeOnElementOnly( modelElement ), 'theme', 'xyz', null );
+			model.change( writer => {
+				writer.removeAttribute( 'theme', modelElement );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
 		} );
@@ -307,14 +286,16 @@ describe( 'Model converter builder', () => {
 			buildModelConverter().for( dispatcher ).fromAttribute( 'highlighted' ).toAttribute( 'style', 'background:yellow' );
 
 			const modelElement = new ModelElement( 'paragraph', { 'highlighted': true }, new ModelText( 'foobar' ) );
-			modelRoot.appendChildren( modelElement );
 
-			dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+			model.change( writer => {
+				writer.insert( modelElement, ModelPosition.createAt( modelRoot, 0 ) );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><p style="background:yellow;">foobar</p></div>' );
 
-			modelElement.removeAttribute( 'highlighted' );
-			dispatcher.convertAttribute( 'removeAttribute', createRangeOnElementOnly( modelElement ), 'highlighted', true, null );
+			model.change( writer => {
+				writer.removeAttribute( 'highlighted', modelElement );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
 		} );
@@ -325,19 +306,22 @@ describe( 'Model converter builder', () => {
 				.toAttribute( value => ( { key: 'class', value: value + '-theme' } ) );
 
 			const modelElement = new ModelElement( 'paragraph', { theme: 'nice' }, new ModelText( 'foobar' ) );
-			modelRoot.appendChildren( modelElement );
 
-			dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+			model.change( writer => {
+				writer.insert( modelElement, ModelPosition.createAt( modelRoot, 0 ) );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><p class="nice-theme">foobar</p></div>' );
 
-			modelElement.setAttribute( 'theme', 'good' );
-			dispatcher.convertAttribute( 'changeAttribute', createRangeOnElementOnly( modelElement ), 'theme', 'nice', 'good' );
+			model.change( writer => {
+				writer.setAttribute( 'theme', 'good', modelElement );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><p class="good-theme">foobar</p></div>' );
 
-			modelElement.removeAttribute( 'theme' );
-			dispatcher.convertAttribute( 'removeAttribute', createRangeOnElementOnly( modelElement ), 'theme', 'good', null );
+			model.change( writer => {
+				writer.removeAttribute( 'theme', modelElement );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
 		} );
@@ -349,13 +333,10 @@ describe( 'Model converter builder', () => {
 		beforeEach( () => {
 			modelText = new ModelText( 'foobar' );
 			modelElement = new ModelElement( 'paragraph', null, [ modelText ] );
-			modelRoot.appendChildren( modelElement );
-
-			const viewText = new ViewText( 'foobar' );
-			const viewElement = new ViewContainerElement( 'p', null, [ viewText ] );
-			viewRoot.appendChildren( viewElement );
 
-			mapper.bindElements( modelElement, viewElement );
+			model.change( writer => {
+				writer.insert( modelElement, ModelPosition.createAt( modelRoot, 0 ) );
+			} );
 		} );
 
 		it( 'using passed highlight descriptor object', () => {
@@ -365,7 +346,9 @@ describe( 'Model converter builder', () => {
 				attributes: { title: 'highlight title' }
 			} );
 
-			dispatcher.convertMarker( 'addMarker', 'search', ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 4 ) );
+			model.change( () => {
+				model.markers.set( 'search', ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 4 ) );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal(
 				'<div>' +
@@ -378,9 +361,9 @@ describe( 'Model converter builder', () => {
 
 			expect( viewRoot.getChild( 0 ).getChild( 1 ).priority ).to.equal( 3 );
 
-			dispatcher.convertMarker(
-				'removeMarker', 'search', ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 4 )
-			);
+			model.change( () => {
+				model.markers.remove( 'search' );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
 		} );
@@ -392,7 +375,9 @@ describe( 'Model converter builder', () => {
 				attributes: { title: 'highlight title' }
 			} ) );
 
-			dispatcher.convertMarker( 'addMarker', 'search', ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 4 ) );
+			model.change( () => {
+				model.markers.set( 'search', ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 4 ) );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal(
 				'<div>' +
@@ -405,9 +390,9 @@ describe( 'Model converter builder', () => {
 
 			expect( viewRoot.getChild( 0 ).getChild( 1 ).priority ).to.equal( 12 );
 
-			dispatcher.convertMarker(
-				'removeMarker', 'search', ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 4 )
-			);
+			model.change( () => {
+				model.markers.remove( 'search' );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
 		} );
@@ -417,28 +402,27 @@ describe( 'Model converter builder', () => {
 				class: 'highlight'
 			} );
 
-			dispatcher.convertMarker( 'addMarker', 'search', ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 2 ) );
+			model.change( () => {
+				model.markers.set( 'search', ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 2 ) );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
 
-			dispatcher.convertMarker(
-				'removeMarker', 'search', ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 2 )
-			);
+			model.change( () => {
+				model.markers.remove( 'search' );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
 		} );
 
 		it( 'should create converters with provided priority', () => {
-			buildModelConverter().for( dispatcher ).fromMarker( 'search' ).toHighlight( {
-				class: 'highlight'
-			} );
+			buildModelConverter().for( dispatcher ).fromMarker( 'search' ).toHighlight( { class: 'highlight' } );
+			buildModelConverter().for( dispatcher ).fromMarker( 'search' ).withPriority( 'high' ).toHighlight( { class: 'override' } );
 
-			buildModelConverter().for( dispatcher ).fromMarker( 'search' ).withPriority( 'high' ).toHighlight( {
-				class: 'override'
+			model.change( () => {
+				model.markers.set( 'search', ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 4 ) );
 			} );
 
-			dispatcher.convertMarker( 'addMarker', 'search', ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 4 ) );
-
 			expect( viewToString( viewRoot ) ).to.equal(
 				'<div>' +
 					'<p>' +
@@ -468,13 +452,10 @@ describe( 'Model converter builder', () => {
 		beforeEach( () => {
 			modelText = new ModelText( 'foobar' );
 			modelElement = new ModelElement( 'paragraph', null, [ modelText ] );
-			modelRoot.appendChildren( modelElement );
-
-			const viewText = new ViewText( 'foobar' );
-			const viewElement = new ViewContainerElement( 'p', null, [ viewText ] );
-			viewRoot.appendChildren( viewElement );
 
-			mapper.bindElements( modelElement, viewElement );
+			model.change( writer => {
+				writer.insert( modelElement, ModelPosition.createAt( modelRoot, 0 ) );
+			} );
 		} );
 
 		describe( 'collapsed range', () => {
@@ -485,11 +466,15 @@ describe( 'Model converter builder', () => {
 			it( 'using passed view element name', () => {
 				buildModelConverter().for( dispatcher ).fromMarker( 'search' ).toElement( 'span' );
 
-				dispatcher.convertMarker( 'addMarker', 'search', range );
+				model.change( () => {
+					model.markers.set( 'search', range );
+				} );
 
 				expect( viewToString( viewRoot ) ).to.equal( '<div><p>fo<span></span>obar</p></div>' );
 
-				dispatcher.convertMarker( 'removeMarker', 'search', range );
+				model.change( () => {
+					model.markers.remove( 'search' );
+				} );
 
 				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
 			} );
@@ -498,11 +483,15 @@ describe( 'Model converter builder', () => {
 				const viewElement = new ViewUIElement( 'span', { class: 'search' } );
 				buildModelConverter().for( dispatcher ).fromMarker( 'search' ).toElement( viewElement );
 
-				dispatcher.convertMarker( 'addMarker', 'search', range );
+				model.change( () => {
+					model.markers.set( 'search', range );
+				} );
 
 				expect( viewToString( viewRoot ) ).to.equal( '<div><p>fo<span class="search"></span>obar</p></div>' );
 
-				dispatcher.convertMarker( 'removeMarker', 'search', range );
+				model.change( () => {
+					model.markers.remove( 'search' );
+				} );
 
 				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
 			} );
@@ -514,11 +503,15 @@ describe( 'Model converter builder', () => {
 					return new ViewUIElement( 'span', { class: className } );
 				} );
 
-				dispatcher.convertMarker( 'addMarker', 'search:red', range );
+				model.change( () => {
+					model.markers.set( 'search:red', range );
+				} );
 
 				expect( viewToString( viewRoot ) ).to.equal( '<div><p>fo<span class="search search-color-red"></span>obar</p></div>' );
 
-				dispatcher.convertMarker( 'removeMarker', 'search:red', range );
+				model.change( () => {
+					model.markers.remove( 'search:red' );
+				} );
 
 				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
 			} );
@@ -532,11 +525,15 @@ describe( 'Model converter builder', () => {
 			it( 'using passed view element name', () => {
 				buildModelConverter().for( dispatcher ).fromMarker( 'search' ).toElement( 'span' );
 
-				dispatcher.convertMarker( 'addMarker', 'search', range );
+				model.change( () => {
+					model.markers.set( 'search', range );
+				} );
 
 				expect( viewToString( viewRoot ) ).to.equal( '<div><p>fo<span></span>ob<span></span>ar</p></div>' );
 
-				dispatcher.convertMarker( 'removeMarker', 'search', range );
+				model.change( () => {
+					model.markers.remove( 'search' );
+				} );
 
 				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
 			} );
@@ -545,13 +542,17 @@ describe( 'Model converter builder', () => {
 				const viewElement = new ViewUIElement( 'span', { class: 'search' } );
 				buildModelConverter().for( dispatcher ).fromMarker( 'search' ).toElement( viewElement );
 
-				dispatcher.convertMarker( 'addMarker', 'search', range );
+				model.change( () => {
+					model.markers.set( 'search', range );
+				} );
 
 				expect( viewToString( viewRoot ) ).to.equal(
 					'<div><p>fo<span class="search"></span>ob<span class="search"></span>ar</p></div>'
 				);
 
-				dispatcher.convertMarker( 'removeMarker', 'search', range );
+				model.change( () => {
+					model.markers.remove( 'search' );
+				} );
 
 				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
 			} );
@@ -563,13 +564,17 @@ describe( 'Model converter builder', () => {
 					return new ViewUIElement( 'span', { class: className } );
 				} );
 
-				dispatcher.convertMarker( 'addMarker', 'search:red', range );
+				model.change( () => {
+					model.markers.set( 'search:red', range );
+				} );
 
 				expect( viewToString( viewRoot ) ).to.equal(
 					'<div><p>fo<span class="search search-color-red"></span>ob<span class="search search-color-red"></span>ar</p></div>'
 				);
 
-				dispatcher.convertMarker( 'removeMarker', 'search:red', range );
+				model.change( () => {
+					model.markers.remove( 'search:red' );
+				} );
 
 				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
 			} );
@@ -581,29 +586,17 @@ describe( 'Model converter builder', () => {
 			buildModelConverter().for( dispatcher ).fromMarker( 'search' ).toElement( 'normal' );
 			buildModelConverter().for( dispatcher ).fromMarker( 'search' ).withPriority( 'high' ).toElement( 'high' );
 
-			dispatcher.convertMarker( 'addMarker', 'search', range );
+			model.change( () => {
+				model.markers.set( 'search', range );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><p>fo<high></high>obar</p></div>' );
 		} );
-	} );
-
-	describe( 'withPriority', () => {
-		it( 'should change default converters priority', () => {
-			buildModelConverter().for( dispatcher ).fromElement( 'custom' ).toElement( 'custom' );
-			buildModelConverter().for( dispatcher ).fromElement( 'custom' ).withPriority( 'high' ).toElement( 'other' );
-
-			const modelElement = new ModelElement( 'custom', null, new ModelText( 'foobar' ) );
-			modelRoot.appendChildren( modelElement );
 
-			dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
-
-			expect( viewToString( viewRoot ) ).to.equal( '<div><other>foobar</other></div>' );
+		it( 'should throw when trying to build model element to view attribute converter', () => {
+			expect( () => {
+				buildModelConverter().for( dispatcher ).fromElement( 'paragraph' ).toAttribute( 'paragraph', true );
+			} ).to.throw( CKEditorError, /^build-model-converter-non-attribute-to-attribute/ );
 		} );
 	} );
-
-	it( 'should throw when trying to build model element to view attribute converter', () => {
-		expect( () => {
-			buildModelConverter().for( dispatcher ).fromElement( 'paragraph' ).toAttribute( 'paragraph', true );
-		} ).to.throw( CKEditorError, /^build-model-converter-non-attribute-to-attribute/ );
-	} );
 } );

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

@@ -28,9 +28,10 @@ import {
 import {
 	insertElement,
 	insertText,
-	wrapItem,
+	wrap,
+	highlightElement,
 	highlightText,
-	highlightElement
+	removeHighlight
 } from '../../src/conversion/model-to-view-converters';
 
 import { stringify as stringifyView } from '../../src/dev-utils/view';
@@ -59,12 +60,11 @@ describe( 'model-selection-to-view-converters', () => {
 		dispatcher = new ModelConversionDispatcher( model, { mapper, viewSelection } );
 
 		dispatcher.on( 'insert:$text', insertText() );
-		dispatcher.on( 'addAttribute:bold', wrapItem( new ViewAttributeElement( 'strong' ) ) );
+		dispatcher.on( 'attribute:bold', wrap( new ViewAttributeElement( 'strong' ) ) );
 
 		dispatcher.on( 'addMarker:marker', highlightText( highlightDescriptor ) );
 		dispatcher.on( 'addMarker:marker', highlightElement( highlightDescriptor ) );
-		dispatcher.on( 'removeMarker:marker', highlightText( highlightDescriptor ) );
-		dispatcher.on( 'removeMarker:marker', highlightElement( highlightDescriptor ) );
+		dispatcher.on( 'removeMarker:marker', removeHighlight( highlightDescriptor ) );
 
 		// Default selection converters.
 		dispatcher.on( 'selection', clearAttributes(), { priority: 'low' } );
@@ -231,8 +231,8 @@ describe( 'model-selection-to-view-converters', () => {
 				viewRoot.removeChildren( 0, viewRoot.childCount );
 
 				// Convert model to view.
-				dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
-				dispatcher.convertMarker( 'addMarker', marker.name, marker.getRange() );
+				dispatcher.convertInsert( ModelRange.createIn( modelRoot ) );
+				dispatcher.convertMarkerAdd( marker.name, marker.getRange() );
 
 				const markers = Array.from( model.markers.getMarkersAtPosition( modelSelection.getFirstPosition() ) );
 				dispatcher.convertSelection( modelSelection, markers );
@@ -258,8 +258,8 @@ describe( 'model-selection-to-view-converters', () => {
 				viewRoot.removeChildren( 0, viewRoot.childCount );
 
 				// Convert model to view.
-				dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
-				dispatcher.convertMarker( 'addMarker', marker.name, marker.getRange() );
+				dispatcher.convertInsert( ModelRange.createIn( modelRoot ) );
+				dispatcher.convertMarkerAdd( marker.name, marker.getRange() );
 
 				const markers = Array.from( model.markers.getMarkersAtPosition( modelSelection.getFirstPosition() ) );
 				dispatcher.convertSelection( modelSelection, markers );
@@ -283,8 +283,8 @@ describe( 'model-selection-to-view-converters', () => {
 				viewRoot.removeChildren( 0, viewRoot.childCount );
 
 				// Convert model to view.
-				dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
-				dispatcher.convertMarker( 'addMarker', marker.name, marker.getRange() );
+				dispatcher.convertInsert( ModelRange.createIn( modelRoot ) );
+				dispatcher.convertMarkerAdd( marker.name, marker.getRange() );
 
 				const markers = Array.from( model.markers.getMarkersAtPosition( modelSelection.getFirstPosition() ) );
 				dispatcher.convertSelection( modelSelection, markers );
@@ -296,6 +296,7 @@ describe( 'model-selection-to-view-converters', () => {
 
 			it( 'in marker - should merge with the rest of attribute elements', () => {
 				dispatcher.on( 'addMarker:marker2', highlightText( data => ( { 'class': data.markerName } ) ) );
+				dispatcher.on( 'addMarker:marker2', highlightElement( data => ( { 'class': data.markerName } ) ) );
 				dispatcher.on( 'selectionMarker:marker2', convertSelectionMarker( data => ( { 'class': data.markerName } ) ) );
 
 				setModelData( model, 'foobar' );
@@ -307,8 +308,8 @@ describe( 'model-selection-to-view-converters', () => {
 				viewRoot.removeChildren( 0, viewRoot.childCount );
 
 				// Convert model to view.
-				dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
-				dispatcher.convertMarker( 'addMarker', marker.name, marker.getRange() );
+				dispatcher.convertInsert( ModelRange.createIn( modelRoot ) );
+				dispatcher.convertMarkerAdd( marker.name, marker.getRange() );
 
 				const markers = Array.from( model.markers.getMarkersAtPosition( modelSelection.getFirstPosition() ) );
 				dispatcher.convertSelection( modelSelection, markers );
@@ -332,8 +333,8 @@ describe( 'model-selection-to-view-converters', () => {
 				viewRoot.removeChildren( 0, viewRoot.childCount );
 
 				// Convert model to view.
-				dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
-				dispatcher.convertMarker( 'addMarker', marker.name, marker.getRange() );
+				dispatcher.convertInsert( ModelRange.createIn( modelRoot ) );
+				dispatcher.convertMarkerAdd( marker.name, marker.getRange() );
 
 				const markers = Array.from( model.markers.getMarkersAtPosition( modelSelection.getFirstPosition() ) );
 				dispatcher.convertSelection( modelSelection, markers );
@@ -372,7 +373,7 @@ describe( 'model-selection-to-view-converters', () => {
 				modelSelection.setAttribute( 'bold', true );
 
 				// Convert model to view.
-				dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+				dispatcher.convertInsert( ModelRange.createIn( modelRoot ) );
 
 				// Add ui element to view.
 				const uiElement = new ViewUIElement( 'span' );
@@ -393,7 +394,7 @@ describe( 'model-selection-to-view-converters', () => {
 				modelSelection.setAttribute( 'bold', true );
 
 				// Convert model to view.
-				dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+				dispatcher.convertInsert( ModelRange.createIn( modelRoot ) );
 
 				// Add ui element to view.
 				const uiElement = new ViewUIElement( 'span' );
@@ -467,7 +468,7 @@ describe( 'model-selection-to-view-converters', () => {
 		describe( 'clearAttributes', () => {
 			it( 'should remove all ranges before adding new range', () => {
 				dispatcher.on( 'selectionAttribute:bold', convertSelectionAttribute( new ViewAttributeElement( 'b' ) ) );
-				dispatcher.on( 'addAttribute:style', wrapItem( new ViewAttributeElement( 'b' ) ) );
+				dispatcher.on( 'attribute:style', wrap( new ViewAttributeElement( 'b' ) ) );
 
 				test(
 					[ 3, 3 ],
@@ -489,7 +490,7 @@ describe( 'model-selection-to-view-converters', () => {
 
 			it( 'should do nothing if the attribute element had been already removed', () => {
 				dispatcher.on( 'selectionAttribute:bold', convertSelectionAttribute( new ViewAttributeElement( 'b' ) ) );
-				dispatcher.on( 'addAttribute:style', wrapItem( new ViewAttributeElement( 'b' ) ) );
+				dispatcher.on( 'attribute:style', wrap( new ViewAttributeElement( 'b' ) ) );
 
 				test(
 					[ 3, 3 ],
@@ -536,7 +537,7 @@ describe( 'model-selection-to-view-converters', () => {
 			}
 
 			dispatcher.on( 'selectionAttribute:theme', convertSelectionAttribute( themeElementCreator ) );
-			dispatcher.on( 'addAttribute:theme', wrapItem( themeElementCreator ) );
+			dispatcher.on( 'attribute:theme', wrap( themeElementCreator ) );
 
 			dispatcher.on( 'selectionAttribute:italic', convertSelectionAttribute( new ViewAttributeElement( 'em' ) ) );
 		} );
@@ -736,7 +737,7 @@ describe( 'model-selection-to-view-converters', () => {
 		viewRoot.removeChildren( 0, viewRoot.childCount );
 
 		// Convert model to view.
-		dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+		dispatcher.convertInsert( ModelRange.createIn( modelRoot ) );
 		dispatcher.convertSelection( modelSelection, [] );
 
 		// Stringify view and check if it is same as expected.

文件差异内容过多而无法显示
+ 512 - 781
packages/ckeditor5-engine/tests/conversion/model-to-view-converters.js


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

@@ -802,12 +802,14 @@ describe( 'debug tools', () => {
 			const modelRoot = modelDoc.getRoot();
 			const viewDoc = editor.editing.view;
 
-			const insert = new InsertOperation( ModelPosition.createAt( modelRoot, 0 ), new ModelText( 'foobar' ), 0 );
-			model.applyOperation( wrapInDelta( insert ) );
+			model.change( () => {
+				const insert = new InsertOperation( ModelPosition.createAt( modelRoot, 0 ), new ModelText( 'foobar' ), 0 );
+				model.applyOperation( wrapInDelta( insert ) );
 
-			const graveyard = modelDoc.graveyard;
-			const remove = new RemoveOperation( ModelPosition.createAt( modelRoot, 1 ), 2, ModelPosition.createAt( graveyard, 0 ), 1 );
-			model.applyOperation( wrapInDelta( remove ) );
+				const graveyard = modelDoc.graveyard;
+				const remove = new RemoveOperation( ModelPosition.createAt( modelRoot, 1 ), 2, ModelPosition.createAt( graveyard, 0 ), 1 );
+				model.applyOperation( wrapInDelta( remove ) );
+			} );
 
 			log.reset();
 
@@ -850,13 +852,6 @@ describe( 'debug tools', () => {
 				'<div></div>'
 			);
 
-			viewDoc.log( 1 );
-			expectLog(
-				'<div>' +
-				'\n\tfoobar' +
-				'\n</div>'
-			);
-
 			viewDoc.log( 2 );
 			expectLog(
 				'<div>' +

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

@@ -244,8 +244,7 @@ describe( 'model test utils', () => {
 			] );
 
 			expect( stringify( root ) ).to.equal(
-				// Because of https://github.com/ckeditor/ckeditor5-engine/issues/562 attributes are not merged
-				'<$text bold="true">foo</$text>bar<$text bold="true"><$text italic="true">bom</$text></$text>' +
+				'<$text bold="true">foo</$text>bar<$text bold="true" italic="true">bom</$text>' +
 				'<a><$text bold="true" underline="true">pom</$text></a>'
 			);
 		} );