8
0
Просмотр исходного кода

Merge pull request #1347 from ckeditor/t/1226

Other: Refactored how markers removal is converted from the model to the view. Closes #1226.
Piotr Jasiun 7 лет назад
Родитель
Сommit
2c9e49b549

+ 7 - 75
packages/ckeditor5-engine/src/controller/editingcontroller.js

@@ -9,13 +9,9 @@
 
 
 import RootEditableElement from '../view/rooteditableelement';
 import RootEditableElement from '../view/rooteditableelement';
 import View from '../view/view';
 import View from '../view/view';
-import ViewWriter from '../view/writer';
 import Mapper from '../conversion/mapper';
 import Mapper from '../conversion/mapper';
 import DowncastDispatcher from '../conversion/downcastdispatcher';
 import DowncastDispatcher from '../conversion/downcastdispatcher';
-import {
-	insertText,
-	remove
-} from '../conversion/downcast-converters';
+import { insertText, remove } from '../conversion/downcast-converters';
 import { convertSelectionChange } from '../conversion/upcast-selection-converters';
 import { convertSelectionChange } from '../conversion/upcast-selection-converters';
 import {
 import {
 	convertRangeSelection,
 	convertRangeSelection,
@@ -28,7 +24,7 @@ import mix from '@ckeditor/ckeditor5-utils/src/mix';
 
 
 /**
 /**
  * Controller for the editing pipeline. The editing pipeline controls {@link ~EditingController#model model} rendering,
  * Controller for the editing pipeline. The editing pipeline controls {@link ~EditingController#model model} rendering,
- * including selection handling. It also creates the {@link ~EditingController#view view document} which builds a
+ * including selection handling. It also creates the {@link ~EditingController#view view} which builds a
  * browser-independent virtualization over the DOM elements. The editing controller also attaches default converters.
  * browser-independent virtualization over the DOM elements. The editing controller also attaches default converters.
  *
  *
  * @mixes module:utils/observablemixin~ObservableMixin
  * @mixes module:utils/observablemixin~ObservableMixin
@@ -41,7 +37,7 @@ export default class EditingController {
 	 */
 	 */
 	constructor( model ) {
 	constructor( model ) {
 		/**
 		/**
-		 * Editing model.
+		 * Editor model.
 		 *
 		 *
 		 * @readonly
 		 * @readonly
 		 * @member {module:engine/model/model~Model}
 		 * @member {module:engine/model/model~Model}
@@ -78,6 +74,9 @@ export default class EditingController {
 		const selection = doc.selection;
 		const selection = doc.selection;
 		const markers = this.model.markers;
 		const markers = this.model.markers;
 
 
+		// Whenever model document is changed, convert those changes to the view (using model.Document#differ).
+		// Do it on 'low' priority, so changes are converted after other listeners did their job.
+		// Also convert model selection.
 		this.listenTo( doc, 'change', () => {
 		this.listenTo( doc, 'change', () => {
 			this.view.change( writer => {
 			this.view.change( writer => {
 				this.downcastDispatcher.convertChanges( doc.differ, writer );
 				this.downcastDispatcher.convertChanges( doc.differ, writer );
@@ -85,7 +84,7 @@ export default class EditingController {
 			} );
 			} );
 		}, { priority: 'low' } );
 		}, { priority: 'low' } );
 
 
-		// Convert selection from view to model.
+		// Convert selection from the view to the model when it changes in the view.
 		this.listenTo( this.view.document, 'selectionChange', convertSelectionChange( this.model, this.mapper ) );
 		this.listenTo( this.view.document, 'selectionChange', convertSelectionChange( this.model, this.mapper ) );
 
 
 		// Attach default model converters.
 		// Attach default model converters.
@@ -97,53 +96,6 @@ export default class EditingController {
 		this.downcastDispatcher.on( 'selection', convertRangeSelection(), { priority: 'low' } );
 		this.downcastDispatcher.on( 'selection', convertRangeSelection(), { priority: 'low' } );
 		this.downcastDispatcher.on( 'selection', convertCollapsedSelection(), { priority: 'low' } );
 		this.downcastDispatcher.on( 'selection', convertCollapsedSelection(), { priority: 'low' } );
 
 
-		// Convert markers removal.
-		//
-		// Markers should be removed from the view before changes to the model are applied. This is because otherwise
-		// it would be impossible to map some markers to the view (if, for example, the marker's boundary parent got removed).
-		//
-		// `removedMarkers` keeps information which markers already has been removed to prevent removing them twice.
-		const removedMarkers = new Set();
-
-		// We don't want to render view when markers are converted, so we need to create view writer
-		// manually instead of using `View#change` block. See https://github.com/ckeditor/ckeditor5-engine/issues/1323.
-		const viewWriter = new ViewWriter( this.view.document );
-
-		this.listenTo( model, 'applyOperation', ( evt, args ) => {
-			// Before operation is applied...
-			const operation = args[ 0 ];
-
-			for ( const marker of model.markers ) {
-				// Check all markers, that aren't already removed...
-				if ( removedMarkers.has( marker.name ) ) {
-					continue;
-				}
-
-				const markerRange = marker.getRange();
-
-				if ( _operationAffectsMarker( operation, marker ) ) {
-					// And if the operation in any way modifies the marker, remove the marker from the view.
-					removedMarkers.add( marker.name );
-					this.downcastDispatcher.convertMarkerRemove( marker.name, markerRange, viewWriter );
-					// TODO: This stinks but this is the safest place to have this code.
-					this.model.document.differ.bufferMarkerChange( marker.name, markerRange, markerRange );
-				}
-			}
-		}, { priority: 'high' } );
-
-		// If an existing marker is updated through `model.Model#markers` directly (not through operation), just remove it.
-		this.listenTo( model.markers, 'update', ( evt, marker, oldRange ) => {
-			if ( oldRange && !removedMarkers.has( marker.name ) ) {
-				removedMarkers.add( marker.name );
-				this.downcastDispatcher.convertMarkerRemove( marker.name, oldRange, viewWriter );
-			}
-		} );
-
-		// When all changes are done, clear `removedMarkers` set.
-		this.listenTo( model, '_change', () => {
-			removedMarkers.clear();
-		}, { priority: 'low' } );
-
 		// Binds {@link module:engine/view/document~Document#roots view roots collection} to
 		// Binds {@link module:engine/view/document~Document#roots view roots collection} to
 		// {@link module:engine/model/document~Document#roots model roots collection} so creating
 		// {@link module:engine/model/document~Document#roots model roots collection} so creating
 		// model root automatically creates corresponding view root.
 		// model root automatically creates corresponding view root.
@@ -174,23 +126,3 @@ export default class EditingController {
 }
 }
 
 
 mix( EditingController, ObservableMixin );
 mix( EditingController, ObservableMixin );
-
-// Helper function which checks whether given operation will affect given marker after the operation is applied.
-function _operationAffectsMarker( operation, marker ) {
-	const range = marker.getRange();
-
-	if ( operation.type == 'insert' || operation.type == 'rename' ) {
-		return _positionAffectsRange( operation.position, range );
-	} else if ( operation.type == 'move' || operation.type == 'remove' || operation.type == 'reinsert' ) {
-		return _positionAffectsRange( operation.targetPosition, range ) || _positionAffectsRange( operation.sourcePosition, range );
-	} else if ( operation.type == 'marker' && operation.name == marker.name ) {
-		return true;
-	}
-
-	return false;
-}
-
-// Helper function which checks whether change at given position affects given range.
-function _positionAffectsRange( position, range ) {
-	return range.containsPosition( position ) || !range.start._getTransformedByInsertion( position, 1, true ).isEqual( range.start );
-}

+ 60 - 73
packages/ckeditor5-engine/src/conversion/downcast-converters.js

@@ -517,15 +517,16 @@ export function remove() {
 
 
 /**
 /**
  * Function factory, creates a converter that converts marker adding change to the view ui element.
  * 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
+ *
+ * The view ui element, that will be added to the view, depends on passed parameter. See {@link ~insertElement}.
+ * In a case of a non-collapsed range, the ui element will not wrap nodes but separate elements will be placed at the beginning
  * and at the end of the range.
  * and at the end of the range.
  *
  *
- * **Note:** unlike {@link ~insertElement}, the converter does not bind view element to model, because this converter
- * uses marker as "model source of data". This means that view ui element does not have corresponding model element.
+ * This converter binds created {@link module:engine/view/uielement~UIElement}s with marker name using
+ * the {@link module:engine/conversion/mapper~Mapper#bindElementToMarker}.
  *
  *
- * @param {module:engine/view/uielement~UIElement|Function} elementCreator View ui element, or function returning a view element, which
- * will be inserted.
+ * @param {module:engine/view/uielement~UIElement|Function} elementCreator View ui element or a function returning a view element
+ * which will be inserted.
  * @returns {Function} Insert element event converter.
  * @returns {Function} Insert element event converter.
  */
  */
 export function insertUIElement( elementCreator ) {
 export function insertUIElement( elementCreator ) {
@@ -563,10 +564,12 @@ export function insertUIElement( elementCreator ) {
 
 
 		// Add "opening" element.
 		// Add "opening" element.
 		viewWriter.insert( mapper.toViewPosition( markerRange.start ), viewStartElement );
 		viewWriter.insert( mapper.toViewPosition( markerRange.start ), viewStartElement );
+		conversionApi.mapper.bindElementToMarker( viewStartElement, data.markerName );
 
 
 		// Add "closing" element only if range is not collapsed.
 		// Add "closing" element only if range is not collapsed.
 		if ( !markerRange.isCollapsed ) {
 		if ( !markerRange.isCollapsed ) {
 			viewWriter.insert( mapper.toViewPosition( markerRange.end ), viewEndElement );
 			viewWriter.insert( mapper.toViewPosition( markerRange.end ), viewEndElement );
+			conversionApi.mapper.bindElementToMarker( viewEndElement, data.markerName );
 		}
 		}
 
 
 		evt.stop();
 		evt.stop();
@@ -574,39 +577,29 @@ export function insertUIElement( elementCreator ) {
 }
 }
 
 
 /**
 /**
- * Function factory, creates a default downcast converter for removing {@link module:engine/view/uielement~UIElement ui element}
+ * Function factory, returns a default downcast converter for removing {@link module:engine/view/uielement~UIElement ui element}
  * basing on marker remove change.
  * basing on marker remove change.
  *
  *
- * @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.
+ * This converter unbinds elements from marker name.
+ *
  * @returns {Function} Remove ui element converter.
  * @returns {Function} Remove ui element converter.
  */
  */
-export function removeUIElement( elementCreator ) {
+export function removeUIElement() {
 	return ( evt, data, conversionApi ) => {
 	return ( evt, data, conversionApi ) => {
-		// 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.
-		data.isOpening = true;
-		const viewStartElement = elementCreator( data, conversionApi.writer );
-
-		data.isOpening = false;
-		const viewEndElement = elementCreator( data, conversionApi.writer );
+		const elements = conversionApi.mapper.markerNameToElements( data.markerName );
 
 
-		if ( !viewStartElement || !viewEndElement ) {
+		if ( !elements ) {
 			return;
 			return;
 		}
 		}
 
 
-		const markerRange = data.markerRange;
+		conversionApi.mapper.unbindElementsFromMarkerName( data.markerName );
+
 		const viewWriter = conversionApi.writer;
 		const viewWriter = conversionApi.writer;
 
 
-		// 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 );
+		for ( const element of elements ) {
+			viewWriter.clear( ViewRange.createOn( element ), element );
 		}
 		}
 
 
-		// Remove "opening" element.
-		viewWriter.clear( conversionApi.mapper.toViewRange( markerRange ).getEnlarged(), viewStartElement );
-
 		evt.stop();
 		evt.stop();
 	};
 	};
 }
 }
@@ -778,6 +771,9 @@ export function wrap( elementCreator ) {
  *
  *
  * If the highlight descriptor will not provide `id` property, name of the marker will be used.
  * If the highlight descriptor will not provide `id` property, name of the marker will be used.
  *
  *
+ * This converter binds created {@link module:engine/view/attributeelement~AttributeElement}s with marker name using
+ * the {@link module:engine/conversion/mapper~Mapper#bindElementToMarker}.
+ *
  * @param {module:engine/conversion/downcast-converters~HighlightDescriptor|Function} highlightDescriptor
  * @param {module:engine/conversion/downcast-converters~HighlightDescriptor|Function} highlightDescriptor
  * @return {Function}
  * @return {Function}
  */
  */
@@ -809,7 +805,17 @@ export function highlightText( highlightDescriptor ) {
 			viewWriter.wrap( viewSelection.getFirstRange(), viewElement, viewSelection );
 			viewWriter.wrap( viewSelection.getFirstRange(), viewElement, viewSelection );
 		} else {
 		} else {
 			const viewRange = conversionApi.mapper.toViewRange( data.range );
 			const viewRange = conversionApi.mapper.toViewRange( data.range );
-			viewWriter.wrap( viewRange, viewElement );
+			const rangeAfterWrap = viewWriter.wrap( viewRange, viewElement );
+
+			for ( const element of rangeAfterWrap.getItems() ) {
+				if ( element.is( 'attributeElement' ) && element.isSimilar( viewElement ) ) {
+					conversionApi.mapper.bindElementToMarker( element, data.markerName );
+
+					// One attribute element is enough, because all of them are bound together by the view writer.
+					// Mapper uses this binding to get all the elements no matter how many of them are registered in the mapper.
+					break;
+				}
+			}
 		}
 		}
 	};
 	};
 }
 }
@@ -828,6 +834,9 @@ export function highlightText( highlightDescriptor ) {
  *
  *
  * If the highlight descriptor will not provide `id` property, name of the marker will be used.
  * If the highlight descriptor will not provide `id` property, name of the marker will be used.
  *
  *
+ * This converter binds altered {@link module:engine/view/containerelement~ContainerElement}s with marker name using
+ * the {@link module:engine/conversion/mapper~Mapper#bindElementToMarker}.
+ *
  * @param {module:engine/conversion/downcast-converters~HighlightDescriptor|Function} highlightDescriptor
  * @param {module:engine/conversion/downcast-converters~HighlightDescriptor|Function} highlightDescriptor
  * @return {Function}
  * @return {Function}
  */
  */
@@ -863,14 +872,16 @@ export function highlightElement( highlightDescriptor ) {
 			}
 			}
 
 
 			viewElement.getCustomProperty( 'addHighlight' )( viewElement, descriptor, conversionApi.writer );
 			viewElement.getCustomProperty( 'addHighlight' )( viewElement, descriptor, conversionApi.writer );
+
+			conversionApi.mapper.bindElementToMarker( viewElement, data.markerName );
 		}
 		}
 	};
 	};
 }
 }
 
 
 /**
 /**
- * Function factory, creates a converter that converts model marker remove to the view.
+ * Function factory, creates a converter that converts removing model marker to the view.
  *
  *
- * Both text nodes and elements are handled by this converter by they are handled a bit differently.
+ * Both text nodes and elements are handled by this converter but they are handled a bit differently.
  *
  *
  * Text nodes are unwrapped using {@link module:engine/view/attributeelement~AttributeElement} created from provided
  * Text nodes are unwrapped using {@link module:engine/view/attributeelement~AttributeElement} created from provided
  * highlight descriptor. See {link module:engine/conversion/downcast-converters~highlightDescriptorToAttributeElement}.
  * highlight descriptor. See {link module:engine/conversion/downcast-converters~highlightDescriptorToAttributeElement}.
@@ -886,6 +897,8 @@ export function highlightElement( highlightDescriptor ) {
  *
  *
  * If the highlight descriptor will not provide `id` property, name of the marker will be used.
  * If the highlight descriptor will not provide `id` property, name of the marker will be used.
  *
  *
+ * This converter unbinds elements from marker name.
+ *
  * @param {module:engine/conversion/downcast-converters~HighlightDescriptor|Function} highlightDescriptor
  * @param {module:engine/conversion/downcast-converters~HighlightDescriptor|Function} highlightDescriptor
  * @return {Function}
  * @return {Function}
  */
  */
@@ -902,33 +915,28 @@ export function removeHighlight( highlightDescriptor ) {
 			return;
 			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() );
+		// View element that will be used to unwrap `AttributeElement`s.
+		const viewHighlightElement = createViewElementFromHighlightDescriptor( descriptor );
 
 
-		// 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, conversionApi.writer );
+		// Get all elements bound with given marker name.
+		const elements = conversionApi.mapper.markerNameToElements( data.markerName );
 
 
-				// If container element had custom handling, remove all it's children from further processing.
-				for ( const descendant of ViewRange.createIn( item ) ) {
-					items.delete( descendant );
-				}
-			}
+		if ( !elements ) {
+			return;
 		}
 		}
 
 
-		// 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 );
-		const viewWriter = conversionApi.writer;
+		conversionApi.mapper.unbindElementsFromMarkerName( data.markerName );
 
 
-		for ( const item of Array.from( items ).reverse() ) {
-			if ( item.is( 'textProxy' ) ) {
-				viewWriter.unwrap( ViewRange.createOn( item ), viewHighlightElement );
+		for ( const element of elements ) {
+			if ( element.is( 'attributeElement' ) ) {
+				conversionApi.writer.unwrap( ViewRange.createOn( element ), viewHighlightElement );
+			} else {
+				// if element.is( 'containerElement' ).
+				element.getCustomProperty( 'removeHighlight' )( element, descriptor.id, conversionApi.writer );
 			}
 			}
 		}
 		}
+
+		evt.stop();
 	};
 	};
 }
 }
 
 
@@ -962,10 +970,10 @@ function _prepareDescriptor( highlightDescriptor, data, conversionApi ) {
  * is not provided in descriptor - default priority will be used.
  * is not provided in descriptor - default priority will be used.
  *
  *
  * @param {module:engine/conversion/downcast-converters~HighlightDescriptor} descriptor
  * @param {module:engine/conversion/downcast-converters~HighlightDescriptor} descriptor
- * @return {module:engine/conversion/downcast-converters~HighlightAttributeElement}
+ * @returns {module:engine/view/attributeelement~AttributeElement}
  */
  */
 export function createViewElementFromHighlightDescriptor( descriptor ) {
 export function createViewElementFromHighlightDescriptor( descriptor ) {
-	const viewElement = new HighlightAttributeElement( 'span', descriptor.attributes );
+	const viewElement = new ViewAttributeElement( 'span', descriptor.attributes );
 
 
 	if ( descriptor.class ) {
 	if ( descriptor.class ) {
 		viewElement._addClass( descriptor.class );
 		viewElement._addClass( descriptor.class );
@@ -975,32 +983,11 @@ export function createViewElementFromHighlightDescriptor( descriptor ) {
 		viewElement._priority = descriptor.priority;
 		viewElement._priority = descriptor.priority;
 	}
 	}
 
 
-	viewElement._setCustomProperty( 'highlightDescriptorId', descriptor.id );
+	viewElement._id = descriptor.id;
 
 
 	return viewElement;
 	return viewElement;
 }
 }
 
 
-/**
- * Special kind of {@link module:engine/view/attributeelement~AttributeElement} that is created and used in
- * marker-to-highlight conversion.
- *
- * The difference between `HighlightAttributeElement` and {@link module:engine/view/attributeelement~AttributeElement}
- * is {@link module:engine/view/attributeelement~AttributeElement#isSimilar} method.
- *
- * For `HighlightAttributeElement` it checks just `highlightDescriptorId` custom property, that is set during marker-to-highlight
- * conversion basing on {@link module:engine/conversion/downcast-converters~HighlightDescriptor} object.
- * `HighlightAttributeElement`s with same `highlightDescriptorId` property are considered similar.
- */
-class HighlightAttributeElement extends ViewAttributeElement {
-	isSimilar( otherElement ) {
-		if ( otherElement.is( 'attributeElement' ) ) {
-			return this.getCustomProperty( 'highlightDescriptorId' ) === otherElement.getCustomProperty( 'highlightDescriptorId' );
-		}
-
-		return false;
-	}
-}
-
 /**
 /**
  * Object describing how the marker highlight should be represented in the view.
  * Object describing how the marker highlight should be represented in the view.
  *
  *

+ 6 - 1
packages/ckeditor5-engine/src/conversion/downcastdispatcher.js

@@ -123,6 +123,11 @@ export default class DowncastDispatcher {
 	 * @param {module:engine/view/writer~Writer} writer View writer that should be used to modify view document.
 	 * @param {module:engine/view/writer~Writer} writer View writer that should be used to modify view document.
 	 */
 	 */
 	convertChanges( differ, writer ) {
 	convertChanges( differ, writer ) {
+		// Before the view is updated, remove markers which have changed.
+		for ( const change of differ.getMarkersToRemove() ) {
+			this.convertMarkerRemove( change.name, change.range, writer );
+		}
+
 		// Convert changes that happened on model tree.
 		// Convert changes that happened on model tree.
 		for ( const entry of differ.getChanges() ) {
 		for ( const entry of differ.getChanges() ) {
 			if ( entry.type == 'insert' ) {
 			if ( entry.type == 'insert' ) {
@@ -135,7 +140,7 @@ export default class DowncastDispatcher {
 			}
 			}
 		}
 		}
 
 
-		// After the view is updated, convert markers which has changed.
+		// After the view is updated, convert markers which have changed.
 		for ( const change of differ.getMarkersToAdd() ) {
 		for ( const change of differ.getMarkersToAdd() ) {
 			this.convertMarkerAdd( change.name, change.range, writer );
 			this.convertMarkerAdd( change.name, change.range, writer );
 		}
 		}

+ 67 - 1
packages/ckeditor5-engine/src/conversion/mapper.js

@@ -18,7 +18,8 @@ import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin';
 import mix from '@ckeditor/ckeditor5-utils/src/mix';
 import mix from '@ckeditor/ckeditor5-utils/src/mix';
 
 
 /**
 /**
- * Maps elements and positions between {@link module:engine/view/document~Document view} and {@link module:engine/model/model model}.
+ * Maps elements, positions and markers between {@link module:engine/view/document~Document the view} and
+ * {@link module:engine/model/model the model}.
  *
  *
  * Mapper use bound elements to find corresponding elements and positions, so, to get proper results,
  * Mapper use bound elements to find corresponding elements and positions, so, to get proper results,
  * all model elements should be {@link module:engine/conversion/mapper~Mapper#bindElements bound}.
  * all model elements should be {@link module:engine/conversion/mapper~Mapper#bindElements bound}.
@@ -62,6 +63,17 @@ export default class Mapper {
 		 */
 		 */
 		this._viewToModelLengthCallbacks = new Map();
 		this._viewToModelLengthCallbacks = new Map();
 
 
+		/**
+		 * Model marker name to view elements mapping.
+		 *
+		 * Keys are `String`s while values are `Set`s with {@link module:engine/view/element~Element view elements}.
+		 * One marker (name) can be mapped to multiple elements.
+		 *
+		 * @private
+		 * @member {Map}
+		 */
+		this._markerNameToElements = new Map();
+
 		// Default mapper algorithm for mapping model position to view position.
 		// Default mapper algorithm for mapping model position to view position.
 		this.on( 'modelToViewPosition', ( evt, data ) => {
 		this.on( 'modelToViewPosition', ( evt, data ) => {
 			if ( data.viewPosition ) {
 			if ( data.viewPosition ) {
@@ -149,12 +161,37 @@ export default class Mapper {
 		}
 		}
 	}
 	}
 
 
+	/**
+	 * Binds given marker name with given {@link module:engine/view/element~Element view element}. The element
+	 * will be added to the current set of elements bound with given marker name.
+	 *
+	 * @param {module:engine/view/element~Element} element Element to bind.
+	 * @param {String} name Marker name.
+	 */
+	bindElementToMarker( element, name ) {
+		const elements = this._markerNameToElements.get( name ) || new Set();
+
+		elements.add( element );
+
+		this._markerNameToElements.set( name, elements );
+	}
+
+	/**
+	 * Unbinds all elements from given marker name.
+	 *
+	 * @param {String} name Marker name.
+	 */
+	unbindElementsFromMarkerName( name ) {
+		this._markerNameToElements.delete( name );
+	}
+
 	/**
 	/**
 	 * Removes all model to view and view to model bindings.
 	 * Removes all model to view and view to model bindings.
 	 */
 	 */
 	clearBindings() {
 	clearBindings() {
 		this._modelToViewMapping = new WeakMap();
 		this._modelToViewMapping = new WeakMap();
 		this._viewToModelMapping = new WeakMap();
 		this._viewToModelMapping = new WeakMap();
+		this._markerNameToElements = new Map();
 	}
 	}
 
 
 	/**
 	/**
@@ -239,6 +276,35 @@ export default class Mapper {
 		return data.viewPosition;
 		return data.viewPosition;
 	}
 	}
 
 
+	/**
+	 * Gets all view elements bound to the given marker name.
+	 *
+	 * @param {String} name Marker name.
+	 * @returns {Set.<module:engine/view/element~Element>|null} View elements bound with given marker name or `null`
+	 * if no elements are bound to given marker name.
+	 */
+	markerNameToElements( name ) {
+		const boundElements = this._markerNameToElements.get( name );
+
+		if ( !boundElements ) {
+			return null;
+		}
+
+		const elements = new Set();
+
+		for ( const element of boundElements ) {
+			if ( element.is( 'attributeElement' ) ) {
+				for ( const clone of element.getElementsWithSameId() ) {
+					elements.add( clone );
+				}
+			} else {
+				elements.add( element );
+			}
+		}
+
+		return elements;
+	}
+
 	/**
 	/**
 	 * Registers a callback that evaluates the length in the model of a view element with given name.
 	 * Registers a callback that evaluates the length in the model of a view element with given name.
 	 *
 	 *

+ 55 - 8
packages/ckeditor5-engine/src/dev-utils/view.js

@@ -47,6 +47,8 @@ const allowedTypes = {
  * instead of `<p>`, `<attribute:b>` instead of `<b>` and `<empty:img>` instead of `<img>`).
  * instead of `<p>`, `<attribute:b>` instead of `<b>` and `<empty:img>` instead of `<img>`).
  * @param {Boolean} [options.showPriority=false] When set to `true`, attribute element's priority will be printed
  * @param {Boolean} [options.showPriority=false] When set to `true`, attribute element's priority will be printed
  * (`<span view-priority="12">`, `<b view-priority="10">`).
  * (`<span view-priority="12">`, `<b view-priority="10">`).
+ * @param {Boolean} [options.showAttributeElementId=false] When set to `true`, attribute element's id will be printed
+ * (`<span id="marker:foo">`).
  * @returns {String} The stringified data.
  * @returns {String} The stringified data.
  */
  */
 export function getData( view, options = {} ) {
 export function getData( view, options = {} ) {
@@ -195,6 +197,13 @@ setData._parse = parse;
  *		attribute._priority = 20;
  *		attribute._priority = 20;
  *		getData( attribute, null, { showPriority: true } ); // <b view-priority="20"></b>
  *		getData( attribute, null, { showPriority: true } ); // <b view-priority="20"></b>
  *
  *
+ * If `options.showAttributeElementId` is set to `true`, the attribute element's id will be displayed for all
+ * {@link module:engine/view/attributeelement~AttributeElement attribute elements} that have it set.
+ *
+ *		const attribute = new AttributeElement( 'span' );
+ *		attribute._id = 'marker:foo';
+ *		getData( attribute, null, { showAttributeElementId: true } ); // <span view-id="marker:foo"></span>
+ *
  * @param {module:engine/view/text~Text|module:engine/view/element~Element|module:engine/view/documentfragment~DocumentFragment}
  * @param {module:engine/view/text~Text|module:engine/view/element~Element|module:engine/view/documentfragment~DocumentFragment}
  * node The node to stringify.
  * node The node to stringify.
  * @param {module:engine/view/selection~Selection|module:engine/view/position~Position|module:engine/view/range~Range}
  * @param {module:engine/view/selection~Selection|module:engine/view/position~Position|module:engine/view/range~Range}
@@ -207,6 +216,8 @@ setData._parse = parse;
  * instead of `<p>`, `<attribute:b>` instead of `<b>` and `<empty:img>` instead of `<img>`).
  * instead of `<p>`, `<attribute:b>` instead of `<b>` and `<empty:img>` instead of `<img>`).
  * @param {Boolean} [options.showPriority=false] When set to `true`,  the attribute element's priority will be printed
  * @param {Boolean} [options.showPriority=false] When set to `true`,  the attribute element's priority will be printed
  * (`<span view-priority="12">`, `<b view-priority="10">`).
  * (`<span view-priority="12">`, `<b view-priority="10">`).
+ * @param {Boolean} [options.showAttributeElementId=false] When set to `true`, attribute element's id will be printed
+ * (`<span id="marker:foo">`).
  * @param {Boolean} [options.ignoreRoot=false] When set to `true`, the root's element opening and closing will not be printed.
  * @param {Boolean} [options.ignoreRoot=false] When set to `true`, the root's element opening and closing will not be printed.
  * Mainly used by the `getData` function to ignore the {@link module:engine/view/document~Document document's} root element.
  * Mainly used by the `getData` function to ignore the {@link module:engine/view/document~Document document's} root element.
  * @param {Boolean} [options.sameSelectionCharacters=false] When set to `true`, the selection inside the text will be marked as
  * @param {Boolean} [options.sameSelectionCharacters=false] When set to `true`, the selection inside the text will be marked as
@@ -604,6 +615,7 @@ class ViewStringify {
 
 
 		this.showType = !!options.showType;
 		this.showType = !!options.showType;
 		this.showPriority = !!options.showPriority;
 		this.showPriority = !!options.showPriority;
+		this.showAttributeElementId = !!options.showAttributeElementId;
 		this.ignoreRoot = !!options.ignoreRoot;
 		this.ignoreRoot = !!options.ignoreRoot;
 		this.sameSelectionCharacters = !!options.sameSelectionCharacters;
 		this.sameSelectionCharacters = !!options.sameSelectionCharacters;
 	}
 	}
@@ -744,9 +756,11 @@ class ViewStringify {
 
 
 	/**
 	/**
 	 * Converts the passed {@link module:engine/view/element~Element element} to an opening tag.
 	 * Converts the passed {@link module:engine/view/element~Element element} to an opening tag.
+	 *
 	 * Depending on the current configuration, the opening tag can be simple (`<a>`), contain a type prefix (`<container:p>`,
 	 * Depending on the current configuration, the opening tag can be simple (`<a>`), contain a type prefix (`<container:p>`,
-	 * `<attribute:a>` or `<empty:img>`) or contain priority information ( `<attribute:a view-priority="20">` ).
-	 * Element attributes will also be included (`<a href="https://ckeditor.com" name="foobar">`).
+	 * `<attribute:a>` or `<empty:img>`), contain priority information ( `<attribute:a view-priority="20">` ),
+	 * or contain element id ( `<attribute:span view-id="foo">` ). Element attributes will also be included
+	 * (`<a href="https://ckeditor.com" name="foobar">`).
 	 *
 	 *
 	 * @private
 	 * @private
 	 * @param {module:engine/view/element~Element} element
 	 * @param {module:engine/view/element~Element} element
@@ -754,11 +768,12 @@ class ViewStringify {
 	 */
 	 */
 	_stringifyElementOpen( element ) {
 	_stringifyElementOpen( element ) {
 		const priority = this._stringifyElementPriority( element );
 		const priority = this._stringifyElementPriority( element );
+		const id = this._stringifyElementId( element );
 
 
 		const type = this._stringifyElementType( element );
 		const type = this._stringifyElementType( element );
 		const name = [ type, element.name ].filter( i => i !== '' ).join( ':' );
 		const name = [ type, element.name ].filter( i => i !== '' ).join( ':' );
 		const attributes = this._stringifyElementAttributes( element );
 		const attributes = this._stringifyElementAttributes( element );
-		const parts = [ name, priority, attributes ];
+		const parts = [ name, priority, id, attributes ];
 
 
 		return `<${ parts.filter( i => i !== '' ).join( ' ' ) }>`;
 		return `<${ parts.filter( i => i !== '' ).join( ' ' ) }>`;
 	}
 	}
@@ -807,6 +822,7 @@ class ViewStringify {
 
 
 	/**
 	/**
 	 * Converts the passed {@link module:engine/view/element~Element element} to its priority representation.
 	 * Converts the passed {@link module:engine/view/element~Element element} to its priority representation.
+	 *
 	 * The priority string representation will be returned when the passed element is an instance of
 	 * The priority string representation will be returned when the passed element is an instance of
 	 * {@link module:engine/view/attributeelement~AttributeElement attribute element} and the current configuration allows to show the
 	 * {@link module:engine/view/attributeelement~AttributeElement attribute element} and the current configuration allows to show the
 	 * priority. Otherwise returns an empty string.
 	 * priority. Otherwise returns an empty string.
@@ -823,6 +839,25 @@ class ViewStringify {
 		return '';
 		return '';
 	}
 	}
 
 
+	/**
+	 * Converts the passed {@link module:engine/view/element~Element element} to its id representation.
+	 *
+	 * The id string representation will be returned when the passed element is an instance of
+	 * {@link module:engine/view/attributeelement~AttributeElement attribute element}, the element has an id
+	 * and the current configuration allows to show the id. Otherwise returns an empty string.
+	 *
+	 * @private
+	 * @param {module:engine/view/element~Element} element
+	 * @returns {String}
+	 */
+	_stringifyElementId( element ) {
+		if ( this.showAttributeElementId && element.is( 'attributeElement' ) && element.id ) {
+			return `view-id="${ element.id }"`;
+		}
+
+		return '';
+	}
+
 	/**
 	/**
 	 * Converts the passed {@link module:engine/view/element~Element element} attributes to their string representation.
 	 * Converts the passed {@link module:engine/view/element~Element element} attributes to their string representation.
 	 * If an element has no attributes, an empty string is returned.
 	 * If an element has no attributes, an empty string is returned.
@@ -901,8 +936,10 @@ function _convertViewElements( rootNode ) {
 // {@link module:engine/view/containerelement~ContainerElement container element},
 // {@link module:engine/view/containerelement~ContainerElement container element},
 // {@link module:engine/view/emptyelement~EmptyElement empty element} or
 // {@link module:engine/view/emptyelement~EmptyElement empty element} or
 // {@link module:engine/view/uielement~UIElement UI element}.
 // {@link module:engine/view/uielement~UIElement UI element}.
-// If the element's name is in the format of `attribute:b` with `view-priority="11"` attribute, it will be converted to
+// If the element's name is in the format of `attribute:b`, it will be converted to
 // an {@link module:engine/view/attributeelement~AttributeElement attribute element} with a priority of 11.
 // an {@link module:engine/view/attributeelement~AttributeElement attribute element} with a priority of 11.
+// Additionally, attribute elements may have specified priority (for example `view-priority="11"`) and/or
+// id (for example `view-id="foo"`).
 // If the element's name is in the format of `container:p`, it will be converted to
 // If the element's name is in the format of `container:p`, it will be converted to
 // a {@link module:engine/view/containerelement~ContainerElement container element}.
 // a {@link module:engine/view/containerelement~ContainerElement container element}.
 // If the element's name is in the format of `empty:img`, it will be converted to
 // If the element's name is in the format of `empty:img`, it will be converted to
@@ -918,7 +955,7 @@ function _convertViewElements( rootNode ) {
 // module:engine/view/containerelement~ContainerElement} A tree view
 // module:engine/view/containerelement~ContainerElement} A tree view
 // element converted according to its name.
 // element converted according to its name.
 function _convertElement( viewElement ) {
 function _convertElement( viewElement ) {
-	const info = _convertElementNameAndPriority( viewElement );
+	const info = _convertElementNameAndInfo( viewElement );
 	const ElementConstructor = allowedTypes[ info.type ];
 	const ElementConstructor = allowedTypes[ info.type ];
 	const newElement = ElementConstructor ? new ElementConstructor( info.name ) : new ViewElement( info.name );
 	const newElement = ElementConstructor ? new ElementConstructor( info.name ) : new ViewElement( info.name );
 
 
@@ -926,6 +963,10 @@ function _convertElement( viewElement ) {
 		if ( info.priority !== null ) {
 		if ( info.priority !== null ) {
 			newElement._priority = info.priority;
 			newElement._priority = info.priority;
 		}
 		}
+
+		if ( info.id !== null ) {
+			newElement._id = info.id;
+		}
 	}
 	}
 
 
 	// Move attributes.
 	// Move attributes.
@@ -949,16 +990,21 @@ function _convertElement( viewElement ) {
 // @returns {String} info.name The parsed name of the element.
 // @returns {String} info.name The parsed name of the element.
 // @returns {String|null} info.type The parsed type of the element. It can be `attribute`, `container` or `empty`.
 // @returns {String|null} info.type The parsed type of the element. It can be `attribute`, `container` or `empty`.
 // returns {Number|null} info.priority The parsed priority of the element.
 // returns {Number|null} info.priority The parsed priority of the element.
-function _convertElementNameAndPriority( viewElement ) {
+function _convertElementNameAndInfo( viewElement ) {
 	const parts = viewElement.name.split( ':' );
 	const parts = viewElement.name.split( ':' );
+
 	const priority = _convertPriority( viewElement.getAttribute( 'view-priority' ) );
 	const priority = _convertPriority( viewElement.getAttribute( 'view-priority' ) );
+	const id = viewElement.hasAttribute( 'view-id' ) ? viewElement.getAttribute( 'view-id' ) : null;
+
 	viewElement._removeAttribute( 'view-priority' );
 	viewElement._removeAttribute( 'view-priority' );
+	viewElement._removeAttribute( 'view-id' );
 
 
 	if ( parts.length == 1 ) {
 	if ( parts.length == 1 ) {
 		return {
 		return {
 			name: parts[ 0 ],
 			name: parts[ 0 ],
 			type: priority !== null ? 'attribute' : null,
 			type: priority !== null ? 'attribute' : null,
-			priority
+			priority,
+			id
 		};
 		};
 	}
 	}
 
 
@@ -969,7 +1015,8 @@ function _convertElementNameAndPriority( viewElement ) {
 		return {
 		return {
 			name: parts[ 1 ],
 			name: parts[ 1 ],
 			type,
 			type,
-			priority
+			priority,
+			id
 		};
 		};
 	}
 	}
 
 

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

@@ -181,7 +181,7 @@ export default class Differ {
 			buffered.newRange = newRange;
 			buffered.newRange = newRange;
 
 
 			if ( buffered.oldRange == null && buffered.newRange == null ) {
 			if ( buffered.oldRange == null && buffered.newRange == null ) {
-				// The marker is going to be removed (`newRange == null`) but it did not exist before the change set
+				// The marker is going to be removed (`newRange == null`) but it did not exist before the first buffered change
 				// (`buffered.oldRange == null`). In this case, do not keep the marker in buffer at all.
 				// (`buffered.oldRange == null`). In this case, do not keep the marker in buffer at all.
 				this._changedMarkers.delete( markerName );
 				this._changedMarkers.delete( markerName );
 			}
 			}

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

@@ -165,8 +165,8 @@ export default class Document {
 			// Whenever marker is updated, buffer that change.
 			// Whenever marker is updated, buffer that change.
 			this.differ.bufferMarkerChange( marker.name, oldRange, newRange );
 			this.differ.bufferMarkerChange( marker.name, oldRange, newRange );
 
 
-			if ( !oldRange ) {
-				// Whenever marker changes, buffer that.
+			if ( oldRange === null ) {
+				// If this is a new marker, add a listener that will buffer change whenever marker changes.
 				marker.on( 'change', ( evt, oldRange ) => {
 				marker.on( 'change', ( evt, oldRange ) => {
 					this.differ.bufferMarkerChange( marker.name, oldRange, marker.getRange() );
 					this.differ.bufferMarkerChange( marker.name, oldRange, marker.getRange() );
 				} );
 				} );

+ 90 - 11
packages/ckeditor5-engine/src/view/attributeelement.js

@@ -8,6 +8,7 @@
  */
  */
 
 
 import Element from './element';
 import Element from './element';
+import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 
 
 // Default attribute priority.
 // Default attribute priority.
 const DEFAULT_PRIORITY = 10;
 const DEFAULT_PRIORITY = 10;
@@ -34,9 +35,15 @@ export default class AttributeElement extends Element {
 		super( name, attrs, children );
 		super( name, attrs, children );
 
 
 		/**
 		/**
-		 * Element priority. Attributes have to have the same priority to be
-		 * {@link module:engine/view/element~Element#isSimilar similar}. Setting different priorities on similar
- 		 * nodes may prevent merging, e.g. two `<abbr>` nodes next each other shouldn't be merged.
+		 * Returns block {@link module:engine/view/filler filler} offset or `null` if block filler is not needed.
+		 *
+		 * @method #getFillerOffset
+		 * @returns {Number|null} Block filler offset or `null` if block filler is not needed.
+		 */
+		this.getFillerOffset = getFillerOffset;
+
+		/**
+		 * Element priority. Decides in what order elements are wrapped by {@link module:engine/view/writer~Writer}.
 		 *
 		 *
 		 * @protected
 		 * @protected
 		 * @member {Number}
 		 * @member {Number}
@@ -44,24 +51,75 @@ export default class AttributeElement extends Element {
 		this._priority = DEFAULT_PRIORITY;
 		this._priority = DEFAULT_PRIORITY;
 
 
 		/**
 		/**
-		 * Returns block {@link module:engine/view/filler filler} offset or `null` if block filler is not needed.
+		 * Element identifier. If set, it is used by {@link module:engine/view/element~Element#isSimilar},
+		 * and then two elements are considered similar if, and only if they have the same `_id`.
 		 *
 		 *
-		 * @method #getFillerOffset
-		 * @returns {Number|null} Block filler offset or `null` if block filler is not needed.
+		 * @protected
+		 * @member {String|Number}
 		 */
 		 */
-		this.getFillerOffset = getFillerOffset;
+		this._id = null;
+
+		/**
+		 * Keeps all the attribute elements that have the same {@link module:engine/view/attributeelement~AttributeElement#id ids}
+		 * and still exist in the view tree.
+		 *
+		 * This property is managed by {@link module:engine/view/writer~Writer}.
+		 *
+		 * @protected
+		 * @member {Set|null}
+		 */
+		this._clonesGroup = null;
 	}
 	}
 
 
 	/**
 	/**
-	 * Priority of this element.
+	 * Element priority. Decides in what order elements are wrapped by {@link module:engine/view/writer~Writer}.
 	 *
 	 *
 	 * @readonly
 	 * @readonly
-	 * @return {Number}
+	 * @returns {Number}
 	 */
 	 */
 	get priority() {
 	get priority() {
 		return this._priority;
 		return this._priority;
 	}
 	}
 
 
+	/**
+	 * Element identifier. If set, it is used by {@link module:engine/view/element~Element#isSimilar},
+	 * and then two elements are considered similar if, and only if they have the same `id`.
+	 *
+	 * @readonly
+	 * @returns {String|Number}
+	 */
+	get id() {
+		return this._id;
+	}
+
+	/**
+	 * Returns all {@link module:engine/view/attributeelement~AttributeElement attribute elements} that has the
+	 * same {@link module:engine/view/attributeelement~AttributeElement#id id} and are in the view tree (were not removed).
+	 *
+	 * Note: If this element has been removed from the tree, returned set will not include it.
+	 *
+	 * Throws {@link module:utils/ckeditorerror~CKEditorError attribute-element-get-elements-with-same-id-no-id}
+	 * if this element has no `id`.
+	 *
+	 * @returns {Set.<module:engine/view/attributeelement~AttributeElement>} Set containing all the attribute elements
+	 * with the same `id` that were added and not removed from the view tree.
+	 */
+	getElementsWithSameId() {
+		if ( this.id === null ) {
+			/**
+			 * Cannot get elements with the same id for an attribute element without id.
+			 *
+			 * @error attribute-element-get-elements-with-same-id-no-id
+			 */
+			throw new CKEditorError(
+				'attribute-element-get-elements-with-same-id-no-id: ' +
+				'Cannot get elements with the same id for an attribute element without id.'
+			);
+		}
+
+		return new Set( this._clonesGroup );
+	}
+
 	/**
 	/**
 	 * @inheritDoc
 	 * @inheritDoc
 	 */
 	 */
@@ -75,13 +133,31 @@ export default class AttributeElement extends Element {
 
 
 	/**
 	/**
 	 * Checks if this element is similar to other element.
 	 * Checks if this element is similar to other element.
-	 * Both elements should have the same name, attributes and priority to be considered as similar.
-	 * Two similar elements can contain different set of children nodes.
+	 *
+	 * If none of elements has set {@link module:engine/view/attributeelement~AttributeElement#id}, then both elements
+	 * should have the same name, attributes and priority to be considered as similar. Two similar elements can contain
+	 * different set of children nodes.
+	 *
+	 * If at least one element has {@link module:engine/view/attributeelement~AttributeElement#id} set, then both
+	 * elements have to have the same {@link module:engine/view/attributeelement~AttributeElement#id} value to be
+	 * considered similar.
+	 *
+	 * Similarity is important for {@link module:engine/view/writer~Writer}. For example:
+	 *
+	 * * two following similar elements can be merged together into one, longer element,
+	 * * {@link module:engine/view/writer~Writer#unwrap} checks similarity of passed element and processed element to
+	 * decide whether processed element should be unwrapped,
+	 * * etc.
 	 *
 	 *
 	 * @param {module:engine/view/element~Element} otherElement
 	 * @param {module:engine/view/element~Element} otherElement
 	 * @returns {Boolean}
 	 * @returns {Boolean}
 	 */
 	 */
 	isSimilar( otherElement ) {
 	isSimilar( otherElement ) {
+		// If any element has an `id` set, just compare the ids.
+		if ( this.id !== null || otherElement.id !== null ) {
+			return this.id === otherElement.id;
+		}
+
 		return super.isSimilar( otherElement ) && this.priority == otherElement.priority;
 		return super.isSimilar( otherElement ) && this.priority == otherElement.priority;
 	}
 	}
 
 
@@ -99,6 +175,9 @@ export default class AttributeElement extends Element {
 		// Clone priority too.
 		// Clone priority too.
 		cloned._priority = this._priority;
 		cloned._priority = this._priority;
 
 
+		// And id too.
+		cloned._id = this._id;
+
 		return cloned;
 		return cloned;
 	}
 	}
 }
 }

+ 4 - 2
packages/ckeditor5-engine/src/view/range.js

@@ -386,9 +386,11 @@ export default class Range {
 	/**
 	/**
 	 * Creates a range from given parents and offsets.
 	 * Creates a range from given parents and offsets.
 	 *
 	 *
-	 * @param {module:engine/view/element~Element} startElement Start position parent element.
+	 * @param {module:engine/view/node~Node|module:engine/view/documentfragment~DocumentFragment} startElement Start position
+	 * parent element.
 	 * @param {Number} startOffset Start position offset.
 	 * @param {Number} startOffset Start position offset.
-	 * @param {module:engine/view/element~Element} endElement End position parent element.
+	 * @param {module:engine/view/node~Node|module:engine/view/documentfragment~DocumentFragment} endElement End position
+	 * parent element.
 	 * @param {Number} endOffset End position offset.
 	 * @param {Number} endOffset End position offset.
 	 * @returns {module:engine/view/range~Range} Created range.
 	 * @returns {module:engine/view/range~Range} Created range.
 	 */
 	 */

+ 266 - 147
packages/ckeditor5-engine/src/view/writer.js

@@ -32,6 +32,15 @@ export default class Writer {
 		 * @type {module:engine/view/document~Document}
 		 * @type {module:engine/view/document~Document}
 		 */
 		 */
 		this.document = document;
 		this.document = document;
+
+		/**
+		 * Holds references to the attribute groups that share the same {@link module:engine/view/attributeelement~AttributeElement#id id}.
+		 * The keys are `id`s, the values are `Set`s holding {@link module:engine/view/attributeelement~AttributeElement}s.
+		 *
+		 * @private
+		 * @type {Map}
+		 */
+		this._cloneGroups = new Map();
 	}
 	}
 
 
 	/**
 	/**
@@ -135,15 +144,28 @@ export default class Writer {
 	 *		writer.createAttributeElement( 'strong' );
 	 *		writer.createAttributeElement( 'strong' );
 	 *		writer.createAttributeElement( 'strong', { 'alignment': 'center' } );
 	 *		writer.createAttributeElement( 'strong', { 'alignment': 'center' } );
 	 *
 	 *
+	 *		// Make `<a>` element contain other attributes element so the `<a>` element is not broken.
+	 *		writer.createAttributeElement( 'a', { href: 'foo.bar' }, { priority: 5 } );
+	 *
+	 *		// Set `id` of a marker element so it is not joined or merged with "normal" elements.
+	 *		writer.createAttributeElement( 'span', { class: 'myMarker' }, { id: 'marker:my' } );
+	 *
 	 * @param {String} name Name of the element.
 	 * @param {String} name Name of the element.
-	 * @param {Object} [attributes] Elements attributes.
+	 * @param {Object} [attributes] Element's attributes.
+	 * @param {Object} [options] Element's options.
+	 * @param {Number} [options.priority] Element's {@link module:engine/view/attributeelement~AttributeElement#priority priority}.
+	 * @param {Number|String} [options.id] Element's {@link module:engine/view/attributeelement~AttributeElement#id id}.
 	 * @returns {module:engine/view/attributeelement~AttributeElement} Created element.
 	 * @returns {module:engine/view/attributeelement~AttributeElement} Created element.
 	 */
 	 */
-	createAttributeElement( name, attributes, priority ) {
+	createAttributeElement( name, attributes, options = {} ) {
 		const attributeElement = new AttributeElement( name, attributes );
 		const attributeElement = new AttributeElement( name, attributes );
 
 
-		if ( priority ) {
-			attributeElement._priority = priority;
+		if ( options.priority ) {
+			attributeElement._priority = options.priority;
+		}
+
+		if ( options.id ) {
+			attributeElement._id = options.id;
 		}
 		}
 
 
 		return attributeElement;
 		return attributeElement;
@@ -166,10 +188,9 @@ export default class Writer {
 	/**
 	/**
 	 * Creates new {@link module:engine/view/editableelement~EditableElement}.
 	 * Creates new {@link module:engine/view/editableelement~EditableElement}.
 	 *
 	 *
-	 *		writer.createEditableElement( document, 'div' );
-	 *		writer.createEditableElement( document, 'div', { 'alignment': 'center' } );
+	 *		writer.createEditableElement( 'div' );
+	 *		writer.createEditableElement( 'div', { 'alignment': 'center' } );
 	 *
 	 *
-	 * @param {module:engine/view/document~Document} document View document.
 	 * @param {String} name Name of the element.
 	 * @param {String} name Name of the element.
 	 * @param {Object} [attributes] Elements attributes.
 	 * @param {Object} [attributes] Elements attributes.
 	 * @returns {module:engine/view/editableelement~EditableElement} Created element.
 	 * @returns {module:engine/view/editableelement~EditableElement} Created element.
@@ -384,9 +405,9 @@ export default class Writer {
 	 */
 	 */
 	breakAttributes( positionOrRange ) {
 	breakAttributes( positionOrRange ) {
 		if ( positionOrRange instanceof Position ) {
 		if ( positionOrRange instanceof Position ) {
-			return _breakAttributes( positionOrRange );
+			return this._breakAttributes( positionOrRange );
 		} else {
 		} else {
-			return _breakAttributesRange( positionOrRange );
+			return this._breakAttributesRange( positionOrRange );
 		}
 		}
 	}
 	}
 
 
@@ -491,7 +512,9 @@ export default class Writer {
 		if ( positionParent.is( 'attributeElement' ) && positionParent.childCount === 0 ) {
 		if ( positionParent.is( 'attributeElement' ) && positionParent.childCount === 0 ) {
 			const parent = positionParent.parent;
 			const parent = positionParent.parent;
 			const offset = positionParent.index;
 			const offset = positionParent.index;
+
 			positionParent._remove();
 			positionParent._remove();
+			this._removeFromClonedElementsGroup( positionParent );
 
 
 			return this.mergeAttributes( new Position( parent, offset ) );
 			return this.mergeAttributes( new Position( parent, offset ) );
 		}
 		}
@@ -508,12 +531,14 @@ export default class Writer {
 		if ( nodeBefore.is( 'text' ) && nodeAfter.is( 'text' ) ) {
 		if ( nodeBefore.is( 'text' ) && nodeAfter.is( 'text' ) ) {
 			return mergeTextNodes( nodeBefore, nodeAfter );
 			return mergeTextNodes( nodeBefore, nodeAfter );
 		}
 		}
-		// When selection is between two same attribute elements.
+		// When position is between two same attribute elements.
 		else if ( nodeBefore.is( 'attributeElement' ) && nodeAfter.is( 'attributeElement' ) && nodeBefore.isSimilar( nodeAfter ) ) {
 		else if ( nodeBefore.is( 'attributeElement' ) && nodeAfter.is( 'attributeElement' ) && nodeBefore.isSimilar( nodeAfter ) ) {
 			// Move all children nodes from node placed after selection and remove that node.
 			// Move all children nodes from node placed after selection and remove that node.
 			const count = nodeBefore.childCount;
 			const count = nodeBefore.childCount;
 			nodeBefore._appendChildren( nodeAfter.getChildren() );
 			nodeBefore._appendChildren( nodeAfter.getChildren() );
+
 			nodeAfter._remove();
 			nodeAfter._remove();
+			this._removeFromClonedElementsGroup( nodeAfter );
 
 
 			// New position is located inside the first node, before new nodes.
 			// New position is located inside the first node, before new nodes.
 			// Call this method recursively to merge again if needed.
 			// Call this method recursively to merge again if needed.
@@ -600,9 +625,13 @@ export default class Writer {
 			throw new CKEditorError( 'view-writer-invalid-position-container' );
 			throw new CKEditorError( 'view-writer-invalid-position-container' );
 		}
 		}
 
 
-		const insertionPosition = _breakAttributes( position, true );
-
+		const insertionPosition = this._breakAttributes( position, true );
 		const length = container._insertChildren( insertionPosition.offset, nodes );
 		const length = container._insertChildren( insertionPosition.offset, nodes );
+
+		for ( const node of nodes ) {
+			this._addToClonedElementsGroup( node );
+		}
+
 		const endPosition = insertionPosition.getShiftedBy( length );
 		const endPosition = insertionPosition.getShiftedBy( length );
 		const start = this.mergeAttributes( insertionPosition );
 		const start = this.mergeAttributes( insertionPosition );
 
 
@@ -641,7 +670,7 @@ export default class Writer {
 		}
 		}
 
 
 		// Break attributes at range start and end.
 		// Break attributes at range start and end.
-		const { start: breakStart, end: breakEnd } = _breakAttributesRange( range, true );
+		const { start: breakStart, end: breakEnd } = this._breakAttributesRange( range, true );
 		const parentContainer = breakStart.parent;
 		const parentContainer = breakStart.parent;
 
 
 		const count = breakEnd.offset - breakStart.offset;
 		const count = breakEnd.offset - breakStart.offset;
@@ -649,6 +678,10 @@ export default class Writer {
 		// Remove nodes in range.
 		// Remove nodes in range.
 		const removed = parentContainer._removeChildren( breakStart.offset, count );
 		const removed = parentContainer._removeChildren( breakStart.offset, count );
 
 
+		for ( const node of removed ) {
+			this._removeFromClonedElementsGroup( node );
+		}
+
 		// Merge after removing.
 		// Merge after removing.
 		const mergePosition = this.mergeAttributes( breakStart );
 		const mergePosition = this.mergeAttributes( breakStart );
 		range.start = mergePosition;
 		range.start = mergePosition;
@@ -733,12 +766,12 @@ export default class Writer {
 		let nodes;
 		let nodes;
 
 
 		if ( targetPosition.isAfter( sourceRange.end ) ) {
 		if ( targetPosition.isAfter( sourceRange.end ) ) {
-			targetPosition = _breakAttributes( targetPosition, true );
+			targetPosition = this._breakAttributes( targetPosition, true );
 
 
 			const parent = targetPosition.parent;
 			const parent = targetPosition.parent;
 			const countBefore = parent.childCount;
 			const countBefore = parent.childCount;
 
 
-			sourceRange = _breakAttributesRange( sourceRange, true );
+			sourceRange = this._breakAttributesRange( sourceRange, true );
 
 
 			nodes = this.remove( sourceRange );
 			nodes = this.remove( sourceRange );
 
 
@@ -829,7 +862,7 @@ export default class Writer {
 		}
 		}
 
 
 		// Break attributes at range start and end.
 		// Break attributes at range start and end.
-		const { start: breakStart, end: breakEnd } = _breakAttributesRange( range, true );
+		const { start: breakStart, end: breakEnd } = this._breakAttributesRange( range, true );
 
 
 		// Range around one element - check if AttributeElement can be unwrapped partially when it's not similar.
 		// Range around one element - check if AttributeElement can be unwrapped partially when it's not similar.
 		// For example:
 		// For example:
@@ -916,8 +949,9 @@ export default class Writer {
 			if ( isText || isEmpty || isUI || ( isAttribute && shouldABeOutsideB( attribute, child ) ) ) {
 			if ( isText || isEmpty || isUI || ( isAttribute && shouldABeOutsideB( attribute, child ) ) ) {
 				// Clone attribute.
 				// Clone attribute.
 				const newAttribute = attribute._clone();
 				const newAttribute = attribute._clone();
+				this._addToClonedElementsGroup( newAttribute );
 
 
-				// Wrap current node with new attribute;
+				// Wrap current node with new attribute.
 				child._remove();
 				child._remove();
 				newAttribute._appendChildren( child );
 				newAttribute._appendChildren( child );
 				parent._insertChildren( i, newAttribute );
 				parent._insertChildren( i, newAttribute );
@@ -980,6 +1014,8 @@ export default class Writer {
 
 
 				// Replace wrapper element with its children
 				// Replace wrapper element with its children
 				child._remove();
 				child._remove();
+				this._removeFromClonedElementsGroup( child );
+
 				parent._insertChildren( i, unwrapped );
 				parent._insertChildren( i, unwrapped );
 
 
 				// Save start and end position of moved items.
 				// Save start and end position of moved items.
@@ -1049,7 +1085,7 @@ export default class Writer {
 		}
 		}
 
 
 		// Break attributes at range start and end.
 		// Break attributes at range start and end.
-		const { start: breakStart, end: breakEnd } = _breakAttributesRange( range, true );
+		const { start: breakStart, end: breakEnd } = this._breakAttributesRange( range, true );
 
 
 		// Range around one element.
 		// Range around one element.
 		if ( breakEnd.isEqual( breakStart.getShiftedBy( 1 ) ) ) {
 		if ( breakEnd.isEqual( breakStart.getShiftedBy( 1 ) ) ) {
@@ -1152,6 +1188,10 @@ export default class Writer {
 	 * 	@returns {Boolean} Returns `true` if elements are merged.
 	 * 	@returns {Boolean} Returns `true` if elements are merged.
 	 */
 	 */
 	_wrapAttributeElement( wrapper, toWrap ) {
 	_wrapAttributeElement( wrapper, toWrap ) {
+		if ( !canBeJoined( wrapper, toWrap ) ) {
+			return false;
+		}
+
 		// Can't merge if name or priority differs.
 		// Can't merge if name or priority differs.
 		if ( wrapper.name !== toWrap.name || wrapper.priority !== toWrap.priority ) {
 		if ( wrapper.name !== toWrap.name || wrapper.priority !== toWrap.priority ) {
 			return false;
 			return false;
@@ -1216,6 +1256,10 @@ export default class Writer {
 	 * @returns {Boolean} Returns `true` if elements are unwrapped.
 	 * @returns {Boolean} Returns `true` if elements are unwrapped.
 	 **/
 	 **/
 	_unwrapAttributeElement( wrapper, toUnwrap ) {
 	_unwrapAttributeElement( wrapper, toUnwrap ) {
+		if ( !canBeJoined( wrapper, toUnwrap ) ) {
+			return false;
+		}
+
 		// Can't unwrap if name or priority differs.
 		// Can't unwrap if name or priority differs.
 		if ( wrapper.name !== toUnwrap.name || wrapper.priority !== toUnwrap.priority ) {
 		if ( wrapper.name !== toUnwrap.name || wrapper.priority !== toUnwrap.priority ) {
 			return false;
 			return false;
@@ -1265,6 +1309,199 @@ export default class Writer {
 
 
 		return true;
 		return true;
 	}
 	}
+
+	/**
+	 * Helper function used by other `Writer` methods. Breaks attribute elements at the boundaries of given range.
+	 *
+	 * @private
+	 * @param {module:engine/view/range~Range} range Range which `start` and `end` positions will be used to break attributes.
+	 * @param {Boolean} [forceSplitText=false] If set to `true`, will break text nodes even if they are directly in container element.
+	 * This behavior will result in incorrect view state, but is needed by other view writing methods which then fixes view state.
+	 * @returns {module:engine/view/range~Range} New range with located at break positions.
+	 */
+	_breakAttributesRange( range, forceSplitText = false ) {
+		const rangeStart = range.start;
+		const rangeEnd = range.end;
+
+		validateRangeContainer( range );
+
+		// Break at the collapsed position. Return new collapsed range.
+		if ( range.isCollapsed ) {
+			const position = this._breakAttributes( range.start, forceSplitText );
+
+			return new Range( position, position );
+		}
+
+		const breakEnd = this._breakAttributes( rangeEnd, forceSplitText );
+		const count = breakEnd.parent.childCount;
+		const breakStart = this._breakAttributes( rangeStart, forceSplitText );
+
+		// Calculate new break end offset.
+		breakEnd.offset += breakEnd.parent.childCount - count;
+
+		return new Range( breakStart, breakEnd );
+	}
+
+	/**
+	 * Helper function used by other `Writer` methods. Breaks attribute elements at given position.
+	 *
+	 * Throws {@link module:utils/ckeditorerror~CKEditorError CKEditorError} `view-writer-cannot-break-empty-element` when break position
+	 * is placed inside {@link module:engine/view/emptyelement~EmptyElement EmptyElement}.
+	 *
+	 * Throws {@link module:utils/ckeditorerror~CKEditorError CKEditorError} `view-writer-cannot-break-ui-element` when break position
+	 * is placed inside {@link module:engine/view/uielement~UIElement UIElement}.
+	 *
+	 * @private
+	 * @param {module:engine/view/position~Position} position Position where to break attributes.
+	 * @param {Boolean} [forceSplitText=false] If set to `true`, will break text nodes even if they are directly in container element.
+	 * This behavior will result in incorrect view state, but is needed by other view writing methods which then fixes view state.
+	 * @returns {module:engine/view/position~Position} New position after breaking the attributes.
+	 */
+	_breakAttributes( position, forceSplitText = false ) {
+		const positionOffset = position.offset;
+		const positionParent = position.parent;
+
+		// If position is placed inside EmptyElement - throw an exception as we cannot break inside.
+		if ( position.parent.is( 'emptyElement' ) ) {
+			/**
+			 * Cannot break inside EmptyElement instance.
+			 *
+			 * @error view-writer-cannot-break-empty-element
+			 */
+			throw new CKEditorError( 'view-writer-cannot-break-empty-element' );
+		}
+
+		// If position is placed inside UIElement - throw an exception as we cannot break inside.
+		if ( position.parent.is( 'uiElement' ) ) {
+			/**
+			 * Cannot break inside UIElement instance.
+			 *
+			 * @error view-writer-cannot-break-ui-element
+			 */
+			throw new CKEditorError( 'view-writer-cannot-break-ui-element' );
+		}
+
+		// There are no attributes to break and text nodes breaking is not forced.
+		if ( !forceSplitText && positionParent.is( 'text' ) && isContainerOrFragment( positionParent.parent ) ) {
+			return Position.createFromPosition( position );
+		}
+
+		// Position's parent is container, so no attributes to break.
+		if ( isContainerOrFragment( positionParent ) ) {
+			return Position.createFromPosition( position );
+		}
+
+		// Break text and start again in new position.
+		if ( positionParent.is( 'text' ) ) {
+			return this._breakAttributes( breakTextNode( position ), forceSplitText );
+		}
+
+		const length = positionParent.childCount;
+
+		// <p>foo<b><u>bar{}</u></b></p>
+		// <p>foo<b><u>bar</u>[]</b></p>
+		// <p>foo<b><u>bar</u></b>[]</p>
+		if ( positionOffset == length ) {
+			const newPosition = new Position( positionParent.parent, positionParent.index + 1 );
+
+			return this._breakAttributes( newPosition, forceSplitText );
+		} else
+		// <p>foo<b><u>{}bar</u></b></p>
+		// <p>foo<b>[]<u>bar</u></b></p>
+		// <p>foo{}<b><u>bar</u></b></p>
+		if ( positionOffset === 0 ) {
+			const newPosition = new Position( positionParent.parent, positionParent.index );
+
+			return this._breakAttributes( newPosition, forceSplitText );
+		}
+		// <p>foo<b><u>b{}ar</u></b></p>
+		// <p>foo<b><u>b[]ar</u></b></p>
+		// <p>foo<b><u>b</u>[]<u>ar</u></b></p>
+		// <p>foo<b><u>b</u></b>[]<b><u>ar</u></b></p>
+		else {
+			const offsetAfter = positionParent.index + 1;
+
+			// Break element.
+			const clonedNode = positionParent._clone();
+			this._addToClonedElementsGroup( clonedNode );
+
+			// Insert cloned node to position's parent node.
+			positionParent.parent._insertChildren( offsetAfter, clonedNode );
+
+			// Get nodes to move.
+			const count = positionParent.childCount - positionOffset;
+			const nodesToMove = positionParent._removeChildren( positionOffset, count );
+
+			// Move nodes to cloned node.
+			clonedNode._appendChildren( nodesToMove );
+
+			// Create new position to work on.
+			const newPosition = new Position( positionParent.parent, offsetAfter );
+
+			return this._breakAttributes( newPosition, forceSplitText );
+		}
+	}
+
+	/**
+	 * Stores the information that an {@link module:engine/view/attributeelement~AttributeElement attribute element} was
+	 * added to the tree. Saves the reference to the group in the given element and updates the group, so other elements
+	 * from the group now keep a reference to the given attribute element.
+	 *
+	 * The clones group can be obtained using {@link module:engine/view/attributeelement~AttributeElement#getElementsWithSameId}.
+	 *
+	 * Does nothing if added element has no {@link module:engine/view/attributeelement~AttributeElement#id id}.
+	 *
+	 * @private
+	 * @param {module:engine/view/attributeelement~AttributeElement} element Attribute element to save.
+	 */
+	_addToClonedElementsGroup( element ) {
+		const id = element.id;
+
+		if ( !id ) {
+			return;
+		}
+
+		let group = this._cloneGroups.get( id );
+
+		if ( !group ) {
+			group = new Set();
+			this._cloneGroups.set( id, group );
+		}
+
+		group.add( element );
+		element._clonesGroup = group;
+	}
+
+	/**
+	 * Removes all the information about the given {@link module:engine/view/attributeelement~AttributeElement attribute element}
+	 * from its clones group.
+	 *
+	 * Keep in mind, that the element will still keep a reference to the group (but the group will not keep a reference to it).
+	 * This allows to reference the whole group even if the element was already removed from the tree.
+	 *
+	 * Does nothing if the element has no {@link module:engine/view/attributeelement~AttributeElement#id id}.
+	 *
+	 * @private
+	 * @param {module:engine/view/attributeelement~AttributeElement} element Attribute element to remove.
+	 */
+	_removeFromClonedElementsGroup( element ) {
+		const id = element.id;
+
+		if ( !id ) {
+			return;
+		}
+
+		const group = this._cloneGroups.get( id );
+
+		group.delete( element );
+		// Not removing group from element on purpose!
+		// If other parts of code have reference to this element, they will be able to get references to other elements from the group.
+		// If all other elements are removed from the set, everything will be garbage collected.
+
+		if ( group.size === 0 ) {
+			this._cloneGroups.delete( id );
+		}
+	}
 }
 }
 
 
 // Helper function for `view.writer.wrap`. Checks if given element has any children that are not ui elements.
 // Helper function for `view.writer.wrap`. Checks if given element has any children that are not ui elements.
@@ -1298,135 +1535,6 @@ function getParentContainer( position ) {
 	return parent;
 	return parent;
 }
 }
 
 
-// Function used by both public breakAttributes (without splitting text nodes) and by other methods (with
-// splitting text nodes).
-//
-// @param {module:engine/view/range~Range} range Range which `start` and `end` positions will be used to break attributes.
-// @param {Boolean} [forceSplitText = false] If set to `true`, will break text nodes even if they are directly in
-// container element. This behavior will result in incorrect view state, but is needed by other view writing methods
-// which then fixes view state. Defaults to `false`.
-// @returns {module:engine/view/range~Range} New range with located at break positions.
-function _breakAttributesRange( range, forceSplitText = false ) {
-	const rangeStart = range.start;
-	const rangeEnd = range.end;
-
-	validateRangeContainer( range );
-
-	// Break at the collapsed position. Return new collapsed range.
-	if ( range.isCollapsed ) {
-		const position = _breakAttributes( range.start, forceSplitText );
-
-		return new Range( position, position );
-	}
-
-	const breakEnd = _breakAttributes( rangeEnd, forceSplitText );
-	const count = breakEnd.parent.childCount;
-	const breakStart = _breakAttributes( rangeStart, forceSplitText );
-
-	// Calculate new break end offset.
-	breakEnd.offset += breakEnd.parent.childCount - count;
-
-	return new Range( breakStart, breakEnd );
-}
-
-// Function used by public breakAttributes (without splitting text nodes) and by other methods (with
-// splitting text nodes).
-//
-// Throws {@link module:utils/ckeditorerror~CKEditorError CKEditorError} `view-writer-cannot-break-empty-element` when break position
-// is placed inside {@link module:engine/view/emptyelement~EmptyElement EmptyElement}.
-//
-// Throws {@link module:utils/ckeditorerror~CKEditorError CKEditorError} `view-writer-cannot-break-ui-element` when break position
-// is placed inside {@link module:engine/view/uielement~UIElement UIElement}.
-//
-// @param {module:engine/view/position~Position} position Position where to break attributes.
-// @param {Boolean} [forceSplitText = false] If set to `true`, will break text nodes even if they are directly in
-// container element. This behavior will result in incorrect view state, but is needed by other view writing methods
-// which then fixes view state. Defaults to `false`.
-// @returns {module:engine/view/position~Position} New position after breaking the attributes.
-function _breakAttributes( position, forceSplitText = false ) {
-	const positionOffset = position.offset;
-	const positionParent = position.parent;
-
-	// If position is placed inside EmptyElement - throw an exception as we cannot break inside.
-	if ( position.parent.is( 'emptyElement' ) ) {
-		/**
-		 * Cannot break inside EmptyElement instance.
-		 *
-		 * @error view-writer-cannot-break-empty-element
-		 */
-		throw new CKEditorError( 'view-writer-cannot-break-empty-element' );
-	}
-
-	// If position is placed inside UIElement - throw an exception as we cannot break inside.
-	if ( position.parent.is( 'uiElement' ) ) {
-		/**
-		 * Cannot break inside UIElement instance.
-		 *
-		 * @error view-writer-cannot-break-ui-element
-		 */
-		throw new CKEditorError( 'view-writer-cannot-break-ui-element' );
-	}
-
-	// There are no attributes to break and text nodes breaking is not forced.
-	if ( !forceSplitText && positionParent.is( 'text' ) && isContainerOrFragment( positionParent.parent ) ) {
-		return Position.createFromPosition( position );
-	}
-
-	// Position's parent is container, so no attributes to break.
-	if ( isContainerOrFragment( positionParent ) ) {
-		return Position.createFromPosition( position );
-	}
-
-	// Break text and start again in new position.
-	if ( positionParent.is( 'text' ) ) {
-		return _breakAttributes( breakTextNode( position ), forceSplitText );
-	}
-
-	const length = positionParent.childCount;
-
-	// <p>foo<b><u>bar{}</u></b></p>
-	// <p>foo<b><u>bar</u>[]</b></p>
-	// <p>foo<b><u>bar</u></b>[]</p>
-	if ( positionOffset == length ) {
-		const newPosition = new Position( positionParent.parent, positionParent.index + 1 );
-
-		return _breakAttributes( newPosition, forceSplitText );
-	} else
-	// <p>foo<b><u>{}bar</u></b></p>
-	// <p>foo<b>[]<u>bar</u></b></p>
-	// <p>foo{}<b><u>bar</u></b></p>
-	if ( positionOffset === 0 ) {
-		const newPosition = new Position( positionParent.parent, positionParent.index );
-
-		return _breakAttributes( newPosition, forceSplitText );
-	}
-	// <p>foo<b><u>b{}ar</u></b></p>
-	// <p>foo<b><u>b[]ar</u></b></p>
-	// <p>foo<b><u>b</u>[]<u>ar</u></b></p>
-	// <p>foo<b><u>b</u></b>[]<b><u>ar</u></b></p>
-	else {
-		const offsetAfter = positionParent.index + 1;
-
-		// Break element.
-		const clonedNode = positionParent._clone();
-
-		// Insert cloned node to position's parent node.
-		positionParent.parent._insertChildren( offsetAfter, clonedNode );
-
-		// Get nodes to move.
-		const count = positionParent.childCount - positionOffset;
-		const nodesToMove = positionParent._removeChildren( positionOffset, count );
-
-		// Move nodes to cloned node.
-		clonedNode._appendChildren( nodesToMove );
-
-		// Create new position to work on.
-		const newPosition = new Position( positionParent.parent, offsetAfter );
-
-		return _breakAttributes( newPosition, forceSplitText );
-	}
-}
-
 // Checks if first {@link module:engine/view/attributeelement~AttributeElement AttributeElement} provided to the function
 // Checks if first {@link module:engine/view/attributeelement~AttributeElement AttributeElement} provided to the function
 // can be wrapped otuside second element. It is done by comparing elements'
 // can be wrapped otuside second element. It is done by comparing elements'
 // {@link module:engine/view/attributeelement~AttributeElement#priority priorities}, if both have same priority
 // {@link module:engine/view/attributeelement~AttributeElement#priority priorities}, if both have same priority
@@ -1592,3 +1700,14 @@ function validateRangeContainer( range ) {
 		throw new CKEditorError( 'view-writer-invalid-range-container' );
 		throw new CKEditorError( 'view-writer-invalid-range-container' );
 	}
 	}
 }
 }
+
+// Checks if two attribute elements can be joined together. Elements can be joined together if, and only if
+// they do not have ids specified.
+//
+// @private
+// @param {module:engine/view/element~Element} a
+// @param {module:engine/view/element~Element} b
+// @returns {Boolean}
+function canBeJoined( a, b ) {
+	return a.id === null && b.id === null;
+}

+ 0 - 220
packages/ckeditor5-engine/tests/controller/editingcontroller.js

@@ -341,226 +341,6 @@ describe( 'EditingController', () => {
 		} );
 		} );
 	} );
 	} );
 
 
-	describe( 'marker clearing', () => {
-		let model, modelRoot, editing, domRoot, mcd, p1;
-
-		beforeEach( () => {
-			model = new Model();
-			modelRoot = model.document.createRoot();
-
-			editing = new EditingController( model );
-
-			domRoot = document.createElement( 'div' );
-			domRoot.contentEditable = true;
-
-			document.body.appendChild( domRoot );
-
-			model.schema.register( 'paragraph', { inheritAllFrom: '$block' } );
-			model.schema.register( 'div', { inheritAllFrom: '$block' } );
-
-			downcastElementToElement( { model: 'paragraph', view: 'p' } )( editing.downcastDispatcher );
-			downcastElementToElement( { model: 'div', view: 'div' } )( editing.downcastDispatcher );
-			downcastMarkerToHighlight( { model: 'marker', view: {} } )( editing.downcastDispatcher );
-
-			const modelData = new ModelDocumentFragment( parse(
-				'<paragraph>foo</paragraph>' +
-				'<paragraph>bar</paragraph>',
-				model.schema
-			)._children );
-
-			model.change( writer => {
-				writer.insert( modelData, modelRoot );
-				p1 = modelRoot.getChild( 0 );
-
-				writer.setSelection( ModelRange.createFromParentsAndOffsets( p1, 0, p1, 0 ) );
-			} );
-
-			mcd = editing.downcastDispatcher;
-			sinon.spy( mcd, 'convertMarkerRemove' );
-		} );
-
-		afterEach( () => {
-			document.body.removeChild( domRoot );
-			editing.destroy();
-		} );
-
-		it( 'should remove marker from view if it will be affected by insert operation', () => {
-			model.change( writer => {
-				writer.setMarker( 'marker', ModelRange.createFromParentsAndOffsets( p1, 1, p1, 2 ), { usingOperation: true } );
-			} );
-
-			// Adding with 'high' priority, because `applyOperation` is decorated - its default callback is fired with 'normal' priority.
-			model.on( 'applyOperation', () => {
-				expect( mcd.convertMarkerRemove.calledOnce ).to.be.true;
-				expect( getViewData( editing.view, { withoutSelection: true } ) ).to.equal( '<p>foo</p><p>bar</p>' );
-			}, { priority: 'high' } );
-
-			model.change( writer => {
-				writer.insertText( 'a', p1, 0 );
-			} );
-
-			expect( getViewData( editing.view, { withoutSelection: true } ) ).to.equal( '<p>af<span>o</span>o</p><p>bar</p>' );
-		} );
-
-		it( 'should remove marker from view if it will be affected by remove operation', () => {
-			model.change( writer => {
-				writer.setMarker( 'marker', ModelRange.createFromParentsAndOffsets( p1, 1, p1, 2 ), { usingOperation: true } );
-			} );
-
-			// Adding with 'high' priority, because `applyOperation` is decorated - its default callback is fired with 'normal' priority.
-			model.on( 'applyOperation', () => {
-				expect( mcd.convertMarkerRemove.calledOnce ).to.be.true;
-				expect( getViewData( editing.view, { withoutSelection: true } ) ).to.equal( '<p>foo</p><p>bar</p>' );
-			}, { priority: 'high' } );
-
-			model.change( writer => {
-				writer.remove( ModelRange.createFromParentsAndOffsets( p1, 0, p1, 1 ) );
-			} );
-
-			expect( getViewData( editing.view, { withoutSelection: true } ) ).to.equal( '<p><span>o</span>o</p><p>bar</p>' );
-		} );
-
-		it( 'should remove marker from view if it will be affected by move operation', () => {
-			model.change( writer => {
-				writer.setMarker( 'marker', ModelRange.createFromParentsAndOffsets( p1, 1, p1, 2 ), { usingOperation: true } );
-			} );
-
-			// Adding with 'high' priority, because `applyOperation` is decorated - its default callback is fired with 'normal' priority.
-			model.on( 'applyOperation', () => {
-				expect( mcd.convertMarkerRemove.calledOnce ).to.be.true;
-				expect( getViewData( editing.view, { withoutSelection: true } ) ).to.equal( '<p>foo</p><p>bar</p>' );
-			}, { priority: 'high' } );
-
-			model.change( writer => {
-				const p2 = p1.nextSibling;
-
-				writer.move( ModelRange.createFromParentsAndOffsets( p2, 0, p2, 2 ), p1, 0 );
-			} );
-
-			expect( getViewData( editing.view, { withoutSelection: true } ) ).to.equal( '<p>baf<span>o</span>o</p><p>r</p>' );
-		} );
-
-		it( 'should remove marker from view if it will be affected by rename operation', () => {
-			model.change( writer => {
-				writer.setMarker(
-					'marker',
-					ModelRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 1 ),
-					{ usingOperation: true }
-				);
-			} );
-
-			// Adding with 'high' priority, because `applyOperation` is decorated - its default callback is fired with 'normal' priority.
-			model.on( 'applyOperation', () => {
-				expect( mcd.convertMarkerRemove.calledOnce ).to.be.true;
-				expect( getViewData( editing.view, { withoutSelection: true } ) ).to.equal( '<p>foo</p><p>bar</p>' );
-			}, { priority: 'high' } );
-
-			model.change( writer => {
-				writer.rename( p1, 'div' );
-			} );
-
-			expect( getViewData( editing.view, { withoutSelection: true } ) ).to.equal( '<div><span>foo</span></div><p>bar</p>' );
-		} );
-
-		it( 'should remove marker from view if it will be affected by marker operation', () => {
-			model.change( writer => {
-				writer.setMarker( 'marker', ModelRange.createFromParentsAndOffsets( p1, 1, p1, 2 ), { usingOperation: true } );
-			} );
-
-			// Adding with 'high' priority, because `applyOperation` is decorated - its default callback is fired with 'normal' priority.
-			model.on( 'applyOperation', () => {
-				expect( mcd.convertMarkerRemove.calledOnce ).to.be.true;
-				expect( getViewData( editing.view, { withoutSelection: true } ) ).to.equal( '<p>foo</p><p>bar</p>' );
-			}, { priority: 'high' } );
-
-			model.change( writer => {
-				const p2 = p1.nextSibling;
-
-				writer.setMarker( 'marker', ModelRange.createFromParentsAndOffsets( p2, 1, p2, 2 ), { usingOperation: true } );
-			} );
-
-			expect( getViewData( editing.view, { withoutSelection: true } ) ).to.equal( '<p>foo</p><p>b<span>a</span>r</p>' );
-		} );
-
-		it( 'should remove marker from view if it is removed through marker collection', () => {
-			model.change( writer => {
-				writer.setMarker( 'marker', ModelRange.createFromParentsAndOffsets( p1, 1, p1, 2 ), { usingOperation: true } );
-			} );
-
-			// Adding with 'high' priority, because `applyOperation` is decorated - its default callback is fired with 'normal' priority.
-			model.markers.on( 'remove:marker', () => {
-				expect( mcd.convertMarkerRemove.calledOnce ).to.be.true;
-				expect( getViewData( editing.view, { withoutSelection: true } ) ).to.equal( '<p>foo</p><p>bar</p>' );
-			}, { priority: 'low' } );
-
-			model.change( writer => {
-				writer.removeMarker( 'marker' );
-			} );
-
-			expect( getViewData( editing.view, { withoutSelection: true } ) ).to.equal( '<p>foo</p><p>bar</p>' );
-		} );
-
-		it( 'should not remove marker if applied operation is an attribute operation', () => {
-			model.change( writer => {
-				writer.setMarker( 'marker', ModelRange.createFromParentsAndOffsets( p1, 1, p1, 2 ), { usingOperation: true } );
-			} );
-
-			// Adding with 'high' priority, because `applyOperation` is decorated - its default callback is fired with 'normal' priority.
-			model.on( 'applyOperation', () => {
-				expect( mcd.convertMarkerRemove.calledOnce ).to.be.false;
-				expect( getViewData( editing.view, { withoutSelection: true } ) ).to.equal( '<p>f<span>o</span>o</p><p>bar</p>' );
-			}, { priority: 'high' } );
-
-			model.change( writer => {
-				writer.setAttribute( 'foo', 'bar', ModelRange.createFromParentsAndOffsets( p1, 0, p1, 2 ) );
-			} );
-
-			expect( getViewData( editing.view, { withoutSelection: true } ) ).to.equal( '<p>f<span>o</span>o</p><p>bar</p>' );
-		} );
-
-		it( 'should not crash if multiple operations affect a marker', () => {
-			model.change( writer => {
-				writer.setMarker( 'marker', ModelRange.createFromParentsAndOffsets( p1, 1, p1, 2 ), { usingOperation: true } );
-			} );
-
-			model.change( writer => {
-				writer.insertText( 'a', p1, 0 );
-				writer.insertText( 'a', p1, 0 );
-				writer.insertText( 'a', p1, 0 );
-			} );
-
-			expect( getViewData( editing.view, { withoutSelection: true } ) ).to.equal( '<p>aaaf<span>o</span>o</p><p>bar</p>' );
-		} );
-
-		it( 'should not crash if marker is removed, added and removed #1', () => {
-			model.change( writer => {
-				writer.setMarker( 'marker', ModelRange.createFromParentsAndOffsets( p1, 1, p1, 2 ), { usingOperation: true } );
-			} );
-
-			model.change( writer => {
-				writer.insertText( 'a', p1, 0 );
-				writer.setMarker( 'marker', ModelRange.createFromParentsAndOffsets( p1, 3, p1, 4 ), { usingOperation: true } );
-				writer.insertText( 'a', p1, 0 );
-			} );
-
-			expect( getViewData( editing.view, { withoutSelection: true } ) ).to.equal( '<p>aafo<span>o</span></p><p>bar</p>' );
-		} );
-
-		it( 'should not crash if marker is removed, added and removed #2', () => {
-			model.change( writer => {
-				writer.setMarker( 'marker', ModelRange.createFromParentsAndOffsets( p1, 1, p1, 2 ), { usingOperation: true } );
-			} );
-
-			model.change( writer => {
-				writer.removeMarker( 'marker', { usingOperation: true } );
-				writer.setMarker( 'marker', ModelRange.createFromParentsAndOffsets( p1, 0, p1, 1 ), { usingOperation: true } );
-				writer.removeMarker( 'marker', { usingOperation: true } );
-			} );
-
-			expect( getViewData( editing.view, { withoutSelection: true } ) ).to.equal( '<p>foo</p><p>bar</p>' );
-		} );
-	} );
-
 	describe( 'destroy()', () => {
 	describe( 'destroy()', () => {
 		it( 'should remove listenters', () => {
 		it( 'should remove listenters', () => {
 			const model = new Model();
 			const model = new Model();

+ 135 - 32
packages/ckeditor5-engine/tests/conversion/downcast-converters.js

@@ -1279,6 +1279,141 @@ describe( 'downcast-converters', () => {
 
 
 				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foo</p><p>bar</p></div>' );
 				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foo</p><p>bar</p></div>' );
 			} );
 			} );
+
+			it( 'should correctly wrap and unwrap multiple, intersecting markers', () => {
+				const descriptorFoo = { class: 'foo' };
+				const descriptorBar = { class: 'bar' };
+				const descriptorXyz = { class: 'xyz' };
+
+				dispatcher.on( 'addMarker:markerFoo', highlightText( descriptorFoo ) );
+				dispatcher.on( 'addMarker:markerBar', highlightText( descriptorBar ) );
+				dispatcher.on( 'addMarker:markerXyz', highlightText( descriptorXyz ) );
+
+				dispatcher.on( 'removeMarker:markerFoo', removeHighlight( descriptorFoo ) );
+				dispatcher.on( 'removeMarker:markerBar', removeHighlight( descriptorBar ) );
+				dispatcher.on( 'removeMarker:markerXyz', removeHighlight( descriptorXyz ) );
+
+				const p1 = modelRoot.getChild( 0 );
+				const p2 = modelRoot.getChild( 1 );
+
+				model.change( writer => {
+					writer.setMarker( 'markerFoo', ModelRange.createFromParentsAndOffsets( p1, 0, p1, 3 ) );
+				} );
+
+				expect( viewToString( viewRoot ) ).to.equal(
+					'<div>' +
+						'<p>' +
+							'<span class="foo">foo</span>' +
+						'</p>' +
+						'<p>bar</p>' +
+					'</div>'
+				);
+
+				model.change( writer => {
+					writer.setMarker( 'markerBar', ModelRange.createFromParentsAndOffsets( p1, 1, p2, 2 ) );
+				} );
+
+				expect( viewToString( viewRoot ) ).to.equal(
+					'<div>' +
+						'<p>' +
+							'<span class="foo">f</span>' +
+							'<span class="bar">' +
+								'<span class="foo">oo</span>' +
+							'</span>' +
+						'</p>' +
+						'<p>' +
+							'<span class="bar">ba</span>' +
+							'r' +
+						'</p>' +
+					'</div>'
+				);
+
+				model.change( writer => {
+					writer.setMarker( 'markerXyz', ModelRange.createFromParentsAndOffsets( p1, 2, p2, 3 ) );
+				} );
+
+				expect( viewToString( viewRoot ) ).to.equal(
+					'<div>' +
+						'<p>' +
+							'<span class="foo">f</span>' +
+							'<span class="bar">' +
+								'<span class="foo">' +
+									'o' +
+									'<span class="xyz">o</span>' +
+								'</span>' +
+							'</span>' +
+						'</p>' +
+						'<p>' +
+							'<span class="bar">' +
+								'<span class="xyz">ba</span>' +
+							'</span>' +
+							'<span class="xyz">r</span>' +
+						'</p>' +
+					'</div>'
+				);
+
+				model.change( writer => {
+					writer.removeMarker( 'markerBar' );
+				} );
+
+				expect( viewToString( viewRoot ) ).to.equal(
+					'<div>' +
+						'<p>' +
+							'<span class="foo">' +
+								'fo' +
+								'<span class="xyz">o</span>' +
+							'</span>' +
+						'</p>' +
+						'<p>' +
+							'<span class="xyz">bar</span>' +
+						'</p>' +
+					'</div>'
+				);
+
+				model.change( writer => {
+					writer.removeMarker( 'markerFoo' );
+				} );
+
+				expect( viewToString( viewRoot ) ).to.equal(
+					'<div>' +
+						'<p>' +
+							'fo' +
+							'<span class="xyz">o</span>' +
+						'</p>' +
+						'<p>' +
+							'<span class="xyz">bar</span>' +
+						'</p>' +
+					'</div>'
+				);
+
+				model.change( writer => {
+					writer.removeMarker( 'markerXyz' );
+				} );
+
+				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foo</p><p>bar</p></div>' );
+			} );
+
+			it( 'should do nothing if marker is applied and removed on empty-ish range', () => {
+				dispatcher.on( 'addMarker:marker', highlightText( highlightDescriptor ) );
+				dispatcher.on( 'removeMarker:marker', removeHighlight( highlightDescriptor ) );
+
+				const p1 = modelRoot.getChild( 0 );
+				const p2 = modelRoot.getChild( 1 );
+
+				const markerRange = ModelRange.createFromParentsAndOffsets( p1, 3, p2, 0 );
+
+				model.change( writer => {
+					writer.setMarker( 'marker', markerRange );
+				} );
+
+				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foo</p><p>bar</p></div>' );
+
+				model.change( writer => {
+					writer.removeMarker( 'marker', markerRange );
+				} );
+
+				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foo</p><p>bar</p></div>' );
+			} );
 		} );
 		} );
 
 
 		describe( 'on element', () => {
 		describe( 'on element', () => {
@@ -1490,37 +1625,5 @@ describe( 'downcast-converters', () => {
 			expect( element.priority ).to.equal( 7 );
 			expect( element.priority ).to.equal( 7 );
 			expect( element.hasClass( 'foo-class' ) ).to.be.true;
 			expect( element.hasClass( 'foo-class' ) ).to.be.true;
 		} );
 		} );
-
-		it( 'should create similar elements if they are created using same descriptor id', () => {
-			const a = createViewElementFromHighlightDescriptor( {
-				id: 'id',
-				class: 'classA',
-				priority: 1
-			} );
-
-			const b = createViewElementFromHighlightDescriptor( {
-				id: 'id',
-				class: 'classB',
-				priority: 2
-			} );
-
-			expect( a.isSimilar( b ) ).to.be.true;
-		} );
-
-		it( 'should create non-similar elements if they have different descriptor id', () => {
-			const a = createViewElementFromHighlightDescriptor( {
-				id: 'a',
-				class: 'foo',
-				priority: 1
-			} );
-
-			const b = createViewElementFromHighlightDescriptor( {
-				id: 'b',
-				class: 'foo',
-				priority: 1
-			} );
-
-			expect( a.isSimilar( b ) ).to.be.false;
-		} );
 	} );
 	} );
 } );
 } );

+ 50 - 0
packages/ckeditor5-engine/tests/conversion/mapper.js

@@ -610,6 +610,56 @@ describe( 'Mapper', () => {
 		}
 		}
 	} );
 	} );
 
 
+	describe( 'Markers mapping', () => {
+		let mapper;
+
+		beforeEach( () => {
+			mapper = new Mapper();
+		} );
+
+		it( 'should bind element to a marker name', () => {
+			const view = new ViewElement( 'a' );
+
+			mapper.bindElementToMarker( view, 'marker' );
+
+			const elements = mapper.markerNameToElements( 'marker' );
+
+			expect( elements ).to.be.instanceof( Set );
+			expect( elements.size ).to.equal( 1 );
+			expect( elements.has( view ) ).to.be.true;
+		} );
+
+		it( 'should bind multiple elements to a marker name', () => {
+			const viewA = new ViewElement( 'a' );
+			const viewB = new ViewElement( 'b' );
+			const viewC = new ViewElement( 'c' );
+
+			mapper.bindElementToMarker( viewA, 'marker' );
+			mapper.bindElementToMarker( viewB, 'marker' );
+			mapper.bindElementToMarker( viewC, 'marker' );
+
+			const elements = Array.from( mapper.markerNameToElements( 'marker' ) );
+
+			expect( elements ).to.deep.equal( [ viewA, viewB, viewC ] );
+		} );
+
+		it( 'should unbind all elements from a marker name', () => {
+			const viewA = new ViewElement( 'a' );
+			const viewB = new ViewElement( 'b' );
+			const viewC = new ViewElement( 'c' );
+
+			mapper.bindElementToMarker( viewA, 'marker' );
+			mapper.bindElementToMarker( viewB, 'marker' );
+			mapper.bindElementToMarker( viewC, 'marker' );
+
+			mapper.unbindElementsFromMarkerName( 'marker' );
+
+			const elements = mapper.markerNameToElements( 'marker' );
+
+			expect( elements ).to.be.null;
+		} );
+	} );
+
 	it( 'should pass isPhantom flag to model-to-view position mapping callback', () => {
 	it( 'should pass isPhantom flag to model-to-view position mapping callback', () => {
 		const mapper = new Mapper();
 		const mapper = new Mapper();
 
 

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

@@ -254,6 +254,16 @@ describe( 'view test utils', () => {
 				.to.equal( '<p><b view-priority="10">foobar</b></p>' );
 				.to.equal( '<p><b view-priority="10">foobar</b></p>' );
 		} );
 		} );
 
 
+		it( 'should write elements id when needed', () => {
+			const text = new Text( 'foobar' );
+			const span = new AttributeElement( 'span', null, text );
+			span._id = 'foo';
+			const p = new ContainerElement( 'p', null, span );
+
+			expect( stringify( p, null, { showAttributeElementId: true } ) )
+				.to.equal( '<p><span view-id="foo">foobar</span></p>' );
+		} );
+
 		it( 'should parse DocumentFragment as root', () => {
 		it( 'should parse DocumentFragment as root', () => {
 			const text1 = new Text( 'foobar' );
 			const text1 = new Text( 'foobar' );
 			const text2 = new Text( 'bazqux' );
 			const text2 = new Text( 'bazqux' );
@@ -465,6 +475,14 @@ describe( 'view test utils', () => {
 			expect( parsed2.isSimilar( attribute2 ) ).to.be.true;
 			expect( parsed2.isSimilar( attribute2 ) ).to.be.true;
 		} );
 		} );
 
 
+		it( 'should parse attribute element id', () => {
+			const parsed1 = parse( '<attribute:span view-id="foo"></attribute:span>' );
+			expect( parsed1.id ).to.equal( 'foo' );
+
+			const parsed2 = parse( '<container:div view-id="bar"></container:div>' );
+			expect( parsed2.id ).to.be.undefined;
+		} );
+
 		it( 'should paste nested elements and texts', () => {
 		it( 'should paste nested elements and texts', () => {
 			const parsed = parse( '<container:p>foo<b view-priority="12">bar<i view-priority="25">qux</i></b></container:p>' );
 			const parsed = parse( '<container:p>foo<b view-priority="12">bar<i view-priority="25">qux</i></b></container:p>' );
 			expect( parsed.isSimilar( new ContainerElement( 'p' ) ) ).to.be.true;
 			expect( parsed.isSimilar( new ContainerElement( 'p' ) ) ).to.be.true;

+ 52 - 0
packages/ckeditor5-engine/tests/view/attributeelement.js

@@ -6,6 +6,7 @@
 import AttributeElement from '../../src/view/attributeelement';
 import AttributeElement from '../../src/view/attributeelement';
 import Element from '../../src/view/element';
 import Element from '../../src/view/element';
 import { parse } from '../../src/dev-utils/view';
 import { parse } from '../../src/dev-utils/view';
+import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 
 
 describe( 'AttributeElement', () => {
 describe( 'AttributeElement', () => {
 	describe( 'constructor()', () => {
 	describe( 'constructor()', () => {
@@ -80,6 +81,57 @@ describe( 'AttributeElement', () => {
 
 
 			expect( b1.isSimilar( b2 ) ).to.be.false;
 			expect( b1.isSimilar( b2 ) ).to.be.false;
 		} );
 		} );
+
+		it( 'should return true if ids are the same even if other properties are different', () => {
+			const element1 = new AttributeElement( 'b' );
+			element1._id = 'xyz';
+
+			const element2 = new AttributeElement( 'b', { foo: 'bar' } );
+			element2._id = 'xyz';
+
+			const element3 = new AttributeElement( 'span' );
+			element3._id = 'xyz';
+
+			expect( element1.isSimilar( element2 ) ).to.be.true;
+			expect( element1.isSimilar( element3 ) ).to.be.true;
+		} );
+
+		it( 'should return false if ids are different even if other properties are same', () => {
+			const element1 = new AttributeElement( 'span', { foo: 'bar' } );
+			element1._priority = 3;
+			element1._id = 'foo';
+
+			const element2 = new AttributeElement( 'span', { foo: 'bar' } );
+			element2._priority = 3;
+			element2._id = 'bar';
+
+			expect( element1.isSimilar( element2 ) ).to.be.false;
+		} );
+	} );
+
+	// More tests are available in Writer tests.
+	describe( 'getElementsWithSameId', () => {
+		it( 'should return a copy of _clonesGroup set', () => {
+			const attributeA = new AttributeElement( 'b' );
+			const attributeB = new AttributeElement( 'b' );
+
+			attributeA._id = 'foo';
+			attributeB._id = 'foo';
+
+			attributeA._clonesGroup = attributeB._clonesGroup = new Set( [ attributeA, attributeB ] );
+
+			expect( attributeA.getElementsWithSameId() ).to.deep.equal( attributeA._clonesGroup );
+			expect( attributeA.getElementsWithSameId() ).not.to.equal( attributeA._clonesGroup );
+			expect( attributeA.getElementsWithSameId() ).to.deep.equal( attributeB.getElementsWithSameId() );
+		} );
+
+		it( 'should throw if attribute element has no id', () => {
+			const attribute = new AttributeElement( 'b' );
+
+			expect( () => {
+				attribute.getElementsWithSameId();
+			} ).to.throw( CKEditorError, /attribute-element-get-elements-with-same-id-no-id/ );
+		} );
 	} );
 	} );
 
 
 	describe( 'getFillerOffset', () => {
 	describe( 'getFillerOffset', () => {

+ 12 - 0
packages/ckeditor5-engine/tests/view/range.js

@@ -8,6 +8,7 @@ import Position from '../../src/view/position';
 import Element from '../../src/view/element';
 import Element from '../../src/view/element';
 import DocumentFragment from '../../src/view/documentfragment';
 import DocumentFragment from '../../src/view/documentfragment';
 import Text from '../../src/view/text';
 import Text from '../../src/view/text';
+import TextProxy from '../../src/view/textproxy';
 import TreeWalker from '../../src/view/treewalker';
 import TreeWalker from '../../src/view/treewalker';
 import { parse, stringify } from '../../src/dev-utils/view';
 import { parse, stringify } from '../../src/dev-utils/view';
 
 
@@ -673,6 +674,17 @@ describe( 'Range', () => {
 				expect( range.end.parent ).to.equal( div );
 				expect( range.end.parent ).to.equal( div );
 				expect( range.end.offset ).to.equal( 1 );
 				expect( range.end.offset ).to.equal( 1 );
 			} );
 			} );
+
+			it( 'should create a proper range on a text proxy', () => {
+				const text = new Text( 'foobar' );
+				const textProxy = new TextProxy( text, 2, 3 );
+				const range = Range.createOn( textProxy );
+
+				expect( range.start.parent ).to.equal( text );
+				expect( range.start.offset ).to.equal( 2 );
+				expect( range.end.parent ).to.equal( text );
+				expect( range.end.offset ).to.equal( 5 );
+			} );
 		} );
 		} );
 
 
 		describe( 'createCollapsedAt()', () => {
 		describe( 'createCollapsedAt()', () => {

+ 61 - 0
packages/ckeditor5-engine/tests/view/writer/unwrap.js

@@ -372,5 +372,66 @@ describe( 'Writer', () => {
 				writer.unwrap( range, attribute );
 				writer.unwrap( range, attribute );
 			} ).to.throw( CKEditorError, 'view-writer-cannot-break-ui-element' );
 			} ).to.throw( CKEditorError, 'view-writer-cannot-break-ui-element' );
 		} );
 		} );
+
+		it( 'should unwrap if both elements have same id', () => {
+			const unwrapper = writer.createAttributeElement( 'span', null, { id: 'foo' } );
+			const attribute = writer.createAttributeElement( 'span', null, { id: 'foo' } );
+			const container = writer.createContainerElement( 'div' );
+
+			writer.insert( Position.createAt( container, 0 ), attribute );
+			writer.unwrap( Range.createOn( attribute ), unwrapper );
+
+			expect( stringify( container, null, { showType: false, showPriority: false } ) ).to.equal( '<div></div>' );
+		} );
+
+		it( 'should always unwrap whole element if both elements have same id', () => {
+			const unwrapper = writer.createAttributeElement( 'b', null, { id: 'foo' } );
+			const attribute = writer.createAttributeElement( 'span', { foo: 'foo' }, { id: 'foo' } );
+			const container = writer.createContainerElement( 'div' );
+
+			writer.insert( Position.createAt( container, 0 ), attribute );
+			writer.unwrap( Range.createOn( attribute ), unwrapper );
+
+			expect( stringify( container, null, { showType: false, showPriority: false } ) ).to.equal( '<div></div>' );
+		} );
+
+		// Below are tests for elements with different ids.
+		// Only partial unwrapping is tested because elements with different ids are never similar.
+		// This means that unwrapping of whole element is not possible in this case.
+		it( 'should not unwrap matching attributes if the element to unwrap has id', () => {
+			const unwrapper = writer.createAttributeElement( 'span', { foo: 'foo' } );
+			const attribute = writer.createAttributeElement( 'span', { foo: 'foo', bar: 'bar' }, { id: 'id' } );
+			const container = writer.createContainerElement( 'div' );
+
+			writer.insert( Position.createAt( container, 0 ), attribute );
+			writer.unwrap( Range.createOn( attribute ), unwrapper );
+
+			const view = stringify( container, null, { showType: false, showPriority: false } );
+			expect( view ).to.equal( '<div><span bar="bar" foo="foo"></span></div>' );
+		} );
+
+		it( 'should not unwrap matching attributes if the unwrapping element has id', () => {
+			const unwrapper = writer.createAttributeElement( 'span', { foo: 'foo' }, { id: 'id' } );
+			const attribute = writer.createAttributeElement( 'span', { foo: 'foo', bar: 'bar' } );
+			const container = writer.createContainerElement( 'div' );
+
+			writer.insert( Position.createAt( container, 0 ), attribute );
+			writer.unwrap( Range.createOn( attribute ), unwrapper );
+
+			const view = stringify( container, null, { showType: false, showPriority: false } );
+			expect( view ).to.equal( '<div><span bar="bar" foo="foo"></span></div>' );
+		} );
+
+		it( 'should not unwrap matching attributes if the elements have different id', () => {
+			const unwrapper = writer.createAttributeElement( 'span', { foo: 'foo' }, { id: 'a' } );
+			const attribute = writer.createAttributeElement( 'span', { foo: 'foo', bar: 'bar' }, { id: 'b' } );
+			const container = writer.createContainerElement( 'div' );
+
+			writer.insert( Position.createAt( container, 0 ), attribute );
+			writer.unwrap( Range.createOn( attribute ), unwrapper );
+
+			const view = stringify( container, null, { showType: false, showPriority: false } );
+			expect( view ).to.equal( '<div><span bar="bar" foo="foo"></span></div>' );
+		} );
 	} );
 	} );
 } );
 } );

+ 44 - 1
packages/ckeditor5-engine/tests/view/writer/wrap.js

@@ -39,7 +39,8 @@ describe( 'Writer', () => {
 				const { view, selection } = parse( input );
 				const { view, selection } = parse( input );
 				const newRange = writer.wrap( selection.getFirstRange(), parse( wrapAttribute ) );
 				const newRange = writer.wrap( selection.getFirstRange(), parse( wrapAttribute ) );
 
 
-				expect( stringify( view.root, newRange, { showType: true, showPriority: true } ) ).to.equal( expected );
+				expect( stringify( view.root, newRange, { showType: true, showPriority: true, showAttributeElementId: true } ) )
+					.to.equal( expected );
 			}
 			}
 
 
 			it( 'wraps single text node', () => {
 			it( 'wraps single text node', () => {
@@ -431,6 +432,48 @@ describe( 'Writer', () => {
 					'</container:p>'
 					'</container:p>'
 				);
 				);
 			} );
 			} );
+
+			it( 'should not join elements if element to wrap has id', () => {
+				test(
+					'<container:p>[<attribute:span foo="foo" view-id="foo">xyz</attribute:span>]</container:p>',
+
+					'<attribute:span bar="bar"></attribute:span>',
+
+					'<container:p>' +
+						'[<attribute:span view-priority="10" bar="bar">' +
+							'<attribute:span view-priority="10" view-id="foo" foo="foo">xyz</attribute:span>' +
+						'</attribute:span>]' +
+					'</container:p>'
+				);
+			} );
+
+			it( 'should not join elements if wrapper element has id', () => {
+				test(
+					'<container:p>[<attribute:span foo="foo">xyz</attribute:span>]</container:p>',
+
+					'<attribute:span bar="bar" view-id="foo"></attribute:span>',
+
+					'<container:p>' +
+						'[<attribute:span view-priority="10" view-id="foo" bar="bar">' +
+							'<attribute:span view-priority="10" foo="foo">xyz</attribute:span>' +
+						'</attribute:span>]' +
+					'</container:p>'
+				);
+			} );
+
+			it( 'should not join elements if they have different ids', () => {
+				test(
+					'<container:p>[<attribute:span foo="foo" view-id="foo">xyz</attribute:span>]</container:p>',
+
+					'<attribute:span bar="bar" view-id="bar"></attribute:span>',
+
+					'<container:p>' +
+						'[<attribute:span view-priority="10" view-id="bar" bar="bar">' +
+							'<attribute:span view-priority="10" view-id="foo" foo="foo">xyz</attribute:span>' +
+						'</attribute:span>]' +
+					'</container:p>'
+				);
+			} );
 		} );
 		} );
 
 
 		describe( 'collapsed range', () => {
 		describe( 'collapsed range', () => {

+ 55 - 2
packages/ckeditor5-engine/tests/view/writer/writer.js

@@ -7,6 +7,7 @@ import Writer from '../../../src/view/writer';
 import Document from '../../../src/view/document';
 import Document from '../../../src/view/document';
 import EditableElement from '../../../src/view/editableelement';
 import EditableElement from '../../../src/view/editableelement';
 import ViewPosition from '../../../src/view/position';
 import ViewPosition from '../../../src/view/position';
+import ViewRange from '../../../src/view/range';
 import createViewRoot from '../_utils/createroot';
 import createViewRoot from '../_utils/createroot';
 
 
 describe( 'Writer', () => {
 describe( 'Writer', () => {
@@ -68,12 +69,13 @@ describe( 'Writer', () => {
 			assertElementAttributes( element, attributes );
 			assertElementAttributes( element, attributes );
 		} );
 		} );
 
 
-		it( 'should allow to pass priority', () => {
-			const element = writer.createAttributeElement( 'foo', attributes, 99 );
+		it( 'should allow to pass additional options', () => {
+			const element = writer.createAttributeElement( 'foo', attributes, { priority: 99, id: 'bar' } );
 
 
 			expect( element.is( 'attributeElement' ) ).to.be.true;
 			expect( element.is( 'attributeElement' ) ).to.be.true;
 			expect( element.name ).to.equal( 'foo' );
 			expect( element.name ).to.equal( 'foo' );
 			expect( element.priority ).to.equal( 99 );
 			expect( element.priority ).to.equal( 99 );
+			expect( element.id ).to.equal( 'bar' );
 			assertElementAttributes( element, attributes );
 			assertElementAttributes( element, attributes );
 		} );
 		} );
 	} );
 	} );
@@ -261,6 +263,57 @@ describe( 'Writer', () => {
 		} );
 		} );
 	} );
 	} );
 
 
+	describe( 'manages AttributeElement#_clonesGroup', () => {
+		it( 'should return all clones of a broken attribute element with id', () => {
+			const container = writer.createContainerElement( 'div' );
+			const text = writer.createText( 'abccccde' );
+
+			writer.insert( ViewPosition.createAt( container, 0 ), text );
+
+			const span = writer.createAttributeElement( 'span', null, { id: 'foo' } );
+			span._priority = 20;
+
+			// <div>ab<span>cccc</span>de</div>
+			writer.wrap( ViewRange.createFromParentsAndOffsets( text, 2, text, 6 ), span );
+
+			const i = writer.createAttributeElement( 'i' );
+
+			// <div>a<i>b<span>c</span></i><span>cc</span>de</div>
+			writer.wrap(
+				ViewRange.createFromParentsAndOffsets(
+					container.getChild( 0 ), 1,
+					container.getChild( 1 ).getChild( 0 ), 1
+				),
+				i
+			);
+
+			// <div>a<i>b<span>c</span></i><span>c</span><i><span>cc</span>d</i>e</div>
+			writer.wrap(
+				ViewRange.createFromParentsAndOffsets(
+					container.getChild( 2 ).getChild( 0 ), 1,
+					container.getChild( 3 ), 1
+				),
+				i
+			);
+
+			// Find all spans.
+			const allSpans = Array.from( ViewRange.createIn( container ).getItems() ).filter( element => element.is( 'span' ) );
+
+			// For each of the spans created above...
+			for ( const oneOfAllSpans of allSpans ) {
+				const brokenSet = oneOfAllSpans.getElementsWithSameId();
+				const brokenArray = Array.from( brokenSet );
+
+				// Check if all spans are included.
+				for ( const s of allSpans ) {
+					expect( brokenSet.has( s ) ).to.be.true;
+				}
+
+				expect( brokenArray.length ).to.equal( allSpans.length );
+			}
+		} );
+	} );
+
 	function assertElementAttributes( element, attributes ) {
 	function assertElementAttributes( element, attributes ) {
 		for ( const key of Object.keys( attributes ) ) {
 		for ( const key of Object.keys( attributes ) ) {
 			if ( element.getAttribute( key ) !== attributes[ key ] ) {
 			if ( element.getAttribute( key ) !== attributes[ key ] ) {