Răsfoiți Sursa

Merge branch 'master' into t/781

Piotrek Koszuliński 9 ani în urmă
părinte
comite
f2d0b7daaf
33 a modificat fișierele cu 2108 adăugiri și 421 ștergeri
  1. 12 7
      packages/ckeditor5-engine/src/controller/editingcontroller.js
  2. 4 2
      packages/ckeditor5-engine/src/conversion/buildmodelconverter.js
  3. 44 14
      packages/ckeditor5-engine/src/conversion/model-selection-to-view-converters.js
  4. 39 11
      packages/ckeditor5-engine/src/conversion/model-to-view-converters.js
  5. 31 9
      packages/ckeditor5-engine/src/conversion/modelconversiondispatcher.js
  6. 1 1
      packages/ckeditor5-engine/src/dev-utils/model.js
  7. 39 0
      packages/ckeditor5-engine/src/model/delta/basic-transformations.js
  8. 118 0
      packages/ckeditor5-engine/src/model/delta/markerdelta.js
  9. 11 11
      packages/ckeditor5-engine/src/model/document.js
  10. 8 8
      packages/ckeditor5-engine/src/model/liveselection.js
  11. 325 0
      packages/ckeditor5-engine/src/model/markercollection.js
  12. 0 154
      packages/ckeditor5-engine/src/model/markerscollection.js
  13. 146 0
      packages/ckeditor5-engine/src/model/operation/markeroperation.js
  14. 2 0
      packages/ckeditor5-engine/src/model/operation/operationfactory.js
  15. 74 0
      packages/ckeditor5-engine/src/model/operation/transform.js
  16. 1 1
      packages/ckeditor5-engine/src/model/selection.js
  17. 2 0
      packages/ckeditor5-engine/src/view/writer.js
  18. 70 17
      packages/ckeditor5-engine/tests/controller/editingcontroller.js
  19. 4 4
      packages/ckeditor5-engine/tests/conversion/buildmodelconverter.js
  20. 95 9
      packages/ckeditor5-engine/tests/conversion/model-selection-to-view-converters.js
  21. 66 21
      packages/ckeditor5-engine/tests/conversion/modelconversiondispatcher.js
  22. 0 0
      packages/ckeditor5-engine/tests/manual/markers.html
  23. 6 6
      packages/ckeditor5-engine/tests/manual/markers.js
  24. 0 0
      packages/ckeditor5-engine/tests/manual/markers.md
  25. 153 0
      packages/ckeditor5-engine/tests/model/delta/markerdelta.js
  26. 9 0
      packages/ckeditor5-engine/tests/model/delta/transform/_utils/utils.js
  27. 97 0
      packages/ckeditor5-engine/tests/model/delta/transform/markerdelta.js
  28. 0 1
      packages/ckeditor5-engine/tests/model/delta/transform/transform.js
  29. 248 0
      packages/ckeditor5-engine/tests/model/markercollection.js
  30. 0 145
      packages/ckeditor5-engine/tests/model/markerscollection.js
  31. 227 0
      packages/ckeditor5-engine/tests/model/operation/markeroperation.js
  32. 264 0
      packages/ckeditor5-engine/tests/model/operation/transform.js
  33. 12 0
      packages/ckeditor5-engine/tests/view/writer/wrap.js

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

@@ -15,7 +15,8 @@ import {
 	remove,
 	remove,
 	move,
 	move,
 	rename,
 	rename,
-	insertIntoMarker,
+	insertRangeIntoMarker,
+	insertRangeWithMarker,
 	moveInOutOfMarker
 	moveInOutOfMarker
 } from '../conversion/model-to-view-converters';
 } from '../conversion/model-to-view-converters';
 import { convertSelectionChange } from '../conversion/view-selection-to-model-converters';
 import { convertSelectionChange } from '../conversion/view-selection-to-model-converters';
@@ -100,17 +101,20 @@ export default class EditingController {
 
 
 		// Convert model selection to view.
 		// Convert model selection to view.
 		this._listener.listenTo( this.model, 'changesDone', () => {
 		this._listener.listenTo( this.model, 'changesDone', () => {
-			this.modelToView.convertSelection( model.selection );
+			const selection = model.selection;
+			const markers = Array.from( model.markers.getMarkersAtPosition( selection.getFirstPosition() ) );
+
+			this.modelToView.convertSelection( selection, markers );
 			this.view.render();
 			this.view.render();
 		}, { priority: 'low' } );
 		}, { priority: 'low' } );
 
 
 		// Convert model markers changes.
 		// Convert model markers changes.
-		this._listener.listenTo( this.model.markers, 'add', ( evt, name, range ) => {
-			this.modelToView.convertMarker( 'addMarker', name, range );
+		this._listener.listenTo( this.model.markers, 'add', ( evt, marker ) => {
+			this.modelToView.convertMarker( 'addMarker', marker.name, marker.getRange() );
 		} );
 		} );
 
 
-		this._listener.listenTo( this.model.markers, 'remove', ( evt, name, range ) => {
-			this.modelToView.convertMarker( 'removeMarker', name, range );
+		this._listener.listenTo( this.model.markers, 'remove', ( evt, marker ) => {
+			this.modelToView.convertMarker( 'removeMarker', marker.name, marker.getRange() );
 		} );
 		} );
 
 
 		// Convert view selection to model.
 		// Convert view selection to model.
@@ -123,7 +127,8 @@ export default class EditingController {
 		this.modelToView.on( 'rename', rename(), { priority: 'low' } );
 		this.modelToView.on( 'rename', rename(), { priority: 'low' } );
 
 
 		// Attach default markers converters.
 		// Attach default markers converters.
-		this.modelToView.on( 'insert', insertIntoMarker( this.model.markers ), { priority: 'lowest' } );
+		this.modelToView.on( 'insert', insertRangeIntoMarker( this.model.markers ), { priority: 'lowest' } );
+		this.modelToView.on( 'insert', insertRangeWithMarker( this.model.markers ), { priority: 'lowest' } );
 		this.modelToView.on( 'move', moveInOutOfMarker( this.model.markers ), { priority: 'lowest' } );
 		this.modelToView.on( 'move', moveInOutOfMarker( this.model.markers ), { priority: 'lowest' } );
 
 
 		// Attach default selection converters.
 		// Attach default selection converters.

+ 4 - 2
packages/ckeditor5-engine/src/conversion/buildmodelconverter.js

@@ -17,7 +17,7 @@ import {
 	unwrapRange
 	unwrapRange
 } from './model-to-view-converters';
 } from './model-to-view-converters';
 
 
-import { convertSelectionAttribute } from './model-selection-to-view-converters';
+import { convertSelectionAttribute, convertSelectionMarker } from './model-selection-to-view-converters';
 
 
 import ViewAttributeElement from '../view/attributeelement';
 import ViewAttributeElement from '../view/attributeelement';
 import ViewContainerElement from '../view/containerelement';
 import ViewContainerElement from '../view/containerelement';
@@ -58,7 +58,7 @@ import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
  * 4. Model marker to view element converter. This is a converter that converts markers from given group to view attribute element.
  * 4. Model marker to view element converter. This is a converter that converts markers from given group to view attribute element.
  * Markers, basically, are {@link module:engine/model/liverange~LiveRange} instances, that are named. In this conversion, model range is
  * Markers, basically, are {@link module:engine/model/liverange~LiveRange} instances, that are named. In this conversion, model range is
  * converted to view range, then that view range is wrapped (or unwrapped, if range is removed) in a view attribute element.
  * converted to view range, then that view range is wrapped (or unwrapped, if range is removed) in a view attribute element.
- * To learn more about markers, see {@link module:engine/model/markerscollection~MarkersCollection}.
+ * To learn more about markers, see {@link module:engine/model/markercollection~MarkerCollection}.
  *
  *
  *		const viewSpanSearchResult = new ViewAttributeElement( 'span', { class: 'search-result' } );
  *		const viewSpanSearchResult = new ViewAttributeElement( 'span', { class: 'search-result' } );
  *		buildModelConverter().for( dispatcher ).fromMarker( 'searchResult' ).toElement( viewSpanSearchResult );
  *		buildModelConverter().for( dispatcher ).fromMarker( 'searchResult' ).toElement( viewSpanSearchResult );
@@ -246,6 +246,8 @@ class ModelConverterBuilder {
 
 
 				dispatcher.on( 'addMarker:' + this._from.name, wrapRange( element ), { priority } );
 				dispatcher.on( 'addMarker:' + this._from.name, wrapRange( element ), { priority } );
 				dispatcher.on( 'removeMarker:' + this._from.name, unwrapRange( element ), { priority } );
 				dispatcher.on( 'removeMarker:' + this._from.name, unwrapRange( element ), { priority } );
+
+				dispatcher.on( 'selectionMarker:' + this._from.name, convertSelectionMarker( element ), { priority } );
 			}
 			}
 		}
 		}
 	}
 	}

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

@@ -145,29 +145,59 @@ export function convertCollapsedSelection() {
  */
  */
 export function convertSelectionAttribute( elementCreator ) {
 export function convertSelectionAttribute( elementCreator ) {
 	return ( evt, data, consumable, conversionApi ) => {
 	return ( evt, data, consumable, conversionApi ) => {
-		const selection = data.selection;
+		const viewElement = elementCreator instanceof ViewElement ?
+			elementCreator.clone( true ) :
+			elementCreator( data.value, data, data.selection, consumable, conversionApi );
 
 
-		if ( !selection.isCollapsed ) {
-			return;
-		}
+		const consumableName = 'selectionAttribute:' + data.key;
 
 
-		if ( !consumable.consume( selection, 'selectionAttribute:' + data.key ) ) {
-			return;
-		}
-
-		let viewPosition = conversionApi.viewSelection.getFirstPosition();
-		conversionApi.viewSelection.removeAllRanges();
+		wrapCollapsedSelectionPosition( data.selection, conversionApi.viewSelection, viewElement, consumable, consumableName );
+	};
+}
 
 
+/**
+ * Performs similar conversion as {@link ~convertSelectionAttribute}, but depends on a marker name of a marker in which
+ * collapsed selection is placed.
+ *
+ *		modelDispatcher.on( 'selectionMarker:searchResult', wrapRange( new ViewAttributeElement( 'span', { class: 'searchResult' } ) ) );
+ *
+ * **Note:** You can use the same `elementCreator` function for this converter factory
+ * and {@link module:engine/conversion/model-to-view-converters~wrapRange}.
+ *
+ * @see {~convertSelectionAttribute}
+ * @param {module:engine/view/attributeelement~AttributeElement|Function} elementCreator View element,
+ * or function returning a view element, which will be used for wrapping.
+ * @returns {Function} Selection converter.
+ */
+export function convertSelectionMarker( elementCreator ) {
+	return ( evt, data, consumable, conversionApi ) => {
 		const viewElement = elementCreator instanceof ViewElement ?
 		const viewElement = elementCreator instanceof ViewElement ?
-				elementCreator.clone( true ) :
-				elementCreator( data.value, data, selection, consumable, conversionApi );
+			elementCreator.clone( true ) :
+			elementCreator( data, consumable, conversionApi );
 
 
-		viewPosition = viewWriter.wrapPosition( viewPosition, viewElement );
+		const consumableName = 'selectionMarker:' + data.name;
 
 
-		conversionApi.viewSelection.addRange( new ViewRange( viewPosition, viewPosition ) );
+		wrapCollapsedSelectionPosition( data.selection, conversionApi.viewSelection, viewElement, consumable, consumableName );
 	};
 	};
 }
 }
 
 
+// Helper function for `convertSelectionAttribute` and `convertSelectionMarker`, which perform similar task.
+function wrapCollapsedSelectionPosition( modelSelection, viewSelection, viewElement, consumable, consumableName ) {
+	if ( !modelSelection.isCollapsed ) {
+		return;
+	}
+
+	if ( !consumable.consume( modelSelection, consumableName ) ) {
+		return;
+	}
+
+	let viewPosition = viewSelection.getFirstPosition();
+	viewPosition = viewWriter.wrapPosition( viewPosition, viewElement );
+
+	viewSelection.removeAllRanges();
+	viewSelection.addRange( new ViewRange( viewPosition, viewPosition ) );
+}
+
 /**
 /**
  * Function factory, creates a converter that clears artifacts after the previous
  * Function factory, creates a converter that clears artifacts after the previous
  * {@link module:engine/model/selection~Selection model selection} conversion. It removes all empty
  * {@link module:engine/model/selection~Selection model selection} conversion. It removes all empty

+ 39 - 11
packages/ckeditor5-engine/src/conversion/model-to-view-converters.js

@@ -492,19 +492,45 @@ export function rename() {
 }
 }
 
 
 /**
 /**
- * Function factory, creates a default converter for inserting {@link module:engine/model/item~Item model item} into a marker range.
+ * Function factory, creates a default converter for inserting {@link module:engine/model/item~Item model item}
+ * into a marker range.
  *
  *
- *		modelDispatcher.on( 'insert', insertIntoRange( modelDocument.markers ) );
+ *		modelDispatcher.on( 'insert', insertRangeIntoMarker( modelDocument.markers ) );
  *
  *
- * @param {module:engine/model/markerscollection~MarkersCollection} markersCollection Markers collection to check when
+ * @param {module:engine/model/markercollection~MarkerCollection} markerCollection Markers collection to check when
  * inserting.
  * inserting.
  * @returns {Function}
  * @returns {Function}
  */
  */
-export function insertIntoMarker( markersCollection ) {
+export function insertRangeIntoMarker( markerCollection ) {
 	return ( evt, data, consumable, conversionApi ) => {
 	return ( evt, data, consumable, conversionApi ) => {
-		for ( let [ name, range ] of markersCollection ) {
+		for ( let marker of markerCollection ) {
+			const range = marker.getRange();
+
 			if ( range.containsPosition( data.range.start ) ) {
 			if ( range.containsPosition( data.range.start ) ) {
-				conversionApi.dispatcher.convertMarker( 'addMarker', name, data.range );
+				conversionApi.dispatcher.convertMarker( 'addMarker', marker.name, data.range );
+			}
+		}
+	};
+}
+
+/**
+ * Function factory, creates a default converter for inserting a {@link module:engine/model/range~Range model range}
+ * that contains a marker. This happens when marker was in a graveyard (so it was removed from the view) or was
+ * created in a {@link module:engine/model/documentfragment~DocumentFragment DocumentFragment} (so it was not in the view before).
+ *
+ *		modelDispatcher.on( 'insert', insertRangeWithMarker( modelDocument.markers ) );
+ *
+ * @param {module:engine/model/markercollection~MarkerCollection} markerCollection Markers collection to check when
+ * inserting.
+ * @returns {Function}
+ */
+export function insertRangeWithMarker( markerCollection ) {
+	return ( evt, data, consumable, conversionApi ) => {
+		for ( let marker of markerCollection ) {
+			const range = marker.getRange();
+
+			if ( data.range.containsRange( range ) || data.range.isEqual( range ) ) {
+				conversionApi.dispatcher.convertMarker( 'addMarker', marker.name, range );
 			}
 			}
 		}
 		}
 	};
 	};
@@ -516,23 +542,25 @@ export function insertIntoMarker( markersCollection ) {
  *
  *
  *		modelDispatcher.on( 'move', moveInOutOfMarker( modelDocument.markers ) );
  *		modelDispatcher.on( 'move', moveInOutOfMarker( modelDocument.markers ) );
  *
  *
- * @param {module:engine/model/markerscollection~MarkersCollection} markersCollection Markers collection to check when
+ * @param {module:engine/model/markercollection~MarkerCollection} markerCollection Markers collection to check when
  * moving.
  * moving.
  * @returns {Function}
  * @returns {Function}
  */
  */
-export function moveInOutOfMarker( markersCollection ) {
+export function moveInOutOfMarker( markerCollection ) {
 	return ( evt, data, consumable, conversionApi ) => {
 	return ( evt, data, consumable, conversionApi ) => {
 		const sourcePos = data.sourcePosition._getTransformedByInsertion( data.targetPosition, data.item.offsetSize );
 		const sourcePos = data.sourcePosition._getTransformedByInsertion( data.targetPosition, data.item.offsetSize );
 		const movedRange = ModelRange.createOn( data.item );
 		const movedRange = ModelRange.createOn( data.item );
 
 
-		for ( let [ name, range ] of markersCollection ) {
+		for ( let marker of markerCollection ) {
+			const range = marker.getRange();
+
 			const wasInMarker = range.containsPosition( sourcePos ) || range.start.isEqual( sourcePos ) || range.end.isEqual( sourcePos );
 			const wasInMarker = range.containsPosition( sourcePos ) || range.start.isEqual( sourcePos ) || range.end.isEqual( sourcePos );
 			const common = movedRange.getIntersection( range );
 			const common = movedRange.getIntersection( range );
 
 
 			if ( wasInMarker && common === null ) {
 			if ( wasInMarker && common === null ) {
-				conversionApi.dispatcher.convertMarker( 'removeMarker', name, movedRange );
+				conversionApi.dispatcher.convertMarker( 'removeMarker', marker.name, movedRange );
 			} else if ( common !== null ) {
 			} else if ( common !== null ) {
-				conversionApi.dispatcher.convertMarker( 'addMarker', name, common );
+				conversionApi.dispatcher.convertMarker( 'addMarker', marker.name, common );
 			}
 			}
 		}
 		}
 	};
 	};

+ 31 - 9
packages/ckeditor5-engine/src/conversion/modelconversiondispatcher.js

@@ -308,18 +308,30 @@ export default class ModelConversionDispatcher {
 	 * @fires selection
 	 * @fires selection
 	 * @fires selectionAttribute
 	 * @fires selectionAttribute
 	 * @param {module:engine/model/selection~Selection} selection Selection to convert.
 	 * @param {module:engine/model/selection~Selection} selection Selection to convert.
+	 * @param {Array.<module:engine/model/markercollection~Marker>} markers Markers which contains selection.
 	 */
 	 */
-	convertSelection( selection ) {
-		const consumable = this._createSelectionConsumable( selection );
-		const data = {
-			selection: selection
-		};
+	convertSelection( selection, markers ) {
+		const consumable = this._createSelectionConsumable( selection, markers );
 
 
-		this.fire( 'selection', data, consumable, this.conversionApi );
+		this.fire( 'selection', { selection }, consumable, this.conversionApi );
+
+		for ( let marker of markers ) {
+			const data = {
+				selection: selection,
+				name: marker.name
+			};
+
+			if ( consumable.test( selection, 'selectionMarker:' + marker.name ) ) {
+				this.fire( 'selectionMarker:' + marker.name, data, consumable, this.conversionApi );
+			}
+		}
 
 
 		for ( let key of selection.getAttributeKeys() ) {
 		for ( let key of selection.getAttributeKeys() ) {
-			data.key = key;
-			data.value = selection.getAttribute( key );
+			const data = {
+				selection: selection,
+				key: key,
+				value: selection.getAttribute( key )
+			};
 
 
 			// Do not fire event if the attribute has been consumed.
 			// Do not fire event if the attribute has been consumed.
 			if ( consumable.test( selection, 'selectionAttribute:' + data.key ) ) {
 			if ( consumable.test( selection, 'selectionAttribute:' + data.key ) ) {
@@ -338,6 +350,11 @@ export default class ModelConversionDispatcher {
 	 * @param {module:engine/model/range~Range} range Marker range.
 	 * @param {module:engine/model/range~Range} range Marker range.
 	 */
 	 */
 	convertMarker( type, name, range ) {
 	convertMarker( type, name, range ) {
+		// Do not convert if range is in graveyard or not in the document (e.g. in DocumentFragment).
+		if ( !range.root.document || range.root.rootName == '$graveyard' ) {
+			return;
+		}
+
 		const consumable = this._createMarkerConsumable( type, range );
 		const consumable = this._createMarkerConsumable( type, range );
 		const data = { name, range };
 		const data = { name, range };
 
 
@@ -392,13 +409,18 @@ export default class ModelConversionDispatcher {
 	 *
 	 *
 	 * @private
 	 * @private
 	 * @param {module:engine/model/selection~Selection} selection Selection to create consumable from.
 	 * @param {module:engine/model/selection~Selection} selection Selection to create consumable from.
+	 * @param {Iterable.<module:engine/model/markercollection~Marker>} markers Markers which contains selection.
 	 * @returns {module:engine/conversion/modelconsumable~ModelConsumable} Values to consume.
 	 * @returns {module:engine/conversion/modelconsumable~ModelConsumable} Values to consume.
 	 */
 	 */
-	_createSelectionConsumable( selection ) {
+	_createSelectionConsumable( selection, markers ) {
 		const consumable = new Consumable();
 		const consumable = new Consumable();
 
 
 		consumable.add( selection, 'selection' );
 		consumable.add( selection, 'selection' );
 
 
+		for ( let marker of markers ) {
+			consumable.add( selection, 'selectionMarker:' + marker.name );
+		}
+
 		for ( let key of selection.getAttributeKeys() ) {
 		for ( let key of selection.getAttributeKeys() ) {
 			consumable.add( selection, 'selectionAttribute:' + key );
 			consumable.add( selection, 'selectionAttribute:' + key );
 		}
 		}

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

@@ -236,7 +236,7 @@ export function stringify( node, selectionOrPositionOrRange = null ) {
 
 
 	// Convert model selection to view selection.
 	// Convert model selection to view selection.
 	if ( selection ) {
 	if ( selection ) {
-		modelToView.convertSelection( selection );
+		modelToView.convertSelection( selection, [] );
 	}
 	}
 
 
 	// Parse view to data string.
 	// Parse view to data string.

+ 39 - 0
packages/ckeditor5-engine/src/model/delta/basic-transformations.js

@@ -20,6 +20,7 @@ import ReinsertOperation from '../operation/reinsertoperation';
 import Delta from './delta';
 import Delta from './delta';
 import AttributeDelta from './attributedelta';
 import AttributeDelta from './attributedelta';
 import InsertDelta from './insertdelta';
 import InsertDelta from './insertdelta';
+import MarkerDelta from './markerdelta';
 import MergeDelta from './mergedelta';
 import MergeDelta from './mergedelta';
 import MoveDelta from './movedelta';
 import MoveDelta from './movedelta';
 import SplitDelta from './splitdelta';
 import SplitDelta from './splitdelta';
@@ -96,6 +97,44 @@ addTransformationCase( InsertDelta, MergeDelta, ( a, b, isStrong ) => {
 	return defaultTransform( a, b, isStrong );
 	return defaultTransform( a, b, isStrong );
 } );
 } );
 
 
+// Add special case for MarkerDelta x SplitDelta
+addTransformationCase( MarkerDelta, SplitDelta, ( a, b, isStrong ) => {
+	// If marked range is split, we need to fix it:
+	// ab[cdef]gh   ==>  ab[cd
+	//                   ef]gh
+	// To mimic what normally happens with LiveRange if you split it.
+	const markerOp = a.operations[ 0 ];
+
+	let oldRangeEndPosition = null;
+	let newRangeEndPosition = null;
+
+	const source = b.position;
+	const target = b._moveOperation.targetPosition;
+
+	if ( markerOp.oldRange.containsPosition( b.position ) ) {
+		oldRangeEndPosition = markerOp.oldRange.end._getCombined( source, target );
+	}
+
+	if ( markerOp.newRange.containsPosition( b.position ) ) {
+		newRangeEndPosition = markerOp.newRange.end._getCombined( source, target );
+	}
+
+	// MarkerDelta can't get split to two deltas, neither can MarkerOperation.
+	const transformedDelta = defaultTransform( a, b, isStrong )[ 0 ];
+	const transformedOp = transformedDelta.operations[ 0 ];
+
+	// Fix positions.
+	if ( oldRangeEndPosition ) {
+		transformedOp.oldRange.end = oldRangeEndPosition;
+	}
+
+	if ( newRangeEndPosition ) {
+		transformedOp.newRange.end = newRangeEndPosition;
+	}
+
+	return [ transformedDelta ];
+} );
+
 // Add special case for MoveDelta x MergeDelta transformation.
 // Add special case for MoveDelta x MergeDelta transformation.
 addTransformationCase( MoveDelta, MergeDelta, ( a, b, isStrong ) => {
 addTransformationCase( MoveDelta, MergeDelta, ( a, b, isStrong ) => {
 	// If move delta is supposed to move a node that has been merged, we reverse the merge (we treat it like it
 	// If move delta is supposed to move a node that has been merged, we reverse the merge (we treat it like it

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

@@ -0,0 +1,118 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module engine/model/delta/markerdelta
+ */
+
+import Delta from './delta';
+import DeltaFactory from './deltafactory';
+import { register } from '../batch';
+import MarkerOperation from '../operation/markeroperation';
+import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
+
+/**
+ * @classdesc
+ * To provide specific OT behavior and better collisions solving, the {@link module:engine/model/batch~Batch#setMarker Batch#setMarker}
+ * and {@link module:engine/model/batch~Batch#removeMarker Batch#removeMarker} methods use the `MarkerDelta` class which inherits
+ * from the `Delta` class and may overwrite some methods.
+ */
+export default class MarkerDelta extends Delta {
+	/**
+	 * A class that will be used when creating reversed delta.
+	 *
+	 * @private
+	 * @type {Function}
+	 */
+	get _reverseDeltaClass() {
+		return MarkerDelta;
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	static get className() {
+		return 'engine.model.delta.MarkerDelta';
+	}
+}
+
+/**
+ * Adds or updates {@link module:engine/model/markercollection~Marker marker} with given name to given `range`.
+ *
+ * If passed name is a name of already existing marker (or {@link module:engine/model/markercollection~Marker Marker} instance
+ * is passed), `range` parameter may be omitted. In this case marker will not be updated in
+ * {@link module:engine/model/document~Document#markers document marker collection}. However the marker will be added to
+ * the document history. This may be important for other features, like undo. From document history point of view, it will
+ * look like the marker was created and added to the document at the moment when it is set using this method.
+ *
+ * This is useful if the marker is created before it can be added to document history (e.g. a feature creating the marker
+ * is waiting for additional data, etc.). In this case, the marker may be first created directly through
+ * {@link module:engine/model/markercollection~MarkerCollection MarkerCollection API} and only later added using `Batch` API.
+ *
+ * @chainable
+ * @method module:engine/model/batch~Batch#setMarker
+ * @param {module:engine/model/markercollection~Marker|String} markerOrName Marker to update or marker name to add or update.
+ * @param {module:engine/model/range~Range} [range] Marker range.
+ */
+register( 'setMarker', function( markerOrName, range ) {
+	const name = typeof markerOrName == 'string' ? markerOrName : markerOrName.name;
+
+	if ( !range && !this.document.markers.has( name ) ) {
+		/**
+		 * Range parameter is required when adding a new marker.
+		 *
+		 * @error batch-setMarker-no-range
+		 */
+		throw new CKEditorError( 'batch-setMarker-no-range: Range parameter is required when adding a new marker.' );
+	}
+
+	if ( !range ) {
+		range = this.document.markers.get( name ).getRange();
+	}
+
+	addOperation( this, name, range );
+
+	return this;
+} );
+
+/**
+ * Removes given {@link module:engine/model/markercollection~Marker marker} or marker with given name.
+ *
+ * @chainable
+ * @method module:engine/model/batch~Batch#removeMarker
+ * @param {module:engine/model/markerscollection~Marker|String} markerOrName
+ */
+register( 'removeMarker', function( markerOrName ) {
+	const name = typeof markerOrName == 'string' ? markerOrName : markerOrName.name;
+
+	addOperation( this, name, null );
+
+	return this;
+} );
+
+function addOperation( batch, name, newRange ) {
+	const doc = batch.document;
+
+	const delta = new MarkerDelta();
+	const marker = doc.markers.get( name );
+	const oldRange = marker ? marker.getRange() : null;
+
+	if ( !newRange && !oldRange ) {
+		/**
+		 * Trying to remove marker that does not exist.
+		 *
+		 * @error batch-removeMarker-no-marker
+		 */
+		throw new CKEditorError( 'batch-removeMarker-no-marker: Trying to remove marker that does not exist.' );
+	}
+
+	const operation = new MarkerOperation( name, oldRange, newRange, doc.version );
+
+	batch.addDelta( delta );
+	delta.addOperation( operation );
+	doc.applyOperation( operation );
+}
+
+DeltaFactory.register( MarkerDelta );

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

@@ -19,7 +19,7 @@ import History from './history';
 import LiveSelection from './liveselection';
 import LiveSelection from './liveselection';
 import Schema from './schema';
 import Schema from './schema';
 import TreeWalker from './treewalker';
 import TreeWalker from './treewalker';
-import MarkersCollection from './markerscollection';
+import MarkerCollection from './markercollection';
 import clone from '@ckeditor/ckeditor5-utils/src/lib/lodash/clone';
 import clone from '@ckeditor/ckeditor5-utils/src/lib/lodash/clone';
 import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin';
 import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
@@ -58,14 +58,6 @@ export default class Document {
 		 */
 		 */
 		this.version = 0;
 		this.version = 0;
 
 
-		/**
-		 * Selection done on this document.
-		 *
-		 * @readonly
-		 * @member {module:engine/model/liveselection~LiveSelection}
-		 */
-		this.selection = new LiveSelection( this );
-
 		/**
 		/**
 		 * Schema for this document.
 		 * Schema for this document.
 		 *
 		 *
@@ -87,9 +79,17 @@ export default class Document {
 		 * Document's markers' collection.
 		 * Document's markers' collection.
 		 *
 		 *
 		 * @readonly
 		 * @readonly
-		 * @member {module:engine/model/markerscollection~MarkersCollection}
+		 * @member {module:engine/model/markercollection~MarkerCollection}
 		 */
 		 */
-		this.markers = new MarkersCollection();
+		this.markers = new MarkerCollection();
+
+		/**
+		 * Selection done on this document.
+		 *
+		 * @readonly
+		 * @member {module:engine/model/liveselection~LiveSelection}
+		 */
+		this.selection = new LiveSelection( this );
 
 
 		/**
 		/**
 		 * Array of pending changes. See: {@link #enqueueChanges}.
 		 * Array of pending changes. See: {@link #enqueueChanges}.

+ 8 - 8
packages/ckeditor5-engine/src/model/liveselection.js

@@ -29,17 +29,18 @@ const attrOpTypes = new Set(
  * {@link module:engine/model/document~Document document} and has it ranges updated accordingly. Internal implementation of this
  * {@link module:engine/model/document~Document document} and has it ranges updated accordingly. Internal implementation of this
  * mechanism bases on {@link module:engine/model/liverange~LiveRange live ranges}.
  * mechanism bases on {@link module:engine/model/liverange~LiveRange live ranges}.
  *
  *
- * Differences between {@link module:engine/model/selection~Selection} and `LiveSelection` are two:
+ * Differences between {@link module:engine/model/selection~Selection} and `LiveSelection` are:
  * * there is always a range in `LiveSelection` - even if no ranges were added there is a
  * * there is always a range in `LiveSelection` - even if no ranges were added there is a
  * {@link module:engine/model/liveselection~LiveSelection#_getDefaultRange "default range"} present in the selection,
  * {@link module:engine/model/liveselection~LiveSelection#_getDefaultRange "default range"} present in the selection,
- * * ranges added to this selection updates automatically when the document changes.
+ * * ranges added to this selection updates automatically when the document changes,
+ * * attributes of `LiveSelection` are updated automatically according to selection ranges.
  *
  *
  * Since `LiveSelection` uses {@link module:engine/model/liverange~LiveRange live ranges}
  * Since `LiveSelection` uses {@link module:engine/model/liverange~LiveRange live ranges}
  * and is updated when {@link module:engine/model/document~Document document}
  * and is updated when {@link module:engine/model/document~Document document}
  * changes, it cannot be set on {@link module:engine/model/node~Node nodes}
  * changes, it cannot be set on {@link module:engine/model/node~Node nodes}
  * that are inside {@link module:engine/model/documentfragment~DocumentFragment document fragment}.
  * that are inside {@link module:engine/model/documentfragment~DocumentFragment document fragment}.
  * If you need to represent a selection in document fragment,
  * If you need to represent a selection in document fragment,
- * use {@link module:engine/model/selection~Selection "normal" selection} instead.
+ * use {@link module:engine/model/selection~Selection Selection class} instead.
  *
  *
  * @extends module:engine/model/selection~Selection
  * @extends module:engine/model/selection~Selection
  */
  */
@@ -72,9 +73,8 @@ export default class LiveSelection extends Selection {
 		 */
 		 */
 		this._attributePriority = new Map();
 		this._attributePriority = new Map();
 
 
-		// Whenever attribute operation is performed on document, update attributes. This is not the most efficient
-		// way to update selection attributes, but should be okay for now. `_updateAttributes` will be fired too often,
-		// but it won't change attributes or fire `change:attribute` event if not needed.
+		// Whenever attribute operation is performed on document, update selection attributes.
+		// This is not the most efficient way to update selection attributes, but should be okay for now.
 		this.listenTo( this._document, 'change', ( evt, type ) => {
 		this.listenTo( this._document, 'change', ( evt, type ) => {
 			if ( attrOpTypes.has( type ) ) {
 			if ( attrOpTypes.has( type ) ) {
 				this._updateAttributes( false );
 				this._updateAttributes( false );
@@ -638,9 +638,9 @@ export default class LiveSelection extends Selection {
 
 
 		const newRange = this._prepareRange( selectionRange );
 		const newRange = this._prepareRange( selectionRange );
 		const index = this._ranges.indexOf( gyRange );
 		const index = this._ranges.indexOf( gyRange );
-		this._ranges.splice( index, 1, newRange );
-
 		gyRange.detach();
 		gyRange.detach();
+
+		this._ranges.splice( index, 1, newRange );
 	}
 	}
 }
 }
 
 

+ 325 - 0
packages/ckeditor5-engine/src/model/markercollection.js

@@ -0,0 +1,325 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import LiveRange from './liverange';
+import Position from './position';
+import Range from './range';
+import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin';
+import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
+import mix from '@ckeditor/ckeditor5-utils/src/mix';
+
+/**
+ * Creates, stores and manages {@link ~Marker markers}.
+ *
+ * Markers are created by {@link ~MarkerCollection#set setting} a name for a {@link module:engine/model/liverange~LiveRange live range}
+ * in `MarkerCollection`. Name is used to group and identify markers. Names have to be unique, but markers can be grouped by
+ * using common prefixes, separated with `:`, for example: `user:john` or `search:3`.
+ *
+ * Since markers are based on {@link module:engine/model/liverange~LiveRange live ranges}, for efficiency reasons, it's
+ * best to create and keep at least markers as possible.
+ */
+export default class MarkerCollection {
+	/**
+	 * Creates a markers collection.
+	 */
+	constructor() {
+		/**
+		 * Stores {@link ~Marker markers} added to the collection.
+		 *
+		 * @private
+		 * @member {Map} #_markers
+		 */
+		this._markers = new Map();
+	}
+
+	/**
+	 * Returns an iterator that iterates over all {@link ~Marker markers} added to the collection.
+	 *
+	 * @returns {Iterator}
+	 */
+	[ Symbol.iterator ]() {
+		return this._markers.values();
+	}
+
+	/**
+	 * Checks if marker with given `markerName` is in the collection.
+	 *
+	 * @param {String} markerName Marker name.
+	 * @returns {Boolean} `true` if marker with given `markerName` is in the collection, `false` otherwise.
+	 */
+	has( markerName ) {
+		return this._markers.has( markerName );
+	}
+
+	/**
+	 * Returns {@link ~Marker marker} with given `markerName`.
+	 *
+	 * @param {String} markerName Name of marker to get.
+	 * @returns {~Marker|null} Marker with given name or `null` if such marker was not added to the collection.
+	 */
+	get( markerName ) {
+		return this._markers.get( markerName ) || null;
+	}
+
+	/**
+	 * Creates and adds a {@link ~Marker marker} to the `MarkerCollection` with given name on given
+	 * {@link module:engine/model/range~Range range}.
+	 *
+	 * If `MarkerCollection` already had a marker with given name (or {@link ~Marker marker} was passed) and the range to
+	 * set is different, the marker in collection is removed and then new marker is added. If the range was same, nothing
+	 * happens and `false` is returned.
+	 *
+	 * @fires {module:engine/model/markercollection~MarkerCollection#event:add}
+	 * @fires {module:engine/model/markercollection~MarkerCollection#event:remove}
+	 * @param {String|~Marker} markerOrName Name of marker to add or Marker instance to update.
+	 * @param {module:engine/model/range~Range} range Marker range.
+	 * @returns {~Marker} `Marker` instance added to the collection.
+	 */
+	set( markerOrName, range ) {
+		const markerName = markerOrName instanceof Marker ? markerOrName.name : markerOrName;
+
+		if ( this._markers.has( markerName ) ) {
+			this.remove( markerName );
+		}
+
+		const liveRange = LiveRange.createFromRange( range );
+		const marker = new Marker( markerName, liveRange );
+
+		this._markers.set( markerName, marker );
+		this.fire( 'add', marker );
+
+		return marker;
+	}
+
+	/**
+	 * Removes given {@link ~Marker marker} or a marker with given name from the `MarkerCollection`.
+	 *
+	 * @param {String} markerOrName Marker or name of a marker to remove.
+	 * @returns {Boolean} `true` if marker was found and removed, `false` otherwise.
+	 */
+	remove( markerOrName ) {
+		const markerName = markerOrName instanceof Marker ? markerOrName.name : markerOrName;
+		const marker = this._markers.get( markerName );
+
+		if ( marker ) {
+			this._markers.delete( markerName );
+			this.fire( 'remove', marker );
+
+			this._destroyMarker( marker );
+
+			return true;
+		}
+
+		return false;
+	}
+
+	/**
+	 * Returns iterator that iterates over all markers, which ranges contain given {@link module:engine/model/position~Position position}.
+	 *
+	 * @param {module:engine/model/position~Position} position
+	 * @returns {Iterator.<~Marker>}
+	 */
+	*getMarkersAtPosition( position ) {
+		for ( let marker of this ) {
+			if ( marker.getRange().containsPosition( position ) ) {
+				yield marker;
+			}
+		}
+	}
+
+	/**
+	 * Destroys markers collection.
+	 */
+	destroy() {
+		for ( let marker of this._markers.values() ) {
+			this._destroyMarker( marker );
+		}
+
+		this._markers = null;
+
+		this.stopListening();
+	}
+
+	/**
+	 * Iterates over all markers that starts with given `prefix`.
+	 *
+	 *		const markerFooA = markersCollection.set( 'foo:a', rangeFooA );
+	 *		const markerFooB = markersCollection.set( 'foo:b', rangeFooB );
+	 *		const markerBarA = markersCollection.set( 'bar:a', rangeBarA );
+	 *		const markerFooBarA = markersCollection.set( 'foobar:a', rangeFooBarA );
+	 *		Array.from( markersCollection.getMarkersGroup( 'foo' ) ); // [ markerFooA, markerFooB ]
+	 *		Array.from( markersCollection.getMarkersGroup( 'a' ) ); // []
+	 *
+	 * @param prefix
+	 * @returns {Iterator.<~Marker>}
+	 */
+	*getMarkersGroup( prefix ) {
+		for ( let marker of this._markers.values() ) {
+			if ( marker.name.startsWith( prefix + ':' ) ) {
+				yield marker;
+			}
+		}
+	}
+
+	/**
+	 * Destroys marker.
+	 *
+	 * @private
+	 * @param {~Marker} marker Marker to destroy.
+	 */
+	_destroyMarker( marker ) {
+		marker.stopListening();
+		marker._liveRange.detach();
+		marker._liveRange = null;
+	}
+
+	/**
+	 * Fired whenever marker is added to `MarkerCollection`.
+	 *
+	 * @event add
+	 * @param {~Marker} The added marker.
+	 */
+
+	/**
+	 * Fired whenever marker is removed from `MarkerCollection`.
+	 *
+	 * @event remove
+	 * @param {~Marker} marker The removed marker.
+	 */
+}
+
+mix( MarkerCollection, EmitterMixin );
+
+/**
+ * `Marker` is a continuous parts of model (like a range), is named and represent some kind of information about marked
+ * part of model document. In contrary to {@link module:engine/model/node~Node nodes}, which are building blocks of
+ * model document tree, markers are not stored directly in document tree. Still, they are document data, by giving
+ * additional meaning to the part of a model document between marker start and marker end.
+ *
+ * In this sense, markers are similar to adding and converting attributes on nodes. The difference is that attribute is
+ * connected with a given node (e.g. a character is bold no matter if it gets moved or content around it changes).
+ * Markers on the other hand are continuous ranges and are characterised by their start and end position. This means that
+ * any character in the marker is marked by the marker. For example, if a character is moved outside of marker it stops being
+ * "special" and the marker is shrunk. Similarly, when a character is moved into the marker from other place in document
+ * model, it starts being "special" and the marker is enlarged.
+ *
+ * Since markers are based on {@link module:engine/model/liverange~LiveRange live ranges}, for efficiency reasons, it's
+ * best to create and keep at least markers as possible.
+ *
+ * Markers can be converted to view by adding appropriate converters for
+ * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher#event:addMarker addMarker} and
+ * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher#event:removeMarker removeMarker}
+ * events, or by building converters for {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher}
+ * using {@link module:engine/conversion/buildmodelconverter~buildModelConverter model converter builder}.
+ *
+ * Another upside of markers is that finding marked part of document is fast and easy. Using attributes to mark some nodes
+ * and then trying to find that part of document would require traversing whole document tree. Marker gives instant access
+ * to the {@link ~Marker#range range} which it is marking at the moment.
+ *
+ * `Marker` instances are created and destroyed only by {@link ~MarkerCollection MarkerCollection}.
+ */
+class Marker {
+	/**
+	 * Creates a marker instance.
+	 *
+	 * @param {String} name Marker name.
+	 * @param {module:engine/model/liverange~LiveRange} liveRange Range marked by the marker.
+	 */
+	constructor( name, liveRange ) {
+		/**
+		 * Marker name.
+		 *
+		 * @readonly
+		 * @member {String} #name
+		 */
+		this.name = name;
+
+		/**
+		 * Range marked by the marker.
+		 *
+		 * @protected
+		 * @member {module:engine/model/liverange~LiveRange} #_liveRange
+		 */
+		this._liveRange = liveRange;
+
+		this._liveRange.delegate( 'change' ).to( this );
+	}
+
+	/**
+	 * Returns current marker start position.
+	 *
+	 * @returns {module:engine/model/position~Position}
+	 */
+	getStart() {
+		if ( !this._liveRange ) {
+			/**
+			 * Operating on destroyed marker instance.
+			 *
+			 * @error marker-destroyed
+			 */
+			throw new CKEditorError( 'marker-destroyed: Operating on destroyed marker instance.' );
+		}
+
+		return Position.createFromPosition( this._liveRange.start );
+	}
+
+	/**
+	 * Returns current marker end position.
+	 *
+	 * @returns {module:engine/model/position~Position}
+	 */
+	getEnd() {
+		if ( !this._liveRange ) {
+			/**
+			 * Operating on destroyed marker instance.
+			 *
+			 * @error marker-destroyed
+			 */
+			throw new CKEditorError( 'marker-destroyed: Operating on destroyed marker instance.' );
+		}
+
+		return Position.createFromPosition( this._liveRange.end );
+	}
+
+	/**
+	 * Returns a range that represents current state of marker.
+	 *
+	 * Keep in mind that returned value is a {@link module:engine/model/range~Range Range}, not a
+	 * {@link module:engine/model/liverange~LiveRange LiveRange}. This means that it is up-to-date and relevant only
+	 * until next model document change. Do not store values returned by this method. Instead, store {@link ~Marker#name}
+	 * and get `Marker` instance from {@link module:engine/model/markercollection~MarkerCollection MarkerCollection} every
+	 * time there is a need to read marker properties. This will guarantee that the marker has not been removed and
+	 * that it's data is up-to-date.
+	 *
+	 * @returns {module:engine/model/range~Range}
+	 */
+	getRange() {
+		if ( !this._liveRange ) {
+			/**
+			 * Operating on destroyed marker instance.
+			 *
+			 * @error marker-destroyed
+			 */
+			throw new CKEditorError( 'marker-destroyed: Operating on destroyed marker instance.' );
+		}
+
+		return Range.createFromRange( this._liveRange );
+	}
+
+	/**
+	 * Fired whenever {@link ~Marker#_liveRange marker range} is changed due to changes on {@link module:engine/model/document~Document}.
+	 * This is actually a delegated {@link module:engine/model/liverange~LiveRange#event:change LiveRange change event}.
+	 *
+	 * When marker is removed from {@link ~MarkerCollection MarkerCollection}, all event listeners listening to it should be
+	 * removed. It is best to do it on {@link ~MarkerCollection#event:remove MarkerCollection remove event}.
+	 *
+	 * @see {module:engine/model/liverange~LiveRange#event:change}
+	 * @event change
+	 * @param {module:engine/model/range~Range} oldRange Range with start and end position equal to start and end position of
+	 * this marker range before it got changed.
+	 */
+}
+
+mix( Marker, EmitterMixin );

+ 0 - 154
packages/ckeditor5-engine/src/model/markerscollection.js

@@ -1,154 +0,0 @@
-/**
- * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
-
-import LiveRange from './liverange';
-import Range from './range';
-import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin';
-import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
-import mix from '@ckeditor/ckeditor5-utils/src/mix';
-
-/**
- * Manages and stores markers.
- *
- * Markers are simply {@link module:engine/model/liverange~LiveRange live ranges} that were added to `MarkersCollection`.
- * Markers are used to represent information connected with model document. In contrary to
- * {@link module:engine/model/node~Node nodes}, which are bits of data, markers are marking a part of model document.
- * Each live range is added with `name` parameter. Name is used to group and identify markers. Names have to be unique, but
- * markers can be grouped by using common prefixes, separated with `:`, for example: `user:john` or `search:3`.
- *
- * Whenever live range is added or removed from `MarkersCollection`,
- * {@link module:engine/model/markerscollection~MarkersCollection#event:addMarker addMarker event} and
- * {@link module:engine/model/markerscollection~MarkersCollection#event:addMarker removeMarker event} are fired.
- *
- * Markers can be converted to view by adding appropriate converters for
- * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher#event:addMarker addMarker} and
- * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher#event:removeMarker removeMarker}
- * events, or by building converters for {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher}
- * using {@link module:engine/conversion/buildmodelconverter~buildModelConverter model converter builder}.
- *
- * Markers are similar to adding and converting attributes on nodes. The difference is that attribute is connected to
- * a given node (e.g. a character is bold no matter if it gets moved or content around it changes). Markers on the
- * other hand are continuous ranges (e.g. if a character from inside of marker range is moved somewhere else, marker
- * range is shrunk and the character does not have any attribute or information that it was in the marked range). Another
- * upside of markers is that finding marked text is fast and easy. Using attributes to mark some nodes and then trying to
- * find that part of document would require traversing whole document tree. For markers, only marker name is needed
- * and a proper range can {@link module:engine/model/markerscollection~MarkersCollection#get be obtained} from the collection.
- */
-export default class MarkersCollection {
-	/**
-	 * Creates a markers collection.
-	 */
-	constructor() {
-		/**
-		 * Stores marker name to range bindings for added ranges.
-		 *
-		 * @private
-		 * @member {Map} #_nameToRange
-		 */
-		this._nameToRange = new Map();
-	}
-
-	/**
-	 * Returns an iterator that iterates over all markers added to the collection. Each item returned by the iterator is an array
-	 * containing two elements, first is a marker {String name} and second is a marker {@link module:engine/model/range~Range range}.
-	 *
-	 * @returns {Iterator}
-	 */
-	[ Symbol.iterator ]() {
-		return this._nameToRange.entries();
-	}
-
-	/**
-	 * Sets a name for given live range and adds it to the markers collection.
-	 *
-	 * Throws, if given `markerName` was already used.
-	 *
-	 * Throws, if given `liveRange` is not an instance of {@link module:engine/model/liverange~LiveRange}.
-	 *
-	 * @param {String} markerName Name to be associated with given `liveRange`.
-	 * @param {module:engine/model/liverange~LiveRange} liveRange Live range to be added as a marker to markers collection.
-	 */
-	add( markerName, liveRange ) {
-		if ( this._nameToRange.has( markerName ) ) {
-			/**
-			 * Marker with given name is already added.
-			 *
-			 * @error markers-collection-add-name-exists
-			 */
-			throw new CKEditorError( 'markers-collection-add-name-exists: Marker with given name is already added.' );
-		}
-
-		if ( !( liveRange instanceof LiveRange ) ) {
-			/**
-			 * Added range is not an instance of LiveRange.
-			 *
-			 * @error markers-collection-add-range-not-live-range
-			 */
-			throw new CKEditorError( 'markers-collection-add-range-not-live-range: Added range is not an instance of LiveRange.' );
-		}
-
-		this._nameToRange.set( markerName, liveRange );
-		this.fire( 'add', markerName, Range.createFromRange( liveRange ) );
-	}
-
-	/**
-	 * Returns the live range that was added to `MarkersCollection` under given `markerName`.
-	 *
-	 * @param {String} markerName Name of range to get.
-	 * @returns {module:engine/model/liverange~LiveRange|null} Range added to collection under given name or `null` if
-	 * no range was added with that name.
-	 */
-	get( markerName ) {
-		return this._nameToRange.get( markerName ) || null;
-	}
-
-	/**
-	 * Removes a live range having given `name` from markers collection.
-	 *
-	 * @param {String} name Name of live range to remove.
-	 * @returns {Boolean} `true` is passed if range was found and removed from the markers collection, `false` otherwise.
-	 */
-	remove( name ) {
-		const range = this._nameToRange.get( name );
-
-		if ( range ) {
-			this._nameToRange.delete( name );
-			this.fire( 'remove', name, Range.createFromRange( range ) );
-
-			return true;
-		}
-
-		return false;
-	}
-
-	/**
-	 * Substitutes range having given `name`, that was already added to the markers collection, with given `newLiveRange`.
-	 *
-	 * This method is basically a wrapper for using {@link module:engine/model/markerscollection~MarkersCollection#removeRange removeRange}
-	 * followed by using {@link module:engine/model/markerscollection~MarkersCollection#addRange addRange}.
-	 *
-	 * @param {String} name Name of a range to be changed.
-	 * @param {module:engine/model/liverange~LiveRange} newLiveRange Live range to be added.
-	 * @returns {Boolean} `true` if range for given `name` was found and changed, `false` otherwise.
-	 */
-	update( name, newLiveRange ) {
-		const removed = this.remove( name );
-
-		if ( removed ) {
-			this.add( name, newLiveRange );
-		}
-
-		return removed;
-	}
-
-	/**
-	 * Destroys markers collection.
-	 */
-	destroy() {
-		this.stopListening();
-	}
-}
-
-mix( MarkersCollection, EmitterMixin );

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

@@ -0,0 +1,146 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module engine/model/operation/markeroperation
+ */
+
+import Operation from './operation';
+import Range from '../range';
+import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
+
+/**
+ * @extends module:engine/model/operation/operation~Operation
+ */
+export default class MarkerOperation extends Operation {
+	/**
+	 * @param {String} name Marker name.
+	 * @param {module:engine/model/range~Range} oldRange Marker range before the change.
+	 * @param {module:engine/model/range~Range} newRange Marker range after the change.
+	 * @param {Number} baseVersion {@link module:engine/model/document~Document#version} on which the operation can be applied.
+	 */
+	constructor( name, oldRange, newRange, baseVersion ) {
+		super( baseVersion );
+
+		/**
+		 * Marker name.
+		 *
+		 * @readonly
+		 * @member {String}
+		 */
+		this.name = name;
+
+		if ( ( oldRange && !oldRange.root.document ) || ( newRange && !newRange.root.document ) ) {
+			/**
+			 * MarkerOperation range must be inside a document.
+			 *
+			 * @error marker-operation-range-not-in-document
+			 */
+			throw new CKEditorError( 'marker-operation-range-not-in-document: MarkerOperation range must be inside a document.' );
+		} else if ( oldRange && newRange && oldRange.root.document != newRange.root.document ) {
+			/**
+			 * MarkerOperation ranges must be inside same document.
+			 *
+			 * @error marker-operation-ranges-in-different-documents
+			 */
+			throw new CKEditorError(
+				'marker-operation-ranges-in-different-documents: MarkerOperation ranges must be inside same document.'
+			);
+		}
+
+		/**
+		 * Marker range before the change.
+		 *
+		 * @readonly
+		 * @member {module:engine/model/range~Range}
+		 */
+		this.oldRange = oldRange ? Range.createFromRange( oldRange ) : null;
+
+		/**
+		 * Marker range after the change.
+		 *
+		 * @readonly
+		 * @member {module:engine/model/range~Range}
+		 */
+		this.newRange = newRange ? Range.createFromRange( newRange ) : null;
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	get type() {
+		return 'marker';
+	}
+
+	/**
+	 * @inheritDoc
+	 * @returns {module:engine/model/operation/markeroperation~MarkerOperation}
+	 */
+	clone() {
+		return new MarkerOperation( this.name, this.oldRange, this.newRange, this.baseVersion );
+	}
+
+	/**
+	 * @inheritDoc
+	 * @returns {module:engine/model/operation/markeroperation~MarkerOperation}
+	 */
+	getReversed() {
+		return new MarkerOperation( this.name, this.newRange, this.oldRange, this.baseVersion + 1 );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	_execute() {
+		// Make a change in Document#markers only when something actually changes, that is
+		// `this.oldRange` and `this.newRange` are different.
+		const changeInMarkersCollection =
+			!( this.oldRange === null && this.newRange === null ) &&
+			!( this.oldRange !== null && this.newRange !== null && this.oldRange.isEqual( this.newRange ) );
+
+		const type = this.newRange ? 'set' : 'remove';
+
+		if ( changeInMarkersCollection ) {
+			const document = ( this.oldRange || this.newRange ).root.document;
+			document.markers[ type ]( this.name, this.newRange );
+		}
+
+		return { name: this.name, type: type };
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	toJSON() {
+		const json = super.toJSON();
+
+		delete json._document;
+
+		return json;
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	static get className() {
+		return 'engine.model.operation.MarkerOperation';
+	}
+
+	/**
+	 * Creates `MarkerOperation` object from deserilized object, i.e. from parsed JSON string.
+	 *
+	 * @param {Object} json Deserialized JSON object.
+	 * @param {module:engine/model/document~Document} document Document on which this operation will be applied.
+	 * @returns {module:engine/model/operation/markeroperation~MarkerOperation}
+	 */
+	static fromJSON( json, document ) {
+		return new MarkerOperation(
+			json.name,
+			json.oldRange ? Range.fromJSON( json.oldRange, document ) : null,
+			json.newRange ? Range.fromJSON( json.newRange, document ) : null,
+			json.baseVersion
+		);
+	}
+}

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

@@ -9,6 +9,7 @@
 
 
 import AttributeOperation from '../operation/attributeoperation';
 import AttributeOperation from '../operation/attributeoperation';
 import InsertOperation from '../operation/insertoperation';
 import InsertOperation from '../operation/insertoperation';
+import MarkerOperation from '../operation/markeroperation';
 import MoveOperation from '../operation/moveoperation';
 import MoveOperation from '../operation/moveoperation';
 import NoOperation from '../operation/nooperation';
 import NoOperation from '../operation/nooperation';
 import Operation from '../operation/operation';
 import Operation from '../operation/operation';
@@ -20,6 +21,7 @@ import RootAttributeOperation from '../operation/rootattributeoperation';
 const operations = {};
 const operations = {};
 operations[ AttributeOperation.className ] = AttributeOperation;
 operations[ AttributeOperation.className ] = AttributeOperation;
 operations[ InsertOperation.className ] = InsertOperation;
 operations[ InsertOperation.className ] = InsertOperation;
+operations[ MarkerOperation.className ] = MarkerOperation;
 operations[ MoveOperation.className ] = MoveOperation;
 operations[ MoveOperation.className ] = MoveOperation;
 operations[ NoOperation.className ] = NoOperation;
 operations[ NoOperation.className ] = NoOperation;
 operations[ Operation.className ] = Operation;
 operations[ Operation.className ] = Operation;

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

@@ -11,6 +11,7 @@ import InsertOperation from './insertoperation';
 import AttributeOperation from './attributeoperation';
 import AttributeOperation from './attributeoperation';
 import RootAttributeOperation from './rootattributeoperation';
 import RootAttributeOperation from './rootattributeoperation';
 import RenameOperation from './renameoperation';
 import RenameOperation from './renameoperation';
+import MarkerOperation from './markeroperation';
 import MoveOperation from './moveoperation';
 import MoveOperation from './moveoperation';
 import RemoveOperation from './removeoperation';
 import RemoveOperation from './removeoperation';
 import NoOperation from './nooperation';
 import NoOperation from './nooperation';
@@ -82,6 +83,8 @@ const ot = {
 
 
 		RenameOperation: doNotUpdate,
 		RenameOperation: doNotUpdate,
 
 
+		MarkerOperation: doNotUpdate,
+
 		// Transforms InsertOperation `a` by MoveOperation `b`. Accepts a flag stating whether `a` is more important
 		// Transforms InsertOperation `a` by MoveOperation `b`. Accepts a flag stating whether `a` is more important
 		// than `b` when it comes to resolving conflicts. Returns results as an array of operations.
 		// than `b` when it comes to resolving conflicts. Returns results as an array of operations.
 		MoveOperation( a, b, isStrong ) {
 		MoveOperation( a, b, isStrong ) {
@@ -142,6 +145,8 @@ const ot = {
 
 
 		RenameOperation: doNotUpdate,
 		RenameOperation: doNotUpdate,
 
 
+		MarkerOperation: doNotUpdate,
+
 		// Transforms AttributeOperation `a` by MoveOperation `b`. Returns results as an array of operations.
 		// Transforms AttributeOperation `a` by MoveOperation `b`. Returns results as an array of operations.
 		MoveOperation( a, b ) {
 		MoveOperation( a, b ) {
 			// Convert MoveOperation properties into a range.
 			// Convert MoveOperation properties into a range.
@@ -222,6 +227,8 @@ const ot = {
 
 
 		RenameOperation: doNotUpdate,
 		RenameOperation: doNotUpdate,
 
 
+		MarkerOperation: doNotUpdate,
+
 		MoveOperation: doNotUpdate
 		MoveOperation: doNotUpdate
 	},
 	},
 
 
@@ -258,6 +265,8 @@ const ot = {
 			return [ clone ];
 			return [ clone ];
 		},
 		},
 
 
+		MarkerOperation: doNotUpdate,
+
 		// Transforms RenameOperation `a` by MoveOperation `b`. Returns results as an array of operations.
 		// Transforms RenameOperation `a` by MoveOperation `b`. Returns results as an array of operations.
 		MoveOperation( a, b ) {
 		MoveOperation( a, b ) {
 			const clone = a.clone();
 			const clone = a.clone();
@@ -269,6 +278,65 @@ const ot = {
 		}
 		}
 	},
 	},
 
 
+	MarkerOperation: {
+		// Transforms MarkerOperation `a` by InsertOperation `b`. Returns results as an array of operations.
+		InsertOperation( a, b ) {
+			// Clone the operation, we don't want to alter the original operation.
+			const clone = a.clone();
+
+			if ( clone.oldRange ) {
+				clone.oldRange = clone.oldRange._getTransformedByInsertion( b.position, b.nodes.maxOffset, false, false )[ 0 ];
+			}
+
+			if ( clone.newRange ) {
+				clone.newRange = clone.newRange._getTransformedByInsertion( b.position, b.nodes.maxOffset, false, false )[ 0 ];
+			}
+
+			return [ clone ];
+		},
+
+		AttributeOperation: doNotUpdate,
+
+		RootAttributeOperation: doNotUpdate,
+
+		RenameOperation: doNotUpdate,
+
+		// Transforms MarkerOperation `a` by MarkerOperation `b`. Accepts a flag stating whether `a` is more important
+		// than `b` when it comes to resolving conflicts. Returns results as an array of operations.
+		MarkerOperation( a, b, isStrong ) {
+			// Clone the operation, we don't want to alter the original operation.
+			const clone = a.clone();
+
+			if ( a.name == b.name ) {
+				if ( isStrong ) {
+					clone.oldRange = b.newRange;
+				} else {
+					return [ new NoOperation( a.baseVersion ) ];
+				}
+			}
+
+			return [ clone ];
+		},
+
+		// Transforms MarkerOperation `a` by MoveOperation `b`. Returns results as an array of operations.
+		MoveOperation( a, b ) {
+			// Clone the operation, we don't want to alter the original operation.
+			const clone = a.clone();
+
+			if ( clone.oldRange ) {
+				const oldRanges = clone.oldRange._getTransformedByMove( b.sourcePosition, b.targetPosition, b.howMany );
+				clone.oldRange = Range.createFromRanges( oldRanges );
+			}
+
+			if ( clone.newRange ) {
+				const newRanges = clone.newRange._getTransformedByMove( b.sourcePosition, b.targetPosition, b.howMany );
+				clone.newRange = Range.createFromRanges( newRanges );
+			}
+
+			return [ clone ];
+		}
+	},
+
 	MoveOperation: {
 	MoveOperation: {
 		// Transforms MoveOperation `a` by InsertOperation `b`. Accepts a flag stating whether `a` is more important
 		// Transforms MoveOperation `a` by InsertOperation `b`. Accepts a flag stating whether `a` is more important
 		// than `b` when it comes to resolving conflicts. Returns results as an array of operations.
 		// than `b` when it comes to resolving conflicts. Returns results as an array of operations.
@@ -298,6 +366,8 @@ const ot = {
 
 
 		RenameOperation: doNotUpdate,
 		RenameOperation: doNotUpdate,
 
 
+		MarkerOperation: doNotUpdate,
+
 		// Transforms MoveOperation `a` by MoveOperation `b`. Accepts a flag stating whether `a` is more important
 		// Transforms MoveOperation `a` by MoveOperation `b`. Accepts a flag stating whether `a` is more important
 		// than `b` when it comes to resolving conflicts. Returns results as an array of operations.
 		// than `b` when it comes to resolving conflicts. Returns results as an array of operations.
 		MoveOperation( a, b, isStrong ) {
 		MoveOperation( a, b, isStrong ) {
@@ -452,6 +522,8 @@ function transform( a, b, isStrong ) {
 		group = ot.RootAttributeOperation;
 		group = ot.RootAttributeOperation;
 	} else if ( a instanceof RenameOperation ) {
 	} else if ( a instanceof RenameOperation ) {
 		group = ot.RenameOperation;
 		group = ot.RenameOperation;
+	} else if ( a instanceof MarkerOperation ) {
+		group = ot.MarkerOperation;
 	} else if ( a instanceof MoveOperation ) {
 	} else if ( a instanceof MoveOperation ) {
 		group = ot.MoveOperation;
 		group = ot.MoveOperation;
 	} else {
 	} else {
@@ -467,6 +539,8 @@ function transform( a, b, isStrong ) {
 			algorithm = group.RootAttributeOperation;
 			algorithm = group.RootAttributeOperation;
 		} else if ( b instanceof RenameOperation ) {
 		} else if ( b instanceof RenameOperation ) {
 			algorithm = group.RenameOperation;
 			algorithm = group.RenameOperation;
+		} else if ( b instanceof MarkerOperation ) {
+			algorithm = group.MarkerOperation;
 		} else if ( b instanceof MoveOperation ) {
 		} else if ( b instanceof MoveOperation ) {
 			algorithm = group.MoveOperation;
 			algorithm = group.MoveOperation;
 		} else {
 		} else {

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

@@ -46,7 +46,7 @@ export default class Selection {
 		 * List of attributes set on current selection.
 		 * List of attributes set on current selection.
 		 *
 		 *
 		 * @protected
 		 * @protected
-		 * @member {Map} module:engine/model/liveselection~LiveSelection#_attrs
+		 * @member {Map} module:engine/model/selection~Selection#_attrs
 		 */
 		 */
 		this._attrs = new Map();
 		this._attrs = new Map();
 	}
 	}

+ 2 - 0
packages/ckeditor5-engine/src/view/writer.js

@@ -887,6 +887,8 @@ function wrapChildren( parent, startOffset, endOffset, attribute ) {
 	let offsetChange = 0;
 	let offsetChange = 0;
 
 
 	for ( let position of wrapPositions ) {
 	for ( let position of wrapPositions ) {
+		position.offset -= offsetChange;
+
 		// Do not merge with elements outside selected children.
 		// Do not merge with elements outside selected children.
 		if ( position.offset == startOffset ) {
 		if ( position.offset == startOffset ) {
 			continue;
 			continue;

+ 70 - 17
packages/ckeditor5-engine/tests/controller/editingcontroller.js

@@ -18,8 +18,8 @@ import buildModelConverter from '../../src/conversion/buildmodelconverter';
 import ModelDocument from '../../src/model/document';
 import ModelDocument from '../../src/model/document';
 import ModelPosition from '../../src/model/position';
 import ModelPosition from '../../src/model/position';
 import ModelElement from '../../src/model/element';
 import ModelElement from '../../src/model/element';
+import ModelText from '../../src/model/text';
 import ModelRange from '../../src/model/range';
 import ModelRange from '../../src/model/range';
-import ModelLiveRange from '../../src/model/liverange';
 import ModelDocumentFragment from '../../src/model/documentfragment';
 import ModelDocumentFragment from '../../src/model/documentfragment';
 
 
 import createElement from '@ckeditor/ckeditor5-utils/src/dom/createelement';
 import createElement from '@ckeditor/ckeditor5-utils/src/dom/createelement';
@@ -55,6 +55,7 @@ describe( 'EditingController', () => {
 
 
 		afterEach( () => {
 		afterEach( () => {
 			editing.destroy();
 			editing.destroy();
+			model.markers.destroy();
 		} );
 		} );
 
 
 		it( 'should create root', () => {
 		it( 'should create root', () => {
@@ -255,29 +256,33 @@ describe( 'EditingController', () => {
 
 
 		it( 'should forward marker events to model conversion dispatcher', () => {
 		it( 'should forward marker events to model conversion dispatcher', () => {
 			const range = ModelRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 1 );
 			const range = ModelRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 1 );
+			const markerStub = {
+				name: 'name',
+				getRange: () => range
+			};
 
 
 			sinon.spy( editing.modelToView, 'convertMarker' );
 			sinon.spy( editing.modelToView, 'convertMarker' );
 
 
-			model.markers.fire( 'add', range, 'name' );
+			model.markers.fire( 'add', markerStub );
 
 
-			expect( editing.modelToView.convertMarker.calledWithExactly( 'addMarker', range, 'name' ) ).to.be.true;
+			expect( editing.modelToView.convertMarker.calledWithExactly( 'addMarker', 'name', range ) ).to.be.true;
 
 
-			model.markers.fire( 'remove', range, 'name' );
+			model.markers.fire( 'remove', markerStub );
 
 
-			expect( editing.modelToView.convertMarker.calledWithExactly( 'removeMarker', range, 'name' ) ).to.be.true;
+			expect( editing.modelToView.convertMarker.calledWithExactly( 'removeMarker', 'name', range ) ).to.be.true;
 
 
 			editing.modelToView.convertMarker.restore();
 			editing.modelToView.convertMarker.restore();
 		} );
 		} );
 
 
 		it( 'should forward add marker event if content is inserted into a marker range', () => {
 		it( 'should forward add marker event if content is inserted into a marker range', () => {
-			const markerRange = ModelLiveRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 3 );
+			const markerRange = ModelRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 3 );
 			const innerRange = ModelRange.createFromParentsAndOffsets( modelRoot, 1, modelRoot, 2 );
 			const innerRange = ModelRange.createFromParentsAndOffsets( modelRoot, 1, modelRoot, 2 );
 			const consumableMock = {
 			const consumableMock = {
 				consume: () => true,
 				consume: () => true,
 				test: () => true
 				test: () => true
 			};
 			};
 
 
-			model.markers.add( 'name', markerRange );
+			model.markers.set( 'name', markerRange );
 
 
 			sinon.spy( editing.modelToView, 'convertMarker' );
 			sinon.spy( editing.modelToView, 'convertMarker' );
 
 
@@ -290,15 +295,63 @@ describe( 'EditingController', () => {
 			editing.modelToView.convertMarker.restore();
 			editing.modelToView.convertMarker.restore();
 		} );
 		} );
 
 
+		it( 'should forward add marker event if inserted content has a marker (reinsert from graveyard)', () => {
+			const gyHolder = new ModelElement( '$graveyardHolder', [], new ModelText( 'foo' ) );
+			model.graveyard.appendChildren( gyHolder );
+
+			const markerRange = ModelRange.createIn( gyHolder );
+			const consumableMock = {
+				consume: () => true,
+				test: () => true
+			};
+
+			model.markers.set( 'name', markerRange );
+
+			sinon.spy( editing.modelToView, 'convertMarker' );
+
+			editing.modelToView.fire( 'insert', {
+				range: markerRange
+			}, consumableMock, { dispatcher: editing.modelToView } );
+
+			expect( editing.modelToView.convertMarker.calledWithExactly( 'addMarker', 'name', markerRange ) ).to.be.true;
+
+			editing.modelToView.convertMarker.restore();
+		} );
+
+		it( 'should forward add marker event if inserted content has a marker (inserted element from outside of tree)', () => {
+			const element = new ModelElement( new ModelText( 'foo' ) );
+			modelRoot.appendChildren( element );
+
+			const markerRange = ModelRange.createFromParentsAndOffsets( element, 1, element, 2 );
+			const outerRange = ModelRange.createOn( element );
+
+			const consumableMock = {
+				consume: () => true,
+				test: () => true
+			};
+
+			model.markers.set( 'name', markerRange );
+
+			sinon.spy( editing.modelToView, 'convertMarker' );
+
+			editing.modelToView.fire( 'insert', {
+				range: outerRange
+			}, consumableMock, { dispatcher: editing.modelToView } );
+
+			expect( editing.modelToView.convertMarker.calledWithExactly( 'addMarker', 'name', markerRange ) ).to.be.true;
+
+			editing.modelToView.convertMarker.restore();
+		} );
+
 		it( 'should not start marker conversion if content is not inserted into any marker range', () => {
 		it( 'should not start marker conversion if content is not inserted into any marker range', () => {
-			const markerRange = ModelLiveRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 3 );
+			const markerRange = ModelRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 3 );
 			const insertRange = ModelRange.createFromParentsAndOffsets( modelRoot, 6, modelRoot, 8 );
 			const insertRange = ModelRange.createFromParentsAndOffsets( modelRoot, 6, modelRoot, 8 );
 			const consumableMock = {
 			const consumableMock = {
 				consume: () => true,
 				consume: () => true,
 				test: () => true
 				test: () => true
 			};
 			};
 
 
-			model.markers.add( 'name', markerRange );
+			model.markers.set( 'name', markerRange );
 
 
 			sinon.spy( editing.modelToView, 'convertMarker' );
 			sinon.spy( editing.modelToView, 'convertMarker' );
 
 
@@ -312,13 +365,13 @@ describe( 'EditingController', () => {
 		} );
 		} );
 
 
 		it( 'should forward remove marker event if part of marker range is moved - intersecting', () => {
 		it( 'should forward remove marker event if part of marker range is moved - intersecting', () => {
-			const markerRange = ModelLiveRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 1 );
+			const markerRange = ModelRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 1 );
 			const consumableMock = {
 			const consumableMock = {
 				consume: () => true,
 				consume: () => true,
 				test: () => true
 				test: () => true
 			};
 			};
 
 
-			model.markers.add( 'name', markerRange );
+			model.markers.set( 'name', markerRange );
 
 
 			sinon.spy( editing.modelToView, 'convertMarker' );
 			sinon.spy( editing.modelToView, 'convertMarker' );
 
 
@@ -338,13 +391,13 @@ describe( 'EditingController', () => {
 				model.batch().insert( ModelPosition.createAt( model.getRoot(), 'end' ), new ModelElement( 'paragraph' ) );
 				model.batch().insert( ModelPosition.createAt( model.getRoot(), 'end' ), new ModelElement( 'paragraph' ) );
 			} );
 			} );
 
 
-			const markerRange = ModelLiveRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 2 );
+			const markerRange = ModelRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 2 );
 			const consumableMock = {
 			const consumableMock = {
 				consume: () => true,
 				consume: () => true,
 				test: () => true
 				test: () => true
 			};
 			};
 
 
-			model.markers.add( 'name', markerRange );
+			model.markers.set( 'name', markerRange );
 
 
 			sinon.spy( editing.modelToView, 'convertMarker' );
 			sinon.spy( editing.modelToView, 'convertMarker' );
 
 
@@ -364,13 +417,13 @@ describe( 'EditingController', () => {
 				model.batch().insert( ModelPosition.createAt( model.getRoot(), 'end' ), new ModelElement( 'paragraph' ) );
 				model.batch().insert( ModelPosition.createAt( model.getRoot(), 'end' ), new ModelElement( 'paragraph' ) );
 			} );
 			} );
 
 
-			const markerRange = ModelLiveRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 3 );
+			const markerRange = ModelRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 3 );
 			const consumableMock = {
 			const consumableMock = {
 				consume: () => true,
 				consume: () => true,
 				test: () => true
 				test: () => true
 			};
 			};
 
 
-			model.markers.add( 'name', markerRange );
+			model.markers.set( 'name', markerRange );
 
 
 			sinon.spy( editing.modelToView, 'convertMarker' );
 			sinon.spy( editing.modelToView, 'convertMarker' );
 
 
@@ -386,13 +439,13 @@ describe( 'EditingController', () => {
 		} );
 		} );
 
 
 		it( 'should not start marker conversion if moved content does not affect the marker', () => {
 		it( 'should not start marker conversion if moved content does not affect the marker', () => {
-			const markerRange = ModelLiveRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 1 );
+			const markerRange = ModelRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 1 );
 			const consumableMock = {
 			const consumableMock = {
 				consume: () => true,
 				consume: () => true,
 				test: () => true
 				test: () => true
 			};
 			};
 
 
-			model.markers.add( 'name', markerRange );
+			model.markers.set( 'name', markerRange );
 
 
 			sinon.spy( editing.modelToView, 'convertMarker' );
 			sinon.spy( editing.modelToView, 'convertMarker' );
 
 

+ 4 - 4
packages/ckeditor5-engine/tests/conversion/buildmodelconverter.js

@@ -212,7 +212,7 @@ describe( 'Model converter builder', () => {
 
 
 			// Convert stuff.
 			// Convert stuff.
 			dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
 			dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
-			dispatcher.convertSelection( modelDoc.selection );
+			dispatcher.convertSelection( modelDoc.selection, [] );
 
 
 			// Check if view structure is ok.
 			// Check if view structure is ok.
 			expect( viewToString( viewRoot ) ).to.equal( '<div><em>foo</em></div>' );
 			expect( viewToString( viewRoot ) ).to.equal( '<div><em>foo</em></div>' );
@@ -226,7 +226,7 @@ describe( 'Model converter builder', () => {
 
 
 			// Change selection attribute, convert it.
 			// Change selection attribute, convert it.
 			modelDoc.selection.setAttribute( 'italic', 'i' );
 			modelDoc.selection.setAttribute( 'italic', 'i' );
-			dispatcher.convertSelection( modelDoc.selection );
+			dispatcher.convertSelection( modelDoc.selection, [] );
 
 
 			// Check if view structure has changed.
 			// Check if view structure has changed.
 			expect( viewToString( viewRoot ) ).to.equal( '<div><em>f</em><i></i><em>oo</em></div>' );
 			expect( viewToString( viewRoot ) ).to.equal( '<div><em>f</em><i></i><em>oo</em></div>' );
@@ -240,7 +240,7 @@ describe( 'Model converter builder', () => {
 
 
 			// Some more tests checking how selection attributes changes are converted:
 			// Some more tests checking how selection attributes changes are converted:
 			modelDoc.selection.removeAttribute( 'italic' );
 			modelDoc.selection.removeAttribute( 'italic' );
-			dispatcher.convertSelection( modelDoc.selection );
+			dispatcher.convertSelection( modelDoc.selection, [] );
 
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><em>f</em><em>oo</em></div>' );
 			expect( viewToString( viewRoot ) ).to.equal( '<div><em>f</em><em>oo</em></div>' );
 			ranges = Array.from( viewSelection.getRanges() );
 			ranges = Array.from( viewSelection.getRanges() );
@@ -248,7 +248,7 @@ describe( 'Model converter builder', () => {
 			expect( ranges[ 0 ].start.offset ).to.equal( 1 );
 			expect( ranges[ 0 ].start.offset ).to.equal( 1 );
 
 
 			modelDoc.selection.setAttribute( 'italic', 'em' );
 			modelDoc.selection.setAttribute( 'italic', 'em' );
-			dispatcher.convertSelection( modelDoc.selection );
+			dispatcher.convertSelection( modelDoc.selection, [] );
 
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><em>foo</em></div>' );
 			expect( viewToString( viewRoot ) ).to.equal( '<div><em>foo</em></div>' );
 			ranges = Array.from( viewSelection.getRanges() );
 			ranges = Array.from( viewSelection.getRanges() );

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

@@ -19,6 +19,7 @@ import {
 	convertRangeSelection,
 	convertRangeSelection,
 	convertCollapsedSelection,
 	convertCollapsedSelection,
 	convertSelectionAttribute,
 	convertSelectionAttribute,
+	convertSelectionMarker,
 	clearAttributes,
 	clearAttributes,
 	clearFakeSelection
 	clearFakeSelection
 } from '../../src/conversion/model-selection-to-view-converters';
 } from '../../src/conversion/model-selection-to-view-converters';
@@ -26,12 +27,17 @@ import {
 import {
 import {
 	insertElement,
 	insertElement,
 	insertText,
 	insertText,
-	wrapItem
+	wrapItem,
+	wrapRange,
+	unwrapRange
 } from '../../src/conversion/model-to-view-converters';
 } from '../../src/conversion/model-to-view-converters';
 
 
 import { stringify as stringifyView } from '../../src/dev-utils/view';
 import { stringify as stringifyView } from '../../src/dev-utils/view';
 import { setData as setModelData } from '../../src/dev-utils/model';
 import { setData as setModelData } from '../../src/dev-utils/model';
 
 
+const spanViewElementForMarker = new ViewAttributeElement( 'span' );
+spanViewElementForMarker.priority = 1;
+
 describe( 'model-selection-to-view-converters', () => {
 describe( 'model-selection-to-view-converters', () => {
 	let dispatcher, mapper;
 	let dispatcher, mapper;
 	let modelDoc, modelRoot, modelSelection;
 	let modelDoc, modelRoot, modelSelection;
@@ -55,6 +61,8 @@ describe( 'model-selection-to-view-converters', () => {
 
 
 		dispatcher.on( 'insert:$text', insertText() );
 		dispatcher.on( 'insert:$text', insertText() );
 		dispatcher.on( 'addAttribute:bold', wrapItem( new ViewAttributeElement( 'strong' ) ) );
 		dispatcher.on( 'addAttribute:bold', wrapItem( new ViewAttributeElement( 'strong' ) ) );
+		dispatcher.on( 'addMarker:marker', wrapRange( spanViewElementForMarker ) );
+		dispatcher.on( 'removeMarker:marker', unwrapRange( spanViewElementForMarker ) );
 
 
 		// Default selection converters.
 		// Default selection converters.
 		dispatcher.on( 'selection', clearAttributes(), { priority: 'low' } );
 		dispatcher.on( 'selection', clearAttributes(), { priority: 'low' } );
@@ -71,6 +79,7 @@ describe( 'model-selection-to-view-converters', () => {
 			// Selection converters for selection attributes.
 			// Selection converters for selection attributes.
 			dispatcher.on( 'selectionAttribute:bold', convertSelectionAttribute( new ViewAttributeElement( 'strong' ) ) );
 			dispatcher.on( 'selectionAttribute:bold', convertSelectionAttribute( new ViewAttributeElement( 'strong' ) ) );
 			dispatcher.on( 'selectionAttribute:italic', convertSelectionAttribute( new ViewAttributeElement( 'em' ) ) );
 			dispatcher.on( 'selectionAttribute:italic', convertSelectionAttribute( new ViewAttributeElement( 'em' ) ) );
+			dispatcher.on( 'selectionMarker:marker', convertSelectionMarker( spanViewElementForMarker ) );
 		} );
 		} );
 
 
 		describe( 'range selection', () => {
 		describe( 'range selection', () => {
@@ -207,6 +216,81 @@ describe( 'model-selection-to-view-converters', () => {
 				);
 				);
 			} );
 			} );
 
 
+			it( 'in attribute and marker', () => {
+				setModelData( modelDoc, 'fo<$text bold="true">ob</$text>ar' );
+				const marker = modelDoc.markers.set( 'marker', ModelRange.createFromParentsAndOffsets( modelRoot, 1, modelRoot, 5 ) );
+
+				modelSelection.setRanges( [ new ModelRange( ModelPosition.createAt( modelRoot, 3 ) ) ] );
+
+				// Update selection attributes according to model.
+				modelSelection.refreshAttributes();
+
+				// Remove view children manually (without firing additional conversion).
+				viewRoot.removeChildren( 0, viewRoot.childCount );
+
+				// Convert model to view.
+				dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+				dispatcher.convertMarker( 'addMarker', marker.name, marker.getRange() );
+
+				const markers = Array.from( modelDoc.markers.getMarkersAtPosition( modelSelection.getFirstPosition() ) );
+				dispatcher.convertSelection( modelSelection, markers );
+
+				// Stringify view and check if it is same as expected.
+				expect( stringifyView( viewRoot, viewSelection, { showType: false } ) )
+					.to.equal( '<div>f<span>o<strong>o{}b</strong>a</span>r</div>' );
+			} );
+
+			it( 'in attribute and marker - no attribute', () => {
+				setModelData( modelDoc, 'fo<$text bold="true">ob</$text>ar' );
+				const marker = modelDoc.markers.set( 'marker', ModelRange.createFromParentsAndOffsets( modelRoot, 1, modelRoot, 5 ) );
+
+				modelSelection.setRanges( [ new ModelRange( ModelPosition.createAt( modelRoot, 3 ) ) ] );
+
+				// Update selection attributes according to model.
+				modelSelection.refreshAttributes();
+
+				modelSelection.removeAttribute( 'bold' );
+
+				// Remove view children manually (without firing additional conversion).
+				viewRoot.removeChildren( 0, viewRoot.childCount );
+
+				// Convert model to view.
+				dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+				dispatcher.convertMarker( 'addMarker', marker.name, marker.getRange() );
+
+				const markers = Array.from( modelDoc.markers.getMarkersAtPosition( modelSelection.getFirstPosition() ) );
+				dispatcher.convertSelection( modelSelection, markers );
+
+				// Stringify view and check if it is same as expected.
+				expect( stringifyView( viewRoot, viewSelection, { showType: false } ) )
+					.to.equal( '<div>f<span>o<strong>o</strong>[]<strong>b</strong>a</span>r</div>' );
+			} );
+
+			it( 'in marker - using view element as element creator function for selection marker conversion', () => {
+				dispatcher.on( 'selectionMarker:marker2', convertSelectionMarker(
+					( data ) => new ViewAttributeElement( 'span', { 'class': data.name } )
+				) );
+
+				setModelData( modelDoc, 'foobar' );
+				const marker = modelDoc.markers.set( 'marker2', ModelRange.createFromParentsAndOffsets( modelRoot, 1, modelRoot, 5 ) );
+
+				modelSelection.setRanges( [ new ModelRange( ModelPosition.createAt( modelRoot, 3 ) ) ] );
+
+				// Remove view children manually (without firing additional conversion).
+				viewRoot.removeChildren( 0, viewRoot.childCount );
+
+				// Convert model to view.
+				dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+				dispatcher.convertMarker( 'addMarker', marker.name, marker.getRange() );
+
+				const markers = Array.from( modelDoc.markers.getMarkersAtPosition( modelSelection.getFirstPosition() ) );
+				dispatcher.convertSelection( modelSelection, markers );
+
+				// Stringify view and check if it is same as expected.
+				expect( stringifyView( viewRoot, viewSelection, { showType: false } ) )
+					.to.equal( '<div>foo<span class="marker2">[]</span>bar</div>' );
+			} );
+
 			it( 'consumes consumable values properly', () => {
 			it( 'consumes consumable values properly', () => {
 				// Add callbacks that will fire before default ones.
 				// Add callbacks that will fire before default ones.
 				// This should prevent default callbacks doing anything.
 				// This should prevent default callbacks doing anything.
@@ -218,7 +302,7 @@ describe( 'model-selection-to-view-converters', () => {
 					expect( consumable.consume( data.selection, 'selectionAttribute:bold' ) ).to.be.true;
 					expect( consumable.consume( data.selection, 'selectionAttribute:bold' ) ).to.be.true;
 				}, { priority: 'high' } );
 				}, { priority: 'high' } );
 
 
-				// Similar test case as above
+				// Similar test case as above.
 				test(
 				test(
 					[ 3, 3 ],
 					[ 3, 3 ],
 					'f<$text bold="true">ooba</$text>r',
 					'f<$text bold="true">ooba</$text>r',
@@ -280,7 +364,7 @@ describe( 'model-selection-to-view-converters', () => {
 				const modelRange = ModelRange.createFromParentsAndOffsets( modelRoot, 1, modelRoot, 1 );
 				const modelRange = ModelRange.createFromParentsAndOffsets( modelRoot, 1, modelRoot, 1 );
 				modelDoc.selection.setRanges( [ modelRange ] );
 				modelDoc.selection.setRanges( [ modelRange ] );
 
 
-				dispatcher.convertSelection( modelDoc.selection );
+				dispatcher.convertSelection( modelDoc.selection, [] );
 
 
 				expect( viewSelection.rangeCount ).to.equal( 1 );
 				expect( viewSelection.rangeCount ).to.equal( 1 );
 
 
@@ -305,7 +389,7 @@ describe( 'model-selection-to-view-converters', () => {
 				const modelRange = ModelRange.createFromParentsAndOffsets( modelRoot, 1, modelRoot, 1 );
 				const modelRange = ModelRange.createFromParentsAndOffsets( modelRoot, 1, modelRoot, 1 );
 				modelDoc.selection.setRanges( [ modelRange ] );
 				modelDoc.selection.setRanges( [ modelRange ] );
 
 
-				dispatcher.convertSelection( modelDoc.selection );
+				dispatcher.convertSelection( modelDoc.selection, [] );
 
 
 				expect( viewSelection.rangeCount ).to.equal( 1 );
 				expect( viewSelection.rangeCount ).to.equal( 1 );
 
 
@@ -319,7 +403,7 @@ describe( 'model-selection-to-view-converters', () => {
 				dispatcher.on( 'selection', clearFakeSelection() );
 				dispatcher.on( 'selection', clearFakeSelection() );
 				viewSelection.setFake( true );
 				viewSelection.setFake( true );
 
 
-				dispatcher.convertSelection( modelSelection );
+				dispatcher.convertSelection( modelSelection, [] );
 
 
 				expect( viewSelection.isFake ).to.be.false;
 				expect( viewSelection.isFake ).to.be.false;
 			} );
 			} );
@@ -499,13 +583,15 @@ describe( 'model-selection-to-view-converters', () => {
 		// Manually set selection ranges using passed `selectionPaths`.
 		// Manually set selection ranges using passed `selectionPaths`.
 		const startPath = typeof selectionPaths[ 0 ] == 'number' ? [ selectionPaths[ 0 ] ] : selectionPaths[ 0 ];
 		const startPath = typeof selectionPaths[ 0 ] == 'number' ? [ selectionPaths[ 0 ] ] : selectionPaths[ 0 ];
 		const endPath = typeof selectionPaths[ 1 ] == 'number' ? [ selectionPaths[ 1 ] ] : selectionPaths[ 1 ];
 		const endPath = typeof selectionPaths[ 1 ] == 'number' ? [ selectionPaths[ 1 ] ] : selectionPaths[ 1 ];
+
 		const startPos = new ModelPosition( modelRoot, startPath );
 		const startPos = new ModelPosition( modelRoot, startPath );
 		const endPos = new ModelPosition( modelRoot, endPath );
 		const endPos = new ModelPosition( modelRoot, endPath );
+
 		const isBackward = selectionPaths[ 2 ] === 'backward';
 		const isBackward = selectionPaths[ 2 ] === 'backward';
 		modelSelection.setRanges( [ new ModelRange( startPos, endPos ) ], isBackward );
 		modelSelection.setRanges( [ new ModelRange( startPos, endPos ) ], isBackward );
 
 
-		// Updated selection attributes according to model.
-		modelSelection._updateAttributes();
+		// Update selection attributes according to model.
+		modelSelection.refreshAttributes();
 
 
 		// And add or remove passed attributes.
 		// And add or remove passed attributes.
 		for ( let key in selectionAttributes ) {
 		for ( let key in selectionAttributes ) {
@@ -518,12 +604,12 @@ describe( 'model-selection-to-view-converters', () => {
 			}
 			}
 		}
 		}
 
 
-		// Remove view children since we do not want to convert deletion.
+		// Remove view children manually (without firing additional conversion).
 		viewRoot.removeChildren( 0, viewRoot.childCount );
 		viewRoot.removeChildren( 0, viewRoot.childCount );
 
 
 		// Convert model to view.
 		// Convert model to view.
 		dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
 		dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
-		dispatcher.convertSelection( modelSelection );
+		dispatcher.convertSelection( modelSelection, [] );
 
 
 		// Stringify view and check if it is same as expected.
 		// Stringify view and check if it is same as expected.
 		expect( stringifyView( viewRoot, viewSelection, { showType: false } ) ).to.equal( '<div>' + expectedView + '</div>' );
 		expect( stringifyView( viewRoot, viewSelection, { showType: false } ) ).to.equal( '<div>' + expectedView + '</div>' );

+ 66 - 21
packages/ckeditor5-engine/tests/conversion/modelconversiondispatcher.js

@@ -447,7 +447,7 @@ describe( 'ModelConversionDispatcher', () => {
 		it( 'should fire selection event', () => {
 		it( 'should fire selection event', () => {
 			sinon.spy( dispatcher, 'fire' );
 			sinon.spy( dispatcher, 'fire' );
 
 
-			dispatcher.convertSelection( doc.selection );
+			dispatcher.convertSelection( doc.selection, [] );
 
 
 			expect( dispatcher.fire.calledWith(
 			expect( dispatcher.fire.calledWith(
 				'selection',
 				'selection',
@@ -468,7 +468,7 @@ describe( 'ModelConversionDispatcher', () => {
 				expect( consumable.test( data.selection, 'selectionAttribute:italic' ) ).to.be.null;
 				expect( consumable.test( data.selection, 'selectionAttribute:italic' ) ).to.be.null;
 			} );
 			} );
 
 
-			dispatcher.convertSelection( doc.selection );
+			dispatcher.convertSelection( doc.selection, [] );
 		} );
 		} );
 
 
 		it( 'should fire attributes events for selection', () => {
 		it( 'should fire attributes events for selection', () => {
@@ -480,7 +480,7 @@ describe( 'ModelConversionDispatcher', () => {
 					.setAttribute( ModelRange.createFromParentsAndOffsets( root, 4, root, 5 ), 'italic', true );
 					.setAttribute( ModelRange.createFromParentsAndOffsets( root, 4, root, 5 ), 'italic', true );
 			} );
 			} );
 
 
-			dispatcher.convertSelection( doc.selection );
+			dispatcher.convertSelection( doc.selection, [] );
 
 
 			expect( dispatcher.fire.calledWith( 'selectionAttribute:bold' ) ).to.be.true;
 			expect( dispatcher.fire.calledWith( 'selectionAttribute:bold' ) ).to.be.true;
 			expect( dispatcher.fire.calledWith( 'selectionAttribute:italic' ) ).to.be.false;
 			expect( dispatcher.fire.calledWith( 'selectionAttribute:italic' ) ).to.be.false;
@@ -499,10 +499,38 @@ describe( 'ModelConversionDispatcher', () => {
 					.setAttribute( ModelRange.createFromParentsAndOffsets( root, 4, root, 5 ), 'italic', true );
 					.setAttribute( ModelRange.createFromParentsAndOffsets( root, 4, root, 5 ), 'italic', true );
 			} );
 			} );
 
 
-			dispatcher.convertSelection( doc.selection );
+			dispatcher.convertSelection( doc.selection, [] );
 
 
 			expect( dispatcher.fire.calledWith( 'selectionAttribute:bold' ) ).to.be.false;
 			expect( dispatcher.fire.calledWith( 'selectionAttribute:bold' ) ).to.be.false;
 		} );
 		} );
+
+		it( 'should fire events for each marker which contains selection', () => {
+			doc.markers.set( 'name', ModelRange.createFromParentsAndOffsets( root, 0, root, 2 ) );
+
+			sinon.spy( dispatcher, 'fire' );
+
+			const markers = Array.from( doc.markers.getMarkersAtPosition( doc.selection.getFirstPosition() ) );
+			dispatcher.convertSelection( doc.selection, markers );
+
+			expect( dispatcher.fire.calledWith( 'selectionMarker:name' ) ).to.be.true;
+		} );
+
+		it( 'should not fire events if information about marker has been consumed', () => {
+			doc.markers.set( 'foo', ModelRange.createFromParentsAndOffsets( root, 0, root, 2 ) );
+			doc.markers.set( 'bar', ModelRange.createFromParentsAndOffsets( root, 0, root, 2 ) );
+
+			sinon.spy( dispatcher, 'fire' );
+
+			dispatcher.on( 'selectionMarker:foo', ( evt, data, consumable ) => {
+				consumable.consume( data.selection, 'selectionMarker:bar' );
+			} );
+
+			const markers = Array.from( doc.markers.getMarkersAtPosition( doc.selection.getFirstPosition() ) );
+			dispatcher.convertSelection( doc.selection, markers );
+
+			expect( dispatcher.fire.calledWith( 'selectionMarker:foo' ) ).to.be.true;
+			expect( dispatcher.fire.calledWith( 'selectionMarker:bar' ) ).to.be.false;
+		} );
 	} );
 	} );
 
 
 	describe( 'convertMarker', () => {
 	describe( 'convertMarker', () => {
@@ -515,36 +543,53 @@ describe( 'ModelConversionDispatcher', () => {
 		it( 'should fire event based on passed parameters', () => {
 		it( 'should fire event based on passed parameters', () => {
 			sinon.spy( dispatcher, 'fire' );
 			sinon.spy( dispatcher, 'fire' );
 
 
-			const data = {
-				name: 'name',
-				range: range
-			};
+			dispatcher.convertMarker( 'addMarker', 'name', range );
+
+			expect( dispatcher.fire.calledWith( 'addMarker:name', 'name', range ) );
+
+			dispatcher.convertMarker( 'removeMarker', 'name', range );
+
+			expect( dispatcher.fire.calledWith( 'removeMarker:name', 'name', range ) );
+		} );
+
+		it( 'should not convert marker if it is added in graveyard', () => {
+			const gyRange = ModelRange.createFromParentsAndOffsets( doc.graveyard, 0, doc.graveyard, 0 );
+			sinon.spy( dispatcher, 'fire' );
+
+			dispatcher.convertMarker( 'addMarker', 'name', gyRange );
+
+			expect( dispatcher.fire.called ).to.be.false;
+
+			dispatcher.convertMarker( 'removeMarker', 'name', gyRange );
 
 
-			dispatcher.convertMarker( 'addMarker', data );
+			expect( dispatcher.fire.called ).to.be.false;
+		} );
+
+		it( 'should not convert marker if it is not in model root', () => {
+			const element = new ModelElement( 'element', null, new ModelText( 'foo' ) );
+			const eleRange = ModelRange.createFromParentsAndOffsets( element, 1, element, 2 );
+			sinon.spy( dispatcher, 'fire' );
 
 
-			expect( dispatcher.fire.calledWith( 'addMarker:name', data ) );
+			dispatcher.convertMarker( 'addMarker', 'name', eleRange );
 
 
-			dispatcher.convertMarker( 'removeMarker', data );
+			expect( dispatcher.fire.called ).to.be.false;
+
+			dispatcher.convertMarker( 'removeMarker', 'name', eleRange );
 
 
-			expect( dispatcher.fire.calledWith( 'removeMarker:name', data ) );
+			expect( dispatcher.fire.called ).to.be.false;
 		} );
 		} );
 
 
 		it( 'should prepare consumable values', () => {
 		it( 'should prepare consumable values', () => {
-			const data = {
-				name: 'name',
-				range: range
-			};
-
 			dispatcher.on( 'addMarker:name', ( evt, data, consumable ) => {
 			dispatcher.on( 'addMarker:name', ( evt, data, consumable ) => {
-				expect( consumable.test( data.range, 'range' ) ).to.be.true;
+				expect( consumable.test( data.range, 'addMarker' ) ).to.be.true;
 			} );
 			} );
 
 
 			dispatcher.on( 'removeMarker:name', ( evt, data, consumable ) => {
 			dispatcher.on( 'removeMarker:name', ( evt, data, consumable ) => {
-				expect( consumable.test( data.range, 'range' ) ).to.be.true;
+				expect( consumable.test( data.range, 'removeMarker' ) ).to.be.true;
 			} );
 			} );
 
 
-			dispatcher.convertMarker( 'addMarker', data );
-			dispatcher.convertMarker( 'removeMarker', data );
+			dispatcher.convertMarker( 'addMarker', 'name', range );
+			dispatcher.convertMarker( 'removeMarker', 'name', range );
 		} );
 		} );
 	} );
 	} );
 } );
 } );

+ 0 - 0
packages/ckeditor5-engine/tests/manual/tickets/643/1.html → packages/ckeditor5-engine/tests/manual/markers.html


+ 6 - 6
packages/ckeditor5-engine/tests/manual/tickets/643/1.js → packages/ckeditor5-engine/tests/manual/markers.js

@@ -15,10 +15,10 @@ import List from '@ckeditor/ckeditor5-list/src/list';
 import Heading from '@ckeditor/ckeditor5-heading/src/heading';
 import Heading from '@ckeditor/ckeditor5-heading/src/heading';
 import Undo from '@ckeditor/ckeditor5-undo/src/undo';
 import Undo from '@ckeditor/ckeditor5-undo/src/undo';
 
 
-import buildModelConverter from '../../../../src/conversion/buildmodelconverter';
-import Position from '../../../../src/model/position';
-import LiveRange from '../../../../src/model/liverange';
-import ViewAttributeElement from '../../../../src/view/attributeelement';
+import buildModelConverter from '../../src/conversion/buildmodelconverter';
+import Position from '../../src/model/position';
+import LiveRange from '../../src/model/liverange';
+import ViewAttributeElement from '../../src/view/attributeelement';
 
 
 let model = null;
 let model = null;
 
 
@@ -51,7 +51,7 @@ ClassicEditor.create( document.querySelector( '#editor' ), {
 		const name = 'highlight:yellow:' + uid();
 		const name = 'highlight:yellow:' + uid();
 
 
 		markerNames.push( name );
 		markerNames.push( name );
-		model.markers.add( name, range );
+		model.markers.set( name, range );
 	} );
 	} );
 } )
 } )
 .catch( err => {
 .catch( err => {
@@ -71,7 +71,7 @@ function addHighlight( color ) {
 		const name = 'highlight:' + color + ':' + uid();
 		const name = 'highlight:' + color + ':' + uid();
 
 
 		markerNames.push( name );
 		markerNames.push( name );
-		model.markers.add( name, range );
+		model.markers.set( name, range );
 	} );
 	} );
 }
 }
 
 

+ 0 - 0
packages/ckeditor5-engine/tests/manual/tickets/643/1.md → packages/ckeditor5-engine/tests/manual/markers.md


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

@@ -0,0 +1,153 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Document from '../../../src/model/document';
+import Range from '../../../src/model/range';
+import Text from '../../../src/model/text';
+import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
+
+import MarkerDelta from '../../../src/model/delta/markerdelta';
+import MarkerOperation from '../../../src/model/operation/markeroperation';
+
+describe( 'Batch', () => {
+	let doc, root, range;
+
+	beforeEach( () => {
+		doc = new Document();
+		root = doc.createRoot();
+		root.appendChildren( new Text( 'foo' ) );
+		range = Range.createIn( root );
+	} );
+
+	describe( 'setMarker', () => {
+		it( 'should add marker to the document marker collection', () => {
+			doc.batch().setMarker( 'name', range );
+
+			expect( doc.markers.get( 'name' ).getRange().isEqual( range ) ).to.be.true;
+		} );
+
+		it( 'should update marker in the document marker collection', () => {
+			doc.batch().setMarker( 'name', range );
+
+			const range2 = Range.createFromParentsAndOffsets( root, 0, root, 0 );
+			doc.batch().setMarker( 'name', range2 );
+
+			expect( doc.markers.get( 'name' ).getRange().isEqual( range2 ) ).to.be.true;
+		} );
+
+		it( 'should accept marker instance', () => {
+			doc.batch().setMarker( 'name', range );
+			const marker = doc.markers.get( 'name' );
+			const range2 = Range.createFromParentsAndOffsets( root, 0, root, 0 );
+
+			doc.batch().setMarker( marker, range2 );
+
+			expect( doc.markers.get( 'name' ).getRange().isEqual( range2 ) ).to.be.true;
+		} );
+
+		it( 'should accept empty range parameter if marker instance is passed', () => {
+			doc.markers.set( 'name', range );
+			const marker = doc.markers.get( 'name' );
+
+			sinon.spy( doc, 'fire' );
+
+			doc.on( 'change', ( evt, type, changes ) => {
+				if ( type == 'marker' ) {
+					expect( changes.type ).to.equal( 'set' );
+					expect( changes.name ).to.equal( 'name' );
+				}
+			} );
+
+			doc.batch().setMarker( marker );
+
+			expect( doc.fire.calledWith( 'change', 'marker' ) ).to.be.true;
+		} );
+
+		it( 'should throw if marker with given name does not exist and range is not passed', () => {
+			expect( () => {
+				doc.batch().setMarker( 'name' );
+			} ).to.throw( CKEditorError, /^batch-setMarker-no-range/ );
+		} );
+	} );
+
+	describe( 'removeMarker', () => {
+		it( 'should remove marker from the document marker collection', () => {
+			doc.batch().setMarker( 'name', range );
+			doc.batch().removeMarker( 'name' );
+
+			expect( doc.markers.get( 'name' ) ).to.be.null;
+		} );
+
+		it( 'should throw when trying to remove non existing marker', () => {
+			expect( () => {
+				doc.batch().removeMarker( 'name' );
+			} ).to.throw( CKEditorError, /^batch-removeMarker-no-marker/ );
+		} );
+
+		it( 'should accept marker instance', () => {
+			doc.batch().setMarker( 'name', range );
+			const marker = doc.markers.get( 'name' );
+
+			doc.batch().removeMarker( marker );
+
+			expect( doc.markers.get( 'name' ) ).to.be.null;
+		} );
+	} );
+
+	it( 'should be chainable', () => {
+		const batch = doc.batch();
+		const chain = batch.setMarker( 'name', range );
+
+		expect( chain ).to.equal( batch );
+	} );
+
+	it( 'should add delta to batch and operation to delta before applying operation', () => {
+		sinon.spy( doc, 'applyOperation' );
+		const batch = doc.batch().setMarker( 'name', range );
+
+		const correctDeltaMatcher = sinon.match( ( operation ) => {
+			return operation.delta && operation.delta.batch && operation.delta.batch == batch;
+		} );
+
+		expect( doc.applyOperation.calledWith( correctDeltaMatcher ) ).to.be.true;
+	} );
+} );
+
+describe( 'MarkerDelta', () => {
+	let markerDelta, doc, root, range;
+
+	beforeEach( () => {
+		doc = new Document();
+		root = doc.createRoot();
+		range = Range.createIn( root );
+		markerDelta = new MarkerDelta();
+	} );
+
+	describe( 'constructor()', () => {
+		it( 'should create merge delta with no operations added', () => {
+			expect( markerDelta.operations.length ).to.equal( 0 );
+		} );
+	} );
+
+	describe( 'getReversed', () => {
+		it( 'should return correct MarkerDelta', () => {
+			markerDelta.addOperation( new MarkerOperation( 'name', null, range, 0 ) );
+			const reversed = markerDelta.getReversed();
+
+			expect( reversed ).to.be.instanceof( MarkerDelta );
+			expect( reversed.operations.length ).to.equal( 1 );
+
+			const op = reversed.operations[ 0 ];
+
+			expect( op ).to.be.an.instanceof( MarkerOperation );
+			expect( op.oldRange.isEqual( range ) ).to.be.true;
+			expect( op.newRange ).to.be.null;
+		} );
+	} );
+
+	it( 'should provide proper className', () => {
+		expect( MarkerDelta.className ).to.equal( 'engine.model.delta.MarkerDelta' );
+	} );
+} );

+ 9 - 0
packages/ckeditor5-engine/tests/model/delta/transform/_utils/utils.js

@@ -15,6 +15,7 @@ import InsertDelta from '../../../../../src/model/delta/insertdelta';
 import WeakInsertDelta from '../../../../../src/model/delta/weakinsertdelta';
 import WeakInsertDelta from '../../../../../src/model/delta/weakinsertdelta';
 import RenameDelta from '../../../../../src/model/delta/renamedelta';
 import RenameDelta from '../../../../../src/model/delta/renamedelta';
 import RemoveDelta from '../../../../../src/model/delta/removedelta';
 import RemoveDelta from '../../../../../src/model/delta/removedelta';
+import MarkerDelta from '../../../../../src/model/delta/markerdelta';
 import MoveDelta from '../../../../../src/model/delta/movedelta';
 import MoveDelta from '../../../../../src/model/delta/movedelta';
 import MergeDelta from '../../../../../src/model/delta/mergedelta';
 import MergeDelta from '../../../../../src/model/delta/mergedelta';
 import SplitDelta from '../../../../../src/model/delta/splitdelta';
 import SplitDelta from '../../../../../src/model/delta/splitdelta';
@@ -23,6 +24,7 @@ import UnwrapDelta from '../../../../../src/model/delta/unwrapdelta';
 
 
 import AttributeOperation from '../../../../../src/model/operation/attributeoperation';
 import AttributeOperation from '../../../../../src/model/operation/attributeoperation';
 import InsertOperation from '../../../../../src/model/operation/insertoperation';
 import InsertOperation from '../../../../../src/model/operation/insertoperation';
+import MarkerOperation from '../../../../../src/model/operation/markeroperation';
 import MoveOperation from '../../../../../src/model/operation/moveoperation';
 import MoveOperation from '../../../../../src/model/operation/moveoperation';
 import RemoveOperation from '../../../../../src/model/operation/removeoperation';
 import RemoveOperation from '../../../../../src/model/operation/removeoperation';
 import RenameOperation from '../../../../../src/model/operation/renameoperation';
 import RenameOperation from '../../../../../src/model/operation/renameoperation';
@@ -48,6 +50,13 @@ export function getWeakInsertDelta( position, nodes, version ) {
 	return delta;
 	return delta;
 }
 }
 
 
+export function getMarkerDelta( name, oldRange, newRange, version ) {
+	let delta = new MarkerDelta();
+	delta.addOperation( new MarkerOperation( name, oldRange, newRange, version ) );
+
+	return delta;
+}
+
 export function getMergeDelta( position, howManyInPrev, howManyInNext, version ) {
 export function getMergeDelta( position, howManyInPrev, howManyInNext, version ) {
 	let delta = new MergeDelta();
 	let delta = new MergeDelta();
 
 

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

@@ -0,0 +1,97 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import transformations from '../../../../src/model/delta/basic-transformations';
+/*jshint unused: false*/
+
+import transform from '../../../../src/model/delta/transform';
+
+import Element from '../../../../src/model/element';
+import Position from '../../../../src/model/position';
+import Range from '../../../../src/model/range';
+
+import MarkerDelta from '../../../../src/model/delta/markerdelta';
+import MarkerOperation from '../../../../src/model/operation/markeroperation';
+
+import {
+	expectDelta,
+	getFilledDocument,
+	getMarkerDelta,
+	getSplitDelta
+} from '../../../model/delta/transform/_utils/utils';
+
+describe( 'transform', () => {
+	let doc, root, gy, baseVersion;
+
+	beforeEach( () => {
+		doc = getFilledDocument();
+		root = doc.getRoot();
+		gy = doc.graveyard;
+		baseVersion = doc.version;
+	} );
+
+	describe( 'MarkerDelta by', () => {
+		let markerDelta;
+
+		beforeEach( () => {
+			const oldRange = new Range( new Position( root, [ 3, 0 ] ), new Position( root, [ 3, 3 ] ) );
+			const newRange = new Range( new Position( root, [ 3, 3, 3, 2 ] ), new Position( root, [ 3, 3, 3, 6 ] ) );
+
+			markerDelta = getMarkerDelta( 'name', oldRange, newRange, baseVersion );
+		} );
+
+		describe( 'SplitDelta', () => {
+			it( 'split inside oldRange', () => {
+				let splitDelta = getSplitDelta( new Position( root, [ 3, 1 ] ), new Element( 'div' ), 3, baseVersion );
+				let transformed = transform( markerDelta, splitDelta );
+
+				baseVersion = splitDelta.operations.length;
+
+				expect( transformed.length ).to.equal( 1 );
+
+				const expectedOldRange = new Range( new Position( root, [ 3, 0 ] ), new Position( root, [ 4, 2 ] ) );
+				const expectedNewRange = new Range( new Position( root, [ 4, 2, 3, 2 ] ), new Position( root, [ 4, 2, 3, 6 ] ) );
+
+				expectDelta( transformed[ 0 ], {
+					type: MarkerDelta,
+					operations: [
+						{
+							type: MarkerOperation,
+							name: 'name',
+							oldRange: expectedOldRange,
+							newRange: expectedNewRange,
+							baseVersion: baseVersion
+						}
+					]
+				} );
+			} );
+
+			it( 'split inside newRange', () => {
+				let splitDelta = getSplitDelta( new Position( root, [ 3, 3, 3, 4 ] ), new Element( 'p' ), 8, baseVersion );
+				let transformed = transform( markerDelta, splitDelta );
+
+				baseVersion = splitDelta.operations.length;
+
+				expect( transformed.length ).to.equal( 1 );
+
+				const expectedOldRange = new Range( new Position( root, [ 3, 0 ] ), new Position( root, [ 3, 3 ] ) );
+				const expectedNewRange = new Range( new Position( root, [ 3, 3, 3, 2 ] ), new Position( root, [ 3, 3, 4, 2 ] ) );
+
+				expectDelta( transformed[ 0 ], {
+					type: MarkerDelta,
+					operations: [
+						{
+							type: MarkerOperation,
+							name: 'name',
+							oldRange: expectedOldRange,
+							newRange: expectedNewRange,
+							baseVersion: baseVersion
+						}
+					]
+				} );
+			} );
+		} );
+	} );
+} );

+ 0 - 1
packages/ckeditor5-engine/tests/model/delta/transform/transform.js

@@ -12,7 +12,6 @@ import Document from '../../../../src/model/document';
 import Element from '../../../../src/model/element';
 import Element from '../../../../src/model/element';
 import Text from '../../../../src/model/text';
 import Text from '../../../../src/model/text';
 import Position from '../../../../src/model/position';
 import Position from '../../../../src/model/position';
-import Range from '../../../../src/model/range';
 
 
 import Delta from '../../../../src/model/delta/delta';
 import Delta from '../../../../src/model/delta/delta';
 import InsertDelta from '../../../../src/model/delta/insertdelta';
 import InsertDelta from '../../../../src/model/delta/insertdelta';

+ 248 - 0
packages/ckeditor5-engine/tests/model/markercollection.js

@@ -0,0 +1,248 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import MarkerCollection from '../../src/model/markercollection';
+import Position from '../../src/model/position';
+import Range from '../../src/model/range';
+import Text from '../../src/model/text';
+import Document from '../../src/model/document';
+import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
+
+describe( 'MarkerCollection', () => {
+	let markers, range, range2, doc, root;
+
+	beforeEach( () => {
+		doc = new Document();
+		markers = new MarkerCollection();
+
+		root = doc.createRoot();
+		range = Range.createFromParentsAndOffsets( root, 0, root, 1 );
+		range2 = Range.createFromParentsAndOffsets( root, 0, root, 2 );
+	} );
+
+	describe( 'iterator', () => {
+		it( 'should return markers added to the marker collection', () => {
+			markers.set( 'a', range );
+			markers.set( 'b', range );
+
+			const markerA = markers.get( 'a' );
+			const markerB = markers.get( 'b' );
+
+			const markersArray = Array.from( markers );
+
+			expect( markersArray.includes( markerA ) ).to.be.true;
+			expect( markersArray.includes( markerB ) ).to.be.true;
+			expect( markersArray.length ).to.equal( 2 );
+		} );
+	} );
+
+	describe( 'set', () => {
+		it( 'should create a marker, fire add event and return true', () => {
+			sinon.spy( markers, 'fire' );
+
+			const result = markers.set( 'name', range );
+			const marker = markers.get( 'name' );
+
+			expect( result ).to.equal( marker );
+			expect( marker.name ).to.equal( 'name' );
+			expect( marker.getRange().isEqual( range ) ).to.be.true;
+			expect( markers.fire.calledWithExactly( 'add', marker ) ).to.be.true;
+		} );
+
+		it( 'should fire remove event, and create a new marker if marker with given name was in the collection', () => {
+			markers.set( 'name', range );
+			const marker1 = markers.get( 'name' );
+
+			sinon.spy( markers, 'fire' );
+
+			const result = markers.set( 'name', range2 );
+			const marker2 = markers.get( 'name' );
+
+			expect( result ).to.equal( marker2 );
+			expect( markers.fire.calledWithExactly( 'remove', marker1 ) ).to.be.true;
+			expect( markers.fire.calledWithExactly( 'add', marker2 ) ).to.be.true;
+
+			expect( marker2.name ).to.equal( 'name' );
+			expect( marker2.getRange().isEqual( range2 ) ).to.be.true;
+
+			expect( marker1 ).not.to.equal( marker2 );
+		} );
+
+		it( 'should accept marker instance instead of name', () => {
+			markers.set( 'name', range );
+			const marker1 = markers.get( 'name' );
+
+			const result = markers.set( marker1, range2 );
+			const marker2 = markers.get( 'name' );
+
+			expect( result ).to.equal( marker2 );
+			expect( marker2.getRange().isEqual( range2 ) );
+			expect( marker1 ).not.to.equal( marker2 );
+		} );
+	} );
+
+	describe( 'has', () => {
+		it( 'should return false if marker with given name is not in the collection', () => {
+			expect( markers.has( 'name' ) ).to.be.false;
+		} );
+
+		it( 'should return true if marker with given name is in the collection', () => {
+			markers.set( 'name', range );
+			expect( markers.has( 'name' ) ).to.be.true;
+		} );
+	} );
+
+	describe( 'get', () => {
+		it( 'should return null if marker with given name has not been found', () => {
+			expect( markers.get( 'name' ) ).to.be.null;
+		} );
+
+		it( 'should always return same instance of marker', () => {
+			expect( markers.get( 'name' ) ).to.equal( markers.get( 'name' ) );
+		} );
+	} );
+
+	describe( 'remove', () => {
+		it( 'should remove marker, return true and fire remove event', () => {
+			const marker = markers.set( 'name', range );
+
+			sinon.spy( markers, 'fire' );
+
+			const result = markers.remove( 'name' );
+
+			expect( result ).to.be.true;
+			expect( markers.fire.calledWithExactly( 'remove', marker ) ).to.be.true;
+			expect( markers.get( 'name' ) ).to.be.null;
+		} );
+
+		it( 'should destroy marker instance', () => {
+			const marker = markers.set( 'name', range );
+			const liveRange = marker._liveRange;
+
+			sinon.spy( marker, 'stopListening' );
+			sinon.spy( liveRange, 'detach' );
+
+			markers.remove( 'name' );
+
+			expect( marker.stopListening.calledOnce ).to.be.true;
+			expect( marker._liveRange ).to.be.null;
+			expect( liveRange.detach.calledOnce ).to.be.true;
+		} );
+
+		it( 'should return false if name has not been found in collection', () => {
+			markers.set( 'name', range );
+
+			sinon.spy( markers, 'fire' );
+
+			const result = markers.remove( 'other' );
+
+			expect( result ).to.be.false;
+			expect( markers.fire.notCalled ).to.be.true;
+		} );
+
+		it( 'should accept marker instance instead of name', () => {
+			const marker = markers.set( 'name', range );
+
+			sinon.spy( markers, 'fire' );
+
+			const result = markers.remove( marker );
+
+			expect( result ).to.be.true;
+			expect( markers.fire.calledWithExactly( 'remove', marker ) ).to.be.true;
+			expect( markers.get( 'name' ) ).to.be.null;
+		} );
+	} );
+
+	describe( 'getMarkersGroup', () => {
+		it( 'returns all markers which names start on given prefix', () => {
+			const markerFooA = markers.set( 'foo:a', range );
+			const markerFooB = markers.set( 'foo:b', range );
+			markers.set( 'bar:a', range );
+			markers.set( 'foobar:a', range );
+
+			expect( Array.from( markers.getMarkersGroup( 'foo' ) ) ).to.deep.equal( [ markerFooA, markerFooB ] );
+			expect( Array.from( markers.getMarkersGroup( 'a' ) ) ).to.deep.equal( [] );
+		} );
+	} );
+
+	describe( 'getMarkersAtPosition', () => {
+		it( 'should return iterator iterating over all markers that contains given position', () => {
+			markers.set( 'a', range );
+			const markerB = markers.set( 'b', range2 );
+
+			const result = Array.from( markers.getMarkersAtPosition( Position.createAt( root, 1 ) ) );
+
+			expect( result ).to.deep.equal( [ markerB ] );
+		} );
+	} );
+
+	describe( 'destroy', () => {
+		it( 'should make MarkerCollection stop listening to all events and destroy all markers', () => {
+			const markerA = markers.set( 'a', range );
+			const markerB = markers.set( 'b', range2 );
+
+			sinon.spy( markers, 'stopListening' );
+			sinon.spy( markerA, 'stopListening' );
+			sinon.spy( markerB, 'stopListening' );
+
+			markers.destroy();
+
+			expect( markers.stopListening.calledWithExactly() ).to.be.true;
+			expect( markerA.stopListening.calledWithExactly() ).to.be.true;
+			expect( markerB.stopListening.calledWithExactly() ).to.be.true;
+			expect( markerA._liveRange ).to.be.null;
+			expect( markerB._liveRange ).to.be.null;
+		} );
+	} );
+} );
+
+describe( 'Marker', () => {
+	let doc, root;
+
+	beforeEach( () => {
+		doc = new Document();
+		root = doc.createRoot();
+	} );
+
+	it( 'should provide API that returns up-to-date marker range parameters', () => {
+		root.appendChildren( new Text( 'foo' ) );
+
+		const range = Range.createFromParentsAndOffsets( root, 1, root, 2 );
+		const marker = doc.markers.set( 'name', range );
+
+		expect( marker.getRange().isEqual( range ) ).to.be.true;
+		expect( marker.getStart().isEqual( range.start ) ).to.be.true;
+		expect( marker.getEnd().isEqual( range.end ) ).to.be.true;
+
+		doc.enqueueChanges( () => {
+			doc.batch().insert( Position.createAt( root, 0 ), 'abc' );
+		} );
+
+		const updatedRange = Range.createFromParentsAndOffsets( root, 4, root, 5 );
+
+		expect( marker.getRange().isEqual( updatedRange ) ).to.be.true;
+		expect( marker.getStart().isEqual( updatedRange.start ) ).to.be.true;
+		expect( marker.getEnd().isEqual( updatedRange.end ) ).to.be.true;
+	} );
+
+	it( 'should throw when using the API if marker was removed from markers collection', () => {
+		const range = Range.createFromParentsAndOffsets( root, 1, root, 2 );
+		const marker = doc.markers.set( 'name', range );
+
+		doc.markers.remove( 'name' );
+
+		expect( () => {
+			marker.getRange();
+		} ).to.throw( CKEditorError, /^marker-destroyed/ );
+
+		expect( () => {
+			marker.getStart();
+		} ).to.throw( CKEditorError, /^marker-destroyed/ );
+
+		expect( () => {
+			marker.getEnd();
+		} ).to.throw( CKEditorError, /^marker-destroyed/ );
+	} );
+} );

+ 0 - 145
packages/ckeditor5-engine/tests/model/markerscollection.js

@@ -1,145 +0,0 @@
-/**
- * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
-
-import MarkersCollection from '../../src/model/markerscollection';
-import Range from '../../src/model/range';
-import LiveRange from '../../src/model/liverange';
-import Document from '../../src/model/document';
-import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
-
-describe( 'MarkersCollection', () => {
-	let markers, live, doc, root;
-
-	beforeEach( () => {
-		doc = new Document();
-		markers = new MarkersCollection();
-
-		root = doc.createRoot();
-		live = LiveRange.createFromParentsAndOffsets( root, 0, root, 4 );
-	} );
-
-	afterEach( () => {
-		markers.destroy();
-		live.detach();
-	} );
-
-	describe( 'add', () => {
-		it( 'should throw if passed parameter is not a LiveRange', () => {
-			const range = Range.createFromParentsAndOffsets( root, 1, root, 3 );
-
-			expect( () => {
-				markers.add( 'name', range );
-			} ).to.throw( CKEditorError, /^markers-collection-add-range-not-live-range/ );
-		} );
-
-		it( 'should fire add event when range is added', () => {
-			sinon.spy( markers, 'fire' );
-
-			markers.on( 'add', ( evt, name, range ) => {
-				expect( name ).to.equal( 'name' );
-				expect( range.isEqual( live ) ).to.be.true;
-				expect( range ).not.to.equal( live );
-			} );
-
-			markers.add( 'name', live );
-
-			expect( markers.fire.calledWith( 'add' ) ).to.be.true;
-		} );
-
-		it( 'should throw if given name was already added', () => {
-			const other = LiveRange.createFromParentsAndOffsets( root, 0, root, 4 );
-			markers.add( 'name', other );
-
-			other.detach();
-
-			expect( () => {
-				markers.add( 'name', live );
-			} ).to.throw( CKEditorError, /^markers-collection-add-name-exists/ );
-		} );
-	} );
-
-	describe( 'get', () => {
-		it( 'should return range added to the collection with given name', () => {
-			markers.add( 'name', live );
-
-			expect( markers.get( 'name' ) ).to.equal( live );
-		} );
-
-		it( 'should return null if range with given name has not been found', () => {
-			expect( markers.get( 'name' ) ).to.be.null;
-		} );
-	} );
-
-	describe( 'remove', () => {
-		it( 'should return true and fire remove event if range is removed', () => {
-			markers.add( 'name', live );
-
-			sinon.spy( markers, 'fire' );
-
-			markers.on( 'remove', ( evt, name, range ) => {
-				expect( name ).to.equal( 'name' );
-				expect( range.isEqual( live ) ).to.be.true;
-				expect( range ).not.to.equal( live );
-			} );
-
-			const result = markers.remove( 'name' );
-
-			expect( result ).to.be.true;
-			expect( markers.fire.calledWith( 'remove' ) ).to.be.true;
-		} );
-
-		it( 'should return false if name has not been found in collection', () => {
-			markers.add( 'name', live );
-
-			const result = markers.remove( 'other' );
-
-			expect( result ).to.be.false;
-		} );
-	} );
-
-	describe( 'update', () => {
-		let newLive;
-
-		beforeEach( () => {
-			newLive = LiveRange.createFromParentsAndOffsets( root, 1, root, 5 );
-		} );
-
-		afterEach( () => {
-			newLive.detach();
-		} );
-
-		it( 'should return true and use remove and add methods if range was found in collection', () => {
-			const newLive = LiveRange.createFromParentsAndOffsets( root, 1, root, 5 );
-			markers.add( 'name', live );
-
-			sinon.spy( markers, 'remove' );
-			sinon.spy( markers, 'add' );
-
-			const result = markers.update( 'name', newLive );
-
-			expect( markers.remove.calledWith( 'name' ) ).to.be.true;
-			expect( markers.add.calledWith( 'name', newLive ) ).to.be.true;
-			expect( result ).to.be.true;
-
-			newLive.detach();
-		} );
-
-		it( 'should return false if given name was not found in collection', () => {
-			const result = markers.update( 'name', newLive );
-
-			expect( result ).to.be.false;
-		} );
-	} );
-
-	describe( 'destroy', () => {
-		it( 'should make MarkersCollection stop listening to all events', () => {
-			sinon.spy( markers, 'stopListening' );
-
-			markers.destroy();
-
-			expect( markers.stopListening.calledWithExactly() ).to.be.true;
-		} );
-	} );
-} );

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

@@ -0,0 +1,227 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Document from '../../../src/model/document';
+import Text from '../../../src/model/text';
+import DocumentFragment from '../../../src/model/documentfragment';
+import Range from '../../../src/model/range';
+import MarkerOperation from '../../../src/model/operation/markeroperation';
+import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
+import { jsonParseStringify, wrapInDelta } from '../../model/_utils/utils';
+
+function matchRange( range ) {
+	return sinon.match( ( rangeToMatch ) => rangeToMatch.isEqual( range ) );
+}
+
+describe( 'MarkerOperation', () => {
+	let doc, root, range;
+
+	beforeEach( () => {
+		doc = new Document();
+		root = doc.createRoot();
+		root.appendChildren( new Text( 'foo' ) );
+		range = Range.createFromParentsAndOffsets( root, 0, root, 0 );
+	} );
+
+	it( 'should have property type equal to "marker"', () => {
+		const op = new MarkerOperation( 'name', null, range, 0 );
+		expect( op.type ).to.equal( 'marker' );
+	} );
+
+	it( 'should add marker to document marker collection', () => {
+		sinon.spy( doc.markers, 'set' );
+		sinon.spy( doc, 'fire' );
+
+		doc.on( 'change', ( evt, type, changes ) => {
+			expect( type ).to.equal( 'marker' );
+			expect( changes.name ).to.equal( 'name' );
+			expect( changes.type ).to.equal( 'set' );
+		} );
+
+		doc.applyOperation( wrapInDelta(
+			new MarkerOperation( 'name', null, range, doc.version )
+		) );
+
+		expect( doc.version ).to.equal( 1 );
+		expect( doc.markers.set.calledWith( 'name', matchRange( range ) ) );
+		expect( doc.markers.get( 'name' ).getRange().isEqual( range ) ).to.be.true;
+		expect( doc.fire.called ).to.be.true;
+	} );
+
+	it( 'should update marker in document marker collection', () => {
+		doc.applyOperation( wrapInDelta(
+			new MarkerOperation( 'name', null, range, doc.version )
+		) );
+
+		const range2 = Range.createFromParentsAndOffsets( root, 0, root, 3 );
+
+		sinon.spy( doc.markers, 'set' );
+		sinon.spy( doc, 'fire' );
+
+		doc.applyOperation( wrapInDelta(
+			new MarkerOperation( 'name', range, range2, doc.version )
+		) );
+
+		expect( doc.version ).to.equal( 2 );
+		expect( doc.markers.set.calledWith( 'name', matchRange( range2 ) ) );
+		expect( doc.markers.get( 'name' ).getRange().isEqual( range2 ) ).to.be.true;
+		expect( doc.fire.called ).to.be.true;
+	} );
+
+	it( 'should remove marker from document marker collection', () => {
+		doc.applyOperation( wrapInDelta(
+			new MarkerOperation( 'name', null, range, doc.version )
+		) );
+
+		sinon.spy( doc.markers, 'remove' );
+		sinon.spy( doc, 'fire' );
+
+		doc.on( 'change', ( evt, type, changes ) => {
+			expect( type ).to.equal( 'marker' );
+			expect( changes.name ).to.equal( 'name' );
+			expect( changes.type ).to.equal( 'remove' );
+		} );
+
+		doc.applyOperation( wrapInDelta(
+			new MarkerOperation( 'name', range, null, doc.version )
+		) );
+
+		expect( doc.version ).to.equal( 2 );
+		expect( doc.markers.remove.calledWith( 'name' ) );
+		expect( doc.markers.get( 'name' ) ).to.be.null;
+		expect( doc.fire.called ).to.be.true;
+	} );
+
+	it( 'should fire document change event but not document markers remove event if oldRange and newRange are null', () => {
+		sinon.spy( doc, 'fire' );
+		sinon.spy( doc.markers, 'fire' );
+
+		doc.on( 'change', ( evt, type, changes ) => {
+			expect( type ).to.equal( 'marker' );
+			expect( changes.name ).to.equal( 'name' );
+			expect( changes.type ).to.equal( 'remove' );
+		} );
+
+		doc.applyOperation( wrapInDelta(
+			new MarkerOperation( 'name', null, null, doc.version )
+		) );
+
+		expect( doc.fire.calledWith( 'change', 'marker' ) ).to.be.true;
+		expect( doc.markers.fire.notCalled ).to.be.true;
+	} );
+
+	it( 'should fire document change event but not document markers remove event if oldRange and newRange are equal', () => {
+		sinon.spy( doc, 'fire' );
+		sinon.spy( doc.markers, 'fire' );
+
+		doc.on( 'change', ( evt, type, changes ) => {
+			expect( type ).to.equal( 'marker' );
+			expect( changes.name ).to.equal( 'name' );
+			expect( changes.type ).to.equal( 'set' );
+		} );
+
+		doc.applyOperation( wrapInDelta(
+			new MarkerOperation( 'name', range, range, doc.version )
+		) );
+
+		expect( doc.fire.calledWith( 'change', 'marker' ) ).to.be.true;
+		expect( doc.markers.fire.notCalled ).to.be.true;
+	} );
+
+	it( 'should return MarkerOperation with swapped ranges as reverse operation', () => {
+		const range2 = Range.createFromParentsAndOffsets( root, 0, root, 3 );
+
+		const op1 = new MarkerOperation( 'name', null, range, doc.version );
+		const reversed1 = op1.getReversed();
+
+		const op2 = new MarkerOperation( 'name', range, range2, doc.version );
+		const reversed2 = op2.getReversed();
+
+		expect( reversed1 ).to.be.an.instanceof( MarkerOperation );
+		expect( reversed2 ).to.be.an.instanceof( MarkerOperation );
+
+		expect( reversed1.name ).to.equal( 'name' );
+		expect( reversed1.oldRange.isEqual( range ) ).to.be.true;
+		expect( reversed1.newRange ).to.be.null;
+		expect( reversed1.baseVersion ).to.equal( 1 );
+
+		expect( reversed2.name ).to.equal( 'name' );
+		expect( reversed2.oldRange.isEqual( range2 ) ).to.be.true;
+		expect( reversed2.newRange.isEqual( range ) ).to.be.true;
+		expect( reversed2.baseVersion ).to.equal( 1 );
+	} );
+
+	it( 'should create a MarkerOperation with the same parameters when cloned', () => {
+		const op = new MarkerOperation( 'name', null, range, 0 );
+		const clone = op.clone();
+
+		expect( clone ).to.be.an.instanceof( MarkerOperation );
+		expect( clone ).to.deep.equal( op );
+	} );
+
+	it( 'should throw if oldRange is not in a document', () => {
+		const docFrag = new DocumentFragment();
+		const rangeInDocFrag = Range.createIn( docFrag );
+
+		expect( () => {
+			new MarkerOperation( 'name', rangeInDocFrag, null, 0 );
+		} ).to.throw( CKEditorError, /^marker-operation-range-not-in-document/ );
+	} );
+
+	it( 'should throw if newRange is not in a document', () => {
+		const docFrag = new DocumentFragment();
+		const rangeInDocFrag = Range.createIn( docFrag );
+
+		expect( () => {
+			new MarkerOperation( 'name', null, rangeInDocFrag, 0 );
+		} ).to.throw( CKEditorError, /^marker-operation-range-not-in-document/ );
+	} );
+
+	it( 'should throw if ranges are in different documents', () => {
+		const document2 = new Document();
+		const root2 = document2.createRoot();
+		const rangeInRoot2 = Range.createIn( root2 );
+
+		expect( () => {
+			new MarkerOperation( 'name', range, rangeInRoot2, 0 );
+		} ).to.throw( CKEditorError, /^marker-operation-ranges-in-different-documents/ );
+	} );
+
+	describe( 'toJSON', () => {
+		it( 'should create proper serialized object', () => {
+			const op = new MarkerOperation( 'name', null, range, doc.version );
+			const serialized = jsonParseStringify( op );
+
+			expect( serialized ).to.deep.equal( {
+				__className: 'engine.model.operation.MarkerOperation',
+				baseVersion: 0,
+				name: 'name',
+				oldRange: null,
+				newRange: jsonParseStringify( range )
+			} );
+		} );
+	} );
+
+	describe( 'fromJSON', () => {
+		it( 'should create proper MarkerOperation from json object #1', () => {
+			const op = new MarkerOperation( 'name', null, range, doc.version );
+
+			const serialized = jsonParseStringify( op );
+			const deserialized = MarkerOperation.fromJSON( serialized, doc );
+
+			expect( deserialized ).to.deep.equal( op );
+		} );
+
+		it( 'should create proper MarkerOperation from json object #2', () => {
+			// Gotta love 100% CC.
+			const op = new MarkerOperation( 'name', range, null, doc.version );
+
+			const serialized = jsonParseStringify( op );
+			const deserialized = MarkerOperation.fromJSON( serialized, doc );
+
+			expect( deserialized ).to.deep.equal( op );
+		} );
+	} );
+} );

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

@@ -14,6 +14,7 @@ import Range from '../../../src/model/range';
 import InsertOperation from '../../../src/model/operation/insertoperation';
 import InsertOperation from '../../../src/model/operation/insertoperation';
 import AttributeOperation from '../../../src/model/operation/attributeoperation';
 import AttributeOperation from '../../../src/model/operation/attributeoperation';
 import RootAttributeOperation from '../../../src/model/operation/rootattributeoperation';
 import RootAttributeOperation from '../../../src/model/operation/rootattributeoperation';
+import MarkerOperation from '../../../src/model/operation/markeroperation';
 import MoveOperation from '../../../src/model/operation/moveoperation';
 import MoveOperation from '../../../src/model/operation/moveoperation';
 import RemoveOperation from '../../../src/model/operation/removeoperation';
 import RemoveOperation from '../../../src/model/operation/removeoperation';
 import RenameOperation from '../../../src/model/operation/renameoperation';
 import RenameOperation from '../../../src/model/operation/renameoperation';
@@ -413,6 +414,18 @@ describe( 'transform', () => {
 				expectOperation( transOp[ 0 ], expected );
 				expectOperation( transOp[ 0 ], expected );
 			} );
 			} );
 		} );
 		} );
+
+		describe( 'by MarkerOperation', () => {
+			it( 'no position update', () => {
+				const newRange = new Range( new Position( root, [ 0, 2, 0 ] ), new Position( root, [ 0, 2, 4 ] ) );
+				let transformBy = new MarkerOperation( 'name', null, newRange, baseVersion );
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+		} );
 	} );
 	} );
 
 
 	describe( 'AttributeOperation', () => {
 	describe( 'AttributeOperation', () => {
@@ -1125,6 +1138,18 @@ describe( 'transform', () => {
 					expectOperation( transOp[ 0 ], expected );
 					expectOperation( transOp[ 0 ], expected );
 				} );
 				} );
 			} );
 			} );
+
+			describe( 'by MarkerOperation', () => {
+				it( 'no operation update', () => {
+					const newRange = new Range( new Position( root, [ 0, 2, 0 ] ), new Position( root, [ 0, 2, 8 ] ) );
+					let transformBy = new MarkerOperation( 'name', null, newRange, baseVersion );
+
+					let transOp = transform( op, transformBy );
+
+					expect( transOp.length ).to.equal( 1 );
+					expectOperation( transOp[ 0 ], expected );
+				} );
+			} );
 		} );
 		} );
 
 
 		// Some extra cases for a AttributeOperation that operates on single tree level range.
 		// Some extra cases for a AttributeOperation that operates on single tree level range.
@@ -1591,6 +1616,18 @@ describe( 'transform', () => {
 				expectOperation( transOp[ 0 ], expected );
 				expectOperation( transOp[ 0 ], expected );
 			} );
 			} );
 		} );
 		} );
+
+		describe( 'by MarkerOperation', () => {
+			it( 'no position update', () => {
+				const newRange = new Range( new Position( root, [ 0, 2, 0 ] ), new Position( root, [ 0, 2, 8 ] ) );
+				let transformBy = new MarkerOperation( 'name', null, newRange, baseVersion );
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+		} );
 	} );
 	} );
 
 
 	describe( 'MoveOperation', () => {
 	describe( 'MoveOperation', () => {
@@ -2705,6 +2742,18 @@ describe( 'transform', () => {
 				expectOperation( transOp[ 0 ], expected );
 				expectOperation( transOp[ 0 ], expected );
 			} );
 			} );
 		} );
 		} );
+
+		describe( 'by MarkerOperation', () => {
+			it( 'no position update', () => {
+				const newRange = new Range( new Position( root, [ 2, 2, 3 ] ), new Position( root, [ 2, 2, 8 ] ) );
+				let transformBy = new MarkerOperation( 'name', null, newRange, baseVersion );
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+		} );
 	} );
 	} );
 
 
 	describe( 'RemoveOperation', () => {
 	describe( 'RemoveOperation', () => {
@@ -2844,6 +2893,18 @@ describe( 'transform', () => {
 				expectOperation( transOp[ 0 ], expected );
 				expectOperation( transOp[ 0 ], expected );
 			} );
 			} );
 		} );
 		} );
+
+		describe( 'by MarkerOperation', () => {
+			it( 'no position update', () => {
+				const newRange = new Range( new Position( root, [ 0, 2, 0 ] ), new Position( root, [ 0, 2, 8 ] ) );
+				let transformBy = new MarkerOperation( 'name', null, newRange, baseVersion );
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+		} );
 	} );
 	} );
 
 
 	describe( 'RenameOperation', () => {
 	describe( 'RenameOperation', () => {
@@ -2957,6 +3018,18 @@ describe( 'transform', () => {
 			} );
 			} );
 		} );
 		} );
 
 
+		describe( 'by MarkerOperation', () => {
+			it( 'no operation update', () => {
+				const newRange = new Range( new Position( root, [ 0, 2, 0 ] ), new Position( root, [ 0, 2, 8 ] ) );
+				let transformBy = new MarkerOperation( 'name', null, newRange, baseVersion );
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+		} );
+
 		describe( 'by RenameOperation', () => {
 		describe( 'by RenameOperation', () => {
 			it( 'different element: no change', () => {
 			it( 'different element: no change', () => {
 				let transformBy = new RenameOperation(
 				let transformBy = new RenameOperation(
@@ -3110,4 +3183,195 @@ describe( 'transform', () => {
 			} );
 			} );
 		} );
 		} );
 	} );
 	} );
+
+	describe( 'MarkerOperation', () => {
+		let oldRange, newRange;
+
+		beforeEach( () => {
+			oldRange = Range.createFromParentsAndOffsets( root, 1, root, 4 );
+			newRange = Range.createFromParentsAndOffsets( root, 10, root, 12 );
+			op = new MarkerOperation( 'name', oldRange, newRange, baseVersion );
+
+			expected = {
+				name: 'name',
+				oldRange: oldRange,
+				newRange: newRange,
+				baseVersion: baseVersion + 1
+			};
+		} );
+
+		describe( 'by InsertOperation', () => {
+			it( 'insert position affecting oldRange: update oldRange', () => {
+				// Just CC things.
+				op.newRange = null;
+				let transformBy = new InsertOperation( Position.createAt( root, 0 ), [ nodeA, nodeB ], baseVersion );
+
+				let transOp = transform( op, transformBy );
+
+				expected.newRange = null;
+				expected.oldRange.start.offset = 3;
+				expected.oldRange.end.offset = 6;
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'insert position affecting newRange: update newRange', () => {
+				// Just CC things.
+				op.oldRange = null;
+				let transformBy = new InsertOperation( Position.createAt( root, 8 ), [ nodeA, nodeB ], baseVersion );
+
+				let transOp = transform( op, transformBy );
+
+				expected.oldRange = null;
+				expected.newRange.start.offset = 12;
+				expected.newRange.end.offset = 14;
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+		} );
+
+		describe( 'by AttributeOperation', () => {
+			it( 'no operation update', () => {
+				let transformBy = new AttributeOperation(
+					new Range(
+						new Position( root, [ 2 ] ),
+						new Position( root, [ 11 ] )
+					),
+					'foo',
+					'bar',
+					'xyz',
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+		} );
+
+		describe( 'by MoveOperation', () => {
+			it( 'moved range is before oldRange: update oldRange', () => {
+				// Just CC things.
+				op.newRange = null;
+
+				let transformBy = new MoveOperation( Position.createAt( root, 0 ), 1, Position.createAt( root, 20 ), baseVersion );
+				let transOp = transform( op, transformBy );
+
+				expected.newRange = null;
+				expected.oldRange.start.offset = 0;
+				expected.oldRange.end.offset = 3;
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'moved range contains oldRange and is before newRange: update oldRange and newRange', () => {
+				let transformBy = new MoveOperation( Position.createAt( root, 2 ), 2, Position.createAt( root, 20 ), baseVersion );
+				let transOp = transform( op, transformBy );
+
+				expected.oldRange.start.offset = 1;
+				expected.oldRange.end.offset = 2;
+				expected.newRange.start.offset = 8;
+				expected.newRange.end.offset = 10;
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'target position is inside newRange: update newRange', () => {
+				// Just CC things.
+				op.oldRange = null;
+
+				let transformBy = new MoveOperation( Position.createAt( root, 20 ), 2, Position.createAt( root, 11 ), baseVersion );
+				let transOp = transform( op, transformBy );
+
+				expected.oldRange = null;
+				expected.newRange.start.offset = 10;
+				expected.newRange.end.offset = 14;
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'target position is inside oldRange and before newRange: update oldRange and newRange', () => {
+				let transformBy = new MoveOperation( Position.createAt( root, 20 ), 4, Position.createAt( root, 2 ), baseVersion );
+				let transOp = transform( op, transformBy );
+
+				expected.oldRange.start.offset = 1;
+				expected.oldRange.end.offset = 8;
+				expected.newRange.start.offset = 14;
+				expected.newRange.end.offset = 16;
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+		} );
+
+		describe( 'by RootAttributeOperation', () => {
+			it( 'no operation update', () => {
+				let transformBy = new RootAttributeOperation(
+					root,
+					'foo',
+					null,
+					'bar',
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+		} );
+
+		describe( 'by RenameOperation', () => {
+			it( 'no operation update', () => {
+				let transformBy = new RenameOperation( new Position( root, [ 1 ] ), 'oldName', 'newName', baseVersion );
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+		} );
+
+		describe( 'by MarkerOperation', () => {
+			it( 'different marker name: no operation update', () => {
+				let transformBy = new MarkerOperation( 'otherName', oldRange, newRange, baseVersion );
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'same marker name and is important: convert to NoOperation', () => {
+				const anotherRange = Range.createFromParentsAndOffsets( root, 2, root, 2 );
+				let transformBy = new MarkerOperation( 'name', oldRange, anotherRange, baseVersion );
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], {
+					type: NoOperation,
+					baseVersion: baseVersion + 1
+				} );
+			} );
+
+			it( 'same marker name and is less important: update oldRange parameter', () => {
+				const anotherRange = Range.createFromParentsAndOffsets( root, 2, root, 2 );
+				let transformBy = new MarkerOperation( 'name', oldRange, anotherRange, baseVersion );
+
+				let transOp = transform( op, transformBy, true );
+
+				expected.oldRange = anotherRange;
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+		} );
+	} );
 } );
 } );

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

@@ -192,6 +192,18 @@ describe( 'writer', () => {
 			);
 			);
 		} );
 		} );
 
 
+		it( 'merges wrapped nodes #6', () => {
+			test(
+				'<container:div>f{o<attribute:strong>ob</attribute:strong>a}r</container:div>',
+				'<attribute:span view-priority="1"></attribute:span>',
+				'<container:div>f[' +
+					'<attribute:span view-priority="1">o' +
+						'<attribute:strong view-priority="10">ob</attribute:strong>' +
+					'a</attribute:span>' +
+				']r</container:div>'
+			);
+		} );
+
 		it( 'should wrap single element by merging attributes', () => {
 		it( 'should wrap single element by merging attributes', () => {
 			test(
 			test(
 				'<container:p>[<attribute:b view-priority="1" foo="bar" one="two"></attribute:b>]</container:p>',
 				'<container:p>[<attribute:b view-priority="1" foo="bar" one="two"></attribute:b>]</container:p>',