Преглед на файлове

Merge pull request #773 from ckeditor/t/765

Changed: Markers refactor
Piotr Jasiun преди 9 години
родител
ревизия
b3314ab9fa
променени са 21 файла, в които са добавени 1681 реда и са изтрити 329 реда
  1. 4 4
      packages/ckeditor5-engine/src/controller/editingcontroller.js
  2. 1 1
      packages/ckeditor5-engine/src/conversion/buildmodelconverter.js
  3. 13 9
      packages/ckeditor5-engine/src/conversion/model-to-view-converters.js
  4. 39 0
      packages/ckeditor5-engine/src/model/delta/basic-transformations.js
  5. 118 0
      packages/ckeditor5-engine/src/model/delta/markerdelta.js
  6. 3 3
      packages/ckeditor5-engine/src/model/document.js
  7. 290 0
      packages/ckeditor5-engine/src/model/markercollection.js
  8. 0 154
      packages/ckeditor5-engine/src/model/markerscollection.js
  9. 146 0
      packages/ckeditor5-engine/src/model/operation/markeroperation.js
  10. 2 0
      packages/ckeditor5-engine/src/model/operation/operationfactory.js
  11. 74 0
      packages/ckeditor5-engine/src/model/operation/transform.js
  12. 14 10
      packages/ckeditor5-engine/tests/controller/editingcontroller.js
  13. 2 2
      packages/ckeditor5-engine/tests/manual/tickets/643/1.js
  14. 153 0
      packages/ckeditor5-engine/tests/model/delta/markerdelta.js
  15. 9 0
      packages/ckeditor5-engine/tests/model/delta/transform/_utils/utils.js
  16. 97 0
      packages/ckeditor5-engine/tests/model/delta/transform/markerdelta.js
  17. 0 1
      packages/ckeditor5-engine/tests/model/delta/transform/transform.js
  18. 225 0
      packages/ckeditor5-engine/tests/model/markercollection.js
  19. 0 145
      packages/ckeditor5-engine/tests/model/markerscollection.js
  20. 227 0
      packages/ckeditor5-engine/tests/model/operation/markeroperation.js
  21. 264 0
      packages/ckeditor5-engine/tests/model/operation/transform.js

+ 4 - 4
packages/ckeditor5-engine/src/controller/editingcontroller.js

@@ -105,12 +105,12 @@ export default class EditingController {
 		}, { priority: 'low' } );
 
 		// 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.

+ 1 - 1
packages/ckeditor5-engine/src/conversion/buildmodelconverter.js

@@ -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.
  * 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.
- * 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' } );
  *		buildModelConverter().for( dispatcher ).fromMarker( 'searchResult' ).toElement( viewSpanSearchResult );

+ 13 - 9
packages/ckeditor5-engine/src/conversion/model-to-view-converters.js

@@ -496,15 +496,17 @@ export function rename() {
  *
  *		modelDispatcher.on( 'insert', insertIntoRange( 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.
  * @returns {Function}
  */
-export function insertIntoMarker( markersCollection ) {
+export function insertIntoMarker( markerCollection ) {
 	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 ) ) {
-				conversionApi.dispatcher.convertMarker( 'addMarker', name, data.range );
+				conversionApi.dispatcher.convertMarker( 'addMarker', marker.name, data.range );
 			}
 		}
 	};
@@ -516,23 +518,25 @@ export function insertIntoMarker( markersCollection ) {
  *
  *		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.
  * @returns {Function}
  */
-export function moveInOutOfMarker( markersCollection ) {
+export function moveInOutOfMarker( markerCollection ) {
 	return ( evt, data, consumable, conversionApi ) => {
 		const sourcePos = data.sourcePosition._getTransformedByInsertion( data.targetPosition, data.item.offsetSize );
 		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 common = movedRange.getIntersection( range );
 
 			if ( wasInMarker && common === null ) {
-				conversionApi.dispatcher.convertMarker( 'removeMarker', name, movedRange );
+				conversionApi.dispatcher.convertMarker( 'removeMarker', marker.name, movedRange );
 			} else if ( common !== null ) {
-				conversionApi.dispatcher.convertMarker( 'addMarker', name, common );
+				conversionApi.dispatcher.convertMarker( 'addMarker', marker.name, common );
 			}
 		}
 	};

+ 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 AttributeDelta from './attributedelta';
 import InsertDelta from './insertdelta';
+import MarkerDelta from './markerdelta';
 import MergeDelta from './mergedelta';
 import MoveDelta from './movedelta';
 import SplitDelta from './splitdelta';
@@ -96,6 +97,44 @@ addTransformationCase( InsertDelta, MergeDelta, ( 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.
 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

+ 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 );

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

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

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

@@ -0,0 +1,290 @@
+/**
+ * @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;
+	}
+
+	/**
+	 * Destroys markers collection.
+	 */
+	destroy() {
+		for ( let marker of this._markers.values() ) {
+			this._destroyMarker( marker );
+		}
+
+		this._markers = null;
+
+		this.stopListening();
+	}
+
+	/**
+	 * 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 InsertOperation from '../operation/insertoperation';
+import MarkerOperation from '../operation/markeroperation';
 import MoveOperation from '../operation/moveoperation';
 import NoOperation from '../operation/nooperation';
 import Operation from '../operation/operation';
@@ -20,6 +21,7 @@ import RootAttributeOperation from '../operation/rootattributeoperation';
 const operations = {};
 operations[ AttributeOperation.className ] = AttributeOperation;
 operations[ InsertOperation.className ] = InsertOperation;
+operations[ MarkerOperation.className ] = MarkerOperation;
 operations[ MoveOperation.className ] = MoveOperation;
 operations[ NoOperation.className ] = NoOperation;
 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 RootAttributeOperation from './rootattributeoperation';
 import RenameOperation from './renameoperation';
+import MarkerOperation from './markeroperation';
 import MoveOperation from './moveoperation';
 import RemoveOperation from './removeoperation';
 import NoOperation from './nooperation';
@@ -82,6 +83,8 @@ const ot = {
 
 		RenameOperation: doNotUpdate,
 
+		MarkerOperation: doNotUpdate,
+
 		// Transforms InsertOperation `a` by MoveOperation `b`. Accepts a flag stating whether `a` is more important
 		// than `b` when it comes to resolving conflicts. Returns results as an array of operations.
 		MoveOperation( a, b, isStrong ) {
@@ -142,6 +145,8 @@ const ot = {
 
 		RenameOperation: doNotUpdate,
 
+		MarkerOperation: doNotUpdate,
+
 		// Transforms AttributeOperation `a` by MoveOperation `b`. Returns results as an array of operations.
 		MoveOperation( a, b ) {
 			// Convert MoveOperation properties into a range.
@@ -222,6 +227,8 @@ const ot = {
 
 		RenameOperation: doNotUpdate,
 
+		MarkerOperation: doNotUpdate,
+
 		MoveOperation: doNotUpdate
 	},
 
@@ -258,6 +265,8 @@ const ot = {
 			return [ clone ];
 		},
 
+		MarkerOperation: doNotUpdate,
+
 		// Transforms RenameOperation `a` by MoveOperation `b`. Returns results as an array of operations.
 		MoveOperation( a, b ) {
 			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: {
 		// 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.
@@ -298,6 +366,8 @@ const ot = {
 
 		RenameOperation: doNotUpdate,
 
+		MarkerOperation: doNotUpdate,
+
 		// Transforms MoveOperation `a` by MoveOperation `b`. Accepts a flag stating whether `a` is more important
 		// than `b` when it comes to resolving conflicts. Returns results as an array of operations.
 		MoveOperation( a, b, isStrong ) {
@@ -452,6 +522,8 @@ function transform( a, b, isStrong ) {
 		group = ot.RootAttributeOperation;
 	} else if ( a instanceof RenameOperation ) {
 		group = ot.RenameOperation;
+	} else if ( a instanceof MarkerOperation ) {
+		group = ot.MarkerOperation;
 	} else if ( a instanceof MoveOperation ) {
 		group = ot.MoveOperation;
 	} else {
@@ -467,6 +539,8 @@ function transform( a, b, isStrong ) {
 			algorithm = group.RootAttributeOperation;
 		} else if ( b instanceof RenameOperation ) {
 			algorithm = group.RenameOperation;
+		} else if ( b instanceof MarkerOperation ) {
+			algorithm = group.MarkerOperation;
 		} else if ( b instanceof MoveOperation ) {
 			algorithm = group.MoveOperation;
 		} else {

+ 14 - 10
packages/ckeditor5-engine/tests/controller/editingcontroller.js

@@ -255,16 +255,20 @@ describe( 'EditingController', () => {
 
 		it( 'should forward marker events to model conversion dispatcher', () => {
 			const range = ModelRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 1 );
+			const markerStub = {
+				name: 'name',
+				getRange: () => range
+			};
 
 			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();
 		} );
@@ -277,7 +281,7 @@ describe( 'EditingController', () => {
 				test: () => true
 			};
 
-			model.markers.add( 'name', markerRange );
+			model.markers.set( 'name', markerRange );
 
 			sinon.spy( editing.modelToView, 'convertMarker' );
 
@@ -298,7 +302,7 @@ describe( 'EditingController', () => {
 				test: () => true
 			};
 
-			model.markers.add( 'name', markerRange );
+			model.markers.set( 'name', markerRange );
 
 			sinon.spy( editing.modelToView, 'convertMarker' );
 
@@ -318,7 +322,7 @@ describe( 'EditingController', () => {
 				test: () => true
 			};
 
-			model.markers.add( 'name', markerRange );
+			model.markers.set( 'name', markerRange );
 
 			sinon.spy( editing.modelToView, 'convertMarker' );
 
@@ -344,7 +348,7 @@ describe( 'EditingController', () => {
 				test: () => true
 			};
 
-			model.markers.add( 'name', markerRange );
+			model.markers.set( 'name', markerRange );
 
 			sinon.spy( editing.modelToView, 'convertMarker' );
 
@@ -370,7 +374,7 @@ describe( 'EditingController', () => {
 				test: () => true
 			};
 
-			model.markers.add( 'name', markerRange );
+			model.markers.set( 'name', markerRange );
 
 			sinon.spy( editing.modelToView, 'convertMarker' );
 
@@ -392,7 +396,7 @@ describe( 'EditingController', () => {
 				test: () => true
 			};
 
-			model.markers.add( 'name', markerRange );
+			model.markers.set( 'name', markerRange );
 
 			sinon.spy( editing.modelToView, 'convertMarker' );
 

+ 2 - 2
packages/ckeditor5-engine/tests/manual/tickets/643/1.js

@@ -51,7 +51,7 @@ ClassicEditor.create( document.querySelector( '#editor' ), {
 		const name = 'highlight:yellow:' + uid();
 
 		markerNames.push( name );
-		model.markers.add( name, range );
+		model.markers.set( name, range );
 	} );
 } )
 .catch( err => {
@@ -71,7 +71,7 @@ function addHighlight( color ) {
 		const name = 'highlight:' + color + ':' + uid();
 
 		markerNames.push( name );
-		model.markers.add( name, range );
+		model.markers.set( name, range );
 	} );
 }
 

+ 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 RenameDelta from '../../../../../src/model/delta/renamedelta';
 import RemoveDelta from '../../../../../src/model/delta/removedelta';
+import MarkerDelta from '../../../../../src/model/delta/markerdelta';
 import MoveDelta from '../../../../../src/model/delta/movedelta';
 import MergeDelta from '../../../../../src/model/delta/mergedelta';
 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 InsertOperation from '../../../../../src/model/operation/insertoperation';
+import MarkerOperation from '../../../../../src/model/operation/markeroperation';
 import MoveOperation from '../../../../../src/model/operation/moveoperation';
 import RemoveOperation from '../../../../../src/model/operation/removeoperation';
 import RenameOperation from '../../../../../src/model/operation/renameoperation';
@@ -48,6 +50,13 @@ export function getWeakInsertDelta( position, nodes, version ) {
 	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 ) {
 	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 Text from '../../../../src/model/text';
 import Position from '../../../../src/model/position';
-import Range from '../../../../src/model/range';
 
 import Delta from '../../../../src/model/delta/delta';
 import InsertDelta from '../../../../src/model/delta/insertdelta';

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

@@ -0,0 +1,225 @@
+/**
+ * @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( '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 AttributeOperation from '../../../src/model/operation/attributeoperation';
 import RootAttributeOperation from '../../../src/model/operation/rootattributeoperation';
+import MarkerOperation from '../../../src/model/operation/markeroperation';
 import MoveOperation from '../../../src/model/operation/moveoperation';
 import RemoveOperation from '../../../src/model/operation/removeoperation';
 import RenameOperation from '../../../src/model/operation/renameoperation';
@@ -413,6 +414,18 @@ describe( 'transform', () => {
 				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', () => {
@@ -1125,6 +1138,18 @@ describe( 'transform', () => {
 					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.
@@ -1591,6 +1616,18 @@ describe( 'transform', () => {
 				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', () => {
@@ -2705,6 +2742,18 @@ describe( 'transform', () => {
 				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', () => {
@@ -2844,6 +2893,18 @@ describe( 'transform', () => {
 				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', () => {
@@ -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', () => {
 			it( 'different element: no change', () => {
 				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 );
+			} );
+		} );
+	} );
 } );