浏览代码

Merge branch 'master' into t/1626

Krzysztof Krztoń 7 年之前
父节点
当前提交
c1086a42e6
共有 31 个文件被更改,包括 1686 次插入1264 次删除
  1. 1 1
      packages/ckeditor5-engine/src/controller/datacontroller.js
  2. 2 3
      packages/ckeditor5-engine/src/controller/editingcontroller.js
  3. 13 28
      packages/ckeditor5-engine/src/conversion/conversion.js
  4. 0 129
      packages/ckeditor5-engine/src/conversion/downcast-selection-converters.js
  5. 49 8
      packages/ckeditor5-engine/src/conversion/downcastdispatcher.js
  6. 121 2
      packages/ckeditor5-engine/src/conversion/downcasthelpers.js
  7. 1 1
      packages/ckeditor5-engine/src/conversion/modelconsumable.js
  8. 0 0
      packages/ckeditor5-engine/src/conversion/upcast-converters.js
  9. 0 48
      packages/ckeditor5-engine/src/conversion/upcast-selection-converters.js
  10. 22 11
      packages/ckeditor5-engine/src/conversion/upcastdispatcher.js
  11. 37 1
      packages/ckeditor5-engine/src/conversion/upcasthelpers.js
  12. 6 3
      packages/ckeditor5-engine/src/dev-utils/model.js
  13. 70 7
      packages/ckeditor5-engine/src/model/documentselection.js
  14. 4 14
      packages/ckeditor5-engine/src/model/model.js
  15. 3 3
      packages/ckeditor5-engine/src/model/schema.js
  16. 67 15
      packages/ckeditor5-engine/src/model/selection.js
  17. 1 3
      packages/ckeditor5-engine/src/model/utils/insertcontent.js
  18. 8 14
      packages/ckeditor5-engine/src/model/writer.js
  19. 3 9
      packages/ckeditor5-engine/src/view/documentselection.js
  20. 3 9
      packages/ckeditor5-engine/src/view/downcastwriter.js
  21. 19 10
      packages/ckeditor5-engine/src/view/selection.js
  22. 1 3
      packages/ckeditor5-engine/src/view/upcastwriter.js
  23. 1 3
      packages/ckeditor5-engine/src/view/view.js
  24. 37 2
      packages/ckeditor5-engine/tests/conversion/conversion.js
  25. 0 596
      packages/ckeditor5-engine/tests/conversion/downcast-selection-converters.js
  26. 768 208
      packages/ckeditor5-engine/tests/conversion/downcasthelpers.js
  27. 0 131
      packages/ckeditor5-engine/tests/conversion/upcast-selection-converters.js
  28. 122 2
      packages/ckeditor5-engine/tests/conversion/upcasthelpers.js
  29. 247 0
      packages/ckeditor5-engine/tests/model/documentselection.js
  30. 68 0
      packages/ckeditor5-engine/tests/model/selection.js
  31. 12 0
      packages/ckeditor5-engine/tests/model/writer.js

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

@@ -59,7 +59,7 @@ export default class DataController {
 		 * Data processor used during the conversion.
 		 *
 		 * @readonly
-		 * @member {module:engine/dataProcessor~DataProcessor}
+		 * @member {module:engine/dataprocessor/dataprocessor~DataProcessor}
 		 */
 		this.processor = dataProcessor;
 

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

@@ -11,12 +11,11 @@ import RootEditableElement from '../view/rooteditableelement';
 import View from '../view/view';
 import Mapper from '../conversion/mapper';
 import DowncastDispatcher from '../conversion/downcastdispatcher';
-import { insertText, remove } from '../conversion/downcasthelpers';
-import { convertSelectionChange } from '../conversion/upcast-selection-converters';
-import { clearAttributes, convertCollapsedSelection, convertRangeSelection } from '../conversion/downcast-selection-converters';
+import { clearAttributes, convertCollapsedSelection, convertRangeSelection, insertText, remove } from '../conversion/downcasthelpers';
 
 import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin';
 import mix from '@ckeditor/ckeditor5-utils/src/mix';
+import { convertSelectionChange } from '../conversion/upcasthelpers';
 
 /**
  * Controller for the editing pipeline. The editing pipeline controls {@link ~EditingController#model model} rendering,

+ 13 - 28
packages/ckeditor5-engine/src/conversion/conversion.js

@@ -60,7 +60,7 @@ export default class Conversion {
 		 * @private
 		 * @member {Map}
 		 */
-		this._dispatchersGroups = new Map();
+		this._conversionHelpers = new Map();
 	}
 
 	/**
@@ -70,17 +70,12 @@ export default class Conversion {
 	 * If a given group name is used for the second time, the
 	 * {@link module:utils/ckeditorerror~CKEditorError `conversion-register-group-exists` error} is thrown.
 	 *
-	 * @param {Object} options
-	 * @param {String} options.name The name for dispatchers group.
-	 * @param {module:engine/conversion/downcastdispatcher~DowncastDispatcher|
-	 * module:engine/conversion/upcastdispatcher~UpcastDispatcher|Array.<module:engine/conversion/downcastdispatcher~DowncastDispatcher|
-	 * module:engine/conversion/upcastdispatcher~UpcastDispatcher>} options.dispatcher Dispatcher or array of dispatchers to register
-	 * under the given name.
+	 * @param {String} name The name for dispatchers group.
 	 * @param {module:engine/conversion/downcasthelpers~DowncastHelpers|
-	 * module:engine/conversion/upcasthelpers~UpcastHelpers} helpers
+	 * module:engine/conversion/upcasthelpers~UpcastHelpers} conversionHelpers
 	 */
-	register( name, group ) {
-		if ( this._dispatchersGroups.has( name ) ) {
+	register( name, conversionHelpers ) {
+		if ( this._conversionHelpers.has( name ) ) {
 			/**
 			 * Trying to register a group name that was already registered.
 			 *
@@ -89,7 +84,7 @@ export default class Conversion {
 			throw new CKEditorError( 'conversion-register-group-exists: Trying to register a group name that was already registered.' );
 		}
 
-		this._dispatchersGroups.set( name, group );
+		this._conversionHelpers.set( name, conversionHelpers );
 	}
 
 	/**
@@ -138,9 +133,7 @@ export default class Conversion {
 	 * @returns {module:engine/conversion/downcasthelpers~DowncastHelpers|module:engine/conversion/upcasthelpers~UpcastHelpers}
 	 */
 	for( groupName ) {
-		const group = this._getDispatchersGroup( groupName );
-
-		return group;
+		return this._getConversionHelpers( groupName );
 	}
 
 	/**
@@ -396,7 +389,7 @@ export default class Conversion {
 				.elementToAttribute( {
 					view,
 					model,
-					converterPriority: definition.priority
+					converterPriority: definition.converterPriority
 				} );
 		}
 	}
@@ -526,17 +519,17 @@ export default class Conversion {
 	}
 
 	/**
-	 * Returns dispatchers group registered under a given group name.
+	 * Returns conversion helpers registered under a given name.
 	 *
 	 * If the given group name has not been registered, the
 	 * {@link module:utils/ckeditorerror~CKEditorError `conversion-for-unknown-group` error} is thrown.
 	 *
 	 * @private
 	 * @param {String} groupName
-	 * @returns {module:engine/conversion/conversion~DispatchersGroup}
+	 * @returns {module:engine/conversion/downcasthelpers~DowncastHelpers|module:engine/conversion/upcasthelpers~UpcastHelpers}
 	 */
-	_getDispatchersGroup( groupName ) {
-		if ( !this._dispatchersGroups.has( groupName ) ) {
+	_getConversionHelpers( groupName ) {
+		if ( !this._conversionHelpers.has( groupName ) ) {
 			/**
 			 * Trying to add a converter to an unknown dispatchers group.
 			 *
@@ -545,7 +538,7 @@ export default class Conversion {
 			throw new CKEditorError( 'conversion-for-unknown-group: Trying to add a converter to an unknown dispatchers group.' );
 		}
 
-		return this._dispatchersGroups.get( groupName );
+		return this._conversionHelpers.get( groupName );
 	}
 }
 
@@ -566,14 +559,6 @@ export default class Conversion {
  * @property {module:utils/priorities~PriorityString} [converterPriority] The converter priority.
  */
 
-/**
- * @typedef {Object} module:engine/conversion/conversion~DispatchersGroup
- * @property {String} name Group name
- * @property {Array.<module:engine/conversion/downcastdispatcher~DowncastDispatcher|
- * module:engine/conversion/upcastdispatcher~UpcastDispatcher>} dispatchers
- * @property {module:engine/conversion/downcasthelpers~DowncastHelpers|module:engine/conversion/upcasthelpers~UpcastHelpers} helpers
- */
-
 // Helper function that creates a joint array out of an item passed in `definition.view` and items passed in
 // `definition.upcastAlso`.
 //

+ 0 - 129
packages/ckeditor5-engine/src/conversion/downcast-selection-converters.js

@@ -1,129 +0,0 @@
-/**
- * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
-
-/**
- * Contains {@link module:engine/model/selection~Selection model selection} to
- * {@link module:engine/view/documentselection~DocumentSelection view selection} converters for
- * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher downcast dispatcher}.
- *
- * @module engine/conversion/downcast-selection-converters
- */
-
-/**
- * Function factory that creates a converter which converts a non-collapsed {@link module:engine/model/selection~Selection model selection}
- * to a {@link module:engine/view/documentselection~DocumentSelection view selection}. The converter consumes appropriate
- * value from the `consumable` object and maps model positions from the selection to view positions.
- *
- *		modelDispatcher.on( 'selection', convertRangeSelection() );
- *
- * @returns {Function} Selection converter.
- */
-export function convertRangeSelection() {
-	return ( evt, data, conversionApi ) => {
-		const selection = data.selection;
-
-		if ( selection.isCollapsed ) {
-			return;
-		}
-
-		if ( !conversionApi.consumable.consume( selection, 'selection' ) ) {
-			return;
-		}
-
-		const viewRanges = [];
-
-		for ( const range of selection.getRanges() ) {
-			const viewRange = conversionApi.mapper.toViewRange( range );
-			viewRanges.push( viewRange );
-		}
-
-		conversionApi.writer.setSelection( viewRanges, { backward: selection.isBackward } );
-	};
-}
-
-/**
- * Function factory that creates a converter which converts a collapsed {@link module:engine/model/selection~Selection model selection} to
- * a {@link module:engine/view/documentselection~DocumentSelection view selection}. The converter consumes appropriate
- * value from the `consumable` object, maps the model selection position to the view position and breaks
- * {@link module:engine/view/attributeelement~AttributeElement attribute elements} at the selection position.
- *
- *		modelDispatcher.on( 'selection', convertCollapsedSelection() );
- *
- * An example of the view state before and after converting the collapsed selection:
- *
- *		   <p><strong>f^oo<strong>bar</p>
- *		-> <p><strong>f</strong>^<strong>oo</strong>bar</p>
- *
- * By breaking attribute elements like `<strong>`, the selection is in a correct element. Then, when the selection attribute is
- * converted, broken attributes might be merged again, or the position where the selection is may be wrapped
- * with different, appropriate attribute elements.
- *
- * See also {@link module:engine/conversion/downcast-selection-converters~clearAttributes} which does a clean-up
- * by merging attributes.
- *
- * @returns {Function} Selection converter.
- */
-export function convertCollapsedSelection() {
-	return ( evt, data, conversionApi ) => {
-		const selection = data.selection;
-
-		if ( !selection.isCollapsed ) {
-			return;
-		}
-
-		if ( !conversionApi.consumable.consume( selection, 'selection' ) ) {
-			return;
-		}
-
-		const viewWriter = conversionApi.writer;
-		const modelPosition = selection.getFirstPosition();
-		const viewPosition = conversionApi.mapper.toViewPosition( modelPosition );
-		const brokenPosition = viewWriter.breakAttributes( viewPosition );
-
-		viewWriter.setSelection( brokenPosition );
-	};
-}
-
-/**
- * Function factory that creates a converter which clears artifacts after the previous
- * {@link module:engine/model/selection~Selection model selection} conversion. It removes all empty
- * {@link module:engine/view/attributeelement~AttributeElement view attribute elements} and merges sibling attributes at all start and end
- * positions of all ranges.
- *
- *		   <p><strong>^</strong></p>
- *		-> <p>^</p>
- *
- *		   <p><strong>foo</strong>^<strong>bar</strong>bar</p>
- *		-> <p><strong>foo^bar<strong>bar</p>
- *
- *		   <p><strong>foo</strong><em>^</em><strong>bar</strong>bar</p>
- *		-> <p><strong>foo^bar<strong>bar</p>
- *
- * This listener should be assigned before any converter for the new selection:
- *
- *		modelDispatcher.on( 'selection', clearAttributes() );
- *
- * See {@link module:engine/conversion/downcast-selection-converters~convertCollapsedSelection}
- * which does the opposite by breaking attributes in the selection position.
- *
- * @returns {Function} Selection converter.
- */
-export function clearAttributes() {
-	return ( evt, data, conversionApi ) => {
-		const viewWriter = conversionApi.writer;
-		const viewSelection = viewWriter.document.selection;
-
-		for ( const range of viewSelection.getRanges() ) {
-			// Not collapsed selection should not have artifacts.
-			if ( range.isCollapsed ) {
-				// Position might be in the node removed by the view writer.
-				if ( range.end.parent.document ) {
-					conversionApi.writer.mergeAttributes( range.start );
-				}
-			}
-		}
-		viewWriter.setSelection( null );
-	};
-}

+ 49 - 8
packages/ckeditor5-engine/src/conversion/downcastdispatcher.js

@@ -105,13 +105,15 @@ export default class DowncastDispatcher {
 	/**
 	 * Creates a `DowncastDispatcher` instance.
 	 *
-	 * @param {Object} [conversionApi] Interface passed by dispatcher to the events calls.
+	 * @see module:engine/conversion/downcastdispatcher~DowncastConversionApi
+	 * @param {Object} [conversionApi] Additional properties for interface that will be passed to events fired
+	 * by `DowncastDispatcher`.
 	 */
 	constructor( conversionApi = {} ) {
 		/**
 		 * Interface passed by dispatcher to the events callbacks.
 		 *
-		 * @member {Object}
+		 * @member {module:engine/conversion/downcastdispatcher~DowncastConversionApi}
 		 */
 		this.conversionApi = extend( { dispatcher: this }, conversionApi );
 	}
@@ -487,7 +489,8 @@ export default class DowncastDispatcher {
 	 * @param {Object} data Additional information about the change.
 	 * @param {module:engine/model/item~Item} data.item Inserted item.
 	 * @param {module:engine/model/range~Range} data.range Range spanning over inserted item.
-	 * @param {Object} conversionApi Conversion interface to be used by callback, passed in `DowncastDispatcher` constructor.
+	 * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi Conversion interface
+	 * to be used by callback, passed in `DowncastDispatcher` constructor.
 	 */
 
 	/**
@@ -503,7 +506,8 @@ export default class DowncastDispatcher {
 	 * @param {Object} data Additional information about the change.
 	 * @param {module:engine/model/position~Position} data.position Position from which the node has been removed.
 	 * @param {Number} data.length Offset size of the removed node.
-	 * @param {Object} conversionApi Conversion interface to be used by callback, passed in `DowncastDispatcher` constructor.
+	 * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi Conversion interface
+	 * to be used by callback, passed in `DowncastDispatcher` constructor.
 	 */
 
 	/**
@@ -529,7 +533,8 @@ export default class DowncastDispatcher {
 	 * @param {*} data.attributeOldValue Attribute value before the change. This is `null` when selection attribute is converted.
 	 * @param {*} data.attributeNewValue New attribute value.
 	 * @param {module:engine/conversion/modelconsumable~ModelConsumable} consumable Values to consume.
-	 * @param {Object} conversionApi Conversion interface to be used by callback, passed in `DowncastDispatcher` constructor.
+	 * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi Conversion interface
+	 * to be used by callback, passed in `DowncastDispatcher` constructor.
 	 */
 
 	/**
@@ -538,7 +543,8 @@ export default class DowncastDispatcher {
 	 * @event selection
 	 * @param {module:engine/model/selection~Selection} selection Selection that is converted.
 	 * @param {module:engine/conversion/modelconsumable~ModelConsumable} consumable Values to consume.
-	 * @param {Object} conversionApi Conversion interface to be used by callback, passed in `DowncastDispatcher` constructor.
+	 * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi Conversion interface
+	 * to be used by callback, passed in `DowncastDispatcher` constructor.
 	 */
 
 	/**
@@ -573,7 +579,8 @@ export default class DowncastDispatcher {
 	 * @param {module:engine/model/range~Range} data.markerRange Marker range.
 	 * @param {String} data.markerName Marker name.
 	 * @param {module:engine/conversion/modelconsumable~ModelConsumable} consumable Values to consume.
-	 * @param {Object} conversionApi Conversion interface to be used by callback, passed in `DowncastDispatcher` constructor.
+	 * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi Conversion interface
+	 * to be used by callback, passed in `DowncastDispatcher` constructor.
 	 */
 
 	/**
@@ -588,7 +595,8 @@ export default class DowncastDispatcher {
 	 * @param {Object} data Additional information about the change.
 	 * @param {module:engine/model/range~Range} data.markerRange Marker range.
 	 * @param {String} data.markerName Marker name.
-	 * @param {Object} conversionApi Conversion interface to be used by callback, passed in `DowncastDispatcher` constructor.
+	 * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi Conversion interface
+	 * to be used by callback, passed in `DowncastDispatcher` constructor.
 	 */
 }
 
@@ -617,3 +625,36 @@ function shouldMarkerChangeBeConverted( modelPosition, marker, mapper ) {
 
 	return !hasCustomHandling;
 }
+
+/**
+ * Conversion interface that is registered for given {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher}
+ * and is passed as one of parameters when {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher dispatcher}
+ * fires it's events.
+ *
+ * @interface module:engine/conversion/downcastdispatcher~DowncastConversionApi
+ */
+
+/**
+ * The {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher} instance.
+ *
+ * @member {module:engine/conversion/downcastdispatcher~DowncastDispatcher} #dispatcher
+ */
+
+/**
+ * Stores information about what parts of processed model item are still waiting to be handled. After a piece of model item
+ * was converted, appropriate consumable value should be {@link module:engine/conversion/modelconsumable~ModelConsumable#consume consumed}.
+ *
+ * @member {module:engine/conversion/modelconsumable~ModelConsumable} #consumable
+ */
+
+/**
+ * The {@link module:engine/conversion/mapper~Mapper} instance.
+ *
+ * @member {module:engine/conversion/mapper~Mapper} #mapper
+ */
+
+/**
+ * The {@link module:engine/view/downcastwriter~DowncastWriter} instance used to manipulate data during conversion.
+ *
+ * @member {module:engine/view/downcastwriter~DowncastWriter} #writer
+ */

+ 121 - 2
packages/ckeditor5-engine/src/conversion/downcasthelpers.js

@@ -263,7 +263,8 @@ export default class DowncastHelpers extends ConversionHelpers {
 	 *
 	 * If a function is passed as the `config.view` parameter, it will be used to generate both boundary elements. The function
 	 * receives the `data` object as a parameter and should return an instance of the
-	 * {@link module:engine/view/uielement~UIElement view UI element}. The `data` and `conversionApi` objects are passed from
+	 * {@link module:engine/view/uielement~UIElement view UI element}. The `data` object and
+	 * {@link module:engine/conversion/downcastdispatcher~DowncastConversionApi `conversionApi`} are passed from
 	 * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:addMarker}. Additionally,
 	 * the `data.isOpening` parameter is passed, which is set to `true` for the marker start boundary element, and `false` to
 	 * the marker end boundary element.
@@ -422,6 +423,123 @@ export function createViewElementFromHighlightDescriptor( descriptor ) {
 }
 
 /**
+ * Function factory that creates a converter which converts a non-collapsed {@link module:engine/model/selection~Selection model selection}
+ * to a {@link module:engine/view/documentselection~DocumentSelection view selection}. The converter consumes appropriate
+ * value from the `consumable` object and maps model positions from the selection to view positions.
+ *
+ *		modelDispatcher.on( 'selection', convertRangeSelection() );
+ *
+ * @returns {Function} Selection converter.
+ */
+export function convertRangeSelection() {
+	return ( evt, data, conversionApi ) => {
+		const selection = data.selection;
+
+		if ( selection.isCollapsed ) {
+			return;
+		}
+
+		if ( !conversionApi.consumable.consume( selection, 'selection' ) ) {
+			return;
+		}
+
+		const viewRanges = [];
+
+		for ( const range of selection.getRanges() ) {
+			const viewRange = conversionApi.mapper.toViewRange( range );
+			viewRanges.push( viewRange );
+		}
+
+		conversionApi.writer.setSelection( viewRanges, { backward: selection.isBackward } );
+	};
+}
+
+/**
+ * Function factory that creates a converter which converts a collapsed {@link module:engine/model/selection~Selection model selection} to
+ * a {@link module:engine/view/documentselection~DocumentSelection view selection}. The converter consumes appropriate
+ * value from the `consumable` object, maps the model selection position to the view position and breaks
+ * {@link module:engine/view/attributeelement~AttributeElement attribute elements} at the selection position.
+ *
+ *		modelDispatcher.on( 'selection', convertCollapsedSelection() );
+ *
+ * An example of the view state before and after converting the collapsed selection:
+ *
+ *		   <p><strong>f^oo<strong>bar</p>
+ *		-> <p><strong>f</strong>^<strong>oo</strong>bar</p>
+ *
+ * By breaking attribute elements like `<strong>`, the selection is in a correct element. Then, when the selection attribute is
+ * converted, broken attributes might be merged again, or the position where the selection is may be wrapped
+ * with different, appropriate attribute elements.
+ *
+ * See also {@link module:engine/conversion/downcasthelpers~clearAttributes} which does a clean-up
+ * by merging attributes.
+ *
+ * @returns {Function} Selection converter.
+ */
+export function convertCollapsedSelection() {
+	return ( evt, data, conversionApi ) => {
+		const selection = data.selection;
+
+		if ( !selection.isCollapsed ) {
+			return;
+		}
+
+		if ( !conversionApi.consumable.consume( selection, 'selection' ) ) {
+			return;
+		}
+
+		const viewWriter = conversionApi.writer;
+		const modelPosition = selection.getFirstPosition();
+		const viewPosition = conversionApi.mapper.toViewPosition( modelPosition );
+		const brokenPosition = viewWriter.breakAttributes( viewPosition );
+
+		viewWriter.setSelection( brokenPosition );
+	};
+}
+
+/**
+ * Function factory that creates a converter which clears artifacts after the previous
+ * {@link module:engine/model/selection~Selection model selection} conversion. It removes all empty
+ * {@link module:engine/view/attributeelement~AttributeElement view attribute elements} and merges sibling attributes at all start and end
+ * positions of all ranges.
+ *
+ *		   <p><strong>^</strong></p>
+ *		-> <p>^</p>
+ *
+ *		   <p><strong>foo</strong>^<strong>bar</strong>bar</p>
+ *		-> <p><strong>foo^bar<strong>bar</p>
+ *
+ *		   <p><strong>foo</strong><em>^</em><strong>bar</strong>bar</p>
+ *		-> <p><strong>foo^bar<strong>bar</p>
+ *
+ * This listener should be assigned before any converter for the new selection:
+ *
+ *		modelDispatcher.on( 'selection', clearAttributes() );
+ *
+ * See {@link module:engine/conversion/downcasthelpers~convertCollapsedSelection}
+ * which does the opposite by breaking attributes in the selection position.
+ *
+ * @returns {Function} Selection converter.
+ */
+export function clearAttributes() {
+	return ( evt, data, conversionApi ) => {
+		const viewWriter = conversionApi.writer;
+		const viewSelection = viewWriter.document.selection;
+
+		for ( const range of viewSelection.getRanges() ) {
+			// Not collapsed selection should not have artifacts.
+			if ( range.isCollapsed ) {
+				// Position might be in the node removed by the view writer.
+				if ( range.end.parent.document ) {
+					conversionApi.writer.mergeAttributes( range.start );
+				}
+			}
+		}
+		viewWriter.setSelection( null );
+	};
+}
+
+/**
  * Function factory that creates a converter which converts set/change/remove attribute changes from the model to the view.
  * It can also be used to convert selection attributes. In that case, an empty attribute element will be created and the
  * selection will be put inside it.
@@ -444,10 +562,11 @@ export function createViewElementFromHighlightDescriptor( descriptor ) {
  * The converter automatically consumes the corresponding value from the consumables list and stops the event (see
  * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher}).
  *
- *		modelDispatcher.on( 'attribute:bold', wrapItem( ( modelAttributeValue, viewWriter ) => {
+ *		modelDispatcher.on( 'attribute:bold', wrap( ( modelAttributeValue, viewWriter ) => {
  *			return viewWriter.createAttributeElement( 'strong' );
  *		} );
  *
+ * @protected
  * @param {Function} elementCreator Function returning a view element that will be used for wrapping.
  * @returns {Function} Set/change attribute converter.
  */

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

@@ -29,7 +29,7 @@ import TextProxy from '../model/textproxy';
  * {@link module:engine/conversion/modelconsumable~ModelConsumable#add add method} directly.
  * However, it is important to understand how consumable values can be
  * {@link module:engine/conversion/modelconsumable~ModelConsumable#consume consumed}.
- * See {@link module:engine/conversion/downcast-selection-converters default downcast converters} for more information.
+ * See {@link module:engine/conversion/downcasthelpers default downcast converters} for more information.
  *
  * Keep in mind, that one conversion event may have multiple callbacks (converters) attached to it. Each of those is
  * able to convert one or more parts of the model. However, when one of those callbacks actually converts

+ 0 - 0
packages/ckeditor5-engine/src/conversion/upcast-converters.js


+ 0 - 48
packages/ckeditor5-engine/src/conversion/upcast-selection-converters.js

@@ -1,48 +0,0 @@
-/**
- * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
-
-/**
- * Contains {@link module:engine/view/documentselection~DocumentSelection view selection}
- * to {@link module:engine/model/selection~Selection model selection} conversion helpers.
- *
- * @module engine/conversion/upcast-selection-converters
- */
-
-import ModelSelection from '../model/selection';
-
-/**
- * Function factory, creates a callback function which converts a {@link module:engine/view/selection~Selection
- * view selection} taken from the {@link module:engine/view/document~Document#event:selectionChange} event
- * and sets in on the {@link module:engine/model/document~Document#selection model}.
- *
- * **Note**: because there is no view selection change dispatcher nor any other advanced view selection to model
- * conversion mechanism, the callback should be set directly on view document.
- *
- *		view.document.on( 'selectionChange', convertSelectionChange( modelDocument, mapper ) );
- *
- * @param {module:engine/model/model~Model} model Data model.
- * @param {module:engine/conversion/mapper~Mapper} mapper Conversion mapper.
- * @returns {Function} {@link module:engine/view/document~Document#event:selectionChange} callback function.
- */
-export function convertSelectionChange( model, mapper ) {
-	return ( evt, data ) => {
-		const viewSelection = data.newSelection;
-		const modelSelection = new ModelSelection();
-
-		const ranges = [];
-
-		for ( const viewRange of viewSelection.getRanges() ) {
-			ranges.push( mapper.toModelRange( viewRange ) );
-		}
-
-		modelSelection.setTo( ranges, { backward: viewSelection.isBackward } );
-
-		if ( !modelSelection.isEqual( model.document.selection ) ) {
-			model.change( writer => {
-				writer.setSelection( modelSelection );
-			} );
-		}
-	};
-}

+ 22 - 11
packages/ckeditor5-engine/src/conversion/upcastdispatcher.js

@@ -102,7 +102,7 @@ export default class UpcastDispatcher {
 	/**
 	 * Creates a `UpcastDispatcher` that operates using passed API.
 	 *
-	 * @see module:engine/conversion/upcastdispatcher~ViewConversionApi
+	 * @see module:engine/conversion/upcastdispatcher~UpcastConversionApi
 	 * @param {Object} [conversionApi] Additional properties for interface that will be passed to events fired
 	 * by `UpcastDispatcher`.
 	 */
@@ -131,7 +131,7 @@ export default class UpcastDispatcher {
 		/**
 		 * Interface passed by dispatcher to the events callbacks.
 		 *
-		 * @member {module:engine/conversion/upcastdispatcher~ViewConversionApi}
+		 * @member {module:engine/conversion/upcastdispatcher~UpcastConversionApi}
 		 */
 		this.conversionApi = Object.assign( {}, conversionApi );
 
@@ -209,7 +209,7 @@ export default class UpcastDispatcher {
 
 	/**
 	 * @private
-	 * @see module:engine/conversion/upcastdispatcher~ViewConversionApi#convertItem
+	 * @see module:engine/conversion/upcastdispatcher~UpcastConversionApi#convertItem
 	 */
 	_convertItem( viewItem, modelCursor ) {
 		const data = Object.assign( { viewItem, modelCursor, modelRange: null } );
@@ -239,7 +239,7 @@ export default class UpcastDispatcher {
 
 	/**
 	 * @private
-	 * @see module:engine/conversion/upcastdispatcher~ViewConversionApi#convertChildren
+	 * @see module:engine/conversion/upcastdispatcher~UpcastConversionApi#convertChildren
 	 */
 	_convertChildren( viewItem, modelCursor ) {
 		const modelRange = new ModelRange( modelCursor );
@@ -259,7 +259,7 @@ export default class UpcastDispatcher {
 
 	/**
 	 * @private
-	 * @see module:engine/conversion/upcastdispatcher~ViewConversionApi#splitToAllowedParent
+	 * @see module:engine/conversion/upcastdispatcher~UpcastConversionApi#splitToAllowedParent
 	 */
 	_splitToAllowedParent( node, modelCursor ) {
 		// Try to find allowed parent.
@@ -348,7 +348,7 @@ export default class UpcastDispatcher {
 	 * Change this value for the next converter to tell where the conversion should continue.
 	 * @param {module:engine/model/range~Range} data.modelRange The current state of conversion result. Every change to
 	 * converted element should be reflected by setting or modifying this property.
-	 * @param {ViewConversionApi} conversionApi Conversion utilities to be used by callback.
+	 * @param {module:engine/conversion/upcastdispatcher~UpcastConversionApi} conversionApi Conversion utilities to be used by callback.
 	 */
 
 	/**
@@ -436,7 +436,7 @@ function createContextTree( contextDefinition, writer ) {
  * and is passed as one of parameters when {@link module:engine/conversion/upcastdispatcher~UpcastDispatcher dispatcher}
  * fires it's events.
  *
- * @interface ViewConversionApi
+ * @interface module:engine/conversion/upcastdispatcher~UpcastConversionApi
  */
 
 /**
@@ -505,11 +505,10 @@ function createContextTree( contextDefinition, writer ) {
  */
 
 /**
- * Instance of {@link module:engine/conversion/viewconsumable~ViewConsumable}. It stores
- * information about what parts of processed view item are still waiting to be handled. After a piece of view item
+ * Stores information about what parts of processed view item are still waiting to be handled. After a piece of view item
  * was converted, appropriate consumable value should be {@link module:engine/conversion/viewconsumable~ViewConsumable#consume consumed}.
  *
- * @param {Object} #consumable
+ * @member {module:engine/conversion/viewconsumable~ViewConsumable} #consumable
  */
 
 /**
@@ -520,5 +519,17 @@ function createContextTree( contextDefinition, writer ) {
  * {@link module:engine/conversion/upcastdispatcher~UpcastDispatcher#event:element} is that `data` parameters allows you
  * to pass parameters within a single event and `store` within the whole conversion.
  *
- * @param {Object} #store
+ * @member {Object} #store
+ */
+
+/**
+ * The model's schema instance.
+ *
+ * @member {module:engine/model/schema~Schema} #schema
+ */
+
+/**
+ * The {@link module:engine/model/writer~Writer} instance used to manipulate data during conversion.
+ *
+ * @member {module:engine/model/writer~Writer} #writer
  */

+ 37 - 1
packages/ckeditor5-engine/src/conversion/upcasthelpers.js

@@ -8,6 +8,7 @@ import ModelRange from '../model/range';
 import { ConversionHelpers } from './conversion';
 
 import { cloneDeep } from 'lodash-es';
+import ModelSelection from '../model/selection';
 
 /**
  * Contains {@link module:engine/view/view view} to {@link module:engine/model/model model} converters for
@@ -352,6 +353,41 @@ export function convertText() {
 	};
 }
 
+/**
+ * Function factory, creates a callback function which converts a {@link module:engine/view/selection~Selection
+ * view selection} taken from the {@link module:engine/view/document~Document#event:selectionChange} event
+ * and sets in on the {@link module:engine/model/document~Document#selection model}.
+ *
+ * **Note**: because there is no view selection change dispatcher nor any other advanced view selection to model
+ * conversion mechanism, the callback should be set directly on view document.
+ *
+ *		view.document.on( 'selectionChange', convertSelectionChange( modelDocument, mapper ) );
+ *
+ * @param {module:engine/model/model~Model} model Data model.
+ * @param {module:engine/conversion/mapper~Mapper} mapper Conversion mapper.
+ * @returns {Function} {@link module:engine/view/document~Document#event:selectionChange} callback function.
+ */
+export function convertSelectionChange( model, mapper ) {
+	return ( evt, data ) => {
+		const viewSelection = data.newSelection;
+		const modelSelection = new ModelSelection();
+
+		const ranges = [];
+
+		for ( const viewRange of viewSelection.getRanges() ) {
+			ranges.push( mapper.toModelRange( viewRange ) );
+		}
+
+		modelSelection.setTo( ranges, { backward: viewSelection.isBackward } );
+
+		if ( !modelSelection.isEqual( model.document.selection ) ) {
+			model.change( writer => {
+				writer.setSelection( modelSelection );
+			} );
+		}
+	};
+}
+
 // View element to model element conversion helper.
 //
 // See {@link ~UpcastHelpers#elementToElement `.elementToElement()` upcast helper} for examples.
@@ -686,7 +722,7 @@ function onlyViewNameIsDefined( config ) {
 //
 // @param {module:engine/model/range~Range} modelRange Model range on which attribute should be set.
 // @param {Object} modelAttribute Model attribute to set.
-// @param {Object} conversionApi Conversion API.
+// @param {module:engine/conversion/upcastdispatcher~UpcastConversionApi} conversionApi Conversion API.
 // @param {Boolean} shallow If set to `true` the attribute will be set only on top-level nodes. Otherwise, it will be set
 // on all elements in the range.
 // @returns {Boolean} `true` if attribute was set on at least one node from given `modelRange`.

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

@@ -29,10 +29,13 @@ import DowncastDispatcher from '../conversion/downcastdispatcher';
 import UpcastDispatcher from '../conversion/upcastdispatcher';
 import Mapper from '../conversion/mapper';
 import {
-	convertRangeSelection,
 	convertCollapsedSelection,
-} from '../conversion/downcast-selection-converters';
-import { insertElement, insertText, insertUIElement, wrap } from '../conversion/downcasthelpers';
+	convertRangeSelection,
+	insertElement,
+	insertText,
+	insertUIElement,
+	wrap
+} from '../conversion/downcasthelpers';
 
 import { isPlainObject } from 'lodash-es';
 import toMap from '@ckeditor/ckeditor5-utils/src/tomap';

+ 70 - 7
packages/ckeditor5-engine/src/model/documentselection.js

@@ -15,6 +15,7 @@ import LiveRange from './liverange';
 import Text from './text';
 import TextProxy from './textproxy';
 import toMap from '@ckeditor/ckeditor5-utils/src/tomap';
+import Collection from '@ckeditor/ckeditor5-utils/src/collection';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 import log from '@ckeditor/ckeditor5-utils/src/log';
 import uid from '@ckeditor/ckeditor5-utils/src/uid';
@@ -150,6 +151,17 @@ export default class DocumentSelection {
 	}
 
 	/**
+	 * A collection of selection markers.
+	 * Marker is a selection marker when selection range is inside the marker range.
+	 *
+	 * @readonly
+	 * @type {module:utils/collection~Collection.<module:engine/model/markercollection~Marker>}
+	 */
+	get markers() {
+		return this._selection.markers;
+	}
+
+	/**
 	 * Used for the compatibility with the {@link module:engine/model/selection~Selection#isEqual} method.
 	 *
 	 * @protected
@@ -247,13 +259,33 @@ export default class DocumentSelection {
 	 *		<paragraph>b</paragraph>
 	 *		<paragraph>]c</paragraph> // this block will not be returned
 	 *
-	 * @returns {Iterator.<module:engine/model/element~Element>}
+	 * @returns {Iterable.<module:engine/model/element~Element>}
 	 */
 	getSelectedBlocks() {
 		return this._selection.getSelectedBlocks();
 	}
 
 	/**
+	 * Returns blocks that aren't nested in other selected blocks.
+	 *
+	 * In this case the method will return blocks A, B and E because C & D are children of block B:
+	 *
+	 *		[<blockA></blockA>
+	 *		<blockB>
+	 *			<blockC></blockC>
+	 *			<blockD></blockD>
+	 *		</blockB>
+	 *		<blockE></blockE>]
+	 *
+	 * **Note:** To get all selected blocks use {@link #getSelectedBlocks `getSelectedBlocks()`}.
+	 *
+	 * @returns {Iterable.<module:engine/model/element~Element>}
+	 */
+	getTopMostBlocks() {
+		return this._selection.getTopMostBlocks();
+	}
+
+	/**
 	 * Returns the selected element. {@link module:engine/model/element~Element Element} is considered as selected if there is only
 	 * one range in the selection, and that range contains exactly one element.
 	 * Returns `null` if there is no selected element.
@@ -346,16 +378,12 @@ export default class DocumentSelection {
 
 	/**
 	 * Sets this selection's ranges and direction to the specified location based on the given
-	 * {@link module:engine/model/selection~Selection selection}, {@link module:engine/model/position~Position position},
-	 * {@link module:engine/model/node~Node node}, {@link module:engine/model/position~Position position},
-	 * {@link module:engine/model/range~Range range}, an iterable of {@link module:engine/model/range~Range ranges} or null.
+	 * {@link module:engine/model/selection~Selectable selectable}.
 	 * Should be used only within the {@link module:engine/model/writer~Writer#setSelection} method.
 	 *
 	 * @see module:engine/model/writer~Writer#setSelection
 	 * @protected
-	 * @param {module:engine/model/selection~Selection|module:engine/model/documentselection~DocumentSelection|
-	 * module:engine/model/position~Position|module:engine/model/node~Node|
-	 * Iterable.<module:engine/model/range~Range>|module:engine/model/range~Range|null} selectable
+	 * @param {module:engine/model/selection~Selectable} selectable
 	 * @param {Number|'before'|'end'|'after'|'on'|'in'} [placeOrOffset] Sets place or offset of the selection.
 	 * @param {Object} [options]
 	 * @param {Boolean} [options.backward] Sets this selection instance to be backward.
@@ -506,6 +534,12 @@ class LiveSelection extends Selection {
 	constructor( doc ) {
 		super();
 
+		// List of selection markers.
+		// Marker is a selection marker when selection range is inside the marker range.
+		//
+		// @type {module:utils/collection~Collection}
+		this.markers = new Collection( { idProperty: 'name' } );
+
 		// Document which owns this selection.
 		//
 		// @protected
@@ -566,6 +600,9 @@ class LiveSelection extends Selection {
 		} );
 
 		this.listenTo( this._document, 'change', ( evt, batch ) => {
+			// Update selection's markers.
+			this._updateMarkers();
+
 			// Update selection's attributes.
 			this._updateAttributes( false );
 
@@ -768,6 +805,32 @@ class LiveSelection extends Selection {
 		return liveRange;
 	}
 
+	_updateMarkers() {
+		const markers = [];
+
+		for ( const marker of this._model.markers ) {
+			const markerRange = marker.getRange();
+
+			for ( const selectionRange of this.getRanges() ) {
+				if ( markerRange.containsRange( selectionRange, !selectionRange.isCollapsed ) ) {
+					markers.push( marker );
+				}
+			}
+		}
+
+		for ( const marker of markers ) {
+			if ( !this.markers.has( marker ) ) {
+				this.markers.add( marker );
+			}
+		}
+
+		for ( const marker of Array.from( this.markers ) ) {
+			if ( !markers.includes( marker ) ) {
+				this.markers.remove( marker );
+			}
+		}
+	}
+
 	// Updates this selection attributes according to its ranges and the {@link module:engine/model/document~Document model document}.
 	//
 	// @protected

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

@@ -331,9 +331,7 @@ export default class Model {
 	 *
 	 * @fires insertContent
 	 * @param {module:engine/model/documentfragment~DocumentFragment|module:engine/model/item~Item} content The content to insert.
-	 * @param {module:engine/model/selection~Selection|module:engine/model/documentselection~DocumentSelection|
-	 * module:engine/model/position~Position|module:engine/model/item~Item|
-	 * Iterable.<module:engine/model/range~Range>|module:engine/model/range~Range|null} [selectable=model.document.selection]
+	 * @param {module:engine/model/selection~Selectable} [selectable=model.document.selection]
 	 * The selection into which the content should be inserted. If not provided the current model document selection will be used.
 	 * @param {Number|'before'|'end'|'after'|'on'|'in'} [placeOrOffset] To be used when a model item was passed as `selectable`.
 	 * This param defines a position in relation to that item.
@@ -596,14 +594,8 @@ export default class Model {
 	}
 
 	/**
-	 * Creates a new selection instance based on:
-	 *
-	 * * the given {@link module:engine/model/selection~Selection selection},
-	 * * or based on the given {@link module:engine/model/range~Range range},
-	 * * or based on the given iterable collection of {@link module:engine/model/range~Range ranges}
-	 * * or at the given {@link module:engine/model/position~Position position},
-	 * * or on the given {@link module:engine/model/element~Element element},
-	 * * or creates an empty selection if no arguments were passed.
+	 * Creates a new selection instance based on the given {@link module:engine/model/selection~Selectable selectable}
+	 * or creates an empty selection if no arguments were passed.
 	 *
 	 * Note: This method is also available as
 	 * {@link module:engine/model/writer~Writer#createSelection `Writer#createSelection()`}.
@@ -650,9 +642,7 @@ export default class Model {
 	 *		// Creates backward selection.
 	 *		const selection = writer.createSelection( range, { backward: true } );
 	 *
-	 * @param {module:engine/model/selection~Selection|module:engine/model/documentselection~DocumentSelection|
-	 * module:engine/model/position~Position|module:engine/model/element~Element|
-	 * Iterable.<module:engine/model/range~Range>|module:engine/model/range~Range|null} selectable
+	 * @param {module:engine/model/selection~Selectable} selectable
 	 * @param {Number|'before'|'end'|'after'|'on'|'in'} [placeOrOffset] Sets place or offset of the selection.
 	 * @param {Object} [options]
 	 * @param {Boolean} [options.backward] Sets this selection instance to be backward.

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

@@ -520,7 +520,7 @@ export default class Schema {
 	 *
 	 * @param {Array.<module:engine/model/range~Range>} ranges Ranges to be validated.
 	 * @param {String} attribute The name of the attribute to check.
-	 * @returns {Iterator.<module:engine/model/range~Range>} Ranges in which the attribute is allowed.
+	 * @returns {Iterable.<module:engine/model/range~Range>} Ranges in which the attribute is allowed.
 	 */
 	* getValidRanges( ranges, attribute ) {
 		ranges = convertToMinimalFlatRanges( ranges );
@@ -539,7 +539,7 @@ export default class Schema {
 	 * @private
 	 * @param {module:engine/model/range~Range} range Range to process.
 	 * @param {String} attribute The name of the attribute to check.
-	 * @returns {Iterator.<module:engine/model/range~Range>} Ranges in which the attribute is allowed.
+	 * @returns {Iterable.<module:engine/model/range~Range>} Ranges in which the attribute is allowed.
 	 */
 	* _getValidRangesForRange( range, attribute ) {
 		let start = range.start;
@@ -1459,7 +1459,7 @@ function* combineWalkers( backward, forward ) {
 // all those minimal flat ranges.
 //
 // @param {Array.<module:engine/model/range~Range>} ranges Ranges to process.
-// @returns {Iterator.<module:engine/model/range~Range>} Minimal flat ranges of given `ranges`.
+// @returns {Iterable.<module:engine/model/range~Range>} Minimal flat ranges of given `ranges`.
 function* convertToMinimalFlatRanges( ranges ) {
 	for ( const range of ranges ) {
 		yield* range.getMinimalFlatRanges();

+ 67 - 15
packages/ckeditor5-engine/src/model/selection.js

@@ -27,12 +27,7 @@ import isIterable from '@ckeditor/ckeditor5-utils/src/isiterable';
  */
 export default class Selection {
 	/**
-	 * Creates a new selection instance
-	 * based on the given {@link module:engine/model/selection~Selection selection},
-	 * or based on the given {@link module:engine/model/range~Range range},
-	 * or based on an iterable collection of {@link module:engine/model/range~Range ranges}
-	 * or at the given {@link module:engine/model/position~Position position},
-	 * or on the given {@link module:engine/model/element~Element element},
+	 * Creates a new selection instance based on the given {@link module:engine/model/selection~Selectable selectable}
 	 * or creates an empty selection if no arguments were passed.
 	 *
 	 *		// Creates empty selection without ranges.
@@ -77,9 +72,7 @@ export default class Selection {
 	 *		// Creates backward selection.
 	 *		const selection = writer.createSelection( range, { backward: true } );
 	 *
-	 * @param {module:engine/model/selection~Selection|module:engine/model/documentselection~DocumentSelection|
-	 * module:engine/model/position~Position|module:engine/model/element~Element|
-	 * Iterable.<module:engine/model/range~Range>|module:engine/model/range~Range|null} selectable
+	 * @param {module:engine/model/selection~Selectable} selectable
 	 * @param {Number|'before'|'end'|'after'|'on'|'in'} [placeOrOffset] Sets place or offset of the selection.
 	 * @param {Object} [options]
 	 * @param {Boolean} [options.backward] Sets this selection instance to be backward.
@@ -322,9 +315,7 @@ export default class Selection {
 
 	/**
 	 * Sets this selection's ranges and direction to the specified location based on the given
-	 * {@link module:engine/model/selection~Selection selection}, {@link module:engine/model/position~Position position},
-	 * {@link module:engine/model/element~Element element}, {@link module:engine/model/position~Position position},
-	 * {@link module:engine/model/range~Range range}, an iterable of {@link module:engine/model/range~Range ranges} or null.
+	 * {@link module:engine/model/selection~Selectable selectable}.
 	 *
 	 *		// Removes all selection's ranges.
 	 *		selection.setTo( null );
@@ -368,9 +359,7 @@ export default class Selection {
 	 *		// Sets backward selection.
 	 *		const selection = writer.createSelection( range, { backward: true } );
 	 *
-	 * @param {module:engine/model/selection~Selection|module:engine/model/documentselection~DocumentSelection|
-	 * module:engine/model/position~Position|module:engine/model/node~Node|
-	 * Iterable.<module:engine/model/range~Range>|module:engine/model/range~Range|null} selectable
+	 * @param {module:engine/model/selection~Selectable} selectable
 	 * @param {Number|'before'|'end'|'after'|'on'|'in'} [placeOrOffset] Sets place or offset of the selection.
 	 * @param {Object} [options]
 	 * @param {Boolean} [options.backward] Sets this selection instance to be backward.
@@ -673,6 +662,35 @@ export default class Selection {
 	}
 
 	/**
+	 * Returns blocks that aren't nested in other selected blocks.
+	 *
+	 * In this case the method will return blocks A, B and E because C & D are children of block B:
+	 *
+	 *		[<blockA></blockA>
+	 *		<blockB>
+	 *			<blockC></blockC>
+	 *			<blockD></blockD>
+	 *		</blockB>
+	 *		<blockE></blockE>]
+	 *
+	 * **Note:** To get all selected blocks use {@link #getSelectedBlocks `getSelectedBlocks()`}.
+	 *
+	 * @returns {Iterable.<module:engine/model/element~Element>}
+	 */
+	* getTopMostBlocks() {
+		const selected = Array.from( this.getSelectedBlocks() );
+
+		for ( const block of selected ) {
+			const parentBlock = findAncestorBlock( block );
+
+			// Filter out blocks that are nested in other selected blocks (like paragraphs in tables).
+			if ( !parentBlock || !selected.includes( parentBlock ) ) {
+				yield block;
+			}
+		}
+	}
+
+	/**
 	 * Checks whether the selection contains the entire content of the given element. This means that selection must start
 	 * at a position {@link module:engine/model/position~Position#isTouching touching} the element's start and ends at position
 	 * touching the element's end.
@@ -802,3 +820,37 @@ function getParentBlock( position, visited ) {
 
 	return block;
 }
+
+// Returns first ancestor block of a node.
+//
+// @param {module:engine/model/node~Node} node
+// @returns {module:engine/model/node~Node|undefined}
+function findAncestorBlock( node ) {
+	const schema = node.document.model.schema;
+
+	let parent = node.parent;
+
+	while ( parent ) {
+		if ( schema.isBlock( parent ) ) {
+			return parent;
+		}
+
+		parent = parent.parent;
+	}
+}
+
+/**
+ * An entity that is used to set selection.
+ *
+ * See also {@link module:engine/model/selection~Selection#setTo}
+ *
+ * @typedef {
+ *     module:engine/model/selection~Selection|
+ *     module:engine/model/documentselection~DocumentSelection|
+ *     module:engine/model/position~Position|
+ *     module:engine/model/range~Range|
+ *     module:engine/model/node~Node|
+ *     Iterable.<module:engine/model/range~Range>|
+ *     null
+ * } module:engine/model/selection~Selectable
+ */

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

@@ -29,9 +29,7 @@ import Selection from '../selection';
  * @param {module:engine/model/model~Model} model The model in context of which the insertion
  * should be performed.
  * @param {module:engine/model/documentfragment~DocumentFragment|module:engine/model/item~Item} content The content to insert.
- * @param {module:engine/model/selection~Selection|module:engine/model/documentselection~DocumentSelection|
- * module:engine/model/position~Position|module:engine/model/element~Element|
- * Iterable.<module:engine/model/range~Range>|module:engine/model/range~Range|null} [selectable=model.document.selection]
+ * @param {module:engine/model/selection~Selectable} [selectable=model.document.selection]
  * Selection into which the content should be inserted.
  * @param {Number|'before'|'end'|'after'|'on'|'in'} [placeOrOffset] Sets place or offset of the selection.
  */

+ 8 - 14
packages/ckeditor5-engine/src/model/writer.js

@@ -159,6 +159,10 @@ export default class Writer {
 	insert( item, itemOrPosition, offset = 0 ) {
 		this._assertWriterUsedCorrectly();
 
+		if ( item instanceof Text && item.data == '' ) {
+			return;
+		}
+
 		const position = Position._createAt( itemOrPosition, offset );
 
 		// If item has a parent already.
@@ -632,9 +636,7 @@ export default class Writer {
 	/**
 	 * Shortcut for {@link module:engine/model/model~Model#createSelection `Model#createSelection()`}.
 	 *
-	 * @param {module:engine/model/selection~Selection|module:engine/model/documentselection~DocumentSelection|
-	 * module:engine/model/position~Position|module:engine/model/element~Element|
-	 * Iterable.<module:engine/model/range~Range>|module:engine/model/range~Range|null} selectable
+	 * @param {module:engine/model/selection~Selectable} selectable
 	 * @param {Number|'before'|'end'|'after'|'on'|'in'} [placeOrOffset] Sets place or offset of the selection.
 	 * @param {Object} [options]
 	 * @param {Boolean} [options.backward] Sets this selection instance to be backward.
@@ -1076,14 +1078,8 @@ export default class Writer {
 	}
 
 	/**
-	 * Sets the document's selection (ranges and direction) to the specified location based on:
-	 *
-	 * * the given {@link module:engine/model/selection~Selection selection},
-	 * * or the given {@link module:engine/model/position~Position position},
-	 * * or the given {@link module:engine/model/range~Range range},
-	 * * or the given iterable of {@link module:engine/model/range~Range ranges},
-	 * * or the given {@link module:engine/model/node~Node node},
-	 * * or `null`.
+	 * Sets the document's selection (ranges and direction) to the specified location based on the given
+	 * {@link module:engine/model/selection~Selectable selectable} or creates an empty selection if no arguments were passed.
 	 *
 	 *		// Sets selection to the given range.
 	 *		const range = writer.createRange( start, end );
@@ -1127,9 +1123,7 @@ export default class Writer {
 	 *
 	 * Throws `writer-incorrect-use` error when the writer is used outside the `change()` block.
 	 *
-	 * @param {module:engine/model/selection~Selection|module:engine/model/documentselection~DocumentSelection|
-	 * module:engine/model/position~Position|module:engine/model/node~Node|
-	 * Iterable.<module:engine/model/range~Range>|module:engine/model/range~Range|null} selectable
+	 * @param {module:engine/model/selection~Selectable} selectable
 	 * @param {Number|'before'|'end'|'after'|'on'|'in'} [placeOrOffset] Sets place or offset of the selection.
 	 * @param {Object} [options]
 	 * @param {Boolean} [options.backward] Sets this selection instance to be backward.

+ 3 - 9
packages/ckeditor5-engine/src/view/documentselection.js

@@ -71,9 +71,7 @@ export default class DocumentSelection {
 	 *		// Creates fake selection with label.
 	 *		const selection = new DocumentSelection( range, { fake: true, label: 'foo' } );
 	 *
-	 * @param {module:engine/view/selection~Selection|module:engine/view/position~Position|
-	 * Iterable.<module:engine/view/range~Range>|module:engine/view/range~Range|
-	 * module:engine/view/item~Item|null} [selectable=null]
+	 * @param {module:engine/view/selection~Selectable} [selectable=null]
 	 * @param {Number|'before'|'end'|'after'|'on'|'in'} [placeOrOffset] Offset or place when selectable is an `Item`.
 	 * @param {Object} [options]
 	 * @param {Boolean} [options.backward] Sets this selection instance to be backward.
@@ -278,10 +276,7 @@ export default class DocumentSelection {
 
 	/**
 	 * Sets this selection's ranges and direction to the specified location based on the given
-	 * {@link module:engine/view/documentselection~DocumentSelection document selection},
-	 * {@link module:engine/view/selection~Selection selection}, {@link module:engine/view/position~Position position},
-	 * {@link module:engine/view/item~Item item}, {@link module:engine/view/range~Range range},
-	 * an iterable of {@link module:engine/view/range~Range ranges} or null.
+	 * {@link module:engine/view/selection~Selectable selectable}.
 	 *
 	 *		// Sets selection to the given range.
 	 *		const range = writer.createRange( start, end );
@@ -331,8 +326,7 @@ export default class DocumentSelection {
 	 *
 	 * @protected
 	 * @fires change
-	 * @param {module:engine/view/selection~Selection|module:engine/view/position~Position|
-	 * Iterable.<module:engine/view/range~Range>|module:engine/view/range~Range|module:engine/view/item~Item|null} selectable
+	 * @param {module:engine/view/selection~Selectable} selectable
 	 * @param {Number|'before'|'end'|'after'|'on'|'in'} [placeOrOffset] Sets place or offset of the selection.
 	 * @param {Object} [options]
 	 * @param {Boolean} [options.backward] Sets this selection instance to be backward.

+ 3 - 9
packages/ckeditor5-engine/src/view/downcastwriter.js

@@ -56,10 +56,7 @@ export default class DowncastWriter {
 
 	/**
 	 * Sets {@link module:engine/view/documentselection~DocumentSelection selection's} ranges and direction to the
-	 * specified location based on the given {@link module:engine/view/documentselection~DocumentSelection document selection},
-	 * {@link module:engine/view/selection~Selection selection}, {@link module:engine/view/position~Position position},
-	 * {@link module:engine/view/item~Item item}, {@link module:engine/view/range~Range range},
-	 * an iterable of {@link module:engine/view/range~Range ranges} or null.
+	 * specified location based on the given {@link module:engine/view/selection~Selectable selectable}.
 	 *
 	 * Usage:
 	 *
@@ -114,8 +111,7 @@ export default class DowncastWriter {
 	 * 		// (and be  properly handled by screen readers).
 	 *		writer.setSelection( range, { fake: true, label: 'foo' } );
 	 *
-	 * @param {module:engine/view/selection~Selection|module:engine/view/position~Position|
-	 * Iterable.<module:engine/view/range~Range>|module:engine/view/range~Range|module:engine/view/item~Item|null} selectable
+	 * @param {module:engine/view/selection~Selectable} selectable
 	 * @param {Number|'before'|'end'|'after'|'on'|'in'} [placeOrOffset] Sets place or offset of the selection.
 	 * @param {Object} [options]
 	 * @param {Boolean} [options.backward] Sets this selection instance to be backward.
@@ -1076,9 +1072,7 @@ export default class DowncastWriter {
 	 *		// Creates fake selection with label.
 	 *		const selection = writer.createSelection( range, { fake: true, label: 'foo' } );
 	 *
-	 * @param {module:engine/view/selection~Selection|module:engine/view/documentselection~DocumentSelection|
-	 * module:engine/view/position~Position|Iterable.<module:engine/view/range~Range>|module:engine/view/range~Range|
-	 * module:engine/view/item~Item|null} [selectable=null]
+	 * @param {module:engine/view/selection~Selectable} [selectable=null]
 	 * @param {Number|'before'|'end'|'after'|'on'|'in'} [placeOrOffset] Offset or place when selectable is an `Item`.
 	 * @param {Object} [options]
 	 * @param {Boolean} [options.backward] Sets this selection instance to be backward.

+ 19 - 10
packages/ckeditor5-engine/src/view/selection.js

@@ -88,9 +88,7 @@ export default class Selection {
 	 *		// Creates fake selection with label.
 	 *		const selection = writer.createSelection( range, { fake: true, label: 'foo' } );
 	 *
-	 * @param {module:engine/view/selection~Selection|module:engine/view/documentselection~DocumentSelection|
-	 * module:engine/view/position~Position|Iterable.<module:engine/view/range~Range>|module:engine/view/range~Range|
-	 * module:engine/view/item~Item|null} [selectable=null]
+	 * @param {module:engine/view/selection~Selectable} [selectable=null]
 	 * @param {Number|'before'|'end'|'after'|'on'|'in'} [placeOrOffset] Offset or place when selectable is an `Item`.
 	 * @param {Object} [options]
 	 * @param {Boolean} [options.backward] Sets this selection instance to be backward.
@@ -424,10 +422,7 @@ export default class Selection {
 
 	/**
 	 * Sets this selection's ranges and direction to the specified location based on the given
-	 * {@link module:engine/view/documentselection~DocumentSelection document selection},
-	 * {@link module:engine/view/selection~Selection selection}, {@link module:engine/view/position~Position position},
-	 * {@link module:engine/view/item~Item item}, {@link module:engine/view/range~Range range},
-	 * an iterable of {@link module:engine/view/range~Range ranges} or null.
+	 * {@link module:engine/view/selection~Selectable selectable}.
 	 *
 	 *		// Sets selection to the given range.
 	 *		const range = writer.createRange( start, end );
@@ -479,9 +474,7 @@ export default class Selection {
 	 *		selection.setTo( range, { fake: true, label: 'foo' } );
 	 *
 	 * @fires change
-	 * @param {module:engine/view/selection~Selection|module:engine/view/documentselection~DocumentSelection|
-	 * module:engine/view/position~Position|Iterable.<module:engine/view/range~Range>|module:engine/view/range~Range|
-	 * module:engine/view/item~Item|null} selectable
+	 * @param {module:engine/view/selection~Selectable} selectable
 	 * @param {Number|'before'|'end'|'after'|'on'|'in'} [placeOrOffset] Sets place or offset of the selection.
 	 * @param {Object} [options]
 	 * @param {Boolean} [options.backward] Sets this selection instance to be backward.
@@ -697,3 +690,19 @@ export default class Selection {
 }
 
 mix( Selection, EmitterMixin );
+
+/**
+ * An entity that is used to set selection.
+ *
+ * See also {@link module:engine/view/selection~Selection#setTo}
+ *
+ * @typedef {
+ *    module:engine/view/selection~Selection|
+ *    module:engine/view/documentselection~DocumentSelection|
+ *    module:engine/view/position~Position|
+ *    Iterable.<module:engine/view/range~Range>|
+ *    module:engine/view/range~Range|
+ *    module:engine/view/item~Item|
+ *    null
+ * } module:engine/view/selection~Selectable
+ */

+ 1 - 3
packages/ckeditor5-engine/src/view/upcastwriter.js

@@ -431,9 +431,7 @@ export default class UpcastWriter {
 	 *		// Creates fake selection with label.
 	 *		const selection = writer.createSelection( range, { fake: true, label: 'foo' } );
 	 *
-	 * @param {module:engine/view/selection~Selection|module:engine/view/documentselection~DocumentSelection|
-	 * module:engine/view/position~Position|Iterable.<module:engine/view/range~Range>|module:engine/view/range~Range|
-	 * module:engine/view/item~Item|null} [selectable=null]
+	 * @param {module:engine/view/selection~Selectable} [selectable=null]
 	 * @param {Number|'before'|'end'|'after'|'on'|'in'} [placeOrOffset] Offset or place when selectable is an `Item`.
 	 * @param {Object} [options]
 	 * @param {Boolean} [options.backward] Sets this selection instance to be backward.

+ 1 - 3
packages/ckeditor5-engine/src/view/view.js

@@ -528,9 +528,7 @@ export default class View {
 	 *		// Creates fake selection with label.
 	 *		const selection = view.createSelection( range, { fake: true, label: 'foo' } );
 	 *
-	 * @param {module:engine/view/selection~Selection|module:engine/view/documentselection~DocumentSelection|
-	 * module:engine/view/position~Position|Iterable.<module:engine/view/range~Range>|module:engine/view/range~Range|
-	 * module:engine/view/item~Item|null} [selectable=null]
+	 * @param {module:engine/view/selection~Selectable} [selectable=null]
 	 * @param {Number|'before'|'end'|'after'|'on'|'in'} [placeOrOffset] Offset or place when selectable is an `Item`.
 	 * @param {Object} [options]
 	 * @param {Boolean} [options.backward] Sets this selection instance to be backward.

+ 37 - 2
packages/ckeditor5-engine/tests/conversion/conversion.js

@@ -133,7 +133,7 @@ describe( 'Conversion', () => {
 				test( '<p>Foo</p>', '<paragraph>Foo</paragraph>' );
 			} );
 
-			it( 'config.converterPriority is defined', () => {
+			it( 'config.converterPriority is defined (override downcast)', () => {
 				conversion.elementToElement( { model: 'paragraph', view: 'p' } );
 				conversion.elementToElement( { model: 'paragraph', view: 'div', converterPriority: 'high' } );
 
@@ -141,6 +141,16 @@ describe( 'Conversion', () => {
 				test( '<p>Foo</p>', '<paragraph>Foo</paragraph>', '<div>Foo</div>' );
 			} );
 
+			it( 'config.converterPriority is defined (override upcast)', () => {
+				schema.register( 'foo', {
+					inheritAllFrom: '$block'
+				} );
+				conversion.elementToElement( { model: 'paragraph', view: 'p' } );
+				conversion.elementToElement( { model: 'foo', view: 'p', converterPriority: 'high' } );
+
+				test( '<p>Foo</p>', '<foo>Foo</foo>', '<p>Foo</p>' );
+			} );
+
 			it( 'config.view is an object', () => {
 				schema.register( 'fancyParagraph', {
 					inheritAllFrom: 'paragraph'
@@ -232,7 +242,7 @@ describe( 'Conversion', () => {
 				test( '<p><strong>Foo</strong> bar</p>', '<paragraph><$text bold="true">Foo</$text> bar</paragraph>' );
 			} );
 
-			it( 'config.converterPriority is defined', () => {
+			it( 'config.converterPriority is defined (override downcast)', () => {
 				conversion.attributeToElement( { model: 'bold', view: 'strong' } );
 				conversion.attributeToElement( { model: 'bold', view: 'b', converterPriority: 'high' } );
 
@@ -240,6 +250,20 @@ describe( 'Conversion', () => {
 				test( '<p><strong>Foo</strong></p>', '<paragraph><$text bold="true">Foo</$text></paragraph>', '<p><b>Foo</b></p>' );
 			} );
 
+			it( 'config.converterPriority is defined (override upcast)', () => {
+				schema.extend( '$text', {
+					allowAttributes: [ 'foo' ]
+				} );
+				conversion.attributeToElement( { model: 'bold', view: 'strong' } );
+				conversion.attributeToElement( { model: 'foo', view: 'strong', converterPriority: 'high' } );
+
+				test(
+					'<p><strong>Foo</strong></p>',
+					'<paragraph><$text foo="true">Foo</$text></paragraph>',
+					'<p><strong>Foo</strong></p>'
+				);
+			} );
+
 			it( 'config.view is an object', () => {
 				conversion.attributeToElement( {
 					model: 'bold',
@@ -634,6 +658,17 @@ describe( 'Conversion', () => {
 					'<div border="border"><div shade="shade"></div></div>'
 				);
 			} );
+
+			it( 'config.converterPriority is defined (override downcast)', () => {
+				schema.extend( 'image', {
+					allowAttributes: [ 'foo' ]
+				} );
+
+				conversion.attributeToAttribute( { model: 'foo', view: 'foo' } );
+				conversion.attributeToAttribute( { model: 'foo', view: 'foofoo', converterPriority: 'high' } );
+
+				test( '<img foo="foo"></img>', '<image foo="foo"></image>', '<img foofoo="foo"></img>' );
+			} );
 		} );
 
 		function test( input, expectedModel, expectedView = null ) {

+ 0 - 596
packages/ckeditor5-engine/tests/conversion/downcast-selection-converters.js

@@ -1,596 +0,0 @@
-/**
- * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
-
-import Model from '../../src/model/model';
-
-import View from '../../src/view/view';
-import ViewUIElement from '../../src/view/uielement';
-
-import Mapper from '../../src/conversion/mapper';
-import DowncastDispatcher from '../../src/conversion/downcastdispatcher';
-import {
-	convertRangeSelection,
-	convertCollapsedSelection,
-	clearAttributes,
-} from '../../src/conversion/downcast-selection-converters';
-
-import DowncastHelpers, { insertText, wrap } from '../../src/conversion/downcasthelpers';
-
-import createViewRoot from '../view/_utils/createroot';
-import { stringify as stringifyView } from '../../src/dev-utils/view';
-import { setData as setModelData } from '../../src/dev-utils/model';
-
-describe( 'downcast-selection-converters', () => {
-	let dispatcher, mapper, model, view, modelDoc, modelRoot, docSelection, viewDoc, viewRoot, viewSelection, downcastHelpers;
-
-	beforeEach( () => {
-		model = new Model();
-		modelDoc = model.document;
-		modelRoot = modelDoc.createRoot();
-		docSelection = modelDoc.selection;
-
-		model.schema.extend( '$text', { allowIn: '$root' } );
-
-		view = new View();
-		viewDoc = view.document;
-		viewRoot = createViewRoot( viewDoc );
-		viewSelection = viewDoc.selection;
-
-		mapper = new Mapper();
-		mapper.bindElements( modelRoot, viewRoot );
-
-		dispatcher = new DowncastDispatcher( { mapper, viewSelection } );
-
-		dispatcher.on( 'insert:$text', insertText() );
-
-		const strongCreator = ( modelAttributeValue, viewWriter ) => viewWriter.createAttributeElement( 'strong' );
-		dispatcher.on( 'attribute:bold', wrap( strongCreator ) );
-
-		downcastHelpers = new DowncastHelpers( dispatcher );
-		downcastHelpers.markerToHighlight( { model: 'marker', view: { classes: 'marker' }, converterPriority: 1 } );
-
-		// Default selection converters.
-		dispatcher.on( 'selection', clearAttributes(), { priority: 'low' } );
-		dispatcher.on( 'selection', convertRangeSelection(), { priority: 'low' } );
-		dispatcher.on( 'selection', convertCollapsedSelection(), { priority: 'low' } );
-	} );
-
-	afterEach( () => {
-		view.destroy();
-	} );
-
-	describe( 'default converters', () => {
-		describe( 'range selection', () => {
-			it( 'in same container', () => {
-				test(
-					[ 1, 4 ],
-					'foobar',
-					'f{oob}ar'
-				);
-			} );
-
-			it( 'in same container with unicode characters', () => {
-				test(
-					[ 2, 6 ],
-					'நிலைக்கு',
-					'நி{லைக்}கு'
-				);
-			} );
-
-			it( 'in same container, over attribute', () => {
-				test(
-					[ 1, 5 ],
-					'fo<$text bold="true">ob</$text>ar',
-					'f{o<strong>ob</strong>a}r'
-				);
-			} );
-
-			it( 'in same container, next to attribute', () => {
-				test(
-					[ 1, 2 ],
-					'fo<$text bold="true">ob</$text>ar',
-					'f{o}<strong>ob</strong>ar'
-				);
-			} );
-
-			it( 'in same attribute', () => {
-				test(
-					[ 2, 4 ],
-					'f<$text bold="true">ooba</$text>r',
-					'f<strong>o{ob}a</strong>r'
-				);
-			} );
-
-			it( 'in same attribute, selection same as attribute', () => {
-				test(
-					[ 2, 4 ],
-					'fo<$text bold="true">ob</$text>ar',
-					'fo{<strong>ob</strong>}ar'
-				);
-			} );
-
-			it( 'starts in text node, ends in attribute #1', () => {
-				test(
-					[ 1, 3 ],
-					'fo<$text bold="true">ob</$text>ar',
-					'f{o<strong>o}b</strong>ar'
-				);
-			} );
-
-			it( 'starts in text node, ends in attribute #2', () => {
-				test(
-					[ 1, 4 ],
-					'fo<$text bold="true">ob</$text>ar',
-					'f{o<strong>ob</strong>}ar'
-				);
-			} );
-
-			it( 'starts in attribute, ends in text node', () => {
-				test(
-					[ 3, 5 ],
-					'fo<$text bold="true">ob</$text>ar',
-					'fo<strong>o{b</strong>a}r'
-				);
-			} );
-
-			it( 'consumes consumable values properly', () => {
-				// Add callback that will fire before default ones.
-				// This should prevent default callback doing anything.
-				dispatcher.on( 'selection', ( evt, data, conversionApi ) => {
-					expect( conversionApi.consumable.consume( data.selection, 'selection' ) ).to.be.true;
-				}, { priority: 'high' } );
-
-				// Similar test case as the first in this suite.
-				test(
-					[ 1, 4 ],
-					'foobar',
-					'foobar' // No selection in view.
-				);
-			} );
-
-			it( 'should convert backward selection', () => {
-				test(
-					[ 1, 3, 'backward' ],
-					'foobar',
-					'f{oo}bar'
-				);
-
-				expect( viewSelection.focus.offset ).to.equal( 1 );
-			} );
-		} );
-
-		describe( 'collapsed selection', () => {
-			let marker;
-
-			it( 'in container', () => {
-				test(
-					[ 1, 1 ],
-					'foobar',
-					'f{}oobar'
-				);
-			} );
-
-			it( 'in attribute', () => {
-				test(
-					[ 3, 3 ],
-					'f<$text bold="true">ooba</$text>r',
-					'f<strong>oo{}ba</strong>r'
-				);
-			} );
-
-			it( 'in attribute and marker', () => {
-				setModelData( model, 'fo<$text bold="true">ob</$text>ar' );
-
-				model.change( writer => {
-					const range = writer.createRange( writer.createPositionAt( modelRoot, 1 ), writer.createPositionAt( modelRoot, 5 ) );
-					marker = writer.addMarker( 'marker', { range, usingOperation: false } );
-					writer.setSelection( modelRoot, 3 );
-				} );
-
-				// Remove view children manually (without firing additional conversion).
-				viewRoot._removeChildren( 0, viewRoot.childCount );
-
-				// Convert model to view.
-				view.change( writer => {
-					dispatcher.convertInsert( model.createRangeIn( modelRoot ), writer );
-					dispatcher.convertMarkerAdd( marker.name, marker.getRange(), writer );
-					dispatcher.convertSelection( docSelection, model.markers, writer );
-				} );
-
-				// Stringify view and check if it is same as expected.
-				expect( stringifyView( viewRoot, viewSelection, { showType: false } ) ).to.equal(
-					'<div>f<span class="marker">o<strong>o{}b</strong>a</span>r</div>'
-				);
-			} );
-
-			it( 'in attribute and marker - no attribute', () => {
-				setModelData( model, 'fo<$text bold="true">ob</$text>ar' );
-
-				model.change( writer => {
-					const range = writer.createRange( writer.createPositionAt( modelRoot, 1 ), writer.createPositionAt( modelRoot, 5 ) );
-					marker = writer.addMarker( 'marker', { range, usingOperation: false } );
-					writer.setSelection( modelRoot, 3 );
-					writer.removeSelectionAttribute( 'bold' );
-				} );
-
-				// Remove view children manually (without firing additional conversion).
-				viewRoot._removeChildren( 0, viewRoot.childCount );
-
-				// Convert model to view.
-				view.change( writer => {
-					dispatcher.convertInsert( model.createRangeIn( modelRoot ), writer );
-					dispatcher.convertMarkerAdd( marker.name, marker.getRange(), writer );
-					dispatcher.convertSelection( docSelection, model.markers, writer );
-				} );
-
-				// Stringify view and check if it is same as expected.
-				expect( stringifyView( viewRoot, viewSelection, { showType: false } ) )
-					.to.equal( '<div>f<span class="marker">o<strong>o</strong>[]<strong>b</strong>a</span>r</div>' );
-			} );
-
-			it( 'in marker - using highlight descriptor creator', () => {
-				downcastHelpers.markerToHighlight( {
-					model: 'marker2',
-					view: data => ( { classes: data.markerName } )
-				} );
-
-				setModelData( model, 'foobar' );
-
-				model.change( writer => {
-					const range = writer.createRange( writer.createPositionAt( modelRoot, 1 ), writer.createPositionAt( modelRoot, 5 ) );
-					marker = writer.addMarker( 'marker2', { range, usingOperation: false } );
-					writer.setSelection( modelRoot, 3 );
-				} );
-
-				// Remove view children manually (without firing additional conversion).
-				viewRoot._removeChildren( 0, viewRoot.childCount );
-
-				// Convert model to view.
-				view.change( writer => {
-					dispatcher.convertInsert( model.createRangeIn( modelRoot ), writer );
-					dispatcher.convertMarkerAdd( marker.name, marker.getRange(), writer );
-					dispatcher.convertSelection( docSelection, model.markers, writer );
-				} );
-
-				// Stringify view and check if it is same as expected.
-				expect( stringifyView( viewRoot, viewSelection, { showType: false } ) )
-					.to.equal( '<div>f<span class="marker2">oo{}ba</span>r</div>' );
-			} );
-
-			it( 'should do nothing if creator return null', () => {
-				downcastHelpers.markerToHighlight( {
-					model: 'marker3',
-					view: () => null
-				} );
-
-				setModelData( model, 'foobar' );
-
-				model.change( writer => {
-					const range = writer.createRange( writer.createPositionAt( modelRoot, 1 ), writer.createPositionAt( modelRoot, 5 ) );
-					marker = writer.addMarker( 'marker3', { range, usingOperation: false } );
-					writer.setSelection( modelRoot, 3 );
-				} );
-
-				// Remove view children manually (without firing additional conversion).
-				viewRoot._removeChildren( 0, viewRoot.childCount );
-
-				// Convert model to view.
-				view.change( writer => {
-					dispatcher.convertInsert( model.createRangeIn( modelRoot ), writer );
-					dispatcher.convertMarkerAdd( marker.name, marker.getRange(), writer );
-					dispatcher.convertSelection( docSelection, model.markers, writer );
-				} );
-
-				// Stringify view and check if it is same as expected.
-				expect( stringifyView( viewRoot, viewSelection, { showType: false } ) )
-					.to.equal( '<div>foo{}bar</div>' );
-			} );
-
-			// #1072 - if the container has only ui elements, collapsed selection attribute should be rendered after those ui elements.
-			it( 'selection with attribute before ui element - no non-ui children', () => {
-				setModelData( model, '' );
-
-				// Add two ui elements to view.
-				viewRoot._appendChild( [
-					new ViewUIElement( 'span' ),
-					new ViewUIElement( 'span' )
-				] );
-
-				model.change( writer => {
-					writer.setSelection( writer.createRange( writer.createPositionFromPath( modelRoot, [ 0 ] ) ) );
-					writer.setSelectionAttribute( 'bold', true );
-				} );
-
-				// Convert model to view.
-				view.change( writer => {
-					dispatcher.convertSelection( docSelection, model.markers, writer );
-				} );
-
-				// Stringify view and check if it is same as expected.
-				expect( stringifyView( viewRoot, viewSelection, { showType: false } ) )
-					.to.equal( '<div><span></span><span></span><strong>[]</strong></div>' );
-			} );
-
-			// #1072.
-			it( 'selection with attribute before ui element - has non-ui children #1', () => {
-				setModelData( model, 'x' );
-
-				model.change( writer => {
-					writer.setSelection( writer.createRange( writer.createPositionFromPath( modelRoot, [ 1 ] ) ) );
-					writer.setSelectionAttribute( 'bold', true );
-				} );
-
-				// Convert model to view.
-				view.change( writer => {
-					dispatcher.convertInsert( model.createRangeIn( modelRoot ), writer );
-
-					// Add ui element to view.
-					const uiElement = new ViewUIElement( 'span' );
-					viewRoot._insertChild( 1, uiElement );
-
-					dispatcher.convertSelection( docSelection, model.markers, writer );
-				} );
-
-				// Stringify view and check if it is same as expected.
-				expect( stringifyView( viewRoot, viewSelection, { showType: false } ) )
-					.to.equal( '<div>x<strong>[]</strong><span></span></div>' );
-			} );
-
-			// #1072.
-			it( 'selection with attribute before ui element - has non-ui children #2', () => {
-				setModelData( model, '<$text bold="true">x</$text>y' );
-
-				model.change( writer => {
-					writer.setSelection( writer.createRange( writer.createPositionFromPath( modelRoot, [ 1 ] ) ) );
-					writer.setSelectionAttribute( 'bold', true );
-				} );
-
-				// Convert model to view.
-				view.change( writer => {
-					dispatcher.convertInsert( model.createRangeIn( modelRoot ), writer );
-
-					// Add ui element to view.
-					const uiElement = new ViewUIElement( 'span' );
-					viewRoot._insertChild( 1, uiElement, writer );
-					dispatcher.convertSelection( docSelection, model.markers, writer );
-				} );
-
-				// Stringify view and check if it is same as expected.
-				expect( stringifyView( viewRoot, viewSelection, { showType: false } ) )
-					.to.equal( '<div><strong>x{}</strong><span></span>y</div>' );
-			} );
-
-			it( 'consumes consumable values properly', () => {
-				// Add callbacks that will fire before default ones.
-				// This should prevent default callbacks doing anything.
-				dispatcher.on( 'selection', ( evt, data, conversionApi ) => {
-					expect( conversionApi.consumable.consume( data.selection, 'selection' ) ).to.be.true;
-				}, { priority: 'high' } );
-
-				dispatcher.on( 'attribute:bold', ( evt, data, conversionApi ) => {
-					expect( conversionApi.consumable.consume( data.item, 'attribute:bold' ) ).to.be.true;
-				}, { priority: 'high' } );
-
-				// Similar test case as above.
-				test(
-					[ 3, 3 ],
-					'f<$text bold="true">ooba</$text>r',
-					'foobar' // No selection in view and no attribute.
-				);
-			} );
-		} );
-	} );
-
-	describe( 'clean-up', () => {
-		describe( 'convertRangeSelection', () => {
-			it( 'should remove all ranges before adding new range', () => {
-				test(
-					[ 0, 2 ],
-					'foobar',
-					'{fo}obar'
-				);
-
-				test(
-					[ 3, 5 ],
-					'foobar',
-					'foo{ba}r'
-				);
-
-				expect( viewSelection.rangeCount ).to.equal( 1 );
-			} );
-		} );
-
-		describe( 'convertCollapsedSelection', () => {
-			it( 'should remove all ranges before adding new range', () => {
-				test(
-					[ 2, 2 ],
-					'foobar',
-					'fo{}obar'
-				);
-
-				test(
-					[ 3, 3 ],
-					'foobar',
-					'foo{}bar'
-				);
-
-				expect( viewSelection.rangeCount ).to.equal( 1 );
-			} );
-		} );
-
-		describe( 'clearAttributes', () => {
-			it( 'should remove all ranges before adding new range', () => {
-				test(
-					[ 3, 3 ],
-					'foobar',
-					'foo<strong>[]</strong>bar',
-					{ bold: 'true' }
-				);
-
-				view.change( writer => {
-					const modelRange = model.createRange( model.createPositionAt( modelRoot, 1 ), model.createPositionAt( modelRoot, 1 ) );
-					model.change( writer => {
-						writer.setSelection( modelRange );
-					} );
-
-					dispatcher.convertSelection( modelDoc.selection, model.markers, writer );
-				} );
-
-				expect( viewSelection.rangeCount ).to.equal( 1 );
-
-				const viewString = stringifyView( viewRoot, viewSelection, { showType: false } );
-				expect( viewString ).to.equal( '<div>f{}oobar</div>' );
-			} );
-
-			it( 'should do nothing if the attribute element had been already removed', () => {
-				test(
-					[ 3, 3 ],
-					'foobar',
-					'foo<strong>[]</strong>bar',
-					{ bold: 'true' }
-				);
-
-				view.change( writer => {
-					// Remove <strong></strong> manually.
-					writer.mergeAttributes( viewSelection.getFirstPosition() );
-
-					const modelRange = model.createRange( model.createPositionAt( modelRoot, 1 ), model.createPositionAt( modelRoot, 1 ) );
-					model.change( writer => {
-						writer.setSelection( modelRange );
-					} );
-
-					dispatcher.convertSelection( modelDoc.selection, model.markers, writer );
-				} );
-
-				expect( viewSelection.rangeCount ).to.equal( 1 );
-
-				const viewString = stringifyView( viewRoot, viewSelection, { showType: false } );
-				expect( viewString ).to.equal( '<div>f{}oobar</div>' );
-			} );
-
-			it( 'should clear fake selection', () => {
-				const modelRange = model.createRange( model.createPositionAt( modelRoot, 1 ), model.createPositionAt( modelRoot, 1 ) );
-
-				view.change( writer => {
-					writer.setSelection( modelRange, { fake: true } );
-
-					dispatcher.convertSelection( docSelection, model.markers, writer );
-				} );
-				expect( viewSelection.isFake ).to.be.false;
-			} );
-		} );
-	} );
-
-	describe( 'table cell selection converter', () => {
-		beforeEach( () => {
-			model.schema.register( 'table', { isLimit: true } );
-			model.schema.register( 'tr', { isLimit: true } );
-			model.schema.register( 'td', { isLimit: true } );
-
-			model.schema.extend( 'table', { allowIn: '$root' } );
-			model.schema.extend( 'tr', { allowIn: 'table' } );
-			model.schema.extend( 'td', { allowIn: 'tr' } );
-			model.schema.extend( '$text', { allowIn: 'td' } );
-
-			const downcastHelpers = new DowncastHelpers( dispatcher );
-
-			// "Universal" converter to convert table structure.
-			downcastHelpers.elementToElement( { model: 'table', view: 'table' } );
-			downcastHelpers.elementToElement( { model: 'tr', view: 'tr' } );
-			downcastHelpers.elementToElement( { model: 'td', view: 'td' } );
-
-			// Special converter for table cells.
-			dispatcher.on( 'selection', ( evt, data, conversionApi ) => {
-				const selection = data.selection;
-
-				if ( !conversionApi.consumable.test( selection, 'selection' ) || selection.isCollapsed ) {
-					return;
-				}
-
-				for ( const range of selection.getRanges() ) {
-					const node = range.start.parent;
-
-					if ( !!node && node.is( 'td' ) ) {
-						conversionApi.consumable.consume( selection, 'selection' );
-
-						const viewNode = conversionApi.mapper.toViewElement( node );
-						conversionApi.writer.addClass( 'selected', viewNode );
-					}
-				}
-			}, { priority: 'high' } );
-		} );
-
-		it( 'should not be used to convert selection that is not on table cell', () => {
-			test(
-				[ 1, 5 ],
-				'f{o<$text bold="true">ob</$text>a}r',
-				'f{o<strong>ob</strong>a}r'
-			);
-		} );
-
-		it( 'should add a class to the selected table cell', () => {
-			test(
-				// table tr#0 td#0 [foo, table tr#0 td#0 bar]
-				[ [ 0, 0, 0, 0 ], [ 0, 0, 0, 3 ] ],
-				'<table><tr><td>foo</td></tr><tr><td>bar</td></tr></table>',
-				'<table><tr><td class="selected">foo</td></tr><tr><td>bar</td></tr></table>'
-			);
-		} );
-
-		it( 'should not be used if selection contains more than just a table cell', () => {
-			test(
-				// table tr td#1 f{oo bar, table tr#2 bar]
-				[ [ 0, 0, 0, 1 ], [ 0, 0, 1, 3 ] ],
-				'<table><tr><td>foo</td><td>bar</td></tr></table>',
-				'[<table><tr><td>foo</td><td>bar</td></tr></table>]'
-			);
-		} );
-	} );
-
-	// Tests if the selection got correctly converted.
-	// Because `setData` might use selection converters itself to set the selection, we can't use it
-	// to set the selection (because then we would test converters using converters).
-	// Instead, the `test` function expects to be passed `selectionPaths` which is an array containing two numbers or two arrays,
-	// that are offsets or paths of selection positions in root element.
-	function test( selectionPaths, modelInput, expectedView, selectionAttributes = {} ) {
-		// Parse passed `modelInput` string and set it as current model.
-		setModelData( model, modelInput );
-
-		// Manually set selection ranges using passed `selectionPaths`.
-		const startPath = typeof selectionPaths[ 0 ] == 'number' ? [ selectionPaths[ 0 ] ] : selectionPaths[ 0 ];
-		const endPath = typeof selectionPaths[ 1 ] == 'number' ? [ selectionPaths[ 1 ] ] : selectionPaths[ 1 ];
-
-		const startPos = model.createPositionFromPath( modelRoot, startPath );
-		const endPos = model.createPositionFromPath( modelRoot, endPath );
-
-		const isBackward = selectionPaths[ 2 ] === 'backward';
-		model.change( writer => {
-			writer.setSelection( writer.createRange( startPos, endPos ), { backward: isBackward } );
-
-			// And add or remove passed attributes.
-			for ( const key in selectionAttributes ) {
-				const value = selectionAttributes[ key ];
-
-				if ( value ) {
-					writer.setSelectionAttribute( key, value );
-				} else {
-					writer.removeSelectionAttribute( key );
-				}
-			}
-		} );
-
-		// Remove view children manually (without firing additional conversion).
-		viewRoot._removeChildren( 0, viewRoot.childCount );
-
-		// Convert model to view.
-		view.change( writer => {
-			dispatcher.convertInsert( model.createRangeIn( modelRoot ), writer );
-			dispatcher.convertSelection( docSelection, model.markers, writer );
-		} );
-
-		// Stringify view and check if it is same as expected.
-		expect( stringifyView( viewRoot, viewSelection, { showType: false } ) ).to.equal( '<div>' + expectedView + '</div>' );
-	}
-} );

文件差异内容过多而无法显示
+ 768 - 208
packages/ckeditor5-engine/tests/conversion/downcasthelpers.js


+ 0 - 131
packages/ckeditor5-engine/tests/conversion/upcast-selection-converters.js

@@ -1,131 +0,0 @@
-/**
- * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
-
-import View from '../../src/view/view';
-import ViewSelection from '../../src/view/selection';
-import ViewRange from '../../src/view/range';
-import createViewRoot from '../view/_utils/createroot';
-
-import Model from '../../src/model/model';
-
-import Mapper from '../../src/conversion/mapper';
-import { convertSelectionChange } from '../../src/conversion/upcast-selection-converters';
-
-import { setData as modelSetData, getData as modelGetData } from '../../src/dev-utils/model';
-import { setData as viewSetData } from '../../src/dev-utils/view';
-
-describe( 'convertSelectionChange', () => {
-	let model, view, viewDocument, mapper, convertSelection, modelRoot, viewRoot;
-
-	beforeEach( () => {
-		model = new Model();
-		modelRoot = model.document.createRoot();
-		model.schema.register( 'paragraph', { inheritAllFrom: '$block' } );
-
-		modelSetData( model, '<paragraph>foo</paragraph><paragraph>bar</paragraph>' );
-
-		view = new View();
-		viewDocument = view.document;
-		viewRoot = createViewRoot( viewDocument, 'div', 'main' );
-
-		viewSetData( view, '<p>foo</p><p>bar</p>' );
-
-		mapper = new Mapper();
-		mapper.bindElements( modelRoot, viewRoot );
-		mapper.bindElements( modelRoot.getChild( 0 ), viewRoot.getChild( 0 ) );
-		mapper.bindElements( modelRoot.getChild( 1 ), viewRoot.getChild( 1 ) );
-
-		convertSelection = convertSelectionChange( model, mapper );
-	} );
-
-	afterEach( () => {
-		view.destroy();
-	} );
-
-	it( 'should convert collapsed selection', () => {
-		const viewSelection = new ViewSelection();
-		viewSelection.setTo( ViewRange._createFromParentsAndOffsets(
-			viewRoot.getChild( 0 ).getChild( 0 ), 1, viewRoot.getChild( 0 ).getChild( 0 ), 1 ) );
-
-		convertSelection( null, { newSelection: viewSelection } );
-
-		expect( modelGetData( model ) ).to.equals( '<paragraph>f[]oo</paragraph><paragraph>bar</paragraph>' );
-		expect( modelGetData( model ) ).to.equal( '<paragraph>f[]oo</paragraph><paragraph>bar</paragraph>' );
-	} );
-
-	it( 'should support unicode', () => {
-		modelSetData( model, '<paragraph>நிலைக்கு</paragraph>' );
-		viewSetData( view, '<p>நிலைக்கு</p>' );
-
-		// Re-bind elements that were just re-set.
-		mapper.bindElements( modelRoot.getChild( 0 ), viewRoot.getChild( 0 ) );
-
-		const viewSelection = new ViewSelection( [
-			ViewRange._createFromParentsAndOffsets( viewRoot.getChild( 0 ).getChild( 0 ), 2, viewRoot.getChild( 0 ).getChild( 0 ), 6 )
-		] );
-
-		convertSelection( null, { newSelection: viewSelection } );
-
-		expect( modelGetData( model ) ).to.equal( '<paragraph>நி[லைக்]கு</paragraph>' );
-	} );
-
-	it( 'should convert multi ranges selection', () => {
-		const viewSelection = new ViewSelection( [
-			ViewRange._createFromParentsAndOffsets(
-				viewRoot.getChild( 0 ).getChild( 0 ), 1, viewRoot.getChild( 0 ).getChild( 0 ), 2 ),
-			ViewRange._createFromParentsAndOffsets(
-				viewRoot.getChild( 1 ).getChild( 0 ), 1, viewRoot.getChild( 1 ).getChild( 0 ), 2 )
-		] );
-
-		convertSelection( null, { newSelection: viewSelection } );
-
-		expect( modelGetData( model ) ).to.equal(
-			'<paragraph>f[o]o</paragraph><paragraph>b[a]r</paragraph>' );
-
-		const ranges = Array.from( model.document.selection.getRanges() );
-		expect( ranges.length ).to.equal( 2 );
-
-		expect( ranges[ 0 ].start.parent ).to.equal( modelRoot.getChild( 0 ) );
-		expect( ranges[ 0 ].start.offset ).to.equal( 1 );
-		expect( ranges[ 0 ].end.parent ).to.equal( modelRoot.getChild( 0 ) );
-		expect( ranges[ 0 ].end.offset ).to.equal( 2 );
-
-		expect( ranges[ 1 ].start.parent ).to.equal( modelRoot.getChild( 1 ) );
-		expect( ranges[ 1 ].start.offset ).to.equal( 1 );
-		expect( ranges[ 1 ].end.parent ).to.equal( modelRoot.getChild( 1 ) );
-		expect( ranges[ 1 ].end.offset ).to.equal( 2 );
-	} );
-
-	it( 'should convert reverse selection', () => {
-		const viewSelection = new ViewSelection( [
-			ViewRange._createFromParentsAndOffsets(
-				viewRoot.getChild( 0 ).getChild( 0 ), 1, viewRoot.getChild( 0 ).getChild( 0 ), 2 ),
-			ViewRange._createFromParentsAndOffsets(
-				viewRoot.getChild( 1 ).getChild( 0 ), 1, viewRoot.getChild( 1 ).getChild( 0 ), 2 )
-		], { backward: true } );
-
-		convertSelection( null, { newSelection: viewSelection } );
-
-		expect( modelGetData( model ) ).to.equal( '<paragraph>f[o]o</paragraph><paragraph>b[a]r</paragraph>' );
-		expect( model.document.selection.isBackward ).to.true;
-	} );
-
-	it( 'should not enqueue changes if selection has not changed', () => {
-		const viewSelection = new ViewSelection( [
-			ViewRange._createFromParentsAndOffsets(
-				viewRoot.getChild( 0 ).getChild( 0 ), 1, viewRoot.getChild( 0 ).getChild( 0 ), 1 )
-		] );
-
-		convertSelection( null, { newSelection: viewSelection } );
-
-		const spy = sinon.spy();
-
-		model.on( 'change', spy );
-
-		convertSelection( null, { newSelection: viewSelection } );
-
-		expect( spy.called ).to.be.false;
-	} );
-} );

+ 122 - 2
packages/ckeditor5-engine/tests/conversion/upcasthelpers.js

@@ -19,9 +19,15 @@ import ModelText from '../../src/model/text';
 import ModelRange from '../../src/model/range';
 import ModelPosition from '../../src/model/position';
 
-import UpcastHelpers, { convertToModelFragment, convertText } from '../../src/conversion/upcasthelpers';
+import UpcastHelpers, { convertToModelFragment, convertText, convertSelectionChange } from '../../src/conversion/upcasthelpers';
 
-import { stringify } from '../../src/dev-utils/model';
+import { getData as modelGetData, setData as modelSetData, stringify } from '../../src/dev-utils/model';
+import View from '../../src/view/view';
+import createViewRoot from '../view/_utils/createroot';
+import { setData as viewSetData } from '../../src/dev-utils/view';
+import Mapper from '../../src/conversion/mapper';
+import ViewSelection from '../../src/view/selection';
+import ViewRange from '../../src/view/range';
 
 describe( 'UpcastHelpers', () => {
 	let upcastDispatcher, model, schema, conversion, upcastHelpers;
@@ -834,4 +840,118 @@ describe( 'upcast-converters', () => {
 			sinon.assert.calledTwice( spy );
 		} );
 	} );
+
+	describe( 'convertSelectionChange()', () => {
+		let model, view, viewDocument, mapper, convertSelection, modelRoot, viewRoot;
+
+		beforeEach( () => {
+			model = new Model();
+			modelRoot = model.document.createRoot();
+			model.schema.register( 'paragraph', { inheritAllFrom: '$block' } );
+
+			modelSetData( model, '<paragraph>foo</paragraph><paragraph>bar</paragraph>' );
+
+			view = new View();
+			viewDocument = view.document;
+			viewRoot = createViewRoot( viewDocument, 'div', 'main' );
+
+			viewSetData( view, '<p>foo</p><p>bar</p>' );
+
+			mapper = new Mapper();
+			mapper.bindElements( modelRoot, viewRoot );
+			mapper.bindElements( modelRoot.getChild( 0 ), viewRoot.getChild( 0 ) );
+			mapper.bindElements( modelRoot.getChild( 1 ), viewRoot.getChild( 1 ) );
+
+			convertSelection = convertSelectionChange( model, mapper );
+		} );
+
+		afterEach( () => {
+			view.destroy();
+		} );
+
+		it( 'should convert collapsed selection', () => {
+			const viewSelection = new ViewSelection();
+			viewSelection.setTo( ViewRange._createFromParentsAndOffsets(
+				viewRoot.getChild( 0 ).getChild( 0 ), 1, viewRoot.getChild( 0 ).getChild( 0 ), 1 ) );
+
+			convertSelection( null, { newSelection: viewSelection } );
+
+			expect( modelGetData( model ) ).to.equals( '<paragraph>f[]oo</paragraph><paragraph>bar</paragraph>' );
+			expect( modelGetData( model ) ).to.equal( '<paragraph>f[]oo</paragraph><paragraph>bar</paragraph>' );
+		} );
+
+		it( 'should support unicode', () => {
+			modelSetData( model, '<paragraph>நிலைக்கு</paragraph>' );
+			viewSetData( view, '<p>நிலைக்கு</p>' );
+
+			// Re-bind elements that were just re-set.
+			mapper.bindElements( modelRoot.getChild( 0 ), viewRoot.getChild( 0 ) );
+
+			const viewSelection = new ViewSelection( [
+				ViewRange._createFromParentsAndOffsets( viewRoot.getChild( 0 ).getChild( 0 ), 2, viewRoot.getChild( 0 ).getChild( 0 ), 6 )
+			] );
+
+			convertSelection( null, { newSelection: viewSelection } );
+
+			expect( modelGetData( model ) ).to.equal( '<paragraph>நி[லைக்]கு</paragraph>' );
+		} );
+
+		it( 'should convert multi ranges selection', () => {
+			const viewSelection = new ViewSelection( [
+				ViewRange._createFromParentsAndOffsets(
+					viewRoot.getChild( 0 ).getChild( 0 ), 1, viewRoot.getChild( 0 ).getChild( 0 ), 2 ),
+				ViewRange._createFromParentsAndOffsets(
+					viewRoot.getChild( 1 ).getChild( 0 ), 1, viewRoot.getChild( 1 ).getChild( 0 ), 2 )
+			] );
+
+			convertSelection( null, { newSelection: viewSelection } );
+
+			expect( modelGetData( model ) ).to.equal(
+				'<paragraph>f[o]o</paragraph><paragraph>b[a]r</paragraph>' );
+
+			const ranges = Array.from( model.document.selection.getRanges() );
+			expect( ranges.length ).to.equal( 2 );
+
+			expect( ranges[ 0 ].start.parent ).to.equal( modelRoot.getChild( 0 ) );
+			expect( ranges[ 0 ].start.offset ).to.equal( 1 );
+			expect( ranges[ 0 ].end.parent ).to.equal( modelRoot.getChild( 0 ) );
+			expect( ranges[ 0 ].end.offset ).to.equal( 2 );
+
+			expect( ranges[ 1 ].start.parent ).to.equal( modelRoot.getChild( 1 ) );
+			expect( ranges[ 1 ].start.offset ).to.equal( 1 );
+			expect( ranges[ 1 ].end.parent ).to.equal( modelRoot.getChild( 1 ) );
+			expect( ranges[ 1 ].end.offset ).to.equal( 2 );
+		} );
+
+		it( 'should convert reverse selection', () => {
+			const viewSelection = new ViewSelection( [
+				ViewRange._createFromParentsAndOffsets(
+					viewRoot.getChild( 0 ).getChild( 0 ), 1, viewRoot.getChild( 0 ).getChild( 0 ), 2 ),
+				ViewRange._createFromParentsAndOffsets(
+					viewRoot.getChild( 1 ).getChild( 0 ), 1, viewRoot.getChild( 1 ).getChild( 0 ), 2 )
+			], { backward: true } );
+
+			convertSelection( null, { newSelection: viewSelection } );
+
+			expect( modelGetData( model ) ).to.equal( '<paragraph>f[o]o</paragraph><paragraph>b[a]r</paragraph>' );
+			expect( model.document.selection.isBackward ).to.true;
+		} );
+
+		it( 'should not enqueue changes if selection has not changed', () => {
+			const viewSelection = new ViewSelection( [
+				ViewRange._createFromParentsAndOffsets(
+					viewRoot.getChild( 0 ).getChild( 0 ), 1, viewRoot.getChild( 0 ).getChild( 0 ), 1 )
+			] );
+
+			convertSelection( null, { newSelection: viewSelection } );
+
+			const spy = sinon.spy();
+
+			model.on( 'change', spy );
+
+			convertSelection( null, { newSelection: viewSelection } );
+
+			expect( spy.called ).to.be.false;
+		} );
+	} );
 } );

+ 247 - 0
packages/ckeditor5-engine/tests/model/documentselection.js

@@ -16,6 +16,7 @@ import MoveOperation from '../../src/model/operation/moveoperation';
 import AttributeOperation from '../../src/model/operation/attributeoperation';
 import SplitOperation from '../../src/model/operation/splitoperation';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
+import Collection from '@ckeditor/ckeditor5-utils/src/collection';
 import count from '@ckeditor/ckeditor5-utils/src/count';
 import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
 import { setData, getData } from '../../src/dev-utils/model';
@@ -197,6 +198,252 @@ describe( 'DocumentSelection', () => {
 		} );
 	} );
 
+	describe( 'markers', () => {
+		it( 'should implement #markers collection', () => {
+			expect( selection.markers ).to.instanceof( Collection );
+			expect( selection.markers ).to.length( 0 );
+		} );
+
+		it( 'should add markers to the collection when selection is inside the marker range', () => {
+			model.change( writer => {
+				writer.setSelection( writer.createRange(
+					writer.createPositionFromPath( root, [ 2, 2 ] ),
+					writer.createPositionFromPath( root, [ 2, 4 ] )
+				) );
+
+				writer.addMarker( 'marker-1', {
+					range: writer.createRange(
+						writer.createPositionFromPath( root, [ 0, 0 ] ),
+						writer.createPositionFromPath( root, [ 2, 2 ] )
+					),
+					usingOperation: false
+				} );
+
+				writer.addMarker( 'marker-2', {
+					range: writer.createRange(
+						writer.createPositionFromPath( root, [ 2, 2 ] ),
+						writer.createPositionFromPath( root, [ 2, 4 ] )
+					),
+					usingOperation: false
+				} );
+
+				writer.addMarker( 'marker-3', {
+					range: writer.createRange(
+						writer.createPositionFromPath( root, [ 2, 1 ] ),
+						writer.createPositionFromPath( root, [ 2, 5 ] )
+					),
+					usingOperation: false
+				} );
+
+				writer.addMarker( 'marker-4', {
+					range: writer.createRange(
+						writer.createPositionFromPath( root, [ 2, 4 ] ),
+						writer.createPositionFromPath( root, [ 3, 0 ] )
+					),
+					usingOperation: false
+				} );
+			} );
+
+			expect( selection.markers.map( marker => marker.name ) ).to.have.members( [ 'marker-2', 'marker-3' ] );
+		} );
+
+		it( 'should update markers after selection change', () => {
+			model.change( writer => {
+				writer.setSelection( writer.createRange(
+					writer.createPositionFromPath( root, [ 2, 1 ] ),
+					writer.createPositionFromPath( root, [ 2, 2 ] )
+				) );
+
+				writer.addMarker( 'marker-1', {
+					range: writer.createRange(
+						writer.createPositionFromPath( root, [ 2, 0 ] ),
+						writer.createPositionFromPath( root, [ 2, 6 ] )
+					),
+					usingOperation: false
+				} );
+
+				writer.addMarker( 'marker-2', {
+					range: writer.createRange(
+						writer.createPositionFromPath( root, [ 2, 0 ] ),
+						writer.createPositionFromPath( root, [ 2, 3 ] )
+					),
+					usingOperation: false
+				} );
+
+				writer.addMarker( 'marker-3', {
+					range: writer.createRange(
+						writer.createPositionFromPath( root, [ 2, 3 ] ),
+						writer.createPositionFromPath( root, [ 2, 6 ] )
+					),
+					usingOperation: false
+				} );
+			} );
+
+			expect( selection.markers.map( marker => marker.name ) ).to.have.members( [ 'marker-1', 'marker-2' ] );
+
+			model.change( writer => {
+				writer.setSelection( writer.createRange(
+					writer.createPositionFromPath( root, [ 2, 4 ] ),
+					writer.createPositionFromPath( root, [ 2, 5 ] )
+				) );
+			} );
+
+			expect( selection.markers.map( marker => marker.name ) ).to.have.members( [ 'marker-1', 'marker-3' ] );
+		} );
+
+		it( 'should update markers after markers change', () => {
+			model.change( writer => {
+				writer.setSelection( writer.createRange(
+					writer.createPositionFromPath( root, [ 2, 1 ] ),
+					writer.createPositionFromPath( root, [ 2, 2 ] )
+				) );
+
+				writer.addMarker( 'marker-1', {
+					range: writer.createRange(
+						writer.createPositionFromPath( root, [ 2, 0 ] ),
+						writer.createPositionFromPath( root, [ 2, 6 ] )
+					),
+					usingOperation: false
+				} );
+
+				writer.addMarker( 'marker-2', {
+					range: writer.createRange(
+						writer.createPositionFromPath( root, [ 2, 0 ] ),
+						writer.createPositionFromPath( root, [ 2, 3 ] )
+					),
+					usingOperation: false
+				} );
+
+				writer.addMarker( 'marker-3', {
+					range: writer.createRange(
+						writer.createPositionFromPath( root, [ 2, 3 ] ),
+						writer.createPositionFromPath( root, [ 2, 6 ] )
+					),
+					usingOperation: false
+				} );
+			} );
+
+			expect( selection.markers.map( marker => marker.name ), 1 ).to.have.members( [ 'marker-1', 'marker-2' ] );
+
+			model.change( writer => {
+				writer.removeMarker( 'marker-1' );
+
+				writer.updateMarker( 'marker-2', {
+					range: writer.createRange(
+						writer.createPositionFromPath( root, [ 2, 3 ] ),
+						writer.createPositionFromPath( root, [ 2, 6 ] )
+					),
+					usingOperation: false
+				} );
+
+				writer.updateMarker( 'marker-3', {
+					range: writer.createRange(
+						writer.createPositionFromPath( root, [ 2, 0 ] ),
+						writer.createPositionFromPath( root, [ 2, 3 ] )
+					),
+					usingOperation: false
+				} );
+			} );
+
+			expect( selection.markers.map( marker => marker.name ), 2 ).to.have.members( [ 'marker-3' ] );
+		} );
+
+		it( 'should not add marker when collapsed selection is on the marker left bound', () => {
+			model.change( writer => {
+				writer.setSelection( writer.createRange(
+					writer.createPositionFromPath( root, [ 2, 2 ] ),
+					writer.createPositionFromPath( root, [ 2, 4 ] )
+				) );
+
+				writer.addMarker( 'marker', {
+					range: writer.createRange(
+						writer.createPositionFromPath( root, [ 2, 2 ] )
+					),
+					usingOperation: false
+				} );
+			} );
+
+			expect( selection.markers ).to.length( 0 );
+		} );
+
+		it( 'should not add marker when collapsed selection is on the marker right bound', () => {
+			model.change( writer => {
+				writer.setSelection( writer.createRange(
+					writer.createPositionFromPath( root, [ 2, 4 ] )
+				) );
+
+				writer.addMarker( 'marker', {
+					range: writer.createRange(
+						writer.createPositionFromPath( root, [ 2, 2 ] ),
+						writer.createPositionFromPath( root, [ 2, 4 ] )
+					),
+					usingOperation: false
+				} );
+			} );
+
+			expect( selection.markers ).to.length( 0 );
+		} );
+
+		it( 'should add marker when non-collapsed selection is inside a marker and touches the left bound', () => {
+			model.change( writer => {
+				writer.setSelection( writer.createRange(
+					writer.createPositionFromPath( root, [ 2, 1 ] ),
+					writer.createPositionFromPath( root, [ 2, 3 ] )
+				) );
+
+				writer.addMarker( 'marker', {
+					range: writer.createRange(
+						writer.createPositionFromPath( root, [ 2, 1 ] ),
+						writer.createPositionFromPath( root, [ 2, 5 ] )
+					),
+					usingOperation: false
+				} );
+			} );
+
+			expect( selection.markers.map( marker => marker.name ) ).to.have.members( [ 'marker' ] );
+		} );
+
+		it( 'should add marker when non-collapsed selection is inside a marker and touches the right bound', () => {
+			model.change( writer => {
+				writer.setSelection( writer.createRange(
+					writer.createPositionFromPath( root, [ 2, 2 ] ),
+					writer.createPositionFromPath( root, [ 2, 5 ] )
+				) );
+
+				writer.addMarker( 'marker', {
+					range: writer.createRange(
+						writer.createPositionFromPath( root, [ 2, 1 ] ),
+						writer.createPositionFromPath( root, [ 2, 5 ] )
+					),
+					usingOperation: false
+				} );
+			} );
+
+			expect( selection.markers.map( marker => marker.name ) ).to.have.members( [ 'marker' ] );
+		} );
+
+		it( 'should add marker of selected widget', () => {
+			root._insertChild( 0, new Element( 'widget' ) );
+
+			model.change( writer => {
+				writer.setSelection( writer.createRange(
+					writer.createPositionFromPath( root, [ 0 ] ),
+					writer.createPositionFromPath( root, [ 1 ] )
+				) );
+
+				writer.addMarker( 'marker', {
+					range: writer.createRange(
+						writer.createPositionFromPath( root, [ 0 ] ),
+						writer.createPositionFromPath( root, [ 1 ] )
+					),
+					usingOperation: false
+				} );
+			} );
+
+			expect( selection.markers.map( marker => marker.name ) ).to.have.members( [ 'marker' ] );
+		} );
+	} );
+
 	describe( 'destroy()', () => {
 		it( 'should unbind all events', () => {
 			selection._setTo( [ range, liveRange ] );

+ 68 - 0
packages/ckeditor5-engine/tests/model/selection.js

@@ -1110,6 +1110,74 @@ describe( 'Selection', () => {
 		}
 	} );
 
+	describe( 'getTopMostBlocks()', () => {
+		beforeEach( () => {
+			model.schema.register( 'p', { inheritAllFrom: '$block' } );
+			model.schema.register( 'lvl0', { isBlock: true, isLimit: true, isObject: true, allowIn: '$root' } );
+			model.schema.register( 'lvl1', { allowIn: 'lvl0', isLimit: true } );
+			model.schema.register( 'lvl2', { allowIn: 'lvl1', isObject: true } );
+
+			model.schema.extend( 'p', { allowIn: 'lvl2' } );
+		} );
+
+		it( 'returns an iterator', () => {
+			setData( model, '<p>a</p><p>[]b</p><p>c</p>' );
+
+			expect( doc.selection.getTopMostBlocks().next ).to.be.a( 'function' );
+		} );
+
+		it( 'returns block for a collapsed selection', () => {
+			setData( model, '<p>a</p><p>[]b</p><p>c</p>' );
+
+			expect( stringifyBlocks( doc.selection.getTopMostBlocks() ) ).to.deep.equal( [ 'p#b' ] );
+		} );
+
+		it( 'returns block for a collapsed selection (empty block)', () => {
+			setData( model, '<p>a</p><p>[]</p><p>c</p>' );
+
+			const blocks = Array.from( doc.selection.getTopMostBlocks() );
+
+			expect( blocks ).to.have.length( 1 );
+			expect( blocks[ 0 ].childCount ).to.equal( 0 );
+		} );
+
+		it( 'returns block for a non collapsed selection', () => {
+			setData( model, '<p>a</p><p>[b]</p><p>c</p>' );
+
+			expect( stringifyBlocks( doc.selection.getTopMostBlocks() ) ).to.deep.equal( [ 'p#b' ] );
+		} );
+
+		it( 'returns two blocks for a non collapsed selection', () => {
+			setData( model, '<p>a</p><p>[b</p><p>c]</p><p>d</p>' );
+
+			expect( stringifyBlocks( doc.selection.getTopMostBlocks() ) ).to.deep.equal( [ 'p#b', 'p#c' ] );
+		} );
+
+		it( 'returns only top most blocks', () => {
+			setData( model, '[<p>foo</p><lvl0><lvl1><lvl2><p>bar</p></lvl2></lvl1></lvl0><p>baz</p>]' );
+
+			expect( stringifyBlocks( doc.selection.getTopMostBlocks() ) ).to.deep.equal( [ 'p#foo', 'lvl0', 'p#baz' ] );
+		} );
+
+		it( 'returns only selected blocks even if nested in other blocks', () => {
+			setData( model, '<p>foo</p><lvl0><lvl1><lvl2><p>[b]ar</p></lvl2></lvl1></lvl0><p>baz</p>' );
+
+			expect( stringifyBlocks( doc.selection.getTopMostBlocks() ) ).to.deep.equal( [ 'p#bar' ] );
+		} );
+
+		// Map all elements to names. If element contains child text node it will be appended to name with '#'.
+		function stringifyBlocks( elements ) {
+			return Array.from( elements ).map( el => {
+				const name = el.name;
+
+				const firstChild = el.getChild( 0 );
+				const hasText = firstChild && firstChild.data;
+
+				return hasText ? `${ name }#${ firstChild.data }` : name;
+			} );
+		}
+	} );
+
 	describe( 'attributes interface', () => {
 		let rangeInFullP;
 

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

@@ -158,6 +158,18 @@ describe( 'Writer', () => {
 			expect( Array.from( parent.getChildren() ) ).to.deep.equal( [ child1, child2, child3 ] );
 		} );
 
+		it( 'should do nothing if empty text node is being inserted', () => {
+			const parent = createDocumentFragment();
+
+			model.enqueueChange( batch, writer => {
+				const text = writer.createText( '' );
+
+				writer.insert( text, parent );
+			} );
+
+			expect( parent.childCount ).to.equal( 0 );
+		} );
+
 		it( 'should create proper operation for inserting element', () => {
 			const parent = createDocumentFragment();
 			const element = createElement( 'child' );