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

Merge branch 'master' into t/1210

Szymon Kupś 8 лет назад
Родитель
Сommit
609e3cf5f6
43 измененных файлов с 771 добавлено и 418 удалено
  1. 41 3
      packages/ckeditor5-engine/src/controller/datacontroller.js
  2. 73 3
      packages/ckeditor5-engine/src/controller/editingcontroller.js
  3. 4 4
      packages/ckeditor5-engine/src/conversion/model-selection-to-view-converters.js
  4. 4 17
      packages/ckeditor5-engine/src/conversion/model-to-view-converters.js
  5. 26 3
      packages/ckeditor5-engine/src/conversion/modelconsumable.js
  6. 0 5
      packages/ckeditor5-engine/src/conversion/modelconversiondispatcher.js
  7. 9 3
      packages/ckeditor5-engine/src/model/batch.js
  8. 2 6
      packages/ckeditor5-engine/src/model/operation/attributeoperation.js
  9. 2 8
      packages/ckeditor5-engine/src/model/operation/detachoperation.js
  10. 2 6
      packages/ckeditor5-engine/src/model/operation/insertoperation.js
  11. 2 24
      packages/ckeditor5-engine/src/model/operation/markeroperation.js
  12. 2 12
      packages/ckeditor5-engine/src/model/operation/moveoperation.js
  13. 0 12
      packages/ckeditor5-engine/src/model/operation/nooperation.js
  14. 11 8
      packages/ckeditor5-engine/src/model/operation/operation.js
  15. 0 15
      packages/ckeditor5-engine/src/model/operation/reinsertoperation.js
  16. 0 16
      packages/ckeditor5-engine/src/model/operation/removeoperation.js
  17. 2 6
      packages/ckeditor5-engine/src/model/operation/renameoperation.js
  18. 17 6
      packages/ckeditor5-engine/src/model/operation/rootattributeoperation.js
  19. 4 4
      packages/ckeditor5-engine/src/model/schema.js
  20. 70 46
      packages/ckeditor5-engine/src/model/writer.js
  21. 2 0
      packages/ckeditor5-engine/src/view/element.js
  22. 10 5
      packages/ckeditor5-engine/src/view/matcher.js
  23. 21 14
      packages/ckeditor5-engine/src/view/renderer.js
  24. 41 1
      packages/ckeditor5-engine/tests/controller/datacontroller.js
  25. 215 0
      packages/ckeditor5-engine/tests/controller/editingcontroller.js
  26. 35 0
      packages/ckeditor5-engine/tests/conversion/modelconsumable.js
  27. 6 6
      packages/ckeditor5-engine/tests/dev-utils/enableenginedebug.js
  28. 15 0
      packages/ckeditor5-engine/tests/model/batch.js
  29. 1 4
      packages/ckeditor5-engine/tests/model/liverange.js
  30. 0 29
      packages/ckeditor5-engine/tests/model/operation/attributeoperation.js
  31. 6 6
      packages/ckeditor5-engine/tests/model/operation/detachoperation.js
  32. 0 25
      packages/ckeditor5-engine/tests/model/operation/insertoperation.js
  33. 0 20
      packages/ckeditor5-engine/tests/model/operation/markeroperation.js
  34. 0 27
      packages/ckeditor5-engine/tests/model/operation/moveoperation.js
  35. 0 4
      packages/ckeditor5-engine/tests/model/operation/nooperation.js
  36. 14 0
      packages/ckeditor5-engine/tests/model/operation/operation.js
  37. 0 4
      packages/ckeditor5-engine/tests/model/operation/reinsertoperation.js
  38. 0 11
      packages/ckeditor5-engine/tests/model/operation/removeoperation.js
  39. 0 16
      packages/ckeditor5-engine/tests/model/operation/renameoperation.js
  40. 35 30
      packages/ckeditor5-engine/tests/model/operation/rootattributeoperation.js
  41. 65 9
      packages/ckeditor5-engine/tests/model/writer.js
  42. 6 0
      packages/ckeditor5-engine/tests/view/element.js
  43. 28 0
      packages/ckeditor5-engine/tests/view/matcher.js

+ 41 - 3
packages/ckeditor5-engine/src/controller/datacontroller.js

@@ -136,10 +136,10 @@ export default class DataController {
 	 * @returns {String} Output data.
 	 */
 	stringify( modelElementOrFragment ) {
-		// model -> view
+		// Model -> view.
 		const viewDocumentFragment = this.toView( modelElementOrFragment );
 
-		// view -> data
+		// View -> data.
 		return this.processor.toData( viewDocumentFragment );
 	}
 
@@ -154,13 +154,26 @@ export default class DataController {
 	 * @returns {module:engine/view/documentfragment~DocumentFragment} Output view DocumentFragment.
 	 */
 	toView( modelElementOrFragment ) {
+		// First, convert elements.
 		const modelRange = ModelRange.createIn( modelElementOrFragment );
 
 		const viewDocumentFragment = new ViewDocumentFragment();
+		const viewWriter = new ViewWriter();
 		this.mapper.bindElements( modelElementOrFragment, viewDocumentFragment );
 
-		this.modelToView.convertInsert( modelRange, new ViewWriter() );
+		this.modelToView.convertInsert( modelRange, viewWriter );
 
+		if ( !modelElementOrFragment.is( 'documentFragment' ) ) {
+			// Then, if a document element is converted, convert markers.
+			// From all document markers, get those, which "intersect" with the converter element.
+			const markers = _getMarkersRelativeToElement( modelElementOrFragment );
+
+			for ( const [ name, range ] of markers ) {
+				this.modelToView.convertMarkerAdd( name, range, viewWriter );
+			}
+		}
+
+		// Clear bindings so the next call to this method gives correct results.
 		this.mapper.clearBindings();
 
 		return viewDocumentFragment;
@@ -235,3 +248,28 @@ export default class DataController {
 }
 
 mix( DataController, ObservableMixin );
+
+// Helper function for converting part of a model to view.
+//
+// Takes a document element (element that is added to a model document) and checks which markers are inside it
+// and which markers are containing it. If the marker is intersecting with element, the intersection is returned.
+function _getMarkersRelativeToElement( element ) {
+	const result = [];
+	const doc = element.root.document;
+
+	if ( !doc ) {
+		return [];
+	}
+
+	const elementRange = ModelRange.createIn( element );
+
+	for ( const marker of doc.model.markers ) {
+		const intersection = elementRange.getIntersection( marker.getRange() );
+
+		if ( intersection ) {
+			result.push( [ marker.name, intersection ] );
+		}
+	}
+
+	return result;
+}

+ 73 - 3
packages/ckeditor5-engine/src/controller/editingcontroller.js

@@ -85,13 +85,14 @@ export default class EditingController {
 
 		const doc = this.model.document;
 
-		// When all changes are done, get the model diff containing all the changes and convert them to view and then render to DOM.
 		this.listenTo( doc, 'change', () => {
 			this.view.change( writer => {
-				// Convert changes stored in `modelDiffer`.
 				this.modelToView.convertChanges( doc.differ, writer );
+			} );
+		}, { priority: 'low' } );
 
-				// After the view is ready, convert selection from model to view.
+		this.listenTo( model, '_change', () => {
+			this.view.change( writer => {
 				this.modelToView.convertSelection( doc.selection, writer );
 			} );
 		}, { priority: 'low' } );
@@ -109,6 +110,55 @@ export default class EditingController {
 		this.modelToView.on( 'selection', convertRangeSelection(), { priority: 'low' } );
 		this.modelToView.on( 'selection', convertCollapsedSelection(), { priority: 'low' } );
 
+		// Convert markers removal.
+		//
+		// Markers should be removed from the view before changes to the model are applied. This is because otherwise
+		// it would be impossible to map some markers to the view (if, for example, the marker's boundary parent got removed).
+		//
+		// `removedMarkers` keeps information which markers already has been removed to prevent removing them twice.
+		const removedMarkers = new Set();
+
+		this.listenTo( model, 'applyOperation', ( evt, args ) => {
+			// Before operation is applied...
+			const operation = args[ 0 ];
+
+			for ( const marker of model.markers ) {
+				// Check all markers, that aren't already removed...
+				if ( removedMarkers.has( marker.name ) ) {
+					continue;
+				}
+
+				const markerRange = marker.getRange();
+
+				if ( _operationAffectsMarker( operation, marker ) ) {
+					// And if the operation in any way modifies the marker, remove the marker from the view.
+					removedMarkers.add( marker.name );
+					this.view.change( writer => {
+						this.modelToView.convertMarkerRemove( marker.name, markerRange, writer );
+					} );
+
+					// TODO: This stinks but this is the safest place to have this code.
+					this.model.document.differ.bufferMarkerChange( marker.name, markerRange, markerRange );
+				}
+			}
+		}, { priority: 'high' } );
+
+		// If a marker is removed through `model.Model#markers` directly (not through operation), just remove it (if
+		// it was not removed already).
+		this.listenTo( model.markers, 'remove', ( evt, marker ) => {
+			if ( !removedMarkers.has( marker.name ) ) {
+				removedMarkers.add( marker.name );
+				this.view.change( writer => {
+					this.modelToView.convertMarkerRemove( marker.name, marker.getRange(), writer );
+				} );
+			}
+		} );
+
+		// When all changes are done, clear `removedMarkers` set.
+		this.listenTo( model, '_change', () => {
+			removedMarkers.clear();
+		}, { priority: 'low' } );
+
 		// Binds {@link module:engine/view/document~Document#roots view roots collection} to
 		// {@link module:engine/model/document~Document#roots model roots collection} so creating
 		// model root automatically creates corresponding view root.
@@ -139,3 +189,23 @@ export default class EditingController {
 }
 
 mix( EditingController, ObservableMixin );
+
+// Helper function which checks whether given operation will affect given marker after the operation is applied.
+function _operationAffectsMarker( operation, marker ) {
+	const range = marker.getRange();
+
+	if ( operation.type == 'insert' || operation.type == 'rename' ) {
+		return _positionAffectsRange( operation.position, range );
+	} else if ( operation.type == 'move' || operation.type == 'remove' || operation.type == 'reinsert' ) {
+		return _positionAffectsRange( operation.targetPosition, range ) || _positionAffectsRange( operation.sourcePosition, range );
+	} else if ( operation.type == 'marker' && operation.name == marker.name ) {
+		return true;
+	}
+
+	return false;
+}
+
+// Helper function which checks whether change at given position affects given range.
+function _positionAffectsRange( position, range ) {
+	return range.containsPosition( position ) || !range.start._getTransformedByInsertion( position, 1, true ).isEqual( range.start );
+}

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

@@ -187,26 +187,25 @@ export function convertSelectionMarker( highlightDescriptor ) {
 		}
 
 		const viewElement = createViewElementFromHighlightDescriptor( descriptor );
-		const consumableName = 'selectionMarker:' + data.markerName;
 
 		wrapCollapsedSelectionPosition(
 			data.selection,
 			conversionApi.viewSelection,
 			viewElement,
 			consumable,
-			consumableName,
+			evt.name,
 			conversionApi.writer
 		);
 	};
 }
 
 // Helper function for `convertSelectionAttribute` and `convertSelectionMarker`, which perform similar task.
-function wrapCollapsedSelectionPosition( modelSelection, viewSelection, viewElement, consumable, consumableName, writer ) {
+function wrapCollapsedSelectionPosition( modelSelection, viewSelection, viewElement, consumable, eventName, writer ) {
 	if ( !modelSelection.isCollapsed ) {
 		return;
 	}
 
-	if ( !consumable.consume( modelSelection, consumableName ) ) {
+	if ( !consumable.consume( modelSelection, eventName ) ) {
 		return;
 	}
 
@@ -220,6 +219,7 @@ function wrapCollapsedSelectionPosition( modelSelection, viewSelection, viewElem
 		viewPosition = viewPosition.getLastMatchingPosition( value => value.item.is( 'uiElement' ) );
 	}
 	// End of hack.
+
 	viewPosition = writer.wrapPosition( viewPosition, viewElement );
 
 	viewSelection.removeAllRanges();

+ 4 - 17
packages/ckeditor5-engine/src/conversion/model-to-view-converters.js

@@ -152,18 +152,17 @@ export function insertUIElement( elementCreator ) {
 		}
 
 		const markerRange = data.markerRange;
-		const eventName = evt.name;
 
 		// Marker that is collapsed has consumable build differently that non-collapsed one.
 		// For more information see `addMarker` event description.
 		// If marker's range is collapsed - check if it can be consumed.
-		if ( markerRange.isCollapsed && !consumable.consume( markerRange, eventName ) ) {
+		if ( markerRange.isCollapsed && !consumable.consume( markerRange, evt.name ) ) {
 			return;
 		}
 
 		// If marker's range is not collapsed - consume all items inside.
 		for ( const value of markerRange ) {
-			if ( !consumable.consume( value.item, eventName ) ) {
+			if ( !consumable.consume( value.item, evt.name ) ) {
 				return;
 			}
 		}
@@ -264,7 +263,7 @@ export function changeAttribute( attributeCreator ) {
 	attributeCreator = attributeCreator || ( ( value, key ) => ( { value, key } ) );
 
 	return ( evt, data, consumable, conversionApi ) => {
-		if ( !consumable.consume( data.item, eventNameToConsumableType( evt.name ) ) ) {
+		if ( !consumable.consume( data.item, evt.name ) ) {
 			return;
 		}
 
@@ -323,7 +322,7 @@ export function wrap( elementCreator ) {
 			return;
 		}
 
-		if ( !consumable.consume( data.item, eventNameToConsumableType( evt.name ) ) ) {
+		if ( !consumable.consume( data.item, evt.name ) ) {
 			return;
 		}
 
@@ -526,18 +525,6 @@ function _prepareDescriptor( highlightDescriptor, data, conversionApi ) {
 	return descriptor;
 }
 
-/**
- * Returns the consumable type that is to be consumed in an event, basing on that event name.
- *
- * @param {String} evtName Event name.
- * @returns {String} Consumable type.
- */
-export function eventNameToConsumableType( evtName ) {
-	const parts = evtName.split( ':' );
-
-	return parts[ 0 ] + ':' + parts[ 1 ];
-}
-
 /**
  * Creates `span` {@link module:engine/view/attributeelement~AttributeElement view attribute element} from information
  * provided by {@link module:engine/conversion/model-to-view-converters~HighlightDescriptor} object. If priority

+ 26 - 3
packages/ckeditor5-engine/src/conversion/modelconsumable.js

@@ -125,9 +125,12 @@ export default class ModelConsumable {
 	 *
 	 * @param {module:engine/model/item~Item|module:engine/model/selection~Selection|module:engine/model/range~Range} item
 	 * Model item, range or selection that has the consumable.
-	 * @param {String} type Consumable type.
+	 * @param {String} type Consumable type. Will be normalized to a proper form, that is either `<word>` or `<part>:<part>`.
+	 * Second colon and everything after will be cut. Passing event name is a safe and good practice.
 	 */
 	add( item, type ) {
+		type = _normalizeConsumableType( type );
+
 		if ( item instanceof TextProxy ) {
 			item = this._getSymbolForTextProxy( item );
 		}
@@ -151,10 +154,13 @@ export default class ModelConsumable {
 	 *
 	 * @param {module:engine/model/item~Item|module:engine/model/selection~Selection|module:engine/model/range~Range} item
 	 * Model item, range or selection from which consumable will be consumed.
-	 * @param {String} type Consumable type.
+	 * @param {String} type Consumable type. Will be normalized to a proper form, that is either `<word>` or `<part>:<part>`.
+	 * Second colon and everything after will be cut. Passing event name is a safe and good practice.
 	 * @returns {Boolean} `true` if consumable value was available and was consumed, `false` otherwise.
 	 */
 	consume( item, type ) {
+		type = _normalizeConsumableType( type );
+
 		if ( item instanceof TextProxy ) {
 			item = this._getSymbolForTextProxy( item );
 		}
@@ -180,11 +186,14 @@ export default class ModelConsumable {
 	 *
 	 * @param {module:engine/model/item~Item|module:engine/model/selection~Selection|module:engine/model/range~Range} item
 	 * Model item, range or selection to be tested.
-	 * @param {String} type Consumable type.
+	 * @param {String} type Consumable type. Will be normalized to a proper form, that is either `<word>` or `<part>:<part>`.
+	 * Second colon and everything after will be cut. Passing event name is a safe and good practice.
 	 * @returns {null|Boolean} `null` if such consumable was never added, `false` if the consumable values was
 	 * already consumed or `true` if it was added and not consumed yet.
 	 */
 	test( item, type ) {
+		type = _normalizeConsumableType( type );
+
 		if ( item instanceof TextProxy ) {
 			item = this._getSymbolForTextProxy( item );
 		}
@@ -221,6 +230,8 @@ export default class ModelConsumable {
 	 * never been added.
 	 */
 	revert( item, type ) {
+		type = _normalizeConsumableType( type );
+
 		if ( item instanceof TextProxy ) {
 			item = this._getSymbolForTextProxy( item );
 		}
@@ -302,3 +313,15 @@ export default class ModelConsumable {
 		return symbol;
 	}
 }
+
+// Returns a normalized consumable type name from given string. A normalized consumable type name is a string that has
+// at most one colon, for example: `insert` or `addMarker:highlight`. If string to normalize has more "parts" (more colons),
+// the other parts are dropped, for example: `addAttribute:bold:$text` -> `addAttribute:bold`.
+//
+// @param {String} type Consumable type.
+// @returns {String} Normalized consumable type.
+function _normalizeConsumableType( type ) {
+	const parts = type.split( ':' );
+
+	return parts.length > 1 ? parts[ 0 ] + ':' + parts[ 1 ] : parts[ 0 ];
+}

+ 0 - 5
packages/ckeditor5-engine/src/conversion/modelconversiondispatcher.js

@@ -130,11 +130,6 @@ export default class ModelConversionDispatcher {
 	convertChanges( differ, writer ) {
 		this.conversionApi.writer = writer;
 
-		// First, before changing view structure, remove all markers that has changed.
-		for ( const change of differ.getMarkersToRemove() ) {
-			this.convertMarkerRemove( change.name, change.range, writer );
-		}
-
 		// Convert changes that happened on model tree.
 		for ( const entry of differ.getChanges() ) {
 			if ( entry.type == 'insert' ) {

+ 9 - 3
packages/ckeditor5-engine/src/model/batch.js

@@ -50,14 +50,20 @@ export default class Batch {
 	}
 
 	/**
-	 * Returns this batch base version, which is equal to the base version of first delta in the batch.
-	 * If there are no deltas in the batch, it returns `null`.
+	 * Returns this batch base version, which is equal to the base version of first delta (which has base version set)
+	 * in the batch. If there are no deltas in the batch or neither delta has base version set, it returns `null`.
 	 *
 	 * @readonly
 	 * @type {Number|null}
 	 */
 	get baseVersion() {
-		return this.deltas.length > 0 ? this.deltas[ 0 ].baseVersion : null;
+		for ( const delta of this.deltas ) {
+			if ( delta.baseVersion !== null ) {
+				return delta.baseVersion;
+			}
+		}
+
+		return null;
 	}
 
 	/**

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

@@ -37,7 +37,8 @@ export default class AttributeOperation extends Operation {
 	 * @param {String} key Key of an attribute to change or remove.
 	 * @param {*} oldValue Old value of the attribute with given key or `null`, if attribute was not set before.
 	 * @param {*} newValue New value of the attribute with given key or `null`, if operation should remove attribute.
-	 * @param {Number} baseVersion {@link module:engine/model/document~Document#version} on which the operation can be applied.
+	 * @param {Number|null} baseVersion Document {@link module:engine/model/document~Document#version} on which operation
+	 * can be applied or `null` if the operation operates on detached (non-document) tree.
 	 */
 	constructor( range, key, oldValue, newValue, baseVersion ) {
 		super( baseVersion );
@@ -73,11 +74,6 @@ export default class AttributeOperation extends Operation {
 		 * @member {*}
 		 */
 		this.newValue = newValue === undefined ? null : newValue;
-
-		/**
-		 * @inheritDoc
-		 */
-		this.isDocumentOperation = !!this.range.root.document;
 	}
 
 	/**

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

@@ -27,10 +27,9 @@ export default class DetachOperation extends Operation {
 	 * Position before the first {@link module:engine/model/item~Item model item} to move.
 	 * @param {Number} howMany Offset size of moved range. Moved range will start from `sourcePosition` and end at
 	 * `sourcePosition` with offset shifted by `howMany`.
-	 * @param {Number} baseVersion {@link module:engine/model/document~Document#version} on which operation can be applied.
 	 */
-	constructor( sourcePosition, howMany, baseVersion ) {
-		super( baseVersion );
+	constructor( sourcePosition, howMany ) {
+		super( null );
 
 		/**
 		 * Position before the first {@link module:engine/model/item~Item model item} to detach.
@@ -45,11 +44,6 @@ export default class DetachOperation extends Operation {
 		 * @member {Number} #howMany
 		 */
 		this.howMany = howMany;
-
-		/**
-		 * @inheritDoc
-		 */
-		this.isDocumentOperation = false;
 	}
 
 	/**

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

@@ -27,7 +27,8 @@ export default class InsertOperation extends Operation {
 	 *
 	 * @param {module:engine/model/position~Position} position Position of insertion.
 	 * @param {module:engine/model/node~NodeSet} nodes The list of nodes to be inserted.
-	 * @param {Number} baseVersion {@link module:engine/model/document~Document#version} on which operation can be applied.
+	 * @param {Number|null} baseVersion Document {@link module:engine/model/document~Document#version} on which operation
+	 * can be applied or `null` if the operation operates on detached (non-document) tree.
 	 */
 	constructor( position, nodes, baseVersion ) {
 		super( baseVersion );
@@ -47,11 +48,6 @@ export default class InsertOperation extends Operation {
 		 * @member {module:engine/model/nodelist~NodeList} module:engine/model/operation/insertoperation~InsertOperation#nodeList
 		 */
 		this.nodes = new NodeList( _normalizeNodes( nodes ) );
-
-		/**
-		 * @inheritDoc
-		 */
-		this.isDocumentOperation = !!this.position.root.document;
 	}
 
 	/**

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

@@ -19,7 +19,8 @@ export default class MarkerOperation extends Operation {
 	 * @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 {module:engine/model/markercollection~MarkerCollection} markers Marker collection on which change should be executed.
-	 * @param {Number} baseVersion {@link module:engine/model/document~Document#version} on which the operation can be applied.
+	 * @param {Number|null} baseVersion Document {@link module:engine/model/document~Document#version} on which operation
+	 * can be applied or `null` if the operation operates on detached (non-document) tree.
 	 */
 	constructor( name, oldRange, newRange, markers, baseVersion ) {
 		super( baseVersion );
@@ -55,11 +56,6 @@ export default class MarkerOperation extends Operation {
 		 * @member {module:engine/model/markercollection~MarkerCollection}
 		 */
 		this._markers = markers;
-
-		/**
-		 * @inheritDoc
-		 */
-		this.isDocumentOperation = this._isDocumentOperation();
 	}
 
 	/**
@@ -69,24 +65,6 @@ export default class MarkerOperation extends Operation {
 		return 'marker';
 	}
 
-	/**
-	 * Checks if operation is executed on document or document fragment nodes.
-	 *
-	 * @private
-	 */
-	_isDocumentOperation() {
-		if ( this.newRange ) {
-			return !!this.newRange.root.document;
-		}
-
-		if ( this.oldRange ) {
-			return !!this.oldRange.root.document;
-		}
-
-		// This is edge and might happen only on data from the server.
-		return true;
-	}
-
 	/**
 	 * Creates and returns an operation that has the same parameters as this operation.
 	 *

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

@@ -29,7 +29,8 @@ export default class MoveOperation extends Operation {
 	 * @param {Number} howMany Offset size of moved range. Moved range will start from `sourcePosition` and end at
 	 * `sourcePosition` with offset shifted by `howMany`.
 	 * @param {module:engine/model/position~Position} targetPosition Position at which moved nodes will be inserted.
-	 * @param {Number} baseVersion {@link module:engine/model/document~Document#version} on which operation can be applied.
+	 * @param {Number|null} baseVersion Document {@link module:engine/model/document~Document#version} on which operation
+	 * can be applied or `null` if the operation operates on detached (non-document) tree.
 	 */
 	constructor( sourcePosition, howMany, targetPosition, baseVersion ) {
 		super( baseVersion );
@@ -64,17 +65,6 @@ export default class MoveOperation extends Operation {
 		 * @member {Boolean} module:engine/model/operation/moveoperation~MoveOperation#isSticky
 		 */
 		this.isSticky = false;
-
-		/**
-		 * Defines whether operation is executed on attached or detached {@link module:engine/model/item~Item items}.
-		 *
-		 * Note that range cannot be moved within different documents e.g. from docFrag to document root so
-		 * root of source and target positions is always the same.
-		 *
-		 * @readonly
-		 * @member {Boolean} #isDocumentOperation
-		 */
-		this.isDocumentOperation = !!this.targetPosition.root.document;
 	}
 
 	/**

+ 0 - 12
packages/ckeditor5-engine/src/model/operation/nooperation.js

@@ -20,18 +20,6 @@ import Operation from './operation';
  * @extends module:engine/model/operation/operation~Operation
  */
 export default class NoOperation extends Operation {
-	/**
-	 * @inheritDoc
-	 */
-	constructor( baseVersion ) {
-		super( baseVersion );
-
-		/**
-		 * @inheritDoc
-		 */
-		this.isDocumentOperation = true;
-	}
-
 	get type() {
 		return 'noop';
 	}

+ 11 - 8
packages/ckeditor5-engine/src/model/operation/operation.js

@@ -17,7 +17,9 @@ import clone from '@ckeditor/ckeditor5-utils/src/lib/lodash/clone';
 export default class Operation {
 	/**
 	 * Base operation constructor.
-	 * @param {Number} baseVersion {@link module:engine/model/document~Document#version} on which the operation can be applied.
+	 *
+	 * @param {Number|null} baseVersion Document {@link module:engine/model/document~Document#version} on which operation
+	 * can be applied or `null` if the operation operates on detached (non-document) tree.
 	 */
 	constructor( baseVersion ) {
 		/**
@@ -30,6 +32,14 @@ export default class Operation {
 		 */
 		this.baseVersion = baseVersion;
 
+		/**
+		 * Defines whether operation is executed on attached or detached {@link module:engine/model/item~Item items}.
+		 *
+		 * @readonly
+		 * @member {Boolean} #isDocumentOperation
+		 */
+		this.isDocumentOperation = this.baseVersion !== null;
+
 		/**
 		 * Operation type.
 		 *
@@ -45,13 +55,6 @@ export default class Operation {
 		 * @member {module:engine/model/delta/delta~Delta} #delta
 		 */
 
-		/**
-		 * Defines whether operation is executed on attached or detached {@link module:engine/model/item~Item items}.
-		 *
-		 * @readonly
-		 * @member {Boolean} #isDocumentOperation
-		 */
-
 		/**
 		 * Creates and returns an operation that has the same parameters as this operation.
 		 *

+ 0 - 15
packages/ckeditor5-engine/src/model/operation/reinsertoperation.js

@@ -18,21 +18,6 @@ import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
  * and fires different change event.
  */
 export default class ReinsertOperation extends MoveOperation {
-	/**
-	 * @inheritDocs
-	 */
-	constructor( sourcePosition, howMany, targetPosition, baseVersion ) {
-		super( sourcePosition, howMany, targetPosition, baseVersion );
-
-		/**
-		 * Reinsert operation is always executed on attached items.
-		 *
-		 * @readonly
-		 * @member {Boolean}
-		 */
-		this.isDocumentOperation = true;
-	}
-
 	/**
 	 * Position where nodes will be re-inserted.
 	 *

+ 0 - 16
packages/ckeditor5-engine/src/model/operation/removeoperation.js

@@ -15,22 +15,6 @@ import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
  * Operation to remove a range of nodes.
  */
 export default class RemoveOperation extends MoveOperation {
-	/**
-	 * @inheritDocs
-	 */
-	constructor( sourcePosition, howMany, targetPosition, baseVersion ) {
-		super( sourcePosition, howMany, targetPosition, baseVersion );
-
-		/**
-		 * Remove operation cannot be applied on element that is not inside the document
-		 * so this will always be a document operation.
-		 *
-		 * @readonly
-		 * @member {Boolean}
-		 */
-		this.isDocumentOperation = true;
-	}
-
 	/**
 	 * @inheritDoc
 	 */

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

@@ -26,7 +26,8 @@ export default class RenameOperation extends Operation {
 	 * @param {module:engine/model/position~Position} position Position before an element to change.
 	 * @param {String} oldName Current name of the element.
 	 * @param {String} newName New name for the element.
-	 * @param {Number} baseVersion {@link module:engine/model/document~Document#version} on which the operation can be applied.
+	 * @param {Number|null} baseVersion Document {@link module:engine/model/document~Document#version} on which operation
+	 * can be applied or `null` if the operation operates on detached (non-document) tree.
 	 */
 	constructor( position, oldName, newName, baseVersion ) {
 		super( baseVersion );
@@ -51,11 +52,6 @@ export default class RenameOperation extends Operation {
 		 * @member {String} module:engine/model/operation/renameoperation~RenameOperation#newName
 		 */
 		this.newName = newName;
-
-		/**
-		 * @inheritDoc
-		 */
-		this.isDocumentOperation = !!this.position.root.document;
 	}
 
 	/**

+ 17 - 6
packages/ckeditor5-engine/src/model/operation/rootattributeoperation.js

@@ -31,7 +31,8 @@ export default class RootAttributeOperation extends Operation {
 	 * @param {String} key Key of an attribute to change or remove.
 	 * @param {*} oldValue Old value of the attribute with given key or `null` if adding a new attribute.
 	 * @param {*} newValue New value to set for the attribute. If `null`, then the operation just removes the attribute.
-	 * @param {Number} baseVersion {@link module:engine/model/document~Document#version} on which the operation can be applied.
+	 * @param {Number|null} baseVersion Document {@link module:engine/model/document~Document#version} on which operation
+	 * can be applied or `null` if the operation operates on detached (non-document) tree.
 	 */
 	constructor( root, key, oldValue, newValue, baseVersion ) {
 		super( baseVersion );
@@ -67,11 +68,6 @@ export default class RootAttributeOperation extends Operation {
 		 * @member {*}
 		 */
 		this.newValue = newValue;
-
-		/**
-		 * @inheritDoc
-		 */
-		this.isDocumentOperation = !!this.root.document;
 	}
 
 	/**
@@ -109,6 +105,21 @@ export default class RootAttributeOperation extends Operation {
 	 * @inheritDoc
 	 */
 	_validate() {
+		if ( this.root != this.root.root || this.root.is( 'documentFragment' ) ) {
+			/**
+			 * The element to change is not a root element.
+			 *
+			 * @error rootattribute-operation-not-a-root
+			 * @param {module:engine/model/rootelement~RootElement} root
+			 * @param {String} key
+			 * @param {*} value
+			 */
+			throw new CKEditorError(
+				'rootattribute-operation-not-a-root: The element to change is not a root element.',
+				{ root: this.root, key: this.key }
+			);
+		}
+
 		if ( this.oldValue !== null && this.root.getAttribute( this.key ) !== this.oldValue ) {
 			/**
 			 * The attribute which should be removed does not exists for the given node.

+ 4 - 4
packages/ckeditor5-engine/src/model/schema.js

@@ -27,9 +27,9 @@ import Range from './range';
  *
  * ## Defining allowed structures
  *
- * When a feature introduces a model element it should registered it in the schema. Besides
+ * When a feature introduces a model element it should register it in the schema. Besides
  * defining that such an element may exist in the model, the feature also needs to define where
- * this element may occur:
+ * this element may be placed:
  *
  *		schema.register( 'myElement', {
  *			allowIn: '$root'
@@ -108,9 +108,9 @@ import Range from './range';
  * ## Defining advanced rules in `checkChild()`'s callbacks
  *
  * The {@link ~Schema#checkChild} method which is the base method used to check whether some element is allowed in a given structure
- * is {@link module:utils/observablemixin~ObservableMixin#decorate decorated} with the {@link ~Schema#event:checkChild} event.
+ * is {@link module:utils/observablemixin~ObservableMixin#decorate a decorated method}.
  * It means that you can add listeners to implement your specific rules which are not limited by the declarative
- * {@link module:engine/model/schema~SchemaItemDefinition} API.
+ * {@link module:engine/model/schema~SchemaItemDefinition API}.
  *
  * The block quote feature defines such a listener to disallow nested `<blockQuote>` structures:
  *

+ 70 - 46
packages/ckeditor5-engine/src/model/writer.js

@@ -175,7 +175,9 @@ export default class Writer {
 			}
 		}
 
-		const insert = new InsertOperation( position, item, this.model.document.version );
+		const version = position.root.document ? position.root.document.version : null;
+
+		const insert = new InsertOperation( position, item, version );
 
 		this.batch.addDelta( delta );
 		delta.addOperation( insert );
@@ -322,9 +324,9 @@ export default class Writer {
 		this._assertWriterUsageCorrectness();
 
 		if ( itemOrRange instanceof Range ) {
-			setAttributeToRange( this, key, value, itemOrRange );
+			setAttributeOnRange( this, key, value, itemOrRange );
 		} else {
-			setAttributeToItem( this, key, value, itemOrRange );
+			setAttributeOnItem( this, key, value, itemOrRange );
 		}
 	}
 
@@ -359,9 +361,9 @@ export default class Writer {
 		this._assertWriterUsageCorrectness();
 
 		if ( itemOrRange instanceof Range ) {
-			setAttributeToRange( this, key, null, itemOrRange );
+			setAttributeOnRange( this, key, null, itemOrRange );
 		} else {
-			setAttributeToItem( this, key, null, itemOrRange );
+			setAttributeOnItem( this, key, null, itemOrRange );
 		}
 	}
 
@@ -449,7 +451,9 @@ export default class Writer {
 		const delta = new MoveDelta();
 		this.batch.addDelta( delta );
 
-		const operation = new MoveOperation( range.start, range.end.offset - range.start.offset, position, this.model.document.version );
+		const version = range.root.document ? range.root.document.version : null;
+
+		const operation = new MoveOperation( range.start, range.end.offset - range.start.offset, position, version );
 		delta.addOperation( operation );
 		this.model.applyOperation( operation );
 	}
@@ -465,19 +469,8 @@ export default class Writer {
 		const addRemoveDelta = ( position, howMany ) => {
 			const delta = new RemoveDelta();
 			this.batch.addDelta( delta );
-			let operation;
-
-			if ( position.root.document ) {
-				const graveyard = this.model.document.graveyard;
-				const gyPosition = new Position( graveyard, [ 0 ] );
-
-				operation = new RemoveOperation( position, howMany, gyPosition, this.model.document.version );
-			} else {
-				operation = new DetachOperation( position, howMany, this.model.document.version );
-			}
 
-			delta.addOperation( operation );
-			this.model.applyOperation( operation );
+			addRemoveOperation( position, howMany, delta, this.model );
 		};
 
 		if ( itemOrRange instanceof Range ) {
@@ -532,23 +525,20 @@ export default class Writer {
 		const positionAfter = Position.createFromParentAndOffset( nodeAfter, 0 );
 		const positionBefore = Position.createFromParentAndOffset( nodeBefore, nodeBefore.maxOffset );
 
+		const moveVersion = position.root.document ? position.root.document.version : null;
+
 		const move = new MoveOperation(
 			positionAfter,
 			nodeAfter.maxOffset,
 			positionBefore,
-			this.model.document.version
+			moveVersion
 		);
 
 		move.isSticky = true;
 		delta.addOperation( move );
 		this.model.applyOperation( move );
 
-		const graveyard = this.model.document.graveyard;
-		const gyPosition = new Position( graveyard, [ 0 ] );
-
-		const remove = new RemoveOperation( position, 1, gyPosition, this.model.document.version );
-		delta.addOperation( remove );
-		this.model.applyOperation( remove );
+		addRemoveOperation( position, 1, delta, this.model );
 	}
 
 	/**
@@ -574,7 +564,9 @@ export default class Writer {
 		const delta = new RenameDelta();
 		this.batch.addDelta( delta );
 
-		const renameOperation = new RenameOperation( Position.createBefore( element ), element.name, newName, this.model.document.version );
+		const version = element.root.document ? element.root.document.version : null;
+
+		const renameOperation = new RenameOperation( Position.createBefore( element ), element.name, newName, version );
 		delta.addOperation( renameOperation );
 		this.model.applyOperation( renameOperation );
 	}
@@ -605,21 +597,24 @@ export default class Writer {
 		}
 
 		const copy = new Element( splitElement.name, splitElement.getAttributes() );
+		const insertVersion = splitElement.root.document ? splitElement.root.document.version : null;
 
 		const insert = new InsertOperation(
 			Position.createAfter( splitElement ),
 			copy,
-			this.model.document.version
+			insertVersion
 		);
 
 		delta.addOperation( insert );
 		this.model.applyOperation( insert );
 
+		const moveVersion = insertVersion !== null ? insertVersion + 1 : null;
+
 		const move = new MoveOperation(
 			position,
 			splitElement.maxOffset - position.offset,
 			Position.createFromParentAndOffset( copy, 0 ),
-			this.model.document.version
+			moveVersion
 		);
 		move.isSticky = true;
 
@@ -670,16 +665,20 @@ export default class Writer {
 		const delta = new WrapDelta();
 		this.batch.addDelta( delta );
 
-		const insert = new InsertOperation( range.end, element, this.model.document.version );
+		const insertVersion = range.root.document ? range.root.document.version : null;
+
+		const insert = new InsertOperation( range.end, element, insertVersion );
 		delta.addOperation( insert );
 		this.model.applyOperation( insert );
 
+		const moveVersion = insertVersion !== null ? insertVersion + 1 : null;
+
 		const targetPosition = Position.createFromParentAndOffset( element, 0 );
 		const move = new MoveOperation(
 			range.start,
 			range.end.offset - range.start.offset,
 			targetPosition,
-			this.model.document.version
+			moveVersion
 		);
 		delta.addOperation( move );
 		this.model.applyOperation( move );
@@ -707,26 +706,20 @@ export default class Writer {
 		this.batch.addDelta( delta );
 
 		const sourcePosition = Position.createFromParentAndOffset( element, 0 );
+		const moveVersion = sourcePosition.root.document ? sourcePosition.root.document.version : null;
 
 		const move = new MoveOperation(
 			sourcePosition,
 			element.maxOffset,
 			Position.createBefore( element ),
-			this.model.document.version
+			moveVersion
 		);
 
 		move.isSticky = true;
 		delta.addOperation( move );
 		this.model.applyOperation( move );
 
-		// Computing new position because we moved some nodes before `element`.
-		// If we would cache `Position.createBefore( element )` we remove wrong node.
-		const graveyard = this.model.document.graveyard;
-		const gyPosition = new Position( graveyard, [ 0 ] );
-
-		const remove = new RemoveOperation( Position.createBefore( element ), 1, gyPosition, this.model.document.version );
-		delta.addOperation( remove );
-		this.model.applyOperation( remove );
+		addRemoveOperation( Position.createBefore( element ), 1, delta, this.model );
 	}
 
 	/**
@@ -824,7 +817,7 @@ export default class Writer {
 // @param {String} key Attribute key.
 // @param {*} value Attribute new value.
 // @param {module:engine/model/range~Range} range Model range on which the attribute will be set.
-function setAttributeToRange( writer, key, value, range ) {
+function setAttributeOnRange( writer, key, value, range ) {
 	const delta = new AttributeDelta();
 	const model = writer.model;
 	const doc = model.document;
@@ -873,7 +866,8 @@ function setAttributeToRange( writer, key, value, range ) {
 		}
 
 		const range = new Range( lastSplitPosition, position );
-		const operation = new AttributeOperation( range, key, valueBefore, value, doc.version );
+		const version = range.root.document ? doc.version : null;
+		const operation = new AttributeOperation( range, key, valueBefore, value, version );
 
 		delta.addOperation( operation );
 		model.applyOperation( operation );
@@ -887,19 +881,23 @@ function setAttributeToRange( writer, key, value, range ) {
 // @param {String} key Attribute key.
 // @param {*} value Attribute new value.
 // @param {module:engine/model/item~Item} item Model item on which the attribute will be set.
-function setAttributeToItem( writer, key, value, item ) {
+function setAttributeOnItem( writer, key, value, item ) {
 	const model = writer.model;
 	const doc = model.document;
 	const previousValue = item.getAttribute( key );
 	let range, operation;
 
 	if ( previousValue != value ) {
-		const delta = item.root === item ? new RootAttributeDelta() : new AttributeDelta();
+		const isRootChanged = item.root === item;
+
+		const delta = isRootChanged ? new RootAttributeDelta() : new AttributeDelta();
 		writer.batch.addDelta( delta );
 
-		if ( item.root === item ) {
+		if ( isRootChanged ) {
 			// If we change attributes of root element, we have to use `RootAttributeOperation`.
-			operation = new RootAttributeOperation( item, key, previousValue, value, doc.version );
+			const version = item.document ? doc.version : null;
+
+			operation = new RootAttributeOperation( item, key, previousValue, value, version );
 		} else {
 			if ( item.is( 'element' ) ) {
 				// If we change the attribute of the element, we do not want to change attributes of its children, so
@@ -912,7 +910,9 @@ function setAttributeToItem( writer, key, value, item ) {
 				range = new Range( Position.createBefore( item ), Position.createAfter( item ) );
 			}
 
-			operation = new AttributeOperation( range, key, previousValue, value, doc.version );
+			const version = range.root.document ? doc.version : null;
+
+			operation = new AttributeOperation( range, key, previousValue, value, version );
 		}
 
 		delta.addOperation( operation );
@@ -939,6 +939,30 @@ function addMarkerOperation( writer, name, oldRange, newRange ) {
 	model.applyOperation( operation );
 }
 
+// Creates `RemoveOperation` or `DetachOperation` that removes `howMany` nodes starting from `position`.
+// The operation will be applied on given model instance and added to given delta instance.
+//
+// @private
+// @param {module:engine/model/position~Position} position Position from which nodes are removed.
+// @param {Number} howMany Number of nodes to remove.
+// @param {module:engine/model/delta~Delta} delta Delta to add new operation to.
+// @param {module:engine/model/model~Model} model Model instance on which operation will be applied.
+function addRemoveOperation( position, howMany, delta, model ) {
+	let operation;
+
+	if ( position.root.document ) {
+		const doc = model.document;
+		const graveyardPosition = new Position( doc.graveyard, [ 0 ] );
+
+		operation = new RemoveOperation( position, howMany, graveyardPosition, doc.version );
+	} else {
+		operation = new DetachOperation( position, howMany );
+	}
+
+	delta.addOperation( operation );
+	model.applyOperation( operation );
+}
+
 // Returns `true` if both root elements are the same element or both are documents root elements.
 //
 // Elements in the same tree can be moved (for instance you can move element form one documents root to another, or

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

@@ -329,6 +329,8 @@ export default class Element extends Node {
 	 * @fires module:engine/view/node~Node#change
 	 */
 	setAttribute( key, value ) {
+		value = String( value );
+
 		this._fireChange( 'attributes', this );
 
 		if ( key == 'class' ) {

+ 10 - 5
packages/ckeditor5-engine/src/view/matcher.js

@@ -41,8 +41,9 @@ export default class Matcher {
 	 *
 	 *		matcher.add( {
 	 *			attribute: {
-	 *				title: 'foobar',
-	 *				foo: /^\w+/
+	 *				title: 'foobar',	// Attribute title should equal 'foobar'.
+	 *				foo: /^\w+/,		// Attribute foo should match /^\w+/ regexp.
+	 *				bar: true			// Attribute bar should be set (can be empty).
 	 *			}
 	 *		} );
 	 *
@@ -95,8 +96,10 @@ export default class Matcher {
 	 * {@link module:engine/view/matcher~Matcher#match match} or {@link module:engine/view/matcher~Matcher#matchAll matchAll} methods.
 	 * @param {String|RegExp} [pattern.name] Name or regular expression to match element's name.
 	 * @param {Object} [pattern.attribute] Object with key-value pairs representing attributes to match. Each object key
-	 * represents attribute name. Value under that key can be either a string or a regular expression and it will be
-	 * used to match attribute value.
+	 * represents attribute name. Value under that key can be either:
+	 * * `true` - then attribute is just required (can be empty),
+	 * * a string - then attribute has to be equal, or
+	 * * a regular expression - then attribute has to match the expression.
 	 * @param {String|RegExp|Array} [pattern.class] Class name or array of class names to match. Each name can be
 	 * provided in a form of string or regular expression.
 	 * @param {Object} [pattern.style] Object with key-value pairs representing styles to match. Each object key
@@ -295,7 +298,9 @@ function matchAttributes( patterns, element ) {
 		if ( element.hasAttribute( name ) ) {
 			const attribute = element.getAttribute( name );
 
-			if ( pattern instanceof RegExp ) {
+			if ( pattern === true ) {
+				match.push( name );
+			} else if ( pattern instanceof RegExp ) {
 				if ( pattern.test( attribute ) ) {
 					match.push( name );
 				} else {

+ 21 - 14
packages/ckeditor5-engine/src/view/renderer.js

@@ -570,35 +570,42 @@ export default class Renderer {
 	 */
 	_updateFakeSelection( domRoot ) {
 		const domDocument = domRoot.ownerDocument;
+		let container = this._fakeSelectionContainer;
 
 		// Create fake selection container if one does not exist.
-		if ( !this._fakeSelectionContainer ) {
-			this._fakeSelectionContainer = domDocument.createElement( 'div' );
-			this._fakeSelectionContainer.style.position = 'fixed';
-			this._fakeSelectionContainer.style.top = 0;
-			this._fakeSelectionContainer.style.left = '-9999px';
-			this._fakeSelectionContainer.appendChild( domDocument.createTextNode( '\u00A0' ) );
+		if ( !container ) {
+			this._fakeSelectionContainer = container = domDocument.createElement( 'div' );
+
+			Object.assign( container.style, {
+				position: 'fixed',
+				top: 0,
+				left: '-9999px',
+				// See https://github.com/ckeditor/ckeditor5/issues/752.
+				width: '42px'
+			} );
+
+			// Fill it with a text node so we can update it later.
+			container.appendChild( domDocument.createTextNode( '\u00A0' ) );
 		}
 
 		// Add fake container if not already added.
-		if ( !this._fakeSelectionContainer.parentElement ) {
-			domRoot.appendChild( this._fakeSelectionContainer );
+		if ( !container.parentElement ) {
+			domRoot.appendChild( container );
 		}
 
 		// Update contents.
-		const content = this.selection.fakeSelectionLabel || '\u00A0';
-		this._fakeSelectionContainer.firstChild.data = content;
+		container.firstChild.data = this.selection.fakeSelectionLabel || '\u00A0';
 
 		// Update selection.
 		const domSelection = domDocument.getSelection();
-		domSelection.removeAllRanges();
-
 		const domRange = domDocument.createRange();
-		domRange.selectNodeContents( this._fakeSelectionContainer );
+
+		domSelection.removeAllRanges();
+		domRange.selectNodeContents( container );
 		domSelection.addRange( domRange );
 
 		// Bind fake selection container with current selection.
-		this.domConverter.bindFakeSelection( this._fakeSelectionContainer, this.selection );
+		this.domConverter.bindFakeSelection( container, this.selection );
 	}
 
 	/**

+ 41 - 1
packages/ckeditor5-engine/tests/controller/datacontroller.js

@@ -4,6 +4,7 @@
  */
 
 import Model from '../../src/model/model';
+import Range from '../../src/model/range';
 import DataController from '../../src/controller/datacontroller';
 import HtmlDataProcessor from '../../src/dataprocessor/htmldataprocessor';
 
@@ -15,7 +16,7 @@ import ModelDocumentFragment from '../../src/model/documentfragment';
 import ViewDocumentFragment from '../../src/view/documentfragment';
 
 import { getData, setData, stringify, parse as parseModel } from '../../src/dev-utils/model';
-import { parse as parseView } from '../../src/dev-utils/view';
+import { parse as parseView, stringify as stringifyView } from '../../src/dev-utils/view';
 
 import count from '@ckeditor/ckeditor5-utils/src/count';
 
@@ -312,6 +313,45 @@ describe( 'DataController', () => {
 			expect( viewElement.getChild( 0 ).data ).to.equal( 'foo' );
 		} );
 
+		it( 'should correctly convert document markers #1', () => {
+			const modelElement = parseModel( '<div><paragraph>foobar</paragraph></div>', schema );
+			const modelRoot = model.document.getRoot();
+
+			buildModelConverter().for( data.modelToView ).fromMarker( 'marker:a' ).toHighlight( { class: 'a' } );
+
+			model.change( writer => {
+				writer.insert( modelElement, modelRoot, 0 );
+				writer.setMarker( 'marker:a', Range.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 1 ) );
+			} );
+
+			const viewDocumentFragment = data.toView( modelElement );
+			const viewElement = viewDocumentFragment.getChild( 0 );
+
+			expect( stringifyView( viewElement ) ).to.equal( '<p><span class="a">foobar</span></p>' );
+		} );
+
+		it( 'should correctly convert document markers #2', () => {
+			const modelElement = parseModel( '<div><paragraph>foo</paragraph><paragraph>bar</paragraph></div>', schema );
+			const modelRoot = model.document.getRoot();
+
+			buildModelConverter().for( data.modelToView ).fromMarker( 'marker:a' ).toHighlight( { class: 'a' } );
+			buildModelConverter().for( data.modelToView ).fromMarker( 'marker:b' ).toHighlight( { class: 'b' } );
+
+			const modelP1 = modelElement.getChild( 0 );
+			const modelP2 = modelElement.getChild( 1 );
+
+			model.change( writer => {
+				writer.insert( modelElement, modelRoot, 0 );
+
+				writer.setMarker( 'marker:a', Range.createFromParentsAndOffsets( modelP1, 1, modelP1, 3 ) );
+				writer.setMarker( 'marker:b', Range.createFromParentsAndOffsets( modelP2, 0, modelP2, 2 ) );
+			} );
+
+			const viewDocumentFragment = data.toView( modelP1 );
+
+			expect( stringifyView( viewDocumentFragment ) ).to.equal( 'f<span class="a">oo</span>' );
+		} );
+
 		it( 'should convert a document fragment', () => {
 			const modelDocumentFragment = parseModel( '<paragraph>foo</paragraph><paragraph>bar</paragraph>', schema );
 			const viewDocumentFragment = data.toView( modelDocumentFragment );

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

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

+ 35 - 0
packages/ckeditor5-engine/tests/conversion/modelconsumable.js

@@ -40,6 +40,17 @@ describe( 'ModelConsumable', () => {
 
 			expect( modelConsumable.test( modelTextProxy, 'type' ) ).to.be.true;
 		} );
+
+		it( 'should normalize type name', () => {
+			modelConsumable.add( modelElement, 'foo:bar:baz:abc' );
+
+			expect( modelConsumable.test( modelElement, 'foo:bar:baz:abc' ) ).to.be.true;
+			expect( modelConsumable.test( modelElement, 'foo:bar:baz' ) ).to.be.true;
+			expect( modelConsumable.test( modelElement, 'foo:bar' ) ).to.be.true;
+			expect( modelConsumable.test( modelElement, 'foo:bar:xxx' ) ).to.be.true;
+
+			expect( modelConsumable.test( modelElement, 'foo:xxx' ) ).to.be.null;
+		} );
 	} );
 
 	describe( 'consume', () => {
@@ -74,6 +85,17 @@ describe( 'ModelConsumable', () => {
 			expect( result ).to.be.true;
 			expect( modelConsumable.test( proxy1To4, 'type' ) ).to.be.false;
 		} );
+
+		it( 'should normalize type name', () => {
+			modelConsumable.add( modelElement, 'foo:bar:baz:abc' );
+			const result = modelConsumable.consume( modelElement, 'foo:bar:baz' );
+
+			expect( result ).to.be.true;
+
+			expect( modelConsumable.test( modelElement, 'foo:bar:baz:abc' ) ).to.be.false;
+			expect( modelConsumable.test( modelElement, 'foo:bar:baz' ) ).to.be.false;
+			expect( modelConsumable.test( modelElement, 'foo:bar' ) ).to.be.false;
+		} );
 	} );
 
 	describe( 'revert', () => {
@@ -112,6 +134,19 @@ describe( 'ModelConsumable', () => {
 			expect( result ).to.be.true;
 			expect( modelConsumable.test( modelTextProxy, 'type' ) ).to.be.true;
 		} );
+
+		it( 'should normalize type name', () => {
+			modelConsumable.add( modelElement, 'foo:bar:baz:abc' );
+			modelConsumable.consume( modelElement, 'foo:bar:baz' );
+
+			const result = modelConsumable.revert( modelElement, 'foo:bar:baz' );
+
+			expect( result ).to.be.true;
+
+			expect( modelConsumable.test( modelElement, 'foo:bar:baz:abc' ) ).to.be.true;
+			expect( modelConsumable.test( modelElement, 'foo:bar:baz' ) ).to.be.true;
+			expect( modelConsumable.test( modelElement, 'foo:bar' ) ).to.be.true;
+		} );
 	} );
 
 	describe( 'test', () => {

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

@@ -246,9 +246,9 @@ describe( 'debug tools', () => {
 			} );
 
 			it( 'DetachOperation (text node)', () => {
-				const op = new DetachOperation( ModelPosition.createAt( modelRoot, 0 ), 3, 0 );
+				const op = new DetachOperation( ModelPosition.createAt( modelRoot, 0 ), 3 );
 
-				expect( op.toString() ).to.equal( 'DetachOperation( 0 ): #foo -> main [ 0 ] - [ 3 ]' );
+				expect( op.toString() ).to.equal( 'DetachOperation( null ): #foo -> main [ 0 ] - [ 3 ]' );
 
 				op.log();
 				expect( log.calledWithExactly( op.toString() ) ).to.be.true;
@@ -258,9 +258,9 @@ describe( 'debug tools', () => {
 				const element = new ModelElement( 'element' );
 				modelRoot.insertChildren( 0, element );
 
-				const op = new DetachOperation( ModelPosition.createBefore( element ), 1, 0 );
+				const op = new DetachOperation( ModelPosition.createBefore( element ), 1 );
 
-				expect( op.toString() ).to.equal( 'DetachOperation( 0 ): <element> -> main [ 0 ] - [ 1 ]' );
+				expect( op.toString() ).to.equal( 'DetachOperation( null ): <element> -> main [ 0 ] - [ 1 ]' );
 
 				op.log();
 				expect( log.calledWithExactly( op.toString() ) ).to.be.true;
@@ -270,9 +270,9 @@ describe( 'debug tools', () => {
 				const element = new ModelElement( 'element' );
 				modelRoot.insertChildren( 0, element );
 
-				const op = new DetachOperation( ModelPosition.createBefore( element ), 2, 0 );
+				const op = new DetachOperation( ModelPosition.createBefore( element ), 2 );
 
-				expect( op.toString() ).to.equal( 'DetachOperation( 0 ): [ 2 ] -> main [ 0 ] - [ 2 ]' );
+				expect( op.toString() ).to.equal( 'DetachOperation( null ): [ 2 ] -> main [ 0 ] - [ 2 ]' );
 
 				op.log();
 				expect( log.calledWithExactly( op.toString() ) ).to.be.true;

+ 15 - 0
packages/ckeditor5-engine/tests/model/batch.js

@@ -38,6 +38,21 @@ describe( 'Batch', () => {
 
 			expect( batch.baseVersion ).to.be.null;
 		} );
+
+		it( 'should return null if all deltas in batch have base version set to null', () => {
+			const batch = new Batch();
+
+			const deltaA = new Delta();
+			deltaA.addOperation( new Operation( null ) );
+
+			const deltaB = new Delta();
+			deltaB.addOperation( new Operation( null ) );
+
+			batch.addDelta( deltaA );
+			batch.addDelta( deltaB );
+
+			expect( batch.baseVersion ).to.equal( null );
+		} );
 	} );
 
 	describe( 'addDelta()', () => {

+ 1 - 4
packages/ckeditor5-engine/tests/model/liverange.js

@@ -694,11 +694,8 @@ describe( 'LiveRange', () => {
 	describe( 'should not get transformed and not fire change event if', () => {
 		let otherRoot, spy, live, clone;
 
-		before( () => {
-			otherRoot = doc.createRoot( '$root', 'otherRoot' );
-		} );
-
 		beforeEach( () => {
+			otherRoot = doc.createRoot( '$root', 'otherRoot' );
 			live = new LiveRange( new Position( root, [ 0, 1, 4 ] ), new Position( root, [ 0, 2, 2 ] ) );
 			clone = Range.createFromRange( live );
 

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

@@ -4,7 +4,6 @@
  */
 
 import Model from '../../../src/model/model';
-import DocumentFragment from '../../../src/model/documentfragment';
 import Element from '../../../src/model/element';
 import Text from '../../../src/model/text';
 import AttributeOperation from '../../../src/model/operation/attributeoperation';
@@ -61,34 +60,6 @@ describe( 'AttributeOperation', () => {
 		} );
 	} );
 
-	describe( 'isDocumentOperation', () => {
-		it( 'should return true when attribute is applied on attached items', () => {
-			const op = new AttributeOperation(
-				new Range( new Position( root, [ 0 ] ), new Position( root, [ 2 ] ) ),
-				'key',
-				'oldValue',
-				'newValue',
-				doc.version
-			);
-
-			expect( op.isDocumentOperation ).to.true;
-		} );
-
-		it( 'should return false when attribute is applied on detached items', () => {
-			const docFrag = new DocumentFragment( [ new Text( 'abc' ) ] );
-
-			const op = new AttributeOperation(
-				Range.createIn( docFrag ),
-				'key',
-				'oldValue',
-				'newValue',
-				doc.version
-			);
-
-			expect( op.isDocumentOperation ).to.false;
-		} );
-	} );
-
 	it( 'should insert attribute to the set of nodes', () => {
 		root.insertChildren( 0, new Text( 'bar' ) );
 

+ 6 - 6
packages/ckeditor5-engine/tests/model/operation/detachoperation.js

@@ -22,13 +22,13 @@ describe( 'DetachOperation', () => {
 	} );
 
 	it( 'should have type equal to detach', () => {
-		const op = new DetachOperation( Position.createBefore( element ), 1, doc.version );
+		const op = new DetachOperation( Position.createBefore( element ), 1 );
 
 		expect( op.type ).to.equal( 'detach' );
 	} );
 
 	it( 'should remove given element from parent', () => {
-		const op = new DetachOperation( Position.createBefore( element ), 1, doc.version );
+		const op = new DetachOperation( Position.createBefore( element ), 1 );
 
 		model.applyOperation( wrapInDelta( op ) );
 
@@ -42,7 +42,7 @@ describe( 'DetachOperation', () => {
 
 			root.appendChildren( [ element ] );
 
-			const op = new DetachOperation( Position.createBefore( element ), 1, doc.version );
+			const op = new DetachOperation( Position.createBefore( element ), 1 );
 
 			expect( () => {
 				op._validate();
@@ -51,7 +51,7 @@ describe( 'DetachOperation', () => {
 	} );
 
 	it( 'should be not a document operation', () => {
-		const op = new DetachOperation( Position.createBefore( element ), 1, doc.version );
+		const op = new DetachOperation( Position.createBefore( element ), 1 );
 
 		expect( op.isDocumentOperation ).to.false;
 	} );
@@ -59,13 +59,13 @@ describe( 'DetachOperation', () => {
 	describe( 'toJSON', () => {
 		it( 'should create proper json object', () => {
 			const position = Position.createBefore( element );
-			const op = new DetachOperation( position, 1, doc.version );
+			const op = new DetachOperation( position, 1 );
 
 			const serialized = jsonParseStringify( op );
 
 			expect( serialized ).to.deep.equal( {
 				__className: 'engine.model.operation.DetachOperation',
-				baseVersion: 0,
+				baseVersion: null,
 				sourcePosition: jsonParseStringify( position ),
 				howMany: 1
 			} );

+ 0 - 25
packages/ckeditor5-engine/tests/model/operation/insertoperation.js

@@ -6,7 +6,6 @@
 import Model from '../../../src/model/model';
 import NodeList from '../../../src/model/nodelist';
 import Element from '../../../src/model/element';
-import DocumentFragment from '../../../src/model/documentfragment';
 import InsertOperation from '../../../src/model/operation/insertoperation';
 import RemoveOperation from '../../../src/model/operation/removeoperation';
 import Position from '../../../src/model/position';
@@ -209,30 +208,6 @@ describe( 'InsertOperation', () => {
 		expect( op2.nodes.getNode( 0 ) ).not.to.equal( text );
 	} );
 
-	describe( 'isDocumentOperation', () => {
-		it( 'should return true when element is inserted to the document', () => {
-			const op = new InsertOperation(
-				new Position( root, [ 0 ] ),
-				new Text( 'x' ),
-				doc.version
-			);
-
-			expect( op.isDocumentOperation ).to.true;
-		} );
-
-		it( 'should return false when element is inserted to document fragment', () => {
-			const docFrag = new DocumentFragment();
-
-			const op = new InsertOperation(
-				new Position( docFrag, [ 0 ] ),
-				new Text( 'x' ),
-				doc.version
-			);
-
-			expect( op.isDocumentOperation ).to.false;
-		} );
-	} );
-
 	describe( '_validate()', () => {
 		it( 'should throw an error if target position does not exist', () => {
 			const element = new Element( 'p' );

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

@@ -128,26 +128,6 @@ describe( 'MarkerOperation', () => {
 		expect( clone ).to.deep.equal( op );
 	} );
 
-	describe( 'isDocumentOperation', () => {
-		it( 'should return true when new marker range is added to the document', () => {
-			const op = new MarkerOperation( 'name', null, range, model.markers, doc.version );
-
-			expect( op.isDocumentOperation ).to.true;
-		} );
-
-		it( 'should return false when marker range is removed from the document', () => {
-			const op = new MarkerOperation( 'name', range, null, model.markers, doc.version );
-
-			expect( op.isDocumentOperation ).to.true;
-		} );
-
-		it( 'should return true when non-existing marker range is removed from the document', () => {
-			const op = new MarkerOperation( 'name', null, null, model.markers, doc.version );
-
-			expect( op.isDocumentOperation ).to.true;
-		} );
-	} );
-
 	describe( 'toJSON', () => {
 		it( 'should create proper serialized object', () => {
 			const op = new MarkerOperation( 'name', null, range, model.markers, doc.version );

+ 0 - 27
packages/ckeditor5-engine/tests/model/operation/moveoperation.js

@@ -6,7 +6,6 @@
 import Model from '../../../src/model/model';
 import MoveOperation from '../../../src/model/operation/moveoperation';
 import Position from '../../../src/model/position';
-import DocumentFragment from '../../../src/model/documentfragment';
 import Element from '../../../src/model/element';
 import Text from '../../../src/model/text';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
@@ -259,32 +258,6 @@ describe( 'MoveOperation', () => {
 		expect( clone.baseVersion ).to.equal( baseVersion );
 	} );
 
-	describe( 'isDocumentOperation', () => {
-		it( 'should return root when operation is executed on attached items', () => {
-			const op = new MoveOperation(
-				new Position( root, [ 0, 0 ] ),
-				1,
-				new Position( root, [ 1, 0 ] ),
-				doc.version
-			);
-
-			expect( op.isDocumentOperation ).to.true;
-		} );
-
-		it( 'should return false when operation is executed on detached items', () => {
-			const docFrag = new DocumentFragment( [ new Text( 'abc' ) ] );
-
-			const op = new MoveOperation(
-				new Position( docFrag, [ 0 ] ),
-				1,
-				new Position( docFrag, [ 2 ] ),
-				doc.version
-			);
-
-			expect( op.isDocumentOperation ).to.false;
-		} );
-	} );
-
 	describe( 'getMovedRangeStart', () => {
 		it( 'should return move operation target position transformed by removing move operation source range', () => {
 			const sourcePosition = new Position( root, [ 0, 2 ] );

+ 0 - 4
packages/ckeditor5-engine/tests/model/operation/nooperation.js

@@ -34,10 +34,6 @@ describe( 'NoOperation', () => {
 		expect( clone.baseVersion ).to.equal( 0 );
 	} );
 
-	it( 'should be a document operation', () => {
-		expect( noop.isDocumentOperation ).to.true;
-	} );
-
 	describe( 'toJSON', () => {
 		it( 'should create proper json object', () => {
 			const serialized = jsonParseStringify( noop );

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

@@ -26,6 +26,20 @@ describe( 'Operation', () => {
 		expect( parsedOutside.delta ).to.be.undefined;
 	} );
 
+	describe( 'isDocumentOperation', () => {
+		it( 'operation is a document operation if it has base version set', () => {
+			const op = new Operation( 0 );
+
+			expect( op.isDocumentOperation ).to.be.true;
+		} );
+
+		it( 'operation is not a document operation if base version is null', () => {
+			const op = new Operation( null );
+
+			expect( op.isDocumentOperation ).to.be.false;
+		} );
+	} );
+
 	describe( 'toJSON', () => {
 		it( 'should create proper json object', () => {
 			const op = new Operation( 4 );

+ 0 - 4
packages/ckeditor5-engine/tests/model/operation/reinsertoperation.js

@@ -102,10 +102,6 @@ describe( 'ReinsertOperation', () => {
 		expect( graveyard.maxOffset ).to.equal( 2 );
 	} );
 
-	it( 'should be a document operation', () => {
-		expect( operation.isDocumentOperation ).to.true;
-	} );
-
 	describe( '_validate()', () => {
 		it( 'should throw when target position is not in the document', () => {
 			const docFrag = new DocumentFragment();

+ 0 - 11
packages/ckeditor5-engine/tests/model/operation/removeoperation.js

@@ -163,17 +163,6 @@ describe( 'RemoveOperation', () => {
 		} );
 	} );
 
-	it( 'should always be a document operation', () => {
-		const op = new RemoveOperation(
-			new Position( root, [ 2 ] ),
-			2,
-			new Position( doc.graveyard, [ 0 ] ),
-			doc.version
-		);
-
-		expect( op.isDocumentOperation ).to.true;
-	} );
-
 	describe( 'toJSON', () => {
 		it( 'should create proper json object', () => {
 			const op = new RemoveOperation(

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

@@ -4,7 +4,6 @@
  */
 
 import Model from '../../../src/model/model';
-import DocumentFragment from '../../../src/model/documentfragment';
 import Element from '../../../src/model/element';
 import RenameOperation from '../../../src/model/operation/renameoperation';
 import Position from '../../../src/model/position';
@@ -104,21 +103,6 @@ describe( 'RenameOperation', () => {
 		expect( clone.newName ).to.equal( newName );
 	} );
 
-	describe( 'isDocumentOperation', () => {
-		it( 'should be true when target item is in the document', () => {
-			const op = new RenameOperation( position, oldName, newName, doc.version );
-
-			expect( op.isDocumentOperation ).to.true;
-		} );
-
-		it( 'should be false when target item is not in the document', () => {
-			const docFrag = new DocumentFragment( [ new Element( 'element' ) ] );
-			const op = new RenameOperation( Position.createAt( docFrag ), oldName, newName, doc.version );
-
-			expect( op.isDocumentOperation ).to.false;
-		} );
-	} );
-
 	describe( 'toJSON', () => {
 		it( 'should create proper serialized object', () => {
 			const op = new RenameOperation( Position.createAt( root, 'end' ), oldName, newName, doc.version );

+ 35 - 30
packages/ckeditor5-engine/tests/model/operation/rootattributeoperation.js

@@ -4,6 +4,7 @@
  */
 
 import Model from '../../../src/model/model';
+import DocumentFragment from '../../../src/model/documentfragment';
 import Element from '../../../src/model/element';
 import RootAttributeOperation from '../../../src/model/operation/rootattributeoperation';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
@@ -56,34 +57,6 @@ describe( 'RootAttributeOperation', () => {
 		} );
 	} );
 
-	describe( 'isDocumentOperation', () => {
-		it( 'should be true when root is in the document', () => {
-			const operation = new RootAttributeOperation(
-				root,
-				'isNew',
-				null,
-				true,
-				doc.version
-			);
-
-			expect( operation.isDocumentOperation ).to.true;
-		} );
-
-		it( 'should be false when root is not in the document', () => {
-			const element = new Element( 'element' );
-
-			const operation = new RootAttributeOperation(
-				element,
-				'isNew',
-				null,
-				true,
-				doc.version
-			);
-
-			expect( operation.isDocumentOperation ).to.false;
-		} );
-	} );
-
 	it( 'should add attribute on the root element', () => {
 		model.applyOperation( wrapInDelta(
 			new RootAttributeOperation(
@@ -204,7 +177,39 @@ describe( 'RootAttributeOperation', () => {
 	} );
 
 	describe( '_validate()', () => {
-		it( 'should throw an error when one try to remove and the attribute does not exists', () => {
+		it( 'should throw an error when trying to change non-root element', () => {
+			const child = new Element( 'p' );
+			const parent = new Element( 'p' );
+			parent.appendChildren( child );
+
+			expect( () => {
+				const op = new RootAttributeOperation(
+					child,
+					'foo',
+					null,
+					'bar',
+					null
+				);
+
+				op._validate();
+			} ).to.throw( CKEditorError, /rootattribute-operation-not-a-root/ );
+		} );
+
+		it( 'should throw an error when trying to change document fragment', () => {
+			expect( () => {
+				const op = new RootAttributeOperation(
+					new DocumentFragment(),
+					'foo',
+					null,
+					'bar',
+					null
+				);
+
+				op._validate();
+			} ).to.throw( CKEditorError, /rootattribute-operation-not-a-root/ );
+		} );
+
+		it( 'should throw an error when trying to remove an attribute that does not exists', () => {
 			expect( () => {
 				const op = new RootAttributeOperation(
 					root,
@@ -218,7 +223,7 @@ describe( 'RootAttributeOperation', () => {
 			} ).to.throw( CKEditorError, /rootattribute-operation-wrong-old-value/ );
 		} );
 
-		it( 'should throw an error when one try to insert and the attribute already exists', () => {
+		it( 'should throw an error when trying to add an attribute that already exists', () => {
 			root.setAttribute( 'x', 1 );
 
 			expect( () => {

+ 65 - 9
packages/ckeditor5-engine/tests/model/writer.js

@@ -189,7 +189,7 @@ describe( 'Writer', () => {
 			expect( spy.lastCall.args[ 0 ].delta.batch ).to.equal( batch );
 		} );
 
-		it( 'should move element from one parent to the other within the same document (documentA -> documentA)', () => {
+		it( 'should move element from one parent to the other within the same document (rootA -> rootA)', () => {
 			const root = doc.createRoot();
 			const parent1 = createElement( 'parent' );
 			const parent2 = createElement( 'parent' );
@@ -213,7 +213,7 @@ describe( 'Writer', () => {
 			expect( spy.firstCall.args[ 0 ].delta.batch ).to.equal( batch );
 		} );
 
-		it( 'should move element from one parent to the other within the same document (documentA -> documentB)', () => {
+		it( 'should move element from one parent to the other within the same document (rootA -> rootB)', () => {
 			const rootA = doc.createRoot( '$root', 'A' );
 			const rootB = doc.createRoot( '$root', 'B' );
 			const node = createText( 'foo' );
@@ -1439,6 +1439,18 @@ describe( 'Writer', () => {
 			expect( root.getChild( 0 ).getChild( 0 ).data ).to.equal( 'foobar' );
 		} );
 
+		it( 'should correctly merge in document fragment', () => {
+			const docFrag = new DocumentFragment( [
+				new Element( 'p', null, 'foo' ),
+				new Element( 'p', null, 'bar' )
+			] );
+
+			merge( new Position( docFrag, [ 1 ] ) );
+
+			expect( docFrag.getChild( 0 ).name ).to.equal( 'p' );
+			expect( docFrag.getChild( 0 ).getChild( 0 ).data ).to.equal( 'foobar' );
+		} );
+
 		it( 'should throw if there is no element after', () => {
 			expect( () => {
 				merge( new Position( root, [ 2 ] ) );
@@ -1653,22 +1665,30 @@ describe( 'Writer', () => {
 	} );
 
 	describe( 'rename()', () => {
-		let root;
-
-		beforeEach( () => {
-			root = doc.createRoot();
-
+		it( 'should rename given element', () => {
+			const root = doc.createRoot();
 			const p = new Element( 'p', null, new Text( 'abc' ) );
+
 			root.appendChildren( p );
 
 			rename( p, 'h' );
-		} );
 
-		it( 'should rename given element', () => {
 			expect( root.maxOffset ).to.equal( 1 );
 			expect( root.getChild( 0 ) ).to.have.property( 'name', 'h' );
 		} );
 
+		it( 'should rename in document fragment', () => {
+			const docFrag = new DocumentFragment();
+			const p = new Element( 'p' );
+
+			docFrag.appendChildren( p );
+
+			rename( p, 'h' );
+
+			expect( docFrag.maxOffset ).to.equal( 1 );
+			expect( docFrag.getChild( 0 ) ).to.have.property( 'name', 'h' );
+		} );
+
 		it( 'should throw if not an Element instance is passed', () => {
 			expect( () => {
 				rename( new Text( 'abc' ), 'h' );
@@ -1714,6 +1734,23 @@ describe( 'Writer', () => {
 			expect( root.getChild( 1 ).getChild( 0 ).data ).to.equal( 'bar' );
 		} );
 
+		it( 'should split inside document fragment', () => {
+			const docFrag = new DocumentFragment();
+			docFrag.appendChildren( new Element( 'p', null, new Text( 'foobar' ) ) );
+
+			split( new Position( docFrag, [ 0, 3 ] ) );
+
+			expect( docFrag.maxOffset ).to.equal( 2 );
+
+			expect( docFrag.getChild( 0 ).name ).to.equal( 'p' );
+			expect( docFrag.getChild( 0 ).maxOffset ).to.equal( 3 );
+			expect( docFrag.getChild( 0 ).getChild( 0 ).data ).to.equal( 'foo' );
+
+			expect( docFrag.getChild( 1 ).name ).to.equal( 'p' );
+			expect( docFrag.getChild( 1 ).maxOffset ).to.equal( 3 );
+			expect( docFrag.getChild( 1 ).getChild( 0 ).data ).to.equal( 'bar' );
+		} );
+
 		it( 'should create an empty paragraph if we split at the end', () => {
 			split( new Position( root, [ 0, 6 ] ) );
 
@@ -1794,6 +1831,16 @@ describe( 'Writer', () => {
 			expect( root.getChild( 2 ).data ).to.equal( 'ar' );
 		} );
 
+		it( 'should wrap inside document fragment', () => {
+			const docFrag = new DocumentFragment( new Text( 'foo' ) );
+
+			wrap( Range.createIn( docFrag ), 'p' );
+
+			expect( docFrag.maxOffset ).to.equal( 1 );
+			expect( docFrag.getChild( 0 ).name ).to.equal( 'p' );
+			expect( docFrag.getChild( 0 ).getChild( 0 ).data ).to.equal( 'foo' );
+		} );
+
 		it( 'should throw if range to wrap is not flat', () => {
 			root.insertChildren( 1, [ new Element( 'p', [], new Text( 'xyz' ) ) ] );
 			const notFlatRange = new Range( new Position( root, [ 3 ] ), new Position( root, [ 6, 2 ] ) );
@@ -1846,6 +1893,15 @@ describe( 'Writer', () => {
 			expect( root.getChild( 0 ).data ).to.equal( 'axyzb' );
 		} );
 
+		it( 'should unwrap inside document fragment', () => {
+			const docFrag = new DocumentFragment( new Element( 'p', null, new Text( 'foo' ) ) );
+
+			unwrap( docFrag.getChild( 0 ) );
+
+			expect( docFrag.maxOffset ).to.equal( 3 );
+			expect( docFrag.getChild( 0 ).data ).to.equal( 'foo' );
+		} );
+
 		it( 'should throw if element to unwrap has no parent', () => {
 			const element = new Element( 'p' );
 

+ 6 - 0
packages/ckeditor5-engine/tests/view/element.js

@@ -430,6 +430,12 @@ describe( 'Element', () => {
 				expect( el._attrs.get( 'foo' ) ).to.equal( 'bar' );
 			} );
 
+			it( 'should cast attribute value to a string', () => {
+				el.setAttribute( 'foo', true );
+
+				expect( el._attrs.get( 'foo' ) ).to.equal( 'true' );
+			} );
+
 			it( 'should fire change event with attributes type', done => {
 				el.once( 'change:attributes', eventInfo => {
 					expect( eventInfo.source ).to.equal( el );

+ 28 - 0
packages/ckeditor5-engine/tests/view/matcher.js

@@ -126,6 +126,34 @@ describe( 'Matcher', () => {
 			expect( matcher.match( el3 ) ).to.be.null;
 		} );
 
+		it( 'should match if element has given attribute', () => {
+			const pattern = {
+				attribute: {
+					title: true
+				}
+			};
+			const matcher = new Matcher( pattern );
+			const el1 = new Element( 'p', { title: 'foobar'	} );
+			const el2 = new Element( 'p', { title: '' } );
+			const el3 = new Element( 'p' );
+
+			let result = matcher.match( el1 );
+			expect( result ).to.be.an( 'object' );
+			expect( result ).to.have.property( 'element' ).that.equal( el1 );
+			expect( result ).to.have.property( 'pattern' ).that.equal( pattern );
+			expect( result ).to.have.property( 'match' ).that.has.property( 'attribute' ).that.is.an( 'array' );
+			expect( result.match.attribute[ 0 ] ).equal( 'title' );
+
+			result = matcher.match( el2 );
+			expect( result ).to.be.an( 'object' );
+			expect( result ).to.have.property( 'element' ).that.equal( el2 );
+			expect( result ).to.have.property( 'pattern' ).that.equal( pattern );
+			expect( result ).to.have.property( 'match' ).that.has.property( 'attribute' ).that.is.an( 'array' );
+			expect( result.match.attribute[ 0 ] ).equal( 'title' );
+
+			expect( matcher.match( el3 ) ).to.be.null;
+		} );
+
 		it( 'should match element class names', () => {
 			const pattern = { class: 'foobar' };
 			const matcher = new Matcher( pattern );