8
0
فهرست منبع

Merge branch 'master' into t/1336

# Conflicts:
#	src/conversion/upcast-converters.js
Maciej Gołaszewski 7 سال پیش
والد
کامیت
8ee8451e8e
59فایلهای تغییر یافته به همراه6137 افزوده شده و 4849 حذف شده
  1. 78 0
      packages/ckeditor5-engine/CHANGELOG.md
  2. 1 1
      packages/ckeditor5-engine/docs/api/engine.md
  3. 16 16
      packages/ckeditor5-engine/package.json
  4. 2 2
      packages/ckeditor5-engine/src/controller/datacontroller.js
  5. 2 3
      packages/ckeditor5-engine/src/controller/editingcontroller.js
  6. 98 91
      packages/ckeditor5-engine/src/conversion/conversion.js
  7. 0 1061
      packages/ckeditor5-engine/src/conversion/downcast-converters.js
  8. 0 129
      packages/ckeditor5-engine/src/conversion/downcast-selection-converters.js
  9. 15 9
      packages/ckeditor5-engine/src/conversion/downcastdispatcher.js
  10. 1346 0
      packages/ckeditor5-engine/src/conversion/downcasthelpers.js
  11. 1 1
      packages/ckeditor5-engine/src/conversion/modelconsumable.js
  12. 0 609
      packages/ckeditor5-engine/src/conversion/upcast-converters.js
  13. 0 48
      packages/ckeditor5-engine/src/conversion/upcast-selection-converters.js
  14. 756 0
      packages/ckeditor5-engine/src/conversion/upcasthelpers.js
  15. 7 4
      packages/ckeditor5-engine/src/dev-utils/model.js
  16. 1 1
      packages/ckeditor5-engine/src/dev-utils/view.js
  17. 23 2
      packages/ckeditor5-engine/src/model/documentselection.js
  18. 3 3
      packages/ckeditor5-engine/src/model/markercollection.js
  19. 1 1
      packages/ckeditor5-engine/src/model/node.js
  20. 70 2
      packages/ckeditor5-engine/src/model/operation/transform.js
  21. 15 0
      packages/ckeditor5-engine/src/model/range.js
  22. 3 3
      packages/ckeditor5-engine/src/model/schema.js
  23. 47 0
      packages/ckeditor5-engine/src/model/selection.js
  24. 2 1
      packages/ckeditor5-engine/src/model/text.js
  25. 47 18
      packages/ckeditor5-engine/src/model/utils/selection-post-fixer.js
  26. 69 12
      packages/ckeditor5-engine/src/model/writer.js
  27. 3 3
      packages/ckeditor5-engine/src/view/attributeelement.js
  28. 1 1
      packages/ckeditor5-engine/src/view/document.js
  29. 2 2
      packages/ckeditor5-engine/src/view/element.js
  30. 2 0
      packages/ckeditor5-engine/src/view/emptyelement.js
  31. 1 1
      packages/ckeditor5-engine/src/view/filler.js
  32. 2 2
      packages/ckeditor5-engine/src/view/matcher.js
  33. 3 2
      packages/ckeditor5-engine/src/view/text.js
  34. 4 1
      packages/ckeditor5-engine/src/view/textproxy.js
  35. 2 0
      packages/ckeditor5-engine/src/view/uielement.js
  36. 24 29
      packages/ckeditor5-engine/tests/controller/datacontroller.js
  37. 6 5
      packages/ckeditor5-engine/tests/controller/editingcontroller.js
  38. 94 15
      packages/ckeditor5-engine/tests/conversion/conversion.js
  39. 0 1755
      packages/ckeditor5-engine/tests/conversion/downcast-converters.js
  40. 0 602
      packages/ckeditor5-engine/tests/conversion/downcast-selection-converters.js
  41. 99 19
      packages/ckeditor5-engine/tests/conversion/downcastdispatcher.js
  42. 2388 0
      packages/ckeditor5-engine/tests/conversion/downcasthelpers.js
  43. 0 131
      packages/ckeditor5-engine/tests/conversion/upcast-selection-converters.js
  44. 197 130
      packages/ckeditor5-engine/tests/conversion/upcasthelpers.js
  45. 22 0
      packages/ckeditor5-engine/tests/dev-utils/model.js
  46. 8 18
      packages/ckeditor5-engine/tests/manual/highlight.js
  47. 4 8
      packages/ckeditor5-engine/tests/manual/markers.js
  48. 8 16
      packages/ckeditor5-engine/tests/manual/nestededitable.js
  49. 8 11
      packages/ckeditor5-engine/tests/manual/selection.js
  50. 4 12
      packages/ckeditor5-engine/tests/manual/tickets/475/1.js
  51. 4 5
      packages/ckeditor5-engine/tests/manual/tickets/ckeditor5-721/1.js
  52. 46 0
      packages/ckeditor5-engine/tests/model/operation/transform/marker.js
  53. 70 0
      packages/ckeditor5-engine/tests/model/operation/transform/undo.js
  54. 21 0
      packages/ckeditor5-engine/tests/model/range.js
  55. 68 0
      packages/ckeditor5-engine/tests/model/selection.js
  56. 284 44
      packages/ckeditor5-engine/tests/model/utils/selection-post-fixer.js
  57. 152 4
      packages/ckeditor5-engine/tests/model/writer.js
  58. 4 12
      packages/ckeditor5-engine/tests/tickets/699.js
  59. 3 4
      packages/ckeditor5-engine/tests/utils/bindtwostepcarettoattribute.js

+ 78 - 0
packages/ckeditor5-engine/CHANGELOG.md

@@ -1,6 +1,84 @@
 Changelog
 Changelog
 =========
 =========
 
 
+## [12.0.0](https://github.com/ckeditor/ckeditor5-engine/compare/v11.0.0...v12.0.0) (2018-12-05)
+
+### Features
+
+* Introduced `createDocumentFragment()`, `createElement()` and `createText()` methods in `UpcastWriter`. Additionally, the `View#change()` method now returns the return value of its callback. Closes [#1549](https://github.com/ckeditor/ckeditor5-engine/issues/1549). ([ec13c85](https://github.com/ckeditor/ckeditor5-engine/commit/ec13c85))
+* The `Model#insertContent()` method will accept offset parameter. ([00dd70c](https://github.com/ckeditor/ckeditor5-engine/commit/00dd70c))
+* Made `Position.createAt()` require the `offset` when the first parameter is a model/view item. This change affected the following methods too. Closes [#1554](https://github.com/ckeditor/ckeditor5-engine/issues/1554). ([00dd70c](https://github.com/ckeditor/ckeditor5-engine/commit/00dd70c))
+
+  * `model.Position.createAt()`
+  * `model.Range.createCollapsedAt()`
+  * `model.Selection#setFocus()`
+  * `model.Writer#insert()`
+  * `model.Writer#insertText()`
+  * `model.Writer#insertElement()`
+  * `model.Writer#move()`
+  * `model.Writer#setSelectionFocus()`
+  * `view.Writer#setSelectionFocus()`
+  * `view.Position.createAt()`
+  * `view.Range.createCollapsedAt()`
+  * `view.Selection#setFocus()`
+
+  See breaking changes.
+
+### Bug fixes
+
+* `Model#deleteContent()` will properly merge elements inside a limit element. Closes [ckeditor/ckeditor5#1265](https://github.com/ckeditor/ckeditor5/issues/1265). ([5d26bc3](https://github.com/ckeditor/ckeditor5-engine/commit/5d26bc3))
+* Added `MoveOperation` x `SplitOperation` transformation for a case when graveyard element is moved. Closes [#1580](https://github.com/ckeditor/ckeditor5-engine/issues/1580). ([f88c918](https://github.com/ckeditor/ckeditor5-engine/commit/f88c918))
+* Better handling for `MoveOperation` x `SplitOperation` transformation special case. Closes [ckeditor/ckeditor5#1288](https://github.com/ckeditor/ckeditor5/issues/1288). ([b92a800](https://github.com/ckeditor/ckeditor5-engine/commit/b92a800))
+* Corrected transformations for pasting and undo scenarios. Closes [ckeditor/ckeditor5#1287](https://github.com/ckeditor/ckeditor5/issues/1287). ([b1e8975](https://github.com/ckeditor/ckeditor5-engine/commit/b1e8975))
+* Do not run attribute-to-attribute downcast conversion on text node attributes. Closes [#1587](https://github.com/ckeditor/ckeditor5-engine/issues/1587). ([6659582](https://github.com/ckeditor/ckeditor5-engine/commit/6659582))
+* Firefox should visually move the caret to a new line after a soft break. Closes [#1439](https://github.com/ckeditor/ckeditor5-engine/issues/1439). ([80392ad](https://github.com/ckeditor/ckeditor5-engine/commit/80392ad))
+* Made markers created by `Writer#insert()` affect the data. Closes [#1583](https://github.com/ckeditor/ckeditor5-engine/issues/1583). ([72aaaf0](https://github.com/ckeditor/ckeditor5-engine/commit/72aaaf0))
+
+### Other changes
+
+* `ContainerElement#getFillerOffset()` can now be re-used in other places in the code (it is now exported by the module). See [ckeditor/ckeditor5-list#117](https://github.com/ckeditor/ckeditor5-list/issues/117). ([12f28bb](https://github.com/ckeditor/ckeditor5-engine/commit/12f28bb))
+* Moved `Position`, `Range` and `Selection` static factories from those classes to the model/view writers and `Model`/`View` instances. Previously, those factories were available as static methods of the `Position`, `Range` and `Selection` classes which meant that you needed to import those classes to your plugin's code to create new instances. That required your package to depend on `@ckeditor/ckeditor5-engine` and was not very useful in general. After this change, you can create instances of those classes without importing anything. See the "Breaking changes" section for more details. Closes [#1555](https://github.com/ckeditor/ckeditor5-engine/issues/1555). ([e7f8467](https://github.com/ckeditor/ckeditor5-engine/commit/e7f8467))
+* Vairous fixes in the API docs. Thanks to [@denisname](https://github.com/denisname)!
+
+### BREAKING CHANGES
+
+* The model `Position.createAt()` method was removed from the public API. Use `writer.createPositionAt()` instead. This method is also available on the `Model` instance.
+* The `offset` parameter of the following methods does not default to `0` and hence is no longer optional when `itemOrPosition` is a model/view item:
+  - `model.Position.createAt()`
+  - `model.Range.createCollapsedAt()`
+  - `model.Selection#setFocus()`
+  - `model.Writer#insert()`
+  - `model.Writer#insertText()`
+  - `model.Writer#insertElement()`
+  - `model.Writer#move()`
+  - `model.Writer#setSelectionFocus()`
+  - `view.Writer#setSelectionFocus()`
+  - `view.Position.createAt()`
+  - `view.Range.createCollapsedAt()`
+  - `view.Selection#setFocus()`
+* The model `Position.createBefore()` method was removed from the public API. Use `writer.createPositionBefore()` instead. This method is also available on the `Model` instance.
+* The model `Position.createFromPosition()` method was removed. Use `writer.createPositionAt( position )` to create a new position instance. This method is also available on the `Model` instance.
+* The model `Position.createFromParentAndOffset()` method was removed. Use `writer.createPositionAt( parent, offset )` instead. This method is also available on the `Model` instance.
+* The model `Range.createIn()` method was removed from the public API. Use `writer.createRangeIn()` instead. This method is also available on the `Model` instance.
+* The model `Range.createOn()` method was removed from the public API. Use `writer.createRangeOn()` instead. This method is also available on the `Model` instance.
+* The model `Range.createFromRange()` method was removed from the public API.
+* The model `Range.createFromParentsAndOffsets()` method was removed from the public API.
+* The model `Range.createFromPositionAndShift()` method was removed from the public API.
+* The model `Range.createCollapsedAt()` method removed method was removed. Use `writer.createRange( position )` to create collapsed range. This method is also available on the `Model` instance.
+* The model `Position.createAfter()` method was removed from the public API. Use `writer.createPositionAfter()` instead. This method is also available on the `Model` instance.
+* The view `Position.createAt()` method was removed from the public API. Use `writer.createPositionAt()` instead. This method is also available on the `View` instance.
+* The view `Position.createAfter()` method was removed from the public API. Use `writer.createPositionAfter()` instead. This method is also available on the `View` instance.
+* The view `Position.createBefore()` method was removed from the public API. Use `writer.createPositionBefore()` instead. This method is also available on the `View` instance.
+* The view `Position.createFromPosition()` method was removed. Use `writer.createPositionAt( position )` to create a new position instance. This method is also available on the `View` instance.
+* The view `Range.createIn()` method was removed from the public API. Use `writer.createRangeIn()` instead. This method is also available on the `View` instance.
+* The view `Range.createOn()` method was removed from the public API. Use `writer.createRangeOn()` instead. This method is also available on the `View` instance.
+* The view `Range.createFromRange()` method was removed from the public API.
+* The view `Range.createFromPositionAndShift()` method was removed from the public API.
+* The view `Range.createFromParentsAndOffsets()` method was removed from the public API.
+* The view `Range.createCollapsedAt()` method removed method was removed. Use `writer.createRange( position )` to create a collapsed range. This method is also available on the `View` instance.
+* The model `Range.createFromRanges()` method was removed from the public API.
+
+
 ## [11.0.0](https://github.com/ckeditor/ckeditor5-engine/compare/v10.2.0...v11.0.0) (2018-10-08)
 ## [11.0.0](https://github.com/ckeditor/ckeditor5-engine/compare/v10.2.0...v11.0.0) (2018-10-08)
 
 
 ### Bug fixes
 ### Bug fixes

+ 1 - 1
packages/ckeditor5-engine/docs/api/engine.md

@@ -12,7 +12,7 @@ Together with the {@link api/core core editor architecture} and the {@link api/u
 
 
 ## Documentation
 ## Documentation
 
 
-See the introduction to the {@link framework/guides/architecture/intro#editing-engine editing engine's architecture}.
+See the introduction to the {@link framework/guides/architecture/editing-engine editing engine's architecture}.
 
 
 You can also browse the API documentation of this package by using the module tree on the left.
 You can also browse the API documentation of this package by using the module tree on the left.
 
 

+ 16 - 16
packages/ckeditor5-engine/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@ckeditor/ckeditor5-engine",
   "name": "@ckeditor/ckeditor5-engine",
-  "version": "11.0.0",
+  "version": "12.0.0",
   "description": "The editing engine of CKEditor 5 – the best browser-based rich text editor.",
   "description": "The editing engine of CKEditor 5 – the best browser-based rich text editor.",
   "keywords": [
   "keywords": [
     "wysiwyg",
     "wysiwyg",
@@ -21,25 +21,25 @@
     "ckeditor 5"
     "ckeditor 5"
   ],
   ],
   "dependencies": {
   "dependencies": {
-    "@ckeditor/ckeditor5-utils": "^11.0.0",
+    "@ckeditor/ckeditor5-utils": "^11.1.0",
     "lodash-es": "^4.17.10"
     "lodash-es": "^4.17.10"
   },
   },
   "devDependencies": {
   "devDependencies": {
-    "@ckeditor/ckeditor5-basic-styles": "^10.0.3",
-    "@ckeditor/ckeditor5-block-quote": "^10.1.0",
-    "@ckeditor/ckeditor5-core": "^11.0.1",
-    "@ckeditor/ckeditor5-editor-classic": "^11.0.1",
-    "@ckeditor/ckeditor5-enter": "^10.1.2",
-    "@ckeditor/ckeditor5-essentials": "^10.1.2",
-    "@ckeditor/ckeditor5-heading": "^10.1.0",
-    "@ckeditor/ckeditor5-link": "^10.0.4",
-    "@ckeditor/ckeditor5-list": "^11.0.2",
-    "@ckeditor/ckeditor5-paragraph": "^10.0.3",
-    "@ckeditor/ckeditor5-typing": "^11.0.1",
-    "@ckeditor/ckeditor5-undo": "^10.0.3",
-    "@ckeditor/ckeditor5-widget": "^10.3.0",
+    "@ckeditor/ckeditor5-basic-styles": "^10.1.0",
+    "@ckeditor/ckeditor5-block-quote": "^10.1.1",
+    "@ckeditor/ckeditor5-core": "^11.1.0",
+    "@ckeditor/ckeditor5-editor-classic": "^11.0.2",
+    "@ckeditor/ckeditor5-enter": "^10.1.3",
+    "@ckeditor/ckeditor5-essentials": "^10.1.3",
+    "@ckeditor/ckeditor5-heading": "^10.1.1",
+    "@ckeditor/ckeditor5-link": "^10.1.0",
+    "@ckeditor/ckeditor5-list": "^11.0.3",
+    "@ckeditor/ckeditor5-paragraph": "^10.0.4",
+    "@ckeditor/ckeditor5-typing": "^11.0.2",
+    "@ckeditor/ckeditor5-undo": "^10.0.4",
+    "@ckeditor/ckeditor5-widget": "^10.3.1",
     "eslint": "^5.5.0",
     "eslint": "^5.5.0",
-    "eslint-config-ckeditor5": "^1.0.7",
+    "eslint-config-ckeditor5": "^1.0.9",
     "husky": "^0.14.3",
     "husky": "^0.14.3",
     "lint-staged": "^7.0.0"
     "lint-staged": "^7.0.0"
   },
   },

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

@@ -14,10 +14,10 @@ import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 import Mapper from '../conversion/mapper';
 import Mapper from '../conversion/mapper';
 
 
 import DowncastDispatcher from '../conversion/downcastdispatcher';
 import DowncastDispatcher from '../conversion/downcastdispatcher';
-import { insertText } from '../conversion/downcast-converters';
+import { insertText } from '../conversion/downcasthelpers';
 
 
 import UpcastDispatcher from '../conversion/upcastdispatcher';
 import UpcastDispatcher from '../conversion/upcastdispatcher';
-import { convertText, convertToModelFragment } from '../conversion/upcast-converters';
+import { convertText, convertToModelFragment } from '../conversion/upcasthelpers';
 
 
 import ViewDocumentFragment from '../view/documentfragment';
 import ViewDocumentFragment from '../view/documentfragment';
 import ViewDocument from '../view/document';
 import ViewDocument from '../view/document';

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

@@ -11,12 +11,11 @@ import RootEditableElement from '../view/rooteditableelement';
 import View from '../view/view';
 import View from '../view/view';
 import Mapper from '../conversion/mapper';
 import Mapper from '../conversion/mapper';
 import DowncastDispatcher from '../conversion/downcastdispatcher';
 import DowncastDispatcher from '../conversion/downcastdispatcher';
-import { insertText, remove } from '../conversion/downcast-converters';
-import { 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 ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin';
 import mix from '@ckeditor/ckeditor5-utils/src/mix';
 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,
  * Controller for the editing pipeline. The editing pipeline controls {@link ~EditingController#model model} rendering,

+ 98 - 91
packages/ckeditor5-engine/src/conversion/conversion.js

@@ -9,18 +9,6 @@
 
 
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 
 
-import {
-	downcastElementToElement,
-	downcastAttributeToElement,
-	downcastAttributeToAttribute
-} from './downcast-converters';
-
-import {
-	upcastElementToElement,
-	upcastElementToAttribute,
-	upcastAttributeToAttribute
-} from './upcast-converters';
-
 /**
 /**
  * A utility class that helps add converters to upcast and downcast dispatchers.
  * A utility class that helps add converters to upcast and downcast dispatchers.
  *
  *
@@ -40,17 +28,18 @@ import {
  * method:
  * method:
  *
  *
  *		// Add a converter to editing downcast and data downcast.
  *		// Add a converter to editing downcast and data downcast.
- *		editor.conversion.for( 'downcast' ).add( downcastElementToElement( config ) );
+ *		editor.conversion.for( 'downcast' ).elementToElement( config ) );
  *
  *
  *		// Add a converter to the data pipepline only:
  *		// Add a converter to the data pipepline only:
- *		editor.conversion.for( 'dataDowncast' ).add( downcastElementToElement( dataConversionConfig ) );
+ *		editor.conversion.for( 'dataDowncast' ).elementToElement( dataConversionConfig ) );
+ *
  *		// And a slightly different one for the editing pipeline:
  *		// And a slightly different one for the editing pipeline:
- *		editor.conversion.for( 'editingDowncast' ).add( downcastElementToElement( editingConversionConfig ) );
+ *		editor.conversion.for( 'editingDowncast' ).elementToElement( editingConversionConfig ) );
  *
  *
  * The functions used in `add()` calls are one-way converters (i.e. you need to remember yourself to add
  * The functions used in `add()` calls are one-way converters (i.e. you need to remember yourself to add
  * a converter in the other direction, if your feature requires that). They are also called "conversion helpers".
  * a converter in the other direction, if your feature requires that). They are also called "conversion helpers".
- * You can find a set of them in the {@link module:engine/conversion/downcast-converters} and
- * {@link module:engine/conversion/upcast-converters} modules.
+ * You can find a set of them in the {@link module:engine/conversion/downcasthelpers} and
+ * {@link module:engine/conversion/upcasthelpers} modules.
  *
  *
  * Besides allowing to register converters to specific dispatchers, you can also use methods available in this
  * Besides allowing to register converters to specific dispatchers, you can also use methods available in this
  * class to add two-way converters (upcast and downcast):
  * class to add two-way converters (upcast and downcast):
@@ -71,7 +60,7 @@ export default class Conversion {
 		 * @private
 		 * @private
 		 * @member {Map}
 		 * @member {Map}
 		 */
 		 */
-		this._dispatchersGroups = new Map();
+		this._conversionHelpers = new Map();
 	}
 	}
 
 
 	/**
 	/**
@@ -81,13 +70,12 @@ export default class Conversion {
 	 * If a given group name is used for the second time, the
 	 * If a given group name is used for the second time, the
 	 * {@link module:utils/ckeditorerror~CKEditorError `conversion-register-group-exists` error} is thrown.
 	 * {@link module:utils/ckeditorerror~CKEditorError `conversion-register-group-exists` error} is thrown.
 	 *
 	 *
-	 * @param {String} groupName The name for dispatchers group.
-	 * @param {Array.<module:engine/conversion/downcastdispatcher~DowncastDispatcher|
-	 * module:engine/conversion/upcastdispatcher~UpcastDispatcher>} dispatchers 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} conversionHelpers
 	 */
 	 */
-	register( groupName, dispatchers ) {
-		if ( this._dispatchersGroups.has( groupName ) ) {
+	register( name, conversionHelpers ) {
+		if ( this._conversionHelpers.has( name ) ) {
 			/**
 			/**
 			 * Trying to register a group name that was already registered.
 			 * Trying to register a group name that was already registered.
 			 *
 			 *
@@ -96,16 +84,18 @@ export default class Conversion {
 			throw new CKEditorError( 'conversion-register-group-exists: Trying to register a group name that was already registered.' );
 			throw new CKEditorError( 'conversion-register-group-exists: Trying to register a group name that was already registered.' );
 		}
 		}
 
 
-		this._dispatchersGroups.set( groupName, dispatchers );
+		this._conversionHelpers.set( name, conversionHelpers );
 	}
 	}
 
 
 	/**
 	/**
 	 * Provides chainable API to assign converters to dispatchers registered under a given group name. Converters are added
 	 * Provides chainable API to assign converters to dispatchers registered under a given group name. Converters are added
-	 * by calling the `.add()` method of an object returned by this function.
+	 * by calling the {@link module:engine/conversion/conversion~ConversionHelpers#add `.add()`} method of an
+	 * {@link module:engine/conversion/conversion~ConversionHelpers conversion helpers} returned by this function.
 	 *
 	 *
-	 *		conversion.for( 'downcast' )
-	 *			.add( conversionHelperA )
-	 *			.add( conversionHelperB );
+	 *		editor.conversion.for( 'downcast' )
+	 *			.add( conversionHelperA ) // Adds a custom converter A.
+	 *			.add( conversionHelperB ) // Adds a custom converter B.
+	 *			.elementToElement( config ); // Adds a custom element-to-element downcast converter.
 	 *
 	 *
 	 * In this example `conversionHelperA` and `conversionHelperB` will be called for all dispatchers from the `'model'` group.
 	 * In this example `conversionHelperA` and `conversionHelperB` will be called for all dispatchers from the `'model'` group.
 	 *
 	 *
@@ -117,15 +107,18 @@ export default class Conversion {
 	 *
 	 *
 	 * For downcast (model-to-view conversion), these are:
 	 * For downcast (model-to-view conversion), these are:
 	 *
 	 *
-	 * * {@link module:engine/conversion/downcast-converters~downcastElementToElement Downcast element-to-element converter},
-	 * * {@link module:engine/conversion/downcast-converters~downcastAttributeToElement Downcast attribute-to-element converter},
-	 * * {@link module:engine/conversion/downcast-converters~downcastAttributeToAttribute Downcast attribute-to-attribute converter}.
+	 * * {@link module:engine/conversion/downcasthelpers~DowncastHelpers#elementToElement Downcast element-to-element converter},
+	 * * {@link module:engine/conversion/downcasthelpers~DowncastHelpers#attributeToElement Downcast attribute-to-element converter},
+	 * * {@link module:engine/conversion/downcasthelpers~DowncastHelpers#attributeToAttribute Downcast attribute-to-attribute converter}.
+	 * * {@link module:engine/conversion/downcasthelpers~DowncastHelpers#markerToElement Downcast marker-to-element converter}.
+	 * * {@link module:engine/conversion/downcasthelpers~DowncastHelpers#markerToHighlight Downcast marker-to-highlight converter}.
 	 *
 	 *
 	 * For upcast (view-to-model conversion), these are:
 	 * For upcast (view-to-model conversion), these are:
 	 *
 	 *
-	 * * {@link module:engine/conversion/upcast-converters~upcastElementToElement Upcast element-to-element converter},
-	 * * {@link module:engine/conversion/upcast-converters~upcastElementToAttribute Upcast attribute-to-element converter},
-	 * * {@link module:engine/conversion/upcast-converters~upcastAttributeToAttribute Upcast attribute-to-attribute converter}.
+	 * * {@link module:engine/conversion/upcasthelpers~UpcastHelpers#elementToElement Upcast element-to-element converter},
+	 * * {@link module:engine/conversion/upcasthelpers~UpcastHelpers#elementToAttribute Upcast attribute-to-element converter},
+	 * * {@link module:engine/conversion/upcasthelpers~UpcastHelpers#attributeToAttribute Upcast attribute-to-attribute converter}.
+	 * * {@link module:engine/conversion/upcasthelpers~UpcastHelpers#elementToMarker Upcast element-to-marker converter}.
 	 *
 	 *
 	 * An example of using conversion helpers to convert the `paragraph` model element to the `p` view element (and back):
 	 * An example of using conversion helpers to convert the `paragraph` model element to the `p` view element (and back):
 	 *
 	 *
@@ -133,27 +126,14 @@ export default class Conversion {
 	 *		const config = { model: 'paragraph', view: 'p' };
 	 *		const config = { model: 'paragraph', view: 'p' };
 	 *
 	 *
 	 *		// Add converters to proper dispatchers using conversion helpers.
 	 *		// Add converters to proper dispatchers using conversion helpers.
-	 *		conversion.for( 'downcast' ).add( downcastElementToElement( config ) );
-	 *		conversion.for( 'upcast' ).add( upcastElementToElement( config ) );
-	 *
-	 * An example of providing a custom conversion helper that uses a custom converter function:
-	 *
-	 *		// Adding a custom `myConverter` converter for 'paragraph' element insertion, with the default priority ('normal').
-	 *		conversion.for( 'downcast' ).add( conversion.customConverter( 'insert:paragraph', myConverter ) );
+	 *		editor.conversion.for( 'downcast' ).elementToElement( config ) );
+	 *		editor.conversion.for( 'upcast' ).elementToElement( config ) );
 	 *
 	 *
 	 * @param {String} groupName The name of dispatchers group to add the converters to.
 	 * @param {String} groupName The name of dispatchers group to add the converters to.
-	 * @returns {Object} An object with the `.add()` method, providing a way to add converters.
+	 * @returns {module:engine/conversion/downcasthelpers~DowncastHelpers|module:engine/conversion/upcasthelpers~UpcastHelpers}
 	 */
 	 */
 	for( groupName ) {
 	for( groupName ) {
-		const dispatchers = this._getDispatchers( groupName );
-
-		return {
-			add( conversionHelper ) {
-				_addToDispatchers( dispatchers, conversionHelper );
-
-				return this;
-			}
-		};
+		return this._getConversionHelpers( groupName );
 	}
 	}
 
 
 	/**
 	/**
@@ -229,17 +209,16 @@ export default class Conversion {
 	 */
 	 */
 	elementToElement( definition ) {
 	elementToElement( definition ) {
 		// Set up downcast converter.
 		// Set up downcast converter.
-		this.for( 'downcast' ).add( downcastElementToElement( definition ) );
+		this.for( 'downcast' ).elementToElement( definition );
 
 
 		// Set up upcast converter.
 		// Set up upcast converter.
 		for ( const { model, view } of _getAllUpcastDefinitions( definition ) ) {
 		for ( const { model, view } of _getAllUpcastDefinitions( definition ) ) {
-			this.for( 'upcast' ).add(
-				upcastElementToElement( {
+			this.for( 'upcast' )
+				.elementToElement( {
 					model,
 					model,
 					view,
 					view,
 					converterPriority: definition.converterPriority
 					converterPriority: definition.converterPriority
-				} )
-			);
+				} );
 		}
 		}
 	}
 	}
 
 
@@ -402,23 +381,25 @@ export default class Conversion {
 	 */
 	 */
 	attributeToElement( definition ) {
 	attributeToElement( definition ) {
 		// Set up downcast converter.
 		// Set up downcast converter.
-		this.for( 'downcast' ).add( downcastAttributeToElement( definition ) );
+		this.for( 'downcast' ).attributeToElement( definition );
 
 
 		// Set up upcast converter.
 		// Set up upcast converter.
 		for ( const { model, view } of _getAllUpcastDefinitions( definition ) ) {
 		for ( const { model, view } of _getAllUpcastDefinitions( definition ) ) {
-			this.for( 'upcast' ).add(
-				upcastElementToAttribute( {
+			this.for( 'upcast' )
+				.elementToAttribute( {
 					view,
 					view,
 					model,
 					model,
-					priority: definition.priority
-				} )
-			);
+					converterPriority: definition.converterPriority
+				} );
 		}
 		}
 	}
 	}
 
 
 	/**
 	/**
 	 * Sets up converters between the model and the view that convert a model attribute to a view attribute (and vice versa).
 	 * Sets up converters between the model and the view that convert a model attribute to a view attribute (and vice versa).
 	 * For example, `<image src='foo.jpg'></image>` is converted to `<img src='foo.jpg'></img>` (the same attribute key and value).
 	 * For example, `<image src='foo.jpg'></image>` is converted to `<img src='foo.jpg'></img>` (the same attribute key and value).
+	 * This type of converters is intended to be used with {@link module:engine/model/element~Element model element} nodes.
+	 * To convert text attributes {@link module:engine/conversion/conversion~Conversion#attributeToElement `attributeToElement converter`}
+	 * should be set up.
 	 *
 	 *
 	 *		// A simple conversion from the `source` model attribute to the `src` view attribute (and vice versa).
 	 *		// A simple conversion from the `source` model attribute to the `src` view attribute (and vice versa).
 	 *		conversion.attributeToAttribute( { model: 'source', view: 'src' } );
 	 *		conversion.attributeToAttribute( { model: 'source', view: 'src' } );
@@ -525,34 +506,30 @@ export default class Conversion {
 	 */
 	 */
 	attributeToAttribute( definition ) {
 	attributeToAttribute( definition ) {
 		// Set up downcast converter.
 		// Set up downcast converter.
-		this.for( 'downcast' ).add( downcastAttributeToAttribute( definition ) );
+		this.for( 'downcast' ).attributeToAttribute( definition );
 
 
 		// Set up upcast converter.
 		// Set up upcast converter.
 		for ( const { model, view } of _getAllUpcastDefinitions( definition ) ) {
 		for ( const { model, view } of _getAllUpcastDefinitions( definition ) ) {
-			this.for( 'upcast' ).add(
-				upcastAttributeToAttribute( {
+			this.for( 'upcast' )
+				.attributeToAttribute( {
 					view,
 					view,
 					model
 					model
-				} )
-			);
+				} );
 		}
 		}
 	}
 	}
 
 
 	/**
 	/**
-	 * Returns dispatchers registered under a given group name.
+	 * Returns conversion helpers registered under a given name.
 	 *
 	 *
 	 * If the given group name has not been registered, the
 	 * If the given group name has not been registered, the
 	 * {@link module:utils/ckeditorerror~CKEditorError `conversion-for-unknown-group` error} is thrown.
 	 * {@link module:utils/ckeditorerror~CKEditorError `conversion-for-unknown-group` error} is thrown.
 	 *
 	 *
 	 * @private
 	 * @private
 	 * @param {String} groupName
 	 * @param {String} groupName
-	 * @returns {Array.<module:engine/conversion/downcastdispatcher~DowncastDispatcher|
-	 * module:engine/conversion/upcastdispatcher~UpcastDispatcher>}
+	 * @returns {module:engine/conversion/downcasthelpers~DowncastHelpers|module:engine/conversion/upcasthelpers~UpcastHelpers}
 	 */
 	 */
-	_getDispatchers( groupName ) {
-		const dispatchers = this._dispatchersGroups.get( groupName );
-
-		if ( !dispatchers ) {
+	_getConversionHelpers( groupName ) {
+		if ( !this._conversionHelpers.has( groupName ) ) {
 			/**
 			/**
 			 * Trying to add a converter to an unknown dispatchers group.
 			 * Trying to add a converter to an unknown dispatchers group.
 			 *
 			 *
@@ -561,7 +538,7 @@ export default class Conversion {
 			throw new CKEditorError( 'conversion-for-unknown-group: Trying to add a converter to an unknown dispatchers group.' );
 			throw new CKEditorError( 'conversion-for-unknown-group: Trying to add a converter to an unknown dispatchers group.' );
 		}
 		}
 
 
-		return dispatchers;
+		return this._conversionHelpers.get( groupName );
 	}
 	}
 }
 }
 
 
@@ -582,21 +559,6 @@ export default class Conversion {
  * @property {module:utils/priorities~PriorityString} [converterPriority] The converter priority.
  * @property {module:utils/priorities~PriorityString} [converterPriority] The converter priority.
  */
  */
 
 
-// Helper function for the `Conversion` `.add()` method.
-//
-// Calls `conversionHelper` on each dispatcher from the group specified earlier in the `.for()` call, effectively
-// adding converters to all specified dispatchers.
-//
-// @private
-// @param {Array.<module:engine/conversion/downcastdispatcher~DowncastDispatcher|
-// module:engine/conversion/upcastdispatcher~UpcastDispatcher>} dispatchers
-// @param {Function} conversionHelper
-function _addToDispatchers( dispatchers, conversionHelper ) {
-	for ( const dispatcher of dispatchers ) {
-		conversionHelper( dispatcher );
-	}
-}
-
 // Helper function that creates a joint array out of an item passed in `definition.view` and items passed in
 // Helper function that creates a joint array out of an item passed in `definition.view` and items passed in
 // `definition.upcastAlso`.
 // `definition.upcastAlso`.
 //
 //
@@ -627,3 +589,48 @@ function* _getUpcastDefinition( model, view, upcastAlso ) {
 		}
 		}
 	}
 	}
 }
 }
+
+/**
+ * Base class for conversion helpers.
+ */
+export class ConversionHelpers {
+	/**
+	 * Creates ConversionHelpers instance.
+	 *
+	 * @param {Array.<module:engine/conversion/downcastdispatcher~DowncastDispatcher|
+	 * module:engine/conversion/upcastdispatcher~UpcastDispatcher>} dispatcher
+	 */
+	constructor( dispatcher ) {
+		this._dispatchers = Array.isArray( dispatcher ) ? dispatcher : [ dispatcher ];
+	}
+
+	/**
+	 * Registers a conversion helper.
+	 *
+	 * **Note**: See full usage example in the `{@link module:engine/conversion/conversion~Conversion#for conversion.for()}`
+	 * method description
+	 *
+	 * @param {Function} conversionHelper The function to be called on event.
+	 * @returns {module:engine/conversion/downcasthelpers~DowncastHelpers|module:engine/conversion/upcasthelpers~UpcastHelpers}
+	 */
+	add( conversionHelper ) {
+		this._addToDispatchers( conversionHelper );
+
+		return this;
+	}
+
+	/**
+	 * Helper function for the `Conversion` `.add()` method.
+	 *
+	 * Calls `conversionHelper` on each dispatcher from the group specified earlier in the `.for()` call, effectively
+	 * adding converters to all specified dispatchers.
+	 *
+	 * @private
+	 * @param {Function} conversionHelper
+	 */
+	_addToDispatchers( conversionHelper ) {
+		for ( const dispatcher of this._dispatchers ) {
+			conversionHelper( dispatcher );
+		}
+	}
+}

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

@@ -1,1061 +0,0 @@
-/**
- * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
-
-import ModelRange from '../model/range';
-import ModelSelection from '../model/selection';
-import ModelElement from '../model/element';
-
-import ViewAttributeElement from '../view/attributeelement';
-import DocumentSelection from '../model/documentselection';
-
-import { cloneDeep } from 'lodash-es';
-
-/**
- * Contains downcast (model-to-view) converters for {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher}.
- *
- * @module engine/conversion/downcast-converters
- */
-
-/**
- * Model element to view element conversion helper.
- *
- * This conversion results in creating a view element. For example, model `<paragraph>Foo</paragraph>` becomes `<p>Foo</p>` in the view.
- *
- *		downcastElementToElement( { model: 'paragraph', view: 'p' } );
- *
- *		downcastElementToElement( { model: 'paragraph', view: 'div', converterPriority: 'high' } );
- *
- *		downcastElementToElement( {
- *			model: 'fancyParagraph',
- *			view: {
- *				name: 'p',
- *				classes: 'fancy'
- *			}
- *		} );
- *
- * 		downcastElementToElement( {
- * 			model: 'heading',
- * 			view: ( modelElement, viewWriter ) => viewWriter.createContainerElement( 'h' + modelElement.getAttribute( 'level' ) )
- * 		} );
- *
- * See {@link module:engine/conversion/conversion~Conversion#for `conversion.for()`} to learn how to add a converter
- * to the conversion process.
- *
- * @param {Object} config Conversion configuration.
- * @param {String} config.model The name of the model element to convert.
- * @param {module:engine/view/elementdefinition~ElementDefinition|Function} config.view A view element definition or a function
- * that takes the model element and {@link module:engine/view/downcastwriter~DowncastWriter view downcast writer}
- * as parameters and returns a view container element.
- * @returns {Function} Conversion helper.
- */
-export function downcastElementToElement( config ) {
-	config = cloneDeep( config );
-
-	config.view = _normalizeToElementConfig( config.view, 'container' );
-
-	return dispatcher => {
-		dispatcher.on( 'insert:' + config.model, insertElement( config.view ), { priority: config.converterPriority || 'normal' } );
-	};
-}
-
-/**
- * Model attribute to view element conversion helper.
- *
- * This conversion results in wrapping view nodes with a view attribute element. For example, a model text node with
- * `"Foo"` as data and the `bold` attribute becomes `<strong>Foo</strong>` in the view.
- *
- *		downcastAttributeToElement( { model: 'bold', view: 'strong' } );
- *
- *		downcastAttributeToElement( { model: 'bold', view: 'b', converterPriority: 'high' } );
- *
- *		downcastAttributeToElement( {
- *			model: 'invert',
- *			view: {
- *				name: 'span',
- *				classes: [ 'font-light', 'bg-dark' ]
- *			}
- *		} );
- *
- *		downcastAttributeToElement( {
- *			model: {
- *				key: 'fontSize',
- *				values: [ 'big', 'small' ]
- *			},
- *			view: {
- *				big: {
- *					name: 'span',
- *					styles: {
- *						'font-size': '1.2em'
- *					}
- *				},
- *				small: {
- *					name: 'span',
- *					styles: {
- *						'font-size': '0.8em'
- *					}
- *				}
- *			}
- *		} );
- *
- * 		downcastAttributeToElement( {
- * 			model: 'bold',
- * 			view: ( modelAttributeValue, viewWriter ) => {
- * 				return viewWriter.createAttributeElement( 'span', { style: 'font-weight:' + modelAttributeValue } );
- * 			}
- * 		} );
- *
- *		downcastAttributeToElement( {
- *			model: {
- *				key: 'color',
- *				name: '$text'
- *			},
- *			view: ( modelAttributeValue, viewWriter ) => {
- *				return viewWriter.createAttributeElement( 'span', { style: 'color:' + modelAttributeValue } );
- *			}
- *		} );
- *
- * See {@link module:engine/conversion/conversion~Conversion#for `conversion.for()`} to learn how to add a converter
- * to the conversion process.
- *
- * @param {Object} config Conversion configuration.
- * @param {String|Object} config.model The key of the attribute to convert from or a `{ key, values }` object. `values` is an array
- * of `String`s with possible values if the model attribute is an enumerable.
- * @param {module:engine/view/elementdefinition~ElementDefinition|Function|Object} config.view A view element definition or a function
- * that takes the model attribute value and {@link module:engine/view/downcastwriter~DowncastWriter view downcast writer}
- * as parameters and returns a view attribute element. If `config.model.values` is
- * given, `config.view` should be an object assigning values from `config.model.values` to view element definitions or functions.
- * @param {module:utils/priorities~PriorityString} [config.converterPriority='normal'] Converter priority.
- * @returns {Function} Conversion helper.
- */
-export function downcastAttributeToElement( config ) {
-	config = cloneDeep( config );
-
-	const modelKey = config.model.key ? config.model.key : config.model;
-	let eventName = 'attribute:' + modelKey;
-
-	if ( config.model.name ) {
-		eventName += ':' + config.model.name;
-	}
-
-	if ( config.model.values ) {
-		for ( const modelValue of config.model.values ) {
-			config.view[ modelValue ] = _normalizeToElementConfig( config.view[ modelValue ], 'attribute' );
-		}
-	} else {
-		config.view = _normalizeToElementConfig( config.view, 'attribute' );
-	}
-
-	const elementCreator = _getFromAttributeCreator( config );
-
-	return dispatcher => {
-		dispatcher.on( eventName, wrap( elementCreator ), { priority: config.converterPriority || 'normal' } );
-	};
-}
-
-/**
- * Model attribute to view attribute conversion helper.
- *
- * This conversion results in adding an attribute to a view node, basing on an attribute from a model node. For example,
- * `<image src='foo.jpg'></image>` is converted to `<img src='foo.jpg'></img>`.
- *
- *		downcastAttributeToAttribute( { model: 'source', view: 'src' } );
- *
- *		downcastAttributeToAttribute( { model: 'source', view: 'href', converterPriority: 'high' } );
- *
- *		downcastAttributeToAttribute( {
- *			model: {
- *				name: 'image',
- *				key: 'source'
- *			},
- *			view: 'src'
- *		} );
- *
- *		downcastAttributeToAttribute( {
- *			model: {
- *				name: 'styled',
- *				values: [ 'dark', 'light' ]
- *			},
- *			view: {
- *				dark: {
- *					key: 'class',
- *					value: [ 'styled', 'styled-dark' ]
- *				},
- *				light: {
- *					key: 'class',
- *					value: [ 'styled', 'styled-light' ]
- *				}
- *			}
- *		} );
- *
- *		downcastAttributeToAttribute( {
- *			model: 'styled',
- *			view: modelAttributeValue => ( { key: 'class', value: 'styled-' + modelAttributeValue } )
- *		} );
- *
- * See {@link module:engine/conversion/conversion~Conversion#for `conversion.for()`} to learn how to add a converter
- * to the conversion process.
- *
- * @param {Object} config Conversion configuration.
- * @param {String|Object} config.model The key of the attribute to convert from or a `{ key, values, [ name ] }` object describing
- * the attribute key, possible values and, optionally, an element name to convert from.
- * @param {String|Object|Function} config.view A view attribute key, or a `{ key, value }` object or a function that takes
- * the model attribute value and returns a `{ key, value }` object. If `key` is `'class'`, `value` can be a `String` or an
- * array of `String`s. If `key` is `'style'`, `value` is an object with key-value pairs. In other cases, `value` is a `String`.
- * If `config.model.values` is set, `config.view` should be an object assigning values from `config.model.values` to
- * `{ key, value }` objects or a functions.
- * @param {module:utils/priorities~PriorityString} [config.converterPriority='normal'] Converter priority.
- * @returns {Function} Conversion helper.
- */
-export function downcastAttributeToAttribute( config ) {
-	config = cloneDeep( config );
-
-	const modelKey = config.model.key ? config.model.key : config.model;
-	let eventName = 'attribute:' + modelKey;
-
-	if ( config.model.name ) {
-		eventName += ':' + config.model.name;
-	}
-
-	if ( config.model.values ) {
-		for ( const modelValue of config.model.values ) {
-			config.view[ modelValue ] = _normalizeToAttributeConfig( config.view[ modelValue ] );
-		}
-	} else {
-		config.view = _normalizeToAttributeConfig( config.view );
-	}
-
-	const elementCreator = _getFromAttributeCreator( config );
-
-	return dispatcher => {
-		dispatcher.on( eventName, changeAttribute( elementCreator ), { priority: config.converterPriority || 'normal' } );
-	};
-}
-
-/**
- * Model marker to view element conversion helper.
- *
- * This conversion results in creating a view element on the boundaries of the converted marker. If the converted marker
- * is collapsed, only one element is created. For example, model marker set like this: `<paragraph>F[oo b]ar</paragraph>`
- * becomes `<p>F<span data-marker="search"></span>oo b<span data-marker="search"></span>ar</p>` in the view.
- *
- *		downcastMarkerToElement( { model: 'search', view: 'marker-search' } );
- *
- *		downcastMarkerToElement( { model: 'search', view: 'search-result', converterPriority: 'high' } );
- *
- *		downcastMarkerToElement( {
- *			model: 'search',
- *			view: {
- *				name: 'span',
- *				attributes: {
- *					'data-marker': 'search'
- *				}
- *			}
- *		} );
- *
- * 		downcastMarkerToElement( {
- * 			model: 'search',
- * 			view: ( markerData, viewWriter ) => {
- *	 			return viewWriter.createUIElement( 'span', { 'data-marker': 'search', 'data-start': markerData.isOpening } );
- * 			}
- * 		} );
- *
- * 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/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.
- *
- * This kind of conversion is useful for saving data into the database, so it should be used in the data conversion pipeline.
- *
- * See {@link module:engine/conversion/conversion~Conversion#for} to learn how to add a converter to the conversion process.
- *
- * @param {Object} config Conversion configuration.
- * @param {String} config.model The name of the model marker (or model marker group) to convert.
- * @param {module:engine/view/elementdefinition~ElementDefinition|Function} config.view A view element definition or a function
- * that takes the model marker data as a parameter and returns a view UI element.
- * @param {module:utils/priorities~PriorityString} [config.converterPriority='normal'] Converter priority.
- * @returns {Function} Conversion helper.
- */
-export function downcastMarkerToElement( config ) {
-	config = cloneDeep( config );
-
-	config.view = _normalizeToElementConfig( config.view, 'ui' );
-
-	return dispatcher => {
-		dispatcher.on( 'addMarker:' + config.model, insertUIElement( config.view ), { priority: config.converterPriority || 'normal' } );
-		dispatcher.on( 'removeMarker:' + config.model, removeUIElement( config.view ), { priority: config.converterPriority || 'normal' } );
-	};
-}
-
-/**
- * Model marker to highlight conversion helper.
- *
- * This conversion results in creating a highlight on view nodes. For this kind of conversion,
- * {@link module:engine/conversion/downcast-converters~HighlightDescriptor} should be provided.
- *
- * For text nodes, a `<span>` {@link module:engine/view/attributeelement~AttributeElement} is created and it wraps all text nodes
- * in the converted marker range. For example, a model marker set like this: `<paragraph>F[oo b]ar</paragraph>` becomes
- * `<p>F<span class="comment">oo b</span>ar</p>` in the view.
- *
- * {@link module:engine/view/containerelement~ContainerElement} may provide a custom way of handling highlight. Most often,
- * the element itself is given classes and attributes described in the highlight descriptor (instead of being wrapped in `<span>`).
- * For example, a model marker set like this: `[<image src="foo.jpg"></image>]` becomes `<img src="foo.jpg" class="comment"></img>`
- * in the view.
- *
- * For container elements, the conversion is two-step. While the converter processes the highlight descriptor and passes it
- * to a container element, it is the container element instance itself that applies values from the highlight descriptor.
- * So, in a sense, the converter takes care of stating what should be applied on what, while the element decides how to apply that.
- *
- *		downcastMarkerToHighlight( { model: 'comment', view: { classes: 'comment' } } );
- *
- *		downcastMarkerToHighlight( { model: 'comment', view: { classes: 'new-comment' }, converterPriority: 'high' } );
- *
- * 		downcastMarkerToHighlight( {
- * 			model: 'comment',
- * 			view: data => {
- * 				// Assuming that the marker name is in a form of comment:commentType.
- *	 			const commentType = data.markerName.split( ':' )[ 1 ];
- *
- *	 			return {
- *	 				classes: [ 'comment', 'comment-' + commentType ]
- *	 			};
- * 			}
- * 		} );
- *
- * If a function is passed as the `config.view` parameter, it will be used to generate the highlight descriptor. The function
- * receives the `data` object as a parameter and should return a
- * {@link module:engine/conversion/downcast-converters~HighlightDescriptor highlight descriptor}.
- * The `data` object properties are passed from {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:addMarker}.
- *
- * See {@link module:engine/conversion/conversion~Conversion#for} to learn how to add a converter to the conversion process.
- *
- * @param {Object} config Conversion configuration.
- * @param {String} config.model The name of the model marker (or model marker group) to convert.
- * @param {module:engine/conversion/downcast-converters~HighlightDescriptor|Function} config.view A highlight descriptor
- * that will be used for highlighting or a function that takes the model marker data as a parameter and returns a highlight descriptor.
- * @param {module:utils/priorities~PriorityString} [config.converterPriority='normal'] Converter priority.
- * @returns {Function} Conversion helper.
- */
-export function downcastMarkerToHighlight( config ) {
-	return dispatcher => {
-		dispatcher.on( 'addMarker:' + config.model, highlightText( config.view ), { priority: config.converterPriority || 'normal' } );
-		dispatcher.on( 'addMarker:' + config.model, highlightElement( config.view ), { priority: config.converterPriority || 'normal' } );
-		dispatcher.on( 'removeMarker:' + config.model, removeHighlight( config.view ), { priority: config.converterPriority || 'normal' } );
-	};
-}
-
-// Takes `config.view`, and if it is an {@link module:engine/view/elementdefinition~ElementDefinition}, converts it
-// to a function (because lower level converters accept only element creator functions).
-//
-// @param {module:engine/view/elementdefinition~ElementDefinition|Function} view View configuration.
-// @param {'container'|'attribute'|'ui'} viewElementType View element type to create.
-// @returns {Function} Element creator function to use in lower level converters.
-function _normalizeToElementConfig( view, viewElementType ) {
-	if ( typeof view == 'function' ) {
-		// If `view` is already a function, don't do anything.
-		return view;
-	}
-
-	return ( modelData, viewWriter ) => _createViewElementFromDefinition( view, viewWriter, viewElementType );
-}
-
-// Creates a view element instance from the provided {@link module:engine/view/elementdefinition~ElementDefinition} and class.
-//
-// @param {module:engine/view/elementdefinition~ElementDefinition} viewElementDefinition
-// @param {module:engine/view/downcastwriter~DowncastWriter} viewWriter
-// @param {'container'|'attribute'|'ui'} viewElementType
-// @returns {module:engine/view/element~Element}
-function _createViewElementFromDefinition( viewElementDefinition, viewWriter, viewElementType ) {
-	if ( typeof viewElementDefinition == 'string' ) {
-		// If `viewElementDefinition` is given as a `String`, normalize it to an object with `name` property.
-		viewElementDefinition = { name: viewElementDefinition };
-	}
-
-	let element;
-	const attributes = Object.assign( {}, viewElementDefinition.attributes );
-
-	if ( viewElementType == 'container' ) {
-		element = viewWriter.createContainerElement( viewElementDefinition.name, attributes );
-	} else if ( viewElementType == 'attribute' ) {
-		const options = {
-			priority: viewElementDefinition.priority || ViewAttributeElement.DEFAULT_PRIORITY
-		};
-
-		element = viewWriter.createAttributeElement( viewElementDefinition.name, attributes, options );
-	} else {
-		// 'ui'.
-		element = viewWriter.createUIElement( viewElementDefinition.name, attributes );
-	}
-
-	if ( viewElementDefinition.styles ) {
-		const keys = Object.keys( viewElementDefinition.styles );
-
-		for ( const key of keys ) {
-			viewWriter.setStyle( key, viewElementDefinition.styles[ key ], element );
-		}
-	}
-
-	if ( viewElementDefinition.classes ) {
-		const classes = viewElementDefinition.classes;
-
-		if ( typeof classes == 'string' ) {
-			viewWriter.addClass( classes, element );
-		} else {
-			for ( const className of classes ) {
-				viewWriter.addClass( className, element );
-			}
-		}
-	}
-
-	return element;
-}
-
-function _getFromAttributeCreator( config ) {
-	if ( config.model.values ) {
-		return ( modelAttributeValue, viewWriter ) => {
-			const view = config.view[ modelAttributeValue ];
-
-			if ( view ) {
-				return view( modelAttributeValue, viewWriter );
-			}
-
-			return null;
-		};
-	} else {
-		return config.view;
-	}
-}
-
-// Takes the configuration, adds default parameters if they do not exist and normalizes other parameters to be used in downcast converters
-// for generating a view attribute.
-//
-// @param {Object} view View configuration.
-function _normalizeToAttributeConfig( view ) {
-	if ( typeof view == 'string' ) {
-		return modelAttributeValue => ( { key: view, value: modelAttributeValue } );
-	} else if ( typeof view == 'object' ) {
-		// { key, value, ... }
-		if ( view.value ) {
-			return () => view;
-		}
-		// { key, ... }
-		else {
-			return modelAttributeValue => ( { key: view.key, value: modelAttributeValue } );
-		}
-	} else {
-		// function.
-		return view;
-	}
-}
-
-/**
- * Function factory that creates a converter which converts node insertion changes from the model to the view.
- * The function passed will be provided with all the parameters of the dispatcher's
- * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:insert `insert` event}.
- * It is expected that the function returns an {@link module:engine/view/element~Element}.
- * The result of the function will be inserted into the view.
- *
- * The converter automatically consumes the corresponding value from the consumables list, stops the event (see
- * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher}) and binds the model and view elements.
- *
- *		downcastDispatcher.on(
- *			'insert:myElem',
- *			insertElement( ( modelItem, viewWriter ) => {
- *				const text = viewWriter.createText( 'myText' );
- *				const myElem = viewWriter.createElement( 'myElem', { myAttr: 'my-' + modelItem.getAttribute( 'myAttr' ) }, text );
- *
- *				// Do something fancy with `myElem` using `modelItem` or other parameters.
- *
- *				return myElem;
- *			}
- *		) );
- *
- * @param {Function} elementCreator Function returning a view element, which will be inserted.
- * @returns {Function} Insert element event converter.
- */
-export function insertElement( elementCreator ) {
-	return ( evt, data, conversionApi ) => {
-		const viewElement = elementCreator( data.item, conversionApi.writer );
-
-		if ( !viewElement ) {
-			return;
-		}
-
-		if ( !conversionApi.consumable.consume( data.item, 'insert' ) ) {
-			return;
-		}
-
-		const viewPosition = conversionApi.mapper.toViewPosition( data.range.start );
-
-		conversionApi.mapper.bindElements( data.item, viewElement );
-		conversionApi.writer.insert( viewPosition, viewElement );
-	};
-}
-
-/**
- * Function factory that creates a default downcast converter for text insertion changes.
- *
- * The converter automatically consumes the corresponding value from the consumables list and stops the event (see
- * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher}).
- *
- *		modelDispatcher.on( 'insert:$text', insertText() );
- *
- * @returns {Function} Insert text event converter.
- */
-export function insertText() {
-	return ( evt, data, conversionApi ) => {
-		if ( !conversionApi.consumable.consume( data.item, 'insert' ) ) {
-			return;
-		}
-
-		const viewWriter = conversionApi.writer;
-		const viewPosition = conversionApi.mapper.toViewPosition( data.range.start );
-		const viewText = viewWriter.createText( data.item.data );
-
-		viewWriter.insert( viewPosition, viewText );
-	};
-}
-
-/**
- * Function factory that creates a default downcast converter for node remove changes.
- *
- *		modelDispatcher.on( 'remove', remove() );
- *
- * @returns {Function} Remove event converter.
- */
-export function remove() {
-	return ( evt, data, conversionApi ) => {
-		// Find view range start position by mapping model position at which the remove happened.
-		const viewStart = conversionApi.mapper.toViewPosition( data.position );
-
-		const modelEnd = data.position.getShiftedBy( data.length );
-		const viewEnd = conversionApi.mapper.toViewPosition( modelEnd, { isPhantom: true } );
-
-		const viewRange = conversionApi.writer.createRange( viewStart, viewEnd );
-
-		// Trim the range to remove in case some UI elements are on the view range boundaries.
-		const removed = conversionApi.writer.remove( viewRange.getTrimmed() );
-
-		// After the range is removed, unbind all view elements from the model.
-		// Range inside view document fragment is used to unbind deeply.
-		for ( const child of conversionApi.writer.createRangeIn( removed ).getItems() ) {
-			conversionApi.mapper.unbindViewElement( child );
-		}
-	};
-}
-
-/**
- * Function factory that creates a converter which converts marker adding change to the
- * {@link module:engine/view/uielement~UIElement view UI element}.
- *
- * The view UI element that will be added to the view depends on the passed parameter. See {@link ~insertElement}.
- * In case of a non-collapsed range, the UI element will not wrap nodes but separate elements will be placed at the beginning
- * and at the end of the range.
- *
- * This converter binds created UI elements with the marker name using {@link module:engine/conversion/mapper~Mapper#bindElementToMarker}.
- *
- * @param {module:engine/view/uielement~UIElement|Function} elementCreator A view UI element or a function returning the view element
- * that will be inserted.
- * @returns {Function} Insert element event converter.
- */
-export function insertUIElement( elementCreator ) {
-	return ( evt, data, conversionApi ) => {
-		// Create two view elements. One will be inserted at the beginning of marker, one at the end.
-		// If marker is collapsed, only "opening" element will be inserted.
-		data.isOpening = true;
-		const viewStartElement = elementCreator( data, conversionApi.writer );
-
-		data.isOpening = false;
-		const viewEndElement = elementCreator( data, conversionApi.writer );
-
-		if ( !viewStartElement || !viewEndElement ) {
-			return;
-		}
-
-		const markerRange = data.markerRange;
-
-		// Marker that is collapsed has consumable build differently that non-collapsed one.
-		// For more information see `addMarker` event description.
-		// If marker's range is collapsed - check if it can be consumed.
-		if ( markerRange.isCollapsed && !conversionApi.consumable.consume( markerRange, evt.name ) ) {
-			return;
-		}
-
-		// If marker's range is not collapsed - consume all items inside.
-		for ( const value of markerRange ) {
-			if ( !conversionApi.consumable.consume( value.item, evt.name ) ) {
-				return;
-			}
-		}
-
-		const mapper = conversionApi.mapper;
-		const viewWriter = conversionApi.writer;
-
-		// Add "opening" element.
-		viewWriter.insert( mapper.toViewPosition( markerRange.start ), viewStartElement );
-		conversionApi.mapper.bindElementToMarker( viewStartElement, data.markerName );
-
-		// Add "closing" element only if range is not collapsed.
-		if ( !markerRange.isCollapsed ) {
-			viewWriter.insert( mapper.toViewPosition( markerRange.end ), viewEndElement );
-			conversionApi.mapper.bindElementToMarker( viewEndElement, data.markerName );
-		}
-
-		evt.stop();
-	};
-}
-
-/**
- * Function factory that returns a default downcast converter for removing a {@link module:engine/view/uielement~UIElement UI element}
- * basing on marker remove change.
- *
- * This converter unbinds elements from the marker name.
- *
- * @returns {Function} Removed UI element converter.
- */
-export function removeUIElement() {
-	return ( evt, data, conversionApi ) => {
-		const elements = conversionApi.mapper.markerNameToElements( data.markerName );
-
-		if ( !elements ) {
-			return;
-		}
-
-		conversionApi.mapper.unbindElementsFromMarkerName( data.markerName );
-
-		for ( const element of elements ) {
-			conversionApi.writer.clear( conversionApi.writer.createRangeOn( element ), element );
-		}
-
-		conversionApi.writer.clearClonedElementsGroup( data.markerName );
-
-		evt.stop();
-	};
-}
-
-/**
- * Function factory that creates a converter which converts set/change/remove attribute changes from the model to the view.
- *
- * Attributes from the model are converted to the view element attributes in the view. You may provide a custom function to generate
- * a key-value attribute pair to add/change/remove. If not provided, model attributes will be converted to view element
- * attributes on a one-to-one basis.
- *
- * **Note:** The provided attribute creator should always return the same `key` for a given attribute from the model.
- *
- * 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:customAttr:myElem', changeAttribute( ( value, data ) => {
- *			// Change attribute key from `customAttr` to `class` in the view.
- *			const key = 'class';
- *			let value = data.attributeNewValue;
- *
- *			// Force attribute value to 'empty' if the model element is empty.
- *			if ( data.item.childCount === 0 ) {
- *				value = 'empty';
- *			}
- *
- *			// Return the key-value pair.
- *			return { key, value };
- *		} ) );
- *
- * @param {Function} [attributeCreator] Function returning an object with two properties: `key` and `value`, which
- * represent the attribute key and attribute value to be set on a {@link module:engine/view/element~Element view element}.
- * The function is passed the model attribute value as the first parameter and additional data about the change as the second parameter.
- * @returns {Function} Set/change attribute converter.
- */
-export function changeAttribute( attributeCreator ) {
-	attributeCreator = attributeCreator || ( ( value, data ) => ( { value, key: data.attributeKey } ) );
-
-	return ( evt, data, conversionApi ) => {
-		const oldAttribute = attributeCreator( data.attributeOldValue, data );
-		const newAttribute = attributeCreator( data.attributeNewValue, data );
-
-		if ( !oldAttribute && !newAttribute ) {
-			return;
-		}
-
-		if ( !conversionApi.consumable.consume( data.item, evt.name ) ) {
-			return;
-		}
-
-		const viewElement = conversionApi.mapper.toViewElement( data.item );
-		const viewWriter = conversionApi.writer;
-
-		// First remove the old attribute if there was one.
-		if ( data.attributeOldValue !== null && oldAttribute ) {
-			if ( oldAttribute.key == 'class' ) {
-				const classes = Array.isArray( oldAttribute.value ) ? oldAttribute.value : [ oldAttribute.value ];
-
-				for ( const className of classes ) {
-					viewWriter.removeClass( className, viewElement );
-				}
-			} else if ( oldAttribute.key == 'style' ) {
-				const keys = Object.keys( oldAttribute.value );
-
-				for ( const key of keys ) {
-					viewWriter.removeStyle( key, viewElement );
-				}
-			} else {
-				viewWriter.removeAttribute( oldAttribute.key, viewElement );
-			}
-		}
-
-		// Then set the new attribute.
-		if ( data.attributeNewValue !== null && newAttribute ) {
-			if ( newAttribute.key == 'class' ) {
-				const classes = Array.isArray( newAttribute.value ) ? newAttribute.value : [ newAttribute.value ];
-
-				for ( const className of classes ) {
-					viewWriter.addClass( className, viewElement );
-				}
-			} else if ( newAttribute.key == 'style' ) {
-				const keys = Object.keys( newAttribute.value );
-
-				for ( const key of keys ) {
-					viewWriter.setStyle( key, newAttribute.value[ key ], viewElement );
-				}
-			} else {
-				viewWriter.setAttribute( newAttribute.key, newAttribute.value, viewElement );
-			}
-		}
-	};
-}
-
-/**
- * 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.
- *
- * Attributes from the model are converted to a view element that will be wrapping these view nodes that are bound to
- * model elements having the given attribute. This is useful for attributes like `bold` that may be set on text nodes in the model
- * but are represented as an element in the view:
- *
- *		[paragraph]              MODEL ====> VIEW        <p>
- *			|- a {bold: true}                             |- <b>
- *			|- b {bold: true}                             |   |- ab
- *			|- c                                          |- c
- *
- * Passed `Function` will be provided with the attribute value and then all the parameters of the
- * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:attribute `attribute` event}.
- * It is expected that the function returns an {@link module:engine/view/element~Element}.
- * The result of the function will be the wrapping element.
- * When the provided `Function` does not return any element, no conversion will take place.
- *
- * 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 ) => {
- *			return viewWriter.createAttributeElement( 'strong' );
- *		} );
- *
- * @param {Function} elementCreator Function returning a view element that will be used for wrapping.
- * @returns {Function} Set/change attribute converter.
- */
-export function wrap( elementCreator ) {
-	return ( evt, data, conversionApi ) => {
-		// Recreate current wrapping node. It will be used to unwrap view range if the attribute value has changed
-		// or the attribute was removed.
-		const oldViewElement = elementCreator( data.attributeOldValue, conversionApi.writer );
-
-		// Create node to wrap with.
-		const newViewElement = elementCreator( data.attributeNewValue, conversionApi.writer );
-
-		if ( !oldViewElement && !newViewElement ) {
-			return;
-		}
-
-		if ( !conversionApi.consumable.consume( data.item, evt.name ) ) {
-			return;
-		}
-
-		const viewWriter = conversionApi.writer;
-		const viewSelection = viewWriter.document.selection;
-
-		if ( data.item instanceof ModelSelection || data.item instanceof DocumentSelection ) {
-			// Selection attribute conversion.
-			viewWriter.wrap( viewSelection.getFirstRange(), newViewElement );
-		} else {
-			// Node attribute conversion.
-			let viewRange = conversionApi.mapper.toViewRange( data.range );
-
-			// First, unwrap the range from current wrapper.
-			if ( data.attributeOldValue !== null && oldViewElement ) {
-				viewRange = viewWriter.unwrap( viewRange, oldViewElement );
-			}
-
-			if ( data.attributeNewValue !== null && newViewElement ) {
-				viewWriter.wrap( viewRange, newViewElement );
-			}
-		}
-	};
-}
-
-/**
- * Function factory that creates a converter which converts the text inside marker's range. The converter wraps the text with
- * {@link module:engine/view/attributeelement~AttributeElement} created from the provided descriptor.
- * See {link module:engine/conversion/downcast-converters~createViewElementFromHighlightDescriptor}.
- *
- * It can also be used to convert the selection that is inside a marker. In that case, an empty attribute element will be
- * created and the selection will be put inside it.
- *
- * If the highlight descriptor does not provide the `priority` property, `10` will be used.
- *
- * If the highlight descriptor does not provide the `id` property, the name of the marker will be used.
- *
- * This converter binds the created {@link module:engine/view/attributeelement~AttributeElement attribute elemens} with the marker name
- * using the {@link module:engine/conversion/mapper~Mapper#bindElementToMarker} method.
- *
- * @param {module:engine/conversion/downcast-converters~HighlightDescriptor|Function} highlightDescriptor
- * @returns {Function}
- */
-export function highlightText( highlightDescriptor ) {
-	return ( evt, data, conversionApi ) => {
-		if ( data.markerRange.isCollapsed ) {
-			return;
-		}
-
-		if ( !( data.item instanceof ModelSelection || data.item instanceof DocumentSelection ) && !data.item.is( 'textProxy' ) ) {
-			return;
-		}
-
-		const descriptor = _prepareDescriptor( highlightDescriptor, data, conversionApi );
-
-		if ( !descriptor ) {
-			return;
-		}
-
-		if ( !conversionApi.consumable.consume( data.item, evt.name ) ) {
-			return;
-		}
-
-		const viewElement = createViewElementFromHighlightDescriptor( descriptor );
-		const viewWriter = conversionApi.writer;
-		const viewSelection = viewWriter.document.selection;
-
-		if ( data.item instanceof ModelSelection || data.item instanceof DocumentSelection ) {
-			viewWriter.wrap( viewSelection.getFirstRange(), viewElement, viewSelection );
-		} else {
-			const viewRange = conversionApi.mapper.toViewRange( data.range );
-			const rangeAfterWrap = viewWriter.wrap( viewRange, viewElement );
-
-			for ( const element of rangeAfterWrap.getItems() ) {
-				if ( element.is( 'attributeElement' ) && element.isSimilar( viewElement ) ) {
-					conversionApi.mapper.bindElementToMarker( element, data.markerName );
-
-					// One attribute element is enough, because all of them are bound together by the view writer.
-					// Mapper uses this binding to get all the elements no matter how many of them are registered in the mapper.
-					break;
-				}
-			}
-		}
-	};
-}
-
-/**
- * Converter function factory. It creates a function which applies the marker's highlight to an element inside the marker's range.
- *
- * The converter checks if an element has the `addHighlight` function stored as a
- * {@link module:engine/view/element~Element#_setCustomProperty custom property} and, if so, uses it to apply the highlight.
- * In such case the converter will consume all element's children, assuming that they were handled by the element itself.
- *
- * When the `addHighlight` custom property is not present, the element is not converted in any special way.
- * This means that converters will proceed to convert the element's child nodes.
- *
- * If the highlight descriptor does not provide the `priority` property, `10` will be used.
- *
- * If the highlight descriptor does not provide the `id` property, the name of the marker will be used.
- *
- * This converter binds altered {@link module:engine/view/containerelement~ContainerElement container elements} with the marker name using
- * the {@link module:engine/conversion/mapper~Mapper#bindElementToMarker} method.
- *
- * @param {module:engine/conversion/downcast-converters~HighlightDescriptor|Function} highlightDescriptor
- * @returns {Function}
- */
-export function highlightElement( highlightDescriptor ) {
-	return ( evt, data, conversionApi ) => {
-		if ( data.markerRange.isCollapsed ) {
-			return;
-		}
-
-		if ( !( data.item instanceof ModelElement ) ) {
-			return;
-		}
-
-		const descriptor = _prepareDescriptor( highlightDescriptor, data, conversionApi );
-
-		if ( !descriptor ) {
-			return;
-		}
-
-		if ( !conversionApi.consumable.test( data.item, evt.name ) ) {
-			return;
-		}
-
-		const viewElement = conversionApi.mapper.toViewElement( data.item );
-
-		if ( viewElement && viewElement.getCustomProperty( 'addHighlight' ) ) {
-			// Consume element itself.
-			conversionApi.consumable.consume( data.item, evt.name );
-
-			// Consume all children nodes.
-			for ( const value of ModelRange._createIn( data.item ) ) {
-				conversionApi.consumable.consume( value.item, evt.name );
-			}
-
-			viewElement.getCustomProperty( 'addHighlight' )( viewElement, descriptor, conversionApi.writer );
-
-			conversionApi.mapper.bindElementToMarker( viewElement, data.markerName );
-		}
-	};
-}
-
-/**
- * Function factory that creates a converter which converts the removing model marker to the view.
- *
- * Both text nodes and elements are handled by this converter but they are handled a bit differently.
- *
- * Text nodes are unwrapped using the {@link module:engine/view/attributeelement~AttributeElement attribute element} created from the
- * provided highlight descriptor. See {link module:engine/conversion/downcast-converters~HighlightDescriptor}.
- *
- * For elements, the converter checks if an element has the `removeHighlight` function stored as a
- * {@link module:engine/view/element~Element#_setCustomProperty custom property}. If so, it uses it to remove the highlight.
- * In such case, the children of that element will not be converted.
- *
- * When `removeHighlight` is not present, the element is not converted in any special way.
- * The converter will proceed to convert the element's child nodes instead.
- *
- * If the highlight descriptor does not provide the `priority` property, `10` will be used.
- *
- * If the highlight descriptor does not provide the `id` property, the name of the marker will be used.
- *
- * This converter unbinds elements from the marker name.
- *
- * @param {module:engine/conversion/downcast-converters~HighlightDescriptor|Function} highlightDescriptor
- * @returns {Function}
- */
-export function removeHighlight( highlightDescriptor ) {
-	return ( evt, data, conversionApi ) => {
-		// This conversion makes sense only for non-collapsed range.
-		if ( data.markerRange.isCollapsed ) {
-			return;
-		}
-
-		const descriptor = _prepareDescriptor( highlightDescriptor, data, conversionApi );
-
-		if ( !descriptor ) {
-			return;
-		}
-
-		// View element that will be used to unwrap `AttributeElement`s.
-		const viewHighlightElement = createViewElementFromHighlightDescriptor( descriptor );
-
-		// Get all elements bound with given marker name.
-		const elements = conversionApi.mapper.markerNameToElements( data.markerName );
-
-		if ( !elements ) {
-			return;
-		}
-
-		conversionApi.mapper.unbindElementsFromMarkerName( data.markerName );
-
-		for ( const element of elements ) {
-			if ( element.is( 'attributeElement' ) ) {
-				conversionApi.writer.unwrap( conversionApi.writer.createRangeOn( element ), viewHighlightElement );
-			} else {
-				// if element.is( 'containerElement' ).
-				element.getCustomProperty( 'removeHighlight' )( element, descriptor.id, conversionApi.writer );
-			}
-		}
-
-		conversionApi.writer.clearClonedElementsGroup( data.markerName );
-
-		evt.stop();
-	};
-}
-
-// Helper function for `highlight`. Prepares the actual descriptor object using value passed to the converter.
-function _prepareDescriptor( highlightDescriptor, data, conversionApi ) {
-	// If passed descriptor is a creator function, call it. If not, just use passed value.
-	const descriptor = typeof highlightDescriptor == 'function' ?
-		highlightDescriptor( data, conversionApi ) :
-		highlightDescriptor;
-
-	if ( !descriptor ) {
-		return null;
-	}
-
-	// Apply default descriptor priority.
-	if ( !descriptor.priority ) {
-		descriptor.priority = 10;
-	}
-
-	// Default descriptor id is marker name.
-	if ( !descriptor.id ) {
-		descriptor.id = data.markerName;
-	}
-
-	return descriptor;
-}
-
-/**
- * Creates a `<span>` {@link module:engine/view/attributeelement~AttributeElement view attribute element} from the information
- * provided by the {@link module:engine/conversion/downcast-converters~HighlightDescriptor highlight descriptor} object. If a priority
- * is not provided in the descriptor, the default priority will be used.
- *
- * @param {module:engine/conversion/downcast-converters~HighlightDescriptor} descriptor
- * @returns {module:engine/view/attributeelement~AttributeElement}
- */
-export function createViewElementFromHighlightDescriptor( descriptor ) {
-	const viewElement = new ViewAttributeElement( 'span', descriptor.attributes );
-
-	if ( descriptor.classes ) {
-		viewElement._addClass( descriptor.classes );
-	}
-
-	if ( descriptor.priority ) {
-		viewElement._priority = descriptor.priority;
-	}
-
-	viewElement._id = descriptor.id;
-
-	return viewElement;
-}
-
-/**
- * An object describing how the marker highlight should be represented in the view.
- *
- * Each text node contained in a highlighted range will be wrapped in a `<span>`
- * {@link module:engine/view/attributeelement~AttributeElement view attribute element} with CSS class(es), attributes and a priority
- * described by this object.
- *
- * Additionally, each {@link module:engine/view/containerelement~ContainerElement container element} can handle displaying the highlight
- * separately by providing the `addHighlight` and `removeHighlight` custom properties. In this case:
- *
- *  * The `HighlightDescriptor` object is passed to the `addHighlight` function upon conversion and should be used to apply the highlight to
- *  the element.
- *  * The descriptor `id` is passed to the `removeHighlight` function upon conversion and should be used to remove the highlight with the
- *  given ID from the element.
- *
- * @typedef {Object} module:engine/conversion/downcast-converters~HighlightDescriptor
- *
- * @property {String|Array.<String>} classes A CSS class or an array of classes to set. If the descriptor is used to
- * create an {@link module:engine/view/attributeelement~AttributeElement attribute element} over text nodes, these classes will be set
- * on that attribute element. If the descriptor is applied to an element, usually these classes will be set on that element, however,
- * this depends on how the element converts the descriptor.
- *
- * @property {String} [id] Descriptor identifier. If not provided, it defaults to the converted marker's name.
- *
- * @property {Number} [priority] Descriptor priority. If not provided, it defaults to `10`. If the descriptor is used to create
- * an {@link module:engine/view/attributeelement~AttributeElement attribute element}, it will be that element's
- * {@link module:engine/view/attributeelement~AttributeElement#priority priority}. If the descriptor is applied to an element,
- * the priority will be used to determine which descriptor is more important.
- *
- * @property {Object} [attributes] Attributes to set. If the descriptor is used to create
- * an {@link module:engine/view/attributeelement~AttributeElement attribute element} over text nodes, these attributes will be set on that
- * attribute element. If the descriptor is applied to an element, usually these attributes will be set on that element, however,
- * this depends on how the element converts the descriptor.
- */

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

+ 15 - 9
packages/ckeditor5-engine/src/conversion/downcastdispatcher.js

@@ -296,7 +296,7 @@ export default class DowncastDispatcher {
 
 
 			// Do not fire event if the attribute has been consumed.
 			// Do not fire event if the attribute has been consumed.
 			if ( this.conversionApi.consumable.test( selection, 'attribute:' + data.attributeKey ) ) {
 			if ( this.conversionApi.consumable.test( selection, 'attribute:' + data.attributeKey ) ) {
-				this.fire( 'attribute:' + data.attributeKey, data, this.conversionApi );
+				this.fire( 'attribute:' + data.attributeKey + ':$text', data, this.conversionApi );
 			}
 			}
 		}
 		}
 
 
@@ -323,22 +323,28 @@ export default class DowncastDispatcher {
 		// In markers' case, event name == consumable name.
 		// In markers' case, event name == consumable name.
 		const eventName = 'addMarker:' + markerName;
 		const eventName = 'addMarker:' + markerName;
 
 
-		// When range is collapsed - fire single event with collapsed range in consumable.
-		if ( markerRange.isCollapsed ) {
-			const consumable = new Consumable();
-			consumable.add( markerRange, eventName );
+		//
+		// First, fire an event for the whole marker.
+		//
+		const consumable = new Consumable();
+		consumable.add( markerRange, eventName );
 
 
-			this.conversionApi.consumable = consumable;
+		this.conversionApi.consumable = consumable;
 
 
-			this.fire( eventName, { markerName, markerRange }, this.conversionApi );
+		this.fire( eventName, { markerName, markerRange }, this.conversionApi );
 
 
+		//
+		// Do not fire events for each item inside the range if the range got consumed.
+		//
+		if ( !consumable.test( markerRange, eventName ) ) {
 			return;
 			return;
 		}
 		}
 
 
-		// Create consumable for each item in range.
+		//
+		// Then, fire an event for each item inside the marker range.
+		//
 		this.conversionApi.consumable = this._createConsumableForRange( markerRange, eventName );
 		this.conversionApi.consumable = this._createConsumableForRange( markerRange, eventName );
 
 
-		// Create separate event for each node in the range.
 		for ( const item of markerRange.getItems() ) {
 		for ( const item of markerRange.getItems() ) {
 			// Do not fire event for already consumed items.
 			// Do not fire event for already consumed items.
 			if ( !this.conversionApi.consumable.test( item, eventName ) ) {
 			if ( !this.conversionApi.consumable.test( item, eventName ) ) {

+ 1346 - 0
packages/ckeditor5-engine/src/conversion/downcasthelpers.js

@@ -0,0 +1,1346 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import ModelRange from '../model/range';
+import ModelSelection from '../model/selection';
+import ModelElement from '../model/element';
+
+import ViewAttributeElement from '../view/attributeelement';
+import DocumentSelection from '../model/documentselection';
+import { ConversionHelpers } from './conversion';
+
+import log from '@ckeditor/ckeditor5-utils/src/log';
+import { cloneDeep } from 'lodash-es';
+
+/**
+ * Contains downcast (model-to-view) converters for {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher}.
+ *
+ * @module engine/conversion/downcasthelpers
+ */
+
+/**
+ * Downcast conversion helper functions.
+ *
+ * @extends module:engine/conversion/conversion~ConversionHelpers
+ */
+export default class DowncastHelpers extends ConversionHelpers {
+	/**
+	 * Model element to view element conversion helper.
+	 *
+	 * This conversion results in creating a view element. For example, model `<paragraph>Foo</paragraph>` becomes `<p>Foo</p>` in the view.
+	 *
+	 *		editor.conversion.for( 'downcast' ).elementToElement( {
+	 *			model: 'paragraph',
+	 *			view: 'p'
+	 *		} );
+	 *
+	 *		editor.conversion.for( 'downcast' ).elementToElement( {
+	 *			model: 'paragraph',
+	 *			view: 'div',
+	 *			converterPriority: 'high'
+	 *		} );
+	 *
+	 *		editor.conversion.for( 'downcast' ).elementToElement( {
+	 *			model: 'fancyParagraph',
+	 *			view: {
+	 *				name: 'p',
+	 *				classes: 'fancy'
+	 *			}
+	 *		} );
+	 *
+	 *		editor.conversion.for( 'downcast' ).elementToElement( {
+	 *			model: 'heading',
+	 *			view: ( modelElement, viewWriter ) => {
+	 *				return viewWriter.createContainerElement( 'h' + modelElement.getAttribute( 'level' ) )
+	 *			}
+	 *		} );
+	 *
+	 * See {@link module:engine/conversion/conversion~Conversion#for `conversion.for()`} to learn how to add a converter
+	 * to the conversion process.
+	 *
+	 * @method #elementToElement
+	 * @param {Object} config Conversion configuration.
+	 * @param {String} config.model The name of the model element to convert.
+	 * @param {module:engine/view/elementdefinition~ElementDefinition|Function} config.view A view element definition or a function
+	 * that takes the model element and {@link module:engine/view/downcastwriter~DowncastWriter view downcast writer}
+	 * as parameters and returns a view container element.
+	 * @returns {module:engine/conversion/downcasthelpers~DowncastHelpers}
+	 */
+	elementToElement( config ) {
+		return this.add( downcastElementToElement( config ) );
+	}
+
+	/**
+	 * Model attribute to view element conversion helper.
+	 *
+	 * This conversion results in wrapping view nodes with a view attribute element. For example, a model text node with
+	 * `"Foo"` as data and the `bold` attribute becomes `<strong>Foo</strong>` in the view.
+	 *
+	 *		editor.conversion.for( 'downcast' ).attributeToElement( {
+	 *			model: 'bold',
+	 *			view: 'strong'
+	 *		} );
+	 *
+	 *		editor.conversion.for( 'downcast' ).attributeToElement( {
+	 *			model: 'bold',
+	 *			view: 'b',
+	 *			converterPriority: 'high'
+	 *		} );
+	 *
+	 *		editor.conversion.for( 'downcast' ).attributeToElement( {
+	 *			model: 'invert',
+	 *			view: {
+	 *				name: 'span',
+	 *				classes: [ 'font-light', 'bg-dark' ]
+	 *			}
+	 *		} );
+	 *
+	 *		editor.conversion.for( 'downcast' ).attributeToElement( {
+	 *			model: {
+	 *				key: 'fontSize',
+	 *				values: [ 'big', 'small' ]
+	 *			},
+	 *			view: {
+	 *				big: {
+	 *					name: 'span',
+	 *					styles: {
+	 *						'font-size': '1.2em'
+	 *					}
+	 *				},
+	 *				small: {
+	 *					name: 'span',
+	 *					styles: {
+	 *						'font-size': '0.8em'
+	 *					}
+	 *				}
+	 *			}
+	 *		} );
+	 *
+	 *		editor.conversion.for( 'downcast' ).attributeToElement( {
+	 *			model: 'bold',
+	 *			view: ( modelAttributeValue, viewWriter ) => {
+	 *				return viewWriter.createAttributeElement( 'span', {
+	 *					style: 'font-weight:' + modelAttributeValue
+	 *				} );
+	 *			}
+	 *		} );
+	 *
+	 *		editor.conversion.for( 'downcast' ).attributeToElement( {
+	 *			model: {
+	 *				key: 'color',
+	 *				name: '$text'
+	 *			},
+	 *			view: ( modelAttributeValue, viewWriter ) => {
+	 *				return viewWriter.createAttributeElement( 'span', {
+	 *					style: 'color:' + modelAttributeValue
+	 *				} );
+	 *			}
+	 *		} );
+	 *
+	 * See {@link module:engine/conversion/conversion~Conversion#for `conversion.for()`} to learn how to add a converter
+	 * to the conversion process.
+	 *
+	 * @method #attributeToElement
+	 * @param {Object} config Conversion configuration.
+	 * @param {String|Object} config.model The key of the attribute to convert from or a `{ key, values }` object. `values` is an array
+	 * of `String`s with possible values if the model attribute is an enumerable.
+	 * @param {module:engine/view/elementdefinition~ElementDefinition|Function|Object} config.view A view element definition or a function
+	 * that takes the model attribute value and {@link module:engine/view/downcastwriter~DowncastWriter view downcast writer}
+	 * as parameters and returns a view attribute element. If `config.model.values` is
+	 * given, `config.view` should be an object assigning values from `config.model.values` to view element definitions or functions.
+	 * @param {module:utils/priorities~PriorityString} [config.converterPriority='normal'] Converter priority.
+	 * @returns {module:engine/conversion/downcasthelpers~DowncastHelpers}
+	 */
+	attributeToElement( config ) {
+		return this.add( downcastAttributeToElement( config ) );
+	}
+
+	/**
+	 * Model attribute to view attribute conversion helper.
+	 *
+	 * This conversion results in adding an attribute to a view node, basing on an attribute from a model node. For example,
+	 * `<image src='foo.jpg'></image>` is converted to `<img src='foo.jpg'></img>`.
+	 *
+	 *		editor.conversion.for( 'downcast' ).attributeToAttribute( {
+	 *			model: 'source',
+	 *			view: 'src'
+	 *		} );
+	 *
+	 *		editor.conversion.for( 'downcast' ).attributeToAttribute( {
+	 *			model: 'source',
+	 *			view: 'href',
+	 *			converterPriority: 'high'
+	 *		} );
+	 *
+	 *		editor.conversion.for( 'downcast' ).attributeToAttribute( {
+	 *			model: {
+	 *				name: 'image',
+	 *				key: 'source'
+	 *			},
+	 *			view: 'src'
+	 *		} );
+	 *
+	 *		editor.conversion.for( 'downcast' ).attributeToAttribute( {
+	 *			model: {
+	 *				name: 'styled',
+	 *				values: [ 'dark', 'light' ]
+	 *			},
+	 *			view: {
+	 *				dark: {
+	 *					key: 'class',
+	 *					value: [ 'styled', 'styled-dark' ]
+	 *				},
+	 *				light: {
+	 *					key: 'class',
+	 *					value: [ 'styled', 'styled-light' ]
+	 *				}
+	 *			}
+	 *		} );
+	 *
+	 *		editor.conversion.for( 'downcast' ).attributeToAttribute( {
+	 *			model: 'styled',
+	 *			view: modelAttributeValue => ( { key: 'class', value: 'styled-' + modelAttributeValue } )
+	 *		} );
+	 *
+	 * See {@link module:engine/conversion/conversion~Conversion#for `conversion.for()`} to learn how to add a converter
+	 * to the conversion process.
+	 *
+	 * @method #attributeToAttribute
+	 * @param {Object} config Conversion configuration.
+	 * @param {String|Object} config.model The key of the attribute to convert from or a `{ key, values, [ name ] }` object describing
+	 * the attribute key, possible values and, optionally, an element name to convert from.
+	 * @param {String|Object|Function} config.view A view attribute key, or a `{ key, value }` object or a function that takes
+	 * the model attribute value and returns a `{ key, value }` object. If `key` is `'class'`, `value` can be a `String` or an
+	 * array of `String`s. If `key` is `'style'`, `value` is an object with key-value pairs. In other cases, `value` is a `String`.
+	 * If `config.model.values` is set, `config.view` should be an object assigning values from `config.model.values` to
+	 * `{ key, value }` objects or a functions.
+	 * @param {module:utils/priorities~PriorityString} [config.converterPriority='normal'] Converter priority.
+	 * @returns {module:engine/conversion/downcasthelpers~DowncastHelpers}
+	 */
+	attributeToAttribute( config ) {
+		return this.add( downcastAttributeToAttribute( config ) );
+	}
+
+	/**
+	 * Model marker to view element conversion helper.
+	 *
+	 * This conversion results in creating a view element on the boundaries of the converted marker. If the converted marker
+	 * is collapsed, only one element is created. For example, model marker set like this: `<paragraph>F[oo b]ar</paragraph>`
+	 * becomes `<p>F<span data-marker="search"></span>oo b<span data-marker="search"></span>ar</p>` in the view.
+	 *
+	 *		editor.conversion.for( 'downcast' ).markerToElement( {
+	 *			model: 'search',
+	 *			view: 'marker-search'
+	 *		} );
+	 *
+	 *		editor.conversion.for( 'downcast' ).markerToElement( {
+	 *			model: 'search',
+	 *			view: 'search-result',
+	 *			converterPriority: 'high'
+	 *		} );
+	 *
+	 *		editor.conversion.for( 'downcast' ).markerToElement( {
+	 *			model: 'search',
+	 *			view: {
+	 *				name: 'span',
+	 *				attributes: {
+	 *					'data-marker': 'search'
+	 *				}
+	 *			}
+	 *		} );
+	 *
+	 *		editor.conversion.for( 'downcast' ).markerToElement( {
+	 *			model: 'search',
+	 *			view: ( markerData, viewWriter ) => {
+	 *				return viewWriter.createUIElement( 'span', {
+	 *					'data-marker': 'search',
+	 *					'data-start': markerData.isOpening
+	 *				} );
+	 *			}
+	 *		} );
+	 *
+	 * 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/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.
+	 *
+	 * This kind of conversion is useful for saving data into the database, so it should be used in the data conversion pipeline.
+	 *
+	 * See {@link module:engine/conversion/conversion~Conversion#for `conversion.for()`} to learn how to add a converter
+	 * to the conversion process.
+	 *
+	 * @method #markerToElement
+	 * @param {Object} config Conversion configuration.
+	 * @param {String} config.model The name of the model marker (or model marker group) to convert.
+	 * @param {module:engine/view/elementdefinition~ElementDefinition|Function} config.view A view element definition or a function
+	 * that takes the model marker data as a parameter and returns a view UI element.
+	 * @param {module:utils/priorities~PriorityString} [config.converterPriority='normal'] Converter priority.
+	 * @returns {module:engine/conversion/downcasthelpers~DowncastHelpers}
+	 */
+	markerToElement( config ) {
+		return this.add( downcastMarkerToElement( config ) );
+	}
+
+	/**
+	 * Model marker to highlight conversion helper.
+	 *
+	 * This conversion results in creating a highlight on view nodes. For this kind of conversion,
+	 * {@link module:engine/conversion/downcasthelpers~HighlightDescriptor} should be provided.
+	 *
+	 * For text nodes, a `<span>` {@link module:engine/view/attributeelement~AttributeElement} is created and it wraps all text nodes
+	 * in the converted marker range. For example, a model marker set like this: `<paragraph>F[oo b]ar</paragraph>` becomes
+	 * `<p>F<span class="comment">oo b</span>ar</p>` in the view.
+	 *
+	 * {@link module:engine/view/containerelement~ContainerElement} may provide a custom way of handling highlight. Most often,
+	 * the element itself is given classes and attributes described in the highlight descriptor (instead of being wrapped in `<span>`).
+	 * For example, a model marker set like this: `[<image src="foo.jpg"></image>]` becomes `<img src="foo.jpg" class="comment"></img>`
+	 * in the view.
+	 *
+	 * For container elements, the conversion is two-step. While the converter processes the highlight descriptor and passes it
+	 * to a container element, it is the container element instance itself that applies values from the highlight descriptor.
+	 * So, in a sense, the converter takes care of stating what should be applied on what, while the element decides how to apply that.
+	 *
+	 *		editor.conversion.for( 'downcast' ).markerToHighlight( { model: 'comment', view: { classes: 'comment' } } );
+	 *
+	 *		editor.conversion.for( 'downcast' ).markerToHighlight( {
+	 *			model: 'comment',
+	 *			view: { classes: 'new-comment' },
+	 *			converterPriority: 'high'
+	 *		} );
+	 *
+	 *		editor.conversion.for( 'downcast' ).markerToHighlight( {
+	 *			model: 'comment',
+	 *			view: data => {
+	 *				// Assuming that the marker name is in a form of comment:commentType.
+	 *				const commentType = data.markerName.split( ':' )[ 1 ];
+	 *
+	 *				return {
+	 *					classes: [ 'comment', 'comment-' + commentType ]
+	 *				};
+	 *			}
+	 *		} );
+	 *
+	 * If a function is passed as the `config.view` parameter, it will be used to generate the highlight descriptor. The function
+	 * receives the `data` object as a parameter and should return a
+	 * {@link module:engine/conversion/downcasthelpers~HighlightDescriptor highlight descriptor}.
+	 * The `data` object properties are passed from {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:addMarker}.
+	 *
+	 * See {@link module:engine/conversion/conversion~Conversion#for `conversion.for()`} to learn how to add a converter
+	 * to the conversion process.
+	 *
+	 * @method #markerToHighlight
+	 * @param {Object} config Conversion configuration.
+	 * @param {String} config.model The name of the model marker (or model marker group) to convert.
+	 * @param {module:engine/conversion/downcasthelpers~HighlightDescriptor|Function} config.view A highlight descriptor
+	 * that will be used for highlighting or a function that takes the model marker data as a parameter and returns a highlight descriptor.
+	 * @param {module:utils/priorities~PriorityString} [config.converterPriority='normal'] Converter priority.
+	 * @returns {module:engine/conversion/downcasthelpers~DowncastHelpers}
+	 */
+	markerToHighlight( config ) {
+		return this.add( downcastMarkerToHighlight( config ) );
+	}
+}
+
+/**
+ * Function factory that creates a default downcast converter for text insertion changes.
+ *
+ * The converter automatically consumes the corresponding value from the consumables list and stops the event (see
+ * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher}).
+ *
+ *		modelDispatcher.on( 'insert:$text', insertText() );
+ *
+ * @returns {Function} Insert text event converter.
+ */
+export function insertText() {
+	return ( evt, data, conversionApi ) => {
+		if ( !conversionApi.consumable.consume( data.item, 'insert' ) ) {
+			return;
+		}
+
+		const viewWriter = conversionApi.writer;
+		const viewPosition = conversionApi.mapper.toViewPosition( data.range.start );
+		const viewText = viewWriter.createText( data.item.data );
+
+		viewWriter.insert( viewPosition, viewText );
+	};
+}
+
+/**
+ * Function factory that creates a default downcast converter for node remove changes.
+ *
+ *		modelDispatcher.on( 'remove', remove() );
+ *
+ * @returns {Function} Remove event converter.
+ */
+export function remove() {
+	return ( evt, data, conversionApi ) => {
+		// Find view range start position by mapping model position at which the remove happened.
+		const viewStart = conversionApi.mapper.toViewPosition( data.position );
+
+		const modelEnd = data.position.getShiftedBy( data.length );
+		const viewEnd = conversionApi.mapper.toViewPosition( modelEnd, { isPhantom: true } );
+
+		const viewRange = conversionApi.writer.createRange( viewStart, viewEnd );
+
+		// Trim the range to remove in case some UI elements are on the view range boundaries.
+		const removed = conversionApi.writer.remove( viewRange.getTrimmed() );
+
+		// After the range is removed, unbind all view elements from the model.
+		// Range inside view document fragment is used to unbind deeply.
+		for ( const child of conversionApi.writer.createRangeIn( removed ).getItems() ) {
+			conversionApi.mapper.unbindViewElement( child );
+		}
+	};
+}
+
+/**
+ * Creates a `<span>` {@link module:engine/view/attributeelement~AttributeElement view attribute element} from the information
+ * provided by the {@link module:engine/conversion/downcasthelpers~HighlightDescriptor highlight descriptor} object. If a priority
+ * is not provided in the descriptor, the default priority will be used.
+ *
+ * @param {module:engine/conversion/downcasthelpers~HighlightDescriptor} descriptor
+ * @returns {module:engine/view/attributeelement~AttributeElement}
+ */
+export function createViewElementFromHighlightDescriptor( descriptor ) {
+	const viewElement = new ViewAttributeElement( 'span', descriptor.attributes );
+
+	if ( descriptor.classes ) {
+		viewElement._addClass( descriptor.classes );
+	}
+
+	if ( descriptor.priority ) {
+		viewElement._priority = descriptor.priority;
+	}
+
+	viewElement._id = descriptor.id;
+
+	return viewElement;
+}
+
+/**
+ * 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.
+ *
+ * Attributes from the model are converted to a view element that will be wrapping these view nodes that are bound to
+ * model elements having the given attribute. This is useful for attributes like `bold` that may be set on text nodes in the model
+ * but are represented as an element in the view:
+ *
+ *		[paragraph]              MODEL ====> VIEW        <p>
+ *			|- a {bold: true}                             |- <b>
+ *			|- b {bold: true}                             |   |- ab
+ *			|- c                                          |- c
+ *
+ * Passed `Function` will be provided with the attribute value and then all the parameters of the
+ * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:attribute `attribute` event}.
+ * It is expected that the function returns an {@link module:engine/view/element~Element}.
+ * The result of the function will be the wrapping element.
+ * When the provided `Function` does not return any element, no conversion will take place.
+ *
+ * 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', 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.
+ */
+export function wrap( elementCreator ) {
+	return ( evt, data, conversionApi ) => {
+		// Recreate current wrapping node. It will be used to unwrap view range if the attribute value has changed
+		// or the attribute was removed.
+		const oldViewElement = elementCreator( data.attributeOldValue, conversionApi.writer );
+
+		// Create node to wrap with.
+		const newViewElement = elementCreator( data.attributeNewValue, conversionApi.writer );
+
+		if ( !oldViewElement && !newViewElement ) {
+			return;
+		}
+
+		if ( !conversionApi.consumable.consume( data.item, evt.name ) ) {
+			return;
+		}
+
+		const viewWriter = conversionApi.writer;
+		const viewSelection = viewWriter.document.selection;
+
+		if ( data.item instanceof ModelSelection || data.item instanceof DocumentSelection ) {
+			// Selection attribute conversion.
+			viewWriter.wrap( viewSelection.getFirstRange(), newViewElement );
+		} else {
+			// Node attribute conversion.
+			let viewRange = conversionApi.mapper.toViewRange( data.range );
+
+			// First, unwrap the range from current wrapper.
+			if ( data.attributeOldValue !== null && oldViewElement ) {
+				viewRange = viewWriter.unwrap( viewRange, oldViewElement );
+			}
+
+			if ( data.attributeNewValue !== null && newViewElement ) {
+				viewWriter.wrap( viewRange, newViewElement );
+			}
+		}
+	};
+}
+
+/**
+ * Function factory that creates a converter which converts node insertion changes from the model to the view.
+ * The function passed will be provided with all the parameters of the dispatcher's
+ * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:insert `insert` event}.
+ * It is expected that the function returns an {@link module:engine/view/element~Element}.
+ * The result of the function will be inserted into the view.
+ *
+ * The converter automatically consumes the corresponding value from the consumables list, stops the event (see
+ * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher}) and binds the model and view elements.
+ *
+ *		downcastDispatcher.on(
+ *			'insert:myElem',
+ *			insertElement( ( modelItem, viewWriter ) => {
+ *				const text = viewWriter.createText( 'myText' );
+ *				const myElem = viewWriter.createElement( 'myElem', { myAttr: 'my-' + modelItem.getAttribute( 'myAttr' ) }, text );
+ *
+ *				// Do something fancy with `myElem` using `modelItem` or other parameters.
+ *
+ *				return myElem;
+ *			}
+ *		) );
+ *
+ * @protected
+ * @param {Function} elementCreator Function returning a view element, which will be inserted.
+ * @returns {Function} Insert element event converter.
+ */
+export function insertElement( elementCreator ) {
+	return ( evt, data, conversionApi ) => {
+		const viewElement = elementCreator( data.item, conversionApi.writer );
+
+		if ( !viewElement ) {
+			return;
+		}
+
+		if ( !conversionApi.consumable.consume( data.item, 'insert' ) ) {
+			return;
+		}
+
+		const viewPosition = conversionApi.mapper.toViewPosition( data.range.start );
+
+		conversionApi.mapper.bindElements( data.item, viewElement );
+		conversionApi.writer.insert( viewPosition, viewElement );
+	};
+}
+
+/**
+ * Function factory that creates a converter which converts marker adding change to the
+ * {@link module:engine/view/uielement~UIElement view UI element}.
+ *
+ * The view UI element that will be added to the view depends on the passed parameter. See {@link ~insertElement}.
+ * In case of a non-collapsed range, the UI element will not wrap nodes but separate elements will be placed at the beginning
+ * and at the end of the range.
+ *
+ * This converter binds created UI elements with the marker name using {@link module:engine/conversion/mapper~Mapper#bindElementToMarker}.
+ *
+ * @protected
+ * @param {module:engine/view/uielement~UIElement|Function} elementCreator A view UI element or a function returning the view element
+ * that will be inserted.
+ * @returns {Function} Insert element event converter.
+ */
+export function insertUIElement( elementCreator ) {
+	return ( evt, data, conversionApi ) => {
+		// Create two view elements. One will be inserted at the beginning of marker, one at the end.
+		// If marker is collapsed, only "opening" element will be inserted.
+		data.isOpening = true;
+		const viewStartElement = elementCreator( data, conversionApi.writer );
+
+		data.isOpening = false;
+		const viewEndElement = elementCreator( data, conversionApi.writer );
+
+		if ( !viewStartElement || !viewEndElement ) {
+			return;
+		}
+
+		const markerRange = data.markerRange;
+
+		// Marker that is collapsed has consumable build differently that non-collapsed one.
+		// For more information see `addMarker` event description.
+		// If marker's range is collapsed - check if it can be consumed.
+		if ( markerRange.isCollapsed && !conversionApi.consumable.consume( markerRange, evt.name ) ) {
+			return;
+		}
+
+		// If marker's range is not collapsed - consume all items inside.
+		for ( const value of markerRange ) {
+			if ( !conversionApi.consumable.consume( value.item, evt.name ) ) {
+				return;
+			}
+		}
+
+		const mapper = conversionApi.mapper;
+		const viewWriter = conversionApi.writer;
+
+		// Add "opening" element.
+		viewWriter.insert( mapper.toViewPosition( markerRange.start ), viewStartElement );
+		conversionApi.mapper.bindElementToMarker( viewStartElement, data.markerName );
+
+		// Add "closing" element only if range is not collapsed.
+		if ( !markerRange.isCollapsed ) {
+			viewWriter.insert( mapper.toViewPosition( markerRange.end ), viewEndElement );
+			conversionApi.mapper.bindElementToMarker( viewEndElement, data.markerName );
+		}
+
+		evt.stop();
+	};
+}
+
+// Function factory that returns a default downcast converter for removing a {@link module:engine/view/uielement~UIElement UI element}
+// basing on marker remove change.
+//
+// This converter unbinds elements from the marker name.
+//
+// @returns {Function} Removed UI element converter.
+function removeUIElement() {
+	return ( evt, data, conversionApi ) => {
+		const elements = conversionApi.mapper.markerNameToElements( data.markerName );
+
+		if ( !elements ) {
+			return;
+		}
+
+		conversionApi.mapper.unbindElementsFromMarkerName( data.markerName );
+
+		for ( const element of elements ) {
+			conversionApi.writer.clear( conversionApi.writer.createRangeOn( element ), element );
+		}
+
+		conversionApi.writer.clearClonedElementsGroup( data.markerName );
+
+		evt.stop();
+	};
+}
+
+// Function factory that creates a converter which converts set/change/remove attribute changes from the model to the view.
+//
+// Attributes from the model are converted to the view element attributes in the view. You may provide a custom function to generate
+// a key-value attribute pair to add/change/remove. If not provided, model attributes will be converted to view element
+// attributes on a one-to-one basis.
+//
+// *Note:** The provided attribute creator should always return the same `key` for a given attribute from the model.
+//
+// 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:customAttr:myElem', changeAttribute( ( value, data ) => {
+//			// Change attribute key from `customAttr` to `class` in the view.
+//			const key = 'class';
+//			let value = data.attributeNewValue;
+//
+//			// Force attribute value to 'empty' if the model element is empty.
+//			if ( data.item.childCount === 0 ) {
+//				value = 'empty';
+//			}
+//
+//			// Return the key-value pair.
+//			return { key, value };
+//		} ) );
+//
+// @param {Function} [attributeCreator] Function returning an object with two properties: `key` and `value`, which
+// represent the attribute key and attribute value to be set on a {@link module:engine/view/element~Element view element}.
+// The function is passed the model attribute value as the first parameter and additional data about the change as the second parameter.
+// @returns {Function} Set/change attribute converter.
+function changeAttribute( attributeCreator ) {
+	return ( evt, data, conversionApi ) => {
+		const oldAttribute = attributeCreator( data.attributeOldValue, data );
+		const newAttribute = attributeCreator( data.attributeNewValue, data );
+
+		if ( !oldAttribute && !newAttribute ) {
+			return;
+		}
+
+		if ( !conversionApi.consumable.consume( data.item, evt.name ) ) {
+			return;
+		}
+
+		const viewElement = conversionApi.mapper.toViewElement( data.item );
+		const viewWriter = conversionApi.writer;
+
+		// If model item cannot be mapped to a view element, it means item is not an `Element` instance but a `TextProxy` node.
+		// Only elements can have attributes in a view so do not proceed for anything else (#1587).
+		if ( !viewElement ) {
+			/**
+			 * This error occurs when a {@link module:engine/model/textproxy~TextProxy text node's} attribute is to be downcasted
+			 * by {@link module:engine/conversion/conversion~Conversion#attributeToAttribute `Attribute to Attribute converter`}.
+			 * In most cases it is caused by converters misconfiguration when only "generic" converter is defined:
+			 *
+			 *		editor.conversion.for( 'downcast' ).attributeToAttribute( {
+			 *			model: 'attribute-name',
+			 *			view: 'attribute-name'
+			 *		} ) );
+			 *
+			 * and given attribute is used on text node, for example:
+			 *
+			 *		model.change( writer => {
+			 *			writer.insertText( 'Foo', { 'attribute-name': 'bar' }, parent, 0 );
+			 *		} );
+			 *
+			 * In such cases, to convert the same attribute for both {@link module:engine/model/element~Element}
+			 * and {@link module:engine/model/textproxy~TextProxy `Text`} nodes, text specific
+			 * {@link module:engine/conversion/conversion~Conversion#attributeToElement `Attribute to Element converter`}
+			 * with higher {@link module:utils/priorities~PriorityString priority} must also be defined:
+			 *
+			 *		editor.conversion.for( 'downcast' ).attributeToElement( {
+			 *			model: {
+			 *				key: 'attribute-name',
+			 *				name: '$text'
+			 *			},
+			 *			view: ( value, writer ) => {
+			 *				return writer.createAttributeElement( 'span', { 'attribute-name': value } );
+			 *			},
+			 *			converterPriority: 'high'
+			 *		} ) );
+			 *
+			 * @error conversion-attribute-to-attribute-on-text
+			 */
+			log.warn( 'conversion-attribute-to-attribute-on-text: ' +
+				'Trying to convert text node\'s attribute with attribute-to-attribute converter.' );
+
+			return;
+		}
+
+		// First remove the old attribute if there was one.
+		if ( data.attributeOldValue !== null && oldAttribute ) {
+			if ( oldAttribute.key == 'class' ) {
+				const classes = Array.isArray( oldAttribute.value ) ? oldAttribute.value : [ oldAttribute.value ];
+
+				for ( const className of classes ) {
+					viewWriter.removeClass( className, viewElement );
+				}
+			} else if ( oldAttribute.key == 'style' ) {
+				const keys = Object.keys( oldAttribute.value );
+
+				for ( const key of keys ) {
+					viewWriter.removeStyle( key, viewElement );
+				}
+			} else {
+				viewWriter.removeAttribute( oldAttribute.key, viewElement );
+			}
+		}
+
+		// Then set the new attribute.
+		if ( data.attributeNewValue !== null && newAttribute ) {
+			if ( newAttribute.key == 'class' ) {
+				const classes = Array.isArray( newAttribute.value ) ? newAttribute.value : [ newAttribute.value ];
+
+				for ( const className of classes ) {
+					viewWriter.addClass( className, viewElement );
+				}
+			} else if ( newAttribute.key == 'style' ) {
+				const keys = Object.keys( newAttribute.value );
+
+				for ( const key of keys ) {
+					viewWriter.setStyle( key, newAttribute.value[ key ], viewElement );
+				}
+			} else {
+				viewWriter.setAttribute( newAttribute.key, newAttribute.value, viewElement );
+			}
+		}
+	};
+}
+
+// Function factory that creates a converter which converts the text inside marker's range. The converter wraps the text with
+// {@link module:engine/view/attributeelement~AttributeElement} created from the provided descriptor.
+// See {link module:engine/conversion/downcasthelpers~createViewElementFromHighlightDescriptor}.
+//
+// It can also be used to convert the selection that is inside a marker. In that case, an empty attribute element will be
+// created and the selection will be put inside it.
+//
+// If the highlight descriptor does not provide the `priority` property, `10` will be used.
+//
+// If the highlight descriptor does not provide the `id` property, the name of the marker will be used.
+//
+// This converter binds the created {@link module:engine/view/attributeelement~AttributeElement attribute elemens} with the marker name
+// using the {@link module:engine/conversion/mapper~Mapper#bindElementToMarker} method.
+//
+// @param {module:engine/conversion/downcasthelpers~HighlightDescriptor|Function} highlightDescriptor
+// @returns {Function}
+function highlightText( highlightDescriptor ) {
+	return ( evt, data, conversionApi ) => {
+		if ( !data.item ) {
+			return;
+		}
+
+		if ( !( data.item instanceof ModelSelection || data.item instanceof DocumentSelection ) && !data.item.is( 'textProxy' ) ) {
+			return;
+		}
+
+		const descriptor = prepareDescriptor( highlightDescriptor, data, conversionApi );
+
+		if ( !descriptor ) {
+			return;
+		}
+
+		if ( !conversionApi.consumable.consume( data.item, evt.name ) ) {
+			return;
+		}
+
+		const viewElement = createViewElementFromHighlightDescriptor( descriptor );
+		const viewWriter = conversionApi.writer;
+		const viewSelection = viewWriter.document.selection;
+
+		if ( data.item instanceof ModelSelection || data.item instanceof DocumentSelection ) {
+			viewWriter.wrap( viewSelection.getFirstRange(), viewElement, viewSelection );
+		} else {
+			const viewRange = conversionApi.mapper.toViewRange( data.range );
+			const rangeAfterWrap = viewWriter.wrap( viewRange, viewElement );
+
+			for ( const element of rangeAfterWrap.getItems() ) {
+				if ( element.is( 'attributeElement' ) && element.isSimilar( viewElement ) ) {
+					conversionApi.mapper.bindElementToMarker( element, data.markerName );
+
+					// One attribute element is enough, because all of them are bound together by the view writer.
+					// Mapper uses this binding to get all the elements no matter how many of them are registered in the mapper.
+					break;
+				}
+			}
+		}
+	};
+}
+
+// Converter function factory. It creates a function which applies the marker's highlight to an element inside the marker's range.
+//
+// The converter checks if an element has the `addHighlight` function stored as a
+// {@link module:engine/view/element~Element#_setCustomProperty custom property} and, if so, uses it to apply the highlight.
+// In such case the converter will consume all element's children, assuming that they were handled by the element itself.
+//
+// When the `addHighlight` custom property is not present, the element is not converted in any special way.
+// This means that converters will proceed to convert the element's child nodes.
+//
+// If the highlight descriptor does not provide the `priority` property, `10` will be used.
+//
+// If the highlight descriptor does not provide the `id` property, the name of the marker will be used.
+//
+// This converter binds altered {@link module:engine/view/containerelement~ContainerElement container elements} with the marker name using
+// the {@link module:engine/conversion/mapper~Mapper#bindElementToMarker} method.
+//
+// @param {module:engine/conversion/downcasthelpers~HighlightDescriptor|Function} highlightDescriptor
+// @returns {Function}
+function highlightElement( highlightDescriptor ) {
+	return ( evt, data, conversionApi ) => {
+		if ( !data.item ) {
+			return;
+		}
+
+		if ( !( data.item instanceof ModelElement ) ) {
+			return;
+		}
+
+		const descriptor = prepareDescriptor( highlightDescriptor, data, conversionApi );
+
+		if ( !descriptor ) {
+			return;
+		}
+
+		if ( !conversionApi.consumable.test( data.item, evt.name ) ) {
+			return;
+		}
+
+		const viewElement = conversionApi.mapper.toViewElement( data.item );
+
+		if ( viewElement && viewElement.getCustomProperty( 'addHighlight' ) ) {
+			// Consume element itself.
+			conversionApi.consumable.consume( data.item, evt.name );
+
+			// Consume all children nodes.
+			for ( const value of ModelRange._createIn( data.item ) ) {
+				conversionApi.consumable.consume( value.item, evt.name );
+			}
+
+			viewElement.getCustomProperty( 'addHighlight' )( viewElement, descriptor, conversionApi.writer );
+
+			conversionApi.mapper.bindElementToMarker( viewElement, data.markerName );
+		}
+	};
+}
+
+// Function factory that creates a converter which converts the removing model marker to the view.
+//
+// Both text nodes and elements are handled by this converter but they are handled a bit differently.
+//
+// Text nodes are unwrapped using the {@link module:engine/view/attributeelement~AttributeElement attribute element} created from the
+// provided highlight descriptor. See {link module:engine/conversion/downcasthelpers~HighlightDescriptor}.
+//
+// For elements, the converter checks if an element has the `removeHighlight` function stored as a
+// {@link module:engine/view/element~Element#_setCustomProperty custom property}. If so, it uses it to remove the highlight.
+// In such case, the children of that element will not be converted.
+//
+// When `removeHighlight` is not present, the element is not converted in any special way.
+// The converter will proceed to convert the element's child nodes instead.
+//
+// If the highlight descriptor does not provide the `priority` property, `10` will be used.
+//
+// If the highlight descriptor does not provide the `id` property, the name of the marker will be used.
+//
+// This converter unbinds elements from the marker name.
+//
+// @param {module:engine/conversion/downcasthelpers~HighlightDescriptor|Function} highlightDescriptor
+// @returns {Function}
+function removeHighlight( highlightDescriptor ) {
+	return ( evt, data, conversionApi ) => {
+		// This conversion makes sense only for non-collapsed range.
+		if ( data.markerRange.isCollapsed ) {
+			return;
+		}
+
+		const descriptor = prepareDescriptor( highlightDescriptor, data, conversionApi );
+
+		if ( !descriptor ) {
+			return;
+		}
+
+		// View element that will be used to unwrap `AttributeElement`s.
+		const viewHighlightElement = createViewElementFromHighlightDescriptor( descriptor );
+
+		// Get all elements bound with given marker name.
+		const elements = conversionApi.mapper.markerNameToElements( data.markerName );
+
+		if ( !elements ) {
+			return;
+		}
+
+		conversionApi.mapper.unbindElementsFromMarkerName( data.markerName );
+
+		for ( const element of elements ) {
+			if ( element.is( 'attributeElement' ) ) {
+				conversionApi.writer.unwrap( conversionApi.writer.createRangeOn( element ), viewHighlightElement );
+			} else {
+				// if element.is( 'containerElement' ).
+				element.getCustomProperty( 'removeHighlight' )( element, descriptor.id, conversionApi.writer );
+			}
+		}
+
+		conversionApi.writer.clearClonedElementsGroup( data.markerName );
+
+		evt.stop();
+	};
+}
+
+// Model element to view element conversion helper.
+//
+// See {@link ~DowncastHelpers#elementToElement `.elementToElement()` downcast helper} for examples.
+//
+// @param {Object} config Conversion configuration.
+// @param {String} config.model The name of the model element to convert.
+// @param {module:engine/view/elementdefinition~ElementDefinition|Function} config.view A view element definition or a function
+// that takes the model element and {@link module:engine/view/downcastwriter~DowncastWriter view downcast writer}
+// as parameters and returns a view container element.
+// @returns {Function} Conversion helper.
+function downcastElementToElement( config ) {
+	config = cloneDeep( config );
+
+	config.view = normalizeToElementConfig( config.view, 'container' );
+
+	return dispatcher => {
+		dispatcher.on( 'insert:' + config.model, insertElement( config.view ), { priority: config.converterPriority || 'normal' } );
+	};
+}
+
+// Model attribute to view element conversion helper.
+//
+// See {@link ~DowncastHelpers#attributeToElement `.attributeToElement()` downcast helper} for examples.
+//
+// @param {Object} config Conversion configuration.
+// @param {String|Object} config.model The key of the attribute to convert from or a `{ key, values }` object. `values` is an array
+// of `String`s with possible values if the model attribute is an enumerable.
+// @param {module:engine/view/elementdefinition~ElementDefinition|Function|Object} config.view A view element definition or a function
+// that takes the model attribute value and {@link module:engine/view/downcastwriter~DowncastWriter view downcast writer}
+// as parameters and returns a view attribute element. If `config.model.values` is
+// given, `config.view` should be an object assigning values from `config.model.values` to view element definitions or functions.
+// @param {module:utils/priorities~PriorityString} [config.converterPriority='normal'] Converter priority.
+// @returns {Function} Conversion helper.
+function downcastAttributeToElement( config ) {
+	config = cloneDeep( config );
+
+	const modelKey = config.model.key ? config.model.key : config.model;
+	let eventName = 'attribute:' + modelKey;
+
+	if ( config.model.name ) {
+		eventName += ':' + config.model.name;
+	}
+
+	if ( config.model.values ) {
+		for ( const modelValue of config.model.values ) {
+			config.view[ modelValue ] = normalizeToElementConfig( config.view[ modelValue ], 'attribute' );
+		}
+	} else {
+		config.view = normalizeToElementConfig( config.view, 'attribute' );
+	}
+
+	const elementCreator = getFromAttributeCreator( config );
+
+	return dispatcher => {
+		dispatcher.on( eventName, wrap( elementCreator ), { priority: config.converterPriority || 'normal' } );
+	};
+}
+
+// Model attribute to view attribute conversion helper.
+//
+// See {@link ~DowncastHelpers#attributeToAttribute `.attributeToAttribute()` downcast helper} for examples.
+//
+// @param {Object} config Conversion configuration.
+// @param {String|Object} config.model The key of the attribute to convert from or a `{ key, values, [ name ] }` object describing
+// the attribute key, possible values and, optionally, an element name to convert from.
+// @param {String|Object|Function} config.view A view attribute key, or a `{ key, value }` object or a function that takes
+// the model attribute value and returns a `{ key, value }` object. If `key` is `'class'`, `value` can be a `String` or an
+// array of `String`s. If `key` is `'style'`, `value` is an object with key-value pairs. In other cases, `value` is a `String`.
+// If `config.model.values` is set, `config.view` should be an object assigning values from `config.model.values` to
+// `{ key, value }` objects or a functions.
+// @param {module:utils/priorities~PriorityString} [config.converterPriority='normal'] Converter priority.
+// @returns {Function} Conversion helper.
+function downcastAttributeToAttribute( config ) {
+	config = cloneDeep( config );
+
+	const modelKey = config.model.key ? config.model.key : config.model;
+	let eventName = 'attribute:' + modelKey;
+
+	if ( config.model.name ) {
+		eventName += ':' + config.model.name;
+	}
+
+	if ( config.model.values ) {
+		for ( const modelValue of config.model.values ) {
+			config.view[ modelValue ] = normalizeToAttributeConfig( config.view[ modelValue ] );
+		}
+	} else {
+		config.view = normalizeToAttributeConfig( config.view );
+	}
+
+	const elementCreator = getFromAttributeCreator( config );
+
+	return dispatcher => {
+		dispatcher.on( eventName, changeAttribute( elementCreator ), { priority: config.converterPriority || 'normal' } );
+	};
+}
+
+// Model marker to view element conversion helper.
+//
+// See {@link ~DowncastHelpers#markerToElement `.markerToElement()` downcast helper} for examples.
+//
+// @param {Object} config Conversion configuration.
+// @param {String} config.model The name of the model marker (or model marker group) to convert.
+// @param {module:engine/view/elementdefinition~ElementDefinition|Function} config.view A view element definition or a function
+// that takes the model marker data as a parameter and returns a view UI element.
+// @param {module:utils/priorities~PriorityString} [config.converterPriority='normal'] Converter priority.
+// @returns {Function} Conversion helper.
+function downcastMarkerToElement( config ) {
+	config = cloneDeep( config );
+
+	config.view = normalizeToElementConfig( config.view, 'ui' );
+
+	return dispatcher => {
+		dispatcher.on( 'addMarker:' + config.model, insertUIElement( config.view ), { priority: config.converterPriority || 'normal' } );
+		dispatcher.on( 'removeMarker:' + config.model, removeUIElement( config.view ), { priority: config.converterPriority || 'normal' } );
+	};
+}
+
+// Model marker to highlight conversion helper.
+//
+// See {@link ~DowncastHelpers#markerToElement `.markerToElement()` downcast helper} for examples.
+//
+// @param {Object} config Conversion configuration.
+// @param {String} config.model The name of the model marker (or model marker group) to convert.
+// @param {module:engine/conversion/downcasthelpers~HighlightDescriptor|Function} config.view A highlight descriptor
+// that will be used for highlighting or a function that takes the model marker data as a parameter and returns a highlight descriptor.
+// @param {module:utils/priorities~PriorityString} [config.converterPriority='normal'] Converter priority.
+// @returns {Function} Conversion helper.
+function downcastMarkerToHighlight( config ) {
+	return dispatcher => {
+		dispatcher.on( 'addMarker:' + config.model, highlightText( config.view ), { priority: config.converterPriority || 'normal' } );
+		dispatcher.on( 'addMarker:' + config.model, highlightElement( config.view ), { priority: config.converterPriority || 'normal' } );
+		dispatcher.on( 'removeMarker:' + config.model, removeHighlight( config.view ), { priority: config.converterPriority || 'normal' } );
+	};
+}
+
+// Takes `config.view`, and if it is an {@link module:engine/view/elementdefinition~ElementDefinition}, converts it
+// to a function (because lower level converters accept only element creator functions).
+//
+// @param {module:engine/view/elementdefinition~ElementDefinition|Function} view View configuration.
+// @param {'container'|'attribute'|'ui'} viewElementType View element type to create.
+// @returns {Function} Element creator function to use in lower level converters.
+function normalizeToElementConfig( view, viewElementType ) {
+	if ( typeof view == 'function' ) {
+		// If `view` is already a function, don't do anything.
+		return view;
+	}
+
+	return ( modelData, viewWriter ) => createViewElementFromDefinition( view, viewWriter, viewElementType );
+}
+
+// Creates a view element instance from the provided {@link module:engine/view/elementdefinition~ElementDefinition} and class.
+//
+// @param {module:engine/view/elementdefinition~ElementDefinition} viewElementDefinition
+// @param {module:engine/view/downcastwriter~DowncastWriter} viewWriter
+// @param {'container'|'attribute'|'ui'} viewElementType
+// @returns {module:engine/view/element~Element}
+function createViewElementFromDefinition( viewElementDefinition, viewWriter, viewElementType ) {
+	if ( typeof viewElementDefinition == 'string' ) {
+		// If `viewElementDefinition` is given as a `String`, normalize it to an object with `name` property.
+		viewElementDefinition = { name: viewElementDefinition };
+	}
+
+	let element;
+	const attributes = Object.assign( {}, viewElementDefinition.attributes );
+
+	if ( viewElementType == 'container' ) {
+		element = viewWriter.createContainerElement( viewElementDefinition.name, attributes );
+	} else if ( viewElementType == 'attribute' ) {
+		const options = {
+			priority: viewElementDefinition.priority || ViewAttributeElement.DEFAULT_PRIORITY
+		};
+
+		element = viewWriter.createAttributeElement( viewElementDefinition.name, attributes, options );
+	} else {
+		// 'ui'.
+		element = viewWriter.createUIElement( viewElementDefinition.name, attributes );
+	}
+
+	if ( viewElementDefinition.styles ) {
+		const keys = Object.keys( viewElementDefinition.styles );
+
+		for ( const key of keys ) {
+			viewWriter.setStyle( key, viewElementDefinition.styles[ key ], element );
+		}
+	}
+
+	if ( viewElementDefinition.classes ) {
+		const classes = viewElementDefinition.classes;
+
+		if ( typeof classes == 'string' ) {
+			viewWriter.addClass( classes, element );
+		} else {
+			for ( const className of classes ) {
+				viewWriter.addClass( className, element );
+			}
+		}
+	}
+
+	return element;
+}
+
+function getFromAttributeCreator( config ) {
+	if ( config.model.values ) {
+		return ( modelAttributeValue, viewWriter ) => {
+			const view = config.view[ modelAttributeValue ];
+
+			if ( view ) {
+				return view( modelAttributeValue, viewWriter );
+			}
+
+			return null;
+		};
+	} else {
+		return config.view;
+	}
+}
+
+// Takes the configuration, adds default parameters if they do not exist and normalizes other parameters to be used in downcast converters
+// for generating a view attribute.
+//
+// @param {Object} view View configuration.
+function normalizeToAttributeConfig( view ) {
+	if ( typeof view == 'string' ) {
+		return modelAttributeValue => ( { key: view, value: modelAttributeValue } );
+	} else if ( typeof view == 'object' ) {
+		// { key, value, ... }
+		if ( view.value ) {
+			return () => view;
+		}
+		// { key, ... }
+		else {
+			return modelAttributeValue => ( { key: view.key, value: modelAttributeValue } );
+		}
+	} else {
+		// function.
+		return view;
+	}
+}
+
+// Helper function for `highlight`. Prepares the actual descriptor object using value passed to the converter.
+function prepareDescriptor( highlightDescriptor, data, conversionApi ) {
+	// If passed descriptor is a creator function, call it. If not, just use passed value.
+	const descriptor = typeof highlightDescriptor == 'function' ?
+		highlightDescriptor( data, conversionApi ) :
+		highlightDescriptor;
+
+	if ( !descriptor ) {
+		return null;
+	}
+
+	// Apply default descriptor priority.
+	if ( !descriptor.priority ) {
+		descriptor.priority = 10;
+	}
+
+	// Default descriptor id is marker name.
+	if ( !descriptor.id ) {
+		descriptor.id = data.markerName;
+	}
+
+	return descriptor;
+}
+
+/**
+ * An object describing how the marker highlight should be represented in the view.
+ *
+ * Each text node contained in a highlighted range will be wrapped in a `<span>`
+ * {@link module:engine/view/attributeelement~AttributeElement view attribute element} with CSS class(es), attributes and a priority
+ * described by this object.
+ *
+ * Additionally, each {@link module:engine/view/containerelement~ContainerElement container element} can handle displaying the highlight
+ * separately by providing the `addHighlight` and `removeHighlight` custom properties. In this case:
+ *
+ *  * The `HighlightDescriptor` object is passed to the `addHighlight` function upon conversion and should be used to apply the highlight to
+ *  the element.
+ *  * The descriptor `id` is passed to the `removeHighlight` function upon conversion and should be used to remove the highlight with the
+ *  given ID from the element.
+ *
+ * @typedef {Object} module:engine/conversion/downcasthelpers~HighlightDescriptor
+ *
+ * @property {String|Array.<String>} classes A CSS class or an array of classes to set. If the descriptor is used to
+ * create an {@link module:engine/view/attributeelement~AttributeElement attribute element} over text nodes, these classes will be set
+ * on that attribute element. If the descriptor is applied to an element, usually these classes will be set on that element, however,
+ * this depends on how the element converts the descriptor.
+ *
+ * @property {String} [id] Descriptor identifier. If not provided, it defaults to the converted marker's name.
+ *
+ * @property {Number} [priority] Descriptor priority. If not provided, it defaults to `10`. If the descriptor is used to create
+ * an {@link module:engine/view/attributeelement~AttributeElement attribute element}, it will be that element's
+ * {@link module:engine/view/attributeelement~AttributeElement#priority priority}. If the descriptor is applied to an element,
+ * the priority will be used to determine which descriptor is more important.
+ *
+ * @property {Object} [attributes] Attributes to set. If the descriptor is used to create
+ * an {@link module:engine/view/attributeelement~AttributeElement attribute element} over text nodes, these attributes will be set on that
+ * attribute element. If the descriptor is applied to an element, usually these attributes will be set on that element, however,
+ * this depends on how the element converts the descriptor.
+ */

+ 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.
  * {@link module:engine/conversion/modelconsumable~ModelConsumable#add add method} directly.
  * However, it is important to understand how consumable values can be
  * However, it is important to understand how consumable values can be
  * {@link module:engine/conversion/modelconsumable~ModelConsumable#consume consumed}.
  * {@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
  * 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
  * able to convert one or more parts of the model. However, when one of those callbacks actually converts

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

@@ -1,609 +0,0 @@
-/**
- * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
-
-import Matcher from '../view/matcher';
-
-import ModelRange from '../model/range';
-
-import { cloneDeep } from 'lodash-es';
-
-/**
- * Contains {@link module:engine/view/view view} to {@link module:engine/model/model model} converters for
- * {@link module:engine/conversion/upcastdispatcher~UpcastDispatcher}.
- *
- * @module engine/conversion/upcast-converters
- */
-
-/**
- * View element to model element conversion helper.
- *
- * This conversion results in creating a model element. For example, view `<p>Foo</p>` becomes `<paragraph>Foo</paragraph>` in the model.
- *
- * Keep in mind that the element will be inserted only if it is allowed by {@link module:engine/model/schema~Schema schema} configuration.
- *
- *		upcastElementToElement( { view: 'p', model: 'paragraph' } );
- *
- *		upcastElementToElement( { view: 'p', model: 'paragraph', converterPriority: 'high' } );
- *
- *		upcastElementToElement( {
- *			view: {
- *				name: 'p',
- *				classes: 'fancy'
- *			},
- *			model: 'fancyParagraph'
- *		} );
- *
- *		upcastElementToElement( {
- * 			view: {
- *				name: 'p',
- *				classes: 'heading'
- * 			},
- * 			model: ( viewElement, modelWriter ) => {
- * 				return modelWriter.createElement( 'heading', { level: viewElement.getAttribute( 'data-level' ) } );
- * 			}
- * 		} );
- *
- * See {@link module:engine/conversion/conversion~Conversion#for} to learn how to add converter to conversion process.
- *
- * @param {Object} config Conversion configuration.
- * @param {module:engine/view/matcher~MatcherPattern} config.view Pattern matching all view elements which should be converted.
- * @param {String|module:engine/model/element~Element|Function} config.model Name of the model element, a model element
- * instance or a function that takes a view element and returns a model element. The model element will be inserted in the model.
- * @param {module:utils/priorities~PriorityString} [config.converterPriority='normal'] Converter priority.
- * @returns {Function} Conversion helper.
- */
-export function upcastElementToElement( config ) {
-	config = cloneDeep( config );
-
-	const converter = _prepareToElementConverter( config );
-
-	const elementName = _getViewElementNameFromConfig( config );
-	const eventName = elementName ? 'element:' + elementName : 'element';
-
-	return dispatcher => {
-		dispatcher.on( eventName, converter, { priority: config.converterPriority || 'normal' } );
-	};
-}
-
-/**
- * View element to model attribute conversion helper.
- *
- * This conversion results in setting an attribute on a model node. For example, view `<strong>Foo</strong>` becomes
- * `Foo` {@link module:engine/model/text~Text model text node} with `bold` attribute set to `true`.
- *
- * This helper is meant to set a model attribute on all the elements that are inside the converted element:
- *
- *		<strong>Foo</strong>   -->   <strong><p>Foo</p></strong>   -->   <paragraph><$text bold="true">Foo</$text></paragraph>
- *
- * Above is a sample of HTML code, that goes through autoparagraphing (first step) and then is converted (second step).
- * Even though `<strong>` is over `<p>` element, `bold="true"` was added to the text. See
- * {@link module:engine/conversion/upcast-converters~upcastAttributeToAttribute} for comparison.
- *
- * Keep in mind that the attribute will be set only if it is allowed by {@link module:engine/model/schema~Schema schema} configuration.
- *
- *		upcastElementToAttribute( { view: 'strong', model: 'bold' } );
- *
- *		upcastElementToAttribute( { view: 'strong', model: 'bold', converterPriority: 'high' } );
- *
- *		upcastElementToAttribute( {
- *			view: {
- *				name: 'span',
- *				classes: 'bold'
- *			},
- *			model: 'bold'
- *		} );
- *
- *		upcastElementToAttribute( {
- *			view: {
- *				name: 'span',
- *				classes: [ 'styled', 'styled-dark' ]
- *			},
- *			model: {
- *				key: 'styled',
- *				value: 'dark'
- *			}
- *		} );
- *
- * 		upcastElementToAttribute( {
- *			view: {
- *				name: 'span',
- *				styles: {
- *					'font-size': /[\s\S]+/
- *				}
- *			},
- *			model: {
- *				key: 'fontSize',
- *				value: viewElement => {
- *					const fontSize = viewElement.getStyle( 'font-size' );
- *					const value = fontSize.substr( 0, fontSize.length - 2 );
- *
- *					if ( value <= 10 ) {
- *						return 'small';
- *					} else if ( value > 12 ) {
- *						return 'big';
- *					}
- *
- *					return null;
- *				}
- *			}
- *		} );
- *
- * See {@link module:engine/conversion/conversion~Conversion#for} to learn how to add converter to conversion process.
- *
- * @param {Object} config Conversion configuration.
- * @param {module:engine/view/matcher~MatcherPattern} config.view Pattern matching all view elements which should be converted.
- * @param {String|Object} config.model Model attribute key or an object with `key` and `value` properties, describing
- * the model attribute. `value` property may be set as a function that takes a view element and returns the value.
- * If `String` is given, the model attribute value will be set to `true`.
- * @param {module:utils/priorities~PriorityString} [config.converterPriority='normal'] Converter priority.
- * @returns {Function} Conversion helper.
- */
-export function upcastElementToAttribute( config ) {
-	config = cloneDeep( config );
-
-	_normalizeModelAttributeConfig( config );
-
-	const converter = _prepareToAttributeConverter( config, false );
-
-	const elementName = _getViewElementNameFromConfig( config );
-	const eventName = elementName ? 'element:' + elementName : 'element';
-
-	return dispatcher => {
-		dispatcher.on( eventName, converter, { priority: config.converterPriority || 'normal' } );
-	};
-}
-
-/**
- * View attribute to model attribute conversion helper.
- *
- * This conversion results in setting an attribute on a model node. For example, view `<img src="foo.jpg"></img>` becomes
- * `<image source="foo.jpg"></image>` in the model.
- *
- * This helper is meant to convert view attributes from view elements which got converted to the model, so the view attribute
- * is set only on the corresponding model node:
- *
- *		<div class="dark"><div>foo</div></div>    -->    <div dark="true"><div>foo</div></div>
- *
- * Above, `class="dark"` attribute is added only to the `<div>` elements that has it. This is in contrary to
- * {@link module:engine/conversion/upcast-converters~upcastElementToAttribute} which sets attributes for all the children in the model:
- *
- *		<strong>Foo</strong>   -->   <strong><p>Foo</p></strong>   -->   <paragraph><$text bold="true">Foo</$text></paragraph>
- *
- * Above is a sample of HTML code, that goes through autoparagraphing (first step) and then is converted (second step).
- * Even though `<strong>` is over `<p>` element, `bold="true"` was added to the text.
- *
- * Keep in mind that the attribute will be set only if it is allowed by {@link module:engine/model/schema~Schema schema} configuration.
- *
- *		upcastAttributeToAttribute( { view: 'src', model: 'source' } );
- *
- *		upcastAttributeToAttribute( { view: { key: 'src' }, model: 'source' } );
- *
- *		upcastAttributeToAttribute( { view: { key: 'src' }, model: 'source', converterPriority: 'normal' } );
- *
- *		upcastAttributeToAttribute( {
- *			view: {
- *				key: 'data-style',
- *				value: /[\s\S]+/
- *			},
- *			model: 'styled'
- *		} );
- *
- *		upcastAttributeToAttribute( {
- *			view: {
- *				name: 'img',
- *				key: 'class',
- *				value: 'styled-dark'
- *			},
- *			model: {
- *				key: 'styled',
- *				value: 'dark'
- *			}
- *		} );
- *
- *		upcastAttributeToAttribute( {
- *			view: {
- *				key: 'class',
- *				value: /styled-[\S]+/
- *			},
- *			model: {
- *				key: 'styled'
- *				value: viewElement => {
- *					const regexp = /styled-([\S]+)/;
- *					const match = viewElement.getAttribute( 'class' ).match( regexp );
- *
- *					return match[ 1 ];
- *				}
- *			}
- *		} );
- *
- * See {@link module:engine/conversion/conversion~Conversion#for} to learn how to add converter to conversion process.
- *
- * @param {Object} config Conversion configuration.
- * @param {String|Object} config.view Specifies which view attribute will be converted. If a `String` is passed,
- * attributes with given key will be converted. If an `Object` is passed, it must have a required `key` property,
- * specifying view attribute key, and may have an optional `value` property, specifying view attribute value and optional `name`
- * property specifying a view element name from/on which the attribute should be converted. `value` can be given as a `String`,
- * a `RegExp` or a function callback, that takes view attribute value as the only parameter and returns `Boolean`.
- * @param {String|Object} config.model Model attribute key or an object with `key` and `value` properties, describing
- * the model attribute. `value` property may be set as a function that takes a view element and returns the value.
- * If `String` is given, the model attribute value will be same as view attribute value.
- * @param {module:utils/priorities~PriorityString} [config.converterPriority='low'] Converter priority.
- * @returns {Function} Conversion helper.
- */
-export function upcastAttributeToAttribute( config ) {
-	config = cloneDeep( config );
-
-	let viewKey = null;
-
-	if ( typeof config.view == 'string' || config.view.key ) {
-		viewKey = _normalizeViewAttributeKeyValueConfig( config );
-	}
-
-	_normalizeModelAttributeConfig( config, viewKey );
-
-	const converter = _prepareToAttributeConverter( config, true );
-
-	return dispatcher => {
-		dispatcher.on( 'element', converter, { priority: config.converterPriority || 'low' } );
-	};
-}
-
-/**
- * View element to model marker conversion helper.
- *
- * This conversion results in creating a model marker. For example, if the marker was stored in a view as an element:
- * `<p>Fo<span data-marker="comment" data-comment-id="7"></span>o</p><p>B<span data-marker="comment" data-comment-id="7"></span>ar</p>`,
- * after the conversion is done, the marker will be available in
- * {@link module:engine/model/model~Model#markers model document markers}.
- *
- *		upcastElementToMarker( { view: 'marker-search', model: 'search' } );
- *
- *		upcastElementToMarker( { view: 'marker-search', model: 'search', converterPriority: 'high' } );
- *
- *		upcastElementToMarker( {
- *			view: 'marker-search',
- *			model: viewElement => 'comment:' + viewElement.getAttribute( 'data-comment-id' )
- *		} );
- *
- *		upcastElementToMarker( {
- *			view: {
- *				name: 'span',
- *				attributes: {
- *					'data-marker': 'search'
- *				}
- *			},
- *			model: 'search'
- *		} );
- *
- * See {@link module:engine/conversion/conversion~Conversion#for} to learn how to add converter to conversion process.
- *
- * @param {Object} config Conversion configuration.
- * @param {module:engine/view/matcher~MatcherPattern} config.view Pattern matching all view elements which should be converted.
- * @param {String|Function} config.model Name of the model marker, or a function that takes a view element and returns
- * a model marker name.
- * @param {module:utils/priorities~PriorityString} [config.converterPriority='normal'] Converter priority.
- * @returns {Function} Conversion helper.
- */
-export function upcastElementToMarker( config ) {
-	config = cloneDeep( config );
-
-	_normalizeToMarkerConfig( config );
-
-	return upcastElementToElement( config );
-}
-
-// Helper function for from-view-element conversion. Checks if `config.view` directly specifies converted view element's name
-// and if so, returns it.
-//
-// @param {Object} config Conversion config.
-// @returns {String|null} View element name or `null` if name is not directly set.
-function _getViewElementNameFromConfig( config ) {
-	if ( typeof config.view == 'string' ) {
-		return config.view;
-	}
-
-	if ( typeof config.view == 'object' && typeof config.view.name == 'string' ) {
-		return config.view.name;
-	}
-
-	return null;
-}
-
-// Helper for to-model-element conversion. Takes a config object and returns a proper converter function.
-//
-// @param {Object} config Conversion configuration.
-// @returns {Function} View to model converter.
-function _prepareToElementConverter( config ) {
-	const matcher = new Matcher( config.view );
-
-	return ( evt, data, conversionApi ) => {
-		// This will be usually just one pattern but we support matchers with many patterns too.
-		const match = matcher.match( data.viewItem );
-
-		// If there is no match, this callback should not do anything.
-		if ( !match ) {
-			return;
-		}
-
-		// Force consuming element's name.
-		match.match.name = true;
-
-		// Create model element basing on config.
-		const modelElement = _getModelElement( config.model, data.viewItem, conversionApi.writer );
-
-		// Do not convert if element building function returned falsy value.
-		if ( !modelElement ) {
-			return;
-		}
-
-		// When element was already consumed then skip it.
-		if ( !conversionApi.consumable.test( data.viewItem, match.match ) ) {
-			return;
-		}
-
-		// Find allowed parent for element that we are going to insert.
-		// If current parent does not allow to insert element but one of the ancestors does
-		// then split nodes to allowed parent.
-		const splitResult = conversionApi.splitToAllowedParent( modelElement, data.modelCursor );
-
-		// When there is no split result it means that we can't insert element to model tree, so let's skip it.
-		if ( !splitResult ) {
-			return;
-		}
-
-		// Insert element on allowed position.
-		conversionApi.writer.insert( modelElement, splitResult.position );
-
-		// Convert children and insert to element.
-		const childrenResult = conversionApi.convertChildren( data.viewItem, conversionApi.writer.createPositionAt( modelElement, 0 ) );
-
-		// Consume appropriate value from consumable values list.
-		conversionApi.consumable.consume( data.viewItem, match.match );
-
-		// Set conversion result range.
-		data.modelRange = new ModelRange(
-			// Range should start before inserted element
-			conversionApi.writer.createPositionBefore( modelElement ),
-			// Should end after but we need to take into consideration that children could split our
-			// element, so we need to move range after parent of the last converted child.
-			// before: <allowed>[]</allowed>
-			// after: <allowed>[<converted><child></child></converted><child></child><converted>]</converted></allowed>
-			conversionApi.writer.createPositionAfter( childrenResult.modelCursor.parent )
-		);
-
-		// Now we need to check where the modelCursor should be.
-		// If we had to split parent to insert our element then we want to continue conversion inside split parent.
-		//
-		// before: <allowed><notAllowed>[]</notAllowed></allowed>
-		// after:  <allowed><notAllowed></notAllowed><converted></converted><notAllowed>[]</notAllowed></allowed>
-		if ( splitResult.cursorParent ) {
-			data.modelCursor = conversionApi.writer.createPositionAt( splitResult.cursorParent, 0 );
-
-			// Otherwise just continue after inserted element.
-		} else {
-			data.modelCursor = data.modelRange.end;
-		}
-	};
-}
-
-// Helper function for upcasting-to-element converter. Takes the model configuration, the converted view element
-// and a writer instance and returns a model element instance to be inserted in the model.
-//
-// @param {String|Function|module:engine/model/element~Element} model Model conversion configuration.
-// @param {module:engine/view/node~Node} input The converted view node.
-// @param {module:engine/model/writer~Writer} writer A writer instance to use to create the model element.
-function _getModelElement( model, input, writer ) {
-	if ( model instanceof Function ) {
-		return model( input, writer );
-	} else {
-		return writer.createElement( model );
-	}
-}
-
-// Helper function view-attribute-to-model-attribute helper. Normalizes `config.view` which was set as `String` or
-// as an `Object` with `key`, `value` and `name` properties. Normalized `config.view` has is compatible with
-// {@link module:engine/view/matcher~MatcherPattern}.
-//
-// @param {Object} config Conversion config.
-// @returns {String} Key of the converted view attribute.
-function _normalizeViewAttributeKeyValueConfig( config ) {
-	if ( typeof config.view == 'string' ) {
-		config.view = { key: config.view };
-	}
-
-	const key = config.view.key;
-	let normalized;
-
-	if ( key == 'class' || key == 'style' ) {
-		const keyName = key == 'class' ? 'classes' : 'styles';
-
-		normalized = {
-			[ keyName ]: config.view.value
-		};
-	} else {
-		const value = typeof config.view.value == 'undefined' ? /[\s\S]*/ : config.view.value;
-
-		normalized = {
-			attributes: {
-				[ key ]: value
-			}
-		};
-	}
-
-	if ( config.view.name ) {
-		normalized.name = config.view.name;
-	}
-
-	config.view = normalized;
-
-	return key;
-}
-
-// Helper function that normalizes `config.model` in from-model-attribute conversion. `config.model` can be set
-// as a `String`, an `Object` with only `key` property or an `Object` with `key` and `value` properties. Normalized
-// `config.model` is an `Object` with `key` and `value` properties.
-//
-// @param {Object} config Conversion config.
-// @param {String} viewAttributeKeyToCopy Key of the converted view attribute. If it is set, model attribute value
-// will be equal to view attribute value.
-function _normalizeModelAttributeConfig( config, viewAttributeKeyToCopy = null ) {
-	const defaultModelValue = viewAttributeKeyToCopy === null ? true : viewElement => viewElement.getAttribute( viewAttributeKeyToCopy );
-
-	const key = typeof config.model != 'object' ? config.model : config.model.key;
-	const value = typeof config.model != 'object' || typeof config.model.value == 'undefined' ? defaultModelValue : config.model.value;
-
-	config.model = { key, value };
-}
-
-// Helper for to-model-attribute conversion. Takes the model attribute name and conversion configuration and returns
-// a proper converter function.
-//
-// @param {String} modelAttributeKey The key of the model attribute to set on a model node.
-// @param {Object|Array.<Object>} config Conversion configuration. It is possible to provide multiple configurations in an array.
-// @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.
-function _prepareToAttributeConverter( config, shallow ) {
-	const matcher = new Matcher( config.view );
-
-	return ( evt, data, conversionApi ) => {
-		const match = matcher.match( data.viewItem );
-
-		// If there is no match, this callback should not do anything.
-		if ( !match ) {
-			return;
-		}
-
-		const modelKey = config.model.key;
-		const modelValue = typeof config.model.value == 'function' ? config.model.value( data.viewItem ) : config.model.value;
-
-		// Do not convert if attribute building function returned falsy value.
-		if ( modelValue === null ) {
-			return;
-		}
-
-		if ( _onlyViewNameIsDefined( config ) ) {
-			match.match.name = true;
-		} else {
-			// Do not test or consume `name` consumable.
-			delete match.match.name;
-		}
-
-		// Try to consume appropriate values from consumable values list.
-		if ( !conversionApi.consumable.test( data.viewItem, match.match ) ) {
-			return;
-		}
-
-		// Since we are converting to attribute we need an range on which we will set the attribute.
-		// If the range is not created yet, we will create it.
-		if ( !data.modelRange ) {
-			// Convert children and set conversion result as a current data.
-			data = Object.assign( data, conversionApi.convertChildren( data.viewItem, data.modelCursor ) );
-		}
-
-		// Set attribute on current `output`. `Schema` is checked inside this helper function.
-		const attributeWasSet = _setAttributeOn( data.modelRange, { key: modelKey, value: modelValue }, shallow, conversionApi );
-
-		if ( attributeWasSet ) {
-			conversionApi.consumable.consume( data.viewItem, match.match );
-		}
-	};
-}
-
-// Helper function that checks if element name should be consumed in attribute converters.
-//
-// @param {Object} config Conversion config.
-// @returns {Boolean}
-function _onlyViewNameIsDefined( config ) {
-	if ( typeof config.view == 'object' && !_getViewElementNameFromConfig( config ) ) {
-		return false;
-	}
-
-	return !config.view.classes && !config.view.attributes && !config.view.styles;
-}
-
-// Helper function for to-model-attribute converter. Sets model attribute on given range. Checks {@link module:engine/model/schema~Schema}
-// to ensure proper model structure.
-//
-// @param {module:engine/model/range~Range} modelRange Model range on which attribute should be set.
-// @param {Object} modelAttribute Model attribute to set.
-// @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`.
-function _setAttributeOn( modelRange, modelAttribute, shallow, conversionApi ) {
-	let result = false;
-
-	// Set attribute on each item in range according to Schema.
-	for ( const node of Array.from( modelRange.getItems( { shallow } ) ) ) {
-		if ( conversionApi.schema.checkAttribute( node, modelAttribute.key ) ) {
-			conversionApi.writer.setAttribute( modelAttribute.key, modelAttribute.value, node );
-
-			result = true;
-		}
-	}
-
-	return result;
-}
-
-// Helper function for upcasting-to-marker conversion. Takes the config in a format requested by `upcastElementToMarker()`
-// function and converts it to a format that is supported by `upcastElementToElement()` function.
-//
-// @param {Object} config Conversion configuration.
-function _normalizeToMarkerConfig( config ) {
-	const oldModel = config.model;
-
-	config.model = ( viewElement, modelWriter ) => {
-		const markerName = typeof oldModel == 'string' ? oldModel : oldModel( viewElement );
-
-		return modelWriter.createElement( '$marker', { 'data-name': markerName } );
-	};
-}
-
-/**
- * Function factory, creates a converter that converts {@link module:engine/view/documentfragment~DocumentFragment view document fragment}
- * or all children of {@link module:engine/view/element~Element} into
- * {@link module:engine/model/documentfragment~DocumentFragment model document fragment}.
- * This is the "entry-point" converter for upcast (view to model conversion). This converter starts the conversion of all children
- * of passed view document fragment. Those children {@link module:engine/view/node~Node view nodes} are then handled by other converters.
- *
- * This also a "default", last resort converter for all view elements that has not been converted by other converters.
- * When a view element is being converted to the model but it does not have converter specified, that view element
- * will be converted to {@link module:engine/model/documentfragment~DocumentFragment model document fragment} and returned.
- *
- * @returns {Function} Universal converter for view {@link module:engine/view/documentfragment~DocumentFragment fragments} and
- * {@link module:engine/view/element~Element elements} that returns
- * {@link module:engine/model/documentfragment~DocumentFragment model fragment} with children of converted view item.
- */
-export function convertToModelFragment() {
-	return ( evt, data, conversionApi ) => {
-		// Second argument in `consumable.consume` is discarded for ViewDocumentFragment but is needed for ViewElement.
-		if ( !data.modelRange && conversionApi.consumable.consume( data.viewItem, { name: true } ) ) {
-			const { modelRange, modelCursor } = conversionApi.convertChildren( data.viewItem, data.modelCursor );
-
-			data.modelRange = modelRange;
-			data.modelCursor = modelCursor;
-		}
-	};
-}
-
-/**
- * Function factory, creates a converter that converts {@link module:engine/view/text~Text} to {@link module:engine/model/text~Text}.
- *
- * @returns {Function} {@link module:engine/view/text~Text View text} converter.
- */
-export function convertText() {
-	return ( evt, data, conversionApi ) => {
-		if ( conversionApi.schema.checkChild( data.modelCursor, '$text' ) ) {
-			if ( conversionApi.consumable.consume( data.viewItem ) ) {
-				const text = conversionApi.writer.createText( data.viewItem.data );
-
-				conversionApi.writer.insert( text, data.modelCursor );
-
-				data.modelRange = ModelRange._createFromPositionAndShift( data.modelCursor, text.offsetSize );
-				data.modelCursor = data.modelRange.end;
-			}
-		}
-	};
-}

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

+ 756 - 0
packages/ckeditor5-engine/src/conversion/upcasthelpers.js

@@ -0,0 +1,756 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Matcher from '../view/matcher';
+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
+ * {@link module:engine/conversion/upcastdispatcher~UpcastDispatcher}.
+ *
+ * @module engine/conversion/upcasthelpers
+ */
+
+/**
+ * Upcast conversion helper functions.
+ *
+ * @extends module:engine/conversion/conversion~ConversionHelpers
+ */
+export default class UpcastHelpers extends ConversionHelpers {
+	/**
+	 * View element to model element conversion helper.
+	 *
+	 * This conversion results in creating a model element. For example,
+	 * view `<p>Foo</p>` becomes `<paragraph>Foo</paragraph>` in the model.
+	 *
+	 * Keep in mind that the element will be inserted only if it is allowed
+	 * by {@link module:engine/model/schema~Schema schema} configuration.
+	 *
+	 *		editor.conversion.for( 'upcast' ).elementToElement( {
+	 *			view: 'p',
+	 *			model: 'paragraph'
+	 *		} );
+	 *
+	 *		editor.conversion.for( 'upcast' ).elementToElement( {
+	 *			view: 'p',
+	 *			model: 'paragraph',
+	 *			converterPriority: 'high'
+	 *		} );
+	 *
+	 *		editor.conversion.for( 'upcast' ).elementToElement( {
+	 *			view: {
+	 *				name: 'p',
+	 *				classes: 'fancy'
+	 *			},
+	 *			model: 'fancyParagraph'
+	 *		} );
+	 *
+	 *		editor.conversion.for( 'upcast' ).elementToElement( {
+	 * 			view: {
+	 *				name: 'p',
+	 *				classes: 'heading'
+	 * 			},
+	 * 			model: ( viewElement, modelWriter ) => {
+	 * 				return modelWriter.createElement( 'heading', { level: viewElement.getAttribute( 'data-level' ) } );
+	 * 			}
+	 * 		} );
+	 *
+	 * See {@link module:engine/conversion/conversion~Conversion#for `conversion.for()`} to learn how to add a converter
+	 * to the conversion process.
+	 *
+	 * @method #elementToElement
+	 * @param {Object} config Conversion configuration.
+	 * @param {module:engine/view/matcher~MatcherPattern} config.view Pattern matching all view elements which should be converted.
+	 * @param {String|module:engine/model/element~Element|Function} config.model Name of the model element, a model element
+	 * instance or a function that takes a view element and returns a model element. The model element will be inserted in the model.
+	 * @param {module:utils/priorities~PriorityString} [config.converterPriority='normal'] Converter priority.
+	 * @returns {module:engine/conversion/upcasthelpers~UpcastHelpers}
+	 */
+	elementToElement( config ) {
+		return this.add( upcastElementToElement( config ) );
+	}
+
+	/**
+	 * View element to model attribute conversion helper.
+	 *
+	 * This conversion results in setting an attribute on a model node. For example, view `<strong>Foo</strong>` becomes
+	 * `Foo` {@link module:engine/model/text~Text model text node} with `bold` attribute set to `true`.
+	 *
+	 * This helper is meant to set a model attribute on all the elements that are inside the converted element:
+	 *
+	 *		<strong>Foo</strong>   -->   <strong><p>Foo</p></strong>   -->   <paragraph><$text bold="true">Foo</$text></paragraph>
+	 *
+	 * Above is a sample of HTML code, that goes through autoparagraphing (first step) and then is converted (second step).
+	 * Even though `<strong>` is over `<p>` element, `bold="true"` was added to the text. See
+	 * {@link module:engine/conversion/upcasthelpers~UpcastHelpers#attributeToAttribute} for comparison.
+	 *
+	 * Keep in mind that the attribute will be set only if it is allowed by {@link module:engine/model/schema~Schema schema} configuration.
+	 *
+	 *		editor.conversion.for( 'upcast' ).elementToAttribute( {
+	 *			view: 'strong',
+	 *			model: 'bold'
+	 *		} );
+	 *
+	 *		editor.conversion.for( 'upcast' ).elementToAttribute( {
+	 *			view: 'strong',
+	 *			model: 'bold',
+	 *			converterPriority: 'high'
+	 *		} );
+	 *
+	 *		editor.conversion.for( 'upcast' ).elementToAttribute( {
+	 *			view: {
+	 *				name: 'span',
+	 *				classes: 'bold'
+	 *			},
+	 *			model: 'bold'
+	 *		} );
+	 *
+	 *		editor.conversion.for( 'upcast' ).elementToAttribute( {
+	 *			view: {
+	 *				name: 'span',
+	 *				classes: [ 'styled', 'styled-dark' ]
+	 *			},
+	 *			model: {
+	 *				key: 'styled',
+	 *				value: 'dark'
+	 *			}
+	 *		} );
+	 *
+	 * 		editor.conversion.for( 'upcast' ).elementToAttribute( {
+	 *			view: {
+	 *				name: 'span',
+	 *				styles: {
+	 *					'font-size': /[\s\S]+/
+	 *				}
+	 *			},
+	 *			model: {
+	 *				key: 'fontSize',
+	 *				value: viewElement => {
+	 *					const fontSize = viewElement.getStyle( 'font-size' );
+	 *					const value = fontSize.substr( 0, fontSize.length - 2 );
+	 *
+	 *					if ( value <= 10 ) {
+	 *						return 'small';
+	 *					} else if ( value > 12 ) {
+	 *						return 'big';
+	 *					}
+	 *
+	 *					return null;
+	 *				}
+	 *			}
+	 *		} );
+	 *
+	 * See {@link module:engine/conversion/conversion~Conversion#for `conversion.for()`} to learn how to add a converter
+	 * to the conversion process.
+	 *
+	 * @method #elementToAttribute
+	 * @param {Object} config Conversion configuration.
+	 * @param {module:engine/view/matcher~MatcherPattern} config.view Pattern matching all view elements which should be converted.
+	 * @param {String|Object} config.model Model attribute key or an object with `key` and `value` properties, describing
+	 * the model attribute. `value` property may be set as a function that takes a view element and returns the value.
+	 * If `String` is given, the model attribute value will be set to `true`.
+	 * @param {module:utils/priorities~PriorityString} [config.converterPriority='normal'] Converter priority.
+	 * @returns {module:engine/conversion/upcasthelpers~UpcastHelpers}
+	 */
+	elementToAttribute( config ) {
+		return this.add( upcastElementToAttribute( config ) );
+	}
+
+	/**
+	 * View attribute to model attribute conversion helper.
+	 *
+	 * This conversion results in setting an attribute on a model node. For example, view `<img src="foo.jpg"></img>` becomes
+	 * `<image source="foo.jpg"></image>` in the model.
+	 *
+	 * This helper is meant to convert view attributes from view elements which got converted to the model, so the view attribute
+	 * is set only on the corresponding model node:
+	 *
+	 *		<div class="dark"><div>foo</div></div>    -->    <div dark="true"><div>foo</div></div>
+	 *
+	 * Above, `class="dark"` attribute is added only to the `<div>` elements that has it. This is in contrary to
+	 * {@link module:engine/conversion/upcasthelpers~UpcastHelpers#elementToAttribute} which sets attributes for
+	 * all the children in the model:
+	 *
+	 *		<strong>Foo</strong>   -->   <strong><p>Foo</p></strong>   -->   <paragraph><$text bold="true">Foo</$text></paragraph>
+	 *
+	 * Above is a sample of HTML code, that goes through autoparagraphing (first step) and then is converted (second step).
+	 * Even though `<strong>` is over `<p>` element, `bold="true"` was added to the text.
+	 *
+	 * Keep in mind that the attribute will be set only if it is allowed by {@link module:engine/model/schema~Schema schema} configuration.
+	 *
+	 *		editor.conversion.for( 'upcast' ).attributeToAttribute( {
+	 *			view: 'src',
+	 *			model: 'source'
+	 *		} );
+	 *
+	 *		editor.conversion.for( 'upcast' ).attributeToAttribute( {
+	 *			view: { key: 'src' },
+	 *			model: 'source'
+	 *		} );
+	 *
+	 *		editor.conversion.for( 'upcast' ).attributeToAttribute( {
+	 *			view: { key: 'src' },
+	 *			model: 'source',
+	 *			converterPriority: 'normal'
+	 *		} );
+	 *
+	 *		editor.conversion.for( 'upcast' ).attributeToAttribute( {
+	 *			view: {
+	 *				key: 'data-style',
+	 *				value: /[\s\S]+/
+	 *			},
+	 *			model: 'styled'
+	 *		} );
+	 *
+	 *		editor.conversion.for( 'upcast' ).attributeToAttribute( {
+	 *			view: {
+	 *				name: 'img',
+	 *				key: 'class',
+	 *				value: 'styled-dark'
+	 *			},
+	 *			model: {
+	 *				key: 'styled',
+	 *				value: 'dark'
+	 *			}
+	 *		} );
+	 *
+	 *		editor.conversion.for( 'upcast' ).attributeToAttribute( {
+	 *			view: {
+	 *				key: 'class',
+	 *				value: /styled-[\S]+/
+	 *			},
+	 *			model: {
+	 *				key: 'styled'
+	 *				value: viewElement => {
+	 *					const regexp = /styled-([\S]+)/;
+	 *					const match = viewElement.getAttribute( 'class' ).match( regexp );
+	 *
+	 *					return match[ 1 ];
+	 *				}
+	 *			}
+	 *		} );
+	 *
+	 * See {@link module:engine/conversion/conversion~Conversion#for `conversion.for()`} to learn how to add a converter
+	 * to the conversion process.
+	 *
+	 * @method #attributeToAttribute
+	 * @param {Object} config Conversion configuration.
+	 * @param {String|Object} config.view Specifies which view attribute will be converted. If a `String` is passed,
+	 * attributes with given key will be converted. If an `Object` is passed, it must have a required `key` property,
+	 * specifying view attribute key, and may have an optional `value` property, specifying view attribute value and optional `name`
+	 * property specifying a view element name from/on which the attribute should be converted. `value` can be given as a `String`,
+	 * a `RegExp` or a function callback, that takes view attribute value as the only parameter and returns `Boolean`.
+	 * @param {String|Object} config.model Model attribute key or an object with `key` and `value` properties, describing
+	 * the model attribute. `value` property may be set as a function that takes a view element and returns the value.
+	 * If `String` is given, the model attribute value will be same as view attribute value.
+	 * @param {module:utils/priorities~PriorityString} [config.converterPriority='low'] Converter priority.
+	 * @returns {module:engine/conversion/upcasthelpers~UpcastHelpers}
+	 */
+	attributeToAttribute( config ) {
+		return this.add( upcastAttributeToAttribute( config ) );
+	}
+
+	/**
+	 * View element to model marker conversion helper.
+	 *
+	 * This conversion results in creating a model marker. For example, if the marker was stored in a view as an element:
+	 * `<p>Fo<span data-marker="comment" data-comment-id="7"></span>o</p><p>B<span data-marker="comment" data-comment-id="7"></span>ar</p>`,
+	 * after the conversion is done, the marker will be available in
+	 * {@link module:engine/model/model~Model#markers model document markers}.
+	 *
+	 *		editor.conversion.for( 'upcast' ).elementToMarker( {
+	 *			view: 'marker-search',
+	 *			model: 'search'
+	 *		} );
+	 *
+	 *		editor.conversion.for( 'upcast' ).elementToMarker( {
+	 *			view: 'marker-search',
+	 *			model: 'search',
+	 *			converterPriority: 'high'
+	 *		} );
+	 *
+	 *		editor.conversion.for( 'upcast' ).elementToMarker( {
+	 *			view: 'marker-search',
+	 *			model: viewElement => 'comment:' + viewElement.getAttribute( 'data-comment-id' )
+	 *		} );
+	 *
+	 *		editor.conversion.for( 'upcast' ).elementToMarker( {
+	 *			view: {
+	 *				name: 'span',
+	 *				attributes: {
+	 *					'data-marker': 'search'
+	 *				}
+	 *			},
+	 *			model: 'search'
+	 *		} );
+	 *
+	 * See {@link module:engine/conversion/conversion~Conversion#for `conversion.for()`} to learn how to add a converter
+	 * to the conversion process.
+	 *
+	 * @method #elementToMarker
+	 * @param {Object} config Conversion configuration.
+	 * @param {module:engine/view/matcher~MatcherPattern} config.view Pattern matching all view elements which should be converted.
+	 * @param {String|Function} config.model Name of the model marker, or a function that takes a view element and returns
+	 * a model marker name.
+	 * @param {module:utils/priorities~PriorityString} [config.converterPriority='normal'] Converter priority.
+	 * @returns {module:engine/conversion/upcasthelpers~UpcastHelpers}
+	 */
+	elementToMarker( config ) {
+		return this.add( upcastElementToMarker( config ) );
+	}
+}
+
+/**
+ * Function factory, creates a converter that converts {@link module:engine/view/documentfragment~DocumentFragment view document fragment}
+ * or all children of {@link module:engine/view/element~Element} into
+ * {@link module:engine/model/documentfragment~DocumentFragment model document fragment}.
+ * This is the "entry-point" converter for upcast (view to model conversion). This converter starts the conversion of all children
+ * of passed view document fragment. Those children {@link module:engine/view/node~Node view nodes} are then handled by other converters.
+ *
+ * This also a "default", last resort converter for all view elements that has not been converted by other converters.
+ * When a view element is being converted to the model but it does not have converter specified, that view element
+ * will be converted to {@link module:engine/model/documentfragment~DocumentFragment model document fragment} and returned.
+ *
+ * @returns {Function} Universal converter for view {@link module:engine/view/documentfragment~DocumentFragment fragments} and
+ * {@link module:engine/view/element~Element elements} that returns
+ * {@link module:engine/model/documentfragment~DocumentFragment model fragment} with children of converted view item.
+ */
+export function convertToModelFragment() {
+	return ( evt, data, conversionApi ) => {
+		// Second argument in `consumable.consume` is discarded for ViewDocumentFragment but is needed for ViewElement.
+		if ( !data.modelRange && conversionApi.consumable.consume( data.viewItem, { name: true } ) ) {
+			const { modelRange, modelCursor } = conversionApi.convertChildren( data.viewItem, data.modelCursor );
+
+			data.modelRange = modelRange;
+			data.modelCursor = modelCursor;
+		}
+	};
+}
+
+/**
+ * Function factory, creates a converter that converts {@link module:engine/view/text~Text} to {@link module:engine/model/text~Text}.
+ *
+ * @returns {Function} {@link module:engine/view/text~Text View text} converter.
+ */
+export function convertText() {
+	return ( evt, data, conversionApi ) => {
+		if ( conversionApi.schema.checkChild( data.modelCursor, '$text' ) ) {
+			if ( conversionApi.consumable.consume( data.viewItem ) ) {
+				const text = conversionApi.writer.createText( data.viewItem.data );
+
+				conversionApi.writer.insert( text, data.modelCursor );
+
+				data.modelRange = ModelRange._createFromPositionAndShift( data.modelCursor, text.offsetSize );
+				data.modelCursor = data.modelRange.end;
+			}
+		}
+	};
+}
+
+/**
+ * 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.
+//
+// @param {Object} config Conversion configuration.
+// @param {module:engine/view/matcher~MatcherPattern} config.view Pattern matching all view elements which should be converted.
+// @param {String|module:engine/model/element~Element|Function} config.model Name of the model element, a model element
+// instance or a function that takes a view element and returns a model element. The model element will be inserted in the model.
+// @param {module:utils/priorities~PriorityString} [config.converterPriority='normal'] Converter priority.
+// @returns {Function} Conversion helper.
+function upcastElementToElement( config ) {
+	config = cloneDeep( config );
+
+	const converter = prepareToElementConverter( config );
+
+	const elementName = getViewElementNameFromConfig( config );
+	const eventName = elementName ? 'element:' + elementName : 'element';
+
+	return dispatcher => {
+		dispatcher.on( eventName, converter, { priority: config.converterPriority || 'normal' } );
+	};
+}
+
+// View element to model attribute conversion helper.
+//
+// See {@link ~UpcastHelpers#elementToAttribute `.elementToAttribute()` upcast helper} for examples.
+//
+// @param {Object} config Conversion configuration.
+// @param {module:engine/view/matcher~MatcherPattern} config.view Pattern matching all view elements which should be converted.
+// @param {String|Object} config.model Model attribute key or an object with `key` and `value` properties, describing
+// the model attribute. `value` property may be set as a function that takes a view element and returns the value.
+// If `String` is given, the model attribute value will be set to `true`.
+// @param {module:utils/priorities~PriorityString} [config.converterPriority='normal'] Converter priority.
+// @returns {Function} Conversion helper.
+function upcastElementToAttribute( config ) {
+	config = cloneDeep( config );
+
+	normalizeModelAttributeConfig( config );
+
+	const converter = prepareToAttributeConverter( config, false );
+
+	const elementName = getViewElementNameFromConfig( config );
+	const eventName = elementName ? 'element:' + elementName : 'element';
+
+	return dispatcher => {
+		dispatcher.on( eventName, converter, { priority: config.converterPriority || 'low' } );
+	};
+}
+
+// View attribute to model attribute conversion helper.
+//
+// See {@link ~UpcastHelpers#attributeToAttribute `.attributeToAttribute()` upcast helper} for examples.
+//
+// @param {Object} config Conversion configuration.
+// @param {String|Object} config.view Specifies which view attribute will be converted. If a `String` is passed,
+// attributes with given key will be converted. If an `Object` is passed, it must have a required `key` property,
+// specifying view attribute key, and may have an optional `value` property, specifying view attribute value and optional `name`
+// property specifying a view element name from/on which the attribute should be converted. `value` can be given as a `String`,
+// a `RegExp` or a function callback, that takes view attribute value as the only parameter and returns `Boolean`.
+// @param {String|Object} config.model Model attribute key or an object with `key` and `value` properties, describing
+// the model attribute. `value` property may be set as a function that takes a view element and returns the value.
+// If `String` is given, the model attribute value will be same as view attribute value.
+// @param {module:utils/priorities~PriorityString} [config.converterPriority='low'] Converter priority.
+// @returns {Function} Conversion helper.
+function upcastAttributeToAttribute( config ) {
+	config = cloneDeep( config );
+
+	let viewKey = null;
+
+	if ( typeof config.view == 'string' || config.view.key ) {
+		viewKey = normalizeViewAttributeKeyValueConfig( config );
+	}
+
+	normalizeModelAttributeConfig( config, viewKey );
+
+	const converter = prepareToAttributeConverter( config, true );
+
+	return dispatcher => {
+		dispatcher.on( 'element', converter, { priority: config.converterPriority || 'low' } );
+	};
+}
+
+// View element to model marker conversion helper.
+//
+// See {@link ~UpcastHelpers#elementToMarker `.elementToMarker()` upcast helper} for examples.
+//
+// @param {Object} config Conversion configuration.
+// @param {module:engine/view/matcher~MatcherPattern} config.view Pattern matching all view elements which should be converted.
+// @param {String|Function} config.model Name of the model marker, or a function that takes a view element and returns
+// a model marker name.
+// @param {module:utils/priorities~PriorityString} [config.converterPriority='normal'] Converter priority.
+// @returns {Function} Conversion helper.
+function upcastElementToMarker( config ) {
+	config = cloneDeep( config );
+
+	normalizeToMarkerConfig( config );
+
+	return upcastElementToElement( config );
+}
+
+// Helper function for from-view-element conversion. Checks if `config.view` directly specifies converted view element's name
+// and if so, returns it.
+//
+// @param {Object} config Conversion config.
+// @returns {String|null} View element name or `null` if name is not directly set.
+function getViewElementNameFromConfig( config ) {
+	if ( typeof config.view == 'string' ) {
+		return config.view;
+	}
+
+	if ( typeof config.view == 'object' && typeof config.view.name == 'string' ) {
+		return config.view.name;
+	}
+
+	return null;
+}
+
+// Helper for to-model-element conversion. Takes a config object and returns a proper converter function.
+//
+// @param {Object} config Conversion configuration.
+// @returns {Function} View to model converter.
+function prepareToElementConverter( config ) {
+	const matcher = new Matcher( config.view );
+
+	return ( evt, data, conversionApi ) => {
+		// This will be usually just one pattern but we support matchers with many patterns too.
+		const match = matcher.match( data.viewItem );
+
+		// If there is no match, this callback should not do anything.
+		if ( !match ) {
+			return;
+		}
+
+		// Force consuming element's name.
+		match.match.name = true;
+
+		// Create model element basing on config.
+		const modelElement = getModelElement( config.model, data.viewItem, conversionApi.writer );
+
+		// Do not convert if element building function returned falsy value.
+		if ( !modelElement ) {
+			return;
+		}
+
+		// When element was already consumed then skip it.
+		if ( !conversionApi.consumable.test( data.viewItem, match.match ) ) {
+			return;
+		}
+
+		// Find allowed parent for element that we are going to insert.
+		// If current parent does not allow to insert element but one of the ancestors does
+		// then split nodes to allowed parent.
+		const splitResult = conversionApi.splitToAllowedParent( modelElement, data.modelCursor );
+
+		// When there is no split result it means that we can't insert element to model tree, so let's skip it.
+		if ( !splitResult ) {
+			return;
+		}
+
+		// Insert element on allowed position.
+		conversionApi.writer.insert( modelElement, splitResult.position );
+
+		// Convert children and insert to element.
+		const childrenResult = conversionApi.convertChildren( data.viewItem, conversionApi.writer.createPositionAt( modelElement, 0 ) );
+
+		// Consume appropriate value from consumable values list.
+		conversionApi.consumable.consume( data.viewItem, match.match );
+
+		// Set conversion result range.
+		data.modelRange = new ModelRange(
+			// Range should start before inserted element
+			conversionApi.writer.createPositionBefore( modelElement ),
+			// Should end after but we need to take into consideration that children could split our
+			// element, so we need to move range after parent of the last converted child.
+			// before: <allowed>[]</allowed>
+			// after: <allowed>[<converted><child></child></converted><child></child><converted>]</converted></allowed>
+			conversionApi.writer.createPositionAfter( childrenResult.modelCursor.parent )
+		);
+
+		// Now we need to check where the modelCursor should be.
+		// If we had to split parent to insert our element then we want to continue conversion inside split parent.
+		//
+		// before: <allowed><notAllowed>[]</notAllowed></allowed>
+		// after:  <allowed><notAllowed></notAllowed><converted></converted><notAllowed>[]</notAllowed></allowed>
+		if ( splitResult.cursorParent ) {
+			data.modelCursor = conversionApi.writer.createPositionAt( splitResult.cursorParent, 0 );
+
+			// Otherwise just continue after inserted element.
+		} else {
+			data.modelCursor = data.modelRange.end;
+		}
+	};
+}
+
+// Helper function for upcasting-to-element converter. Takes the model configuration, the converted view element
+// and a writer instance and returns a model element instance to be inserted in the model.
+//
+// @param {String|Function|module:engine/model/element~Element} model Model conversion configuration.
+// @param {module:engine/view/node~Node} input The converted view node.
+// @param {module:engine/model/writer~Writer} writer A writer instance to use to create the model element.
+function getModelElement( model, input, writer ) {
+	if ( model instanceof Function ) {
+		return model( input, writer );
+	} else {
+		return writer.createElement( model );
+	}
+}
+
+// Helper function view-attribute-to-model-attribute helper. Normalizes `config.view` which was set as `String` or
+// as an `Object` with `key`, `value` and `name` properties. Normalized `config.view` has is compatible with
+// {@link module:engine/view/matcher~MatcherPattern}.
+//
+// @param {Object} config Conversion config.
+// @returns {String} Key of the converted view attribute.
+function normalizeViewAttributeKeyValueConfig( config ) {
+	if ( typeof config.view == 'string' ) {
+		config.view = { key: config.view };
+	}
+
+	const key = config.view.key;
+	let normalized;
+
+	if ( key == 'class' || key == 'style' ) {
+		const keyName = key == 'class' ? 'classes' : 'styles';
+
+		normalized = {
+			[ keyName ]: config.view.value
+		};
+	} else {
+		const value = typeof config.view.value == 'undefined' ? /[\s\S]*/ : config.view.value;
+
+		normalized = {
+			attributes: {
+				[ key ]: value
+			}
+		};
+	}
+
+	if ( config.view.name ) {
+		normalized.name = config.view.name;
+	}
+
+	config.view = normalized;
+
+	return key;
+}
+
+// Helper function that normalizes `config.model` in from-model-attribute conversion. `config.model` can be set
+// as a `String`, an `Object` with only `key` property or an `Object` with `key` and `value` properties. Normalized
+// `config.model` is an `Object` with `key` and `value` properties.
+//
+// @param {Object} config Conversion config.
+// @param {String} viewAttributeKeyToCopy Key of the converted view attribute. If it is set, model attribute value
+// will be equal to view attribute value.
+function normalizeModelAttributeConfig( config, viewAttributeKeyToCopy = null ) {
+	const defaultModelValue = viewAttributeKeyToCopy === null ? true : viewElement => viewElement.getAttribute( viewAttributeKeyToCopy );
+
+	const key = typeof config.model != 'object' ? config.model : config.model.key;
+	const value = typeof config.model != 'object' || typeof config.model.value == 'undefined' ? defaultModelValue : config.model.value;
+
+	config.model = { key, value };
+}
+
+// Helper for to-model-attribute conversion. Takes the model attribute name and conversion configuration and returns
+// a proper converter function.
+//
+// @param {String} modelAttributeKey The key of the model attribute to set on a model node.
+// @param {Object|Array.<Object>} config Conversion configuration. It is possible to provide multiple configurations in an array.
+// @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.
+function prepareToAttributeConverter( config, shallow ) {
+	const matcher = new Matcher( config.view );
+
+	return ( evt, data, conversionApi ) => {
+		const match = matcher.match( data.viewItem );
+
+		// If there is no match, this callback should not do anything.
+		if ( !match ) {
+			return;
+		}
+
+		const modelKey = config.model.key;
+		const modelValue = typeof config.model.value == 'function' ? config.model.value( data.viewItem ) : config.model.value;
+
+		// Do not convert if attribute building function returned falsy value.
+		if ( modelValue === null ) {
+			return;
+		}
+
+		if ( onlyViewNameIsDefined( config ) ) {
+			match.match.name = true;
+		} else {
+			// Do not test or consume `name` consumable.
+			delete match.match.name;
+		}
+
+		// Try to consume appropriate values from consumable values list.
+		if ( !conversionApi.consumable.test( data.viewItem, match.match ) ) {
+			return;
+		}
+
+		// Since we are converting to attribute we need an range on which we will set the attribute.
+		// If the range is not created yet, we will create it.
+		if ( !data.modelRange ) {
+			// Convert children and set conversion result as a current data.
+			data = Object.assign( data, conversionApi.convertChildren( data.viewItem, data.modelCursor ) );
+		}
+
+		// Set attribute on current `output`. `Schema` is checked inside this helper function.
+		const attributeWasSet = setAttributeOn( data.modelRange, { key: modelKey, value: modelValue }, shallow, conversionApi );
+
+		if ( attributeWasSet ) {
+			conversionApi.consumable.consume( data.viewItem, match.match );
+		}
+	};
+}
+
+// Helper function that checks if element name should be consumed in attribute converters.
+//
+// @param {Object} config Conversion config.
+// @returns {Boolean}
+function onlyViewNameIsDefined( config ) {
+	if ( typeof config.view == 'object' && !getViewElementNameFromConfig( config ) ) {
+		return false;
+	}
+
+	return !config.view.classes && !config.view.attributes && !config.view.styles;
+}
+
+// Helper function for to-model-attribute converter. Sets model attribute on given range. Checks {@link module:engine/model/schema~Schema}
+// to ensure proper model structure.
+//
+// @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 {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`.
+function setAttributeOn( modelRange, modelAttribute, shallow, conversionApi ) {
+	let result = false;
+
+	// Set attribute on each item in range according to Schema.
+	for ( const node of Array.from( modelRange.getItems( { shallow } ) ) ) {
+		if ( conversionApi.schema.checkAttribute( node, modelAttribute.key ) ) {
+			conversionApi.writer.setAttribute( modelAttribute.key, modelAttribute.value, node );
+
+			result = true;
+		}
+	}
+
+	return result;
+}
+
+// Helper function for upcasting-to-marker conversion. Takes the config in a format requested by `upcastElementToMarker()`
+// function and converts it to a format that is supported by `_upcastElementToElement()` function.
+//
+// @param {Object} config Conversion configuration.
+function normalizeToMarkerConfig( config ) {
+	const oldModel = config.model;
+
+	config.model = ( viewElement, modelWriter ) => {
+		const markerName = typeof oldModel == 'string' ? oldModel : oldModel( viewElement );
+
+		return modelWriter.createElement( '$marker', { 'data-name': markerName } );
+	};
+}

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

@@ -29,10 +29,13 @@ import DowncastDispatcher from '../conversion/downcastdispatcher';
 import UpcastDispatcher from '../conversion/upcastdispatcher';
 import UpcastDispatcher from '../conversion/upcastdispatcher';
 import Mapper from '../conversion/mapper';
 import Mapper from '../conversion/mapper';
 import {
 import {
-	convertRangeSelection,
 	convertCollapsedSelection,
 	convertCollapsedSelection,
-} from '../conversion/downcast-selection-converters';
-import { insertText, insertElement, wrap, insertUIElement } from '../conversion/downcast-converters';
+	convertRangeSelection,
+	insertElement,
+	insertText,
+	insertUIElement,
+	wrap
+} from '../conversion/downcasthelpers';
 
 
 import { isPlainObject } from 'lodash-es';
 import { isPlainObject } from 'lodash-es';
 import toMap from '@ckeditor/ckeditor5-utils/src/tomap';
 import toMap from '@ckeditor/ckeditor5-utils/src/tomap';
@@ -231,6 +234,7 @@ export function stringify( node, selectionOrPositionOrRange = null, markers = nu
 
 
 		return new ViewContainerElement( modelItem.name, attributes );
 		return new ViewContainerElement( modelItem.name, attributes );
 	} ) );
 	} ) );
+
 	downcastDispatcher.on( 'selection', convertRangeSelection() );
 	downcastDispatcher.on( 'selection', convertRangeSelection() );
 	downcastDispatcher.on( 'selection', convertCollapsedSelection() );
 	downcastDispatcher.on( 'selection', convertCollapsedSelection() );
 	downcastDispatcher.on( 'addMarker', insertUIElement( ( data, writer ) => {
 	downcastDispatcher.on( 'addMarker', insertUIElement( ( data, writer ) => {
@@ -278,7 +282,6 @@ export function stringify( node, selectionOrPositionOrRange = null, markers = nu
  *
  *
  * @param {String} data HTML-like string to be parsed.
  * @param {String} data HTML-like string to be parsed.
  * @param {module:engine/model/schema~Schema} schema A schema instance used by converters for element validation.
  * @param {module:engine/model/schema~Schema} schema A schema instance used by converters for element validation.
- * @param {module:engine/model/batch~Batch} batch A batch used for conversion.
  * @param {Object} [options={}] Additional configuration.
  * @param {Object} [options={}] Additional configuration.
  * @param {Array<Object>} [options.selectionAttributes] A list of attributes which will be passed to the selection.
  * @param {Array<Object>} [options.selectionAttributes] A list of attributes which will be passed to the selection.
  * @param {Boolean} [options.lastRangeBackward=false] If set to `true`, the last range will be added as backward.
  * @param {Boolean} [options.lastRangeBackward=false] If set to `true`, the last range will be added as backward.

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

@@ -1053,7 +1053,7 @@ function _convertType( type ) {
 // Checks if a given priority is allowed. Returns null if the priority cannot be converted.
 // Checks if a given priority is allowed. Returns null if the priority cannot be converted.
 //
 //
 // @param {String} priorityString
 // @param {String} priorityString
-// returns {Number|Null}
+// returns {Number|null}
 function _convertPriority( priorityString ) {
 function _convertPriority( priorityString ) {
 	const priority = parseInt( priorityString, 10 );
 	const priority = parseInt( priorityString, 10 );
 
 

+ 23 - 2
packages/ckeditor5-engine/src/model/documentselection.js

@@ -247,12 +247,32 @@ export default class DocumentSelection {
 	 *		<paragraph>b</paragraph>
 	 *		<paragraph>b</paragraph>
 	 *		<paragraph>]c</paragraph> // this block will not be returned
 	 *		<paragraph>]c</paragraph> // this block will not be returned
 	 *
 	 *
-	 * @returns {Iterator.<module:engine/model/element~Element>}
+	 * @returns {Iterable.<module:engine/model/element~Element>}
 	 */
 	 */
 	getSelectedBlocks() {
 	getSelectedBlocks() {
 		return this._selection.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
 	 * 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.
 	 * one range in the selection, and that range contains exactly one element.
@@ -696,7 +716,8 @@ class LiveSelection extends Selection {
 			 * UID obtained from the {@link module:engine/model/writer~Writer#overrideSelectionGravity} to restore.
 			 * UID obtained from the {@link module:engine/model/writer~Writer#overrideSelectionGravity} to restore.
 			 *
 			 *
 			 * @error document-selection-gravity-wrong-restore
 			 * @error document-selection-gravity-wrong-restore
-			 * @param {String} uid The unique identifier returned by {@link #overrideGravity}.
+			 * @param {String} uid The unique identifier returned by
+			 * {@link module:engine/model/documentselection~DocumentSelection#_overrideGravity}.
 			 */
 			 */
 			throw new CKEditorError(
 			throw new CKEditorError(
 				'document-selection-gravity-wrong-restore: Attempting to restore the selection gravity for an unknown UID.',
 				'document-selection-gravity-wrong-restore: Attempting to restore the selection gravity for an unknown UID.',

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

@@ -230,7 +230,7 @@ export default class MarkerCollection {
 	 * Fired whenever marker is added, updated or removed from `MarkerCollection`.
 	 * Fired whenever marker is added, updated or removed from `MarkerCollection`.
 	 *
 	 *
 	 * @event update
 	 * @event update
-	 * @param {module:engine/model/markercollection~Marker} Updated Marker.
+	 * @param {module:engine/model/markercollection~Marker} marker Updated Marker.
 	 * @param {module:engine/model/range~Range|null} oldRange Marker range before the update. When is not defined it
 	 * @param {module:engine/model/range~Range|null} oldRange Marker range before the update. When is not defined it
 	 * means that marker is just added.
 	 * means that marker is just added.
 	 * @param {module:engine/model/range~Range|null} newRange Marker range after update. When is not defined it
 	 * @param {module:engine/model/range~Range|null} newRange Marker range after update. When is not defined it
@@ -300,9 +300,9 @@ mix( MarkerCollection, EmitterMixin );
  *
  *
  * Markers downcast happens on {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:addMarker} and
  * Markers downcast happens on {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:addMarker} and
  * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:removeMarker} events.
  * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:removeMarker} events.
- * Use {@link module:engine/conversion/downcast-converters downcast converters} or attach a custom converter to mentioned events.
+ * Use {@link module:engine/conversion/downcasthelpers downcast converters} or attach a custom converter to mentioned events.
  * For {@link module:engine/controller/datacontroller~DataController data pipeline}, marker should be downcasted to an element.
  * For {@link module:engine/controller/datacontroller~DataController data pipeline}, marker should be downcasted to an element.
- * Then, it can be upcasted back to a marker. Again, use {@link module:engine/conversion/upcast-converters upcast converters} or
+ * Then, it can be upcasted back to a marker. Again, use {@link module:engine/conversion/upcasthelpers upcast converters} or
  * attach a custom converter to {@link module:engine/conversion/upcastdispatcher~UpcastDispatcher#event:element}.
  * attach a custom converter to {@link module:engine/conversion/upcastdispatcher~UpcastDispatcher#event:element}.
  *
  *
  * Another upside of markers is that finding marked part of document is fast and easy. Using attributes to mark some nodes
  * Another upside of markers is that finding marked part of document is fast and easy. Using attributes to mark some nodes

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

@@ -99,7 +99,7 @@ export default class Node {
 	 * This means that model tree got broken.
 	 * This means that model tree got broken.
 	 *
 	 *
 	 * @readonly
 	 * @readonly
-	 * @type {Number|Null}
+	 * @type {Number|null}
 	 */
 	 */
 	get startOffset() {
 	get startOffset() {
 		let pos;
 		let pos;

+ 70 - 2
packages/ckeditor5-engine/src/model/operation/transform.js

@@ -515,6 +515,48 @@ class ContextFactory {
 
 
 				break;
 				break;
 			}
 			}
+
+			case MarkerOperation: {
+				const markerRange = opA.newRange;
+
+				if ( !markerRange ) {
+					return;
+				}
+
+				switch ( opB.constructor ) {
+					case MoveOperation: {
+						const movedRange = Range._createFromPositionAndShift( opB.sourcePosition, opB.howMany );
+
+						const affectedLeft = movedRange.containsPosition( markerRange.start ) ||
+							movedRange.start.isEqual( markerRange.start );
+
+						const affectedRight = movedRange.containsPosition( markerRange.end ) ||
+							movedRange.end.isEqual( markerRange.end );
+
+						if ( ( affectedLeft || affectedRight ) && !movedRange.containsRange( markerRange ) ) {
+							this._setRelation( opA, opB, {
+								side: affectedLeft ? 'left' : 'right',
+								offset: affectedLeft ? markerRange.start.offset : markerRange.end.offset
+							} );
+						}
+
+						break;
+					}
+
+					case MergeOperation: {
+						const wasInLeftElement = markerRange.start.isEqual( opB.targetPosition );
+						const wasInRightElement = markerRange.end.isEqual( opB.sourcePosition );
+
+						if ( wasInLeftElement || wasInRightElement ) {
+							this._setRelation( opA, opB, { wasInLeftElement, wasInRightElement } );
+						}
+
+						break;
+					}
+				}
+
+				break;
+			}
 		}
 		}
 	}
 	}
 
 
@@ -1068,24 +1110,49 @@ setTransformation( MarkerOperation, MergeOperation, ( a, b ) => {
 	return [ a ];
 	return [ a ];
 } );
 } );
 
 
-setTransformation( MarkerOperation, MoveOperation, ( a, b ) => {
+setTransformation( MarkerOperation, MoveOperation, ( a, b, context ) => {
 	if ( a.oldRange ) {
 	if ( a.oldRange ) {
 		a.oldRange = Range._createFromRanges( a.oldRange._getTransformedByMoveOperation( b ) );
 		a.oldRange = Range._createFromRanges( a.oldRange._getTransformedByMoveOperation( b ) );
 	}
 	}
 
 
 	if ( a.newRange ) {
 	if ( a.newRange ) {
+		if ( context.abRelation ) {
+			if ( context.abRelation.side == 'left' && b.targetPosition.isEqual( a.newRange.start ) ) {
+				a.newRange.start.offset = context.abRelation.offset;
+				a.newRange.end.offset += b.howMany;
+
+				return [ a ];
+			} else if ( context.abRelation.side == 'right' && b.targetPosition.isEqual( a.newRange.end ) ) {
+				a.newRange.end.offset = context.abRelation.offset;
+
+				return [ a ];
+			}
+		}
+
 		a.newRange = Range._createFromRanges( a.newRange._getTransformedByMoveOperation( b ) );
 		a.newRange = Range._createFromRanges( a.newRange._getTransformedByMoveOperation( b ) );
 	}
 	}
 
 
 	return [ a ];
 	return [ a ];
 } );
 } );
 
 
-setTransformation( MarkerOperation, SplitOperation, ( a, b ) => {
+setTransformation( MarkerOperation, SplitOperation, ( a, b, context ) => {
 	if ( a.oldRange ) {
 	if ( a.oldRange ) {
 		a.oldRange = a.oldRange._getTransformedBySplitOperation( b );
 		a.oldRange = a.oldRange._getTransformedBySplitOperation( b );
 	}
 	}
 
 
 	if ( a.newRange ) {
 	if ( a.newRange ) {
+		if ( context.abRelation ) {
+			if ( a.newRange.start.isEqual( b.splitPosition ) && !context.abRelation.wasInLeftElement ) {
+				a.newRange.start = Position._createAt( b.moveTargetPosition );
+			}
+
+			if ( a.newRange.end.isEqual( b.splitPosition ) && context.abRelation.wasInRightElement ) {
+				a.newRange.end = Position._createAt( b.moveTargetPosition );
+			}
+
+			return [ a ];
+		}
+
 		a.newRange = a.newRange._getTransformedBySplitOperation( b );
 		a.newRange = a.newRange._getTransformedBySplitOperation( b );
 	}
 	}
 
 
@@ -1719,6 +1786,7 @@ setTransformation( MoveOperation, MergeOperation, ( a, b, context ) => {
 				targetPositionPath.push( 0 );
 				targetPositionPath.push( 0 );
 
 
 				const splitNodesMoveTarget = new Position( gyMove.targetPosition.root, targetPositionPath );
 				const splitNodesMoveTarget = new Position( gyMove.targetPosition.root, targetPositionPath );
+				splitNodesMoveSource = splitNodesMoveSource._getTransformedByMove( gyMoveSource, gyMoveTarget, 1 );
 				const splitNodesMove = new MoveOperation( splitNodesMoveSource, b.howMany, splitNodesMoveTarget, 0 );
 				const splitNodesMove = new MoveOperation( splitNodesMoveSource, b.howMany, splitNodesMoveTarget, 0 );
 
 
 				results.push( gyMove );
 				results.push( gyMove );

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

@@ -562,6 +562,21 @@ export default class Range {
 	 * @returns {module:engine/model/range~Range}
 	 * @returns {module:engine/model/range~Range}
 	 */
 	 */
 	_getTransformedByMergeOperation( operation ) {
 	_getTransformedByMergeOperation( operation ) {
+		// Special case when the marker is set on "the closing tag" of an element. Marker can be set like that during
+		// transformations, especially when a content of a few block elements were removed. For example:
+		//
+		// {} is the transformed range, [] is the removed range.
+		// <p>F[o{o</p><p>B}ar</p><p>Xy]z</p>
+		//
+		// <p>Fo{o</p><p>B}ar</p><p>z</p>
+		// <p>F{</p><p>B}ar</p><p>z</p>
+		// <p>F{</p>}<p>z</p>
+		// <p>F{}z</p>
+		//
+		if ( this.start.isEqual( operation.targetPosition ) && this.end.isEqual( operation.deletionPosition ) ) {
+			return new Range( this.start );
+		}
+
 		let start = this.start._getTransformedByMergeOperation( operation );
 		let start = this.start._getTransformedByMergeOperation( operation );
 		let end = this.end._getTransformedByMergeOperation( operation );
 		let end = this.end._getTransformedByMergeOperation( operation );
 
 

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

+ 47 - 0
packages/ckeditor5-engine/src/model/selection.js

@@ -672,6 +672,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
 	 * 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
 	 * at a position {@link module:engine/model/position~Position#isTouching touching} the element's start and ends at position
@@ -802,3 +831,21 @@ function getParentBlock( position, visited ) {
 
 
 	return block;
 	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;
+	}
+}

+ 2 - 1
packages/ckeditor5-engine/src/model/text.js

@@ -55,7 +55,8 @@ export default class Text extends Node {
 	/**
 	/**
 	 * Returns a text data contained in the node.
 	 * Returns a text data contained in the node.
 	 *
 	 *
-	 * @returns {String}
+	 * @readonly
+	 * @type {String}
 	 */
 	 */
 	get data() {
 	get data() {
 		return this._data;
 		return this._data;

+ 47 - 18
packages/ckeditor5-engine/src/model/utils/selection-post-fixer.js

@@ -183,11 +183,19 @@ function tryFixingNonCollapsedRage( range, schema ) {
 		// - [<block>foo</block>]    ->    <block>[foo]</block>
 		// - [<block>foo</block>]    ->    <block>[foo]</block>
 		// - [<block>foo]</block>    ->    <block>[foo]</block>
 		// - [<block>foo]</block>    ->    <block>[foo]</block>
 		// - <block>f[oo</block>]    ->    <block>f[oo]</block>
 		// - <block>f[oo</block>]    ->    <block>f[oo]</block>
+		// - [<block>foo</block><object></object>]    ->    <block>[foo</block><object></object>]
 		if ( checkSelectionOnNonLimitElements( start, end, schema ) ) {
 		if ( checkSelectionOnNonLimitElements( start, end, schema ) ) {
-			const fixedStart = schema.getNearestSelectionRange( start, 'forward' );
-			const fixedEnd = schema.getNearestSelectionRange( end, 'backward' );
+			const isStartObject = start.nodeAfter && schema.isObject( start.nodeAfter );
+			const fixedStart = isStartObject ? null : schema.getNearestSelectionRange( start, 'forward' );
 
 
-			return new Range( fixedStart ? fixedStart.start : start, fixedEnd ? fixedEnd.start : end );
+			const isEndObject = end.nodeBefore && schema.isObject( end.nodeBefore );
+			const fixedEnd = isEndObject ? null : schema.getNearestSelectionRange( end, 'backward' );
+
+			// The schema.getNearestSelectionRange might return null - if that happens use original position.
+			const rangeStart = fixedStart ? fixedStart.start : start;
+			const rangeEnd = fixedEnd ? fixedEnd.start : end;
+
+			return new Range( rangeStart, rangeEnd );
 		}
 		}
 	}
 	}
 
 
@@ -197,13 +205,23 @@ function tryFixingNonCollapsedRage( range, schema ) {
 	// At this point we eliminated valid positions on text nodes so if one of range positions is placed inside a limit element
 	// At this point we eliminated valid positions on text nodes so if one of range positions is placed inside a limit element
 	// then the range crossed limit element boundaries and needs to be fixed.
 	// then the range crossed limit element boundaries and needs to be fixed.
 	if ( isStartInLimit || isEndInLimit ) {
 	if ( isStartInLimit || isEndInLimit ) {
+		const bothInSameParent = ( start.nodeAfter && end.nodeBefore ) && start.nodeAfter.parent === end.nodeBefore.parent;
+
+		const expandStart = isStartInLimit && ( !bothInSameParent || !isInObject( start.nodeAfter, schema ) );
+		const expandEnd = isEndInLimit && ( !bothInSameParent || !isInObject( end.nodeBefore, schema ) );
+
 		// Although we've already found limit element on start/end positions we must find the outer-most limit element.
 		// Although we've already found limit element on start/end positions we must find the outer-most limit element.
 		// as limit elements might be nested directly inside (ie table > tableRow > tableCell).
 		// as limit elements might be nested directly inside (ie table > tableRow > tableCell).
-		const startPosition = Position._createAt( startLimitElement, 0 );
-		const endPosition = Position._createAt( endLimitElement, 0 );
+		let fixedStart = start;
+		let fixedEnd = end;
+
+		if ( expandStart ) {
+			fixedStart = Position._createBefore( findOutermostLimitAncestor( startLimitElement, schema ) );
+		}
 
 
-		const fixedStart = isStartInLimit ? expandSelectionOnIsLimitNode( startPosition, schema, 'start' ) : start;
-		const fixedEnd = isEndInLimit ? expandSelectionOnIsLimitNode( endPosition, schema, 'end' ) : end;
+		if ( expandEnd ) {
+			fixedEnd = Position._createAfter( findOutermostLimitAncestor( endLimitElement, schema ) );
+		}
 
 
 		return new Range( fixedStart, fixedEnd );
 		return new Range( fixedStart, fixedEnd );
 	}
 	}
@@ -212,34 +230,45 @@ function tryFixingNonCollapsedRage( range, schema ) {
 	return null;
 	return null;
 }
 }
 
 
-// Expands selection so it contains whole limit node.
+// Finds the outer-most ancestor.
 //
 //
-// @param {module:engine/model/position~Position} position
+// @param {module:engine/model/node~Node} startingNode
 // @param {module:engine/model/schema~Schema} schema
 // @param {module:engine/model/schema~Schema} schema
 // @param {String} expandToDirection Direction of expansion - either 'start' or 'end' of the range.
 // @param {String} expandToDirection Direction of expansion - either 'start' or 'end' of the range.
-// @returns {module:engine/model/position~Position}
-function expandSelectionOnIsLimitNode( position, schema, expandToDirection ) {
-	let node = position.parent;
-	let parent = node;
+// @returns {module:engine/model/node~Node}
+function findOutermostLimitAncestor( startingNode, schema ) {
+	let isLimitNode = startingNode;
+	let parent = isLimitNode;
 
 
 	// Find outer most isLimit block as such blocks might be nested (ie. in tables).
 	// Find outer most isLimit block as such blocks might be nested (ie. in tables).
 	while ( schema.isLimit( parent ) && parent.parent ) {
 	while ( schema.isLimit( parent ) && parent.parent ) {
-		node = parent;
+		isLimitNode = parent;
 		parent = parent.parent;
 		parent = parent.parent;
 	}
 	}
 
 
-	// Depending on direction of expanding selection return position before or after found node.
-	return expandToDirection === 'start' ? Position._createBefore( node ) : Position._createAfter( node );
+	return isLimitNode;
 }
 }
 
 
-// Checks whether both range ends are placed around non-limit elements.
+// Checks whether any of range boundaries is placed around non-limit elements.
 //
 //
 // @param {module:engine/model/position~Position} start
 // @param {module:engine/model/position~Position} start
 // @param {module:engine/model/position~Position} end
 // @param {module:engine/model/position~Position} end
 // @param {module:engine/model/schema~Schema} schema
 // @param {module:engine/model/schema~Schema} schema
+// @returns {Boolean}
 function checkSelectionOnNonLimitElements( start, end, schema ) {
 function checkSelectionOnNonLimitElements( start, end, schema ) {
 	const startIsOnBlock = ( start.nodeAfter && !schema.isLimit( start.nodeAfter ) ) || schema.checkChild( start, '$text' );
 	const startIsOnBlock = ( start.nodeAfter && !schema.isLimit( start.nodeAfter ) ) || schema.checkChild( start, '$text' );
 	const endIsOnBlock = ( end.nodeBefore && !schema.isLimit( end.nodeBefore ) ) || schema.checkChild( end, '$text' );
 	const endIsOnBlock = ( end.nodeBefore && !schema.isLimit( end.nodeBefore ) ) || schema.checkChild( end, '$text' );
 
 
-	return startIsOnBlock && endIsOnBlock;
+	// We should fix such selection when one of those nodes needs fixing.
+	return startIsOnBlock || endIsOnBlock;
+}
+
+// Checks if node exists and if it's an object.
+//
+// @param {module:engine/model/node~Node} node
+// @param {module:engine/model/schema~Schema} schema
+// @returns {Boolean}
+function isInObject( node, schema ) {
+	return node && schema.isObject( node );
 }
 }
+

+ 69 - 12
packages/ckeditor5-engine/src/model/writer.js

@@ -159,6 +159,10 @@ export default class Writer {
 	insert( item, itemOrPosition, offset = 0 ) {
 	insert( item, itemOrPosition, offset = 0 ) {
 		this._assertWriterUsedCorrectly();
 		this._assertWriterUsedCorrectly();
 
 
+		if ( item instanceof Text && item.data == '' ) {
+			return;
+		}
+
 		const position = Position._createAt( itemOrPosition, offset );
 		const position = Position._createAt( itemOrPosition, offset );
 
 
 		// If item has a parent already.
 		// If item has a parent already.
@@ -466,6 +470,14 @@ export default class Writer {
 
 
 		const position = Position._createAt( itemOrPosition, offset );
 		const position = Position._createAt( itemOrPosition, offset );
 
 
+		// Do not move anything if the move target is same as moved range start.
+		if ( position.isEqual( range.start ) ) {
+			return;
+		}
+
+		// If part of the marker is removed, create additional marker operation for undo purposes.
+		this._addOperationForAffectedMarkers( 'move', range );
+
 		if ( !isSameTree( range.root, position.root ) ) {
 		if ( !isSameTree( range.root, position.root ) ) {
 			/**
 			/**
 			 * Range is going to be moved within not the same document. Please use
 			 * Range is going to be moved within not the same document. Please use
@@ -491,17 +503,15 @@ export default class Writer {
 	remove( itemOrRange ) {
 	remove( itemOrRange ) {
 		this._assertWriterUsedCorrectly();
 		this._assertWriterUsedCorrectly();
 
 
-		if ( itemOrRange instanceof Range ) {
-			// The array is reversed, so the ranges to remove are in correct order and do not have to be updated.
-			const ranges = itemOrRange.getMinimalFlatRanges().reverse();
+		const rangeToRemove = itemOrRange instanceof Range ? itemOrRange : Range._createOn( itemOrRange );
 
 
-			for ( const flat of ranges ) {
-				applyRemoveOperation( flat.start, flat.end.offset - flat.start.offset, this.batch, this.model );
-			}
-		} else {
-			const howMany = itemOrRange.is( 'text' ) ? itemOrRange.offsetSize : 1;
+		// If part of the marker is removed, create additional marker operation for undo purposes.
+		this._addOperationForAffectedMarkers( 'move', rangeToRemove );
 
 
-			applyRemoveOperation( Position._createBefore( itemOrRange ), howMany, this.batch, this.model );
+		const ranges = rangeToRemove.getMinimalFlatRanges().reverse();
+
+		for ( const flat of ranges ) {
+			applyRemoveOperation( flat.start, flat.end.offset - flat.start.offset, this.batch, this.model );
 		}
 		}
 	}
 	}
 
 
@@ -519,6 +529,9 @@ export default class Writer {
 		const nodeBefore = position.nodeBefore;
 		const nodeBefore = position.nodeBefore;
 		const nodeAfter = position.nodeAfter;
 		const nodeAfter = position.nodeAfter;
 
 
+		// If part of the marker is removed, create additional marker operation for undo purposes.
+		this._addOperationForAffectedMarkers( 'merge', position );
+
 		if ( !( nodeBefore instanceof Element ) ) {
 		if ( !( nodeBefore instanceof Element ) ) {
 			/**
 			/**
 			 * Node before merge position must be an element.
 			 * Node before merge position must be an element.
@@ -888,12 +901,12 @@ export default class Writer {
 
 
 		if ( !options || typeof options.usingOperation != 'boolean' ) {
 		if ( !options || typeof options.usingOperation != 'boolean' ) {
 			/**
 			/**
-			 * The `options.usingOperations` parameter is required when adding a new marker.
+			 * The `options.usingOperation` parameter is required when adding a new marker.
 			 *
 			 *
-			 * @error writer-addMarker-no-usingOperations
+			 * @error writer-addMarker-no-usingOperation
 			 */
 			 */
 			throw new CKEditorError(
 			throw new CKEditorError(
-				'writer-addMarker-no-usingOperations: The options.usingOperations parameter is required when adding a new marker.'
+				'writer-addMarker-no-usingOperation: The options.usingOperation parameter is required when adding a new marker.'
 			);
 			);
 		}
 		}
 
 
@@ -1294,6 +1307,50 @@ export default class Writer {
 			throw new CKEditorError( 'writer-incorrect-use: Trying to use a writer outside the change() block.' );
 			throw new CKEditorError( 'writer-incorrect-use: Trying to use a writer outside the change() block.' );
 		}
 		}
 	}
 	}
+
+	/**
+	 * For given action `type` and `positionOrRange` where the action happens, this function finds all affected markers
+	 * and applies a marker operation with the new marker range equal to the current range. Thanks to this, the marker range
+	 * can be later correctly processed during undo.
+	 *
+	 * @private
+	 * @param {'move'|'merge'} type Writer action type.
+	 * @param {module:engine/model/position~Position|module:engine/model/range~Range} positionOrRange Position or range
+	 * where the writer action happens.
+	 */
+	_addOperationForAffectedMarkers( type, positionOrRange ) {
+		for ( const marker of this.model.markers ) {
+			if ( !marker.managedUsingOperations ) {
+				continue;
+			}
+
+			const markerRange = marker.getRange();
+			let isAffected = false;
+
+			if ( type == 'move' ) {
+				const intersecting =
+					positionOrRange.containsPosition( markerRange.start ) ||
+					positionOrRange.start.isEqual( markerRange.start ) ||
+					positionOrRange.containsPosition( markerRange.end ) ||
+					positionOrRange.end.isEqual( markerRange.end );
+
+				isAffected = intersecting && !positionOrRange.containsRange( markerRange );
+			} else {
+				// if type == 'merge'.
+				const elementBefore = positionOrRange.nodeBefore;
+				const elementAfter = positionOrRange.nodeAfter;
+
+				const affectedOnLeft = markerRange.start.parent == elementBefore && markerRange.start.isAtEnd;
+				const affectedOnRight = markerRange.end.parent == elementAfter && markerRange.end.offset == 0;
+
+				isAffected = affectedOnLeft || affectedOnRight;
+			}
+
+			if ( isAffected ) {
+				this.updateMarker( marker.name, { range: markerRange } );
+			}
+		}
+	}
 }
 }
 
 
 // Sets given attribute to each node in given range. When attribute value is null then attribute will be removed.
 // Sets given attribute to each node in given range. When attribute value is null then attribute will be removed.

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

@@ -69,7 +69,7 @@ export default class AttributeElement extends Element {
 		 * This property is managed by {@link module:engine/view/downcastwriter~DowncastWriter}.
 		 * This property is managed by {@link module:engine/view/downcastwriter~DowncastWriter}.
 		 *
 		 *
 		 * @protected
 		 * @protected
-		 * @member {Set|null}
+		 * @member {Set.<module:engine/view/attributeelement~AttributeElement>|null}
 		 */
 		 */
 		this._clonesGroup = null;
 		this._clonesGroup = null;
 	}
 	}
@@ -78,7 +78,7 @@ export default class AttributeElement extends Element {
 	 * Element priority. Decides in what order elements are wrapped by {@link module:engine/view/downcastwriter~DowncastWriter}.
 	 * Element priority. Decides in what order elements are wrapped by {@link module:engine/view/downcastwriter~DowncastWriter}.
 	 *
 	 *
 	 * @readonly
 	 * @readonly
-	 * @returns {Number}
+	 * @type {Number}
 	 */
 	 */
 	get priority() {
 	get priority() {
 		return this._priority;
 		return this._priority;
@@ -89,7 +89,7 @@ export default class AttributeElement extends Element {
 	 * and then two elements are considered similar if, and only if they have the same `id`.
 	 * and then two elements are considered similar if, and only if they have the same `id`.
 	 *
 	 *
 	 * @readonly
 	 * @readonly
-	 * @returns {String|Number}
+	 * @type {String|Number}
 	 */
 	 */
 	get id() {
 	get id() {
 		return this._id;
 		return this._id;

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

@@ -40,7 +40,7 @@ export default class Document {
 		 * model root using {@link module:engine/model/document~Document#createRoot}.
 		 * model root using {@link module:engine/model/document~Document#createRoot}.
 		 *
 		 *
 		 * @readonly
 		 * @readonly
-		 * @member {Collection} module:engine/view/document~Document#roots
+		 * @member {module:utils/collection~Collection} module:engine/view/document~Document#roots
 		 */
 		 */
 		this.roots = new Collection( { idProperty: 'rootName' } );
 		this.roots = new Collection( { idProperty: 'rootName' } );
 
 

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

@@ -106,7 +106,7 @@ export default class Element extends Node {
 		 * Map of styles.
 		 * Map of styles.
 		 *
 		 *
 		 * @protected
 		 * @protected
-		 * @member {Set} module:engine/view/element~Element#_styles
+		 * @member {Map} module:engine/view/element~Element#_styles
 		 */
 		 */
 		this._styles = new Map();
 		this._styles = new Map();
 
 
@@ -121,7 +121,7 @@ export default class Element extends Node {
 		 * Custom properties can be added to element instance, will be cloned but not rendered into DOM.
 		 * Custom properties can be added to element instance, will be cloned but not rendered into DOM.
 		 *
 		 *
 		 * @protected
 		 * @protected
-		 * @memeber {Map}
+		 * @member {Map}
 		 */
 		 */
 		this._customProperties = new Map();
 		this._customProperties = new Map();
 	}
 	}

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

@@ -16,6 +16,8 @@ import Node from './node';
  *
  *
  * To create a new empty element use the
  * To create a new empty element use the
  * {@link module:engine/view/downcastwriter~DowncastWriter#createEmptyElement `downcastWriter#createEmptyElement()`} method.
  * {@link module:engine/view/downcastwriter~DowncastWriter#createEmptyElement `downcastWriter#createEmptyElement()`} method.
+ *
+ * @extends module:engine/view/element~Element
  */
  */
 export default class EmptyElement extends Element {
 export default class EmptyElement extends Element {
 	/**
 	/**

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

@@ -37,7 +37,7 @@ import isText from '@ckeditor/ckeditor5-utils/src/dom/istext';
  */
  */
 
 
 /**
 /**
- * `<br> filler creator. This is a function which creates `<br data-cke-filler="true">` element.
+ * `<br>` filler creator. This is a function which creates `<br data-cke-filler="true">` element.
  * It defines how the filler is created.
  * It defines how the filler is created.
  *
  *
  * @see module:engine/view/filler~NBSP_FILLER
  * @see module:engine/view/filler~NBSP_FILLER

+ 2 - 2
packages/ckeditor5-engine/src/view/matcher.js

@@ -364,7 +364,7 @@ function matchStyles( patterns, element ) {
  *
  *
  *		// Match view element which has matching attributes.
  *		// Match view element which has matching attributes.
  *		const pattern = {
  *		const pattern = {
- *			attribute: {
+ *			attributes: {
  *				title: 'foobar',	// Attribute title should equal 'foobar'.
  *				title: 'foobar',	// Attribute title should equal 'foobar'.
  *				foo: /^\w+/,		// Attribute foo should match /^\w+/ regexp.
  *				foo: /^\w+/,		// Attribute foo should match /^\w+/ regexp.
  *				bar: true			// Attribute bar should be set (can be empty).
  *				bar: true			// Attribute bar should be set (can be empty).
@@ -384,7 +384,7 @@ function matchStyles( patterns, element ) {
  *		// Multiple classes to match.
  *		// Multiple classes to match.
  *		const pattern = {
  *		const pattern = {
  *			classes: [ 'baz', 'bar', /foo.../ ]
  *			classes: [ 'baz', 'bar', /foo.../ ]
- *		}:
+ *		};
  *
  *
  *		// Match view element which has given styles.
  *		// Match view element which has given styles.
  *		const pattern = {
  *		const pattern = {

+ 3 - 2
packages/ckeditor5-engine/src/view/text.js

@@ -51,7 +51,8 @@ export default class Text extends Node {
 	/**
 	/**
 	 * The text content.
 	 * The text content.
 	 *
 	 *
-	 * @returns {String}
+	 * @readonly
+	 * @type {String}
 	 */
 	 */
 	get data() {
 	get data() {
 		return this._textData;
 		return this._textData;
@@ -69,7 +70,7 @@ export default class Text extends Node {
 	 * If the protected getter didn't exist, `foo._data` will return `undefined` and result of the merge will be invalid.
 	 * If the protected getter didn't exist, `foo._data` will return `undefined` and result of the merge will be invalid.
 	 *
 	 *
 	 * @protected
 	 * @protected
-	 * @returns {String}
+	 * @type {String}
 	 */
 	 */
 	get _data() {
 	get _data() {
 		return this.data;
 		return this.data;

+ 4 - 1
packages/ckeditor5-engine/src/view/textproxy.js

@@ -85,7 +85,10 @@ export default class TextProxy {
 	}
 	}
 
 
 	/**
 	/**
-	 * @inheritDoc
+	 * Offset size of this node.
+	 *
+	 * @readonly
+	 * @type {Number}
 	 */
 	 */
 	get offsetSize() {
 	get offsetSize() {
 		return this.data.length;
 		return this.data.length;

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

@@ -29,6 +29,8 @@ import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
  *
  *
  * To create a new UI element use the
  * To create a new UI element use the
  * {@link module:engine/view/downcastwriter~DowncastWriter#createUIElement `downcastWriter#createUIElement()`} method.
  * {@link module:engine/view/downcastwriter~DowncastWriter#createUIElement `downcastWriter#createUIElement()`} method.
+ *
+ * @extends module:engine/view/element~Element
  */
  */
 export default class UIElement extends Element {
 export default class UIElement extends Element {
 	/**
 	/**

+ 24 - 29
packages/ckeditor5-engine/tests/controller/datacontroller.js

@@ -18,19 +18,11 @@ import { parse as parseView, stringify as stringifyView } from '../../src/dev-ut
 
 
 import count from '@ckeditor/ckeditor5-utils/src/count';
 import count from '@ckeditor/ckeditor5-utils/src/count';
 
 
-import {
-	upcastElementToElement,
-	upcastElementToAttribute
-} from '../../src/conversion/upcast-converters';
-
-import {
-	downcastElementToElement,
-	downcastAttributeToElement,
-	downcastMarkerToHighlight
-} from '../../src/conversion/downcast-converters';
+import UpcastHelpers from '../../src/conversion/upcasthelpers';
+import DowncastHelpers from '../../src/conversion/downcasthelpers';
 
 
 describe( 'DataController', () => {
 describe( 'DataController', () => {
-	let model, modelDocument, htmlDataProcessor, data, schema;
+	let model, modelDocument, htmlDataProcessor, data, schema, upcastHelpers, downcastHelpers;
 
 
 	beforeEach( () => {
 	beforeEach( () => {
 		model = new Model();
 		model = new Model();
@@ -46,6 +38,9 @@ describe( 'DataController', () => {
 		htmlDataProcessor = new HtmlDataProcessor();
 		htmlDataProcessor = new HtmlDataProcessor();
 
 
 		data = new DataController( model, htmlDataProcessor );
 		data = new DataController( model, htmlDataProcessor );
+
+		upcastHelpers = new UpcastHelpers( data.upcastDispatcher );
+		downcastHelpers = new DowncastHelpers( data.downcastDispatcher );
 	} );
 	} );
 
 
 	describe( 'constructor()', () => {
 	describe( 'constructor()', () => {
@@ -68,7 +63,7 @@ describe( 'DataController', () => {
 		it( 'should set paragraph', () => {
 		it( 'should set paragraph', () => {
 			schema.register( 'paragraph', { inheritAllFrom: '$block' } );
 			schema.register( 'paragraph', { inheritAllFrom: '$block' } );
 
 
-			upcastElementToElement( { view: 'p', model: 'paragraph' } )( data.upcastDispatcher );
+			upcastHelpers.elementToElement( { view: 'p', model: 'paragraph' } );
 
 
 			const output = data.parse( '<p>foo<b>bar</b></p>' );
 			const output = data.parse( '<p>foo<b>bar</b></p>' );
 
 
@@ -79,7 +74,7 @@ describe( 'DataController', () => {
 		it( 'should set two paragraphs', () => {
 		it( 'should set two paragraphs', () => {
 			schema.register( 'paragraph', { inheritAllFrom: '$block' } );
 			schema.register( 'paragraph', { inheritAllFrom: '$block' } );
 
 
-			upcastElementToElement( { view: 'p', model: 'paragraph' } )( data.upcastDispatcher );
+			upcastHelpers.elementToElement( { view: 'p', model: 'paragraph' } );
 
 
 			const output = data.parse( '<p>foo</p><p>bar</p>' );
 			const output = data.parse( '<p>foo</p><p>bar</p>' );
 
 
@@ -93,8 +88,8 @@ describe( 'DataController', () => {
 				allowAttributes: [ 'bold' ]
 				allowAttributes: [ 'bold' ]
 			} );
 			} );
 
 
-			upcastElementToElement( { view: 'p', model: 'paragraph' } )( data.upcastDispatcher );
-			upcastElementToAttribute( { view: 'strong', model: 'bold' } )( data.upcastDispatcher );
+			upcastHelpers.elementToElement( { view: 'p', model: 'paragraph' } );
+			upcastHelpers.elementToAttribute( { view: 'strong', model: 'bold' } );
 
 
 			const output = data.parse( '<p>foo<strong>bar</strong></p>' );
 			const output = data.parse( '<p>foo<strong>bar</strong></p>' );
 
 
@@ -119,7 +114,7 @@ describe( 'DataController', () => {
 		beforeEach( () => {
 		beforeEach( () => {
 			schema.register( 'paragraph', { inheritAllFrom: '$block' } );
 			schema.register( 'paragraph', { inheritAllFrom: '$block' } );
 
 
-			upcastElementToElement( { view: 'p', model: 'paragraph' } )( data.upcastDispatcher );
+			upcastHelpers.elementToElement( { view: 'p', model: 'paragraph' } );
 		} );
 		} );
 
 
 		it( 'should convert content of an element #1', () => {
 		it( 'should convert content of an element #1', () => {
@@ -285,7 +280,7 @@ describe( 'DataController', () => {
 			schema.register( 'paragraph', { inheritAllFrom: '$block' } );
 			schema.register( 'paragraph', { inheritAllFrom: '$block' } );
 			setData( model, '<paragraph>foo</paragraph>' );
 			setData( model, '<paragraph>foo</paragraph>' );
 
 
-			downcastElementToElement( { model: 'paragraph', view: 'p' } )( data.downcastDispatcher );
+			downcastHelpers.elementToElement( { model: 'paragraph', view: 'p' } );
 
 
 			expect( data.get() ).to.equal( '<p>foo</p>' );
 			expect( data.get() ).to.equal( '<p>foo</p>' );
 		} );
 		} );
@@ -294,7 +289,7 @@ describe( 'DataController', () => {
 			schema.register( 'paragraph', { inheritAllFrom: '$block' } );
 			schema.register( 'paragraph', { inheritAllFrom: '$block' } );
 			setData( model, '<paragraph></paragraph>' );
 			setData( model, '<paragraph></paragraph>' );
 
 
-			downcastElementToElement( { model: 'paragraph', view: 'p' } )( data.downcastDispatcher );
+			downcastHelpers.elementToElement( { model: 'paragraph', view: 'p' } );
 
 
 			expect( data.get() ).to.equal( '<p>&nbsp;</p>' );
 			expect( data.get() ).to.equal( '<p>&nbsp;</p>' );
 		} );
 		} );
@@ -303,7 +298,7 @@ describe( 'DataController', () => {
 			schema.register( 'paragraph', { inheritAllFrom: '$block' } );
 			schema.register( 'paragraph', { inheritAllFrom: '$block' } );
 			setData( model, '<paragraph>foo</paragraph><paragraph>bar</paragraph>' );
 			setData( model, '<paragraph>foo</paragraph><paragraph>bar</paragraph>' );
 
 
-			downcastElementToElement( { model: 'paragraph', view: 'p' } )( data.downcastDispatcher );
+			downcastHelpers.elementToElement( { model: 'paragraph', view: 'p' } );
 
 
 			expect( data.get() ).to.equal( '<p>foo</p><p>bar</p>' );
 			expect( data.get() ).to.equal( '<p>foo</p><p>bar</p>' );
 		} );
 		} );
@@ -319,7 +314,7 @@ describe( 'DataController', () => {
 			schema.register( 'paragraph', { inheritAllFrom: '$block' } );
 			schema.register( 'paragraph', { inheritAllFrom: '$block' } );
 			setData( model, '<paragraph>foo<$text bold="true">bar</$text></paragraph>' );
 			setData( model, '<paragraph>foo<$text bold="true">bar</$text></paragraph>' );
 
 
-			downcastElementToElement( { model: 'paragraph', view: 'p' } )( data.downcastDispatcher );
+			downcastHelpers.elementToElement( { model: 'paragraph', view: 'p' } );
 
 
 			expect( data.get() ).to.equal( '<p>foobar</p>' );
 			expect( data.get() ).to.equal( '<p>foobar</p>' );
 		} );
 		} );
@@ -328,8 +323,8 @@ describe( 'DataController', () => {
 			schema.register( 'paragraph', { inheritAllFrom: '$block' } );
 			schema.register( 'paragraph', { inheritAllFrom: '$block' } );
 			setData( model, '<paragraph>foo<$text bold="true">bar</$text></paragraph>' );
 			setData( model, '<paragraph>foo<$text bold="true">bar</$text></paragraph>' );
 
 
-			downcastElementToElement( { model: 'paragraph', view: 'p' } )( data.downcastDispatcher );
-			downcastAttributeToElement( { model: 'bold', view: 'strong' } )( data.downcastDispatcher );
+			downcastHelpers.elementToElement( { model: 'paragraph', view: 'p' } );
+			downcastHelpers.attributeToElement( { model: 'bold', view: 'strong' } );
 
 
 			expect( data.get() ).to.equal( '<p>foo<strong>bar</strong></p>' );
 			expect( data.get() ).to.equal( '<p>foo<strong>bar</strong></p>' );
 		} );
 		} );
@@ -341,8 +336,8 @@ describe( 'DataController', () => {
 			setData( model, '<paragraph>foo</paragraph>', { rootName: 'main' } );
 			setData( model, '<paragraph>foo</paragraph>', { rootName: 'main' } );
 			setData( model, 'Bar', { rootName: 'title' } );
 			setData( model, 'Bar', { rootName: 'title' } );
 
 
-			downcastElementToElement( { model: 'paragraph', view: 'p' } )( data.downcastDispatcher );
-			downcastAttributeToElement( { model: 'bold', view: 'strong' } )( data.downcastDispatcher );
+			downcastHelpers.elementToElement( { model: 'paragraph', view: 'p' } );
+			downcastHelpers.attributeToElement( { model: 'bold', view: 'strong' } );
 
 
 			expect( data.get() ).to.equal( '<p>foo</p>' );
 			expect( data.get() ).to.equal( '<p>foo</p>' );
 			expect( data.get( 'main' ) ).to.equal( '<p>foo</p>' );
 			expect( data.get( 'main' ) ).to.equal( '<p>foo</p>' );
@@ -358,7 +353,7 @@ describe( 'DataController', () => {
 			schema.extend( '$block', { allowIn: 'div' } );
 			schema.extend( '$block', { allowIn: 'div' } );
 			schema.extend( 'div', { allowIn: '$root' } );
 			schema.extend( 'div', { allowIn: '$root' } );
 
 
-			downcastElementToElement( { model: 'paragraph', view: 'p' } )( data.downcastDispatcher );
+			downcastHelpers.elementToElement( { model: 'paragraph', view: 'p' } );
 		} );
 		} );
 
 
 		it( 'should stringify a content of an element', () => {
 		it( 'should stringify a content of an element', () => {
@@ -382,7 +377,7 @@ describe( 'DataController', () => {
 			schema.extend( '$block', { allowIn: 'div' } );
 			schema.extend( '$block', { allowIn: 'div' } );
 			schema.extend( 'div', { allowIn: '$root' } );
 			schema.extend( 'div', { allowIn: '$root' } );
 
 
-			downcastElementToElement( { model: 'paragraph', view: 'p' } )( data.downcastDispatcher );
+			downcastHelpers.elementToElement( { model: 'paragraph', view: 'p' } );
 		} );
 		} );
 
 
 		it( 'should convert a content of an element', () => {
 		it( 'should convert a content of an element', () => {
@@ -403,7 +398,7 @@ describe( 'DataController', () => {
 			const modelElement = parseModel( '<div><paragraph>foobar</paragraph></div>', schema );
 			const modelElement = parseModel( '<div><paragraph>foobar</paragraph></div>', schema );
 			const modelRoot = model.document.getRoot();
 			const modelRoot = model.document.getRoot();
 
 
-			downcastMarkerToHighlight( { model: 'marker:a', view: { classes: 'a' } } )( data.downcastDispatcher );
+			downcastHelpers.markerToHighlight( { model: 'marker:a', view: { classes: 'a' } } );
 
 
 			model.change( writer => {
 			model.change( writer => {
 				writer.insert( modelElement, modelRoot, 0 );
 				writer.insert( modelElement, modelRoot, 0 );
@@ -421,8 +416,8 @@ describe( 'DataController', () => {
 			const modelElement = parseModel( '<div><paragraph>foo</paragraph><paragraph>bar</paragraph></div>', schema );
 			const modelElement = parseModel( '<div><paragraph>foo</paragraph><paragraph>bar</paragraph></div>', schema );
 			const modelRoot = model.document.getRoot();
 			const modelRoot = model.document.getRoot();
 
 
-			downcastMarkerToHighlight( { model: 'marker:a', view: { classes: 'a' } } )( data.downcastDispatcher );
-			downcastMarkerToHighlight( { model: 'marker:b', view: { classes: 'b' } } )( data.downcastDispatcher );
+			downcastHelpers.markerToHighlight( { model: 'marker:a', view: { classes: 'a' } } );
+			downcastHelpers.markerToHighlight( { model: 'marker:b', view: { classes: 'b' } } );
 
 
 			const modelP1 = modelElement.getChild( 0 );
 			const modelP1 = modelElement.getChild( 0 );
 			const modelP2 = modelElement.getChild( 1 );
 			const modelP2 = modelElement.getChild( 1 );

+ 6 - 5
packages/ckeditor5-engine/tests/controller/editingcontroller.js

@@ -14,8 +14,7 @@ import View from '../../src/view/view';
 import Mapper from '../../src/conversion/mapper';
 import Mapper from '../../src/conversion/mapper';
 import DowncastDispatcher from '../../src/conversion/downcastdispatcher';
 import DowncastDispatcher from '../../src/conversion/downcastdispatcher';
 
 
-import { downcastElementToElement, downcastMarkerToHighlight } from '../../src/conversion/downcast-converters';
-
+import DowncastHelpers from '../../src/conversion/downcasthelpers';
 import Model from '../../src/model/model';
 import Model from '../../src/model/model';
 import ModelPosition from '../../src/model/position';
 import ModelPosition from '../../src/model/position';
 import ModelRange from '../../src/model/range';
 import ModelRange from '../../src/model/range';
@@ -91,9 +90,11 @@ describe( 'EditingController', () => {
 			model.schema.register( 'paragraph', { inheritAllFrom: '$block' } );
 			model.schema.register( 'paragraph', { inheritAllFrom: '$block' } );
 			model.schema.register( 'div', { inheritAllFrom: '$block' } );
 			model.schema.register( 'div', { inheritAllFrom: '$block' } );
 
 
-			downcastElementToElement( { model: 'paragraph', view: 'p' } )( editing.downcastDispatcher );
-			downcastElementToElement( { model: 'div', view: 'div' } )( editing.downcastDispatcher );
-			downcastMarkerToHighlight( { model: 'marker', view: {} } )( editing.downcastDispatcher );
+			const downcastHelpers = new DowncastHelpers( editing.downcastDispatcher );
+
+			downcastHelpers.elementToElement( { model: 'paragraph', view: 'p' } );
+			downcastHelpers.elementToElement( { model: 'div', view: 'div' } );
+			downcastHelpers.markerToHighlight( { model: 'marker', view: {} } );
 
 
 			// Note: The below code is highly overcomplicated due to #455.
 			// Note: The below code is highly overcomplicated due to #455.
 			model.change( writer => {
 			model.change( writer => {

+ 94 - 15
packages/ckeditor5-engine/tests/conversion/conversion.js

@@ -3,22 +3,23 @@
  * For licensing, see LICENSE.md.
  * For licensing, see LICENSE.md.
  */
  */
 
 
-import Conversion from '../../src/conversion/conversion';
+import Conversion, { ConversionHelpers } from '../../src/conversion/conversion';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 
 
 import UpcastDispatcher from '../../src/conversion/upcastdispatcher';
 import UpcastDispatcher from '../../src/conversion/upcastdispatcher';
 
 
-import { convertText, convertToModelFragment } from '../../src/conversion/upcast-converters';
+import UpcastHelpers, { convertText, convertToModelFragment } from '../../src/conversion/upcasthelpers';
+import DowncastHelpers from '../../src/conversion/downcasthelpers';
 
 
 import EditingController from '../../src/controller/editingcontroller';
 import EditingController from '../../src/controller/editingcontroller';
 
 
 import Model from '../../src/model/model';
 import Model from '../../src/model/model';
 
 
-import { stringify as viewStringify, parse as viewParse } from '../../src/dev-utils/view';
+import { parse as viewParse, stringify as viewStringify } from '../../src/dev-utils/view';
 import { stringify as modelStringify } from '../../src/dev-utils/model';
 import { stringify as modelStringify } from '../../src/dev-utils/model';
 
 
 describe( 'Conversion', () => {
 describe( 'Conversion', () => {
-	let conversion, dispA, dispB;
+	let conversion, dispA, dispB, dispC;
 
 
 	beforeEach( () => {
 	beforeEach( () => {
 		conversion = new Conversion();
 		conversion = new Conversion();
@@ -26,10 +27,12 @@ describe( 'Conversion', () => {
 		// Placeholders. Will be used only to see if their were given as attribute for a spy function.
 		// Placeholders. Will be used only to see if their were given as attribute for a spy function.
 		dispA = Symbol( 'dispA' );
 		dispA = Symbol( 'dispA' );
 		dispB = Symbol( 'dispB' );
 		dispB = Symbol( 'dispB' );
+		dispC = Symbol( 'dispC' );
 
 
-		conversion.register( 'ab', [ dispA, dispB ] );
-		conversion.register( 'a', [ dispA ] );
-		conversion.register( 'b', [ dispB ] );
+		conversion.register( 'ab', new UpcastHelpers( [ dispA, dispB ] ) );
+		conversion.register( 'a', new UpcastHelpers( dispA ) );
+		conversion.register( 'b', new UpcastHelpers( dispB ) );
+		conversion.register( 'c', new DowncastHelpers( dispC ) );
 	} );
 	} );
 
 
 	describe( 'register()', () => {
 	describe( 'register()', () => {
@@ -41,10 +44,8 @@ describe( 'Conversion', () => {
 	} );
 	} );
 
 
 	describe( 'for()', () => {
 	describe( 'for()', () => {
-		it( 'should return object with .add() method', () => {
-			const forResult = conversion.for( 'ab' );
-
-			expect( forResult.add ).to.be.instanceof( Function );
+		it( 'should return ConversionHelpers', () => {
+			expect( conversion.for( 'ab' ) ).to.be.instanceof( ConversionHelpers );
 		} );
 		} );
 
 
 		it( 'should throw if non-existing group name has been used', () => {
 		it( 'should throw if non-existing group name has been used', () => {
@@ -52,6 +53,13 @@ describe( 'Conversion', () => {
 				conversion.for( 'foo' );
 				conversion.for( 'foo' );
 			} ).to.throw( CKEditorError, /conversion-for-unknown-group/ );
 			} ).to.throw( CKEditorError, /conversion-for-unknown-group/ );
 		} );
 		} );
+
+		it( 'should return proper helpers for group', () => {
+			expect( conversion.for( 'ab' ) ).to.be.instanceof( UpcastHelpers );
+			expect( conversion.for( 'a' ) ).to.be.instanceof( UpcastHelpers );
+			expect( conversion.for( 'b' ) ).to.be.instanceof( UpcastHelpers );
+			expect( conversion.for( 'c' ) ).to.be.instanceof( DowncastHelpers );
+		} );
 	} );
 	} );
 
 
 	describe( 'add()', () => {
 	describe( 'add()', () => {
@@ -100,6 +108,7 @@ describe( 'Conversion', () => {
 			schema = model.schema;
 			schema = model.schema;
 
 
 			schema.extend( '$text', {
 			schema.extend( '$text', {
+				allowIn: '$root',
 				allowAttributes: [ 'bold' ]
 				allowAttributes: [ 'bold' ]
 			} );
 			} );
 
 
@@ -113,8 +122,8 @@ describe( 'Conversion', () => {
 			viewDispatcher.on( 'documentFragment', convertToModelFragment(), { priority: 'lowest' } );
 			viewDispatcher.on( 'documentFragment', convertToModelFragment(), { priority: 'lowest' } );
 
 
 			conversion = new Conversion();
 			conversion = new Conversion();
-			conversion.register( 'upcast', [ viewDispatcher ] );
-			conversion.register( 'downcast', [ controller.downcastDispatcher ] );
+			conversion.register( 'upcast', new UpcastHelpers( [ viewDispatcher ] ) );
+			conversion.register( 'downcast', new DowncastHelpers( controller.downcastDispatcher ) );
 		} );
 		} );
 
 
 		describe( 'elementToElement', () => {
 		describe( 'elementToElement', () => {
@@ -124,7 +133,7 @@ describe( 'Conversion', () => {
 				test( '<p>Foo</p>', '<paragraph>Foo</paragraph>' );
 				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: 'p' } );
 				conversion.elementToElement( { model: 'paragraph', view: 'div', converterPriority: 'high' } );
 				conversion.elementToElement( { model: 'paragraph', view: 'div', converterPriority: 'high' } );
 
 
@@ -132,6 +141,16 @@ describe( 'Conversion', () => {
 				test( '<p>Foo</p>', '<paragraph>Foo</paragraph>', '<div>Foo</div>' );
 				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', () => {
 			it( 'config.view is an object', () => {
 				schema.register( 'fancyParagraph', {
 				schema.register( 'fancyParagraph', {
 					inheritAllFrom: 'paragraph'
 					inheritAllFrom: 'paragraph'
@@ -223,7 +242,7 @@ describe( 'Conversion', () => {
 				test( '<p><strong>Foo</strong> bar</p>', '<paragraph><$text bold="true">Foo</$text> bar</paragraph>' );
 				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: 'strong' } );
 				conversion.attributeToElement( { model: 'bold', view: 'b', converterPriority: 'high' } );
 				conversion.attributeToElement( { model: 'bold', view: 'b', converterPriority: 'high' } );
 
 
@@ -231,6 +250,20 @@ describe( 'Conversion', () => {
 				test( '<p><strong>Foo</strong></p>', '<paragraph><$text bold="true">Foo</$text></paragraph>', '<p><b>Foo</b></p>' );
 				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', () => {
 			it( 'config.view is an object', () => {
 				conversion.attributeToElement( {
 				conversion.attributeToElement( {
 					model: 'bold',
 					model: 'bold',
@@ -625,6 +658,17 @@ describe( 'Conversion', () => {
 					'<div border="border"><div shade="shade"></div></div>'
 					'<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 ) {
 		function test( input, expectedModel, expectedView = null ) {
@@ -652,3 +696,38 @@ describe( 'Conversion', () => {
 		}
 		}
 	} );
 	} );
 } );
 } );
+
+describe( 'ConversionHelpers', () => {
+	describe( 'add()', () => {
+		const dispA = Symbol( 'dispA' );
+		const dispB = Symbol( 'dispB' );
+
+		it( 'should call a helper for one defined dispatcher', () => {
+			const spy = sinon.spy();
+			const helpers = new ConversionHelpers( dispA );
+
+			helpers.add( spy );
+
+			sinon.assert.calledOnce( spy );
+			sinon.assert.calledWithExactly( spy, dispA );
+		} );
+
+		it( 'should call helper for all defined dispatcherers', () => {
+			const spy = sinon.spy();
+			const helpers = new ConversionHelpers( [ dispA, dispB ] );
+
+			helpers.add( spy );
+
+			sinon.assert.calledTwice( spy );
+			sinon.assert.calledWithExactly( spy, dispA );
+			sinon.assert.calledWithExactly( spy, dispB );
+		} );
+
+		it( 'should be chainable', () => {
+			const spy = sinon.spy();
+			const helpers = new ConversionHelpers( dispA );
+
+			expect( helpers.add( spy ) ).to.equal( helpers );
+		} );
+	} );
+} );

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

@@ -1,1755 +0,0 @@
-/**
- * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
-
-import EditingController from '../../src/controller/editingcontroller';
-
-import Conversion from '../../src/conversion/conversion';
-
-import Model from '../../src/model/model';
-import ModelElement from '../../src/model/element';
-import ModelText from '../../src/model/text';
-
-import ViewElement from '../../src/view/element';
-import ViewAttributeElement from '../../src/view/attributeelement';
-import ViewContainerElement from '../../src/view/containerelement';
-import ViewUIElement from '../../src/view/uielement';
-import ViewText from '../../src/view/text';
-
-import {
-	downcastElementToElement, downcastAttributeToElement, downcastAttributeToAttribute, downcastMarkerToElement, downcastMarkerToHighlight,
-	insertElement, insertUIElement, changeAttribute, wrap, removeUIElement,
-	highlightElement, highlightText, removeHighlight, createViewElementFromHighlightDescriptor
-} from '../../src/conversion/downcast-converters';
-
-import { stringify } from '../../src/dev-utils/view';
-
-describe( 'downcast-helpers', () => {
-	let conversion, model, modelRoot, viewRoot;
-
-	beforeEach( () => {
-		model = new Model();
-		const modelDoc = model.document;
-		modelRoot = modelDoc.createRoot();
-
-		const controller = new EditingController( model );
-
-		// Set name of view root the same as dom root.
-		// This is a mock of attaching view root to dom root.
-		controller.view.document.getRoot()._name = 'div';
-
-		viewRoot = controller.view.document.getRoot();
-
-		conversion = new Conversion();
-		conversion.register( 'downcast', [ controller.downcastDispatcher ] );
-	} );
-
-	describe( 'downcastElementToElement', () => {
-		it( 'config.view is a string', () => {
-			const helper = downcastElementToElement( { model: 'paragraph', view: 'p' } );
-
-			conversion.for( 'downcast' ).add( helper );
-
-			model.change( writer => {
-				writer.insertElement( 'paragraph', modelRoot, 0 );
-			} );
-
-			expectResult( '<p></p>' );
-		} );
-
-		it( 'can be overwritten using converterPriority', () => {
-			const helperA = downcastElementToElement( { model: 'paragraph', view: 'p' } );
-			const helperB = downcastElementToElement( { model: 'paragraph', view: 'foo', converterPriority: 'high' } );
-
-			conversion.for( 'downcast' ).add( helperA ).add( helperB );
-
-			model.change( writer => {
-				writer.insertElement( 'paragraph', modelRoot, 0 );
-			} );
-
-			expectResult( '<foo></foo>' );
-		} );
-
-		it( 'config.view is a view element definition', () => {
-			const helper = downcastElementToElement( {
-				model: 'fancyParagraph',
-				view: {
-					name: 'p',
-					classes: 'fancy'
-				}
-			} );
-
-			conversion.for( 'downcast' ).add( helper );
-
-			model.change( writer => {
-				writer.insertElement( 'fancyParagraph', modelRoot, 0 );
-			} );
-
-			expectResult( '<p class="fancy"></p>' );
-		} );
-
-		it( 'config.view is a function', () => {
-			const helper = downcastElementToElement( {
-				model: 'heading',
-				view: ( modelElement, viewWriter ) => viewWriter.createContainerElement( 'h' + modelElement.getAttribute( 'level' ) )
-			} );
-
-			conversion.for( 'downcast' ).add( helper );
-
-			model.change( writer => {
-				writer.insertElement( 'heading', { level: 2 }, modelRoot, 0 );
-			} );
-
-			expectResult( '<h2></h2>' );
-		} );
-	} );
-
-	describe( 'downcastAttributeToElement', () => {
-		it( 'config.view is a string', () => {
-			const helper = downcastAttributeToElement( { model: 'bold', view: 'strong' } );
-
-			conversion.for( 'downcast' ).add( helper );
-
-			model.change( writer => {
-				writer.insertText( 'foo', { bold: true }, modelRoot, 0 );
-			} );
-
-			expectResult( '<strong>foo</strong>' );
-		} );
-
-		it( 'can be overwritten using converterPriority', () => {
-			const helperA = downcastAttributeToElement( { model: 'bold', view: 'strong' } );
-			const helperB = downcastAttributeToElement( { model: 'bold', view: 'b', converterPriority: 'high' } );
-
-			conversion.for( 'downcast' ).add( helperA ).add( helperB );
-
-			model.change( writer => {
-				writer.insertText( 'foo', { bold: true }, modelRoot, 0 );
-			} );
-
-			expectResult( '<b>foo</b>' );
-		} );
-
-		it( 'config.view is a view element definition', () => {
-			const helper = downcastAttributeToElement( {
-				model: 'invert',
-				view: {
-					name: 'span',
-					classes: [ 'font-light', 'bg-dark' ]
-				}
-			} );
-
-			conversion.for( 'downcast' ).add( helper );
-
-			model.change( writer => {
-				writer.insertText( 'foo', { invert: true }, modelRoot, 0 );
-			} );
-
-			expectResult( '<span class="bg-dark font-light">foo</span>' );
-			expect( viewRoot.getChild( 0 ).priority ).to.equal( ViewAttributeElement.DEFAULT_PRIORITY );
-		} );
-
-		it( 'config.view allows specifying the element\'s priority', () => {
-			const helper = downcastAttributeToElement( {
-				model: 'invert',
-				view: {
-					name: 'span',
-					priority: 5
-				}
-			} );
-
-			conversion.for( 'downcast' ).add( helper );
-
-			model.change( writer => {
-				writer.insertText( 'foo', { invert: true }, modelRoot, 0 );
-			} );
-
-			expect( viewRoot.getChild( 0 ).priority ).to.equal( 5 );
-		} );
-
-		it( 'model attribute value is enum', () => {
-			const helper = downcastAttributeToElement( {
-				model: {
-					key: 'fontSize',
-					values: [ 'big', 'small' ]
-				},
-				view: {
-					big: {
-						name: 'span',
-						styles: {
-							'font-size': '1.2em'
-						}
-					},
-					small: {
-						name: 'span',
-						styles: {
-							'font-size': '0.8em'
-						},
-						priority: 5
-					}
-				}
-			} );
-
-			conversion.for( 'downcast' ).add( helper );
-
-			model.change( writer => {
-				writer.insertText( 'foo', { fontSize: 'big' }, modelRoot, 0 );
-			} );
-
-			expect( viewRoot.getChild( 0 ).priority ).to.equal( ViewAttributeElement.DEFAULT_PRIORITY );
-			expectResult( '<span style="font-size:1.2em">foo</span>' );
-
-			model.change( writer => {
-				writer.setAttribute( 'fontSize', 'small', modelRoot.getChild( 0 ) );
-			} );
-
-			expectResult( '<span style="font-size:0.8em">foo</span>' );
-			expect( viewRoot.getChild( 0 ).priority ).to.equal( 5 );
-
-			model.change( writer => {
-				writer.removeAttribute( 'fontSize', modelRoot.getChild( 0 ) );
-			} );
-
-			expectResult( 'foo' );
-		} );
-
-		it( 'config.view is a function', () => {
-			const helper = downcastAttributeToElement( {
-				model: 'bold',
-				view: ( modelAttributeValue, viewWriter ) => {
-					return viewWriter.createAttributeElement( 'span', { style: 'font-weight:' + modelAttributeValue } );
-				}
-			} );
-
-			conversion.for( 'downcast' ).add( helper );
-
-			model.change( writer => {
-				writer.insertText( 'foo', { bold: '500' }, modelRoot, 0 );
-			} );
-
-			expectResult( '<span style="font-weight:500">foo</span>' );
-		} );
-
-		it( 'config.model.name is given', () => {
-			const helper = downcastAttributeToElement( {
-				model: {
-					key: 'color',
-					name: '$text'
-				},
-				view: ( modelAttributeValue, viewWriter ) => {
-					return viewWriter.createAttributeElement( 'span', { style: 'color:' + modelAttributeValue } );
-				}
-			} );
-
-			conversion.for( 'downcast' )
-				.add( helper )
-				.add( downcastElementToElement( {
-					model: 'smiley',
-					view: ( modelElement, viewWriter ) => {
-						return viewWriter.createEmptyElement( 'img', {
-							src: 'smile.jpg',
-							class: 'smiley'
-						} );
-					}
-				} ) );
-
-			model.change( writer => {
-				writer.insertText( 'foo', { color: '#FF0000' }, modelRoot, 0 );
-				writer.insertElement( 'smiley', { color: '#FF0000' }, modelRoot, 3 );
-			} );
-
-			expectResult( '<span style="color:#FF0000">foo</span><img class="smiley" src="smile.jpg"></img>' );
-		} );
-	} );
-
-	describe( 'downcastAttributeToAttribute', () => {
-		beforeEach( () => {
-			conversion.for( 'downcast' ).add( downcastElementToElement( { model: 'image', view: 'img' } ) );
-		} );
-
-		it( 'config.view is a string', () => {
-			const helper = downcastAttributeToAttribute( { model: 'source', view: 'src' } );
-
-			conversion.for( 'downcast' ).add( helper );
-
-			model.change( writer => {
-				writer.insertElement( 'image', { source: 'foo.jpg' }, modelRoot, 0 );
-			} );
-
-			expectResult( '<img src="foo.jpg"></img>' );
-
-			model.change( writer => {
-				writer.removeAttribute( 'source', modelRoot.getChild( 0 ) );
-			} );
-
-			expectResult( '<img></img>' );
-		} );
-
-		it( 'can be overwritten using converterPriority', () => {
-			const helperA = downcastAttributeToAttribute( { model: 'source', view: 'href' } );
-			const helperB = downcastAttributeToAttribute( { model: 'source', view: 'src', converterPriority: 'high' } );
-
-			conversion.for( 'downcast' ).add( helperA ).add( helperB );
-
-			model.change( writer => {
-				writer.insertElement( 'image', { source: 'foo.jpg' }, modelRoot, 0 );
-			} );
-
-			expectResult( '<img src="foo.jpg"></img>' );
-		} );
-
-		it( 'model element name specified', () => {
-			conversion.for( 'downcast' ).add( downcastElementToElement( { model: 'paragraph', view: 'p' } ) );
-
-			const helper = downcastAttributeToAttribute( {
-				model: {
-					name: 'image',
-					key: 'source'
-				},
-				view: 'src'
-			} );
-
-			conversion.for( 'downcast' ).add( helper );
-
-			model.change( writer => {
-				writer.insertElement( 'image', { source: 'foo.jpg' }, modelRoot, 0 );
-			} );
-
-			expectResult( '<img src="foo.jpg"></img>' );
-
-			model.change( writer => {
-				writer.rename( modelRoot.getChild( 0 ), 'paragraph' );
-			} );
-
-			expectResult( '<p></p>' );
-		} );
-
-		it( 'config.view is an object, model attribute value is enum', () => {
-			conversion.for( 'downcast' ).add( downcastElementToElement( { model: 'paragraph', view: 'p' } ) );
-
-			const helper = downcastAttributeToAttribute( {
-				model: {
-					key: 'styled',
-					values: [ 'dark', 'light' ]
-				},
-				view: {
-					dark: {
-						key: 'class',
-						value: [ 'styled', 'styled-dark' ]
-					},
-					light: {
-						key: 'class',
-						value: [ 'styled', 'styled-light' ]
-					}
-				}
-			} );
-
-			conversion.for( 'downcast' ).add( helper );
-
-			model.change( writer => {
-				writer.insertElement( 'paragraph', { styled: 'dark' }, modelRoot, 0 );
-			} );
-
-			expectResult( '<p class="styled styled-dark"></p>' );
-
-			model.change( writer => {
-				writer.setAttribute( 'styled', 'light', modelRoot.getChild( 0 ) );
-			} );
-
-			expectResult( '<p class="styled styled-light"></p>' );
-
-			model.change( writer => {
-				writer.removeAttribute( 'styled', modelRoot.getChild( 0 ) );
-			} );
-
-			expectResult( '<p></p>' );
-		} );
-
-		it( 'config.view is an object, model attribute value is enum, view has style', () => {
-			conversion.for( 'downcast' ).add( downcastElementToElement( { model: 'paragraph', view: 'p' } ) );
-
-			const helper = downcastAttributeToAttribute( {
-				model: {
-					key: 'align',
-					values: [ 'right', 'center' ]
-				},
-				view: {
-					right: {
-						key: 'style',
-						value: {
-							'text-align': 'right'
-						}
-					},
-					center: {
-						key: 'style',
-						value: {
-							'text-align': 'center'
-						}
-					}
-				}
-			} );
-
-			conversion.for( 'downcast' ).add( helper );
-
-			model.change( writer => {
-				writer.insertElement( 'paragraph', { align: 'right' }, modelRoot, 0 );
-			} );
-
-			expectResult( '<p style="text-align:right"></p>' );
-
-			model.change( writer => {
-				writer.setAttribute( 'align', 'center', modelRoot.getChild( 0 ) );
-			} );
-
-			expectResult( '<p style="text-align:center"></p>' );
-
-			model.change( writer => {
-				writer.removeAttribute( 'align', modelRoot.getChild( 0 ) );
-			} );
-
-			expectResult( '<p></p>' );
-		} );
-
-		it( 'config.view is an object, only name and key are provided', () => {
-			conversion.for( 'downcast' ).add( downcastElementToElement( { model: 'paragraph', view: 'p' } ) );
-
-			const helper = downcastAttributeToAttribute( {
-				model: {
-					name: 'paragraph',
-					key: 'class'
-				},
-				view: {
-					name: 'paragraph',
-					key: 'class'
-				}
-			} );
-
-			conversion.for( 'downcast' ).add( helper );
-
-			model.change( writer => {
-				writer.insertElement( 'paragraph', { class: 'dark' }, modelRoot, 0 );
-			} );
-
-			expectResult( '<p class="dark"></p>' );
-
-			model.change( writer => {
-				writer.setAttribute( 'class', 'light', modelRoot.getChild( 0 ) );
-			} );
-
-			expectResult( '<p class="light"></p>' );
-
-			model.change( writer => {
-				writer.removeAttribute( 'class', modelRoot.getChild( 0 ) );
-			} );
-
-			expectResult( '<p></p>' );
-		} );
-
-		it( 'config.view is a function', () => {
-			const helper = downcastAttributeToAttribute( {
-				model: 'styled',
-				view: attributeValue => ( { key: 'class', value: 'styled-' + attributeValue } )
-			} );
-
-			conversion.for( 'downcast' ).add( helper );
-
-			model.change( writer => {
-				writer.insertElement( 'image', { styled: 'pull-out' }, modelRoot, 0 );
-			} );
-
-			expectResult( '<img class="styled-pull-out"></img>' );
-		} );
-	} );
-
-	describe( 'downcastMarkerToElement', () => {
-		it( 'config.view is a string', () => {
-			const helper = downcastMarkerToElement( { model: 'search', view: 'marker-search' } );
-
-			conversion.for( 'downcast' ).add( helper );
-
-			model.change( writer => {
-				writer.insertText( 'foo', modelRoot, 0 );
-
-				const range = writer.createRange( writer.createPositionAt( modelRoot, 1 ), writer.createPositionAt( modelRoot, 2 ) );
-				writer.addMarker( 'search', { range, usingOperation: false } );
-			} );
-
-			expectResult( 'f<marker-search></marker-search>o<marker-search></marker-search>o' );
-		} );
-
-		it( 'can be overwritten using converterPriority', () => {
-			const helperA = downcastMarkerToElement( { model: 'search', view: 'marker-search' } );
-			const helperB = downcastMarkerToElement( { model: 'search', view: 'search', converterPriority: 'high' } );
-
-			conversion.for( 'downcast' ).add( helperA ).add( helperB );
-
-			model.change( writer => {
-				writer.insertText( 'foo', modelRoot, 0 );
-				const range = writer.createRange( writer.createPositionAt( modelRoot, 1 ), writer.createPositionAt( modelRoot, 2 ) );
-				writer.addMarker( 'search', { range, usingOperation: false } );
-			} );
-
-			expectResult( 'f<search></search>o<search></search>o' );
-		} );
-
-		it( 'config.view is a view element definition', () => {
-			const helper = downcastMarkerToElement( {
-				model: 'search',
-				view: {
-					name: 'span',
-					attributes: {
-						'data-marker': 'search'
-					}
-				}
-			} );
-
-			conversion.for( 'downcast' ).add( helper );
-
-			model.change( writer => {
-				writer.insertText( 'foo', modelRoot, 0 );
-				const range = writer.createRange( writer.createPositionAt( modelRoot, 1 ), writer.createPositionAt( modelRoot, 2 ) );
-				writer.addMarker( 'search', { range, usingOperation: false } );
-			} );
-
-			expectResult( 'f<span data-marker="search"></span>o<span data-marker="search"></span>o' );
-		} );
-
-		it( 'config.view is a function', () => {
-			const helper = downcastMarkerToElement( {
-				model: 'search',
-				view: ( data, viewWriter ) => {
-					return viewWriter.createUIElement( 'span', { 'data-marker': 'search', 'data-start': data.isOpening } );
-				}
-			} );
-
-			conversion.for( 'downcast' ).add( helper );
-
-			model.change( writer => {
-				writer.insertText( 'foo', modelRoot, 0 );
-				const range = writer.createRange( writer.createPositionAt( modelRoot, 1 ), writer.createPositionAt( modelRoot, 2 ) );
-				writer.addMarker( 'search', { range, usingOperation: false } );
-			} );
-
-			expectResult( 'f<span data-marker="search" data-start="true"></span>o<span data-marker="search" data-start="false"></span>o' );
-		} );
-	} );
-
-	describe( 'downcastMarkerToHighlight', () => {
-		it( 'config.view is a highlight descriptor', () => {
-			const helper = downcastMarkerToHighlight( { model: 'comment', view: { classes: 'comment' } } );
-
-			conversion.for( 'downcast' ).add( helper );
-
-			model.change( writer => {
-				writer.insertText( 'foo', modelRoot, 0 );
-				const range = writer.createRange( writer.createPositionAt( modelRoot, 0 ), writer.createPositionAt( modelRoot, 3 ) );
-				writer.addMarker( 'comment', { range, usingOperation: false } );
-			} );
-
-			expectResult( '<span class="comment">foo</span>' );
-		} );
-
-		it( 'can be overwritten using converterPriority', () => {
-			const helperA = downcastMarkerToHighlight( { model: 'comment', view: { classes: 'comment' } } );
-			const helperB = downcastMarkerToHighlight( { model: 'comment', view: { classes: 'new-comment' }, converterPriority: 'high' } );
-
-			conversion.for( 'downcast' ).add( helperA ).add( helperB );
-
-			model.change( writer => {
-				writer.insertText( 'foo', modelRoot, 0 );
-				const range = writer.createRange( writer.createPositionAt( modelRoot, 0 ), writer.createPositionAt( modelRoot, 3 ) );
-				writer.addMarker( 'comment', { range, usingOperation: false } );
-			} );
-
-			expectResult( '<span class="new-comment">foo</span>' );
-		} );
-
-		it( 'config.view is a function', () => {
-			const helper = downcastMarkerToHighlight( {
-				model: 'comment',
-				view: data => {
-					const commentType = data.markerName.split( ':' )[ 1 ];
-
-					return {
-						classes: [ 'comment', 'comment-' + commentType ]
-					};
-				}
-			} );
-
-			conversion.for( 'downcast' ).add( helper );
-
-			model.change( writer => {
-				writer.insertText( 'foo', modelRoot, 0 );
-				const range = writer.createRange( writer.createPositionAt( modelRoot, 0 ), writer.createPositionAt( modelRoot, 3 ) );
-				writer.addMarker( 'comment:abc', { range, usingOperation: false } );
-			} );
-
-			expectResult( '<span class="comment comment-abc">foo</span>' );
-		} );
-	} );
-
-	function expectResult( string ) {
-		expect( stringify( viewRoot, null, { ignoreRoot: true } ) ).to.equal( string );
-	}
-} );
-
-describe( 'downcast-converters', () => {
-	let dispatcher, modelDoc, modelRoot, viewRoot, controller, modelRootStart, model;
-
-	beforeEach( () => {
-		model = new Model();
-		modelDoc = model.document;
-		modelRoot = modelDoc.createRoot();
-
-		controller = new EditingController( model );
-
-		viewRoot = controller.view.document.getRoot();
-		// Set name of view root the same as dom root.
-		// This is a mock of attaching view root to dom root.
-		controller.view.document.getRoot()._name = 'div';
-
-		dispatcher = controller.downcastDispatcher;
-
-		dispatcher.on(
-			'insert:paragraph',
-			insertElement(
-				( modelItem, viewWriter ) => viewWriter.createContainerElement( 'p' )
-			)
-		);
-
-		dispatcher.on( 'attribute:class', changeAttribute() );
-
-		modelRootStart = model.createPositionAt( modelRoot, 0 );
-	} );
-
-	function viewAttributesToString( item ) {
-		let result = '';
-
-		for ( const key of item.getAttributeKeys() ) {
-			const value = item.getAttribute( key );
-
-			if ( value ) {
-				result += ' ' + key + '="' + value + '"';
-			}
-		}
-
-		return result;
-	}
-
-	function viewToString( item ) {
-		let result = '';
-
-		if ( item instanceof ViewText ) {
-			result = item.data;
-		} else {
-			// ViewElement or ViewDocumentFragment.
-			for ( const child of item.getChildren() ) {
-				result += viewToString( child );
-			}
-
-			if ( item instanceof ViewElement ) {
-				result = '<' + item.name + viewAttributesToString( item ) + '>' + result + '</' + item.name + '>';
-			}
-		}
-
-		return result;
-	}
-
-	describe( 'insertText', () => {
-		it( 'should downcast text', () => {
-			model.change( writer => {
-				writer.insert( new ModelText( 'foobar' ), modelRootStart );
-			} );
-
-			expect( viewToString( viewRoot ) ).to.equal( '<div>foobar</div>' );
-		} );
-
-		it( 'should support unicode', () => {
-			model.change( writer => {
-				writer.insert( new ModelText( 'நிலைக்கு' ), modelRootStart );
-			} );
-
-			expect( viewToString( viewRoot ) ).to.equal( '<div>நிலைக்கு</div>' );
-		} );
-
-		it( 'should be possible to override it', () => {
-			dispatcher.on( 'insert:$text', ( evt, data, conversionApi ) => {
-				conversionApi.consumable.consume( data.item, 'insert' );
-			}, { converterPriority: 'high' } );
-
-			model.change( writer => {
-				writer.insert( new ModelText( 'foobar' ), modelRootStart );
-			} );
-
-			expect( viewToString( viewRoot ) ).to.equal( '<div></div>' );
-		} );
-	} );
-
-	describe( 'insertElement', () => {
-		it( 'should convert element insertion in model', () => {
-			const modelElement = new ModelElement( 'paragraph', null, new ModelText( 'foobar' ) );
-
-			model.change( writer => {
-				writer.insert( modelElement, modelRootStart );
-			} );
-
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
-		} );
-
-		it( 'should not convert if creator returned null', () => {
-			dispatcher.on( 'insert:div', insertElement( () => null ) );
-
-			const modelElement = new ModelElement( 'div' );
-
-			model.change( writer => {
-				writer.insert( modelElement, modelRootStart );
-			} );
-
-			expect( viewToString( viewRoot ) ).to.equal( '<div></div>' );
-		} );
-	} );
-
-	describe( 'changeAttribute', () => {
-		it( 'should convert attribute insert/change/remove on a model node', () => {
-			const modelElement = new ModelElement( 'paragraph', { class: 'foo' }, new ModelText( 'foobar' ) );
-
-			model.change( writer => {
-				writer.insert( modelElement, modelRootStart );
-			} );
-
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p class="foo">foobar</p></div>' );
-
-			model.change( writer => {
-				writer.setAttribute( 'class', 'bar', modelElement );
-			} );
-
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p class="bar">foobar</p></div>' );
-
-			model.change( writer => {
-				writer.removeAttribute( 'class', modelElement );
-			} );
-
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
-		} );
-
-		it( 'should convert insert/change/remove with attribute generating function as a parameter', () => {
-			const themeConverter = ( value, data ) => {
-				if ( data.item instanceof ModelElement && data.item.childCount > 0 ) {
-					value += ' fix-content';
-				}
-
-				return { key: 'class', value };
-			};
-
-			dispatcher.on( 'insert:div', insertElement( ( modelElement, viewWriter ) => viewWriter.createContainerElement( 'div' ) ) );
-			dispatcher.on( 'attribute:theme', changeAttribute( themeConverter ) );
-
-			const modelParagraph = new ModelElement( 'paragraph', { theme: 'nice' }, new ModelText( 'foobar' ) );
-			const modelDiv = new ModelElement( 'div', { theme: 'nice' } );
-
-			model.change( writer => {
-				writer.insert( [ modelParagraph, modelDiv ], modelRootStart );
-			} );
-
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p class="nice fix-content">foobar</p><div class="nice"></div></div>' );
-
-			model.change( writer => {
-				writer.setAttribute( 'theme', 'awesome', modelParagraph );
-			} );
-
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p class="awesome fix-content">foobar</p><div class="nice"></div></div>' );
-
-			model.change( writer => {
-				writer.removeAttribute( 'theme', modelParagraph );
-			} );
-
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p><div class="nice"></div></div>' );
-		} );
-
-		it( 'should be possible to override setAttribute', () => {
-			const modelElement = new ModelElement( 'paragraph', { classes: 'foo' }, new ModelText( 'foobar' ) );
-
-			dispatcher.on( 'attribute:class', ( evt, data, conversionApi ) => {
-				conversionApi.consumable.consume( data.item, 'attribute:class' );
-			}, { converterPriority: 'high' } );
-
-			model.change( writer => {
-				writer.insert( modelElement, modelRootStart );
-			} );
-
-			// No attribute set.
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
-		} );
-
-		it( 'should not convert or consume if element creator returned null', () => {
-			const callback = sinon.stub().returns( null );
-
-			dispatcher.on( 'attribute:class', changeAttribute( callback ) );
-
-			const modelElement = new ModelElement( 'paragraph', { class: 'foo' }, new ModelText( 'foobar' ) );
-
-			model.change( writer => {
-				writer.insert( modelElement, modelRootStart );
-			} );
-
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p class="foo">foobar</p></div>' );
-
-			sinon.assert.called( callback );
-		} );
-	} );
-
-	describe( 'wrap', () => {
-		it( 'should convert insert/change/remove of attribute in model into wrapping element in a view', () => {
-			const modelElement = new ModelElement( 'paragraph', null, new ModelText( 'foobar', { bold: true } ) );
-			const creator = ( modelAttributeValue, viewWriter ) => viewWriter.createAttributeElement( 'b' );
-
-			dispatcher.on( 'attribute:bold', wrap( creator ) );
-
-			model.change( writer => {
-				writer.insert( modelElement, modelRootStart );
-			} );
-
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p><b>foobar</b></p></div>' );
-
-			model.change( writer => {
-				writer.removeAttribute( 'bold', writer.createRangeIn( modelElement ) );
-			} );
-
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
-		} );
-
-		it( 'should convert insert/remove of attribute in model with wrapping element generating function as a parameter', () => {
-			const modelElement = new ModelElement( 'paragraph', null, new ModelText( 'foobar', { style: 'bold' } ) );
-
-			const elementGenerator = ( modelAttributeValue, viewWriter ) => {
-				if ( modelAttributeValue == 'bold' ) {
-					return viewWriter.createAttributeElement( 'b' );
-				}
-			};
-
-			dispatcher.on( 'attribute:style', wrap( elementGenerator ) );
-
-			model.change( writer => {
-				writer.insert( modelElement, modelRootStart );
-			} );
-
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p><b>foobar</b></p></div>' );
-
-			model.change( writer => {
-				writer.removeAttribute( 'style', writer.createRangeIn( modelElement ) );
-			} );
-
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
-		} );
-
-		it( 'should update range on re-wrapping attribute (#475)', () => {
-			const modelElement = new ModelElement( 'paragraph', null, [
-				new ModelText( 'x' ),
-				new ModelText( 'foo', { link: 'http://foo.com' } ),
-				new ModelText( 'x' )
-			] );
-
-			const elementGenerator = ( modelAttributeValue, viewWriter ) => {
-				return viewWriter.createAttributeElement( 'a', { href: modelAttributeValue } );
-			};
-
-			dispatcher.on( 'attribute:link', wrap( elementGenerator ) );
-
-			model.change( writer => {
-				writer.insert( modelElement, modelRootStart );
-			} );
-
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p>x<a href="http://foo.com">foo</a>x</p></div>' );
-
-			// Set new attribute on old link but also on non-linked characters.
-			model.change( writer => {
-				writer.setAttribute( 'link', 'http://foobar.com', writer.createRangeIn( modelElement ) );
-			} );
-
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p><a href="http://foobar.com">xfoox</a></p></div>' );
-		} );
-
-		it( 'should support unicode', () => {
-			const modelElement = new ModelElement( 'paragraph', null, [ 'நி', new ModelText( 'லைக்', { bold: true } ), 'கு' ] );
-			const creator = ( modelAttributeValue, viewWriter ) => viewWriter.createAttributeElement( 'b' );
-
-			dispatcher.on( 'attribute:bold', wrap( creator ) );
-
-			model.change( writer => {
-				writer.insert( modelElement, modelRootStart );
-			} );
-
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p>நி<b>லைக்</b>கு</p></div>' );
-
-			model.change( writer => {
-				writer.removeAttribute( 'bold', writer.createRangeIn( modelElement ) );
-			} );
-
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p>நிலைக்கு</p></div>' );
-		} );
-
-		it( 'should be possible to override wrap', () => {
-			const modelElement = new ModelElement( 'paragraph', null, new ModelText( 'foobar', { bold: true } ) );
-
-			dispatcher.on( 'attribute:bold', wrap( ( modelAttributeValue, viewWriter ) => viewWriter.createAttributeElement( 'b' ) ) );
-
-			dispatcher.on(
-				'attribute:bold',
-				wrap( ( modelAttributeValue, viewWriter ) => viewWriter.createAttributeElement( 'strong' ) ),
-				{ priority: 'high' }
-			);
-
-			model.change( writer => {
-				writer.insert( modelElement, modelRootStart );
-			} );
-
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p><strong>foobar</strong></p></div>' );
-		} );
-
-		it( 'should not convert and not consume if creator function returned null', () => {
-			const elementGenerator = () => null;
-
-			sinon.spy( dispatcher, 'fire' );
-
-			const modelElement = new ModelElement( 'paragraph', null, new ModelText( 'foobar', { italic: true } ) );
-
-			dispatcher.on( 'attribute:italic', wrap( elementGenerator ) );
-
-			const spy = sinon.spy();
-			dispatcher.on( 'attribute:italic', spy );
-
-			model.change( writer => {
-				writer.insert( modelElement, modelRootStart );
-			} );
-
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
-			expect( dispatcher.fire.calledWith( 'attribute:italic:$text' ) ).to.be.true;
-			expect( spy.called ).to.be.true;
-		} );
-	} );
-
-	describe( 'insertUIElement/removeUIElement', () => {
-		let modelText, modelElement, range;
-
-		beforeEach( () => {
-			modelText = new ModelText( 'foobar' );
-			modelElement = new ModelElement( 'paragraph', null, modelText );
-
-			model.change( writer => {
-				writer.insert( modelElement, modelRootStart );
-			} );
-		} );
-
-		describe( 'collapsed range', () => {
-			beforeEach( () => {
-				range = model.createRange( model.createPositionAt( modelElement, 3 ), model.createPositionAt( modelElement, 3 ) );
-			} );
-
-			it( 'should insert and remove ui element', () => {
-				const creator = ( data, viewWriter ) => viewWriter.createUIElement( 'span', { 'class': 'marker' } );
-
-				dispatcher.on( 'addMarker:marker', insertUIElement( creator ) );
-				dispatcher.on( 'removeMarker:marker', removeUIElement( creator ) );
-
-				model.change( writer => {
-					writer.addMarker( 'marker', { range, usingOperation: false } );
-				} );
-
-				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foo<span class="marker"></span>bar</p></div>' );
-
-				model.change( writer => {
-					writer.removeMarker( 'marker' );
-				} );
-
-				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
-			} );
-
-			it( 'should not convert if consumable was consumed', () => {
-				sinon.spy( dispatcher, 'fire' );
-
-				dispatcher.on( 'addMarker:marker', insertUIElement(
-					( data, viewWriter ) => viewWriter.createUIElement( 'span', { 'class': 'marker' } ) )
-				);
-
-				dispatcher.on( 'addMarker:marker', ( evt, data, conversionApi ) => {
-					conversionApi.consumable.consume( data.markerRange, 'addMarker:marker' );
-				}, { priority: 'high' } );
-
-				model.change( writer => {
-					writer.addMarker( 'marker', { range, usingOperation: false } );
-				} );
-
-				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
-				expect( dispatcher.fire.calledWith( 'addMarker:marker' ) );
-			} );
-
-			it( 'should not convert if creator returned null', () => {
-				dispatcher.on( 'addMarker:marker', insertUIElement( () => null ) );
-				dispatcher.on( 'removeMarker:marker', removeUIElement( () => null ) );
-
-				model.change( writer => {
-					writer.addMarker( 'marker', { range, usingOperation: false } );
-				} );
-
-				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
-
-				model.change( writer => {
-					writer.removeMarker( 'marker' );
-				} );
-
-				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
-			} );
-		} );
-
-		describe( 'non-collapsed range', () => {
-			beforeEach( () => {
-				range = model.createRange( model.createPositionAt( modelElement, 2 ), model.createPositionAt( modelElement, 5 ) );
-			} );
-
-			it( 'should insert and remove ui element - element as a creator', () => {
-				const creator = ( data, viewWriter ) => viewWriter.createUIElement( 'span', { 'class': 'marker' } );
-
-				dispatcher.on( 'addMarker:marker', insertUIElement( creator ) );
-				dispatcher.on( 'removeMarker:marker', removeUIElement( creator ) );
-
-				model.change( writer => {
-					writer.addMarker( 'marker', { range, usingOperation: false } );
-				} );
-
-				expect( viewToString( viewRoot ) )
-					.to.equal( '<div><p>fo<span class="marker"></span>oba<span class="marker"></span>r</p></div>' );
-
-				model.change( writer => {
-					writer.removeMarker( 'marker' );
-				} );
-
-				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
-			} );
-
-			it( 'should insert and remove ui element - function as a creator', () => {
-				const creator = ( data, viewWriter ) => viewWriter.createUIElement( 'span', { 'class': data.markerName } );
-
-				dispatcher.on( 'addMarker:marker', insertUIElement( creator ) );
-				dispatcher.on( 'removeMarker:marker', removeUIElement( creator ) );
-
-				model.change( writer => {
-					writer.addMarker( 'marker', { range, usingOperation: false } );
-				} );
-
-				expect( viewToString( viewRoot ) )
-					.to.equal( '<div><p>fo<span class="marker"></span>oba<span class="marker"></span>r</p></div>' );
-
-				model.change( writer => {
-					writer.removeMarker( 'marker' );
-				} );
-
-				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
-			} );
-
-			it( 'should insert and remove different opening and ending element', () => {
-				function creator( data, viewWriter ) {
-					if ( data.isOpening ) {
-						return viewWriter.createUIElement( 'span', { 'class': data.markerName, 'data-start': true } );
-					}
-
-					return viewWriter.createUIElement( 'span', { 'class': data.markerName, 'data-end': true } );
-				}
-
-				dispatcher.on( 'addMarker:marker', insertUIElement( creator ) );
-				dispatcher.on( 'removeMarker:marker', removeUIElement( creator ) );
-
-				model.change( writer => {
-					writer.addMarker( 'marker', { range, usingOperation: false } );
-				} );
-
-				expect( viewToString( viewRoot ) ).to.equal(
-					'<div><p>fo<span class="marker" data-start="true"></span>oba<span class="marker" data-end="true"></span>r</p></div>'
-				);
-
-				model.change( writer => {
-					writer.removeMarker( 'marker' );
-				} );
-
-				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
-			} );
-
-			it( 'should not convert if consumable was consumed', () => {
-				const creator = ( data, viewWriter ) => viewWriter.createUIElement( 'span', { 'class': 'marker' } );
-
-				sinon.spy( dispatcher, 'fire' );
-
-				dispatcher.on( 'addMarker:marker', insertUIElement( creator ) );
-				dispatcher.on( 'addMarker:marker', ( evt, data, conversionApi ) => {
-					conversionApi.consumable.consume( data.item, 'addMarker:marker' );
-				}, { priority: 'high' } );
-
-				model.change( writer => {
-					writer.addMarker( 'marker', { range, usingOperation: false } );
-				} );
-
-				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
-				expect( dispatcher.fire.calledWith( 'addMarker:marker' ) );
-			} );
-		} );
-	} );
-
-	// Remove converter is by default already added in `EditingController` instance.
-	describe( 'remove', () => {
-		it( 'should remove items from view accordingly to changes in model #1', () => {
-			const modelElement = new ModelElement( 'paragraph', null, new ModelText( 'foobar' ) );
-
-			model.change( writer => {
-				writer.insert( modelElement, modelRootStart );
-			} );
-
-			model.change( writer => {
-				writer.remove(
-					writer.createRange( writer.createPositionAt( modelElement, 2 ), writer.createPositionAt( modelElement, 4 ) )
-				);
-			} );
-
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foar</p></div>' );
-		} );
-
-		it( 'should be possible to overwrite', () => {
-			dispatcher.on( 'remove', evt => evt.stop(), { priority: 'high' } );
-
-			const modelElement = new ModelElement( 'paragraph', null, new ModelText( 'foobar' ) );
-
-			model.change( writer => {
-				writer.insert( modelElement, modelRootStart );
-			} );
-
-			model.change( writer => {
-				writer.remove(
-					writer.createRange( writer.createPositionAt( modelElement, 2 ), writer.createPositionAt( modelElement, 4 ) )
-				);
-			} );
-
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
-		} );
-
-		it( 'should support unicode', () => {
-			const modelElement = new ModelElement( 'paragraph', null, 'நிலைக்கு' );
-
-			model.change( writer => {
-				writer.insert( modelElement, modelRootStart );
-			} );
-
-			model.change( writer => {
-				writer.remove(
-					writer.createRange( writer.createPositionAt( modelElement, 0 ), writer.createPositionAt( modelElement, 6 ) )
-				);
-			} );
-
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p>கு</p></div>' );
-		} );
-
-		it( 'should not remove view ui elements that are placed next to removed content', () => {
-			modelRoot._appendChild( new ModelText( 'fozbar' ) );
-			viewRoot._appendChild( [
-				new ViewText( 'foz' ),
-				new ViewUIElement( 'span' ),
-				new ViewText( 'bar' )
-			] );
-
-			// Remove 'b'.
-			model.change( writer => {
-				writer.remove(
-					writer.createRange( writer.createPositionAt( modelRoot, 3 ), writer.createPositionAt( modelRoot, 4 ) )
-				);
-			} );
-
-			expect( viewToString( viewRoot ) ).to.equal( '<div>foz<span></span>ar</div>' );
-
-			// Remove 'z'.
-			model.change( writer => {
-				writer.remove(
-					writer.createRange( writer.createPositionAt( modelRoot, 2 ), writer.createPositionAt( modelRoot, 3 ) )
-				);
-			} );
-
-			expect( viewToString( viewRoot ) ).to.equal( '<div>fo<span></span>ar</div>' );
-		} );
-
-		it( 'should remove correct amount of text when it is split by view ui element', () => {
-			modelRoot._appendChild( new ModelText( 'fozbar' ) );
-			viewRoot._appendChild( [
-				new ViewText( 'foz' ),
-				new ViewUIElement( 'span' ),
-				new ViewText( 'bar' )
-			] );
-
-			// Remove 'z<span></span>b'.
-			model.change( writer => {
-				writer.remove(
-					writer.createRange( writer.createPositionAt( modelRoot, 2 ), writer.createPositionAt( modelRoot, 4 ) )
-				);
-			} );
-
-			expect( viewToString( viewRoot ) ).to.equal( '<div>foar</div>' );
-		} );
-
-		it( 'should unbind elements', () => {
-			const modelElement = new ModelElement( 'paragraph' );
-
-			model.change( writer => {
-				writer.insert( modelElement, modelRootStart );
-			} );
-
-			const viewElement = controller.mapper.toViewElement( modelElement );
-			expect( viewElement ).not.to.be.undefined;
-			expect( controller.mapper.toModelElement( viewElement ) ).to.equal( modelElement );
-
-			model.change( writer => {
-				writer.remove( modelElement );
-			} );
-
-			expect( controller.mapper.toViewElement( modelElement ) ).to.be.undefined;
-			expect( controller.mapper.toModelElement( viewElement ) ).to.be.undefined;
-		} );
-
-		it( 'should not break when remove() is used as part of unwrapping', () => {
-			const modelP = new ModelElement( 'paragraph', null, new ModelText( 'foo' ) );
-			const modelWidget = new ModelElement( 'widget', null, modelP );
-
-			dispatcher.on( 'insert:widget', insertElement(
-				( modelElement, viewWriter ) => viewWriter.createContainerElement( 'widget' ) )
-			);
-
-			model.change( writer => {
-				writer.insert( modelWidget, modelRootStart );
-			} );
-
-			expect( viewToString( viewRoot ) ).to.equal( '<div><widget><p>foo</p></widget></div>' );
-
-			const viewP = controller.mapper.toViewElement( modelP );
-
-			expect( viewP ).not.to.be.undefined;
-
-			model.change( writer => {
-				writer.unwrap( modelWidget );
-			} );
-
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foo</p></div>' );
-			// `modelP` is now bound with newly created view element.
-			expect( controller.mapper.toViewElement( modelP ) ).not.to.equal( viewP );
-			// `viewP` is no longer bound with model element.
-			expect( controller.mapper.toModelElement( viewP ) ).to.be.undefined;
-			// View element from view root is bound to `modelP`.
-			expect( controller.mapper.toModelElement( viewRoot.getChild( 0 ) ) ).to.equal( modelP );
-		} );
-
-		it( 'should work correctly if container element after ui element is removed', () => {
-			// Prepare a model and view structure.
-			// This is done outside of conversion to put view ui elements inside easily.
-			const modelP1 = new ModelElement( 'paragraph' );
-			const modelP2 = new ModelElement( 'paragraph' );
-
-			const viewP1 = new ViewContainerElement( 'p' );
-			const viewUi1 = new ViewUIElement( 'span' );
-			const viewUi2 = new ViewUIElement( 'span' );
-			const viewP2 = new ViewContainerElement( 'p' );
-
-			modelRoot._appendChild( [ modelP1, modelP2 ] );
-			viewRoot._appendChild( [ viewP1, viewUi1, viewUi2, viewP2 ] );
-
-			controller.mapper.bindElements( modelP1, viewP1 );
-			controller.mapper.bindElements( modelP2, viewP2 );
-
-			// Remove second paragraph element.
-			model.change( writer => {
-				writer.remove( writer.createRange( writer.createPositionAt( modelRoot, 1 ), writer.createPositionAt( modelRoot, 2 ) ) );
-			} );
-
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p></p><span></span><span></span></div>' );
-		} );
-
-		it( 'should work correctly if container element after text node is removed', () => {
-			const modelText = new ModelText( 'foo' );
-			const modelP = new ModelElement( 'paragraph' );
-
-			model.change( writer => {
-				writer.insert( [ modelText, modelP ], modelRootStart );
-			} );
-
-			model.change( writer => {
-				writer.remove( modelP );
-			} );
-
-			expect( viewToString( viewRoot ) ).to.equal( '<div>foo</div>' );
-		} );
-	} );
-
-	describe( 'highlight', () => {
-		describe( 'on text', () => {
-			const highlightDescriptor = {
-				classes: 'highlight-class',
-				priority: 7,
-				attributes: { title: 'title' }
-			};
-
-			let markerRange;
-
-			beforeEach( () => {
-				const modelElement1 = new ModelElement( 'paragraph', null, new ModelText( 'foo' ) );
-				const modelElement2 = new ModelElement( 'paragraph', null, new ModelText( 'bar' ) );
-
-				model.change( writer => {
-					writer.insert( [ modelElement1, modelElement2 ], modelRootStart );
-				} );
-
-				markerRange = model.createRangeIn( modelRoot );
-			} );
-
-			it( 'should wrap and unwrap text nodes', () => {
-				dispatcher.on( 'addMarker:marker', highlightText( highlightDescriptor ) );
-				dispatcher.on( 'addMarker:marker', highlightElement( highlightDescriptor ) );
-				dispatcher.on( 'removeMarker:marker', removeHighlight( highlightDescriptor ) );
-
-				model.change( writer => {
-					writer.addMarker( 'marker', { range: markerRange, usingOperation: false } );
-				} );
-
-				expect( viewToString( viewRoot ) ).to.equal(
-					'<div>' +
-						'<p>' +
-							'<span class="highlight-class" title="title">foo</span>' +
-						'</p>' +
-						'<p>' +
-							'<span class="highlight-class" title="title">bar</span>' +
-						'</p>' +
-					'</div>'
-				);
-
-				model.change( writer => {
-					writer.removeMarker( 'marker' );
-				} );
-
-				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foo</p><p>bar</p></div>' );
-			} );
-
-			it( 'should be possible to overwrite', () => {
-				dispatcher.on( 'addMarker:marker', highlightText( highlightDescriptor ) );
-				dispatcher.on( 'addMarker:marker', highlightElement( highlightDescriptor ) );
-				dispatcher.on( 'removeMarker:marker', removeHighlight( highlightDescriptor ) );
-
-				const newDescriptor = { classes: 'override-class' };
-
-				dispatcher.on( 'addMarker:marker', highlightText( newDescriptor ), { priority: 'high' } );
-				dispatcher.on( 'addMarker:marker', highlightElement( newDescriptor ), { priority: 'high' } );
-				dispatcher.on( 'removeMarker:marker', removeHighlight( newDescriptor ), { priority: 'high' } );
-
-				model.change( writer => {
-					writer.addMarker( 'marker', { range: markerRange, usingOperation: false } );
-				} );
-
-				expect( viewToString( viewRoot ) ).to.equal(
-					'<div>' +
-						'<p>' +
-							'<span class="override-class">foo</span>' +
-						'</p>' +
-						'<p>' +
-							'<span class="override-class">bar</span>' +
-						'</p>' +
-					'</div>'
-				);
-
-				model.change( writer => {
-					writer.removeMarker( 'marker' );
-				} );
-
-				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foo</p><p>bar</p></div>' );
-			} );
-
-			it( 'should do nothing if descriptor is not provided or generating function returns null', () => {
-				dispatcher.on( 'addMarker:marker', highlightText( () => null ), { priority: 'high' } );
-				dispatcher.on( 'addMarker:marker', highlightElement( () => null ), { priority: 'high' } );
-				dispatcher.on( 'removeMarker:marker', removeHighlight( () => null ), { priority: 'high' } );
-
-				model.change( writer => {
-					writer.addMarker( 'marker', { range: markerRange, usingOperation: false } );
-				} );
-
-				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foo</p><p>bar</p></div>' );
-
-				model.change( writer => {
-					writer.removeMarker( 'marker' );
-				} );
-
-				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foo</p><p>bar</p></div>' );
-			} );
-
-			it( 'should do nothing if collapsed marker is converted', () => {
-				const descriptor = { classes: 'foo' };
-
-				dispatcher.on( 'addMarker:marker', highlightText( descriptor ), { priority: 'high' } );
-				dispatcher.on( 'addMarker:marker', highlightElement( descriptor ), { priority: 'high' } );
-				dispatcher.on( 'removeMarker:marker', removeHighlight( descriptor ), { priority: 'high' } );
-
-				markerRange = model.createRange( model.createPositionAt( modelRoot, 0 ), model.createPositionAt( modelRoot, 0 ) );
-
-				model.change( writer => {
-					writer.addMarker( 'marker', { range: markerRange, usingOperation: false } );
-				} );
-
-				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foo</p><p>bar</p></div>' );
-
-				model.change( () => {
-					model.markers._remove( 'marker' );
-				} );
-
-				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foo</p><p>bar</p></div>' );
-			} );
-
-			it( 'should correctly wrap and unwrap multiple, intersecting markers', () => {
-				const descriptorFoo = { classes: 'foo' };
-				const descriptorBar = { classes: 'bar' };
-				const descriptorXyz = { classes: 'xyz' };
-
-				dispatcher.on( 'addMarker:markerFoo', highlightText( descriptorFoo ) );
-				dispatcher.on( 'addMarker:markerBar', highlightText( descriptorBar ) );
-				dispatcher.on( 'addMarker:markerXyz', highlightText( descriptorXyz ) );
-
-				dispatcher.on( 'removeMarker:markerFoo', removeHighlight( descriptorFoo ) );
-				dispatcher.on( 'removeMarker:markerBar', removeHighlight( descriptorBar ) );
-				dispatcher.on( 'removeMarker:markerXyz', removeHighlight( descriptorXyz ) );
-
-				const p1 = modelRoot.getChild( 0 );
-				const p2 = modelRoot.getChild( 1 );
-
-				model.change( writer => {
-					const range = writer.createRange( writer.createPositionAt( p1, 0 ), writer.createPositionAt( p1, 3 ) );
-					writer.addMarker( 'markerFoo', { range, usingOperation: false } );
-				} );
-
-				expect( viewToString( viewRoot ) ).to.equal(
-					'<div>' +
-						'<p>' +
-							'<span class="foo">foo</span>' +
-						'</p>' +
-						'<p>bar</p>' +
-					'</div>'
-				);
-
-				model.change( writer => {
-					const range = writer.createRange( writer.createPositionAt( p1, 1 ), writer.createPositionAt( p2, 2 ) );
-					writer.addMarker( 'markerBar', { range, usingOperation: false } );
-				} );
-
-				expect( viewToString( viewRoot ) ).to.equal(
-					'<div>' +
-						'<p>' +
-							'<span class="foo">f</span>' +
-							'<span class="bar">' +
-								'<span class="foo">oo</span>' +
-							'</span>' +
-						'</p>' +
-						'<p>' +
-							'<span class="bar">ba</span>' +
-							'r' +
-						'</p>' +
-					'</div>'
-				);
-
-				model.change( writer => {
-					const range = writer.createRange( writer.createPositionAt( p1, 2 ), writer.createPositionAt( p2, 3 ) );
-					writer.addMarker( 'markerXyz', { range, usingOperation: false } );
-				} );
-
-				expect( viewToString( viewRoot ) ).to.equal(
-					'<div>' +
-						'<p>' +
-							'<span class="foo">f</span>' +
-							'<span class="bar">' +
-								'<span class="foo">' +
-									'o' +
-									'<span class="xyz">o</span>' +
-								'</span>' +
-							'</span>' +
-						'</p>' +
-						'<p>' +
-							'<span class="bar">' +
-								'<span class="xyz">ba</span>' +
-							'</span>' +
-							'<span class="xyz">r</span>' +
-						'</p>' +
-					'</div>'
-				);
-
-				model.change( writer => {
-					writer.removeMarker( 'markerBar' );
-				} );
-
-				expect( viewToString( viewRoot ) ).to.equal(
-					'<div>' +
-						'<p>' +
-							'<span class="foo">' +
-								'fo' +
-								'<span class="xyz">o</span>' +
-							'</span>' +
-						'</p>' +
-						'<p>' +
-							'<span class="xyz">bar</span>' +
-						'</p>' +
-					'</div>'
-				);
-
-				model.change( writer => {
-					writer.removeMarker( 'markerFoo' );
-				} );
-
-				expect( viewToString( viewRoot ) ).to.equal(
-					'<div>' +
-						'<p>' +
-							'fo' +
-							'<span class="xyz">o</span>' +
-						'</p>' +
-						'<p>' +
-							'<span class="xyz">bar</span>' +
-						'</p>' +
-					'</div>'
-				);
-
-				model.change( writer => {
-					writer.removeMarker( 'markerXyz' );
-				} );
-
-				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foo</p><p>bar</p></div>' );
-			} );
-
-			it( 'should do nothing if marker is applied and removed on empty-ish range', () => {
-				dispatcher.on( 'addMarker:marker', highlightText( highlightDescriptor ) );
-				dispatcher.on( 'removeMarker:marker', removeHighlight( highlightDescriptor ) );
-
-				const p1 = modelRoot.getChild( 0 );
-				const p2 = modelRoot.getChild( 1 );
-
-				const markerRange = model.createRange( model.createPositionAt( p1, 3 ), model.createPositionAt( p2, 0 ) );
-
-				model.change( writer => {
-					writer.addMarker( 'marker', { range: markerRange, usingOperation: false } );
-				} );
-
-				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foo</p><p>bar</p></div>' );
-
-				model.change( writer => {
-					writer.removeMarker( 'marker', { range: markerRange, usingOperation: false } );
-				} );
-
-				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foo</p><p>bar</p></div>' );
-			} );
-		} );
-
-		describe( 'on element', () => {
-			const highlightDescriptor = {
-				classes: 'highlight-class',
-				priority: 7,
-				attributes: { title: 'title' },
-				id: 'customId'
-			};
-
-			let markerRange;
-
-			beforeEach( () => {
-				// Provide converter for div element. View div element will have custom highlight handling.
-				dispatcher.on( 'insert:div', insertElement( () => {
-					const viewContainer = new ViewContainerElement( 'div' );
-
-					viewContainer._setCustomProperty( 'addHighlight', ( element, descriptor, writer ) => {
-						writer.addClass( descriptor.classes, element );
-					} );
-
-					viewContainer._setCustomProperty( 'removeHighlight', ( element, id, writer ) => {
-						writer.setAttribute( 'class', '', element );
-					} );
-
-					return viewContainer;
-				} ) );
-
-				const modelElement = new ModelElement( 'div', null, new ModelText( 'foo' ) );
-
-				model.change( writer => {
-					writer.insert( modelElement, modelRootStart );
-				} );
-
-				markerRange = model.createRangeOn( modelElement );
-
-				dispatcher.on( 'addMarker:marker', highlightText( highlightDescriptor ) );
-				dispatcher.on( 'addMarker:marker', highlightElement( highlightDescriptor ) );
-				dispatcher.on( 'removeMarker:marker', removeHighlight( highlightDescriptor ) );
-			} );
-
-			it( 'should use addHighlight and removeHighlight on elements and not convert children nodes', () => {
-				model.change( writer => {
-					writer.addMarker( 'marker', { range: markerRange, usingOperation: false } );
-				} );
-
-				expect( viewToString( viewRoot ) ).to.equal(
-					'<div>' +
-						'<div class="highlight-class">' +
-							'foo' +
-						'</div>' +
-					'</div>'
-				);
-
-				model.change( writer => {
-					writer.removeMarker( 'marker' );
-				} );
-
-				expect( viewToString( viewRoot ) ).to.equal( '<div><div>foo</div></div>' );
-			} );
-
-			it( 'should be possible to override', () => {
-				const newDescriptor = { classes: 'override-class' };
-
-				dispatcher.on( 'addMarker:marker', highlightText( newDescriptor ), { priority: 'high' } );
-				dispatcher.on( 'addMarker:marker', highlightElement( newDescriptor ), { priority: 'high' } );
-				dispatcher.on( 'removeMarker:marker', removeHighlight( newDescriptor ), { priority: 'high' } );
-
-				model.change( writer => {
-					writer.addMarker( 'marker', { range: markerRange, usingOperation: false } );
-				} );
-
-				expect( viewToString( viewRoot ) ).to.equal(
-					'<div>' +
-						'<div class="override-class">' +
-							'foo' +
-						'</div>' +
-					'</div>'
-				);
-
-				model.change( writer => {
-					writer.removeMarker( 'marker' );
-				} );
-
-				expect( viewToString( viewRoot ) ).to.equal( '<div><div>foo</div></div>' );
-			} );
-
-			it( 'should use default priority and id if not provided', () => {
-				const viewDiv = viewRoot.getChild( 0 );
-
-				dispatcher.on( 'addMarker:marker2', highlightText( () => null ) );
-				dispatcher.on( 'addMarker:marker2', highlightElement( () => null ) );
-				dispatcher.on( 'removeMarker:marker2', removeHighlight( () => null ) );
-
-				viewDiv._setCustomProperty( 'addHighlight', ( element, descriptor ) => {
-					expect( descriptor.priority ).to.equal( ViewAttributeElement.DEFAULT_PRIORITY );
-					expect( descriptor.id ).to.equal( 'marker:foo-bar-baz' );
-				} );
-
-				viewDiv._setCustomProperty( 'removeHighlight', ( element, id ) => {
-					expect( id ).to.equal( 'marker:foo-bar-baz' );
-				} );
-
-				model.change( writer => {
-					writer.addMarker( 'marker2', { range: markerRange, usingOperation: false } );
-				} );
-			} );
-
-			it( 'should do nothing if descriptor is not provided', () => {
-				dispatcher.on( 'addMarker:marker2', highlightText( () => null ) );
-				dispatcher.on( 'addMarker:marker2', highlightElement( () => null ) );
-				dispatcher.on( 'removeMarker:marker2', removeHighlight( () => null ) );
-
-				model.change( writer => {
-					writer.addMarker( 'marker2', { range: markerRange, usingOperation: false } );
-				} );
-
-				expect( viewToString( viewRoot ) ).to.equal( '<div><div>foo</div></div>' );
-
-				model.change( writer => {
-					writer.removeMarker( 'marker2' );
-				} );
-
-				expect( viewToString( viewRoot ) ).to.equal( '<div><div>foo</div></div>' );
-			} );
-		} );
-	} );
-
-	describe( 'createViewElementFromHighlightDescriptor()', () => {
-		it( 'should return attribute element from descriptor object', () => {
-			const descriptor = {
-				classes: 'foo-class',
-				attributes: { one: '1', two: '2' },
-				priority: 7,
-			};
-			const element = createViewElementFromHighlightDescriptor( descriptor );
-
-			expect( element.is( 'attributeElement' ) ).to.be.true;
-			expect( element.name ).to.equal( 'span' );
-			expect( element.priority ).to.equal( 7 );
-			expect( element.hasClass( 'foo-class' ) ).to.be.true;
-
-			for ( const key of Object.keys( descriptor.attributes ) ) {
-				expect( element.getAttribute( key ) ).to.equal( descriptor.attributes[ key ] );
-			}
-		} );
-
-		it( 'should return attribute element from descriptor object - array with classes', () => {
-			const descriptor = {
-				classes: [ 'foo-class', 'bar-class' ],
-				attributes: { one: '1', two: '2' },
-				priority: 7,
-			};
-			const element = createViewElementFromHighlightDescriptor( descriptor );
-
-			expect( element.is( 'attributeElement' ) ).to.be.true;
-			expect( element.name ).to.equal( 'span' );
-			expect( element.priority ).to.equal( 7 );
-			expect( element.hasClass( 'foo-class' ) ).to.be.true;
-			expect( element.hasClass( 'bar-class' ) ).to.be.true;
-
-			for ( const key of Object.keys( descriptor.attributes ) ) {
-				expect( element.getAttribute( key ) ).to.equal( descriptor.attributes[ key ] );
-			}
-		} );
-
-		it( 'should create element without class', () => {
-			const descriptor = {
-				attributes: { one: '1', two: '2' },
-				priority: 7,
-			};
-			const element = createViewElementFromHighlightDescriptor( descriptor );
-
-			expect( element.is( 'attributeElement' ) ).to.be.true;
-			expect( element.name ).to.equal( 'span' );
-			expect( element.priority ).to.equal( 7 );
-
-			for ( const key of Object.keys( descriptor.attributes ) ) {
-				expect( element.getAttribute( key ) ).to.equal( descriptor.attributes[ key ] );
-			}
-		} );
-
-		it( 'should create element without priority', () => {
-			const descriptor = {
-				classes: 'foo-class',
-				attributes: { one: '1', two: '2' },
-			};
-			const element = createViewElementFromHighlightDescriptor( descriptor );
-
-			expect( element.is( 'attributeElement' ) ).to.be.true;
-			expect( element.name ).to.equal( 'span' );
-			expect( element.priority ).to.equal( ViewAttributeElement.DEFAULT_PRIORITY );
-			expect( element.hasClass( 'foo-class' ) ).to.be.true;
-
-			for ( const key of Object.keys( descriptor.attributes ) ) {
-				expect( element.getAttribute( key ) ).to.equal( descriptor.attributes[ key ] );
-			}
-		} );
-
-		it( 'should create element without attributes', () => {
-			const descriptor = {
-				classes: 'foo-class',
-				priority: 7
-			};
-			const element = createViewElementFromHighlightDescriptor( descriptor );
-
-			expect( element.is( 'attributeElement' ) ).to.be.true;
-			expect( element.name ).to.equal( 'span' );
-			expect( element.priority ).to.equal( 7 );
-			expect( element.hasClass( 'foo-class' ) ).to.be.true;
-		} );
-	} );
-} );

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

@@ -1,602 +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 {
-	insertElement,
-	insertText,
-	wrap,
-	highlightElement,
-	highlightText,
-	removeHighlight
-} from '../../src/conversion/downcast-converters';
-
-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, highlightDescriptor;
-
-	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 );
-
-		highlightDescriptor = { classes: 'marker', priority: 1 };
-
-		dispatcher = new DowncastDispatcher( { mapper, viewSelection } );
-
-		dispatcher.on( 'insert:$text', insertText() );
-
-		const strongCreator = ( modelAttributeValue, viewWriter ) => viewWriter.createAttributeElement( 'strong' );
-		dispatcher.on( 'attribute:bold', wrap( strongCreator ) );
-
-		dispatcher.on( 'addMarker:marker', highlightText( highlightDescriptor ) );
-		dispatcher.on( 'addMarker:marker', highlightElement( highlightDescriptor ) );
-		dispatcher.on( 'removeMarker:marker', removeHighlight( highlightDescriptor ) );
-
-		// 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', () => {
-				dispatcher.on( 'addMarker:marker2', highlightText(
-					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', () => {
-				dispatcher.on( 'addMarker:marker3', highlightText( () => 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' } );
-
-			// "Universal" converter to convert table structure.
-			const containerCreator = ( modelElement, viewWriter ) => viewWriter.createContainerElement( modelElement.name );
-			const tableConverter = insertElement( containerCreator );
-			dispatcher.on( 'insert:table', tableConverter );
-			dispatcher.on( 'insert:tr', tableConverter );
-			dispatcher.on( 'insert:td', tableConverter );
-
-			// 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>' );
-	}
-} );

+ 99 - 19
packages/ckeditor5-engine/tests/conversion/downcastdispatcher.js

@@ -319,7 +319,7 @@ describe( 'DowncastDispatcher', () => {
 
 
 			dispatcher.convertSelection( doc.selection, model.markers, [] );
 			dispatcher.convertSelection( doc.selection, model.markers, [] );
 
 
-			expect( dispatcher.fire.calledWith( 'attribute:bold' ) ).to.be.true;
+			expect( dispatcher.fire.calledWith( 'attribute:bold:$text' ) ).to.be.true;
 		} );
 		} );
 
 
 		it( 'should not fire attributes events if attribute has been consumed', () => {
 		it( 'should not fire attributes events if attribute has been consumed', () => {
@@ -446,22 +446,29 @@ describe( 'DowncastDispatcher', () => {
 	} );
 	} );
 
 
 	describe( 'convertMarkerAdd', () => {
 	describe( 'convertMarkerAdd', () => {
-		let range, element, text;
+		let element, text;
 
 
 		beforeEach( () => {
 		beforeEach( () => {
 			text = new ModelText( 'foo bar baz' );
 			text = new ModelText( 'foo bar baz' );
 			element = new ModelElement( 'paragraph', null, [ text ] );
 			element = new ModelElement( 'paragraph', null, [ text ] );
 			root._appendChild( [ element ] );
 			root._appendChild( [ element ] );
-
-			range = model.createRange( model.createPositionAt( element, 0 ), model.createPositionAt( element, 4 ) );
 		} );
 		} );
 
 
-		it( 'should fire addMarker event', () => {
-			sinon.spy( dispatcher, 'fire' );
+		it( 'should fire addMarker event for whole collapsed marker', () => {
+			const range = model.createRange( model.createPositionAt( element, 2 ), model.createPositionAt( element, 2 ) );
+
+			const spy = sinon.spy();
+
+			dispatcher.on( 'addMarker:name', ( evt, data ) => {
+				spy();
+
+				expect( data.markerName ).to.equal( 'name' );
+				expect( data.markerRange.isEqual( range ) ).to.be.true;
+			} );
 
 
 			dispatcher.convertMarkerAdd( 'name', range );
 			dispatcher.convertMarkerAdd( 'name', range );
 
 
-			expect( dispatcher.fire.calledWith( 'addMarker:name' ) ).to.be.true;
+			expect( spy.calledOnce ).to.be.true;
 		} );
 		} );
 
 
 		it( 'should not convert marker if it is in graveyard', () => {
 		it( 'should not convert marker if it is in graveyard', () => {
@@ -483,37 +490,110 @@ describe( 'DowncastDispatcher', () => {
 			expect( dispatcher.fire.called ).to.be.false;
 			expect( dispatcher.fire.called ).to.be.false;
 		} );
 		} );
 
 
-		it( 'should fire conversion for each item in the range', () => {
-			range = model.createRangeIn( root );
+		it( 'should fire addMarker event for whole non-collapsed marker and for each item in the range', () => {
+			const range = model.createRangeIn( root );
+
+			const spyWholeRange = sinon.spy();
+
+			dispatcher.on( 'addMarker:name', ( evt, data ) => {
+				if ( !data.item ) {
+					spyWholeRange();
+
+					expect( data.markerName ).to.equal( 'name' );
+					expect( data.markerRange.isEqual( range ) ).to.be.true;
+				}
+			} );
 
 
+			const spyItems = sinon.spy();
 			const items = [];
 			const items = [];
 
 
 			dispatcher.on( 'addMarker:name', ( evt, data, conversionApi ) => {
 			dispatcher.on( 'addMarker:name', ( evt, data, conversionApi ) => {
-				expect( data.markerName ).to.equal( 'name' );
-				expect( data.markerRange.isEqual( range ) ).to.be.true;
-				expect( conversionApi.consumable.test( data.item, 'addMarker:name' ) );
+				if ( data.item ) {
+					spyItems();
 
 
-				items.push( data.item );
+					expect( data.markerName ).to.equal( 'name' );
+					expect( data.markerRange.isEqual( range ) ).to.be.true;
+					expect( conversionApi.consumable.test( data.item, 'addMarker:name' ) );
+
+					items.push( data.item );
+				}
 			} );
 			} );
 
 
 			dispatcher.convertMarkerAdd( 'name', range );
 			dispatcher.convertMarkerAdd( 'name', range );
 
 
+			expect( spyWholeRange.calledOnce ).to.be.true;
+			expect( spyItems.calledTwice ).to.be.true;
+
 			expect( items[ 0 ] ).to.equal( element );
 			expect( items[ 0 ] ).to.equal( element );
 			expect( items[ 1 ].data ).to.equal( text.data );
 			expect( items[ 1 ].data ).to.equal( text.data );
 		} );
 		} );
 
 
-		it( 'should be possible to override', () => {
-			range = model.createRangeIn( root );
+		it( 'should not fire conversion for non-collapsed marker items if marker was consumed in earlier event', () => {
+			const range = model.createRangeIn( root );
+
+			dispatcher.on( 'addMarker:name', ( evt, data, conversionApi ) => {
+				if ( !data.item ) {
+					conversionApi.consumable.consume( data.markerRange, evt.name );
+				}
+			}, { priority: 'high' } );
+
+			const spyItems = sinon.spy();
+
+			dispatcher.on( 'addMarker:name', ( evt, data ) => {
+				if ( data.item ) {
+					spyItems();
+				}
+			} );
+
+			dispatcher.convertMarkerAdd( 'name', range );
+
+			expect( spyItems.called ).to.be.false;
+		} );
+
+		it( 'should be possible to override #1', () => {
+			const range = model.createRangeIn( root );
 
 
 			const addMarkerSpy = sinon.spy();
 			const addMarkerSpy = sinon.spy();
 			const highAddMarkerSpy = sinon.spy();
 			const highAddMarkerSpy = sinon.spy();
 
 
-			dispatcher.on( 'addMarker:marker', addMarkerSpy );
+			dispatcher.on( 'addMarker:marker', ( evt, data ) => {
+				if ( !data.item ) {
+					addMarkerSpy();
+				}
+			} );
 
 
-			dispatcher.on( 'addMarker:marker', evt => {
-				highAddMarkerSpy();
+			dispatcher.on( 'addMarker:marker', ( evt, data ) => {
+				if ( !data.item ) {
+					highAddMarkerSpy();
 
 
-				evt.stop();
+					evt.stop();
+				}
+			}, { priority: 'high' } );
+
+			dispatcher.convertMarkerAdd( 'marker', range );
+
+			expect( addMarkerSpy.called ).to.be.false;
+			expect( highAddMarkerSpy.calledOnce ).to.be.true;
+		} );
+
+		it( 'should be possible to override #2', () => {
+			const range = model.createRangeIn( root );
+
+			const addMarkerSpy = sinon.spy();
+			const highAddMarkerSpy = sinon.spy();
+
+			dispatcher.on( 'addMarker:marker', ( evt, data ) => {
+				if ( data.item ) {
+					addMarkerSpy();
+				}
+			} );
+
+			dispatcher.on( 'addMarker:marker', ( evt, data ) => {
+				if ( data.item ) {
+					highAddMarkerSpy();
+
+					evt.stop();
+				}
 			}, { priority: 'high' } );
 			}, { priority: 'high' } );
 
 
 			dispatcher.convertMarkerAdd( 'marker', range );
 			dispatcher.convertMarkerAdd( 'marker', range );

+ 2388 - 0
packages/ckeditor5-engine/tests/conversion/downcasthelpers.js

@@ -0,0 +1,2388 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import EditingController from '../../src/controller/editingcontroller';
+
+import Conversion from '../../src/conversion/conversion';
+
+import Model from '../../src/model/model';
+import ModelElement from '../../src/model/element';
+import ModelText from '../../src/model/text';
+
+import ViewElement from '../../src/view/element';
+import ViewAttributeElement from '../../src/view/attributeelement';
+import ViewContainerElement from '../../src/view/containerelement';
+import ViewUIElement from '../../src/view/uielement';
+import ViewText from '../../src/view/text';
+
+import log from '@ckeditor/ckeditor5-utils/src/log';
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+
+import DowncastHelpers, {
+	clearAttributes,
+	convertCollapsedSelection,
+	convertRangeSelection,
+	createViewElementFromHighlightDescriptor,
+	insertText
+} from '../../src/conversion/downcasthelpers';
+
+import Mapper from '../../src/conversion/mapper';
+import DowncastDispatcher from '../../src/conversion/downcastdispatcher';
+import { stringify as stringifyView } from '../../src/dev-utils/view';
+import View from '../../src/view/view';
+import createViewRoot from '../view/_utils/createroot';
+import { setData as setModelData } from '../../src/dev-utils/model';
+
+describe( 'DowncastHelpers', () => {
+	let conversion, model, modelRoot, viewRoot, downcastHelpers, controller;
+
+	let modelRootStart;
+
+	beforeEach( () => {
+		model = new Model();
+		const modelDoc = model.document;
+		modelRoot = modelDoc.createRoot();
+
+		controller = new EditingController( model );
+
+		// Set name of view root the same as dom root.
+		// This is a mock of attaching view root to dom root.
+		controller.view.document.getRoot()._name = 'div';
+
+		viewRoot = controller.view.document.getRoot();
+
+		downcastHelpers = new DowncastHelpers( controller.downcastDispatcher );
+
+		conversion = new Conversion();
+		conversion.register( 'downcast', downcastHelpers );
+
+		modelRootStart = model.createPositionAt( modelRoot, 0 );
+	} );
+
+	describe( 'elementToElement()', () => {
+		it( 'should be chainable', () => {
+			expect( downcastHelpers.elementToElement( { model: 'paragraph', view: 'p' } ) ).to.equal( downcastHelpers );
+		} );
+
+		it( 'config.view is a string', () => {
+			downcastHelpers.elementToElement( { model: 'paragraph', view: 'p' } );
+
+			model.change( writer => {
+				writer.insertElement( 'paragraph', modelRoot, 0 );
+			} );
+
+			expectResult( '<p></p>' );
+		} );
+
+		it( 'can be overwritten using converterPriority', () => {
+			downcastHelpers.elementToElement( { model: 'paragraph', view: 'p' } );
+			downcastHelpers.elementToElement( { model: 'paragraph', view: 'foo', converterPriority: 'high' } );
+
+			model.change( writer => {
+				writer.insertElement( 'paragraph', modelRoot, 0 );
+			} );
+
+			expectResult( '<foo></foo>' );
+		} );
+
+		it( 'config.view is a view element definition', () => {
+			downcastHelpers.elementToElement( {
+				model: 'fancyParagraph',
+				view: {
+					name: 'p',
+					classes: 'fancy'
+				}
+			} );
+
+			model.change( writer => {
+				writer.insertElement( 'fancyParagraph', modelRoot, 0 );
+			} );
+
+			expectResult( '<p class="fancy"></p>' );
+		} );
+
+		it( 'config.view is a function', () => {
+			downcastHelpers.elementToElement( {
+				model: 'heading',
+				view: ( modelElement, viewWriter ) => viewWriter.createContainerElement( 'h' + modelElement.getAttribute( 'level' ) )
+			} );
+
+			model.change( writer => {
+				writer.insertElement( 'heading', { level: 2 }, modelRoot, 0 );
+			} );
+
+			expectResult( '<h2></h2>' );
+		} );
+	} );
+
+	describe( 'attributeToElement()', () => {
+		beforeEach( () => {
+			downcastHelpers.elementToElement( { model: 'paragraph', view: 'p' } );
+		} );
+
+		it( 'should be chainable', () => {
+			expect( downcastHelpers.attributeToElement( { model: 'bold', view: 'strong' } ) ).to.equal( downcastHelpers );
+		} );
+
+		it( 'config.view is a string', () => {
+			downcastHelpers.attributeToElement( { model: 'bold', view: 'strong' } );
+
+			model.change( writer => {
+				writer.insertText( 'foo', { bold: true }, modelRoot, 0 );
+			} );
+
+			expectResult( '<strong>foo</strong>' );
+		} );
+
+		it( 'can be overwritten using converterPriority', () => {
+			downcastHelpers.attributeToElement( { model: 'bold', view: 'strong' } );
+			downcastHelpers.attributeToElement( { model: 'bold', view: 'b', converterPriority: 'high' } );
+
+			model.change( writer => {
+				writer.insertText( 'foo', { bold: true }, modelRoot, 0 );
+			} );
+
+			expectResult( '<b>foo</b>' );
+		} );
+
+		it( 'config.view is a view element definition', () => {
+			downcastHelpers.attributeToElement( {
+				model: 'invert',
+				view: {
+					name: 'span',
+					classes: [ 'font-light', 'bg-dark' ]
+				}
+			} );
+
+			model.change( writer => {
+				writer.insertText( 'foo', { invert: true }, modelRoot, 0 );
+			} );
+
+			expectResult( '<span class="bg-dark font-light">foo</span>' );
+			expect( viewRoot.getChild( 0 ).priority ).to.equal( ViewAttributeElement.DEFAULT_PRIORITY );
+		} );
+
+		it( 'config.view allows specifying the element\'s priority', () => {
+			downcastHelpers.attributeToElement( {
+				model: 'invert',
+				view: {
+					name: 'span',
+					priority: 5
+				}
+			} );
+
+			model.change( writer => {
+				writer.insertText( 'foo', { invert: true }, modelRoot, 0 );
+			} );
+
+			expect( viewRoot.getChild( 0 ).priority ).to.equal( 5 );
+		} );
+
+		it( 'model attribute value is enum', () => {
+			downcastHelpers.attributeToElement( {
+				model: {
+					key: 'fontSize',
+					values: [ 'big', 'small' ]
+				},
+				view: {
+					big: {
+						name: 'span',
+						styles: {
+							'font-size': '1.2em'
+						}
+					},
+					small: {
+						name: 'span',
+						styles: {
+							'font-size': '0.8em'
+						},
+						priority: 5
+					}
+				}
+			} );
+
+			model.change( writer => {
+				writer.insertText( 'foo', { fontSize: 'big' }, modelRoot, 0 );
+			} );
+
+			expect( viewRoot.getChild( 0 ).priority ).to.equal( ViewAttributeElement.DEFAULT_PRIORITY );
+			expectResult( '<span style="font-size:1.2em">foo</span>' );
+
+			model.change( writer => {
+				writer.setAttribute( 'fontSize', 'small', modelRoot.getChild( 0 ) );
+			} );
+
+			expectResult( '<span style="font-size:0.8em">foo</span>' );
+			expect( viewRoot.getChild( 0 ).priority ).to.equal( 5 );
+
+			model.change( writer => {
+				writer.removeAttribute( 'fontSize', modelRoot.getChild( 0 ) );
+			} );
+
+			expectResult( 'foo' );
+		} );
+
+		it( 'config.view is a function', () => {
+			downcastHelpers.attributeToElement( {
+				model: 'bold',
+				view: ( modelAttributeValue, viewWriter ) => {
+					return viewWriter.createAttributeElement( 'span', { style: 'font-weight:' + modelAttributeValue } );
+				}
+			} );
+
+			model.change( writer => {
+				writer.insertText( 'foo', { bold: '500' }, modelRoot, 0 );
+			} );
+
+			expectResult( '<span style="font-weight:500">foo</span>' );
+		} );
+
+		it( 'config.model.name is given', () => {
+			downcastHelpers.attributeToElement( {
+				model: {
+					key: 'color',
+					name: '$text'
+				},
+				view: ( modelAttributeValue, viewWriter ) => {
+					return viewWriter.createAttributeElement( 'span', { style: 'color:' + modelAttributeValue } );
+				}
+			} );
+
+			downcastHelpers.elementToElement( {
+				model: 'smiley',
+				view: ( modelElement, viewWriter ) => {
+					return viewWriter.createEmptyElement( 'img', {
+						src: 'smile.jpg',
+						class: 'smiley'
+					} );
+				}
+			} );
+
+			model.change( writer => {
+				writer.insertText( 'foo', { color: '#FF0000' }, modelRoot, 0 );
+				writer.insertElement( 'smiley', { color: '#FF0000' }, modelRoot, 3 );
+			} );
+
+			expectResult( '<span style="color:#FF0000">foo</span><img class="smiley" src="smile.jpg"></img>' );
+		} );
+
+		it( 'should not convert if creator returned null', () => {
+			downcastHelpers.elementToElement( { model: 'div', view: () => null } );
+
+			const modelElement = new ModelElement( 'div' );
+
+			model.change( writer => {
+				writer.insert( modelElement, modelRootStart );
+			} );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div></div>' );
+		} );
+
+		it( 'should convert insert/change/remove of attribute in model into wrapping element in a view', () => {
+			const modelElement = new ModelElement( 'paragraph', null, new ModelText( 'foobar', { bold: true } ) );
+
+			downcastHelpers.attributeToElement( {
+				model: 'bold',
+				view: ( modelAttributeValue, viewWriter ) => viewWriter.createAttributeElement( 'b' )
+			} );
+
+			model.change( writer => {
+				writer.insert( modelElement, modelRootStart );
+			} );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p><b>foobar</b></p></div>' );
+
+			model.change( writer => {
+				writer.removeAttribute( 'bold', writer.createRangeIn( modelElement ) );
+			} );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+		} );
+
+		it( 'should convert insert/remove of attribute in model with wrapping element generating function as a parameter', () => {
+			const modelElement = new ModelElement( 'paragraph', null, new ModelText( 'foobar', { style: 'bold' } ) );
+
+			downcastHelpers.attributeToElement( {
+				model: 'style',
+				view: ( modelAttributeValue, viewWriter ) => {
+					if ( modelAttributeValue == 'bold' ) {
+						return viewWriter.createAttributeElement( 'b' );
+					}
+				}
+			} );
+
+			model.change( writer => {
+				writer.insert( modelElement, modelRootStart );
+			} );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p><b>foobar</b></p></div>' );
+
+			model.change( writer => {
+				writer.removeAttribute( 'style', writer.createRangeIn( modelElement ) );
+			} );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+		} );
+
+		it( 'should update range on re-wrapping attribute (#475)', () => {
+			const modelElement = new ModelElement( 'paragraph', null, [
+				new ModelText( 'x' ),
+				new ModelText( 'foo', { link: 'http://foo.com' } ),
+				new ModelText( 'x' )
+			] );
+
+			downcastHelpers.attributeToElement( {
+				model: 'link',
+				view: ( modelAttributeValue, viewWriter ) => {
+					return viewWriter.createAttributeElement( 'a', { href: modelAttributeValue } );
+				}
+			} );
+
+			model.change( writer => {
+				writer.insert( modelElement, modelRootStart );
+			} );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>x<a href="http://foo.com">foo</a>x</p></div>' );
+
+			// Set new attribute on old link but also on non-linked characters.
+			model.change( writer => {
+				writer.setAttribute( 'link', 'http://foobar.com', writer.createRangeIn( modelElement ) );
+			} );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p><a href="http://foobar.com">xfoox</a></p></div>' );
+		} );
+
+		it( 'should support unicode', () => {
+			const modelElement = new ModelElement( 'paragraph', null, [ 'நி', new ModelText( 'லைக்', { bold: true } ), 'கு' ] );
+
+			downcastHelpers.attributeToElement( {
+				model: 'bold',
+				view: ( modelAttributeValue, viewWriter ) => viewWriter.createAttributeElement( 'b' )
+			} );
+
+			model.change( writer => {
+				writer.insert( modelElement, modelRootStart );
+			} );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>நி<b>லைக்</b>கு</p></div>' );
+
+			model.change( writer => {
+				writer.removeAttribute( 'bold', writer.createRangeIn( modelElement ) );
+			} );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>நிலைக்கு</p></div>' );
+		} );
+
+		it( 'should be possible to override ', () => {
+			const modelElement = new ModelElement( 'paragraph', null, new ModelText( 'foobar', { bold: true } ) );
+
+			downcastHelpers.attributeToElement( {
+				model: 'bold',
+				view: ( modelAttributeValue, viewWriter ) => viewWriter.createAttributeElement( 'b' )
+			} );
+			downcastHelpers.attributeToElement( {
+				model: 'bold',
+				view: ( modelAttributeValue, viewWriter ) => viewWriter.createAttributeElement( 'strong' ),
+				converterPriority: 'high'
+			} );
+
+			model.change( writer => {
+				writer.insert( modelElement, modelRootStart );
+			} );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p><strong>foobar</strong></p></div>' );
+		} );
+
+		it( 'should not convert and not consume if creator function returned null', () => {
+			sinon.spy( controller.downcastDispatcher, 'fire' );
+
+			const modelElement = new ModelElement( 'paragraph', null, new ModelText( 'foobar', { italic: true } ) );
+
+			downcastHelpers.attributeToElement( {
+				model: 'italic',
+				view: () => null
+			} );
+
+			const spy = sinon.spy();
+			controller.downcastDispatcher.on( 'attribute:italic', spy );
+
+			model.change( writer => {
+				writer.insert( modelElement, modelRootStart );
+			} );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+			expect( controller.downcastDispatcher.fire.calledWith( 'attribute:italic:$text' ) ).to.be.true;
+			expect( spy.called ).to.be.true;
+		} );
+	} );
+
+	describe( 'attributeToAttribute()', () => {
+		testUtils.createSinonSandbox();
+
+		beforeEach( () => {
+			downcastHelpers.elementToElement( { model: 'image', view: 'img' } );
+			downcastHelpers.elementToElement( {
+				model: 'paragraph',
+				view: ( modelItem, viewWriter ) => viewWriter.createContainerElement( 'p' )
+			} );
+
+			downcastHelpers.attributeToAttribute( {
+				model: 'class',
+				view: 'class'
+			} );
+		} );
+
+		it( 'should be chainable', () => {
+			expect( downcastHelpers.attributeToAttribute( { model: 'source', view: 'src' } ) ).to.equal( downcastHelpers );
+		} );
+
+		it( 'config.view is a string', () => {
+			downcastHelpers.attributeToAttribute( { model: 'source', view: 'src' } );
+
+			model.change( writer => {
+				writer.insertElement( 'image', { source: 'foo.jpg' }, modelRoot, 0 );
+			} );
+
+			expectResult( '<img src="foo.jpg"></img>' );
+
+			model.change( writer => {
+				writer.removeAttribute( 'source', modelRoot.getChild( 0 ) );
+			} );
+
+			expectResult( '<img></img>' );
+		} );
+
+		it( 'can be overwritten using converterPriority', () => {
+			downcastHelpers.attributeToAttribute( { model: 'source', view: 'href' } );
+			downcastHelpers.attributeToAttribute( { model: 'source', view: 'src', converterPriority: 'high' } );
+
+			model.change( writer => {
+				writer.insertElement( 'image', { source: 'foo.jpg' }, modelRoot, 0 );
+			} );
+
+			expectResult( '<img src="foo.jpg"></img>' );
+		} );
+
+		it( 'model element name specified', () => {
+			downcastHelpers.elementToElement( { model: 'paragraph', view: 'p' } );
+
+			downcastHelpers.attributeToAttribute( {
+				model: {
+					name: 'image',
+					key: 'source'
+				},
+				view: 'src'
+			} );
+
+			model.change( writer => {
+				writer.insertElement( 'image', { source: 'foo.jpg' }, modelRoot, 0 );
+			} );
+
+			expectResult( '<img src="foo.jpg"></img>' );
+
+			model.change( writer => {
+				writer.rename( modelRoot.getChild( 0 ), 'paragraph' );
+			} );
+
+			expectResult( '<p></p>' );
+		} );
+
+		it( 'config.view is an object, model attribute value is enum', () => {
+			downcastHelpers.elementToElement( { model: 'paragraph', view: 'p' } );
+
+			downcastHelpers.attributeToAttribute( {
+				model: {
+					key: 'styled',
+					values: [ 'dark', 'light' ]
+				},
+				view: {
+					dark: {
+						key: 'class',
+						value: [ 'styled', 'styled-dark' ]
+					},
+					light: {
+						key: 'class',
+						value: [ 'styled', 'styled-light' ]
+					}
+				}
+			} );
+
+			model.change( writer => {
+				writer.insertElement( 'paragraph', { styled: 'dark' }, modelRoot, 0 );
+			} );
+
+			expectResult( '<p class="styled styled-dark"></p>' );
+
+			model.change( writer => {
+				writer.setAttribute( 'styled', 'light', modelRoot.getChild( 0 ) );
+			} );
+
+			expectResult( '<p class="styled styled-light"></p>' );
+
+			model.change( writer => {
+				writer.removeAttribute( 'styled', modelRoot.getChild( 0 ) );
+			} );
+
+			expectResult( '<p></p>' );
+		} );
+
+		it( 'config.view is an object, model attribute value is enum, view has style', () => {
+			downcastHelpers.elementToElement( { model: 'paragraph', view: 'p' } );
+
+			downcastHelpers.attributeToAttribute( {
+				model: {
+					key: 'align',
+					values: [ 'right', 'center' ]
+				},
+				view: {
+					right: {
+						key: 'style',
+						value: {
+							'text-align': 'right'
+						}
+					},
+					center: {
+						key: 'style',
+						value: {
+							'text-align': 'center'
+						}
+					}
+				}
+			} );
+
+			model.change( writer => {
+				writer.insertElement( 'paragraph', { align: 'right' }, modelRoot, 0 );
+			} );
+
+			expectResult( '<p style="text-align:right"></p>' );
+
+			model.change( writer => {
+				writer.setAttribute( 'align', 'center', modelRoot.getChild( 0 ) );
+			} );
+
+			expectResult( '<p style="text-align:center"></p>' );
+
+			model.change( writer => {
+				writer.removeAttribute( 'align', modelRoot.getChild( 0 ) );
+			} );
+
+			expectResult( '<p></p>' );
+		} );
+
+		it( 'config.view is an object, only name and key are provided', () => {
+			downcastHelpers.elementToElement( { model: 'paragraph', view: 'p' } );
+
+			downcastHelpers.attributeToAttribute( {
+				model: {
+					name: 'paragraph',
+					key: 'class'
+				},
+				view: {
+					name: 'paragraph',
+					key: 'class'
+				}
+			} );
+
+			model.change( writer => {
+				writer.insertElement( 'paragraph', { class: 'dark' }, modelRoot, 0 );
+			} );
+
+			expectResult( '<p class="dark"></p>' );
+
+			model.change( writer => {
+				writer.setAttribute( 'class', 'light', modelRoot.getChild( 0 ) );
+			} );
+
+			expectResult( '<p class="light"></p>' );
+
+			model.change( writer => {
+				writer.removeAttribute( 'class', modelRoot.getChild( 0 ) );
+			} );
+
+			expectResult( '<p></p>' );
+		} );
+
+		it( 'config.view is a function', () => {
+			downcastHelpers.attributeToAttribute( {
+				model: 'styled',
+				view: attributeValue => ( { key: 'class', value: 'styled-' + attributeValue } )
+			} );
+
+			model.change( writer => {
+				writer.insertElement( 'image', { styled: 'pull-out' }, modelRoot, 0 );
+			} );
+
+			expectResult( '<img class="styled-pull-out"></img>' );
+		} );
+
+		// #1587
+		it( 'config.view and config.model as strings in generic conversion (elements only)', () => {
+			const logSpy = testUtils.sinon.spy( log, 'warn' );
+
+			downcastHelpers.elementToElement( { model: 'paragraph', view: 'p' } );
+
+			downcastHelpers.attributeToAttribute( { model: 'test', view: 'test' } );
+
+			model.change( writer => {
+				writer.insertElement( 'paragraph', { test: '1' }, modelRoot, 0 );
+				writer.insertElement( 'paragraph', { test: '2' }, modelRoot, 1 );
+			} );
+
+			expectResult( '<p test="1"></p><p test="2"></p>' );
+			expect( logSpy.callCount ).to.equal( 0 );
+
+			model.change( writer => {
+				writer.removeAttribute( 'test', modelRoot.getChild( 1 ) );
+			} );
+
+			expectResult( '<p test="1"></p><p></p>' );
+		} );
+
+		// #1587
+		it( 'config.view and config.model as strings in generic conversion (elements + text)', () => {
+			const logSpy = testUtils.sinon.spy( log, 'warn' );
+
+			downcastHelpers.elementToElement( { model: 'paragraph', view: 'p' } );
+
+			downcastHelpers.attributeToAttribute( { model: 'test', view: 'test' } );
+
+			model.change( writer => {
+				writer.insertElement( 'paragraph', modelRoot, 0 );
+				writer.insertElement( 'paragraph', { test: '1' }, modelRoot, 1 );
+
+				writer.insertText( 'Foo', { test: '2' }, modelRoot.getChild( 0 ), 0 );
+				writer.insertText( 'Bar', { test: '3' }, modelRoot.getChild( 1 ), 0 );
+			} );
+
+			expectResult( '<p>Foo</p><p test="1">Bar</p>' );
+			expect( logSpy.callCount ).to.equal( 2 );
+			expect( logSpy.alwaysCalledWithMatch( 'conversion-attribute-to-attribute-on-text' ) ).to.true;
+
+			model.change( writer => {
+				writer.removeAttribute( 'test', modelRoot.getChild( 1 ) );
+			} );
+
+			expectResult( '<p>Foo</p><p>Bar</p>' );
+		} );
+
+		it( 'should convert attribute insert/change/remove on a model node', () => {
+			const modelElement = new ModelElement( 'paragraph', { class: 'foo' }, new ModelText( 'foobar' ) );
+
+			model.change( writer => {
+				writer.insert( modelElement, modelRootStart );
+			} );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p class="foo">foobar</p></div>' );
+
+			model.change( writer => {
+				writer.setAttribute( 'class', 'bar', modelElement );
+			} );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p class="bar">foobar</p></div>' );
+
+			model.change( writer => {
+				writer.removeAttribute( 'class', modelElement );
+			} );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+		} );
+
+		it( 'should convert insert/change/remove with attribute generating function as a parameter', () => {
+			downcastHelpers.elementToElement( { model: 'div', view: 'div' } );
+			downcastHelpers.attributeToAttribute( {
+				model: 'theme',
+				view: ( value, data ) => {
+					if ( data.item instanceof ModelElement && data.item.childCount > 0 ) {
+						value += ' fix-content';
+					}
+
+					return { key: 'class', value };
+				}
+			} );
+
+			const modelParagraph = new ModelElement( 'paragraph', { theme: 'nice' }, new ModelText( 'foobar' ) );
+			const modelDiv = new ModelElement( 'div', { theme: 'nice' } );
+
+			model.change( writer => {
+				writer.insert( [ modelParagraph, modelDiv ], modelRootStart );
+			} );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p class="nice fix-content">foobar</p><div class="nice"></div></div>' );
+
+			model.change( writer => {
+				writer.setAttribute( 'theme', 'awesome', modelParagraph );
+			} );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p class="awesome fix-content">foobar</p><div class="nice"></div></div>' );
+
+			model.change( writer => {
+				writer.removeAttribute( 'theme', modelParagraph );
+			} );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p><div class="nice"></div></div>' );
+		} );
+
+		it( 'should be possible to override setAttribute', () => {
+			downcastHelpers.attributeToAttribute( {
+				model: 'class',
+				view: ( evt, data, conversionApi ) => {
+					conversionApi.consumable.consume( data.item, 'attribute:class' );
+				},
+				converterPriority: 'high'
+			} );
+
+			model.change( writer => {
+				const modelElement = new ModelElement( 'paragraph', { classes: 'foo' }, new ModelText( 'foobar' ) );
+				writer.insert( modelElement, modelRootStart );
+			} );
+
+			// No attribute set.
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+		} );
+
+		it( 'should not convert or consume if element creator returned null', () => {
+			const callback = sinon.stub().returns( null );
+
+			downcastHelpers.attributeToAttribute( {
+				model: 'class',
+				view: callback,
+				converterPriority: 'high'
+			} );
+
+			const modelElement = new ModelElement( 'paragraph', { class: 'foo' }, new ModelText( 'foobar' ) );
+
+			model.change( writer => {
+				writer.insert( modelElement, modelRootStart );
+			} );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p class="foo">foobar</p></div>' );
+
+			sinon.assert.called( callback );
+		} );
+	} );
+
+	describe( 'markerToElement()', () => {
+		let modelText, modelElement, range;
+
+		it( 'should be chainable', () => {
+			expect( downcastHelpers.markerToElement( { model: 'search', view: 'marker-search' } ) ).to.equal( downcastHelpers );
+		} );
+
+		it( 'config.view is a string', () => {
+			downcastHelpers.markerToElement( { model: 'search', view: 'marker-search' } );
+
+			model.change( writer => {
+				writer.insertText( 'foo', modelRoot, 0 );
+
+				const range = writer.createRange( writer.createPositionAt( modelRoot, 1 ), writer.createPositionAt( modelRoot, 2 ) );
+				writer.addMarker( 'search', { range, usingOperation: false } );
+			} );
+
+			expectResult( 'f<marker-search></marker-search>o<marker-search></marker-search>o' );
+		} );
+
+		it( 'can be overwritten using converterPriority', () => {
+			downcastHelpers.markerToElement( { model: 'search', view: 'marker-search' } );
+			downcastHelpers.markerToElement( { model: 'search', view: 'search', converterPriority: 'high' } );
+
+			model.change( writer => {
+				writer.insertText( 'foo', modelRoot, 0 );
+				const range = writer.createRange( writer.createPositionAt( modelRoot, 1 ), writer.createPositionAt( modelRoot, 2 ) );
+				writer.addMarker( 'search', { range, usingOperation: false } );
+			} );
+
+			expectResult( 'f<search></search>o<search></search>o' );
+		} );
+
+		it( 'config.view is a view element definition', () => {
+			downcastHelpers.markerToElement( {
+				model: 'search',
+				view: {
+					name: 'span',
+					attributes: {
+						'data-marker': 'search'
+					}
+				}
+			} );
+
+			model.change( writer => {
+				writer.insertText( 'foo', modelRoot, 0 );
+				const range = writer.createRange( writer.createPositionAt( modelRoot, 1 ), writer.createPositionAt( modelRoot, 2 ) );
+				writer.addMarker( 'search', { range, usingOperation: false } );
+			} );
+
+			expectResult( 'f<span data-marker="search"></span>o<span data-marker="search"></span>o' );
+		} );
+
+		it( 'config.view is a function', () => {
+			downcastHelpers.markerToElement( {
+				model: 'search',
+				view: ( data, viewWriter ) => {
+					return viewWriter.createUIElement( 'span', { 'data-marker': 'search', 'data-start': data.isOpening } );
+				}
+			} );
+
+			model.change( writer => {
+				writer.insertText( 'foo', modelRoot, 0 );
+				const range = writer.createRange( writer.createPositionAt( modelRoot, 1 ), writer.createPositionAt( modelRoot, 2 ) );
+				writer.addMarker( 'search', { range, usingOperation: false } );
+			} );
+
+			expectResult( 'f<span data-marker="search" data-start="true"></span>o<span data-marker="search" data-start="false"></span>o' );
+		} );
+
+		describe( 'collapsed range', () => {
+			beforeEach( () => {
+				modelText = new ModelText( 'foobar' );
+				modelElement = new ModelElement( 'paragraph', null, modelText );
+
+				downcastHelpers.elementToElement( { model: 'paragraph', view: 'p' } );
+
+				model.change( writer => {
+					writer.insert( modelElement, modelRootStart );
+				} );
+
+				range = model.createRange( model.createPositionAt( modelElement, 3 ), model.createPositionAt( modelElement, 3 ) );
+			} );
+
+			it( 'should insert and remove ui element', () => {
+				downcastHelpers.markerToElement( {
+					model: 'marker',
+					view: ( data, viewWriter ) => viewWriter.createUIElement( 'span', { 'class': 'marker' } )
+				} );
+
+				model.change( writer => {
+					writer.addMarker( 'marker', { range, usingOperation: false } );
+				} );
+
+				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foo<span class="marker"></span>bar</p></div>' );
+
+				model.change( writer => {
+					writer.removeMarker( 'marker' );
+				} );
+
+				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+			} );
+
+			it( 'should not convert if consumable was consumed', () => {
+				sinon.spy( controller.downcastDispatcher, 'fire' );
+
+				downcastHelpers.markerToElement( {
+					model: 'marker',
+					view: ( data, viewWriter ) => viewWriter.createUIElement( 'span', { 'class': 'marker' } )
+				} );
+
+				controller.downcastDispatcher.on( 'addMarker:marker', ( evt, data, conversionApi ) => {
+					conversionApi.consumable.consume( data.markerRange, 'addMarker:marker' );
+				}, { priority: 'high' } );
+
+				model.change( writer => {
+					writer.addMarker( 'marker', { range, usingOperation: false } );
+				} );
+
+				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+				expect( controller.downcastDispatcher.fire.calledWith( 'addMarker:marker' ) );
+			} );
+
+			it( 'should not convert if creator returned null', () => {
+				downcastHelpers.markerToElement( {
+					model: 'marker',
+					view: () => null
+				} );
+
+				model.change( writer => {
+					writer.addMarker( 'marker', { range, usingOperation: false } );
+				} );
+
+				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+
+				model.change( writer => {
+					writer.removeMarker( 'marker' );
+				} );
+
+				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+			} );
+		} );
+
+		describe( 'non-collapsed range', () => {
+			beforeEach( () => {
+				modelText = new ModelText( 'foobar' );
+				modelElement = new ModelElement( 'paragraph', null, modelText );
+
+				downcastHelpers.elementToElement( { model: 'paragraph', view: 'p' } );
+
+				model.change( writer => {
+					writer.insert( modelElement, modelRootStart );
+				} );
+
+				range = model.createRange( model.createPositionAt( modelElement, 2 ), model.createPositionAt( modelElement, 5 ) );
+			} );
+
+			it( 'should insert and remove ui element - element as a creator', () => {
+				downcastHelpers.markerToElement( {
+					model: 'marker',
+					view: ( data, viewWriter ) => viewWriter.createUIElement( 'span', { 'class': 'marker' } )
+				} );
+
+				model.change( writer => {
+					writer.addMarker( 'marker', { range, usingOperation: false } );
+				} );
+
+				expect( viewToString( viewRoot ) )
+					.to.equal( '<div><p>fo<span class="marker"></span>oba<span class="marker"></span>r</p></div>' );
+
+				model.change( writer => {
+					writer.removeMarker( 'marker' );
+				} );
+
+				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+			} );
+
+			it( 'should insert and remove ui element - function as a creator', () => {
+				downcastHelpers.markerToElement( {
+					model: 'marker',
+					view: ( data, viewWriter ) => viewWriter.createUIElement( 'span', { 'class': data.markerName } )
+				} );
+
+				model.change( writer => {
+					writer.addMarker( 'marker', { range, usingOperation: false } );
+				} );
+
+				expect( viewToString( viewRoot ) )
+					.to.equal( '<div><p>fo<span class="marker"></span>oba<span class="marker"></span>r</p></div>' );
+
+				model.change( writer => {
+					writer.removeMarker( 'marker' );
+				} );
+
+				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+			} );
+
+			it( 'should insert and remove different opening and ending element', () => {
+				downcastHelpers.markerToElement( {
+					model: 'marker',
+					view: ( data, viewWriter ) => {
+						if ( data.isOpening ) {
+							return viewWriter.createUIElement( 'span', { 'class': data.markerName, 'data-start': true } );
+						}
+
+						return viewWriter.createUIElement( 'span', { 'class': data.markerName, 'data-end': true } );
+					}
+				} );
+
+				model.change( writer => {
+					writer.addMarker( 'marker', { range, usingOperation: false } );
+				} );
+
+				expect( viewToString( viewRoot ) ).to.equal(
+					'<div><p>fo<span class="marker" data-start="true"></span>oba<span class="marker" data-end="true"></span>r</p></div>'
+				);
+
+				model.change( writer => {
+					writer.removeMarker( 'marker' );
+				} );
+
+				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+			} );
+
+			it( 'should not convert if consumable was consumed', () => {
+				sinon.spy( controller.downcastDispatcher, 'fire' );
+
+				downcastHelpers.markerToElement( {
+					model: 'marker',
+					view: ( data, viewWriter ) => viewWriter.createUIElement( 'span', { 'class': 'marker' } )
+				} );
+				controller.downcastDispatcher.on( 'addMarker:marker', ( evt, data, conversionApi ) => {
+					conversionApi.consumable.consume( data.item, 'addMarker:marker' );
+				}, { priority: 'high' } );
+
+				model.change( writer => {
+					writer.addMarker( 'marker', { range, usingOperation: false } );
+				} );
+
+				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+				expect( controller.downcastDispatcher.fire.calledWith( 'addMarker:marker' ) );
+			} );
+		} );
+	} );
+
+	describe( 'markerToHighlight()', () => {
+		it( 'should be chainable', () => {
+			expect( downcastHelpers.markerToHighlight( { model: 'comment', view: { classes: 'comment' } } ) ).to.equal( downcastHelpers );
+		} );
+
+		it( 'config.view is a highlight descriptor', () => {
+			downcastHelpers.markerToHighlight( { model: 'comment', view: { classes: 'comment' } } );
+
+			model.change( writer => {
+				writer.insertText( 'foo', modelRoot, 0 );
+				const range = writer.createRange( writer.createPositionAt( modelRoot, 0 ), writer.createPositionAt( modelRoot, 3 ) );
+				writer.addMarker( 'comment', { range, usingOperation: false } );
+			} );
+
+			expectResult( '<span class="comment">foo</span>' );
+		} );
+
+		it( 'can be overwritten using converterPriority', () => {
+			downcastHelpers.markerToHighlight( { model: 'comment', view: { classes: 'comment' } } );
+			downcastHelpers.markerToHighlight( { model: 'comment', view: { classes: 'new-comment' }, converterPriority: 'high' } );
+
+			model.change( writer => {
+				writer.insertText( 'foo', modelRoot, 0 );
+				const range = writer.createRange( writer.createPositionAt( modelRoot, 0 ), writer.createPositionAt( modelRoot, 3 ) );
+				writer.addMarker( 'comment', { range, usingOperation: false } );
+			} );
+
+			expectResult( '<span class="new-comment">foo</span>' );
+		} );
+
+		it( 'config.view is a function', () => {
+			downcastHelpers.markerToHighlight( {
+				model: 'comment',
+				view: data => {
+					const commentType = data.markerName.split( ':' )[ 1 ];
+
+					return {
+						classes: [ 'comment', 'comment-' + commentType ]
+					};
+				}
+			} );
+
+			model.change( writer => {
+				writer.insertText( 'foo', modelRoot, 0 );
+				const range = writer.createRange( writer.createPositionAt( modelRoot, 0 ), writer.createPositionAt( modelRoot, 3 ) );
+				writer.addMarker( 'comment:abc', { range, usingOperation: false } );
+			} );
+
+			expectResult( '<span class="comment comment-abc">foo</span>' );
+		} );
+
+		describe( 'highlight', () => {
+			const highlightConfig = {
+				model: 'marker',
+				view: {
+					classes: 'highlight-class',
+					attributes: { title: 'title' }
+				},
+				converterPriority: 7
+			};
+
+			describe( 'on text', () => {
+				let markerRange;
+
+				beforeEach( () => {
+					downcastHelpers.elementToElement( { model: 'paragraph', view: 'p' } );
+
+					const modelElement1 = new ModelElement( 'paragraph', null, new ModelText( 'foo' ) );
+					const modelElement2 = new ModelElement( 'paragraph', null, new ModelText( 'bar' ) );
+
+					model.change( writer => {
+						writer.insert( [ modelElement1, modelElement2 ], modelRootStart );
+					} );
+
+					markerRange = model.createRangeIn( modelRoot );
+				} );
+
+				it( 'should wrap and unwrap text nodes', () => {
+					downcastHelpers.markerToHighlight( highlightConfig );
+
+					model.change( writer => {
+						writer.addMarker( 'marker', { range: markerRange, usingOperation: false } );
+					} );
+
+					expect( viewToString( viewRoot ) ).to.equal(
+						'<div>' +
+						'<p>' +
+						'<span class="highlight-class" title="title">foo</span>' +
+						'</p>' +
+						'<p>' +
+						'<span class="highlight-class" title="title">bar</span>' +
+						'</p>' +
+						'</div>'
+					);
+
+					model.change( writer => {
+						writer.removeMarker( 'marker' );
+					} );
+
+					expect( viewToString( viewRoot ) ).to.equal( '<div><p>foo</p><p>bar</p></div>' );
+				} );
+
+				it( 'should be possible to overwrite', () => {
+					downcastHelpers.markerToHighlight( highlightConfig );
+					downcastHelpers.markerToHighlight( {
+						model: 'marker',
+						view: { classes: 'override-class' },
+						converterPriority: 'high'
+					} );
+
+					model.change( writer => {
+						writer.addMarker( 'marker', { range: markerRange, usingOperation: false } );
+					} );
+
+					expect( viewToString( viewRoot ) ).to.equal(
+						'<div>' +
+						'<p>' +
+						'<span class="override-class">foo</span>' +
+						'</p>' +
+						'<p>' +
+						'<span class="override-class">bar</span>' +
+						'</p>' +
+						'</div>'
+					);
+
+					model.change( writer => {
+						writer.removeMarker( 'marker' );
+					} );
+
+					expect( viewToString( viewRoot ) ).to.equal( '<div><p>foo</p><p>bar</p></div>' );
+				} );
+
+				it( 'should do nothing if descriptor is not provided or generating function returns null', () => {
+					downcastHelpers.markerToHighlight( {
+						model: 'marker',
+						view: () => null,
+						converterPriority: 'high'
+					} );
+
+					model.change( writer => {
+						writer.addMarker( 'marker', { range: markerRange, usingOperation: false } );
+					} );
+
+					expect( viewToString( viewRoot ) ).to.equal( '<div><p>foo</p><p>bar</p></div>' );
+
+					model.change( writer => {
+						writer.removeMarker( 'marker' );
+					} );
+
+					expect( viewToString( viewRoot ) ).to.equal( '<div><p>foo</p><p>bar</p></div>' );
+				} );
+
+				it( 'should do nothing if collapsed marker is converted', () => {
+					downcastHelpers.markerToHighlight( {
+						model: 'marker',
+						view: { classes: 'foo' },
+						converterPriority: 'high'
+					} );
+
+					markerRange = model.createRange( model.createPositionAt( modelRoot, 0 ), model.createPositionAt( modelRoot, 0 ) );
+
+					model.change( writer => {
+						writer.addMarker( 'marker', { range: markerRange, usingOperation: false } );
+					} );
+
+					expect( viewToString( viewRoot ) ).to.equal( '<div><p>foo</p><p>bar</p></div>' );
+
+					model.change( () => {
+						model.markers._remove( 'marker' );
+					} );
+
+					expect( viewToString( viewRoot ) ).to.equal( '<div><p>foo</p><p>bar</p></div>' );
+				} );
+
+				it( 'should correctly wrap and unwrap multiple, intersecting markers', () => {
+					downcastHelpers.markerToHighlight( {
+						model: 'markerFoo',
+						view: { classes: 'foo' }
+					} );
+					downcastHelpers.markerToHighlight( {
+						model: 'markerBar',
+						view: { classes: 'bar' }
+					} );
+					downcastHelpers.markerToHighlight( {
+						model: 'markerXyz',
+						view: { classes: 'xyz' }
+					} );
+
+					const p1 = modelRoot.getChild( 0 );
+					const p2 = modelRoot.getChild( 1 );
+
+					model.change( writer => {
+						const range = writer.createRange( writer.createPositionAt( p1, 0 ), writer.createPositionAt( p1, 3 ) );
+						writer.addMarker( 'markerFoo', { range, usingOperation: false } );
+					} );
+
+					expect( viewToString( viewRoot ) ).to.equal(
+						'<div>' +
+						'<p>' +
+						'<span class="foo">foo</span>' +
+						'</p>' +
+						'<p>bar</p>' +
+						'</div>'
+					);
+
+					model.change( writer => {
+						const range = writer.createRange( writer.createPositionAt( p1, 1 ), writer.createPositionAt( p2, 2 ) );
+						writer.addMarker( 'markerBar', { range, usingOperation: false } );
+					} );
+
+					expect( viewToString( viewRoot ) ).to.equal(
+						'<div>' +
+						'<p>' +
+						'<span class="foo">f</span>' +
+						'<span class="bar">' +
+						'<span class="foo">oo</span>' +
+						'</span>' +
+						'</p>' +
+						'<p>' +
+						'<span class="bar">ba</span>' +
+						'r' +
+						'</p>' +
+						'</div>'
+					);
+
+					model.change( writer => {
+						const range = writer.createRange( writer.createPositionAt( p1, 2 ), writer.createPositionAt( p2, 3 ) );
+						writer.addMarker( 'markerXyz', { range, usingOperation: false } );
+					} );
+
+					expect( viewToString( viewRoot ) ).to.equal(
+						'<div>' +
+						'<p>' +
+						'<span class="foo">f</span>' +
+						'<span class="bar">' +
+						'<span class="foo">' +
+						'o' +
+						'<span class="xyz">o</span>' +
+						'</span>' +
+						'</span>' +
+						'</p>' +
+						'<p>' +
+						'<span class="bar">' +
+						'<span class="xyz">ba</span>' +
+						'</span>' +
+						'<span class="xyz">r</span>' +
+						'</p>' +
+						'</div>'
+					);
+
+					model.change( writer => {
+						writer.removeMarker( 'markerBar' );
+					} );
+
+					expect( viewToString( viewRoot ) ).to.equal(
+						'<div>' +
+						'<p>' +
+						'<span class="foo">' +
+						'fo' +
+						'<span class="xyz">o</span>' +
+						'</span>' +
+						'</p>' +
+						'<p>' +
+						'<span class="xyz">bar</span>' +
+						'</p>' +
+						'</div>'
+					);
+
+					model.change( writer => {
+						writer.removeMarker( 'markerFoo' );
+					} );
+
+					expect( viewToString( viewRoot ) ).to.equal(
+						'<div>' +
+						'<p>' +
+						'fo' +
+						'<span class="xyz">o</span>' +
+						'</p>' +
+						'<p>' +
+						'<span class="xyz">bar</span>' +
+						'</p>' +
+						'</div>'
+					);
+
+					model.change( writer => {
+						writer.removeMarker( 'markerXyz' );
+					} );
+
+					expect( viewToString( viewRoot ) ).to.equal( '<div><p>foo</p><p>bar</p></div>' );
+				} );
+
+				it( 'should do nothing if marker is applied and removed on empty-ish range', () => {
+					downcastHelpers.markerToHighlight( highlightConfig );
+
+					const p1 = modelRoot.getChild( 0 );
+					const p2 = modelRoot.getChild( 1 );
+
+					const markerRange = model.createRange( model.createPositionAt( p1, 3 ), model.createPositionAt( p2, 0 ) );
+
+					model.change( writer => {
+						writer.addMarker( 'marker', { range: markerRange, usingOperation: false } );
+					} );
+
+					expect( viewToString( viewRoot ) ).to.equal( '<div><p>foo</p><p>bar</p></div>' );
+
+					model.change( writer => {
+						writer.removeMarker( 'marker', { range: markerRange, usingOperation: false } );
+					} );
+
+					expect( viewToString( viewRoot ) ).to.equal( '<div><p>foo</p><p>bar</p></div>' );
+				} );
+			} );
+
+			describe( 'on element', () => {
+				const highlightConfig = {
+					model: 'marker',
+					view: {
+						classes: 'highlight-class',
+						attributes: { title: 'title' },
+						id: 'customId'
+					},
+					converterPriority: 7
+				};
+
+				let markerRange;
+
+				beforeEach( () => {
+					downcastHelpers.elementToElement( {
+						model: 'div',
+						view: () => {
+							const viewContainer = new ViewContainerElement( 'div' );
+
+							viewContainer._setCustomProperty( 'addHighlight', ( element, descriptor, writer ) => {
+								writer.addClass( descriptor.classes, element );
+							} );
+
+							viewContainer._setCustomProperty( 'removeHighlight', ( element, id, writer ) => {
+								writer.setAttribute( 'class', '', element );
+							} );
+
+							return viewContainer;
+						}
+					} );
+
+					const modelElement = new ModelElement( 'div', null, new ModelText( 'foo' ) );
+
+					model.change( writer => {
+						writer.insert( modelElement, modelRootStart );
+					} );
+
+					markerRange = model.createRangeOn( modelElement );
+
+					downcastHelpers.markerToHighlight( highlightConfig );
+				} );
+
+				it( 'should use addHighlight and removeHighlight on elements and not convert children nodes', () => {
+					model.change( writer => {
+						writer.addMarker( 'marker', { range: markerRange, usingOperation: false } );
+					} );
+
+					expect( viewToString( viewRoot ) ).to.equal(
+						'<div>' +
+						'<div class="highlight-class">' +
+						'foo' +
+						'</div>' +
+						'</div>'
+					);
+
+					model.change( writer => {
+						writer.removeMarker( 'marker' );
+					} );
+
+					expect( viewToString( viewRoot ) ).to.equal( '<div><div>foo</div></div>' );
+				} );
+
+				it( 'should be possible to override', () => {
+					downcastHelpers.markerToHighlight( {
+						model: 'marker',
+						view: { classes: 'override-class' },
+						converterPriority: 'high'
+					} );
+
+					model.change( writer => {
+						writer.addMarker( 'marker', { range: markerRange, usingOperation: false } );
+					} );
+
+					expect( viewToString( viewRoot ) ).to.equal(
+						'<div>' +
+						'<div class="override-class">' +
+						'foo' +
+						'</div>' +
+						'</div>'
+					);
+
+					model.change( writer => {
+						writer.removeMarker( 'marker' );
+					} );
+
+					expect( viewToString( viewRoot ) ).to.equal( '<div><div>foo</div></div>' );
+				} );
+
+				it( 'should use default priority and id if not provided', () => {
+					const viewDiv = viewRoot.getChild( 0 );
+					downcastHelpers.markerToHighlight( {
+						model: 'marker2',
+						view: () => null,
+						converterPriority: 'high'
+					} );
+
+					viewDiv._setCustomProperty( 'addHighlight', ( element, descriptor ) => {
+						expect( descriptor.priority ).to.equal( ViewAttributeElement.DEFAULT_PRIORITY );
+						expect( descriptor.id ).to.equal( 'marker:foo-bar-baz' );
+					} );
+
+					viewDiv._setCustomProperty( 'removeHighlight', ( element, id ) => {
+						expect( id ).to.equal( 'marker:foo-bar-baz' );
+					} );
+
+					model.change( writer => {
+						writer.addMarker( 'marker2', { range: markerRange, usingOperation: false } );
+					} );
+				} );
+
+				it( 'should do nothing if descriptor is not provided', () => {
+					downcastHelpers.markerToHighlight( {
+						model: 'marker2',
+						view: () => null
+					} );
+
+					model.change( writer => {
+						writer.addMarker( 'marker2', { range: markerRange, usingOperation: false } );
+					} );
+
+					expect( viewToString( viewRoot ) ).to.equal( '<div><div>foo</div></div>' );
+
+					model.change( writer => {
+						writer.removeMarker( 'marker2' );
+					} );
+
+					expect( viewToString( viewRoot ) ).to.equal( '<div><div>foo</div></div>' );
+				} );
+			} );
+		} );
+	} );
+
+	function expectResult( string ) {
+		expect( stringifyView( viewRoot, null, { ignoreRoot: true } ) ).to.equal( string );
+	}
+} );
+
+describe( 'downcast converters', () => {
+	let dispatcher, modelDoc, modelRoot, viewRoot, controller, modelRootStart, model;
+
+	beforeEach( () => {
+		model = new Model();
+		modelDoc = model.document;
+		modelRoot = modelDoc.createRoot();
+
+		controller = new EditingController( model );
+
+		viewRoot = controller.view.document.getRoot();
+		// Set name of view root the same as dom root.
+		// This is a mock of attaching view root to dom root.
+		controller.view.document.getRoot()._name = 'div';
+
+		dispatcher = controller.downcastDispatcher;
+		const downcastHelpers = new DowncastHelpers( dispatcher );
+		downcastHelpers.elementToElement( { model: 'paragraph', view: 'p' } );
+
+		modelRootStart = model.createPositionAt( modelRoot, 0 );
+	} );
+
+	describe( 'insertText()', () => {
+		it( 'should downcast text', () => {
+			model.change( writer => {
+				writer.insert( new ModelText( 'foobar' ), modelRootStart );
+			} );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div>foobar</div>' );
+		} );
+
+		it( 'should support unicode', () => {
+			model.change( writer => {
+				writer.insert( new ModelText( 'நிலைக்கு' ), modelRootStart );
+			} );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div>நிலைக்கு</div>' );
+		} );
+
+		it( 'should be possible to override it', () => {
+			dispatcher.on( 'insert:$text', ( evt, data, conversionApi ) => {
+				conversionApi.consumable.consume( data.item, 'insert' );
+			}, { converterPriority: 'high' } );
+
+			model.change( writer => {
+				writer.insert( new ModelText( 'foobar' ), modelRootStart );
+			} );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div></div>' );
+		} );
+	} );
+
+	// Remove converter is by default already added in `EditingController` instance.
+	describe( 'remove()', () => {
+		it( 'should remove items from view accordingly to changes in model #1', () => {
+			const modelElement = new ModelElement( 'paragraph', null, new ModelText( 'foobar' ) );
+
+			model.change( writer => {
+				writer.insert( modelElement, modelRootStart );
+			} );
+
+			model.change( writer => {
+				writer.remove(
+					writer.createRange( writer.createPositionAt( modelElement, 2 ), writer.createPositionAt( modelElement, 4 ) )
+				);
+			} );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foar</p></div>' );
+		} );
+
+		it( 'should be possible to overwrite', () => {
+			dispatcher.on( 'remove', evt => evt.stop(), { priority: 'high' } );
+
+			const modelElement = new ModelElement( 'paragraph', null, new ModelText( 'foobar' ) );
+
+			model.change( writer => {
+				writer.insert( modelElement, modelRootStart );
+			} );
+
+			model.change( writer => {
+				writer.remove(
+					writer.createRange( writer.createPositionAt( modelElement, 2 ), writer.createPositionAt( modelElement, 4 ) )
+				);
+			} );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+		} );
+
+		it( 'should support unicode', () => {
+			const modelElement = new ModelElement( 'paragraph', null, 'நிலைக்கு' );
+
+			model.change( writer => {
+				writer.insert( modelElement, modelRootStart );
+			} );
+
+			model.change( writer => {
+				writer.remove(
+					writer.createRange( writer.createPositionAt( modelElement, 0 ), writer.createPositionAt( modelElement, 6 ) )
+				);
+			} );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>கு</p></div>' );
+		} );
+
+		it( 'should not remove view ui elements that are placed next to removed content', () => {
+			modelRoot._appendChild( new ModelText( 'fozbar' ) );
+			viewRoot._appendChild( [
+				new ViewText( 'foz' ),
+				new ViewUIElement( 'span' ),
+				new ViewText( 'bar' )
+			] );
+
+			// Remove 'b'.
+			model.change( writer => {
+				writer.remove(
+					writer.createRange( writer.createPositionAt( modelRoot, 3 ), writer.createPositionAt( modelRoot, 4 ) )
+				);
+			} );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div>foz<span></span>ar</div>' );
+
+			// Remove 'z'.
+			model.change( writer => {
+				writer.remove(
+					writer.createRange( writer.createPositionAt( modelRoot, 2 ), writer.createPositionAt( modelRoot, 3 ) )
+				);
+			} );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div>fo<span></span>ar</div>' );
+		} );
+
+		it( 'should remove correct amount of text when it is split by view ui element', () => {
+			modelRoot._appendChild( new ModelText( 'fozbar' ) );
+			viewRoot._appendChild( [
+				new ViewText( 'foz' ),
+				new ViewUIElement( 'span' ),
+				new ViewText( 'bar' )
+			] );
+
+			// Remove 'z<span></span>b'.
+			model.change( writer => {
+				writer.remove(
+					writer.createRange( writer.createPositionAt( modelRoot, 2 ), writer.createPositionAt( modelRoot, 4 ) )
+				);
+			} );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div>foar</div>' );
+		} );
+
+		it( 'should unbind elements', () => {
+			const modelElement = new ModelElement( 'paragraph' );
+
+			model.change( writer => {
+				writer.insert( modelElement, modelRootStart );
+			} );
+
+			const viewElement = controller.mapper.toViewElement( modelElement );
+			expect( viewElement ).not.to.be.undefined;
+			expect( controller.mapper.toModelElement( viewElement ) ).to.equal( modelElement );
+
+			model.change( writer => {
+				writer.remove( modelElement );
+			} );
+
+			expect( controller.mapper.toViewElement( modelElement ) ).to.be.undefined;
+			expect( controller.mapper.toModelElement( viewElement ) ).to.be.undefined;
+		} );
+
+		it( 'should not break when remove() is used as part of unwrapping', () => {
+			const modelP = new ModelElement( 'paragraph', null, new ModelText( 'foo' ) );
+			const modelWidget = new ModelElement( 'widget', null, modelP );
+
+			const downcastHelpers = new DowncastHelpers( dispatcher );
+			downcastHelpers.elementToElement( { model: 'widget', view: 'widget' } );
+
+			model.change( writer => {
+				writer.insert( modelWidget, modelRootStart );
+			} );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><widget><p>foo</p></widget></div>' );
+
+			const viewP = controller.mapper.toViewElement( modelP );
+
+			expect( viewP ).not.to.be.undefined;
+
+			model.change( writer => {
+				writer.unwrap( modelWidget );
+			} );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foo</p></div>' );
+			// `modelP` is now bound with newly created view element.
+			expect( controller.mapper.toViewElement( modelP ) ).not.to.equal( viewP );
+			// `viewP` is no longer bound with model element.
+			expect( controller.mapper.toModelElement( viewP ) ).to.be.undefined;
+			// View element from view root is bound to `modelP`.
+			expect( controller.mapper.toModelElement( viewRoot.getChild( 0 ) ) ).to.equal( modelP );
+		} );
+
+		it( 'should work correctly if container element after ui element is removed', () => {
+			// Prepare a model and view structure.
+			// This is done outside of conversion to put view ui elements inside easily.
+			const modelP1 = new ModelElement( 'paragraph' );
+			const modelP2 = new ModelElement( 'paragraph' );
+
+			const viewP1 = new ViewContainerElement( 'p' );
+			const viewUi1 = new ViewUIElement( 'span' );
+			const viewUi2 = new ViewUIElement( 'span' );
+			const viewP2 = new ViewContainerElement( 'p' );
+
+			modelRoot._appendChild( [ modelP1, modelP2 ] );
+			viewRoot._appendChild( [ viewP1, viewUi1, viewUi2, viewP2 ] );
+
+			controller.mapper.bindElements( modelP1, viewP1 );
+			controller.mapper.bindElements( modelP2, viewP2 );
+
+			// Remove second paragraph element.
+			model.change( writer => {
+				writer.remove( writer.createRange( writer.createPositionAt( modelRoot, 1 ), writer.createPositionAt( modelRoot, 2 ) ) );
+			} );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p></p><span></span><span></span></div>' );
+		} );
+
+		it( 'should work correctly if container element after text node is removed', () => {
+			const modelText = new ModelText( 'foo' );
+			const modelP = new ModelElement( 'paragraph' );
+
+			model.change( writer => {
+				writer.insert( [ modelText, modelP ], modelRootStart );
+			} );
+
+			model.change( writer => {
+				writer.remove( modelP );
+			} );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div>foo</div>' );
+		} );
+	} );
+
+	describe( 'createViewElementFromHighlightDescriptor()', () => {
+		it( 'should return attribute element from descriptor object', () => {
+			const descriptor = {
+				classes: 'foo-class',
+				attributes: { one: '1', two: '2' },
+				priority: 7
+			};
+			const element = createViewElementFromHighlightDescriptor( descriptor );
+
+			expect( element.is( 'attributeElement' ) ).to.be.true;
+			expect( element.name ).to.equal( 'span' );
+			expect( element.priority ).to.equal( 7 );
+			expect( element.hasClass( 'foo-class' ) ).to.be.true;
+
+			for ( const key of Object.keys( descriptor.attributes ) ) {
+				expect( element.getAttribute( key ) ).to.equal( descriptor.attributes[ key ] );
+			}
+		} );
+
+		it( 'should return attribute element from descriptor object - array with classes', () => {
+			const descriptor = {
+				classes: [ 'foo-class', 'bar-class' ],
+				attributes: { one: '1', two: '2' },
+				priority: 7
+			};
+			const element = createViewElementFromHighlightDescriptor( descriptor );
+
+			expect( element.is( 'attributeElement' ) ).to.be.true;
+			expect( element.name ).to.equal( 'span' );
+			expect( element.priority ).to.equal( 7 );
+			expect( element.hasClass( 'foo-class' ) ).to.be.true;
+			expect( element.hasClass( 'bar-class' ) ).to.be.true;
+
+			for ( const key of Object.keys( descriptor.attributes ) ) {
+				expect( element.getAttribute( key ) ).to.equal( descriptor.attributes[ key ] );
+			}
+		} );
+
+		it( 'should create element without class', () => {
+			const descriptor = {
+				attributes: { one: '1', two: '2' },
+				priority: 7
+			};
+			const element = createViewElementFromHighlightDescriptor( descriptor );
+
+			expect( element.is( 'attributeElement' ) ).to.be.true;
+			expect( element.name ).to.equal( 'span' );
+			expect( element.priority ).to.equal( 7 );
+
+			for ( const key of Object.keys( descriptor.attributes ) ) {
+				expect( element.getAttribute( key ) ).to.equal( descriptor.attributes[ key ] );
+			}
+		} );
+
+		it( 'should create element without priority', () => {
+			const descriptor = {
+				classes: 'foo-class',
+				attributes: { one: '1', two: '2' }
+			};
+			const element = createViewElementFromHighlightDescriptor( descriptor );
+
+			expect( element.is( 'attributeElement' ) ).to.be.true;
+			expect( element.name ).to.equal( 'span' );
+			expect( element.priority ).to.equal( ViewAttributeElement.DEFAULT_PRIORITY );
+			expect( element.hasClass( 'foo-class' ) ).to.be.true;
+
+			for ( const key of Object.keys( descriptor.attributes ) ) {
+				expect( element.getAttribute( key ) ).to.equal( descriptor.attributes[ key ] );
+			}
+		} );
+
+		it( 'should create element without attributes', () => {
+			const descriptor = {
+				classes: 'foo-class',
+				priority: 7
+			};
+			const element = createViewElementFromHighlightDescriptor( descriptor );
+
+			expect( element.is( 'attributeElement' ) ).to.be.true;
+			expect( element.name ).to.equal( 'span' );
+			expect( element.priority ).to.equal( 7 );
+			expect( element.hasClass( 'foo-class' ) ).to.be.true;
+		} );
+	} );
+} );
+
+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() );
+
+		downcastHelpers = new DowncastHelpers( dispatcher );
+		downcastHelpers.attributeToElement( { model: 'bold', view: 'strong' } );
+		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>' );
+	}
+} );
+
+function viewToString( item ) {
+	let result = '';
+
+	if ( item instanceof ViewText ) {
+		result = item.data;
+	} else {
+		// ViewElement or ViewDocumentFragment.
+		for ( const child of item.getChildren() ) {
+			result += viewToString( child );
+		}
+
+		if ( item instanceof ViewElement ) {
+			result = '<' + item.name + viewAttributesToString( item ) + '>' + result + '</' + item.name + '>';
+		}
+	}
+
+	return result;
+}
+
+function viewAttributesToString( item ) {
+	let result = '';
+
+	for ( const key of item.getAttributeKeys() ) {
+		const value = item.getAttribute( key );
+
+		if ( value ) {
+			result += ' ' + key + '="' + value + '"';
+		}
+	}
+
+	return result;
+}

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

+ 197 - 130
packages/ckeditor5-engine/tests/conversion/upcast-converters.js → packages/ckeditor5-engine/tests/conversion/upcasthelpers.js

@@ -19,15 +19,18 @@ import ModelText from '../../src/model/text';
 import ModelRange from '../../src/model/range';
 import ModelRange from '../../src/model/range';
 import ModelPosition from '../../src/model/position';
 import ModelPosition from '../../src/model/position';
 
 
-import {
-	upcastElementToElement, upcastElementToAttribute, upcastAttributeToAttribute, upcastElementToMarker,
-	convertToModelFragment, convertText
-} from '../../src/conversion/upcast-converters';
+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( 'upcast-helpers', () => {
-	let upcastDispatcher, model, schema, conversion;
+describe( 'UpcastHelpers', () => {
+	let upcastDispatcher, model, schema, conversion, upcastHelpers;
 
 
 	beforeEach( () => {
 	beforeEach( () => {
 		model = new Model();
 		model = new Model();
@@ -49,14 +52,17 @@ describe( 'upcast-helpers', () => {
 		upcastDispatcher.on( 'documentFragment', convertToModelFragment(), { priority: 'lowest' } );
 		upcastDispatcher.on( 'documentFragment', convertToModelFragment(), { priority: 'lowest' } );
 
 
 		conversion = new Conversion();
 		conversion = new Conversion();
-		conversion.register( 'upcast', [ upcastDispatcher ] );
+		upcastHelpers = new UpcastHelpers( upcastDispatcher );
+		conversion.register( 'upcast', upcastHelpers );
 	} );
 	} );
 
 
-	describe( 'upcastElementToElement', () => {
-		it( 'config.view is a string', () => {
-			const helper = upcastElementToElement( { view: 'p', model: 'paragraph' } );
+	describe( '.elementToElement()', () => {
+		it( 'should be chainable', () => {
+			expect( upcastHelpers.elementToElement( { view: 'p', model: 'paragraph' } ) ).to.equal( upcastHelpers );
+		} );
 
 
-			conversion.for( 'upcast' ).add( helper );
+		it( 'config.view is a string', () => {
+			upcastHelpers.elementToElement( { view: 'p', model: 'paragraph' } );
 
 
 			expectResult( new ViewContainerElement( 'p' ), '<paragraph></paragraph>' );
 			expectResult( new ViewContainerElement( 'p' ), '<paragraph></paragraph>' );
 		} );
 		} );
@@ -66,10 +72,8 @@ describe( 'upcast-helpers', () => {
 				inheritAllFrom: '$block'
 				inheritAllFrom: '$block'
 			} );
 			} );
 
 
-			const helperA = upcastElementToElement( { view: 'p', model: 'p' } );
-			const helperB = upcastElementToElement( { view: 'p', model: 'paragraph', converterPriority: 'high' } );
-
-			conversion.for( 'upcast' ).add( helperA ).add( helperB );
+			upcastHelpers.elementToElement( { view: 'p', model: 'p' } );
+			upcastHelpers.elementToElement( { view: 'p', model: 'paragraph', converterPriority: 'high' } );
 
 
 			expectResult( new ViewContainerElement( 'p' ), '<paragraph></paragraph>' );
 			expectResult( new ViewContainerElement( 'p' ), '<paragraph></paragraph>' );
 		} );
 		} );
@@ -79,16 +83,14 @@ describe( 'upcast-helpers', () => {
 				inheritAllFrom: '$block'
 				inheritAllFrom: '$block'
 			} );
 			} );
 
 
-			const helperFancy = upcastElementToElement( {
+			upcastHelpers.elementToElement( {
 				view: {
 				view: {
 					name: 'p',
 					name: 'p',
 					classes: 'fancy'
 					classes: 'fancy'
 				},
 				},
-				model: 'fancyParagraph',
+				model: 'fancyParagraph'
 			} );
 			} );
 
 
-			conversion.for( 'upcast' ).add( helperFancy );
-
 			expectResult( new ViewContainerElement( 'p', { class: 'fancy' } ), '<fancyParagraph></fancyParagraph>' );
 			expectResult( new ViewContainerElement( 'p', { class: 'fancy' } ), '<fancyParagraph></fancyParagraph>' );
 			expectResult( new ViewContainerElement( 'p' ), '' );
 			expectResult( new ViewContainerElement( 'p' ), '' );
 		} );
 		} );
@@ -99,7 +101,7 @@ describe( 'upcast-helpers', () => {
 				allowAttributes: [ 'level' ]
 				allowAttributes: [ 'level' ]
 			} );
 			} );
 
 
-			const helper = upcastElementToElement( {
+			upcastHelpers.elementToElement( {
 				view: {
 				view: {
 					name: 'p',
 					name: 'p',
 					classes: 'heading'
 					classes: 'heading'
@@ -109,24 +111,18 @@ describe( 'upcast-helpers', () => {
 				}
 				}
 			} );
 			} );
 
 
-			conversion.for( 'upcast' ).add( helper );
-
 			expectResult( new ViewContainerElement( 'p', { class: 'heading', 'data-level': 2 } ), '<heading level="2"></heading>' );
 			expectResult( new ViewContainerElement( 'p', { class: 'heading', 'data-level': 2 } ), '<heading level="2"></heading>' );
 			expectResult( new ViewContainerElement( 'p', { 'data-level': 2 } ), '' );
 			expectResult( new ViewContainerElement( 'p', { 'data-level': 2 } ), '' );
 		} );
 		} );
 
 
 		it( 'should fire conversion of the element children', () => {
 		it( 'should fire conversion of the element children', () => {
-			const helper = upcastElementToElement( { view: 'p', model: 'paragraph' } );
-
-			conversion.for( 'upcast' ).add( helper );
+			upcastHelpers.elementToElement( { view: 'p', model: 'paragraph' } );
 
 
 			expectResult( new ViewContainerElement( 'p', null, new ViewText( 'foo' ) ), '<paragraph>foo</paragraph>' );
 			expectResult( new ViewContainerElement( 'p', null, new ViewText( 'foo' ) ), '<paragraph>foo</paragraph>' );
 		} );
 		} );
 
 
 		it( 'should not insert a model element if it is not allowed by schema', () => {
 		it( 'should not insert a model element if it is not allowed by schema', () => {
-			const helper = upcastElementToElement( { view: 'h2', model: 'heading' } );
-
-			conversion.for( 'upcast' ).add( helper );
+			upcastHelpers.elementToElement( { view: 'h2', model: 'heading' } );
 
 
 			expectResult( new ViewContainerElement( 'h2' ), '' );
 			expectResult( new ViewContainerElement( 'h2' ), '' );
 		} );
 		} );
@@ -136,10 +132,8 @@ describe( 'upcast-helpers', () => {
 				inheritAllFrom: '$block'
 				inheritAllFrom: '$block'
 			} );
 			} );
 
 
-			const helperParagraph = upcastElementToElement( { view: 'p', model: 'paragraph' } );
-			const helperHeading = upcastElementToElement( { view: 'h2', model: 'heading' } );
-
-			conversion.for( 'upcast' ).add( helperParagraph ).add( helperHeading );
+			upcastHelpers.elementToElement( { view: 'p', model: 'paragraph' } );
+			upcastHelpers.elementToElement( { view: 'h2', model: 'heading' } );
 
 
 			expectResult(
 			expectResult(
 				new ViewContainerElement( 'p', null, [
 				new ViewContainerElement( 'p', null, [
@@ -152,20 +146,20 @@ describe( 'upcast-helpers', () => {
 		} );
 		} );
 
 
 		it( 'should not do anything if returned model element is null', () => {
 		it( 'should not do anything if returned model element is null', () => {
-			const helperA = upcastElementToElement( { view: 'p', model: 'paragraph' } );
-			const helperB = upcastElementToElement( { view: 'p', model: () => null, converterPriority: 'high' } );
-
-			conversion.for( 'upcast' ).add( helperA ).add( helperB );
+			upcastHelpers.elementToElement( { view: 'p', model: 'paragraph' } );
+			upcastHelpers.elementToElement( { view: 'p', model: () => null, converterPriority: 'high' } );
 
 
 			expectResult( new ViewContainerElement( 'p' ), '<paragraph></paragraph>' );
 			expectResult( new ViewContainerElement( 'p' ), '<paragraph></paragraph>' );
 		} );
 		} );
 	} );
 	} );
 
 
-	describe( 'upcastElementToAttribute', () => {
-		it( 'config.view is string', () => {
-			const helper = upcastElementToAttribute( { view: 'strong', model: 'bold' } );
+	describe( '.elementToAttribute()', () => {
+		it( 'should be chainable', () => {
+			expect( upcastHelpers.elementToAttribute( { view: 'strong', model: 'bold' } ) ).to.equal( upcastHelpers );
+		} );
 
 
-			conversion.for( 'upcast' ).add( helper );
+		it( 'config.view is string', () => {
+			upcastHelpers.elementToAttribute( { view: 'strong', model: 'bold' } );
 
 
 			expectResult(
 			expectResult(
 				new ViewAttributeElement( 'strong', null, new ViewText( 'foo' ) ),
 				new ViewAttributeElement( 'strong', null, new ViewText( 'foo' ) ),
@@ -174,10 +168,8 @@ describe( 'upcast-helpers', () => {
 		} );
 		} );
 
 
 		it( 'can be overwritten using converterPriority', () => {
 		it( 'can be overwritten using converterPriority', () => {
-			const helperA = upcastElementToAttribute( { view: 'strong', model: 'strong' } );
-			const helperB = upcastElementToAttribute( { view: 'strong', model: 'bold', converterPriority: 'high' } );
-
-			conversion.for( 'upcast' ).add( helperA ).add( helperB );
+			upcastHelpers.elementToAttribute( { view: 'strong', model: 'strong' } );
+			upcastHelpers.elementToAttribute( { view: 'strong', model: 'bold', converterPriority: 'high' } );
 
 
 			expectResult(
 			expectResult(
 				new ViewAttributeElement( 'strong', null, new ViewText( 'foo' ) ),
 				new ViewAttributeElement( 'strong', null, new ViewText( 'foo' ) ),
@@ -186,7 +178,7 @@ describe( 'upcast-helpers', () => {
 		} );
 		} );
 
 
 		it( 'config.view is an object', () => {
 		it( 'config.view is an object', () => {
-			const helper = upcastElementToAttribute( {
+			upcastHelpers.elementToAttribute( {
 				view: {
 				view: {
 					name: 'span',
 					name: 'span',
 					classes: 'bold'
 					classes: 'bold'
@@ -194,8 +186,6 @@ describe( 'upcast-helpers', () => {
 				model: 'bold'
 				model: 'bold'
 			} );
 			} );
 
 
-			conversion.for( 'upcast' ).add( helper );
-
 			expectResult(
 			expectResult(
 				new ViewAttributeElement( 'span', { class: 'bold' }, new ViewText( 'foo' ) ),
 				new ViewAttributeElement( 'span', { class: 'bold' }, new ViewText( 'foo' ) ),
 				'<$text bold="true">foo</$text>'
 				'<$text bold="true">foo</$text>'
@@ -209,7 +199,7 @@ describe( 'upcast-helpers', () => {
 				allowAttributes: [ 'styled' ]
 				allowAttributes: [ 'styled' ]
 			} );
 			} );
 
 
-			const helper = upcastElementToAttribute( {
+			upcastHelpers.elementToAttribute( {
 				view: {
 				view: {
 					name: 'span',
 					name: 'span',
 					classes: [ 'styled', 'styled-dark' ]
 					classes: [ 'styled', 'styled-dark' ]
@@ -220,8 +210,6 @@ describe( 'upcast-helpers', () => {
 				}
 				}
 			} );
 			} );
 
 
-			conversion.for( 'upcast' ).add( helper );
-
 			expectResult(
 			expectResult(
 				new ViewAttributeElement( 'span', { class: 'styled styled-dark' }, new ViewText( 'foo' ) ),
 				new ViewAttributeElement( 'span', { class: 'styled styled-dark' }, new ViewText( 'foo' ) ),
 				'<$text styled="dark">foo</$text>'
 				'<$text styled="dark">foo</$text>'
@@ -235,7 +223,7 @@ describe( 'upcast-helpers', () => {
 				allowAttributes: [ 'fontSize' ]
 				allowAttributes: [ 'fontSize' ]
 			} );
 			} );
 
 
-			const helper = upcastElementToAttribute( {
+			upcastHelpers.elementToAttribute( {
 				view: {
 				view: {
 					name: 'span',
 					name: 'span',
 					styles: {
 					styles: {
@@ -259,8 +247,6 @@ describe( 'upcast-helpers', () => {
 				}
 				}
 			} );
 			} );
 
 
-			conversion.for( 'upcast' ).add( helper );
-
 			expectResult(
 			expectResult(
 				new ViewAttributeElement( 'span', { style: 'font-size:9px' }, new ViewText( 'foo' ) ),
 				new ViewAttributeElement( 'span', { style: 'font-size:9px' }, new ViewText( 'foo' ) ),
 				'<$text fontSize="small">foo</$text>'
 				'<$text fontSize="small">foo</$text>'
@@ -278,9 +264,7 @@ describe( 'upcast-helpers', () => {
 		} );
 		} );
 
 
 		it( 'should not set an attribute if it is not allowed by schema', () => {
 		it( 'should not set an attribute if it is not allowed by schema', () => {
-			const helper = upcastElementToAttribute( { view: 'em', model: 'italic' } );
-
-			conversion.for( 'upcast' ).add( helper );
+			upcastHelpers.elementToAttribute( { view: 'em', model: 'italic' } );
 
 
 			expectResult(
 			expectResult(
 				new ViewAttributeElement( 'em', null, new ViewText( 'foo' ) ),
 				new ViewAttributeElement( 'em', null, new ViewText( 'foo' ) ),
@@ -289,8 +273,8 @@ describe( 'upcast-helpers', () => {
 		} );
 		} );
 
 
 		it( 'should not do anything if returned model attribute is null', () => {
 		it( 'should not do anything if returned model attribute is null', () => {
-			const helperA = upcastElementToAttribute( { view: 'strong', model: 'bold' } );
-			const helperB = upcastElementToAttribute( {
+			upcastHelpers.elementToAttribute( { view: 'strong', model: 'bold' } );
+			upcastHelpers.elementToAttribute( {
 				view: 'strong',
 				view: 'strong',
 				model: {
 				model: {
 					key: 'bold',
 					key: 'bold',
@@ -299,8 +283,6 @@ describe( 'upcast-helpers', () => {
 				converterPriority: 'high'
 				converterPriority: 'high'
 			} );
 			} );
 
 
-			conversion.for( 'upcast' ).add( helperA ).add( helperB );
-
 			expectResult(
 			expectResult(
 				new ViewAttributeElement( 'strong', null, new ViewText( 'foo' ) ),
 				new ViewAttributeElement( 'strong', null, new ViewText( 'foo' ) ),
 				'<$text bold="true">foo</$text>'
 				'<$text bold="true">foo</$text>'
@@ -308,18 +290,16 @@ describe( 'upcast-helpers', () => {
 		} );
 		} );
 
 
 		it( 'should allow two converters to convert attributes on the same element', () => {
 		it( 'should allow two converters to convert attributes on the same element', () => {
-			const helperA = upcastElementToAttribute( {
+			upcastHelpers.elementToAttribute( {
 				model: 'attribA',
 				model: 'attribA',
 				view: { name: 'span', classes: 'attrib-a' }
 				view: { name: 'span', classes: 'attrib-a' }
 			} );
 			} );
 
 
-			const helperB = upcastElementToAttribute( {
+			upcastHelpers.elementToAttribute( {
 				model: 'attribB',
 				model: 'attribB',
 				view: { name: 'span', styles: { color: 'attrib-b' } }
 				view: { name: 'span', styles: { color: 'attrib-b' } }
 			} );
 			} );
 
 
-			conversion.for( 'upcast' ).add( helperA ).add( helperB );
-
 			expectResult(
 			expectResult(
 				new ViewAttributeElement( 'span', { class: 'attrib-a', style: 'color:attrib-b;' }, new ViewText( 'foo' ) ),
 				new ViewAttributeElement( 'span', { class: 'attrib-a', style: 'color:attrib-b;' }, new ViewText( 'foo' ) ),
 				'<$text attribA="true" attribB="true">foo</$text>'
 				'<$text attribA="true" attribB="true">foo</$text>'
@@ -327,23 +307,21 @@ describe( 'upcast-helpers', () => {
 		} );
 		} );
 
 
 		it( 'should consume element only when only is name specified', () => {
 		it( 'should consume element only when only is name specified', () => {
-			const helperBold = upcastElementToAttribute( {
+			upcastHelpers.elementToAttribute( {
 				model: 'bold',
 				model: 'bold',
 				view: { name: 'strong' }
 				view: { name: 'strong' }
 			} );
 			} );
 
 
-			const helperA = upcastElementToAttribute( {
+			upcastHelpers.elementToAttribute( {
 				model: 'attribA',
 				model: 'attribA',
 				view: { name: 'strong' }
 				view: { name: 'strong' }
 			} );
 			} );
 
 
-			const helperB = upcastElementToAttribute( {
+			upcastHelpers.elementToAttribute( {
 				model: 'attribB',
 				model: 'attribB',
 				view: { name: 'strong', classes: 'foo' }
 				view: { name: 'strong', classes: 'foo' }
 			} );
 			} );
 
 
-			conversion.for( 'upcast' ).add( helperBold ).add( helperA ).add( helperB );
-
 			expectResult(
 			expectResult(
 				new ViewAttributeElement( 'strong', { class: 'foo' }, new ViewText( 'foo' ) ),
 				new ViewAttributeElement( 'strong', { class: 'foo' }, new ViewText( 'foo' ) ),
 				'<$text attribB="true" bold="true">foo</$text>'
 				'<$text attribB="true" bold="true">foo</$text>'
@@ -352,14 +330,12 @@ describe( 'upcast-helpers', () => {
 
 
 		// #1443.
 		// #1443.
 		it( 'should set attributes on the element\'s children', () => {
 		it( 'should set attributes on the element\'s children', () => {
-			const helperBold = upcastElementToAttribute( {
+			upcastHelpers.elementToAttribute( {
 				model: 'bold',
 				model: 'bold',
 				view: { name: 'strong' }
 				view: { name: 'strong' }
 			} );
 			} );
 
 
-			const helperP = upcastElementToElement( { view: 'p', model: 'paragraph' } );
-
-			conversion.for( 'upcast' ).add( helperP ).add( helperBold );
+			upcastHelpers.elementToElement( { view: 'p', model: 'paragraph' } );
 
 
 			expectResult(
 			expectResult(
 				new ViewAttributeElement(
 				new ViewAttributeElement(
@@ -372,23 +348,25 @@ describe( 'upcast-helpers', () => {
 		} );
 		} );
 	} );
 	} );
 
 
-	describe( 'upcastAttributeToAttribute', () => {
+	describe( '.attributeToAttribute()', () => {
 		beforeEach( () => {
 		beforeEach( () => {
-			conversion.for( 'upcast' ).add( upcastElementToElement( { view: 'img', model: 'image' } ) );
+			upcastHelpers.elementToElement( { view: 'img', model: 'image' } );
 
 
 			schema.register( 'image', {
 			schema.register( 'image', {
 				inheritAllFrom: '$block'
 				inheritAllFrom: '$block'
 			} );
 			} );
 		} );
 		} );
 
 
+		it( 'should be chainable', () => {
+			expect( upcastHelpers.attributeToAttribute( { view: 'src', model: 'source' } ) ).to.equal( upcastHelpers );
+		} );
+
 		it( 'config.view is a string', () => {
 		it( 'config.view is a string', () => {
 			schema.extend( 'image', {
 			schema.extend( 'image', {
 				allowAttributes: [ 'source' ]
 				allowAttributes: [ 'source' ]
 			} );
 			} );
 
 
-			const helper = upcastAttributeToAttribute( { view: 'src', model: 'source' } );
-
-			conversion.for( 'upcast' ).add( helper );
+			upcastHelpers.attributeToAttribute( { view: 'src', model: 'source' } );
 
 
 			expectResult(
 			expectResult(
 				new ViewAttributeElement( 'img', { src: 'foo.jpg' } ),
 				new ViewAttributeElement( 'img', { src: 'foo.jpg' } ),
@@ -401,9 +379,7 @@ describe( 'upcast-helpers', () => {
 				allowAttributes: [ 'source' ]
 				allowAttributes: [ 'source' ]
 			} );
 			} );
 
 
-			const helper = upcastAttributeToAttribute( { view: { key: 'src' }, model: 'source' } );
-
-			conversion.for( 'upcast' ).add( helper );
+			upcastHelpers.attributeToAttribute( { view: { key: 'src' }, model: 'source' } );
 
 
 			expectResult(
 			expectResult(
 				new ViewAttributeElement( 'img', { src: 'foo.jpg' } ),
 				new ViewAttributeElement( 'img', { src: 'foo.jpg' } ),
@@ -416,9 +392,7 @@ describe( 'upcast-helpers', () => {
 				allowAttributes: [ 'source' ]
 				allowAttributes: [ 'source' ]
 			} );
 			} );
 
 
-			const helper = upcastAttributeToAttribute( { view: { name: 'img', key: 'src' }, model: { name: 'image', key: 'source' } } );
-
-			conversion.for( 'upcast' ).add( helper );
+			upcastHelpers.attributeToAttribute( { view: { name: 'img', key: 'src' }, model: { name: 'image', key: 'source' } } );
 
 
 			expectResult(
 			expectResult(
 				new ViewAttributeElement( 'img', { src: 'foo.jpg' } ),
 				new ViewAttributeElement( 'img', { src: 'foo.jpg' } ),
@@ -431,10 +405,8 @@ describe( 'upcast-helpers', () => {
 				allowAttributes: [ 'src', 'source' ]
 				allowAttributes: [ 'src', 'source' ]
 			} );
 			} );
 
 
-			const helperA = upcastAttributeToAttribute( { view: { key: 'src' }, model: 'src' } );
-			const helperB = upcastAttributeToAttribute( { view: { key: 'src' }, model: 'source', converterPriority: 'normal' } );
-
-			conversion.for( 'upcast' ).add( helperA ).add( helperB );
+			upcastHelpers.attributeToAttribute( { view: { key: 'src' }, model: 'src' } );
+			upcastHelpers.attributeToAttribute( { view: { key: 'src' }, model: 'source', converterPriority: 'normal' } );
 
 
 			expectResult(
 			expectResult(
 				new ViewAttributeElement( 'img', { src: 'foo.jpg' } ),
 				new ViewAttributeElement( 'img', { src: 'foo.jpg' } ),
@@ -447,7 +419,7 @@ describe( 'upcast-helpers', () => {
 				allowAttributes: [ 'styled' ]
 				allowAttributes: [ 'styled' ]
 			} );
 			} );
 
 
-			const helper = upcastAttributeToAttribute( {
+			upcastHelpers.attributeToAttribute( {
 				view: {
 				view: {
 					key: 'data-style',
 					key: 'data-style',
 					value: /[\s\S]*/
 					value: /[\s\S]*/
@@ -455,8 +427,6 @@ describe( 'upcast-helpers', () => {
 				model: 'styled'
 				model: 'styled'
 			} );
 			} );
 
 
-			conversion.for( 'upcast' ).add( helper );
-
 			expectResult(
 			expectResult(
 				new ViewAttributeElement( 'img', { 'data-style': 'dark' } ),
 				new ViewAttributeElement( 'img', { 'data-style': 'dark' } ),
 				'<image styled="dark"></image>'
 				'<image styled="dark"></image>'
@@ -468,7 +438,7 @@ describe( 'upcast-helpers', () => {
 				allowAttributes: [ 'styled' ]
 				allowAttributes: [ 'styled' ]
 			} );
 			} );
 
 
-			const helper = upcastAttributeToAttribute( {
+			upcastHelpers.attributeToAttribute( {
 				view: {
 				view: {
 					name: 'img',
 					name: 'img',
 					key: 'class',
 					key: 'class',
@@ -480,9 +450,7 @@ describe( 'upcast-helpers', () => {
 				}
 				}
 			} );
 			} );
 
 
-			conversion.for( 'upcast' )
-				.add( helper )
-				.add( upcastElementToElement( { view: 'p', model: 'paragraph' } ) );
+			upcastHelpers.elementToElement( { view: 'p', model: 'paragraph' } );
 
 
 			expectResult(
 			expectResult(
 				new ViewContainerElement( 'img', { class: 'styled-dark' } ),
 				new ViewContainerElement( 'img', { class: 'styled-dark' } ),
@@ -505,7 +473,7 @@ describe( 'upcast-helpers', () => {
 				allowAttributes: [ 'styled' ]
 				allowAttributes: [ 'styled' ]
 			} );
 			} );
 
 
-			const helper = upcastAttributeToAttribute( {
+			upcastHelpers.attributeToAttribute( {
 				view: {
 				view: {
 					key: 'class',
 					key: 'class',
 					value: /styled-[\S]+/
 					value: /styled-[\S]+/
@@ -521,8 +489,6 @@ describe( 'upcast-helpers', () => {
 				}
 				}
 			} );
 			} );
 
 
-			conversion.for( 'upcast' ).add( helper );
-
 			expectResult(
 			expectResult(
 				new ViewAttributeElement( 'img', { 'class': 'styled-dark' } ),
 				new ViewAttributeElement( 'img', { 'class': 'styled-dark' } ),
 				'<image styled="dark"></image>'
 				'<image styled="dark"></image>'
@@ -530,9 +496,7 @@ describe( 'upcast-helpers', () => {
 		} );
 		} );
 
 
 		it( 'should not set an attribute if it is not allowed by schema', () => {
 		it( 'should not set an attribute if it is not allowed by schema', () => {
-			const helper = upcastAttributeToAttribute( { view: 'src', model: 'source' } );
-
-			conversion.for( 'upcast' ).add( helper );
+			upcastHelpers.attributeToAttribute( { view: 'src', model: 'source' } );
 
 
 			expectResult(
 			expectResult(
 				new ViewAttributeElement( 'img', { src: 'foo.jpg' } ),
 				new ViewAttributeElement( 'img', { src: 'foo.jpg' } ),
@@ -545,7 +509,7 @@ describe( 'upcast-helpers', () => {
 				allowAttributes: [ 'styled' ]
 				allowAttributes: [ 'styled' ]
 			} );
 			} );
 
 
-			const helperA = upcastAttributeToAttribute( {
+			upcastHelpers.attributeToAttribute( {
 				view: {
 				view: {
 					key: 'class',
 					key: 'class',
 					value: 'styled'
 					value: 'styled'
@@ -556,7 +520,7 @@ describe( 'upcast-helpers', () => {
 				}
 				}
 			} );
 			} );
 
 
-			const helperB = upcastAttributeToAttribute( {
+			upcastHelpers.attributeToAttribute( {
 				view: {
 				view: {
 					key: 'class',
 					key: 'class',
 					value: 'styled'
 					value: 'styled'
@@ -567,8 +531,6 @@ describe( 'upcast-helpers', () => {
 				}
 				}
 			} );
 			} );
 
 
-			conversion.for( 'upcast' ).add( helperA ).add( helperB );
-
 			expectResult(
 			expectResult(
 				new ViewAttributeElement( 'img', { class: 'styled' } ),
 				new ViewAttributeElement( 'img', { class: 'styled' } ),
 				'<image styled="true"></image>'
 				'<image styled="true"></image>'
@@ -584,13 +546,10 @@ describe( 'upcast-helpers', () => {
 				allowAttributes: [ 'border', 'shade' ]
 				allowAttributes: [ 'border', 'shade' ]
 			} );
 			} );
 
 
-			conversion.for( 'upcast' ).add( upcastElementToElement( { view: 'div', model: 'div' } ) );
-
-			const shadeHelper = upcastAttributeToAttribute( { view: { key: 'class', value: 'shade' }, model: 'shade' } );
-			const borderHelper = upcastAttributeToAttribute( { view: { key: 'class', value: 'border' }, model: 'border' } );
+			upcastHelpers.elementToElement( { view: 'div', model: 'div' } );
 
 
-			conversion.for( 'upcast' ).add( shadeHelper );
-			conversion.for( 'upcast' ).add( borderHelper );
+			upcastHelpers.attributeToAttribute( { view: { key: 'class', value: 'shade' }, model: 'shade' } );
+			upcastHelpers.attributeToAttribute( { view: { key: 'class', value: 'border' }, model: 'border' } );
 
 
 			expectResult(
 			expectResult(
 				new ViewContainerElement(
 				new ViewContainerElement(
@@ -603,11 +562,13 @@ describe( 'upcast-helpers', () => {
 		} );
 		} );
 	} );
 	} );
 
 
-	describe( 'upcastElementToMarker', () => {
-		it( 'config.view is a string', () => {
-			const helper = upcastElementToMarker( { view: 'marker-search', model: 'search' } );
+	describe( '.elementToMarker()', () => {
+		it( 'should be chainable', () => {
+			expect( upcastHelpers.elementToMarker( { view: 'marker-search', model: 'search' } ) ).to.equal( upcastHelpers );
+		} );
 
 
-			conversion.for( 'upcast' ).add( helper );
+		it( 'config.view is a string', () => {
+			upcastHelpers.elementToMarker( { view: 'marker-search', model: 'search' } );
 
 
 			const frag = new ViewDocumentFragment( [
 			const frag = new ViewDocumentFragment( [
 				new ViewText( 'fo' ),
 				new ViewText( 'fo' ),
@@ -623,10 +584,8 @@ describe( 'upcast-helpers', () => {
 		} );
 		} );
 
 
 		it( 'can be overwritten using converterPriority', () => {
 		it( 'can be overwritten using converterPriority', () => {
-			const helperA = upcastElementToMarker( { view: 'marker-search', model: 'search-result' } );
-			const helperB = upcastElementToMarker( { view: 'marker-search', model: 'search', converterPriority: 'high' } );
-
-			conversion.for( 'upcast' ).add( helperA ).add( helperB );
+			upcastHelpers.elementToMarker( { view: 'marker-search', model: 'search-result' } );
+			upcastHelpers.elementToMarker( { view: 'marker-search', model: 'search', converterPriority: 'high' } );
 
 
 			const frag = new ViewDocumentFragment( [
 			const frag = new ViewDocumentFragment( [
 				new ViewText( 'fo' ),
 				new ViewText( 'fo' ),
@@ -642,7 +601,7 @@ describe( 'upcast-helpers', () => {
 		} );
 		} );
 
 
 		it( 'config.view is an object', () => {
 		it( 'config.view is an object', () => {
-			const helper = upcastElementToMarker( {
+			upcastHelpers.elementToMarker( {
 				view: {
 				view: {
 					name: 'span',
 					name: 'span',
 					'data-marker': 'search'
 					'data-marker': 'search'
@@ -650,8 +609,6 @@ describe( 'upcast-helpers', () => {
 				model: 'search'
 				model: 'search'
 			} );
 			} );
 
 
-			conversion.for( 'upcast' ).add( helper );
-
 			const frag = new ViewDocumentFragment( [
 			const frag = new ViewDocumentFragment( [
 				new ViewText( 'f' ),
 				new ViewText( 'f' ),
 				new ViewUIElement( 'span', { 'data-marker': 'search' } ),
 				new ViewUIElement( 'span', { 'data-marker': 'search' } ),
@@ -666,13 +623,11 @@ describe( 'upcast-helpers', () => {
 		} );
 		} );
 
 
 		it( 'config.model is a function', () => {
 		it( 'config.model is a function', () => {
-			const helper = upcastElementToMarker( {
+			upcastHelpers.elementToMarker( {
 				view: 'comment',
 				view: 'comment',
 				model: viewElement => 'comment:' + viewElement.getAttribute( 'data-comment-id' )
 				model: viewElement => 'comment:' + viewElement.getAttribute( 'data-comment-id' )
 			} );
 			} );
 
 
-			conversion.for( 'upcast' ).add( helper );
-
 			const frag = new ViewDocumentFragment( [
 			const frag = new ViewDocumentFragment( [
 				new ViewText( 'foo' ),
 				new ViewText( 'foo' ),
 				new ViewUIElement( 'comment', { 'data-comment-id': 4 } ),
 				new ViewUIElement( 'comment', { 'data-comment-id': 4 } ),
@@ -687,11 +642,9 @@ describe( 'upcast-helpers', () => {
 		} );
 		} );
 
 
 		it( 'marker is in a block element', () => {
 		it( 'marker is in a block element', () => {
-			conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'paragraph', view: 'p' } ) );
-
-			const helper = upcastElementToMarker( { view: 'marker-search', model: 'search' } );
+			upcastHelpers.elementToElement( { model: 'paragraph', view: 'p' } );
 
 
-			conversion.for( 'upcast' ).add( helper );
+			upcastHelpers.elementToMarker( { view: 'marker-search', model: 'search' } );
 
 
 			const element = new ViewContainerElement( 'p', null, [
 			const element = new ViewContainerElement( 'p', null, [
 				new ViewText( 'fo' ),
 				new ViewText( 'fo' ),
@@ -887,4 +840,118 @@ describe( 'upcast-converters', () => {
 			sinon.assert.calledTwice( spy );
 			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;
+		} );
+	} );
 } );
 } );

+ 22 - 0
packages/ckeditor5-engine/tests/dev-utils/model.js

@@ -79,6 +79,28 @@ describe( 'model test utils', () => {
 				getData( { invalid: 'document' } );
 				getData( { invalid: 'document' } );
 			} ).to.throw( TypeError, 'Model needs to be an instance of module:engine/model/model~Model.' );
 			} ).to.throw( TypeError, 'Model needs to be an instance of module:engine/model/model~Model.' );
 		} );
 		} );
+
+		describe( 'markers', () => {
+			it( 'should stringify collapsed marker', () => {
+				setData( model, '<paragraph>bar</paragraph>' );
+
+				model.markers._set( 'foo', new Range( Position._createAt( document.getRoot(), 0 ) ) );
+
+				expect( getData( model, { convertMarkers: true, withoutSelection: true } ) )
+					.to.equal( '<foo:start></foo:start><paragraph>bar</paragraph>' );
+			} );
+
+			it( 'should stringify non-collapsed marker', () => {
+				setData( model, '<paragraph>bar</paragraph>' );
+
+				const markerRange = new Range( Position._createAt( document.getRoot(), 0 ), Position._createAt( document.getRoot(), 1 ) );
+
+				model.markers._set( 'foo', markerRange );
+
+				expect( getData( model, { convertMarkers: true, withoutSelection: true } ) )
+					.to.equal( '<foo:start></foo:start><paragraph>bar</paragraph><foo:end></foo:end>' );
+			} );
+		} );
 	} );
 	} );
 
 
 	describe( 'setData', () => {
 	describe( 'setData', () => {

+ 8 - 18
packages/ckeditor5-engine/tests/manual/highlight.js

@@ -5,15 +5,6 @@
 
 
 /* global console, window, document */
 /* global console, window, document */
 
 
-import {
-	upcastElementToElement,
-} from '../../src/conversion/upcast-converters';
-
-import {
-	downcastElementToElement,
-	downcastMarkerToHighlight
-} from '../../src/conversion/downcast-converters';
-
 import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
 import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
 import Enter from '@ckeditor/ckeditor5-enter/src/enter';
 import Enter from '@ckeditor/ckeditor5-enter/src/enter';
 import Typing from '@ckeditor/ckeditor5-typing/src/typing';
 import Typing from '@ckeditor/ckeditor5-typing/src/typing';
@@ -44,7 +35,7 @@ class FancyWidget extends Plugin {
 		} );
 		} );
 		schema.extend( 'fancywidget', { allowIn: '$root' } );
 		schema.extend( 'fancywidget', { allowIn: '$root' } );
 
 
-		conversion.for( 'editingDowncast' ).add( downcastElementToElement( {
+		conversion.for( 'editingDowncast' ).elementToElement( {
 			model: 'fancywidget',
 			model: 'fancywidget',
 			view: ( modelItem, viewWriter ) => {
 			view: ( modelItem, viewWriter ) => {
 				const widgetElement = viewWriter.createContainerElement( 'figure', { class: 'fancy-widget' } );
 				const widgetElement = viewWriter.createContainerElement( 'figure', { class: 'fancy-widget' } );
@@ -52,13 +43,12 @@ class FancyWidget extends Plugin {
 
 
 				return toWidget( widgetElement, viewWriter );
 				return toWidget( widgetElement, viewWriter );
 			}
 			}
-		} ) );
+		} );
 
 
-		conversion.for( 'upcast' )
-			.add( upcastElementToElement( {
-				view: 'figure',
-				model: 'fancywidget'
-			} ) );
+		conversion.for( 'upcast' ).elementToElement( {
+			view: 'figure',
+			model: 'fancywidget'
+		} );
 	}
 	}
 }
 }
 
 
@@ -69,12 +59,12 @@ ClassicEditor.create( global.document.querySelector( '#editor' ), {
 	.then( editor => {
 	.then( editor => {
 		window.editor = editor;
 		window.editor = editor;
 
 
-		editor.conversion.for( 'editingDowncast' ).add( downcastMarkerToHighlight( {
+		editor.conversion.for( 'editingDowncast' ).markerToHighlight( {
 			model: 'marker',
 			model: 'marker',
 			view: data => ( {
 			view: data => ( {
 				classes: 'highlight-' + data.markerName.split( ':' )[ 1 ]
 				classes: 'highlight-' + data.markerName.split( ':' )[ 1 ]
 			} )
 			} )
-		} ) );
+		} );
 
 
 		document.getElementById( 'add-marker-yellow' ).addEventListener( 'mousedown', evt => {
 		document.getElementById( 'add-marker-yellow' ).addEventListener( 'mousedown', evt => {
 			addMarker( editor, 'yellow' );
 			addMarker( editor, 'yellow' );

+ 4 - 8
packages/ckeditor5-engine/tests/manual/markers.js

@@ -15,10 +15,6 @@ import List from '@ckeditor/ckeditor5-list/src/list';
 import Heading from '@ckeditor/ckeditor5-heading/src/heading';
 import Heading from '@ckeditor/ckeditor5-heading/src/heading';
 import Undo from '@ckeditor/ckeditor5-undo/src/undo';
 import Undo from '@ckeditor/ckeditor5-undo/src/undo';
 
 
-import {
-	downcastMarkerToHighlight
-} from '../../src/conversion/downcast-converters';
-
 import Position from '../../src/model/position';
 import Position from '../../src/model/position';
 import Range from '../../src/model/range';
 import Range from '../../src/model/range';
 
 
@@ -35,7 +31,7 @@ ClassicEditor
 		window.editor = editor;
 		window.editor = editor;
 		model = editor.model;
 		model = editor.model;
 
 
-		editor.conversion.for( 'editingDowncast' ).add( downcastMarkerToHighlight( {
+		editor.conversion.for( 'editingDowncast' ).markerToHighlight( {
 			model: 'highlight',
 			model: 'highlight',
 			view: data => {
 			view: data => {
 				const color = data.markerName.split( ':' )[ 1 ];
 				const color = data.markerName.split( ':' )[ 1 ];
@@ -45,9 +41,9 @@ ClassicEditor
 					priority: 1
 					priority: 1
 				};
 				};
 			}
 			}
-		} ) );
+		} );
 
 
-		editor.conversion.for( 'dataDowncast' ).add( downcastMarkerToHighlight( {
+		editor.conversion.for( 'dataDowncast' ).markerToHighlight( {
 			model: 'highlight',
 			model: 'highlight',
 			view: data => {
 			view: data => {
 				const color = data.markerName.split( ':' )[ 1 ];
 				const color = data.markerName.split( ':' )[ 1 ];
@@ -57,7 +53,7 @@ ClassicEditor
 					priority: 1
 					priority: 1
 				};
 				};
 			}
 			}
-		} ) );
+		} );
 
 
 		window.document.getElementById( 'add-yellow' ).addEventListener( 'mousedown', e => {
 		window.document.getElementById( 'add-yellow' ).addEventListener( 'mousedown', e => {
 			e.preventDefault();
 			e.preventDefault();

+ 8 - 16
packages/ckeditor5-engine/tests/manual/nestededitable.js

@@ -5,14 +5,6 @@
 
 
 /* global console */
 /* global console */
 
 
-import {
-	upcastElementToElement
-} from '../../src/conversion/upcast-converters';
-
-import {
-	downcastElementToElement
-} from '../../src/conversion/downcast-converters';
-
 import { getData } from '../../src/dev-utils/model';
 import { getData } from '../../src/dev-utils/model';
 import global from '@ckeditor/ckeditor5-utils/src/dom/global';
 import global from '@ckeditor/ckeditor5-utils/src/dom/global';
 
 
@@ -41,7 +33,7 @@ class NestedEditable extends Plugin {
 			allowIn: [ 'figure', 'figcaption' ]
 			allowIn: [ 'figure', 'figcaption' ]
 		} );
 		} );
 
 
-		editor.conversion.for( 'downcast' ).add( downcastElementToElement( {
+		editor.conversion.for( 'downcast' ).elementToElement( {
 			model: 'figure',
 			model: 'figure',
 			view: {
 			view: {
 				name: 'figure',
 				name: 'figure',
@@ -49,14 +41,14 @@ class NestedEditable extends Plugin {
 					contenteditable: 'false'
 					contenteditable: 'false'
 				}
 				}
 			}
 			}
-		} ) );
+		} );
 
 
-		editor.conversion.for( 'upcast' ).add( upcastElementToElement( {
+		editor.conversion.for( 'upcast' ).elementToElement( {
 			model: 'figure',
 			model: 'figure',
 			view: 'figure'
 			view: 'figure'
-		} ) );
+		} );
 
 
-		editor.conversion.for( 'downcast' ).add( downcastElementToElement( {
+		editor.conversion.for( 'downcast' ).elementToElement( {
 			model: 'figcaption',
 			model: 'figcaption',
 			view: ( modelItem, viewWriter ) => {
 			view: ( modelItem, viewWriter ) => {
 				const element = viewWriter.createEditableElement( 'figcaption', { contenteditable: 'true' } );
 				const element = viewWriter.createEditableElement( 'figcaption', { contenteditable: 'true' } );
@@ -71,12 +63,12 @@ class NestedEditable extends Plugin {
 
 
 				return element;
 				return element;
 			}
 			}
-		} ) );
+		} );
 
 
-		editor.conversion.for( 'upcast' ).add( upcastElementToElement( {
+		editor.conversion.for( 'upcast' ).elementToElement( {
 			model: 'figcaption',
 			model: 'figcaption',
 			view: 'figcaption'
 			view: 'figcaption'
-		} ) );
+		} );
 	}
 	}
 }
 }
 
 

+ 8 - 11
packages/ckeditor5-engine/tests/manual/selection.js

@@ -5,8 +5,6 @@
 
 
 /* global console */
 /* global console */
 
 
-import { downcastElementToElement } from '../../src/conversion/downcast-converters';
-
 import { getData } from '../../src/dev-utils/model';
 import { getData } from '../../src/dev-utils/model';
 import global from '@ckeditor/ckeditor5-utils/src/dom/global';
 import global from '@ckeditor/ckeditor5-utils/src/dom/global';
 
 
@@ -16,7 +14,6 @@ import Enter from '@ckeditor/ckeditor5-enter/src/enter';
 import Typing from '@ckeditor/ckeditor5-typing/src/typing';
 import Typing from '@ckeditor/ckeditor5-typing/src/typing';
 import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 import Undo from '@ckeditor/ckeditor5-undo/src/undo';
 import Undo from '@ckeditor/ckeditor5-undo/src/undo';
-import { upcastElementToElement } from '../../src/conversion/upcast-converters';
 
 
 import './selection.css';
 import './selection.css';
 import { toWidget, toWidgetEditable } from '@ckeditor/ckeditor5-widget/src/utils';
 import { toWidget, toWidgetEditable } from '@ckeditor/ckeditor5-widget/src/utils';
@@ -44,24 +41,24 @@ class SelectionTest extends Plugin {
 			isLimit: true
 			isLimit: true
 		} );
 		} );
 
 
-		editor.conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'table', view: 'table' } ) );
-		editor.conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableRow', view: 'tr' } ) );
-		editor.conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableCell', view: 'td' } ) );
+		editor.conversion.for( 'upcast' ).elementToElement( { model: 'table', view: 'table' } );
+		editor.conversion.for( 'upcast' ).elementToElement( { model: 'tableRow', view: 'tr' } );
+		editor.conversion.for( 'upcast' ).elementToElement( { model: 'tableCell', view: 'td' } );
 
 
-		editor.conversion.for( 'downcast' ).add( downcastElementToElement( {
+		editor.conversion.for( 'downcast' ).elementToElement( {
 			model: 'table',
 			model: 'table',
 			view: ( modelItem, viewWriter ) => {
 			view: ( modelItem, viewWriter ) => {
 				return toWidget( viewWriter.createContainerElement( 'table' ), viewWriter );
 				return toWidget( viewWriter.createContainerElement( 'table' ), viewWriter );
 			}
 			}
-		} ) );
-		editor.conversion.for( 'downcast' ).add( downcastElementToElement( { model: 'tableRow', view: 'tr' } ) );
+		} );
+		editor.conversion.for( 'downcast' ).elementToElement( { model: 'tableRow', view: 'tr' } );
 
 
-		editor.conversion.for( 'downcast' ).add( downcastElementToElement( {
+		editor.conversion.for( 'downcast' ).elementToElement( {
 			model: 'tableCell',
 			model: 'tableCell',
 			view: ( modelItem, viewWriter ) => {
 			view: ( modelItem, viewWriter ) => {
 				return toWidgetEditable( viewWriter.createEditableElement( 'td' ), viewWriter );
 				return toWidgetEditable( viewWriter.createEditableElement( 'td' ), viewWriter );
 			}
 			}
-		} ) );
+		} );
 	}
 	}
 }
 }
 
 

+ 4 - 12
packages/ckeditor5-engine/tests/manual/tickets/475/1.js

@@ -11,14 +11,6 @@ import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import Range from '../../../../src/model/range';
 import Range from '../../../../src/model/range';
 import LivePosition from '../../../../src/model/liveposition';
 import LivePosition from '../../../../src/model/liveposition';
 
 
-import {
-	upcastElementToAttribute
-} from '../../../../src/conversion/upcast-converters';
-
-import {
-	downcastAttributeToElement,
-} from '../../../../src/conversion/downcast-converters';
-
 import Enter from '@ckeditor/ckeditor5-enter/src/enter';
 import Enter from '@ckeditor/ckeditor5-enter/src/enter';
 import Typing from '@ckeditor/ckeditor5-typing/src/typing';
 import Typing from '@ckeditor/ckeditor5-typing/src/typing';
 import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
@@ -31,20 +23,20 @@ class Link extends Plugin {
 		// Allow bold attribute on all inline nodes.
 		// Allow bold attribute on all inline nodes.
 		editor.model.schema.extend( '$text', { allowAttributes: 'link' } );
 		editor.model.schema.extend( '$text', { allowAttributes: 'link' } );
 
 
-		editor.conversion.for( 'downcast' ).add( downcastAttributeToElement( {
+		editor.conversion.for( 'downcast' ).attributeToElement( {
 			model: 'link',
 			model: 'link',
 			view: ( modelAttributeValue, viewWriter ) => {
 			view: ( modelAttributeValue, viewWriter ) => {
 				return viewWriter.createAttributeElement( 'a', { href: modelAttributeValue } );
 				return viewWriter.createAttributeElement( 'a', { href: modelAttributeValue } );
 			}
 			}
-		} ) );
+		} );
 
 
-		editor.conversion.for( 'upcast' ).add( upcastElementToAttribute( {
+		editor.conversion.for( 'upcast' ).elementToAttribute( {
 			view: 'a',
 			view: 'a',
 			model: {
 			model: {
 				key: 'link',
 				key: 'link',
 				value: viewElement => viewElement.getAttribute( 'href' )
 				value: viewElement => viewElement.getAttribute( 'href' )
 			}
 			}
-		} ) );
+		} );
 	}
 	}
 }
 }
 
 

+ 4 - 5
packages/ckeditor5-engine/tests/manual/tickets/ckeditor5-721/1.js

@@ -13,7 +13,6 @@ import { toWidget } from '@ckeditor/ckeditor5-widget/src/utils';
 import Widget from '@ckeditor/ckeditor5-widget/src/widget';
 import Widget from '@ckeditor/ckeditor5-widget/src/widget';
 
 
 import ViewPosition from '../../../../src/view/position';
 import ViewPosition from '../../../../src/view/position';
-import { downcastElementToElement } from '../../../../src/conversion/downcast-converters';
 import { setData } from '../../../../src/dev-utils/model';
 import { setData } from '../../../../src/dev-utils/model';
 
 
 ClassicEditor
 ClassicEditor
@@ -41,7 +40,7 @@ ClassicEditor
 		} );
 		} );
 
 
 		editor.conversion.for( 'downcast' )
 		editor.conversion.for( 'downcast' )
-			.add( downcastElementToElement( {
+			.elementToElement( {
 				model: 'widget',
 				model: 'widget',
 				view: ( modelItem, writer ) => {
 				view: ( modelItem, writer ) => {
 					const b = writer.createAttributeElement( 'b' );
 					const b = writer.createAttributeElement( 'b' );
@@ -51,11 +50,11 @@ ClassicEditor
 
 
 					return toWidget( div, writer, { label: 'element label' } );
 					return toWidget( div, writer, { label: 'element label' } );
 				}
 				}
-			} ) )
-			.add( downcastElementToElement( {
+			} )
+			.elementToElement( {
 				model: 'nested',
 				model: 'nested',
 				view: ( item, writer ) => writer.createEditableElement( 'figcaption', { contenteditable: true } )
 				view: ( item, writer ) => writer.createEditableElement( 'figcaption', { contenteditable: true } )
-			} ) );
+			} );
 
 
 		setData( editor.model,
 		setData( editor.model,
 			'<paragraph>foo[]</paragraph>' +
 			'<paragraph>foo[]</paragraph>' +

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

@@ -421,6 +421,52 @@ describe( 'transform', () => {
 					'<paragraph><m1:start></m1:start>Foo Bar<m1:end></m1:end></paragraph>'
 					'<paragraph><m1:start></m1:start>Foo Bar<m1:end></m1:end></paragraph>'
 				);
 				);
 			} );
 			} );
+
+			it( 'left side of marker moved, insertion at the moved range start, move undo', () => {
+				john.setData( '<paragraph>Foo[bar]</paragraph><paragraph></paragraph>' );
+				kate.setData( '<paragraph>Foo[bar]</paragraph><paragraph></paragraph>' );
+
+				john.setMarker( 'm1' );
+				john.setSelection( [ 0, 2 ], [ 0, 4 ] );
+				john.move( [ 1, 0 ] );
+
+				syncClients();
+
+				kate.setSelection( [ 0, 2 ] );
+				kate.type( 'xx' );
+
+				syncClients();
+
+				expectClients( '<paragraph>Foxx<m1:start></m1:start>ar<m1:end></m1:end></paragraph><paragraph>ob</paragraph>' );
+
+				john.undo();
+				syncClients();
+
+				expectClients( '<paragraph>Foobxx<m1:start></m1:start>ar<m1:end></m1:end></paragraph><paragraph></paragraph>' );
+			} );
+
+			it( 'right side of marker moved, insertion at the moved range start, move undo', () => {
+				john.setData( '<paragraph>[Foo]bar</paragraph><paragraph></paragraph>' );
+				kate.setData( '<paragraph>[Foo]bar</paragraph><paragraph></paragraph>' );
+
+				john.setMarker( 'm1' );
+				john.setSelection( [ 0, 2 ], [ 0, 4 ] );
+				john.move( [ 1, 0 ] );
+
+				syncClients();
+
+				kate.setSelection( [ 0, 2 ] );
+				kate.type( 'xx' );
+
+				syncClients();
+
+				expectClients( '<paragraph><m1:start></m1:start>Fo<m1:end></m1:end>xxar</paragraph><paragraph>ob</paragraph>' );
+
+				john.undo();
+				syncClients();
+
+				expectClients( '<paragraph><m1:start></m1:start>Foo<m1:end></m1:end>bxxar</paragraph><paragraph></paragraph>' );
+			} );
 		} );
 		} );
 
 
 		describe( 'by remove', () => {
 		describe( 'by remove', () => {

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

@@ -388,4 +388,74 @@ describe( 'transform', () => {
 		john.undo();
 		john.undo();
 		expectClients( '<paragraph>Foo</paragraph><paragraph>Bar</paragraph>' );
 		expectClients( '<paragraph>Foo</paragraph><paragraph>Bar</paragraph>' );
 	} );
 	} );
+
+	it( 'collapsed marker at the beginning of merged element then undo', () => {
+		john.setData( '<paragraph>Foo</paragraph><paragraph>[]Bar</paragraph>' );
+
+		john.setMarker( 'm1' );
+		john.setSelection( [ 1 ] );
+		john.merge();
+
+		expectClients( '<paragraph>Foo<m1:start></m1:start>Bar</paragraph>' );
+
+		john.undo();
+
+		expectClients( '<paragraph>Foo</paragraph><paragraph><m1:start></m1:start>Bar</paragraph>' );
+	} );
+
+	it( 'collapsed marker at the end of merge-target element then undo', () => {
+		john.setData( '<paragraph>Foo[]</paragraph><paragraph>Bar</paragraph>' );
+
+		john.setMarker( 'm1' );
+		john.setSelection( [ 1 ] );
+		john.merge();
+
+		expectClients( '<paragraph>Foo<m1:start></m1:start>Bar</paragraph>' );
+
+		john.undo();
+
+		expectClients( '<paragraph>Foo<m1:start></m1:start></paragraph><paragraph>Bar</paragraph>' );
+	} );
+
+	it( 'empty marker between merged elements then undo', () => {
+		john.setData( '<paragraph>Foo[</paragraph><paragraph>]Bar</paragraph>' );
+
+		john.setMarker( 'm1' );
+		john.setSelection( [ 1 ] );
+		john.merge();
+
+		expectClients( '<paragraph>Foo<m1:start></m1:start>Bar</paragraph>' );
+
+		john.undo();
+
+		expectClients( '<paragraph>Foo<m1:start></m1:start></paragraph><paragraph><m1:end></m1:end>Bar</paragraph>' );
+	} );
+
+	it( 'left side of marker moved then undo', () => {
+		john.setData( '<paragraph>Foo[bar]</paragraph><paragraph></paragraph>' );
+
+		john.setMarker( 'm1' );
+		john.setSelection( [ 0, 2 ], [ 0, 4 ] );
+		john.move( [ 1, 0 ] );
+
+		expectClients( '<paragraph>Fo<m1:start></m1:start>ar<m1:end></m1:end></paragraph><paragraph>ob</paragraph>' );
+
+		john.undo();
+
+		expectClients( '<paragraph>Foo<m1:start></m1:start>bar<m1:end></m1:end></paragraph><paragraph></paragraph>' );
+	} );
+
+	it( 'right side of marker moved then undo', () => {
+		john.setData( '<paragraph>[Foo]bar</paragraph><paragraph></paragraph>' );
+
+		john.setMarker( 'm1' );
+		john.setSelection( [ 0, 2 ], [ 0, 4 ] );
+		john.move( [ 1, 0 ] );
+
+		expectClients( '<paragraph><m1:start></m1:start>Fo<m1:end></m1:end>ar</paragraph><paragraph>ob</paragraph>' );
+
+		john.undo();
+
+		expectClients( '<paragraph><m1:start></m1:start>Foo<m1:end></m1:end>bar</paragraph><paragraph></paragraph>' );
+	} );
 } );
 } );

+ 21 - 0
packages/ckeditor5-engine/tests/model/range.js

@@ -1179,6 +1179,27 @@ describe( 'Range', () => {
 				expect( transformed[ 0 ].start.path ).to.deep.equal( [ 0, 2 ] );
 				expect( transformed[ 0 ].start.path ).to.deep.equal( [ 0, 2 ] );
 				expect( transformed[ 0 ].end.path ).to.deep.equal( [ 2 ] );
 				expect( transformed[ 0 ].end.path ).to.deep.equal( [ 2 ] );
 			} );
 			} );
+
+			it( 'range is set on closing tag of merge target element', () => {
+				// <p>aa{</p>}<p>bb</p>
+				const range = new Range( new Position( root, [ 0, 2 ] ), new Position( root, [ 1 ] ) );
+
+				const op = new MergeOperation(
+					new Position( root, [ 1, 0 ] ),
+					2,
+					new Position( root, [ 0, 2 ] ),
+					gyPos,
+					1
+				);
+
+				const transformed = range.getTransformedByOperation( op );
+
+				expect( transformed.length ).to.equal( 1 );
+
+				// <p>aa{}bb</p>
+				expect( transformed[ 0 ].start.path ).to.deep.equal( [ 0, 2 ] );
+				expect( transformed[ 0 ].end.path ).to.deep.equal( [ 0, 2 ] );
+			} );
 		} );
 		} );
 	} );
 	} );
 
 

+ 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', () => {
 	describe( 'attributes interface', () => {
 		let rangeInFullP;
 		let rangeInFullP;
 
 

+ 284 - 44
packages/ckeditor5-engine/tests/model/utils/selection-post-fixer.js

@@ -27,8 +27,9 @@ describe( 'Selection post-fixer', () => {
 
 
 			model.schema.register( 'table', {
 			model.schema.register( 'table', {
 				allowWhere: '$block',
 				allowWhere: '$block',
-				isObject: true,
-				isLimit: true
+				allowAttributes: [ 'headingRows', 'headingColumns' ],
+				isLimit: true,
+				isObject: true
 			} );
 			} );
 
 
 			model.schema.register( 'tableRow', {
 			model.schema.register( 'tableRow', {
@@ -38,15 +39,18 @@ describe( 'Selection post-fixer', () => {
 
 
 			model.schema.register( 'tableCell', {
 			model.schema.register( 'tableCell', {
 				allowIn: 'tableRow',
 				allowIn: 'tableRow',
-				allowContentOf: '$block',
+				allowAttributes: [ 'colspan', 'rowspan' ],
 				isLimit: true
 				isLimit: true
 			} );
 			} );
 
 
 			model.schema.register( 'image', {
 			model.schema.register( 'image', {
-				allowIn: '$root',
-				isObject: true
+				isObject: true,
+				isBlock: true,
+				allowWhere: '$block'
 			} );
 			} );
 
 
+			model.schema.extend( '$block', { allowIn: 'tableCell' } );
+
 			model.schema.register( 'caption', {
 			model.schema.register( 'caption', {
 				allowIn: 'image',
 				allowIn: 'image',
 				allowContentOf: '$block',
 				allowContentOf: '$block',
@@ -98,13 +102,16 @@ describe( 'Selection post-fixer', () => {
 				setModelData( model,
 				setModelData( model,
 					'<paragraph>[]foo</paragraph>' +
 					'<paragraph>[]foo</paragraph>' +
 					'<table>' +
 					'<table>' +
-						'<tableRow><tableCell>aaa</tableCell><tableCell>bbb</tableCell></tableRow>' +
+						'<tableRow>' +
+							'<tableCell><paragraph>aaa</paragraph></tableCell>' +
+							'<tableCell><paragraph>bbb</paragraph></tableCell>' +
+						'</tableRow>' +
 					'</table>' +
 					'</table>' +
 					'<paragraph>bar</paragraph>'
 					'<paragraph>bar</paragraph>'
 				);
 				);
 			} );
 			} );
 
 
-			it( 'should fix #1', () => {
+			it( 'should fix #1 - range start outside table, end on table cell', () => {
 				// <paragraph>f[oo</paragraph><table><tableRow><tableCell></tableCell>]<tableCell>...
 				// <paragraph>f[oo</paragraph><table><tableRow><tableCell></tableCell>]<tableCell>...
 				model.change( writer => {
 				model.change( writer => {
 					writer.setSelection( writer.createRange(
 					writer.setSelection( writer.createRange(
@@ -116,13 +123,16 @@ describe( 'Selection post-fixer', () => {
 				expect( getModelData( model ) ).to.equal(
 				expect( getModelData( model ) ).to.equal(
 					'<paragraph>f[oo</paragraph>' +
 					'<paragraph>f[oo</paragraph>' +
 					'<table>' +
 					'<table>' +
-						'<tableRow><tableCell>aaa</tableCell><tableCell>bbb</tableCell></tableRow>' +
+						'<tableRow>' +
+							'<tableCell><paragraph>aaa</paragraph></tableCell>' +
+							'<tableCell><paragraph>bbb</paragraph></tableCell>' +
+						'</tableRow>' +
 					'</table>]' +
 					'</table>]' +
 					'<paragraph>bar</paragraph>'
 					'<paragraph>bar</paragraph>'
 				);
 				);
 			} );
 			} );
 
 
-			it( 'should fix #2', () => {
+			it( 'should fix #2 - range start on table cell, end outside table', () => {
 				// ...<table><tableRow><tableCell></tableCell>[<tableCell></tableCell></tableRow></table><paragraph>b]ar</paragraph>
 				// ...<table><tableRow><tableCell></tableCell>[<tableCell></tableCell></tableRow></table><paragraph>b]ar</paragraph>
 				model.change( writer => {
 				model.change( writer => {
 					writer.setSelection( writer.createRange(
 					writer.setSelection( writer.createRange(
@@ -134,7 +144,10 @@ describe( 'Selection post-fixer', () => {
 				expect( getModelData( model ) ).to.equal(
 				expect( getModelData( model ) ).to.equal(
 					'<paragraph>foo</paragraph>' +
 					'<paragraph>foo</paragraph>' +
 					'[<table>' +
 					'[<table>' +
-						'<tableRow><tableCell>aaa</tableCell><tableCell>bbb</tableCell></tableRow>' +
+						'<tableRow>' +
+							'<tableCell><paragraph>aaa</paragraph></tableCell>' +
+							'<tableCell><paragraph>bbb</paragraph></tableCell>' +
+						'</tableRow>' +
 					'</table>' +
 					'</table>' +
 					'<paragraph>b]ar</paragraph>'
 					'<paragraph>b]ar</paragraph>'
 				);
 				);
@@ -152,7 +165,10 @@ describe( 'Selection post-fixer', () => {
 				expect( getModelData( model ) ).to.equal(
 				expect( getModelData( model ) ).to.equal(
 					'<paragraph>f[oo</paragraph>' +
 					'<paragraph>f[oo</paragraph>' +
 					'<table>' +
 					'<table>' +
-						'<tableRow><tableCell>aaa</tableCell><tableCell>bbb</tableCell></tableRow>' +
+						'<tableRow>' +
+							'<tableCell><paragraph>aaa</paragraph></tableCell>' +
+							'<tableCell><paragraph>bbb</paragraph></tableCell>' +
+						'</tableRow>' +
 					'</table>]' +
 					'</table>]' +
 					'<paragraph>bar</paragraph>'
 					'<paragraph>bar</paragraph>'
 				);
 				);
@@ -162,15 +178,18 @@ describe( 'Selection post-fixer', () => {
 				// <paragraph>foo</paragraph><table><tableRow><tableCell>a[aa</tableCell><tableCell>b]bb</tableCell>
 				// <paragraph>foo</paragraph><table><tableRow><tableCell>a[aa</tableCell><tableCell>b]bb</tableCell>
 				model.change( writer => {
 				model.change( writer => {
 					writer.setSelection( writer.createRange(
 					writer.setSelection( writer.createRange(
-						writer.createPositionAt( modelRoot.getChild( 1 ).getChild( 0 ).getChild( 0 ), 1 ),
-						writer.createPositionAt( modelRoot.getChild( 1 ).getChild( 0 ).getChild( 1 ), 2 )
+						writer.createPositionAt( modelRoot.getNodeByPath( [ 1, 0, 0, 0 ] ), 1 ),
+						writer.createPositionAt( modelRoot.getNodeByPath( [ 1, 0, 1, 0 ] ), 2 )
 					) );
 					) );
 				} );
 				} );
 
 
 				expect( getModelData( model ) ).to.equal(
 				expect( getModelData( model ) ).to.equal(
 					'<paragraph>foo</paragraph>' +
 					'<paragraph>foo</paragraph>' +
 					'[<table>' +
 					'[<table>' +
-						'<tableRow><tableCell>aaa</tableCell><tableCell>bbb</tableCell></tableRow>' +
+						'<tableRow>' +
+							'<tableCell><paragraph>aaa</paragraph></tableCell>' +
+							'<tableCell><paragraph>bbb</paragraph></tableCell>' +
+						'</tableRow>' +
 					'</table>]' +
 					'</table>]' +
 					'<paragraph>bar</paragraph>'
 					'<paragraph>bar</paragraph>'
 				);
 				);
@@ -180,11 +199,17 @@ describe( 'Selection post-fixer', () => {
 				setModelData( model,
 				setModelData( model,
 					'<paragraph>foo</paragraph>' +
 					'<paragraph>foo</paragraph>' +
 					'<table>' +
 					'<table>' +
-						'<tableRow><tableCell>aaa</tableCell><tableCell>bbb</tableCell></tableRow>' +
+						'<tableRow>' +
+							'<tableCell><paragraph>aaa</paragraph></tableCell>' +
+							'<tableCell><paragraph>bbb</paragraph></tableCell>' +
+						'</tableRow>' +
 					'</table>' +
 					'</table>' +
 					'[]' +
 					'[]' +
 					'<table>' +
 					'<table>' +
-						'<tableRow><tableCell>xxx</tableCell><tableCell>yyy</tableCell></tableRow>' +
+						'<tableRow>' +
+							'<tableCell><paragraph>xxx</paragraph></tableCell>' +
+							'<tableCell><paragraph>yyy</paragraph></tableCell>' +
+						'</tableRow>' +
 					'</table>' +
 					'</table>' +
 					'<paragraph>baz</paragraph>'
 					'<paragraph>baz</paragraph>'
 				);
 				);
@@ -192,10 +217,16 @@ describe( 'Selection post-fixer', () => {
 				expect( getModelData( model ) ).to.equal(
 				expect( getModelData( model ) ).to.equal(
 					'<paragraph>foo</paragraph>' +
 					'<paragraph>foo</paragraph>' +
 					'[<table>' +
 					'[<table>' +
-						'<tableRow><tableCell>aaa</tableCell><tableCell>bbb</tableCell></tableRow>' +
+						'<tableRow>' +
+							'<tableCell><paragraph>aaa</paragraph></tableCell>' +
+							'<tableCell><paragraph>bbb</paragraph></tableCell>' +
+						'</tableRow>' +
 					'</table>]' +
 					'</table>]' +
 					'<table>' +
 					'<table>' +
-						'<tableRow><tableCell>xxx</tableCell><tableCell>yyy</tableCell></tableRow>' +
+						'<tableRow>' +
+							'<tableCell><paragraph>xxx</paragraph></tableCell>' +
+							'<tableCell><paragraph>yyy</paragraph></tableCell>' +
+						'</tableRow>' +
 					'</table>' +
 					'</table>' +
 					'<paragraph>baz</paragraph>'
 					'<paragraph>baz</paragraph>'
 				);
 				);
@@ -208,7 +239,10 @@ describe( 'Selection post-fixer', () => {
 				setModelData( model,
 				setModelData( model,
 					'<paragraph>foo</paragraph>' +
 					'<paragraph>foo</paragraph>' +
 					'<table>' +
 					'<table>' +
-						'[<tableRow><tableCell>aaa</tableCell><tableCell>bbb</tableCell></tableRow>]' +
+						'[<tableRow>' +
+							'<tableCell><paragraph>aaa</paragraph></tableCell>' +
+							'<tableCell><paragraph>bbb</paragraph></tableCell>' +
+						'</tableRow>]' +
 					'</table>' +
 					'</table>' +
 					'<paragraph>baz</paragraph>'
 					'<paragraph>baz</paragraph>'
 				);
 				);
@@ -216,7 +250,10 @@ describe( 'Selection post-fixer', () => {
 				expect( getModelData( model ) ).to.equal(
 				expect( getModelData( model ) ).to.equal(
 					'<paragraph>foo</paragraph>' +
 					'<paragraph>foo</paragraph>' +
 					'[<table>' +
 					'[<table>' +
-						'<tableRow><tableCell>aaa</tableCell><tableCell>bbb</tableCell></tableRow>' +
+						'<tableRow>' +
+							'<tableCell><paragraph>aaa</paragraph></tableCell>' +
+							'<tableCell><paragraph>bbb</paragraph></tableCell>' +
+						'</tableRow>' +
 					'</table>]' +
 					'</table>]' +
 					'<paragraph>baz</paragraph>'
 					'<paragraph>baz</paragraph>'
 				);
 				);
@@ -226,9 +263,18 @@ describe( 'Selection post-fixer', () => {
 				setModelData( model,
 				setModelData( model,
 					'<paragraph>foo</paragraph>' +
 					'<paragraph>foo</paragraph>' +
 					'<table>' +
 					'<table>' +
-						'[<tableRow><tableCell>1</tableCell><tableCell>2</tableCell></tableRow>' +
-						'<tableRow><tableCell>3</tableCell><tableCell>4</tableCell>]</tableRow>' +
-						'<tableRow><tableCell>5</tableCell><tableCell>6</tableCell></tableRow>' +
+						'[<tableRow>' +
+							'<tableCell><paragraph>1</paragraph></tableCell>' +
+							'<tableCell><paragraph>2</paragraph></tableCell>' +
+						'</tableRow>' +
+						'<tableRow>' +
+							'<tableCell><paragraph>3</paragraph></tableCell>' +
+							'<tableCell><paragraph>4</paragraph></tableCell>]' +
+						'</tableRow>' +
+						'<tableRow>' +
+							'<tableCell><paragraph>5</paragraph></tableCell>' +
+							'<tableCell><paragraph>6</paragraph></tableCell>' +
+						'</tableRow>' +
 					'</table>' +
 					'</table>' +
 					'<paragraph>baz</paragraph>'
 					'<paragraph>baz</paragraph>'
 				);
 				);
@@ -236,9 +282,18 @@ describe( 'Selection post-fixer', () => {
 				expect( getModelData( model ) ).to.equal(
 				expect( getModelData( model ) ).to.equal(
 					'<paragraph>foo</paragraph>' +
 					'<paragraph>foo</paragraph>' +
 					'[<table>' +
 					'[<table>' +
-						'<tableRow><tableCell>1</tableCell><tableCell>2</tableCell></tableRow>' +
-						'<tableRow><tableCell>3</tableCell><tableCell>4</tableCell></tableRow>' +
-						'<tableRow><tableCell>5</tableCell><tableCell>6</tableCell></tableRow>' +
+						'<tableRow>' +
+							'<tableCell><paragraph>1</paragraph></tableCell>' +
+							'<tableCell><paragraph>2</paragraph></tableCell>' +
+						'</tableRow>' +
+						'<tableRow>' +
+							'<tableCell><paragraph>3</paragraph></tableCell>' +
+							'<tableCell><paragraph>4</paragraph></tableCell>' +
+						'</tableRow>' +
+						'<tableRow>' +
+							'<tableCell><paragraph>5</paragraph></tableCell>' +
+							'<tableCell><paragraph>6</paragraph></tableCell>' +
+						'</tableRow>' +
 					'</table>]' +
 					'</table>]' +
 					'<paragraph>baz</paragraph>'
 					'<paragraph>baz</paragraph>'
 				);
 				);
@@ -270,11 +325,14 @@ describe( 'Selection post-fixer', () => {
 				);
 				);
 			} );
 			} );
 
 
-			it( 'should not fix #1', () => {
+			it( 'should not fix #1 - selection over paragraphs outside table', () => {
 				setModelData( model,
 				setModelData( model,
 					'<paragraph>foo</paragraph>' +
 					'<paragraph>foo</paragraph>' +
 					'<table>' +
 					'<table>' +
-						'<tableRow><tableCell>aaa</tableCell><tableCell>bbb</tableCell></tableRow>' +
+						'<tableRow>' +
+							'<tableCell><paragraph>aaa</paragraph></tableCell>' +
+							'<tableCell><paragraph>bbb</paragraph></tableCell>' +
+						'</tableRow>' +
 					'</table>' +
 					'</table>' +
 					'<paragraph>b[ar</paragraph>' +
 					'<paragraph>b[ar</paragraph>' +
 					'<paragraph>ba]z</paragraph>'
 					'<paragraph>ba]z</paragraph>'
@@ -283,13 +341,133 @@ describe( 'Selection post-fixer', () => {
 				expect( getModelData( model ) ).to.equal(
 				expect( getModelData( model ) ).to.equal(
 					'<paragraph>foo</paragraph>' +
 					'<paragraph>foo</paragraph>' +
 					'<table>' +
 					'<table>' +
-						'<tableRow><tableCell>aaa</tableCell><tableCell>bbb</tableCell></tableRow>' +
+						'<tableRow>' +
+							'<tableCell><paragraph>aaa</paragraph></tableCell>' +
+							'<tableCell><paragraph>bbb</paragraph></tableCell>' +
+						'</tableRow>' +
 					'</table>' +
 					'</table>' +
 					'<paragraph>b[ar</paragraph>' +
 					'<paragraph>b[ar</paragraph>' +
 					'<paragraph>ba]z</paragraph>'
 					'<paragraph>ba]z</paragraph>'
 				);
 				);
 			} );
 			} );
 
 
+			it( 'should not fix #2 - selection over image in table', () => {
+				setModelData( model,
+					'<paragraph>foo</paragraph>' +
+					'<table>' +
+						'<tableRow>' +
+							'<tableCell><paragraph>foo</paragraph><image></image></tableCell>' +
+							'<tableCell><paragraph>[]bbb</paragraph></tableCell>' +
+						'</tableRow>' +
+					'</table>'
+				);
+
+				model.change( writer => {
+					const image = model.document.getRoot().getNodeByPath( [ 1, 0, 0, 1 ] );
+
+					writer.setSelection( writer.createRangeOn( image ) );
+				} );
+
+				expect( getModelData( model ) ).to.equal(
+					'<paragraph>foo</paragraph>' +
+					'<table>' +
+						'<tableRow>' +
+							'<tableCell><paragraph>foo</paragraph>[<image></image>]</tableCell>' +
+							'<tableCell><paragraph>bbb</paragraph></tableCell>' +
+						'</tableRow>' +
+					'</table>'
+				);
+			} );
+
+			it( 'should not fix #3 - selection over paragraph & image in table', () => {
+				setModelData( model,
+					'<paragraph>foo</paragraph>' +
+					'<table>' +
+						'<tableRow>' +
+							'<tableCell><paragraph>foo</paragraph><image></image></tableCell>' +
+							'<tableCell><paragraph>[]bbb</paragraph></tableCell>' +
+						'</tableRow>' +
+					'</table>'
+				);
+
+				model.change( writer => {
+					const tableCell = model.document.getRoot().getNodeByPath( [ 1, 0, 0 ] );
+
+					writer.setSelection( writer.createRangeIn( tableCell ) );
+				} );
+
+				expect( getModelData( model ) ).to.equal(
+					'<paragraph>foo</paragraph>' +
+					'<table>' +
+						'<tableRow>' +
+							'<tableCell><paragraph>[foo</paragraph><image></image>]</tableCell>' +
+							'<tableCell><paragraph>bbb</paragraph></tableCell>' +
+						'</tableRow>' +
+					'</table>'
+				);
+			} );
+
+			it( 'should not fix #4 - selection over image & paragraph in table', () => {
+				setModelData( model,
+					'<paragraph>foo</paragraph>' +
+					'<table>' +
+						'<tableRow>' +
+							'<tableCell><image></image><paragraph>foo</paragraph></tableCell>' +
+							'<tableCell><paragraph>[]bbb</paragraph></tableCell>' +
+						'</tableRow>' +
+					'</table>'
+				);
+
+				model.change( writer => {
+					const tableCell = model.document.getRoot().getNodeByPath( [ 1, 0, 0 ] );
+
+					writer.setSelection( writer.createRangeIn( tableCell ) );
+				} );
+
+				expect( getModelData( model ) ).to.equal(
+					'<paragraph>foo</paragraph>' +
+					'<table>' +
+						'<tableRow>' +
+							'<tableCell>[<image></image><paragraph>foo]</paragraph></tableCell>' +
+							'<tableCell><paragraph>bbb</paragraph></tableCell>' +
+						'</tableRow>' +
+					'</table>'
+				);
+			} );
+
+			it( 'should not fix #5 - selection over blockQuote in table', () => {
+				model.schema.register( 'blockQuote', {
+					allowWhere: '$block',
+					allowContentOf: '$root'
+				} );
+
+				setModelData( model,
+					'<paragraph>foo</paragraph>' +
+					'<table>' +
+						'<tableRow>' +
+							'<tableCell><blockQuote><paragraph>foo</paragraph></blockQuote></tableCell>' +
+							'<tableCell><paragraph>[]bbb</paragraph></tableCell>' +
+						'</tableRow>' +
+					'</table>'
+				);
+
+				model.change( writer => {
+					const tableCell = model.document.getRoot().getNodeByPath( [ 1, 0, 0 ] );
+
+					writer.setSelection( writer.createRangeIn( tableCell ) );
+				} );
+
+				expect( getModelData( model ) ).to.equal(
+					'<paragraph>foo</paragraph>' +
+					'<table>' +
+						'<tableRow>' +
+							'<tableCell><blockQuote><paragraph>[foo]</paragraph></blockQuote></tableCell>' +
+							'<tableCell><paragraph>bbb</paragraph></tableCell>' +
+						'</tableRow>' +
+					'</table>'
+				);
+			} );
+
 			it( 'should fix multiple ranges #1', () => {
 			it( 'should fix multiple ranges #1', () => {
 				model.change( writer => {
 				model.change( writer => {
 					const ranges = [
 					const ranges = [
@@ -308,7 +486,10 @@ describe( 'Selection post-fixer', () => {
 				expect( getModelData( model ) ).to.equal(
 				expect( getModelData( model ) ).to.equal(
 					'<paragraph>f[oo</paragraph>' +
 					'<paragraph>f[oo</paragraph>' +
 					'<table>' +
 					'<table>' +
-						'<tableRow><tableCell>aaa</tableCell><tableCell>bbb</tableCell></tableRow>' +
+						'<tableRow>' +
+							'<tableCell><paragraph>aaa</paragraph></tableCell>' +
+							'<tableCell><paragraph>bbb</paragraph></tableCell>' +
+						'</tableRow>' +
 					'</table>]' +
 					'</table>]' +
 					'<paragraph>bar</paragraph>'
 					'<paragraph>bar</paragraph>'
 				);
 				);
@@ -333,7 +514,10 @@ describe( 'Selection post-fixer', () => {
 				expect( getModelData( model ) ).to.equal(
 				expect( getModelData( model ) ).to.equal(
 					'<paragraph>f[oo</paragraph>' +
 					'<paragraph>f[oo</paragraph>' +
 					'<table>' +
 					'<table>' +
-						'<tableRow><tableCell>aaa</tableCell><tableCell>bbb</tableCell></tableRow>' +
+						'<tableRow>' +
+							'<tableCell><paragraph>aaa</paragraph></tableCell>' +
+							'<tableCell><paragraph>bbb</paragraph></tableCell>' +
+						'</tableRow>' +
 					'</table>' +
 					'</table>' +
 					'<paragraph>ba]r</paragraph>'
 					'<paragraph>ba]r</paragraph>'
 				);
 				);
@@ -343,10 +527,22 @@ describe( 'Selection post-fixer', () => {
 				setModelData( model,
 				setModelData( model,
 					'<paragraph>foo</paragraph>' +
 					'<paragraph>foo</paragraph>' +
 					'<table>' +
 					'<table>' +
-						'<tableRow><tableCell>[aaa</tableCell><tableCell>bbb</tableCell></tableRow>' +
-						'<tableRow>]<tableCell>[aaa</tableCell><tableCell>bbb</tableCell></tableRow>' +
-						'<tableRow>]<tableCell>[aaa</tableCell><tableCell>bbb</tableCell></tableRow>' +
-						'<tableRow>]<tableCell>[aaa</tableCell><tableCell>bbb</tableCell></tableRow>' +
+						'<tableRow>' +
+							'<tableCell><paragraph>[aaa</paragraph></tableCell>' +
+							'<tableCell><paragraph>bbb</paragraph></tableCell>' +
+						'</tableRow>' +
+						'<tableRow>]' +
+							'<tableCell><paragraph>[aaa</paragraph></tableCell>' +
+							'<tableCell><paragraph>bbb</paragraph></tableCell>' +
+						'</tableRow>' +
+						'<tableRow>]' +
+							'<tableCell><paragraph>[aaa</paragraph></tableCell>' +
+							'<tableCell><paragraph>bbb</paragraph></tableCell>' +
+						'</tableRow>' +
+						'<tableRow>]' +
+							'<tableCell><paragraph>[aaa</paragraph></tableCell>' +
+							'<tableCell><paragraph>bbb</paragraph></tableCell>' +
+						'</tableRow>' +
 					'</table>' +
 					'</table>' +
 					'<paragraph>b]az</paragraph>'
 					'<paragraph>b]az</paragraph>'
 				);
 				);
@@ -354,10 +550,22 @@ describe( 'Selection post-fixer', () => {
 				expect( getModelData( model ) ).to.equal(
 				expect( getModelData( model ) ).to.equal(
 					'<paragraph>foo</paragraph>' +
 					'<paragraph>foo</paragraph>' +
 					'[<table>' +
 					'[<table>' +
-						'<tableRow><tableCell>aaa</tableCell><tableCell>bbb</tableCell></tableRow>' +
-						'<tableRow><tableCell>aaa</tableCell><tableCell>bbb</tableCell></tableRow>' +
-						'<tableRow><tableCell>aaa</tableCell><tableCell>bbb</tableCell></tableRow>' +
-						'<tableRow><tableCell>aaa</tableCell><tableCell>bbb</tableCell></tableRow>' +
+						'<tableRow>' +
+							'<tableCell><paragraph>aaa</paragraph></tableCell>' +
+							'<tableCell><paragraph>bbb</paragraph></tableCell>' +
+						'</tableRow>' +
+						'<tableRow>' +
+							'<tableCell><paragraph>aaa</paragraph></tableCell>' +
+							'<tableCell><paragraph>bbb</paragraph></tableCell>' +
+						'</tableRow>' +
+						'<tableRow>' +
+							'<tableCell><paragraph>aaa</paragraph></tableCell>' +
+							'<tableCell><paragraph>bbb</paragraph></tableCell>' +
+						'</tableRow>' +
+						'<tableRow>' +
+							'<tableCell><paragraph>aaa</paragraph></tableCell>' +
+							'<tableCell><paragraph>bbb</paragraph></tableCell>' +
+						'</tableRow>' +
 					'</table>' +
 					'</table>' +
 					'<paragraph>b]az</paragraph>'
 					'<paragraph>b]az</paragraph>'
 				);
 				);
@@ -386,7 +594,10 @@ describe( 'Selection post-fixer', () => {
 				expect( getModelData( model ) ).to.equal(
 				expect( getModelData( model ) ).to.equal(
 					'<paragraph>f[oo</paragraph>' +
 					'<paragraph>f[oo</paragraph>' +
 					'<table>' +
 					'<table>' +
-						'<tableRow><tableCell>aaa</tableCell><tableCell>bbb</tableCell></tableRow>' +
+						'<tableRow>' +
+							'<tableCell><paragraph>aaa</paragraph></tableCell>' +
+							'<tableCell><paragraph>bbb</paragraph></tableCell>' +
+						'</tableRow>' +
 					'</table>' +
 					'</table>' +
 					'<paragraph>bar]</paragraph>'
 					'<paragraph>bar]</paragraph>'
 				);
 				);
@@ -715,6 +926,23 @@ describe( 'Selection post-fixer', () => {
 					'<paragraph>fo[o<inlineWidget></inlineWidget>b]ar</paragraph>'
 					'<paragraph>fo[o<inlineWidget></inlineWidget>b]ar</paragraph>'
 				);
 				);
 			} );
 			} );
+
+			it( 'should not fix #4 - object in object', () => {
+				model.schema.register( 'div', {
+					allowIn: [ '$root', 'div' ],
+					isObject: true
+				} );
+
+				setModelData( model, '<div>[<div></div>]</div>' );
+
+				model.change( writer => {
+					const innerDiv = model.document.getRoot().getNodeByPath( [ 0, 0 ] );
+
+					writer.setSelection( writer.createRangeOn( innerDiv ) );
+				} );
+
+				expect( getModelData( model ) ).to.equal( '<div>[<div></div>]</div>' );
+			} );
 		} );
 		} );
 
 
 		describe( 'collapsed selection', () => {
 		describe( 'collapsed selection', () => {
@@ -722,7 +950,10 @@ describe( 'Selection post-fixer', () => {
 				setModelData( model,
 				setModelData( model,
 					'<paragraph>[]foo</paragraph>' +
 					'<paragraph>[]foo</paragraph>' +
 					'<table>' +
 					'<table>' +
-						'<tableRow><tableCell>aaa</tableCell><tableCell>bbb</tableCell></tableRow>' +
+						'<tableRow>' +
+							'<tableCell><paragraph>aaa</paragraph></tableCell>' +
+							'<tableCell><paragraph>bbb</paragraph></tableCell>' +
+						'</tableRow>' +
 					'</table>' +
 					'</table>' +
 					'<paragraph>bar</paragraph>'
 					'<paragraph>bar</paragraph>'
 				);
 				);
@@ -739,7 +970,10 @@ describe( 'Selection post-fixer', () => {
 				expect( getModelData( model ) ).to.equal(
 				expect( getModelData( model ) ).to.equal(
 					'<paragraph>foo[]</paragraph>' +
 					'<paragraph>foo[]</paragraph>' +
 					'<table>' +
 					'<table>' +
-						'<tableRow><tableCell>aaa</tableCell><tableCell>bbb</tableCell></tableRow>' +
+						'<tableRow>' +
+							'<tableCell><paragraph>aaa</paragraph></tableCell>' +
+							'<tableCell><paragraph>bbb</paragraph></tableCell>' +
+						'</tableRow>' +
 					'</table>' +
 					'</table>' +
 					'<paragraph>bar</paragraph>'
 					'<paragraph>bar</paragraph>'
 				);
 				);
@@ -758,7 +992,10 @@ describe( 'Selection post-fixer', () => {
 				expect( getModelData( model ) ).to.equal(
 				expect( getModelData( model ) ).to.equal(
 					'<paragraph>foo</paragraph>' +
 					'<paragraph>foo</paragraph>' +
 					'<table>' +
 					'<table>' +
-						'<tableRow><tableCell>[]aaa</tableCell><tableCell>bbb</tableCell></tableRow>' +
+						'<tableRow>' +
+							'<tableCell><paragraph>[]aaa</paragraph></tableCell>' +
+							'<tableCell><paragraph>bbb</paragraph></tableCell>' +
+						'</tableRow>' +
 					'</table>' +
 					'</table>' +
 					'<paragraph>bar</paragraph>'
 					'<paragraph>bar</paragraph>'
 				);
 				);
@@ -778,7 +1015,10 @@ describe( 'Selection post-fixer', () => {
 				expect( getModelData( model ) ).to.equal(
 				expect( getModelData( model ) ).to.equal(
 					'<paragraph>[foo]</paragraph>' +
 					'<paragraph>[foo]</paragraph>' +
 					'<table>' +
 					'<table>' +
-						'<tableRow><tableCell>aaa</tableCell><tableCell>bbb</tableCell></tableRow>' +
+						'<tableRow>' +
+							'<tableCell><paragraph>aaa</paragraph></tableCell>' +
+							'<tableCell><paragraph>bbb</paragraph></tableCell>' +
+						'</tableRow>' +
 					'</table>' +
 					'</table>' +
 					'<paragraph>bar</paragraph>'
 					'<paragraph>bar</paragraph>'
 				);
 				);

+ 152 - 4
packages/ckeditor5-engine/tests/model/writer.js

@@ -158,6 +158,18 @@ describe( 'Writer', () => {
 			expect( Array.from( parent.getChildren() ) ).to.deep.equal( [ child1, child2, child3 ] );
 			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', () => {
 		it( 'should create proper operation for inserting element', () => {
 			const parent = createDocumentFragment();
 			const parent = createDocumentFragment();
 			const element = createElement( 'child' );
 			const element = createElement( 'child' );
@@ -1413,6 +1425,51 @@ describe( 'Writer', () => {
 			expect( docFrag.getChild( 0 ).getChild( 0 ).data ).to.equal( 'foobar' );
 			expect( docFrag.getChild( 0 ).getChild( 0 ).data ).to.equal( 'foobar' );
 		} );
 		} );
 
 
+		it( 'should create a marker operation if a marker was affected', () => {
+			const markerRange = new Range( Position._createAt( p2, 0 ), Position._createAt( p2, 0 ) );
+
+			addMarker( 'name', {
+				range: markerRange,
+				usingOperation: true
+			} );
+
+			const documentVersion = model.document.version;
+
+			merge( Position._createAfter( p1 ) );
+
+			const history = model.document.history;
+
+			const lastOperation = history._operations[ history._operations.length - 1 ];
+			const secondLastOperation = history._operations[ history._operations.length - 2 ];
+
+			expect( secondLastOperation.type ).to.equal( 'marker' );
+			expect( secondLastOperation.oldRange.isEqual( markerRange ) );
+			expect( secondLastOperation.newRange.isEqual( markerRange ) );
+
+			expect( lastOperation.type ).to.equal( 'merge' );
+			expect( model.document.version ).to.equal( documentVersion + 2 );
+		} );
+
+		it( 'should not create a marker operation if affected marker was not using operations', () => {
+			const markerRange = new Range( Position._createAt( p2, 0 ), Position._createAt( p2, 2 ) );
+
+			addMarker( 'name', {
+				range: markerRange,
+				usingOperation: false
+			} );
+
+			const documentVersion = model.document.version;
+
+			merge( Position._createAfter( p1 ) );
+
+			const history = model.document.history;
+
+			const lastOperation = history._operations[ history._operations.length - 1 ];
+
+			expect( lastOperation.type ).to.equal( 'merge' );
+			expect( model.document.version ).to.equal( documentVersion + 1 );
+		} );
+
 		it( 'should throw if there is no element after', () => {
 		it( 'should throw if there is no element after', () => {
 			expect( () => {
 			expect( () => {
 				merge( new Position( root, [ 2 ] ) );
 				merge( new Position( root, [ 2 ] ) );
@@ -1458,6 +1515,51 @@ describe( 'Writer', () => {
 			expect( getNodesAndText( Range._createIn( root.getChild( 1 ) ) ) ).to.equal( 'abcobarxyz' );
 			expect( getNodesAndText( Range._createIn( root.getChild( 1 ) ) ) ).to.equal( 'abcobarxyz' );
 		} );
 		} );
 
 
+		it( 'should create a marker operation if a marker was affected', () => {
+			const markerRange = new Range( Position._createAt( p, 1 ), Position._createAt( p, 4 ) );
+
+			addMarker( 'name', {
+				range: markerRange,
+				usingOperation: true
+			} );
+
+			const documentVersion = model.document.version;
+
+			move( new Range( Position._createAt( p, 0 ), Position._createAt( p, 2 ) ), Position._createAt( div, 0 ) );
+
+			const history = model.document.history;
+
+			const lastOperation = history._operations[ history._operations.length - 1 ];
+			const secondLastOperation = history._operations[ history._operations.length - 2 ];
+
+			expect( secondLastOperation.type ).to.equal( 'marker' );
+			expect( secondLastOperation.oldRange.isEqual( markerRange ) );
+			expect( secondLastOperation.newRange.isEqual( markerRange ) );
+
+			expect( lastOperation.type ).to.equal( 'move' );
+			expect( model.document.version ).to.equal( documentVersion + 2 );
+		} );
+
+		it( 'should not create a marker operation if affected marker was not using operations', () => {
+			const markerRange = new Range( Position._createAt( p, 1 ), Position._createAt( p, 4 ) );
+
+			addMarker( 'name', {
+				range: markerRange,
+				usingOperation: false
+			} );
+
+			const documentVersion = model.document.version;
+
+			move( new Range( Position._createAt( p, 0 ), Position._createAt( p, 2 ) ), Position._createAt( div, 0 ) );
+
+			const history = model.document.history;
+
+			const lastOperation = history._operations[ history._operations.length - 1 ];
+
+			expect( lastOperation.type ).to.equal( 'move' );
+			expect( model.document.version ).to.equal( documentVersion + 1 );
+		} );
+
 		it( 'should throw if object to move is not a range', () => {
 		it( 'should throw if object to move is not a range', () => {
 			expect( () => {
 			expect( () => {
 				move( root.getChild( 0 ), new Position( root, [ 1, 3 ] ) );
 				move( root.getChild( 0 ), new Position( root, [ 1, 3 ] ) );
@@ -1555,6 +1657,52 @@ describe( 'Writer', () => {
 				expect( batch.operations[ 0 ].type ).to.equal( 'remove' );
 				expect( batch.operations[ 0 ].type ).to.equal( 'remove' );
 			} );
 			} );
 
 
+			it( 'should create a marker operation if a marker was affected', () => {
+				const markerRange = new Range( Position._createAt( p, 1 ), Position._createAt( p, 4 ) );
+
+				addMarker( 'name', {
+					range: markerRange,
+					usingOperation: true
+				} );
+
+				const documentVersion = model.document.version;
+
+				remove( new Range( Position._createAt( p, 0 ), Position._createAt( p, 2 ) ) );
+
+				const history = model.document.history;
+
+				const lastOperation = history._operations[ history._operations.length - 1 ];
+				const secondLastOperation = history._operations[ history._operations.length - 2 ];
+
+				expect( secondLastOperation.type ).to.equal( 'marker' );
+				expect( secondLastOperation.oldRange.isEqual( markerRange ) );
+				expect( secondLastOperation.newRange.isEqual( markerRange ) );
+
+				expect( lastOperation.type ).to.equal( 'remove' );
+
+				expect( model.document.version ).to.equal( documentVersion + 2 );
+			} );
+
+			it( 'should not create a marker operation if affected marker was not using operations', () => {
+				const markerRange = new Range( Position._createAt( p, 1 ), Position._createAt( p, 4 ) );
+
+				addMarker( 'name', {
+					range: markerRange,
+					usingOperation: false
+				} );
+
+				const documentVersion = model.document.version;
+
+				remove( new Range( Position._createAt( p, 0 ), Position._createAt( p, 2 ) ) );
+
+				const history = model.document.history;
+
+				const lastOperation = history._operations[ history._operations.length - 1 ];
+
+				expect( lastOperation.type ).to.equal( 'remove' );
+				expect( model.document.version ).to.equal( documentVersion + 1 );
+			} );
+
 			it( 'should throw when trying to use detached writer', () => {
 			it( 'should throw when trying to use detached writer', () => {
 				const writer = new Writer( model, batch );
 				const writer = new Writer( model, batch );
 
 
@@ -1922,16 +2070,16 @@ describe( 'Writer', () => {
 			range = Range._createIn( root );
 			range = Range._createIn( root );
 		} );
 		} );
 
 
-		it( 'should throw if options.usingOperations is not defined', () => {
+		it( 'should throw if options.usingOperation is not defined', () => {
 			expect( () => {
 			expect( () => {
 				addMarker( 'name' );
 				addMarker( 'name' );
-			} ).to.throw( CKEditorError, /^writer-addMarker-no-usingOperations/ );
+			} ).to.throw( CKEditorError, /^writer-addMarker-no-usingOperation/ );
 		} );
 		} );
 
 
-		it( 'should throw if name and range is defined but options.usingOperations is not defined', () => {
+		it( 'should throw if name and range is defined but options.usingOperation is not defined', () => {
 			expect( () => {
 			expect( () => {
 				addMarker( 'name', { range } );
 				addMarker( 'name', { range } );
-			} ).to.throw( CKEditorError, /^writer-addMarker-no-usingOperations/ );
+			} ).to.throw( CKEditorError, /^writer-addMarker-no-usingOperation/ );
 		} );
 		} );
 
 
 		it( 'should add marker to the document marker collection', () => {
 		it( 'should add marker to the document marker collection', () => {

+ 4 - 12
packages/ckeditor5-engine/tests/tickets/699.js

@@ -8,14 +8,6 @@
 import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
 import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
 import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 
 
-import {
-	upcastElementToElement
-} from '../../src/conversion/upcast-converters';
-
-import {
-	downcastElementToElement
-} from '../../src/conversion/downcast-converters';
-
 import { getData as getModelData } from '../../src/dev-utils/model';
 import { getData as getModelData } from '../../src/dev-utils/model';
 import { getData as getViewData } from '../../src/dev-utils/view';
 import { getData as getViewData } from '../../src/dev-utils/view';
 
 
@@ -54,13 +46,13 @@ function WidgetPlugin( editor ) {
 	} );
 	} );
 	schema.extend( 'widget', { allowIn: '$root' } );
 	schema.extend( 'widget', { allowIn: '$root' } );
 
 
-	editor.conversion.for( 'downcast' ).add( downcastElementToElement( {
+	editor.conversion.for( 'downcast' ).elementToElement( {
 		model: 'widget',
 		model: 'widget',
 		view: 'widget'
 		view: 'widget'
-	} ) );
+	} );
 
 
-	editor.conversion.for( 'upcast' ).add( upcastElementToElement( {
+	editor.conversion.for( 'upcast' ).elementToElement( {
 		model: 'widget',
 		model: 'widget',
 		view: 'widget'
 		view: 'widget'
-	} ) );
+	} );
 }
 }

+ 3 - 4
packages/ckeditor5-engine/tests/utils/bindtwostepcarettoattribute.js

@@ -11,7 +11,6 @@ import DomEventData from '../../src/view/observer/domeventdata';
 import EventInfo from '@ckeditor/ckeditor5-utils/src/eventinfo';
 import EventInfo from '@ckeditor/ckeditor5-utils/src/eventinfo';
 import bindTwoStepCaretToAttribute from '../../src/utils/bindtwostepcarettoattribute';
 import bindTwoStepCaretToAttribute from '../../src/utils/bindtwostepcarettoattribute';
 import Position from '../../src/model/position';
 import Position from '../../src/model/position';
-import { upcastElementToAttribute } from '../../src/conversion/upcast-converters';
 import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
 import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
 import { setData } from '../../src/dev-utils/model';
 import { setData } from '../../src/dev-utils/model';
 
 
@@ -37,9 +36,9 @@ describe( 'bindTwoStepCaretToAttribute()', () => {
 			} );
 			} );
 
 
 			model.schema.register( 'paragraph', { inheritAllFrom: '$block' } );
 			model.schema.register( 'paragraph', { inheritAllFrom: '$block' } );
-			editor.conversion.for( 'upcast' ).add( upcastElementToAttribute( { view: 'a', model: 'a' } ) );
-			editor.conversion.for( 'upcast' ).add( upcastElementToAttribute( { view: 'b', model: 'b' } ) );
-			editor.conversion.for( 'upcast' ).add( upcastElementToAttribute( { view: 'c', model: 'c' } ) );
+			editor.conversion.for( 'upcast' ).elementToAttribute( { view: 'a', model: 'a' } );
+			editor.conversion.for( 'upcast' ).elementToAttribute( { view: 'b', model: 'b' } );
+			editor.conversion.for( 'upcast' ).elementToAttribute( { view: 'c', model: 'c' } );
 			editor.conversion.elementToElement( { model: 'paragraph', view: 'p' } );
 			editor.conversion.elementToElement( { model: 'paragraph', view: 'p' } );
 
 
 			bindTwoStepCaretToAttribute( editor.editing.view, editor.model, emitter, 'a' );
 			bindTwoStepCaretToAttribute( editor.editing.view, editor.model, emitter, 'a' );