Procházet zdrojové kódy

Merge branch 'master' into ckeditor5/t/1214

Piotrek Koszuliński před 7 roky
rodič
revize
f6a586f934
86 změnil soubory, kde provedl 6964 přidání a 5143 odebrání
  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. 3 3
      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. 71 21
      packages/ckeditor5-engine/src/conversion/downcastdispatcher.js
  10. 1347 0
      packages/ckeditor5-engine/src/conversion/downcasthelpers.js
  11. 2 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. 22 11
      packages/ckeditor5-engine/src/conversion/upcastdispatcher.js
  15. 756 0
      packages/ckeditor5-engine/src/conversion/upcasthelpers.js
  16. 11 7
      packages/ckeditor5-engine/src/dev-utils/model.js
  17. 35 32
      packages/ckeditor5-engine/src/dev-utils/view.js
  18. 72 8
      packages/ckeditor5-engine/src/model/documentselection.js
  19. 3 3
      packages/ckeditor5-engine/src/model/markercollection.js
  20. 4 14
      packages/ckeditor5-engine/src/model/model.js
  21. 1 1
      packages/ckeditor5-engine/src/model/node.js
  22. 70 2
      packages/ckeditor5-engine/src/model/operation/transform.js
  23. 15 0
      packages/ckeditor5-engine/src/model/range.js
  24. 3 3
      packages/ckeditor5-engine/src/model/schema.js
  25. 67 15
      packages/ckeditor5-engine/src/model/selection.js
  26. 2 1
      packages/ckeditor5-engine/src/model/text.js
  27. 1 3
      packages/ckeditor5-engine/src/model/utils/insertcontent.js
  28. 47 18
      packages/ckeditor5-engine/src/model/utils/selection-post-fixer.js
  29. 73 26
      packages/ckeditor5-engine/src/model/writer.js
  30. 13 10
      packages/ckeditor5-engine/src/view/attributeelement.js
  31. 19 30
      packages/ckeditor5-engine/src/view/containerelement.js
  32. 1 1
      packages/ckeditor5-engine/src/view/document.js
  33. 7 3
      packages/ckeditor5-engine/src/view/documentfragment.js
  34. 3 9
      packages/ckeditor5-engine/src/view/documentselection.js
  35. 12 14
      packages/ckeditor5-engine/src/view/downcastwriter.js
  36. 3 0
      packages/ckeditor5-engine/src/view/editableelement.js
  37. 19 23
      packages/ckeditor5-engine/src/view/element.js
  38. 6 1
      packages/ckeditor5-engine/src/view/emptyelement.js
  39. 1 1
      packages/ckeditor5-engine/src/view/filler.js
  40. 2 2
      packages/ckeditor5-engine/src/view/matcher.js
  41. 10 7
      packages/ckeditor5-engine/src/view/node.js
  42. 7 1
      packages/ckeditor5-engine/src/view/position.js
  43. 36 22
      packages/ckeditor5-engine/src/view/range.js
  44. 19 10
      packages/ckeditor5-engine/src/view/selection.js
  45. 12 8
      packages/ckeditor5-engine/src/view/text.js
  46. 4 1
      packages/ckeditor5-engine/src/view/textproxy.js
  47. 19 3
      packages/ckeditor5-engine/src/view/uielement.js
  48. 55 5
      packages/ckeditor5-engine/src/view/upcastwriter.js
  49. 26 24
      packages/ckeditor5-engine/src/view/view.js
  50. 24 29
      packages/ckeditor5-engine/tests/controller/datacontroller.js
  51. 6 5
      packages/ckeditor5-engine/tests/controller/editingcontroller.js
  52. 94 15
      packages/ckeditor5-engine/tests/conversion/conversion.js
  53. 0 1755
      packages/ckeditor5-engine/tests/conversion/downcast-converters.js
  54. 0 602
      packages/ckeditor5-engine/tests/conversion/downcast-selection-converters.js
  55. 99 19
      packages/ckeditor5-engine/tests/conversion/downcastdispatcher.js
  56. 2388 0
      packages/ckeditor5-engine/tests/conversion/downcasthelpers.js
  57. 0 131
      packages/ckeditor5-engine/tests/conversion/upcast-selection-converters.js
  58. 197 130
      packages/ckeditor5-engine/tests/conversion/upcasthelpers.js
  59. 22 0
      packages/ckeditor5-engine/tests/dev-utils/model.js
  60. 9 19
      packages/ckeditor5-engine/tests/manual/highlight.js
  61. 4 8
      packages/ckeditor5-engine/tests/manual/markers.js
  62. 0 3
      packages/ckeditor5-engine/tests/manual/markers.md
  63. 8 16
      packages/ckeditor5-engine/tests/manual/nestededitable.js
  64. 1 1
      packages/ckeditor5-engine/tests/manual/selection.html
  65. 8 11
      packages/ckeditor5-engine/tests/manual/selection.js
  66. 2 0
      packages/ckeditor5-engine/tests/manual/tickets/462/1.md
  67. 4 12
      packages/ckeditor5-engine/tests/manual/tickets/475/1.js
  68. 5 6
      packages/ckeditor5-engine/tests/manual/tickets/ckeditor5-721/1.js
  69. 247 0
      packages/ckeditor5-engine/tests/model/documentselection.js
  70. 46 0
      packages/ckeditor5-engine/tests/model/operation/transform/marker.js
  71. 70 0
      packages/ckeditor5-engine/tests/model/operation/transform/undo.js
  72. 21 0
      packages/ckeditor5-engine/tests/model/range.js
  73. 68 0
      packages/ckeditor5-engine/tests/model/selection.js
  74. 284 44
      packages/ckeditor5-engine/tests/model/utils/selection-post-fixer.js
  75. 152 4
      packages/ckeditor5-engine/tests/model/writer.js
  76. 4 12
      packages/ckeditor5-engine/tests/tickets/699.js
  77. 3 4
      packages/ckeditor5-engine/tests/utils/bindtwostepcarettoattribute.js
  78. 7 1
      packages/ckeditor5-engine/tests/view/containerelement.js
  79. 2 2
      packages/ckeditor5-engine/tests/view/manual/focus.js
  80. 3 3
      packages/ckeditor5-engine/tests/view/manual/focusobserver.js
  81. 1 1
      packages/ckeditor5-engine/tests/view/manual/keyobserver.js
  82. 0 1
      packages/ckeditor5-engine/tests/view/manual/noselection-iframe.html
  83. 13 7
      packages/ckeditor5-engine/tests/view/manual/noselection-iframe.js
  84. 3 0
      packages/ckeditor5-engine/tests/view/manual/uielement.md
  85. 67 0
      packages/ckeditor5-engine/tests/view/upcastwriter.js
  86. 28 0
      packages/ckeditor5-engine/tests/view/view/view.js

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

@@ -1,6 +1,84 @@
 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)
 
 ### 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
 
-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.
 

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

@@ -1,6 +1,6 @@
 {
   "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.",
   "keywords": [
     "wysiwyg",
@@ -21,25 +21,25 @@
     "ckeditor 5"
   ],
   "dependencies": {
-    "@ckeditor/ckeditor5-utils": "^11.0.0",
+    "@ckeditor/ckeditor5-utils": "^11.1.0",
     "lodash-es": "^4.17.10"
   },
   "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-config-ckeditor5": "^1.0.7",
+    "eslint-config-ckeditor5": "^1.0.9",
     "husky": "^0.14.3",
     "lint-staged": "^7.0.0"
   },

+ 3 - 3
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 DowncastDispatcher from '../conversion/downcastdispatcher';
-import { insertText } from '../conversion/downcast-converters';
+import { insertText } from '../conversion/downcasthelpers';
 
 import UpcastDispatcher from '../conversion/upcastdispatcher';
-import { convertText, convertToModelFragment } from '../conversion/upcast-converters';
+import { convertText, convertToModelFragment } from '../conversion/upcasthelpers';
 
 import ViewDocumentFragment from '../view/documentfragment';
 import ViewDocument from '../view/document';
@@ -58,7 +58,7 @@ export default class DataController {
 		 * Data processor used during the conversion.
 		 *
 		 * @readonly
-		 * @member {module:engine/dataProcessor~DataProcessor}
+		 * @member {module:engine/dataprocessor/dataprocessor~DataProcessor}
 		 */
 		this.processor = dataProcessor;
 

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

@@ -11,12 +11,11 @@ import RootEditableElement from '../view/rooteditableelement';
 import View from '../view/view';
 import Mapper from '../conversion/mapper';
 import DowncastDispatcher from '../conversion/downcastdispatcher';
-import { insertText, remove } from '../conversion/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 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,

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

@@ -9,18 +9,6 @@
 
 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.
  *
@@ -40,17 +28,18 @@ import {
  * method:
  *
  *		// 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:
- *		editor.conversion.for( 'dataDowncast' ).add( downcastElementToElement( dataConversionConfig ) );
+ *		editor.conversion.for( 'dataDowncast' ).elementToElement( dataConversionConfig ) );
+ *
  *		// 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
  * 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
  * class to add two-way converters (upcast and downcast):
@@ -71,7 +60,7 @@ export default class Conversion {
 		 * @private
 		 * @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
 	 * {@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.
 			 *
@@ -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.' );
 		}
 
-		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
-	 * 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.
 	 *
@@ -117,15 +107,18 @@ export default class Conversion {
 	 *
 	 * 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:
 	 *
-	 * * {@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):
 	 *
@@ -133,27 +126,14 @@ export default class Conversion {
 	 *		const config = { model: 'paragraph', view: 'p' };
 	 *
 	 *		// 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.
-	 * @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 ) {
-		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 ) {
 		// Set up downcast converter.
-		this.for( 'downcast' ).add( downcastElementToElement( definition ) );
+		this.for( 'downcast' ).elementToElement( definition );
 
 		// Set up upcast converter.
 		for ( const { model, view } of _getAllUpcastDefinitions( definition ) ) {
-			this.for( 'upcast' ).add(
-				upcastElementToElement( {
+			this.for( 'upcast' )
+				.elementToElement( {
 					model,
 					view,
 					converterPriority: definition.converterPriority
-				} )
-			);
+				} );
 		}
 	}
 
@@ -402,23 +381,25 @@ export default class Conversion {
 	 */
 	attributeToElement( definition ) {
 		// Set up downcast converter.
-		this.for( 'downcast' ).add( downcastAttributeToElement( definition ) );
+		this.for( 'downcast' ).attributeToElement( definition );
 
 		// Set up upcast converter.
 		for ( const { model, view } of _getAllUpcastDefinitions( definition ) ) {
-			this.for( 'upcast' ).add(
-				upcastElementToAttribute( {
+			this.for( 'upcast' )
+				.elementToAttribute( {
 					view,
 					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).
 	 * 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).
 	 *		conversion.attributeToAttribute( { model: 'source', view: 'src' } );
@@ -525,34 +506,30 @@ export default class Conversion {
 	 */
 	attributeToAttribute( definition ) {
 		// Set up downcast converter.
-		this.for( 'downcast' ).add( downcastAttributeToAttribute( definition ) );
+		this.for( 'downcast' ).attributeToAttribute( definition );
 
 		// Set up upcast converter.
 		for ( const { model, view } of _getAllUpcastDefinitions( definition ) ) {
-			this.for( 'upcast' ).add(
-				upcastAttributeToAttribute( {
+			this.for( 'upcast' )
+				.attributeToAttribute( {
 					view,
 					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
 	 * {@link module:utils/ckeditorerror~CKEditorError `conversion-for-unknown-group` error} is thrown.
 	 *
 	 * @private
 	 * @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.
 			 *
@@ -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.' );
 		}
 
-		return dispatchers;
+		return this._conversionHelpers.get( groupName );
 	}
 }
 
@@ -582,21 +559,6 @@ export default class Conversion {
  * @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
 // `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 );
-	};
-}

+ 71 - 21
packages/ckeditor5-engine/src/conversion/downcastdispatcher.js

@@ -105,13 +105,15 @@ export default class DowncastDispatcher {
 	/**
 	 * Creates a `DowncastDispatcher` instance.
 	 *
-	 * @param {Object} [conversionApi] Interface passed by dispatcher to the events calls.
+	 * @see module:engine/conversion/downcastdispatcher~DowncastConversionApi
+	 * @param {Object} [conversionApi] Additional properties for interface that will be passed to events fired
+	 * by `DowncastDispatcher`.
 	 */
 	constructor( conversionApi = {} ) {
 		/**
 		 * Interface passed by dispatcher to the events callbacks.
 		 *
-		 * @member {Object}
+		 * @member {module:engine/conversion/downcastdispatcher~DowncastConversionApi}
 		 */
 		this.conversionApi = extend( { dispatcher: this }, conversionApi );
 	}
@@ -294,7 +296,7 @@ export default class DowncastDispatcher {
 
 			// Do not fire event if the attribute has been consumed.
 			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 );
 			}
 		}
 
@@ -321,22 +323,28 @@ export default class DowncastDispatcher {
 		// In markers' case, event name == consumable name.
 		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;
 		}
 
-		// 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 );
 
-		// Create separate event for each node in the range.
 		for ( const item of markerRange.getItems() ) {
 			// Do not fire event for already consumed items.
 			if ( !this.conversionApi.consumable.test( item, eventName ) ) {
@@ -481,7 +489,8 @@ export default class DowncastDispatcher {
 	 * @param {Object} data Additional information about the change.
 	 * @param {module:engine/model/item~Item} data.item Inserted item.
 	 * @param {module:engine/model/range~Range} data.range Range spanning over inserted item.
-	 * @param {Object} conversionApi Conversion interface to be used by callback, passed in `DowncastDispatcher` constructor.
+	 * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi Conversion interface
+	 * to be used by callback, passed in `DowncastDispatcher` constructor.
 	 */
 
 	/**
@@ -495,14 +504,18 @@ export default class DowncastDispatcher {
 	 *
 	 * @event remove
 	 * @param {Object} data Additional information about the change.
-	 * @param {module:engine/model/position~Position} data.sourcePosition Position from where the range has been removed.
-	 * @param {module:engine/model/range~Range} data.range Removed range (in {@link module:engine/model/document~Document#graveyard
-	 * graveyard root}).
-	 * @param {Object} conversionApi Conversion interface to be used by callback, passed in `DowncastDispatcher` constructor.
+	 * @param {module:engine/model/position~Position} data.position Position from which the node has been removed.
+	 * @param {Number} data.length Offset size of the removed node.
+	 * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi Conversion interface
+	 * to be used by callback, passed in `DowncastDispatcher` constructor.
 	 */
 
 	/**
-	 * Fired when attribute has been added/changed/removed from a node. Also fired when collapsed model selection attribute is converted.
+	 * Fired in the following cases:
+	 *
+	 * * when an attribute has been added, changed, or removed from a node,
+	 * * when a node with an attribute is inserted,
+	 * * when collapsed model selection attribute is converted.
 	 *
 	 * `attribute` is a namespace for a class of events. Names of actually called events follow this pattern:
 	 * `attribute:attributeKey:name`. `attributeKey` is the key of added/changed/removed attribute.
@@ -520,7 +533,8 @@ export default class DowncastDispatcher {
 	 * @param {*} data.attributeOldValue Attribute value before the change. This is `null` when selection attribute is converted.
 	 * @param {*} data.attributeNewValue New attribute value.
 	 * @param {module:engine/conversion/modelconsumable~ModelConsumable} consumable Values to consume.
-	 * @param {Object} conversionApi Conversion interface to be used by callback, passed in `DowncastDispatcher` constructor.
+	 * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi Conversion interface
+	 * to be used by callback, passed in `DowncastDispatcher` constructor.
 	 */
 
 	/**
@@ -529,7 +543,8 @@ export default class DowncastDispatcher {
 	 * @event selection
 	 * @param {module:engine/model/selection~Selection} selection Selection that is converted.
 	 * @param {module:engine/conversion/modelconsumable~ModelConsumable} consumable Values to consume.
-	 * @param {Object} conversionApi Conversion interface to be used by callback, passed in `DowncastDispatcher` constructor.
+	 * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi Conversion interface
+	 * to be used by callback, passed in `DowncastDispatcher` constructor.
 	 */
 
 	/**
@@ -564,7 +579,8 @@ export default class DowncastDispatcher {
 	 * @param {module:engine/model/range~Range} data.markerRange Marker range.
 	 * @param {String} data.markerName Marker name.
 	 * @param {module:engine/conversion/modelconsumable~ModelConsumable} consumable Values to consume.
-	 * @param {Object} conversionApi Conversion interface to be used by callback, passed in `DowncastDispatcher` constructor.
+	 * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi Conversion interface
+	 * to be used by callback, passed in `DowncastDispatcher` constructor.
 	 */
 
 	/**
@@ -579,7 +595,8 @@ export default class DowncastDispatcher {
 	 * @param {Object} data Additional information about the change.
 	 * @param {module:engine/model/range~Range} data.markerRange Marker range.
 	 * @param {String} data.markerName Marker name.
-	 * @param {Object} conversionApi Conversion interface to be used by callback, passed in `DowncastDispatcher` constructor.
+	 * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi Conversion interface
+	 * to be used by callback, passed in `DowncastDispatcher` constructor.
 	 */
 }
 
@@ -608,3 +625,36 @@ function shouldMarkerChangeBeConverted( modelPosition, marker, mapper ) {
 
 	return !hasCustomHandling;
 }
+
+/**
+ * Conversion interface that is registered for given {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher}
+ * and is passed as one of parameters when {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher dispatcher}
+ * fires it's events.
+ *
+ * @interface module:engine/conversion/downcastdispatcher~DowncastConversionApi
+ */
+
+/**
+ * The {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher} instance.
+ *
+ * @member {module:engine/conversion/downcastdispatcher~DowncastDispatcher} #dispatcher
+ */
+
+/**
+ * Stores information about what parts of processed model item are still waiting to be handled. After a piece of model item
+ * was converted, appropriate consumable value should be {@link module:engine/conversion/modelconsumable~ModelConsumable#consume consumed}.
+ *
+ * @member {module:engine/conversion/modelconsumable~ModelConsumable} #consumable
+ */
+
+/**
+ * The {@link module:engine/conversion/mapper~Mapper} instance.
+ *
+ * @member {module:engine/conversion/mapper~Mapper} #mapper
+ */
+
+/**
+ * The {@link module:engine/view/downcastwriter~DowncastWriter} instance used to manipulate data during conversion.
+ *
+ * @member {module:engine/view/downcastwriter~DowncastWriter} #writer
+ */

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

@@ -0,0 +1,1347 @@
+/**
+ * @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` object and
+	 * {@link module:engine/conversion/downcastdispatcher~DowncastConversionApi `conversionApi`} are passed from
+	 * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:addMarker}. Additionally,
+	 * the `data.isOpening` parameter is passed, which is set to `true` for the marker start boundary element, and `false` to
+	 * the marker end boundary element.
+	 *
+	 * 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.
+ */

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

@@ -29,7 +29,7 @@ import TextProxy from '../model/textproxy';
  * {@link module:engine/conversion/modelconsumable~ModelConsumable#add add method} directly.
  * However, it is important to understand how consumable values can be
  * {@link module:engine/conversion/modelconsumable~ModelConsumable#consume consumed}.
- * See {@link module:engine/conversion/downcast-selection-converters default downcast converters} for more information.
+ * See {@link module:engine/conversion/downcasthelpers default downcast converters} for more information.
  *
  * Keep in mind, that one conversion event may have multiple callbacks (converters) attached to it. Each of those is
  * able to convert one or more parts of the model. However, when one of those callbacks actually converts
@@ -61,6 +61,7 @@ import TextProxy from '../model/textproxy';
  *			// Maybe it will be "decorated" later.
  *			const viewImage = new ViewElement( 'img' );
  *			const insertPosition = conversionApi.mapper.toViewPosition( data.range.start );
+ *			const viewWriter = conversionApi.writer;
  *
  *			// Check if the `image` element has children.
  *			if ( data.item.childCount > 0 ) {

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

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

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

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

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

@@ -29,16 +29,19 @@ import DowncastDispatcher from '../conversion/downcastdispatcher';
 import UpcastDispatcher from '../conversion/upcastdispatcher';
 import Mapper from '../conversion/mapper';
 import {
-	convertRangeSelection,
 	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 toMap from '@ckeditor/ckeditor5-utils/src/tomap';
 
 /**
- * Writes the content of the {@link module:engine/model/document~Document document} to an HTML-like string.
+ * Writes the content of a model {@link module:engine/model/document~Document document} to an HTML-like string.
  *
  * **Note:** A {@link module:engine/model/text~Text text} node that contains attributes will be represented as:
  *
@@ -72,9 +75,10 @@ export function getData( model, options = {} ) {
 getData._stringify = stringify;
 
 /**
- * Sets the content of the {@link module:engine/model/document~Document document} provided as an HTML-like string.
+ * Sets the content of a model {@link module:engine/model/document~Document document} provided as an HTML-like string.
  *
- * **Note:** Remember to register elements in the {@link module:engine/model/model~Model#schema model's schema} before inserting them.
+ * **Note:** Remember to register elements in the {@link module:engine/model/model~Model#schema model's schema} before
+ * trying to use them.
  *
  * **Note:** To create a {@link module:engine/model/text~Text text} node that contains attributes use:
  *
@@ -230,6 +234,7 @@ export function stringify( node, selectionOrPositionOrRange = null, markers = nu
 
 		return new ViewContainerElement( modelItem.name, attributes );
 	} ) );
+
 	downcastDispatcher.on( 'selection', convertRangeSelection() );
 	downcastDispatcher.on( 'selection', convertCollapsedSelection() );
 	downcastDispatcher.on( 'addMarker', insertUIElement( ( data, writer ) => {
@@ -277,7 +282,6 @@ export function stringify( node, selectionOrPositionOrRange = null, markers = nu
  *
  * @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/batch~Batch} batch A batch used for conversion.
  * @param {Object} [options={}] Additional configuration.
  * @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.

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

@@ -80,7 +80,7 @@ export function getData( view, options = {} ) {
 getData._stringify = stringify;
 
 /**
- * Sets the content of the {@link module:engine/view/document~Document document} provided as an HTML-like string.
+ * Sets the content of a view {@link module:engine/view/document~Document document} provided as an HTML-like string.
  *
  * @param {module:engine/view/view~View} view
  * @param {String} data An HTML-like string to write into the document.
@@ -111,44 +111,47 @@ setData._parse = parse;
 
 /**
  * Converts view elements to HTML-like string representation.
+ *
  * A root element can be provided as {@link module:engine/view/text~Text text}:
  *
- *		const text = new Text( 'foobar' );
+ *		const text = downcastWriter.createText( 'foobar' );
  *		stringify( text ); // 'foobar'
  *
  * or as an {@link module:engine/view/element~Element element}:
  *
- *		const element = new Element( 'p', null, new Text( 'foobar' ) );
+ *		const element = downcastWriter.createElement( 'p', null, downcastWriter.createText( 'foobar' ) );
  *		stringify( element ); // '<p>foobar</p>'
  *
  * or as a {@link module:engine/view/documentfragment~DocumentFragment document fragment}:
  *
- *		const text = new Text( 'foobar' );
- *		const b = new Element( 'b', { name: 'test' }, text );
- *		const p = new Element( 'p', { style: 'color:red;' } );
- *		const fragment = new DocumentFragment( [ p, b ] );
+ *		const text = downcastWriter.createText( 'foobar' );
+ *		const b = downcastWriter.createElement( 'b', { name: 'test' }, text );
+ *		const p = downcastWriter.createElement( 'p', { style: 'color:red;' } );
+ *		const fragment = downcastWriter.createDocumentFragment( [ p, b ] );
  *
  *		stringify( fragment ); // '<p style="color:red;"></p><b name="test">foobar</b>'
  *
  * Additionally, a {@link module:engine/view/documentselection~DocumentSelection selection} instance can be provided.
- * Ranges from the selection will then be included in output data.
+ * Ranges from the selection will then be included in the output data.
  * If a range position is placed inside the element node, it will be represented with `[` and `]`:
  *
- *		const text = new Text( 'foobar' );
- *		const b = new Element( 'b', null, text );
- *		const p = new Element( 'p', null, b );
- *		const selection = new Selection(
- *			Range._createFromParentsAndOffsets( p, 0, p, 1 )
+ *		const text = downcastWriter.createText( 'foobar' );
+ *		const b = downcastWriter.createElement( 'b', null, text );
+ *		const p = downcastWriter.createElement( 'p', null, b );
+ *		const selection = downcastWriter.createSelection(
+ *			downcastWriter.createRangeIn( p )
  *		);
  *
  *		stringify( p, selection ); // '<p>[<b>foobar</b>]</p>'
  *
  * If a range is placed inside the text node, it will be represented with `{` and `}`:
  *
- *		const text = new Text( 'foobar' );
- *		const b = new Element( 'b', null, text );
- *		const p = new Element( 'p', null, b );
- *		const selection = new Selection( Range._createFromParentsAndOffsets( text, 1, text, 5 ) );
+ *		const text = downcastWriter.createText( 'foobar' );
+ *		const b = downcastWriter.createElement( 'b', null, text );
+ *		const p = downcastWriter.createElement( 'p', null, b );
+ *		const selection = downcastWriter.createSelection(
+ *			downcastWriter.createRange( downcastWriter.createPositionAt( text, 1 ), downcastWriter.createPositionAt( text, 5 ) )
+ *		);
  *
  *		stringify( p, selection ); // '<p><b>f{ooba}r</b></p>'
  *
@@ -159,10 +162,10 @@ setData._parse = parse;
  *
  * Multiple ranges are supported:
  *
- *		const text = new Text( 'foobar' );
- *		const selection = new Selection( [
- *			Range._createFromParentsAndOffsets( text, 0, text, 1 ) ),
- *			Range._createFromParentsAndOffsets( text, 3, text, 5 ) )
+ *		const text = downcastWriter.createText( 'foobar' );
+ *		const selection = downcastWriter.createSelection( [
+ *			downcastWriter.createRange( downcastWriter.createPositionAt( text, 0 ), downcastWriter.createPositionAt( text, 1 ) ),
+ *			downcastWriter.createRange( downcastWriter.createPositionAt( text, 3 ), downcastWriter.createPositionAt( text, 5 ) )
  *		] );
  *
  *		stringify( text, selection ); // '{f}oo{ba}r'
@@ -172,9 +175,9 @@ setData._parse = parse;
  * is provided, it will be converted to a selection containing this range. If a position instance is provided, it will
  * be converted to a selection containing one range collapsed at this position.
  *
- *		const text = new Text( 'foobar' );
- *		const range = Range._createFromParentsAndOffsets( text, 0, text, 1 );
- *		const position = new Position( text, 3 );
+ *		const text = downcastWriter.createText( 'foobar' );
+ *		const range = downcastWriter.createRange( downcastWriter.createPositionAt( text, 0 ), downcastWriter.createPositionAt( text, 1 ) );
+ *		const position = downcastWriter.createPositionAt( text, 3 );
  *
  *		stringify( text, range ); // '{f}oobar'
  *		stringify( text, position ); // 'foo{}bar'
@@ -186,10 +189,10 @@ setData._parse = parse;
  * {@link module:engine/view/emptyelement~EmptyElement empty elements}
  * and {@link module:engine/view/uielement~UIElement UI elements}:
  *
- *		const attribute = new AttributeElement( 'b' );
- *		const container = new ContainerElement( 'p' );
- *		const empty = new EmptyElement( 'img' );
- *		const ui = new UIElement( 'span' );
+ *		const attribute = downcastWriter.createAttributeElement( 'b' );
+ *		const container = downcastWriter.createContainerElement( 'p' );
+ *		const empty = downcastWriter.createEmptyElement( 'img' );
+ *		const ui = downcastWriter.createUIElement( 'span' );
  *		getData( attribute, null, { showType: true } ); // '<attribute:b></attribute:b>'
  *		getData( container, null, { showType: true } ); // '<container:p></container:p>'
  *		getData( empty, null, { showType: true } ); // '<empty:img></empty:img>'
@@ -198,14 +201,14 @@ setData._parse = parse;
  * If `options.showPriority` is set to `true`, a priority will be displayed for all
  * {@link module:engine/view/attributeelement~AttributeElement attribute elements}.
  *
- *		const attribute = new AttributeElement( 'b' );
+ *		const attribute = downcastWriter.createAttributeElement( 'b' );
  *		attribute._priority = 20;
  *		getData( attribute, null, { showPriority: true } ); // <b view-priority="20"></b>
  *
  * If `options.showAttributeElementId` is set to `true`, the attribute element's id will be displayed for all
  * {@link module:engine/view/attributeelement~AttributeElement attribute elements} that have it set.
  *
- *		const attribute = new AttributeElement( 'span' );
+ *		const attribute = downcastWriter.createAttributeElement( 'span' );
  *		attribute._id = 'marker:foo';
  *		getData( attribute, null, { showAttributeElementId: true } ); // <span view-id="marker:foo"></span>
  *
@@ -249,7 +252,7 @@ export function stringify( node, selectionOrPositionOrRange = null, options = {}
 }
 
 /**
- * Parses an HTML-like string and returns view tree nodes.
+ * Parses an HTML-like string and returns a view tree.
  * A simple string will be converted to a {@link module:engine/view/text~Text text} node:
  *
  *		parse( 'foobar' ); // Returns an instance of text.
@@ -1050,7 +1053,7 @@ function _convertType( type ) {
 // Checks if a given priority is allowed. Returns null if the priority cannot be converted.
 //
 // @param {String} priorityString
-// returns {Number|Null}
+// returns {Number|null}
 function _convertPriority( priorityString ) {
 	const priority = parseInt( priorityString, 10 );
 

+ 72 - 8
packages/ckeditor5-engine/src/model/documentselection.js

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

+ 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`.
 	 *
 	 * @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
 	 * means that marker is just added.
 	 * @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
  * {@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.
- * 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}.
  *
  * Another upside of markers is that finding marked part of document is fast and easy. Using attributes to mark some nodes

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

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

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

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

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

@@ -515,6 +515,48 @@ class ContextFactory {
 
 				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 ];
 } );
 
-setTransformation( MarkerOperation, MoveOperation, ( a, b ) => {
+setTransformation( MarkerOperation, MoveOperation, ( a, b, context ) => {
 	if ( a.oldRange ) {
 		a.oldRange = Range._createFromRanges( a.oldRange._getTransformedByMoveOperation( b ) );
 	}
 
 	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 ) );
 	}
 
 	return [ a ];
 } );
 
-setTransformation( MarkerOperation, SplitOperation, ( a, b ) => {
+setTransformation( MarkerOperation, SplitOperation, ( a, b, context ) => {
 	if ( a.oldRange ) {
 		a.oldRange = a.oldRange._getTransformedBySplitOperation( b );
 	}
 
 	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 );
 	}
 
@@ -1719,6 +1786,7 @@ setTransformation( MoveOperation, MergeOperation, ( a, b, context ) => {
 				targetPositionPath.push( 0 );
 
 				const splitNodesMoveTarget = new Position( gyMove.targetPosition.root, targetPositionPath );
+				splitNodesMoveSource = splitNodesMoveSource._getTransformedByMove( gyMoveSource, gyMoveTarget, 1 );
 				const splitNodesMove = new MoveOperation( splitNodesMoveSource, b.howMany, splitNodesMoveTarget, 0 );
 
 				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}
 	 */
 	_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 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 {String} attribute The name of the attribute to check.
-	 * @returns {Iterator.<module:engine/model/range~Range>} Ranges in which the attribute is allowed.
+	 * @returns {Iterable.<module:engine/model/range~Range>} Ranges in which the attribute is allowed.
 	 */
 	* getValidRanges( ranges, attribute ) {
 		ranges = convertToMinimalFlatRanges( ranges );
@@ -539,7 +539,7 @@ export default class Schema {
 	 * @private
 	 * @param {module:engine/model/range~Range} range Range to process.
 	 * @param {String} attribute The name of the attribute to check.
-	 * @returns {Iterator.<module:engine/model/range~Range>} Ranges in which the attribute is allowed.
+	 * @returns {Iterable.<module:engine/model/range~Range>} Ranges in which the attribute is allowed.
 	 */
 	* _getValidRangesForRange( range, attribute ) {
 		let start = range.start;
@@ -1459,7 +1459,7 @@ function* combineWalkers( backward, forward ) {
 // all those minimal flat ranges.
 //
 // @param {Array.<module:engine/model/range~Range>} ranges Ranges to process.
-// @returns {Iterator.<module:engine/model/range~Range>} Minimal flat ranges of given `ranges`.
+// @returns {Iterable.<module:engine/model/range~Range>} Minimal flat ranges of given `ranges`.
 function* convertToMinimalFlatRanges( ranges ) {
 	for ( const range of ranges ) {
 		yield* range.getMinimalFlatRanges();

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

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

+ 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 {String}
+	 * @readonly
+	 * @type {String}
 	 */
 	get data() {
 		return this._data;

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

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

+ 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>f[oo</block>]    ->    <block>f[oo]</block>
+		// - [<block>foo</block><object></object>]    ->    <block>[foo</block><object></object>]
 		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
 	// then the range crossed limit element boundaries and needs to be fixed.
 	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.
 		// 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 );
 	}
@@ -212,34 +230,45 @@ function tryFixingNonCollapsedRage( range, schema ) {
 	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 {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).
 	while ( schema.isLimit( parent ) && parent.parent ) {
-		node = parent;
+		isLimitNode = 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} end
 // @param {module:engine/model/schema~Schema} schema
+// @returns {Boolean}
 function checkSelectionOnNonLimitElements( start, end, schema ) {
 	const startIsOnBlock = ( start.nodeAfter && !schema.isLimit( start.nodeAfter ) ) || schema.checkChild( start, '$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 );
 }
+

+ 73 - 26
packages/ckeditor5-engine/src/model/writer.js

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

+ 13 - 10
packages/ckeditor5-engine/src/view/attributeelement.js

@@ -14,22 +14,25 @@ import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 const DEFAULT_PRIORITY = 10;
 
 /**
- * Attributes are elements which define document presentation. They are mostly elements like `<b>` or `<span>`.
- * Attributes can be broken and merged by the {@link module:engine/view/downcastwriter~DowncastWriter view downcast writer}.
+ * Attribute elements are used to represent formatting elements in the view (think – `<b>`, `<span style="font-size: 2em">`, etc.).
+ * Most often they are created when downcasting model text attributes.
  *
- * Editing engine does not define fixed HTML DTD. This is why the type of the {@link module:engine/view/element~Element} need to
- * be defined by the feature developer. Creating an element you should use {@link module:engine/view/containerelement~ContainerElement}
- * class or `AttributeElement`.
+ * Editing engine does not define a fixed HTML DTD. This is why a feature developer needs to choose between various
+ * types (container element, {@link module:engine/view/attributeelement~AttributeElement attribute element},
+ * {@link module:engine/view/emptyelement~EmptyElement empty element}, etc) when developing a feature.
+ *
+ * To create a new attribute element instance use the
+ * {@link module:engine/view/downcastwriter~DowncastWriter#createAttributeElement `DowncastWriter#createAttributeElement()`} method.
  *
  * @extends module:engine/view/element~Element
  */
 export default class AttributeElement extends Element {
 	/**
-	 * Creates a attribute element.
+	 * Creates an attribute element.
 	 *
 	 * @see module:engine/view/downcastwriter~DowncastWriter#createAttributeElement
-	 * @protected
 	 * @see module:engine/view/element~Element
+	 * @protected
 	 */
 	constructor( name, attrs, children ) {
 		super( name, attrs, children );
@@ -66,7 +69,7 @@ export default class AttributeElement extends Element {
 		 * This property is managed by {@link module:engine/view/downcastwriter~DowncastWriter}.
 		 *
 		 * @protected
-		 * @member {Set|null}
+		 * @member {Set.<module:engine/view/attributeelement~AttributeElement>|null}
 		 */
 		this._clonesGroup = null;
 	}
@@ -75,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}.
 	 *
 	 * @readonly
-	 * @returns {Number}
+	 * @type {Number}
 	 */
 	get priority() {
 		return this._priority;
@@ -86,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`.
 	 *
 	 * @readonly
-	 * @returns {String|Number}
+	 * @type {String|Number}
 	 */
 	get id() {
 		return this._id;

+ 19 - 30
packages/ckeditor5-engine/src/view/containerelement.js

@@ -11,35 +11,22 @@ import Element from './element';
 
 /**
  * Containers are elements which define document structure. They define boundaries for
- * {@link module:engine/view/attributeelement~AttributeElement attributes}. They are mostly use for block elements like `<p>` or `<div>`.
+ * {@link module:engine/view/attributeelement~AttributeElement attributes}. They are mostly used for block elements like `<p>` or `<div>`.
  *
- * Editing engine does not define fixed HTML DTD. This is why the type of the {@link module:engine/view/element~Element} need to
- * be defined by the feature developer.
+ * Editing engine does not define a fixed HTML DTD. This is why a feature developer needs to choose between various
+ * types (container element, {@link module:engine/view/attributeelement~AttributeElement attribute element},
+ * {@link module:engine/view/emptyelement~EmptyElement empty element}, etc) when developing a feature.
  *
- * Creating an element you should use `ContainerElement` class or {@link module:engine/view/attributeelement~AttributeElement}. This is
- * important to define the type of the element because of two reasons:
+ * The container element should be your default choice when writing a converter, unless:
  *
- * Firstly, {@link module:engine/view/domconverter~DomConverter} needs the information what is an editable block to convert elements to
- * DOM properly. {@link module:engine/view/domconverter~DomConverter} will ensure that `ContainerElement` is editable and it is possible
- * to put caret inside it, even if the container is empty.
+ * * this element represents a model text attribute (then use {@link module:engine/view/attributeelement~AttributeElement}),
+ * * this is an empty element like `<img>` (then use {@link module:engine/view/emptyelement~EmptyElement}),
+ * * this is a root element,
+ * * this is a nested editable element (then use  {@link module:engine/view/editableelement~EditableElement}).
  *
- * Secondly, {@link module:engine/view/downcastwriter~DowncastWriter view downcast writer} uses this information.
- * Nodes {@link module:engine/view/downcastwriter~DowncastWriter#breakAttributes breaking} and
- * {@link module:engine/view/downcastwriter~DowncastWriter#mergeAttributes merging} is performed only in a bounds of a container nodes.
- *
- * For instance if `<p>` is an container and `<b>` is attribute:
- *
- *		<p><b>fo^o</b></p>
- *
- * {@link module:engine/view/downcastwriter~DowncastWriter#breakAttributes breakAttributes} will create:
- *
- *		<p><b>fo</b><b>o</b></p>
- *
- * There might be a need to mark `<span>` element as a container node, for example in situation when it will be a
- * container of an inline widget:
- *
- *		<span color="red">foobar</span>		// attribute
- *		<span data-widget>foobar</span>		// container
+ * To create a new container element instance use the
+ * {@link module:engine/view/downcastwriter~DowncastWriter#createContainerElement `DowncastWriter#createContainerElement()`}
+ * method.
  *
  * @extends module:engine/view/element~Element
  */
@@ -47,8 +34,8 @@ export default class ContainerElement extends Element {
 	/**
 	 * Creates a container element.
 	 *
-	 * @see module:engine/view/element~Element
 	 * @see module:engine/view/downcastwriter~DowncastWriter#createContainerElement
+	 * @see module:engine/view/element~Element
 	 * @protected
 	 */
 	constructor( name, attrs, children ) {
@@ -75,10 +62,12 @@ export default class ContainerElement extends Element {
 	}
 }
 
-// Returns block {@link module:engine/view/filler filler} offset or `null` if block filler is not needed.
-//
-// @returns {Number|null} Block filler offset or `null` if block filler is not needed.
-function getFillerOffset() {
+/**
+ * Returns block {@link module:engine/view/filler filler} offset or `null` if block filler is not needed.
+ *
+ * @returns {Number|null} Block filler offset or `null` if block filler is not needed.
+ */
+export function getFillerOffset() {
 	const children = [ ...this.getChildren() ];
 	const lastChild = children[ this.childCount - 1 ];
 

+ 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}.
 		 *
 		 * @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' } );
 

+ 7 - 3
packages/ckeditor5-engine/src/view/documentfragment.js

@@ -14,15 +14,19 @@ import isIterable from '@ckeditor/ckeditor5-utils/src/isiterable';
 import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin';
 
 /**
- * DocumentFragment class.
+ * Document fragment.
+ *
+ * To create a new document fragment instance use the
+ * {@link module:engine/view/upcastwriter~UpcastWriter#createDocumentFragment `UpcastWriter#createDocumentFragment()`}
+ * method.
  */
 export default class DocumentFragment {
 	/**
 	 * Creates new DocumentFragment instance.
 	 *
 	 * @protected
-	 * @param {module:engine/view/node~Node|Iterable.<module:engine/view/node~Node>} [children] List of nodes to be inserted into
-	 * created document fragment.
+	 * @param {module:engine/view/node~Node|Iterable.<module:engine/view/node~Node>} [children]
+	 * A list of nodes to be inserted into the created document fragment.
 	 */
 	constructor( children ) {
 		/**

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

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

+ 12 - 14
packages/ckeditor5-engine/src/view/downcastwriter.js

@@ -26,11 +26,15 @@ import { isPlainObject } from 'lodash-es';
  *
  * It provides a set of methods used to manipulate view nodes.
  *
+ * Do not create an instance of this writer manually. To modify a view structure, use
+ * the {@link module:engine/view/view~View#change `View#change()`} block.
+ *
  * The `DowncastWriter` is designed to work with semantic views which are the views that were/are being downcasted from the model.
- * To work with ordinary views (e.g. parsed from a string) use the {@link module:engine/view/upcastwriter~UpcastWriter upcast writer}.
+ * To work with ordinary views (e.g. parsed from a pasted content) use the
+ * {@link module:engine/view/upcastwriter~UpcastWriter upcast writer}.
  *
- * Do not create an instance of this writer manually. To modify a view structure, use
- * the {@link module:engine/view/view~View#change View#change()) block.
+ * Read more about changing the view in the {@glink framework/guides/architecture/editing-engine#changing-the-view Changing the view}
+ * section of the {@glink framework/guides/architecture/editing-engine Editing engine architecture} guide.
  */
 export default class DowncastWriter {
 	constructor( document ) {
@@ -52,10 +56,7 @@ export default class DowncastWriter {
 
 	/**
 	 * Sets {@link module:engine/view/documentselection~DocumentSelection selection's} ranges and direction to the
-	 * specified location based on the given {@link module:engine/view/documentselection~DocumentSelection document selection},
-	 * {@link module:engine/view/selection~Selection selection}, {@link module:engine/view/position~Position position},
-	 * {@link module:engine/view/item~Item item}, {@link module:engine/view/range~Range range},
-	 * an iterable of {@link module:engine/view/range~Range ranges} or null.
+	 * specified location based on the given {@link module:engine/view/selection~Selectable selectable}.
 	 *
 	 * Usage:
 	 *
@@ -110,8 +111,7 @@ export default class DowncastWriter {
 	 * 		// (and be  properly handled by screen readers).
 	 *		writer.setSelection( range, { fake: true, label: 'foo' } );
 	 *
-	 * @param {module:engine/view/selection~Selection|module:engine/view/position~Position|
-	 * Iterable.<module:engine/view/range~Range>|module:engine/view/range~Range|module:engine/view/item~Item|null} selectable
+	 * @param {module:engine/view/selection~Selectable} selectable
 	 * @param {Number|'before'|'end'|'after'|'on'|'in'} [placeOrOffset] Sets place or offset of the selection.
 	 * @param {Object} [options]
 	 * @param {Boolean} [options.backward] Sets this selection instance to be backward.
@@ -141,8 +141,8 @@ export default class DowncastWriter {
 	 *
 	 *		writer.createText( 'foo' );
 	 *
-	 * @param {String} data Text data.
-	 * @returns {module:engine/view/text~Text} Created text node.
+	 * @param {String} data The text's data.
+	 * @returns {module:engine/view/text~Text} The created text node.
 	 */
 	createText( data ) {
 		return new Text( data );
@@ -1072,9 +1072,7 @@ export default class DowncastWriter {
 	 *		// Creates fake selection with label.
 	 *		const selection = writer.createSelection( range, { fake: true, label: 'foo' } );
 	 *
-	 * @param {module:engine/view/selection~Selection|module:engine/view/documentselection~DocumentSelection|
-	 * module:engine/view/position~Position|Iterable.<module:engine/view/range~Range>|module:engine/view/range~Range|
-	 * module:engine/view/item~Item|null} [selectable=null]
+	 * @param {module:engine/view/selection~Selectable} [selectable=null]
 	 * @param {Number|'before'|'end'|'after'|'on'|'in'} [placeOrOffset] Offset or place when selectable is an `Item`.
 	 * @param {Object} [options]
 	 * @param {Boolean} [options.backward] Sets this selection instance to be backward.

+ 3 - 0
packages/ckeditor5-engine/src/view/editableelement.js

@@ -20,6 +20,9 @@ const documentSymbol = Symbol( 'document' );
  *
  * Editable is automatically read-only when its {@link module:engine/view/document~Document Document} is read-only.
  *
+ * The constructor of this class shouldn't be used directly. To create new `EditableElement` use the
+ * {@link module:engine/view/downcastwriter~DowncastWriter#createEditableElement `downcastWriter#createEditableElement()`} method.
+ *
  * @extends module:engine/view/containerelement~ContainerElement
  * @mixes module:utils/observablemixin~ObservableMixin
  */

+ 19 - 23
packages/ckeditor5-engine/src/view/element.js

@@ -22,20 +22,21 @@ import { isPlainObject } from 'lodash-es';
  * This is why the type of the {@link module:engine/view/element~Element} need to
  * be defined by the feature developer. When creating an element you should use one of the following methods:
  *
- * * {@link module:engine/view/downcastwriter~DowncastWriter#createContainerElement `writer.createContainerElement()`} in order to create
- * a {@link module:engine/view/containerelement~ContainerElement},
- * * {@link module:engine/view/downcastwriter~DowncastWriter#createAttributeElement `writer.createAttributeElement()`} in order to create
- * a {@link module:engine/view/attributeelement~AttributeElement},
- * * {@link module:engine/view/downcastwriter~DowncastWriter#createEmptyElement `writer.createEmptyElement()`} in order to create
- * a {@link module:engine/view/emptyelement~EmptyElement}.
- * * {@link module:engine/view/downcastwriter~DowncastWriter#createUIElement `writer.createUIElement()`} in order to create
- * a {@link module:engine/view/uielement~UIElement}.
- * * {@link module:engine/view/downcastwriter~DowncastWriter#createEditableElement `writer.createEditableElement()`} in order to create
- * a {@link module:engine/view/editableelement~EditableElement}.
+ * * {@link module:engine/view/downcastwriter~DowncastWriter#createContainerElement `downcastWriter#createContainerElement()`}
+ * in order to create a {@link module:engine/view/containerelement~ContainerElement},
+ * * {@link module:engine/view/downcastwriter~DowncastWriter#createAttributeElement `downcastWriter#createAttributeElement()`}
+ * in order to create a {@link module:engine/view/attributeelement~AttributeElement},
+ * * {@link module:engine/view/downcastwriter~DowncastWriter#createEmptyElement `downcastWriter#createEmptyElement()`}
+ * in order to create a {@link module:engine/view/emptyelement~EmptyElement}.
+ * * {@link module:engine/view/downcastwriter~DowncastWriter#createUIElement `downcastWriter#createUIElement()`}
+ * in order to create a {@link module:engine/view/uielement~UIElement}.
+ * * {@link module:engine/view/downcastwriter~DowncastWriter#createEditableElement `downcastWriter#createEditableElement()`}
+ * in order to create a {@link module:engine/view/editableelement~EditableElement}.
  *
  * Note that for view elements which are not created from the model, like elements from mutations, paste or
- * {@link module:engine/controller/datacontroller~DataController#set data.set} it is not possible to define the type of the element, so
- * these will be instances of the {@link module:engine/view/element~Element}.
+ * {@link module:engine/controller/datacontroller~DataController#set data.set} it is not possible to define the type of the element.
+ * In such cases the {@link module:engine/view/upcastwriter~UpcastWriter#createElement `UpcastWriter#createElement()`} method
+ * should be used to create generic view elements.
  *
  * @extends module:engine/view/node~Node
  */
@@ -45,22 +46,15 @@ export default class Element extends Node {
 	 *
 	 * Attributes can be passed in various formats:
 	 *
-	 *		new Element( 'div', { 'class': 'editor', 'contentEditable': 'true' } ); // object
+	 *		new Element( 'div', { class: 'editor', contentEditable: 'true' } ); // object
 	 *		new Element( 'div', [ [ 'class', 'editor' ], [ 'contentEditable', 'true' ] ] ); // map-like iterator
 	 *		new Element( 'div', mapOfAttributes ); // map
 	 *
-	 * **Note:** Constructor of this class shouldn't be used directly in the code. Use the
-	 * {@link module:engine/view/downcastwriter~DowncastWriter#createAttributeElement} for inline element,
-	 * {@link module:engine/view/downcastwriter~DowncastWriter#createContainerElement} for block element,
-	 * {@link module:engine/view/downcastwriter~DowncastWriter#createEditableElement} for editable element,
-	 * {@link module:engine/view/downcastwriter~DowncastWriter#createEmptyElement} for empty element or
-	 * {@link module:engine/view/downcastwriter~DowncastWriter#createUIElement} for UI element instead.
-	 *
 	 * @protected
 	 * @param {String} name Node name.
 	 * @param {Object|Iterable} [attrs] Collection of attributes.
 	 * @param {module:engine/view/node~Node|Iterable.<module:engine/view/node~Node>} [children]
-	 * List of nodes to be inserted into created element.
+	 * A list of nodes to be inserted into created element.
 	 */
 	constructor( name, attrs, children ) {
 		super();
@@ -112,7 +106,7 @@ export default class Element extends Node {
 		 * Map of styles.
 		 *
 		 * @protected
-		 * @member {Set} module:engine/view/element~Element#_styles
+		 * @member {Map} module:engine/view/element~Element#_styles
 		 */
 		this._styles = new Map();
 
@@ -127,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.
 		 *
 		 * @protected
-		 * @memeber {Map}
+		 * @member {Map}
 		 */
 		this._customProperties = new Map();
 	}
@@ -574,6 +568,7 @@ export default class Element extends Node {
 	 * Removes number of child nodes starting at the given index and set the parent of these nodes to `null`.
 	 *
 	 * @see module:engine/view/downcastwriter~DowncastWriter#remove
+	 * @protected
 	 * @param {Number} index Number of the first node to remove.
 	 * @param {Number} [howMany=1] Number of nodes to remove.
 	 * @fires module:engine/view/node~Node#change
@@ -675,6 +670,7 @@ export default class Element extends Node {
 	 *		element._removeClass( [ 'foo', 'bar' ] ); // Removes both 'foo' and 'bar' classes.
 	 *
 	 * @see module:engine/view/downcastwriter~DowncastWriter#removeClass
+	 * @protected
 	 * @param {Array.<String>|String} className
 	 * @fires module:engine/view/node~Node#change
 	 */

+ 6 - 1
packages/ckeditor5-engine/src/view/emptyelement.js

@@ -12,7 +12,12 @@ import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 import Node from './node';
 
 /**
- * EmptyElement class. It is used to represent elements that cannot contain any child nodes.
+ * Empty element class. It is used to represent elements that cannot contain any child nodes (for example `<img>` elements).
+ *
+ * To create a new empty element use the
+ * {@link module:engine/view/downcastwriter~DowncastWriter#createEmptyElement `downcastWriter#createEmptyElement()`} method.
+ *
+ * @extends module:engine/view/element~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.
  *
  * @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.
  *		const pattern = {
- *			attribute: {
+ *			attributes: {
  *				title: 'foobar',	// Attribute title should equal 'foobar'.
  *				foo: /^\w+/,		// Attribute foo should match /^\w+/ regexp.
  *				bar: true			// Attribute bar should be set (can be empty).
@@ -384,7 +384,7 @@ function matchStyles( patterns, element ) {
  *		// Multiple classes to match.
  *		const pattern = {
  *			classes: [ 'baz', 'bar', /foo.../ ]
- *		}:
+ *		};
  *
  *		// Match view element which has given styles.
  *		const pattern = {

+ 10 - 7
packages/ckeditor5-engine/src/view/node.js

@@ -16,13 +16,15 @@ import { clone } from 'lodash-es';
 /**
  * Abstract tree view node class.
  *
+ * This is an abstract class. Its constructor should not be used directly.
+ * Use the {@link module:engine/view/element~Element} class to create view elements
+ * or {@link module:engine/view/text~Text} class to create view text nodes.
+ *
  * @abstract
  */
 export default class Node {
 	/**
 	 * Creates a tree view node.
-	 *
-	 * This is an abstract class, so this constructor should not be used directly.
 	 */
 	constructor() {
 		/**
@@ -123,11 +125,11 @@ export default class Node {
 	 * Gets a path to the node. The path is an array containing indices of consecutive ancestors of this node,
 	 * beginning from {@link module:engine/view/node~Node#root root}, down to this node's index.
 	 *
-	 *		const abc = new Text( 'abc' );
-	 *		const foo = new Text( 'foo' );
-	 *		const h1 = new Element( 'h1', null, new Text( 'header' ) );
-	 *		const p = new Element( 'p', null, [ abc, foo ] );
-	 *		const div = new Element( 'div', null, [ h1, p ] );
+	 *		const abc = downcastWriter.createText( 'abc' );
+	 *		const foo = downcastWriter.createText( 'foo' );
+	 *		const h1 = downcastWriter.createElement( 'h1', null, downcastWriter.createText( 'header' ) );
+	 *		const p = downcastWriter.createElement( 'p', null, [ abc, foo ] );
+	 *		const div = downcastWriter.createElement( 'div', null, [ h1, p ] );
 	 *		foo.getPath(); // Returns [ 1, 3 ]. `foo` is in `p` which is in `div`. `p` starts at offset 1, while `foo` at 3.
 	 *		h1.getPath(); // Returns [ 0 ].
 	 *		div.getPath(); // Returns [].
@@ -257,6 +259,7 @@ export default class Node {
 	}
 
 	/**
+	 * @protected
 	 * @param {module:engine/view/document~ChangeType} type Type of the change.
 	 * @param {module:engine/view/node~Node} node Changed node.
 	 * @fires change

+ 7 - 1
packages/ckeditor5-engine/src/view/position.js

@@ -14,7 +14,13 @@ import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 import EditableElement from './editableelement';
 
 /**
- * Position in the tree. Position is always located before or after a node.
+ * Position in the view tree. Position is represented by its parent node and an offset in this parent.
+ *
+ * In order to create a new position instance use the `createPosition*()` factory methods available in:
+ *
+ * * {@module:engine/view/view~View}
+ * * {@module:engine/view/downcastwriter~DowncastWriter}
+ * * {@module:engine/view/upcastwriter~UpcastWriter}
  */
 export default class Position {
 	/**

+ 36 - 22
packages/ckeditor5-engine/src/view/range.js

@@ -11,7 +11,13 @@ import Position from './position';
 import TreeWalker from './treewalker';
 
 /**
- * Tree view range.
+ * Range in the view tree. A range is represented by its start and end {@link module:engine/view/position~Position positions}.
+ *
+ * In order to create a new position instance use the `createPosition*()` factory methods available in:
+ *
+ * * {@module:engine/view/view~View}
+ * * {@module:engine/view/downcastwriter~DowncastWriter}
+ * * {@module:engine/view/upcastwriter~UpcastWriter}
  */
 export default class Range {
 	/**
@@ -20,7 +26,7 @@ export default class Range {
 	 * **Note:** Constructor creates it's own {@link module:engine/view/position~Position} instances basing on passed values.
 	 *
 	 * @param {module:engine/view/position~Position} start Start position.
-	 * @param {module:engine/view/position~Position} [end] End position. If not set, range will be collapsed at `start` position.
+	 * @param {module:engine/view/position~Position} [end] End position. If not set, range will be collapsed at the `start` position.
 	 */
 	constructor( start, end = null ) {
 		/**
@@ -91,13 +97,14 @@ export default class Range {
 	 *
 	 * For example:
 	 *
-	 * 		<p>Foo</p><p><b>{Bar}</b></p> -> <p>Foo</p>[<p><b>Bar</b>]</p>
-	 * 		<p><b>foo</b>{bar}<span></span></p> -> <p><b>foo[</b>bar<span></span>]</p>
+	 *		<p>Foo</p><p><b>{Bar}</b></p> -> <p>Foo</p>[<p><b>Bar</b>]</p>
+	 *		<p><b>foo</b>{bar}<span></span></p> -> <p><b>foo[</b>bar<span></span>]</p>
 	 *
 	 * Note that in the sample above:
-	 *  - `<p>` have type of {@link module:engine/view/containerelement~ContainerElement},
-	 *  - `<b>` have type of {@link module:engine/view/attributeelement~AttributeElement},
-	 *  - `<span>` have type of {@link module:engine/view/uielement~UIElement}.
+	 *
+	 * - `<p>` have type of {@link module:engine/view/containerelement~ContainerElement},
+	 * - `<b>` have type of {@link module:engine/view/attributeelement~AttributeElement},
+	 * - `<span>` have type of {@link module:engine/view/uielement~UIElement}.
 	 *
 	 * @returns {module:engine/view/range~Range} Enlarged range.
 	 */
@@ -123,13 +130,14 @@ export default class Range {
 	 *
 	 * For example:
 	 *
-	 * 		<p>Foo</p>[<p><b>Bar</b>]</p> -> <p>Foo</p><p><b>{Bar}</b></p>
-	 * 		<p><b>foo[</b>bar<span></span>]</p> -> <p><b>foo</b>{bar}<span></span></p>
+	 *		<p>Foo</p>[<p><b>Bar</b>]</p> -> <p>Foo</p><p><b>{Bar}</b></p>
+	 *		<p><b>foo[</b>bar<span></span>]</p> -> <p><b>foo</b>{bar}<span></span></p>
 	 *
 	 * Note that in the sample above:
-	 *  - `<p>` have type of {@link module:engine/view/containerelement~ContainerElement},
-	 *  - `<b>` have type of {@link module:engine/view/attributeelement~AttributeElement},
-	 *  - `<span>` have type of {@link module:engine/view/uielement~UIElement}.
+	 *
+	 * - `<p>` have type of {@link module:engine/view/containerelement~ContainerElement},
+	 * - `<b>` have type of {@link module:engine/view/attributeelement~AttributeElement},
+	 * - `<span>` have type of {@link module:engine/view/uielement~UIElement}.
 	 *
 	 * @returns {module:engine/view/range~Range} Shrink range.
 	 */
@@ -205,10 +213,10 @@ export default class Range {
 	 *
 	 * Examples:
 	 *
-	 *		let foo = new Text( 'foo' );
-	 *		let img = new ContainerElement( 'img' );
-	 *		let bar = new Text( 'bar' );
-	 *		let p = new ContainerElement( 'p', null, [ foo, img, bar ] );
+	 *		let foo = downcastWriter.createText( 'foo' );
+	 *		let img = downcastWriter.createContainerElement( 'img' );
+	 *		let bar = downcastWriter.createText( 'bar' );
+	 *		let p = downcastWriter.createContainerElement( 'p', null, [ foo, img, bar ] );
 	 *
 	 *		let range = view.createRange( view.createPositionAt( foo, 2 ), view.createPositionAt( bar, 1 ); // "o", img, "b" are in range.
 	 *		let otherRange = view.createRange( // "oo", img, "ba" are in range.
@@ -260,10 +268,10 @@ export default class Range {
 	 *
 	 * Examples:
 	 *
-	 *		let foo = new Text( 'foo' );
-	 *		let img = new ContainerElement( 'img' );
-	 *		let bar = new Text( 'bar' );
-	 *		let p = new ContainerElement( 'p', null, [ foo, img, bar ] );
+	 *		let foo = downcastWriter.createText( 'foo' );
+	 *		let img = downcastWriter.createContainerElement( 'img' );
+	 *		let bar = downcastWriter.createText( 'bar' );
+	 *		let p = downcastWriter.createContainerElement( 'p', null, [ foo, img, bar ] );
 	 *
 	 *		let range = view.createRange( view.createPositionAt( foo, 2 ), view.createPositionAt( bar, 1 ); // "o", img, "b" are in range.
 	 *		let otherRange = view.createRange( view.createPositionAt( foo, 1 ), view.createPositionAt( p, 2 ); // "oo", img are in range.
@@ -309,6 +317,7 @@ export default class Range {
 	 * @param {Boolean} [options.singleCharacters=false]
 	 * @param {Boolean} [options.shallow=false]
 	 * @param {Boolean} [options.ignoreElementEnd=false]
+	 * @returns {module:engine/view/treewalker~TreeWalker}
 	 */
 	getWalker( options = {} ) {
 		options.boundaries = this;
@@ -326,6 +335,11 @@ export default class Range {
 		return this.start.getCommonAncestor( this.end );
 	}
 
+	/**
+	 * Clones this range.
+	 *
+	 * @returns {module:engine/view/range~Range}
+	 */
 	clone() {
 		return new Range( this.start, this.end );
 	}
@@ -381,7 +395,7 @@ export default class Range {
 	}
 
 	/**
-	 * Checks and returns whether this range intersects with given range.
+	 * Checks and returns whether this range intersects with the given range.
 	 *
 	 * @param {module:engine/view/range~Range} otherRange Range to compare with.
 	 * @returns {Boolean} True if ranges intersect.
@@ -391,7 +405,7 @@ export default class Range {
 	}
 
 	/**
-	 * Creates a range from given parents and offsets.
+	 * Creates a range from the given parents and offsets.
 	 *
 	 * @protected
 	 * @param {module:engine/view/node~Node|module:engine/view/documentfragment~DocumentFragment} startElement Start position

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

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

+ 12 - 8
packages/ckeditor5-engine/src/view/text.js

@@ -12,17 +12,20 @@ import Node from './node';
 /**
  * Tree view text node.
  *
+ * The constructor of this class shouldn't be used directly. To create new Text instances
+ * use the {@link module:engine/view/downcastwriter~DowncastWriter#createText `DowncastWriter#createText()`}
+ * method when working on data downcasted from the model or the
+ * {@link module:engine/view/upcastwriter~UpcastWriter#createText `UpcastWriter#createText()`}
+ * method when working on non-semantic views.
+ *
  * @extends module:engine/view/node~Node
  */
 export default class Text extends Node {
 	/**
 	 * Creates a tree view text node.
 	 *
-	 * **Note:** Constructor of this class shouldn't be used directly in the code.
-	 * Use the {@link module:engine/view/downcastwriter~DowncastWriter#createText} method instead.
-	 *
 	 * @protected
-	 * @param {String} data Text.
+	 * @param {String} data The text's data.
 	 */
 	constructor( data ) {
 		super();
@@ -48,7 +51,8 @@ export default class Text extends Node {
 	/**
 	 * The text content.
 	 *
-	 * @returns {String}
+	 * @readonly
+	 * @type {String}
 	 */
 	get data() {
 		return this._textData;
@@ -57,8 +61,8 @@ export default class Text extends Node {
 	/**
 	 * This getter is required when using the addition assignment operator on protected property:
 	 *
-	 *		const foo = new Text( 'foo' );
-	 *		const bar = new Text( 'bar' );
+	 *		const foo = downcastWriter.createText( 'foo' );
+	 *		const bar = downcastWriter.createText( 'bar' );
 	 *
 	 *		foo._data += bar.data;   // executes: `foo._data = foo._data + bar.data`
 	 *		console.log( foo.data ); // prints: 'foobar'
@@ -66,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.
 	 *
 	 * @protected
-	 * @returns {String}
+	 * @type {String}
 	 */
 	get _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() {
 		return this.data.length;

+ 19 - 3
packages/ckeditor5-engine/src/view/uielement.js

@@ -13,8 +13,24 @@ import Node from './node';
 import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
 
 /**
- * UIElement class. It is used to represent UI not a content of the document.
- * This element can't be split and selection can't be placed inside this element.
+ * UI element class. It should be used to represent editing UI which needs to be injected into the editing view
+ * If possible, you should keep your UI outside the editing view. However, if that is not possible,
+ * UI elements can be used.
+ *
+ * How a UI element is rendered is in your control (you pass a callback to
+ * {@link module:engine/view/downcastwriter~DowncastWriter#createUIElement `downcastWriter#createUIElement()`}).
+ * The editor will ignore your UI element – the selection cannot be placed in it, it is skipped (invisible) when
+ * the user modifies the selection by using arrow keys and the editor does not listen to any mutations which
+ * happen inside your UI elements.
+ *
+ * The limitation is that you cannot convert a model element to a UI element. UI elements need to be
+ * created for {@link module:engine/model/markercollection~Marker markers} or as additinal elements
+ * inside normal {@link module:engine/view/containerelement~ContainerElement container elements}.
+ *
+ * To create a new UI element use the
+ * {@link module:engine/view/downcastwriter~DowncastWriter#createUIElement `downcastWriter#createUIElement()`} method.
+ *
+ * @extends module:engine/view/element~Element
  */
 export default class UIElement extends Element {
 	/**
@@ -72,7 +88,7 @@ export default class UIElement extends Element {
 	 * {@link module:engine/view/domconverter~DomConverter}.
 	 * Do not use inheritance to create custom rendering method, replace `render()` method instead:
 	 *
-	 *		const myUIElement = new UIElement( 'span' );
+	 *		const myUIElement = downcastWriter.createUIElement( 'span' );
 	 *		myUIElement.render = function( domDocument ) {
 	 *			const domElement = this.toDomElement( domDocument );
 	 *			domElement.innerHTML = '<b>this is ui element</b>';

+ 55 - 5
packages/ckeditor5-engine/src/view/upcastwriter.js

@@ -7,22 +7,74 @@
  * @module module:engine/view/upcastwriter
  */
 
+import DocumentFragment from './documentfragment';
 import Element from './element';
+import Text from './text';
 import { isPlainObject } from 'lodash-es';
 import Position from './position';
 import Range from './range';
 import Selection from './selection';
 
 /**
- * View upcast writer.
+ * View upcast writer. It provides a set of methods used to manipulate non-semantic view trees.
  *
- * It provides a set of methods used to manipulate view nodes.
  * It should be used only while working on a non-semantic view
  * (e.g. a view created from HTML string on paste).
  * To manipulate a view which was or is being downcasted from the the model use the
  * {@link module:engine/view/downcastwriter~DowncastWriter downcast writer}.
+ *
+ * Read more about changing the view in the {@glink framework/guides/architecture/editing-engine#changing-the-view Changing the view}
+ * section of the {@glink framework/guides/architecture/editing-engine Editing engine architecture} guide.
+ *
+ * Unlike `DowncastWriter`, which is available in the {@link module:engine/view/view~View#change `View#change()`} block,
+ * `UpcastWriter` can wherever you need it:
+ *
+ *		const writer = new UpcastWriter();
+ *		const text = writer.createText( 'foo!' );
+ *
+ *		writer.appendChild( text, someViewElement );
  */
 export default class UpcastWriter {
+	/**
+	 * Creates a new {@link module:engine/view/documentfragment~DocumentFragment} instance.
+	 *
+	 * @param {module:engine/view/node~Node|Iterable.<module:engine/view/node~Node>} [children]
+	 * A list of nodes to be inserted into the created document fragment.
+	 * @returns {module:engine/view/documentfragment~DocumentFragment} The created document fragment.
+	 */
+	createDocumentFragment( children ) {
+		return new DocumentFragment( children );
+	}
+
+	/**
+	 * Creates a new {@link module:engine/view/element~Element} instance.
+	 *
+	 * Attributes can be passed in various formats:
+	 *
+	 *		upcastWriter.createElement( 'div', { class: 'editor', contentEditable: 'true' } ); // object
+	 *		upcastWriter.createElement( 'div', [ [ 'class', 'editor' ], [ 'contentEditable', 'true' ] ] ); // map-like iterator
+	 *		upcastWriter.createElement( 'div', mapOfAttributes ); // map
+	 *
+	 * @param {String} name Node name.
+	 * @param {Object|Iterable} [attrs] Collection of attributes.
+	 * @param {module:engine/view/node~Node|Iterable.<module:engine/view/node~Node>} [children]
+	 * A list of nodes to be inserted into created element.
+	 * @returns {module:engine/view/element~Element} Created element.
+	 */
+	createElement( name, attrs, children ) {
+		return new Element( name, attrs, children );
+	}
+
+	/**
+	 * Creates a new {@link module:engine/view/text~Text} instance.
+	 *
+	 * @param {String} data The text's data.
+	 * @returns {module:engine/view/text~Text} The created text node.
+	 */
+	createText( data ) {
+		return new Text( data );
+	}
+
 	/**
 	 * Clones the provided element.
 	 *
@@ -379,9 +431,7 @@ export default class UpcastWriter {
 	 *		// Creates fake selection with label.
 	 *		const selection = writer.createSelection( range, { fake: true, label: 'foo' } );
 	 *
-	 * @param {module:engine/view/selection~Selection|module:engine/view/documentselection~DocumentSelection|
-	 * module:engine/view/position~Position|Iterable.<module:engine/view/range~Range>|module:engine/view/range~Range|
-	 * module:engine/view/item~Item|null} [selectable=null]
+	 * @param {module:engine/view/selection~Selectable} [selectable=null]
 	 * @param {Number|'before'|'end'|'after'|'on'|'in'} [placeOrOffset] Offset or place when selectable is an `Item`.
 	 * @param {Object} [options]
 	 * @param {Boolean} [options.backward] Sets this selection instance to be backward.

+ 26 - 24
packages/ckeditor5-engine/src/view/view.js

@@ -53,10 +53,10 @@ import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
  * * {@link module:engine/view/observer/fakeselectionobserver~FakeSelectionObserver}.
  * * {@link module:engine/view/observer/compositionobserver~CompositionObserver}.
  *
- * This class also {@link module:engine/view/view~View#attachDomRoot bind DOM and View elements}.
+ * This class also {@link module:engine/view/view~View#attachDomRoot binds the DOM and the view elements}.
  *
- * If you do not need full DOM - View management, and want to only transform the tree of view elements to the DOM
- * elements you do not need this controller, you can use the {@link module:engine/view/domconverter~DomConverter DomConverter}.
+ * If you do not need full a DOM - view management, and only want to transform a tree of view elements to a tree of DOM
+ * elements you do not need this controller. You can use the {@link module:engine/view/domconverter~DomConverter DomConverter} instead.
  *
  * @mixes module:utils/observablemixin~ObservableMixin
  */
@@ -308,30 +308,32 @@ export default class View {
 	}
 
 	/**
-	 * Change method is the primary way of changing the view. You should use it to modify any node in the view tree.
-	 * It makes sure that after all changes are made view is rendered to DOM. It prevents situations when DOM is updated
-	 * when view state is not yet correct. It allows to nest calls one inside another and still perform single rendering
-	 * after all changes are applied.
+	 * The `change()` method is the primary way of changing the view. You should use it to modify any node in the view tree.
+	 * It makes sure that after all changes are made the view is rendered to the DOM. It prevents situations when the DOM is updated
+	 * when the view state is not yet correct. It allows to nest calls one inside another and still performs a single rendering
+	 * after all those changes are made. It also returns the return value of its callback.
 	 *
-	 *		view.change( writer => {
-	 *			writer.insert( position1, writer.createText( 'foo' ) );
+	 *		const text = view.change( writer => {
+	 *			const newText = writer.createText( 'foo' );
+	 *			writer.insert( position1, newText );
 	 *
 	 *			view.change( writer => {
 	 *				writer.insert( position2, writer.createText( 'bar' ) );
 	 *			} );
 	 *
 	 * 			writer.remove( range );
-	 *		} );
 	 *
-	 * Change block is executed immediately.
+	 * 			return newText;
+	 *		} );
 	 *
-	 * When the outermost change block is done and rendering to DOM is over it fires
-	 * {@link module:engine/view/view~View#event:render} event.
+	 * When the outermost change block is done and rendering to the DOM is over the
+	 * {@link module:engine/view/view~View#event:render `View#render`} event is fired.
 	 *
-	 * Throws {@link module:utils/ckeditorerror~CKEditorError CKEditorError} `applying-view-changes-on-rendering` when
-	 * change block is used after rendering to DOM has started.
+	 * This method throws a `applying-view-changes-on-rendering` error when
+	 * the change block is used after rendering to the DOM has started.
 	 *
 	 * @param {Function} callback Callback function which may modify the view.
+	 * @returns {*} Value returned by the callback.
 	 */
 	change( callback ) {
 		if ( this._renderingInProgress || this._postFixersInProgress ) {
@@ -343,25 +345,25 @@ export default class View {
 			 * * calling {@link #change} or {@link #render} during rendering process,
 			 * * calling {@link #change} or {@link #render} inside of
 			 *   {@link module:engine/view/document~Document#registerPostFixer post-fixer function}.
+			 *
+			 * @error cannot-change-view-tree
 			 */
 			throw new CKEditorError(
 				'cannot-change-view-tree: ' +
-				'Attempting to make changes to the view when it is in incorrect state: rendering or post-fixers are in progress. ' +
-				'This may cause some unexpected behaviour and inconsistency between the DOM and the view.'
+				'Attempting to make changes to the view when it is in an incorrect state: rendering or post-fixers are in progress. ' +
+				'This may cause some unexpected behavior and inconsistency between the DOM and the view.'
 			);
 		}
 
 		// Recursive call to view.change() method - execute listener immediately.
 		if ( this._ongoingChange ) {
-			callback( this._writer );
-
-			return;
+			return callback( this._writer );
 		}
 
 		// This lock will assure that all recursive calls to view.change() will end up in same block - one "render"
 		// event for all nested calls.
 		this._ongoingChange = true;
-		callback( this._writer );
+		const callbackResult = callback( this._writer );
 		this._ongoingChange = false;
 
 		// This lock is used by editing controller to render changes from outer most model.change() once. As plugins might call
@@ -373,6 +375,8 @@ export default class View {
 
 			this.fire( 'render' );
 		}
+
+		return callbackResult;
 	}
 
 	/**
@@ -524,9 +528,7 @@ export default class View {
 	 *		// Creates fake selection with label.
 	 *		const selection = view.createSelection( range, { fake: true, label: 'foo' } );
 	 *
-	 * @param {module:engine/view/selection~Selection|module:engine/view/documentselection~DocumentSelection|
-	 * module:engine/view/position~Position|Iterable.<module:engine/view/range~Range>|module:engine/view/range~Range|
-	 * module:engine/view/item~Item|null} [selectable=null]
+	 * @param {module:engine/view/selection~Selectable} [selectable=null]
 	 * @param {Number|'before'|'end'|'after'|'on'|'in'} [placeOrOffset] Offset or place when selectable is an `Item`.
 	 * @param {Object} [options]
 	 * @param {Boolean} [options.backward] Sets this selection instance to be backward.

+ 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 {
-	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', () => {
-	let model, modelDocument, htmlDataProcessor, data, schema;
+	let model, modelDocument, htmlDataProcessor, data, schema, upcastHelpers, downcastHelpers;
 
 	beforeEach( () => {
 		model = new Model();
@@ -46,6 +38,9 @@ describe( 'DataController', () => {
 		htmlDataProcessor = new HtmlDataProcessor();
 
 		data = new DataController( model, htmlDataProcessor );
+
+		upcastHelpers = new UpcastHelpers( data.upcastDispatcher );
+		downcastHelpers = new DowncastHelpers( data.downcastDispatcher );
 	} );
 
 	describe( 'constructor()', () => {
@@ -68,7 +63,7 @@ describe( 'DataController', () => {
 		it( 'should set paragraph', () => {
 			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>' );
 
@@ -79,7 +74,7 @@ describe( 'DataController', () => {
 		it( 'should set two paragraphs', () => {
 			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>' );
 
@@ -93,8 +88,8 @@ describe( 'DataController', () => {
 				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>' );
 
@@ -119,7 +114,7 @@ describe( 'DataController', () => {
 		beforeEach( () => {
 			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', () => {
@@ -285,7 +280,7 @@ describe( 'DataController', () => {
 			schema.register( 'paragraph', { inheritAllFrom: '$block' } );
 			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>' );
 		} );
@@ -294,7 +289,7 @@ describe( 'DataController', () => {
 			schema.register( 'paragraph', { inheritAllFrom: '$block' } );
 			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>' );
 		} );
@@ -303,7 +298,7 @@ describe( 'DataController', () => {
 			schema.register( 'paragraph', { inheritAllFrom: '$block' } );
 			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>' );
 		} );
@@ -319,7 +314,7 @@ describe( 'DataController', () => {
 			schema.register( 'paragraph', { inheritAllFrom: '$block' } );
 			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>' );
 		} );
@@ -328,8 +323,8 @@ describe( 'DataController', () => {
 			schema.register( 'paragraph', { inheritAllFrom: '$block' } );
 			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>' );
 		} );
@@ -341,8 +336,8 @@ describe( 'DataController', () => {
 			setData( model, '<paragraph>foo</paragraph>', { rootName: 'main' } );
 			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( 'main' ) ).to.equal( '<p>foo</p>' );
@@ -358,7 +353,7 @@ describe( 'DataController', () => {
 			schema.extend( '$block', { allowIn: 'div' } );
 			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', () => {
@@ -382,7 +377,7 @@ describe( 'DataController', () => {
 			schema.extend( '$block', { allowIn: 'div' } );
 			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', () => {
@@ -403,7 +398,7 @@ describe( 'DataController', () => {
 			const modelElement = parseModel( '<div><paragraph>foobar</paragraph></div>', schema );
 			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 => {
 				writer.insert( modelElement, modelRoot, 0 );
@@ -421,8 +416,8 @@ describe( 'DataController', () => {
 			const modelElement = parseModel( '<div><paragraph>foo</paragraph><paragraph>bar</paragraph></div>', schema );
 			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 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 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 ModelPosition from '../../src/model/position';
 import ModelRange from '../../src/model/range';
@@ -91,9 +90,11 @@ describe( 'EditingController', () => {
 			model.schema.register( 'paragraph', { 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.
 			model.change( writer => {

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

@@ -3,22 +3,23 @@
  * 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 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 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';
 
 describe( 'Conversion', () => {
-	let conversion, dispA, dispB;
+	let conversion, dispA, dispB, dispC;
 
 	beforeEach( () => {
 		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.
 		dispA = Symbol( 'dispA' );
 		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()', () => {
@@ -41,10 +44,8 @@ describe( 'Conversion', () => {
 	} );
 
 	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', () => {
@@ -52,6 +53,13 @@ describe( 'Conversion', () => {
 				conversion.for( 'foo' );
 			} ).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()', () => {
@@ -100,6 +108,7 @@ describe( 'Conversion', () => {
 			schema = model.schema;
 
 			schema.extend( '$text', {
+				allowIn: '$root',
 				allowAttributes: [ 'bold' ]
 			} );
 
@@ -113,8 +122,8 @@ describe( 'Conversion', () => {
 			viewDispatcher.on( 'documentFragment', convertToModelFragment(), { priority: 'lowest' } );
 
 			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', () => {
@@ -124,7 +133,7 @@ describe( 'Conversion', () => {
 				test( '<p>Foo</p>', '<paragraph>Foo</paragraph>' );
 			} );
 
-			it( 'config.converterPriority is defined', () => {
+			it( 'config.converterPriority is defined (override downcast)', () => {
 				conversion.elementToElement( { model: 'paragraph', view: 'p' } );
 				conversion.elementToElement( { model: 'paragraph', view: 'div', converterPriority: 'high' } );
 
@@ -132,6 +141,16 @@ describe( 'Conversion', () => {
 				test( '<p>Foo</p>', '<paragraph>Foo</paragraph>', '<div>Foo</div>' );
 			} );
 
+			it( 'config.converterPriority is defined (override upcast)', () => {
+				schema.register( 'foo', {
+					inheritAllFrom: '$block'
+				} );
+				conversion.elementToElement( { model: 'paragraph', view: 'p' } );
+				conversion.elementToElement( { model: 'foo', view: 'p', converterPriority: 'high' } );
+
+				test( '<p>Foo</p>', '<foo>Foo</foo>', '<p>Foo</p>' );
+			} );
+
 			it( 'config.view is an object', () => {
 				schema.register( 'fancyParagraph', {
 					inheritAllFrom: 'paragraph'
@@ -223,7 +242,7 @@ describe( 'Conversion', () => {
 				test( '<p><strong>Foo</strong> bar</p>', '<paragraph><$text bold="true">Foo</$text> bar</paragraph>' );
 			} );
 
-			it( 'config.converterPriority is defined', () => {
+			it( 'config.converterPriority is defined (override downcast)', () => {
 				conversion.attributeToElement( { model: 'bold', view: 'strong' } );
 				conversion.attributeToElement( { model: 'bold', view: 'b', converterPriority: 'high' } );
 
@@ -231,6 +250,20 @@ describe( 'Conversion', () => {
 				test( '<p><strong>Foo</strong></p>', '<paragraph><$text bold="true">Foo</$text></paragraph>', '<p><b>Foo</b></p>' );
 			} );
 
+			it( 'config.converterPriority is defined (override upcast)', () => {
+				schema.extend( '$text', {
+					allowAttributes: [ 'foo' ]
+				} );
+				conversion.attributeToElement( { model: 'bold', view: 'strong' } );
+				conversion.attributeToElement( { model: 'foo', view: 'strong', converterPriority: 'high' } );
+
+				test(
+					'<p><strong>Foo</strong></p>',
+					'<paragraph><$text foo="true">Foo</$text></paragraph>',
+					'<p><strong>Foo</strong></p>'
+				);
+			} );
+
 			it( 'config.view is an object', () => {
 				conversion.attributeToElement( {
 					model: 'bold',
@@ -625,6 +658,17 @@ describe( 'Conversion', () => {
 					'<div border="border"><div shade="shade"></div></div>'
 				);
 			} );
+
+			it( 'config.converterPriority is defined (override downcast)', () => {
+				schema.extend( 'image', {
+					allowAttributes: [ 'foo' ]
+				} );
+
+				conversion.attributeToAttribute( { model: 'foo', view: 'foo' } );
+				conversion.attributeToAttribute( { model: 'foo', view: 'foofoo', converterPriority: 'high' } );
+
+				test( '<img foo="foo"></img>', '<image foo="foo"></image>', '<img foofoo="foo"></img>' );
+			} );
 		} );
 
 		function test( input, expectedModel, expectedView = null ) {
@@ -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, [] );
 
-			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', () => {
@@ -446,22 +446,29 @@ describe( 'DowncastDispatcher', () => {
 	} );
 
 	describe( 'convertMarkerAdd', () => {
-		let range, element, text;
+		let element, text;
 
 		beforeEach( () => {
 			text = new ModelText( 'foo bar baz' );
 			element = new ModelElement( 'paragraph', null, [ text ] );
 			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 );
 
-			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', () => {
@@ -483,37 +490,110 @@ describe( 'DowncastDispatcher', () => {
 			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 = [];
 
 			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 );
 
+			expect( spyWholeRange.calledOnce ).to.be.true;
+			expect( spyItems.calledTwice ).to.be.true;
+
 			expect( items[ 0 ] ).to.equal( element );
 			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 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' } );
 
 			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 logStub = testUtils.sinon.stub( 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( logStub.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 logStub = testUtils.sinon.stub( 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( logStub.callCount ).to.equal( 2 );
+			expect( logStub.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 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( () => {
 		model = new Model();
@@ -49,14 +52,17 @@ describe( 'upcast-helpers', () => {
 		upcastDispatcher.on( 'documentFragment', convertToModelFragment(), { priority: 'lowest' } );
 
 		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>' );
 		} );
@@ -66,10 +72,8 @@ describe( 'upcast-helpers', () => {
 				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>' );
 		} );
@@ -79,16 +83,14 @@ describe( 'upcast-helpers', () => {
 				inheritAllFrom: '$block'
 			} );
 
-			const helperFancy = upcastElementToElement( {
+			upcastHelpers.elementToElement( {
 				view: {
 					name: 'p',
 					classes: 'fancy'
 				},
-				model: 'fancyParagraph',
+				model: 'fancyParagraph'
 			} );
 
-			conversion.for( 'upcast' ).add( helperFancy );
-
 			expectResult( new ViewContainerElement( 'p', { class: 'fancy' } ), '<fancyParagraph></fancyParagraph>' );
 			expectResult( new ViewContainerElement( 'p' ), '' );
 		} );
@@ -99,7 +101,7 @@ describe( 'upcast-helpers', () => {
 				allowAttributes: [ 'level' ]
 			} );
 
-			const helper = upcastElementToElement( {
+			upcastHelpers.elementToElement( {
 				view: {
 					name: 'p',
 					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', { 'data-level': 2 } ), '' );
 		} );
 
 		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>' );
 		} );
 
 		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' ), '' );
 		} );
@@ -136,10 +132,8 @@ describe( 'upcast-helpers', () => {
 				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(
 				new ViewContainerElement( 'p', null, [
@@ -152,20 +146,20 @@ describe( 'upcast-helpers', () => {
 		} );
 
 		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>' );
 		} );
 	} );
 
-	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(
 				new ViewAttributeElement( 'strong', null, new ViewText( 'foo' ) ),
@@ -174,10 +168,8 @@ describe( 'upcast-helpers', () => {
 		} );
 
 		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(
 				new ViewAttributeElement( 'strong', null, new ViewText( 'foo' ) ),
@@ -186,7 +178,7 @@ describe( 'upcast-helpers', () => {
 		} );
 
 		it( 'config.view is an object', () => {
-			const helper = upcastElementToAttribute( {
+			upcastHelpers.elementToAttribute( {
 				view: {
 					name: 'span',
 					classes: 'bold'
@@ -194,8 +186,6 @@ describe( 'upcast-helpers', () => {
 				model: 'bold'
 			} );
 
-			conversion.for( 'upcast' ).add( helper );
-
 			expectResult(
 				new ViewAttributeElement( 'span', { class: 'bold' }, new ViewText( 'foo' ) ),
 				'<$text bold="true">foo</$text>'
@@ -209,7 +199,7 @@ describe( 'upcast-helpers', () => {
 				allowAttributes: [ 'styled' ]
 			} );
 
-			const helper = upcastElementToAttribute( {
+			upcastHelpers.elementToAttribute( {
 				view: {
 					name: 'span',
 					classes: [ 'styled', 'styled-dark' ]
@@ -220,8 +210,6 @@ describe( 'upcast-helpers', () => {
 				}
 			} );
 
-			conversion.for( 'upcast' ).add( helper );
-
 			expectResult(
 				new ViewAttributeElement( 'span', { class: 'styled styled-dark' }, new ViewText( 'foo' ) ),
 				'<$text styled="dark">foo</$text>'
@@ -235,7 +223,7 @@ describe( 'upcast-helpers', () => {
 				allowAttributes: [ 'fontSize' ]
 			} );
 
-			const helper = upcastElementToAttribute( {
+			upcastHelpers.elementToAttribute( {
 				view: {
 					name: 'span',
 					styles: {
@@ -259,8 +247,6 @@ describe( 'upcast-helpers', () => {
 				}
 			} );
 
-			conversion.for( 'upcast' ).add( helper );
-
 			expectResult(
 				new ViewAttributeElement( 'span', { style: 'font-size:9px' }, new ViewText( 'foo' ) ),
 				'<$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', () => {
-			const helper = upcastElementToAttribute( { view: 'em', model: 'italic' } );
-
-			conversion.for( 'upcast' ).add( helper );
+			upcastHelpers.elementToAttribute( { view: 'em', model: 'italic' } );
 
 			expectResult(
 				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', () => {
-			const helperA = upcastElementToAttribute( { view: 'strong', model: 'bold' } );
-			const helperB = upcastElementToAttribute( {
+			upcastHelpers.elementToAttribute( { view: 'strong', model: 'bold' } );
+			upcastHelpers.elementToAttribute( {
 				view: 'strong',
 				model: {
 					key: 'bold',
@@ -299,8 +283,6 @@ describe( 'upcast-helpers', () => {
 				converterPriority: 'high'
 			} );
 
-			conversion.for( 'upcast' ).add( helperA ).add( helperB );
-
 			expectResult(
 				new ViewAttributeElement( 'strong', null, new ViewText( 'foo' ) ),
 				'<$text bold="true">foo</$text>'
@@ -308,18 +290,16 @@ describe( 'upcast-helpers', () => {
 		} );
 
 		it( 'should allow two converters to convert attributes on the same element', () => {
-			const helperA = upcastElementToAttribute( {
+			upcastHelpers.elementToAttribute( {
 				model: 'attribA',
 				view: { name: 'span', classes: 'attrib-a' }
 			} );
 
-			const helperB = upcastElementToAttribute( {
+			upcastHelpers.elementToAttribute( {
 				model: 'attribB',
 				view: { name: 'span', styles: { color: 'attrib-b' } }
 			} );
 
-			conversion.for( 'upcast' ).add( helperA ).add( helperB );
-
 			expectResult(
 				new ViewAttributeElement( 'span', { class: 'attrib-a', style: 'color:attrib-b;' }, new ViewText( 'foo' ) ),
 				'<$text attribA="true" attribB="true">foo</$text>'
@@ -327,23 +307,21 @@ describe( 'upcast-helpers', () => {
 		} );
 
 		it( 'should consume element only when only is name specified', () => {
-			const helperBold = upcastElementToAttribute( {
+			upcastHelpers.elementToAttribute( {
 				model: 'bold',
 				view: { name: 'strong' }
 			} );
 
-			const helperA = upcastElementToAttribute( {
+			upcastHelpers.elementToAttribute( {
 				model: 'attribA',
 				view: { name: 'strong' }
 			} );
 
-			const helperB = upcastElementToAttribute( {
+			upcastHelpers.elementToAttribute( {
 				model: 'attribB',
 				view: { name: 'strong', classes: 'foo' }
 			} );
 
-			conversion.for( 'upcast' ).add( helperBold ).add( helperA ).add( helperB );
-
 			expectResult(
 				new ViewAttributeElement( 'strong', { class: 'foo' }, new ViewText( 'foo' ) ),
 				'<$text attribB="true" bold="true">foo</$text>'
@@ -352,14 +330,12 @@ describe( 'upcast-helpers', () => {
 
 		// #1443.
 		it( 'should set attributes on the element\'s children', () => {
-			const helperBold = upcastElementToAttribute( {
+			upcastHelpers.elementToAttribute( {
 				model: 'bold',
 				view: { name: 'strong' }
 			} );
 
-			const helperP = upcastElementToElement( { view: 'p', model: 'paragraph' } );
-
-			conversion.for( 'upcast' ).add( helperP ).add( helperBold );
+			upcastHelpers.elementToElement( { view: 'p', model: 'paragraph' } );
 
 			expectResult(
 				new ViewAttributeElement(
@@ -372,23 +348,25 @@ describe( 'upcast-helpers', () => {
 		} );
 	} );
 
-	describe( 'upcastAttributeToAttribute', () => {
+	describe( '.attributeToAttribute()', () => {
 		beforeEach( () => {
-			conversion.for( 'upcast' ).add( upcastElementToElement( { view: 'img', model: 'image' } ) );
+			upcastHelpers.elementToElement( { view: 'img', model: 'image' } );
 
 			schema.register( 'image', {
 				inheritAllFrom: '$block'
 			} );
 		} );
 
+		it( 'should be chainable', () => {
+			expect( upcastHelpers.attributeToAttribute( { view: 'src', model: 'source' } ) ).to.equal( upcastHelpers );
+		} );
+
 		it( 'config.view is a string', () => {
 			schema.extend( 'image', {
 				allowAttributes: [ 'source' ]
 			} );
 
-			const helper = upcastAttributeToAttribute( { view: 'src', model: 'source' } );
-
-			conversion.for( 'upcast' ).add( helper );
+			upcastHelpers.attributeToAttribute( { view: 'src', model: 'source' } );
 
 			expectResult(
 				new ViewAttributeElement( 'img', { src: 'foo.jpg' } ),
@@ -401,9 +379,7 @@ describe( 'upcast-helpers', () => {
 				allowAttributes: [ 'source' ]
 			} );
 
-			const helper = upcastAttributeToAttribute( { view: { key: 'src' }, model: 'source' } );
-
-			conversion.for( 'upcast' ).add( helper );
+			upcastHelpers.attributeToAttribute( { view: { key: 'src' }, model: 'source' } );
 
 			expectResult(
 				new ViewAttributeElement( 'img', { src: 'foo.jpg' } ),
@@ -416,9 +392,7 @@ describe( 'upcast-helpers', () => {
 				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(
 				new ViewAttributeElement( 'img', { src: 'foo.jpg' } ),
@@ -431,10 +405,8 @@ describe( 'upcast-helpers', () => {
 				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(
 				new ViewAttributeElement( 'img', { src: 'foo.jpg' } ),
@@ -447,7 +419,7 @@ describe( 'upcast-helpers', () => {
 				allowAttributes: [ 'styled' ]
 			} );
 
-			const helper = upcastAttributeToAttribute( {
+			upcastHelpers.attributeToAttribute( {
 				view: {
 					key: 'data-style',
 					value: /[\s\S]*/
@@ -455,8 +427,6 @@ describe( 'upcast-helpers', () => {
 				model: 'styled'
 			} );
 
-			conversion.for( 'upcast' ).add( helper );
-
 			expectResult(
 				new ViewAttributeElement( 'img', { 'data-style': 'dark' } ),
 				'<image styled="dark"></image>'
@@ -468,7 +438,7 @@ describe( 'upcast-helpers', () => {
 				allowAttributes: [ 'styled' ]
 			} );
 
-			const helper = upcastAttributeToAttribute( {
+			upcastHelpers.attributeToAttribute( {
 				view: {
 					name: 'img',
 					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(
 				new ViewContainerElement( 'img', { class: 'styled-dark' } ),
@@ -505,7 +473,7 @@ describe( 'upcast-helpers', () => {
 				allowAttributes: [ 'styled' ]
 			} );
 
-			const helper = upcastAttributeToAttribute( {
+			upcastHelpers.attributeToAttribute( {
 				view: {
 					key: 'class',
 					value: /styled-[\S]+/
@@ -521,8 +489,6 @@ describe( 'upcast-helpers', () => {
 				}
 			} );
 
-			conversion.for( 'upcast' ).add( helper );
-
 			expectResult(
 				new ViewAttributeElement( 'img', { 'class': 'styled-dark' } ),
 				'<image styled="dark"></image>'
@@ -530,9 +496,7 @@ describe( 'upcast-helpers', () => {
 		} );
 
 		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(
 				new ViewAttributeElement( 'img', { src: 'foo.jpg' } ),
@@ -545,7 +509,7 @@ describe( 'upcast-helpers', () => {
 				allowAttributes: [ 'styled' ]
 			} );
 
-			const helperA = upcastAttributeToAttribute( {
+			upcastHelpers.attributeToAttribute( {
 				view: {
 					key: 'class',
 					value: 'styled'
@@ -556,7 +520,7 @@ describe( 'upcast-helpers', () => {
 				}
 			} );
 
-			const helperB = upcastAttributeToAttribute( {
+			upcastHelpers.attributeToAttribute( {
 				view: {
 					key: 'class',
 					value: 'styled'
@@ -567,8 +531,6 @@ describe( 'upcast-helpers', () => {
 				}
 			} );
 
-			conversion.for( 'upcast' ).add( helperA ).add( helperB );
-
 			expectResult(
 				new ViewAttributeElement( 'img', { class: 'styled' } ),
 				'<image styled="true"></image>'
@@ -584,13 +546,10 @@ describe( 'upcast-helpers', () => {
 				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(
 				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( [
 				new ViewText( 'fo' ),
@@ -623,10 +584,8 @@ describe( 'upcast-helpers', () => {
 		} );
 
 		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( [
 				new ViewText( 'fo' ),
@@ -642,7 +601,7 @@ describe( 'upcast-helpers', () => {
 		} );
 
 		it( 'config.view is an object', () => {
-			const helper = upcastElementToMarker( {
+			upcastHelpers.elementToMarker( {
 				view: {
 					name: 'span',
 					'data-marker': 'search'
@@ -650,8 +609,6 @@ describe( 'upcast-helpers', () => {
 				model: 'search'
 			} );
 
-			conversion.for( 'upcast' ).add( helper );
-
 			const frag = new ViewDocumentFragment( [
 				new ViewText( 'f' ),
 				new ViewUIElement( 'span', { 'data-marker': 'search' } ),
@@ -666,13 +623,11 @@ describe( 'upcast-helpers', () => {
 		} );
 
 		it( 'config.model is a function', () => {
-			const helper = upcastElementToMarker( {
+			upcastHelpers.elementToMarker( {
 				view: 'comment',
 				model: viewElement => 'comment:' + viewElement.getAttribute( 'data-comment-id' )
 			} );
 
-			conversion.for( 'upcast' ).add( helper );
-
 			const frag = new ViewDocumentFragment( [
 				new ViewText( 'foo' ),
 				new ViewUIElement( 'comment', { 'data-comment-id': 4 } ),
@@ -687,11 +642,9 @@ describe( 'upcast-helpers', () => {
 		} );
 
 		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, [
 				new ViewText( 'fo' ),
@@ -887,4 +840,118 @@ describe( 'upcast-converters', () => {
 			sinon.assert.calledTwice( spy );
 		} );
 	} );
+
+	describe( 'convertSelectionChange()', () => {
+		let model, view, viewDocument, mapper, convertSelection, modelRoot, viewRoot;
+
+		beforeEach( () => {
+			model = new Model();
+			modelRoot = model.document.createRoot();
+			model.schema.register( 'paragraph', { inheritAllFrom: '$block' } );
+
+			modelSetData( model, '<paragraph>foo</paragraph><paragraph>bar</paragraph>' );
+
+			view = new View();
+			viewDocument = view.document;
+			viewRoot = createViewRoot( viewDocument, 'div', 'main' );
+
+			viewSetData( view, '<p>foo</p><p>bar</p>' );
+
+			mapper = new Mapper();
+			mapper.bindElements( modelRoot, viewRoot );
+			mapper.bindElements( modelRoot.getChild( 0 ), viewRoot.getChild( 0 ) );
+			mapper.bindElements( modelRoot.getChild( 1 ), viewRoot.getChild( 1 ) );
+
+			convertSelection = convertSelectionChange( model, mapper );
+		} );
+
+		afterEach( () => {
+			view.destroy();
+		} );
+
+		it( 'should convert collapsed selection', () => {
+			const viewSelection = new ViewSelection();
+			viewSelection.setTo( ViewRange._createFromParentsAndOffsets(
+				viewRoot.getChild( 0 ).getChild( 0 ), 1, viewRoot.getChild( 0 ).getChild( 0 ), 1 ) );
+
+			convertSelection( null, { newSelection: viewSelection } );
+
+			expect( modelGetData( model ) ).to.equals( '<paragraph>f[]oo</paragraph><paragraph>bar</paragraph>' );
+			expect( modelGetData( model ) ).to.equal( '<paragraph>f[]oo</paragraph><paragraph>bar</paragraph>' );
+		} );
+
+		it( 'should support unicode', () => {
+			modelSetData( model, '<paragraph>நிலைக்கு</paragraph>' );
+			viewSetData( view, '<p>நிலைக்கு</p>' );
+
+			// Re-bind elements that were just re-set.
+			mapper.bindElements( modelRoot.getChild( 0 ), viewRoot.getChild( 0 ) );
+
+			const viewSelection = new ViewSelection( [
+				ViewRange._createFromParentsAndOffsets( viewRoot.getChild( 0 ).getChild( 0 ), 2, viewRoot.getChild( 0 ).getChild( 0 ), 6 )
+			] );
+
+			convertSelection( null, { newSelection: viewSelection } );
+
+			expect( modelGetData( model ) ).to.equal( '<paragraph>நி[லைக்]கு</paragraph>' );
+		} );
+
+		it( 'should convert multi ranges selection', () => {
+			const viewSelection = new ViewSelection( [
+				ViewRange._createFromParentsAndOffsets(
+					viewRoot.getChild( 0 ).getChild( 0 ), 1, viewRoot.getChild( 0 ).getChild( 0 ), 2 ),
+				ViewRange._createFromParentsAndOffsets(
+					viewRoot.getChild( 1 ).getChild( 0 ), 1, viewRoot.getChild( 1 ).getChild( 0 ), 2 )
+			] );
+
+			convertSelection( null, { newSelection: viewSelection } );
+
+			expect( modelGetData( model ) ).to.equal(
+				'<paragraph>f[o]o</paragraph><paragraph>b[a]r</paragraph>' );
+
+			const ranges = Array.from( model.document.selection.getRanges() );
+			expect( ranges.length ).to.equal( 2 );
+
+			expect( ranges[ 0 ].start.parent ).to.equal( modelRoot.getChild( 0 ) );
+			expect( ranges[ 0 ].start.offset ).to.equal( 1 );
+			expect( ranges[ 0 ].end.parent ).to.equal( modelRoot.getChild( 0 ) );
+			expect( ranges[ 0 ].end.offset ).to.equal( 2 );
+
+			expect( ranges[ 1 ].start.parent ).to.equal( modelRoot.getChild( 1 ) );
+			expect( ranges[ 1 ].start.offset ).to.equal( 1 );
+			expect( ranges[ 1 ].end.parent ).to.equal( modelRoot.getChild( 1 ) );
+			expect( ranges[ 1 ].end.offset ).to.equal( 2 );
+		} );
+
+		it( 'should convert reverse selection', () => {
+			const viewSelection = new ViewSelection( [
+				ViewRange._createFromParentsAndOffsets(
+					viewRoot.getChild( 0 ).getChild( 0 ), 1, viewRoot.getChild( 0 ).getChild( 0 ), 2 ),
+				ViewRange._createFromParentsAndOffsets(
+					viewRoot.getChild( 1 ).getChild( 0 ), 1, viewRoot.getChild( 1 ).getChild( 0 ), 2 )
+			], { backward: true } );
+
+			convertSelection( null, { newSelection: viewSelection } );
+
+			expect( modelGetData( model ) ).to.equal( '<paragraph>f[o]o</paragraph><paragraph>b[a]r</paragraph>' );
+			expect( model.document.selection.isBackward ).to.true;
+		} );
+
+		it( 'should not enqueue changes if selection has not changed', () => {
+			const viewSelection = new ViewSelection( [
+				ViewRange._createFromParentsAndOffsets(
+					viewRoot.getChild( 0 ).getChild( 0 ), 1, viewRoot.getChild( 0 ).getChild( 0 ), 1 )
+			] );
+
+			convertSelection( null, { newSelection: viewSelection } );
+
+			const spy = sinon.spy();
+
+			model.on( 'change', spy );
+
+			convertSelection( null, { newSelection: viewSelection } );
+
+			expect( spy.called ).to.be.false;
+		} );
+	} );
 } );

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

@@ -79,6 +79,28 @@ describe( 'model test utils', () => {
 				getData( { invalid: 'document' } );
 			} ).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', () => {

+ 9 - 19
packages/ckeditor5-engine/tests/manual/highlight.js

@@ -5,15 +5,6 @@
 
 /* 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 Enter from '@ckeditor/ckeditor5-enter/src/enter';
 import Typing from '@ckeditor/ckeditor5-typing/src/typing';
@@ -44,21 +35,20 @@ class FancyWidget extends Plugin {
 		} );
 		schema.extend( 'fancywidget', { allowIn: '$root' } );
 
-		conversion.for( 'editingDowncast' ).add( downcastElementToElement( {
+		conversion.for( 'editingDowncast' ).elementToElement( {
 			model: 'fancywidget',
 			view: ( modelItem, viewWriter ) => {
 				const widgetElement = viewWriter.createContainerElement( 'figure', { class: 'fancy-widget' } );
-				viewWriter.insert( viewWriter.createPositionAt( widgetElement ), viewWriter.createText( 'widget' ) );
+				viewWriter.insert( viewWriter.createPositionAt( widgetElement, 0 ), viewWriter.createText( 'widget' ) );
 
 				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 => {
 		window.editor = editor;
 
-		editor.conversion.for( 'editingDowncast' ).add( downcastMarkerToHighlight( {
+		editor.conversion.for( 'editingDowncast' ).markerToHighlight( {
 			model: 'marker',
 			view: data => ( {
 				classes: 'highlight-' + data.markerName.split( ':' )[ 1 ]
 			} )
-		} ) );
+		} );
 
 		document.getElementById( 'add-marker-yellow' ).addEventListener( 'mousedown', evt => {
 			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 Undo from '@ckeditor/ckeditor5-undo/src/undo';
 
-import {
-	downcastMarkerToHighlight
-} from '../../src/conversion/downcast-converters';
-
 import Position from '../../src/model/position';
 import Range from '../../src/model/range';
 
@@ -35,7 +31,7 @@ ClassicEditor
 		window.editor = editor;
 		model = editor.model;
 
-		editor.conversion.for( 'editingDowncast' ).add( downcastMarkerToHighlight( {
+		editor.conversion.for( 'editingDowncast' ).markerToHighlight( {
 			model: 'highlight',
 			view: data => {
 				const color = data.markerName.split( ':' )[ 1 ];
@@ -45,9 +41,9 @@ ClassicEditor
 					priority: 1
 				};
 			}
-		} ) );
+		} );
 
-		editor.conversion.for( 'dataDowncast' ).add( downcastMarkerToHighlight( {
+		editor.conversion.for( 'dataDowncast' ).markerToHighlight( {
 			model: 'highlight',
 			view: data => {
 				const color = data.markerName.split( ':' )[ 1 ];
@@ -57,7 +53,7 @@ ClassicEditor
 					priority: 1
 				};
 			}
-		} ) );
+		} );
 
 		window.document.getElementById( 'add-yellow' ).addEventListener( 'mousedown', e => {
 			e.preventDefault();

+ 0 - 3
packages/ckeditor5-engine/tests/manual/markers.md

@@ -1,6 +1,3 @@
-@bender-ui: collapsed
-@bender-tags: ticket, 643, iteration5
-
 ### Markers integration [#643](https://github.com/ckeditor/ckeditor5-engine/issues/643)
 
 1. Write before, after and inside marked text.

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

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

+ 1 - 1
packages/ckeditor5-engine/tests/manual/selection.html

@@ -45,4 +45,4 @@
 	<p>baz</p>
 </div>
 <h2>Model contents:</h2>
-<div id="model"></div>
+<pre id="model"></pre>

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

@@ -5,8 +5,6 @@
 
 /* global console */
 
-import { downcastElementToElement } from '../../src/conversion/downcast-converters';
-
 import { getData } from '../../src/dev-utils/model';
 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 Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 import Undo from '@ckeditor/ckeditor5-undo/src/undo';
-import { upcastElementToElement } from '../../src/conversion/upcast-converters';
 
 import './selection.css';
 import { toWidget, toWidgetEditable } from '@ckeditor/ckeditor5-widget/src/utils';
@@ -44,24 +41,24 @@ class SelectionTest extends Plugin {
 			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',
 			view: ( modelItem, 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',
 			view: ( modelItem, viewWriter ) => {
 				return toWidgetEditable( viewWriter.createEditableElement( 'td' ), viewWriter );
 			}
-		} ) );
+		} );
 	}
 }
 

+ 2 - 0
packages/ckeditor5-engine/tests/manual/tickets/462/1.md

@@ -1,5 +1,7 @@
 ### Selection attributes conversion test [#462](https://github.com/ckeditor/ckeditor5-engine/issues/462)
 
+Open the console. Watch the logged data.
+
 #### Test 1
 
 1. Put caret inside unstyled word.

+ 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 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 Typing from '@ckeditor/ckeditor5-typing/src/typing';
 import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
@@ -31,20 +23,20 @@ class Link extends Plugin {
 		// Allow bold attribute on all inline nodes.
 		editor.model.schema.extend( '$text', { allowAttributes: 'link' } );
 
-		editor.conversion.for( 'downcast' ).add( downcastAttributeToElement( {
+		editor.conversion.for( 'downcast' ).attributeToElement( {
 			model: 'link',
 			view: ( modelAttributeValue, viewWriter ) => {
 				return viewWriter.createAttributeElement( 'a', { href: modelAttributeValue } );
 			}
-		} ) );
+		} );
 
-		editor.conversion.for( 'upcast' ).add( upcastElementToAttribute( {
+		editor.conversion.for( 'upcast' ).elementToAttribute( {
 			view: 'a',
 			model: {
 				key: 'link',
 				value: viewElement => viewElement.getAttribute( 'href' )
 			}
-		} ) );
+		} );
 	}
 }
 

+ 5 - 6
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 ViewPosition from '../../../../src/view/position';
-import { downcastElementToElement } from '../../../../src/conversion/downcast-converters';
 import { setData } from '../../../../src/dev-utils/model';
 
 ClassicEditor
@@ -41,21 +40,21 @@ ClassicEditor
 		} );
 
 		editor.conversion.for( 'downcast' )
-			.add( downcastElementToElement( {
+			.elementToElement( {
 				model: 'widget',
 				view: ( modelItem, writer ) => {
 					const b = writer.createAttributeElement( 'b' );
 					const div = writer.createContainerElement( 'div' );
 
-					writer.insert( ViewPosition._createAt( div ), b );
+					writer.insert( ViewPosition._createAt( div, 0 ), b );
 
 					return toWidget( div, writer, { label: 'element label' } );
 				}
-			} ) )
-			.add( downcastElementToElement( {
+			} )
+			.elementToElement( {
 				model: 'nested',
 				view: ( item, writer ) => writer.createEditableElement( 'figcaption', { contenteditable: true } )
-			} ) );
+			} );
 
 		setData( editor.model,
 			'<paragraph>foo[]</paragraph>' +

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

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

+ 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>'
 				);
 			} );
+
+			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', () => {

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

@@ -388,4 +388,74 @@ describe( 'transform', () => {
 		john.undo();
 		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 ].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', () => {
 		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', {
 				allowWhere: '$block',
-				isObject: true,
-				isLimit: true
+				allowAttributes: [ 'headingRows', 'headingColumns' ],
+				isLimit: true,
+				isObject: true
 			} );
 
 			model.schema.register( 'tableRow', {
@@ -38,15 +39,18 @@ describe( 'Selection post-fixer', () => {
 
 			model.schema.register( 'tableCell', {
 				allowIn: 'tableRow',
-				allowContentOf: '$block',
+				allowAttributes: [ 'colspan', 'rowspan' ],
 				isLimit: true
 			} );
 
 			model.schema.register( 'image', {
-				allowIn: '$root',
-				isObject: true
+				isObject: true,
+				isBlock: true,
+				allowWhere: '$block'
 			} );
 
+			model.schema.extend( '$block', { allowIn: 'tableCell' } );
+
 			model.schema.register( 'caption', {
 				allowIn: 'image',
 				allowContentOf: '$block',
@@ -98,13 +102,16 @@ describe( 'Selection post-fixer', () => {
 				setModelData( model,
 					'<paragraph>[]foo</paragraph>' +
 					'<table>' +
-						'<tableRow><tableCell>aaa</tableCell><tableCell>bbb</tableCell></tableRow>' +
+						'<tableRow>' +
+							'<tableCell><paragraph>aaa</paragraph></tableCell>' +
+							'<tableCell><paragraph>bbb</paragraph></tableCell>' +
+						'</tableRow>' +
 					'</table>' +
 					'<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>...
 				model.change( writer => {
 					writer.setSelection( writer.createRange(
@@ -116,13 +123,16 @@ describe( 'Selection post-fixer', () => {
 				expect( getModelData( model ) ).to.equal(
 					'<paragraph>f[oo</paragraph>' +
 					'<table>' +
-						'<tableRow><tableCell>aaa</tableCell><tableCell>bbb</tableCell></tableRow>' +
+						'<tableRow>' +
+							'<tableCell><paragraph>aaa</paragraph></tableCell>' +
+							'<tableCell><paragraph>bbb</paragraph></tableCell>' +
+						'</tableRow>' +
 					'</table>]' +
 					'<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>
 				model.change( writer => {
 					writer.setSelection( writer.createRange(
@@ -134,7 +144,10 @@ describe( 'Selection post-fixer', () => {
 				expect( getModelData( model ) ).to.equal(
 					'<paragraph>foo</paragraph>' +
 					'[<table>' +
-						'<tableRow><tableCell>aaa</tableCell><tableCell>bbb</tableCell></tableRow>' +
+						'<tableRow>' +
+							'<tableCell><paragraph>aaa</paragraph></tableCell>' +
+							'<tableCell><paragraph>bbb</paragraph></tableCell>' +
+						'</tableRow>' +
 					'</table>' +
 					'<paragraph>b]ar</paragraph>'
 				);
@@ -152,7 +165,10 @@ describe( 'Selection post-fixer', () => {
 				expect( getModelData( model ) ).to.equal(
 					'<paragraph>f[oo</paragraph>' +
 					'<table>' +
-						'<tableRow><tableCell>aaa</tableCell><tableCell>bbb</tableCell></tableRow>' +
+						'<tableRow>' +
+							'<tableCell><paragraph>aaa</paragraph></tableCell>' +
+							'<tableCell><paragraph>bbb</paragraph></tableCell>' +
+						'</tableRow>' +
 					'</table>]' +
 					'<paragraph>bar</paragraph>'
 				);
@@ -162,15 +178,18 @@ describe( 'Selection post-fixer', () => {
 				// <paragraph>foo</paragraph><table><tableRow><tableCell>a[aa</tableCell><tableCell>b]bb</tableCell>
 				model.change( writer => {
 					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(
 					'<paragraph>foo</paragraph>' +
 					'[<table>' +
-						'<tableRow><tableCell>aaa</tableCell><tableCell>bbb</tableCell></tableRow>' +
+						'<tableRow>' +
+							'<tableCell><paragraph>aaa</paragraph></tableCell>' +
+							'<tableCell><paragraph>bbb</paragraph></tableCell>' +
+						'</tableRow>' +
 					'</table>]' +
 					'<paragraph>bar</paragraph>'
 				);
@@ -180,11 +199,17 @@ describe( 'Selection post-fixer', () => {
 				setModelData( model,
 					'<paragraph>foo</paragraph>' +
 					'<table>' +
-						'<tableRow><tableCell>aaa</tableCell><tableCell>bbb</tableCell></tableRow>' +
+						'<tableRow>' +
+							'<tableCell><paragraph>aaa</paragraph></tableCell>' +
+							'<tableCell><paragraph>bbb</paragraph></tableCell>' +
+						'</tableRow>' +
 					'</table>' +
 					'[]' +
 					'<table>' +
-						'<tableRow><tableCell>xxx</tableCell><tableCell>yyy</tableCell></tableRow>' +
+						'<tableRow>' +
+							'<tableCell><paragraph>xxx</paragraph></tableCell>' +
+							'<tableCell><paragraph>yyy</paragraph></tableCell>' +
+						'</tableRow>' +
 					'</table>' +
 					'<paragraph>baz</paragraph>'
 				);
@@ -192,10 +217,16 @@ describe( 'Selection post-fixer', () => {
 				expect( getModelData( model ) ).to.equal(
 					'<paragraph>foo</paragraph>' +
 					'[<table>' +
-						'<tableRow><tableCell>aaa</tableCell><tableCell>bbb</tableCell></tableRow>' +
+						'<tableRow>' +
+							'<tableCell><paragraph>aaa</paragraph></tableCell>' +
+							'<tableCell><paragraph>bbb</paragraph></tableCell>' +
+						'</tableRow>' +
 					'</table>]' +
 					'<table>' +
-						'<tableRow><tableCell>xxx</tableCell><tableCell>yyy</tableCell></tableRow>' +
+						'<tableRow>' +
+							'<tableCell><paragraph>xxx</paragraph></tableCell>' +
+							'<tableCell><paragraph>yyy</paragraph></tableCell>' +
+						'</tableRow>' +
 					'</table>' +
 					'<paragraph>baz</paragraph>'
 				);
@@ -208,7 +239,10 @@ describe( 'Selection post-fixer', () => {
 				setModelData( model,
 					'<paragraph>foo</paragraph>' +
 					'<table>' +
-						'[<tableRow><tableCell>aaa</tableCell><tableCell>bbb</tableCell></tableRow>]' +
+						'[<tableRow>' +
+							'<tableCell><paragraph>aaa</paragraph></tableCell>' +
+							'<tableCell><paragraph>bbb</paragraph></tableCell>' +
+						'</tableRow>]' +
 					'</table>' +
 					'<paragraph>baz</paragraph>'
 				);
@@ -216,7 +250,10 @@ describe( 'Selection post-fixer', () => {
 				expect( getModelData( model ) ).to.equal(
 					'<paragraph>foo</paragraph>' +
 					'[<table>' +
-						'<tableRow><tableCell>aaa</tableCell><tableCell>bbb</tableCell></tableRow>' +
+						'<tableRow>' +
+							'<tableCell><paragraph>aaa</paragraph></tableCell>' +
+							'<tableCell><paragraph>bbb</paragraph></tableCell>' +
+						'</tableRow>' +
 					'</table>]' +
 					'<paragraph>baz</paragraph>'
 				);
@@ -226,9 +263,18 @@ describe( 'Selection post-fixer', () => {
 				setModelData( model,
 					'<paragraph>foo</paragraph>' +
 					'<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>' +
 					'<paragraph>baz</paragraph>'
 				);
@@ -236,9 +282,18 @@ describe( 'Selection post-fixer', () => {
 				expect( getModelData( model ) ).to.equal(
 					'<paragraph>foo</paragraph>' +
 					'[<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>]' +
 					'<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,
 					'<paragraph>foo</paragraph>' +
 					'<table>' +
-						'<tableRow><tableCell>aaa</tableCell><tableCell>bbb</tableCell></tableRow>' +
+						'<tableRow>' +
+							'<tableCell><paragraph>aaa</paragraph></tableCell>' +
+							'<tableCell><paragraph>bbb</paragraph></tableCell>' +
+						'</tableRow>' +
 					'</table>' +
 					'<paragraph>b[ar</paragraph>' +
 					'<paragraph>ba]z</paragraph>'
@@ -283,13 +341,133 @@ describe( 'Selection post-fixer', () => {
 				expect( getModelData( model ) ).to.equal(
 					'<paragraph>foo</paragraph>' +
 					'<table>' +
-						'<tableRow><tableCell>aaa</tableCell><tableCell>bbb</tableCell></tableRow>' +
+						'<tableRow>' +
+							'<tableCell><paragraph>aaa</paragraph></tableCell>' +
+							'<tableCell><paragraph>bbb</paragraph></tableCell>' +
+						'</tableRow>' +
 					'</table>' +
 					'<paragraph>b[ar</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', () => {
 				model.change( writer => {
 					const ranges = [
@@ -308,7 +486,10 @@ describe( 'Selection post-fixer', () => {
 				expect( getModelData( model ) ).to.equal(
 					'<paragraph>f[oo</paragraph>' +
 					'<table>' +
-						'<tableRow><tableCell>aaa</tableCell><tableCell>bbb</tableCell></tableRow>' +
+						'<tableRow>' +
+							'<tableCell><paragraph>aaa</paragraph></tableCell>' +
+							'<tableCell><paragraph>bbb</paragraph></tableCell>' +
+						'</tableRow>' +
 					'</table>]' +
 					'<paragraph>bar</paragraph>'
 				);
@@ -333,7 +514,10 @@ describe( 'Selection post-fixer', () => {
 				expect( getModelData( model ) ).to.equal(
 					'<paragraph>f[oo</paragraph>' +
 					'<table>' +
-						'<tableRow><tableCell>aaa</tableCell><tableCell>bbb</tableCell></tableRow>' +
+						'<tableRow>' +
+							'<tableCell><paragraph>aaa</paragraph></tableCell>' +
+							'<tableCell><paragraph>bbb</paragraph></tableCell>' +
+						'</tableRow>' +
 					'</table>' +
 					'<paragraph>ba]r</paragraph>'
 				);
@@ -343,10 +527,22 @@ describe( 'Selection post-fixer', () => {
 				setModelData( model,
 					'<paragraph>foo</paragraph>' +
 					'<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>' +
 					'<paragraph>b]az</paragraph>'
 				);
@@ -354,10 +550,22 @@ describe( 'Selection post-fixer', () => {
 				expect( getModelData( model ) ).to.equal(
 					'<paragraph>foo</paragraph>' +
 					'[<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>' +
 					'<paragraph>b]az</paragraph>'
 				);
@@ -386,7 +594,10 @@ describe( 'Selection post-fixer', () => {
 				expect( getModelData( model ) ).to.equal(
 					'<paragraph>f[oo</paragraph>' +
 					'<table>' +
-						'<tableRow><tableCell>aaa</tableCell><tableCell>bbb</tableCell></tableRow>' +
+						'<tableRow>' +
+							'<tableCell><paragraph>aaa</paragraph></tableCell>' +
+							'<tableCell><paragraph>bbb</paragraph></tableCell>' +
+						'</tableRow>' +
 					'</table>' +
 					'<paragraph>bar]</paragraph>'
 				);
@@ -715,6 +926,23 @@ describe( 'Selection post-fixer', () => {
 					'<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', () => {
@@ -722,7 +950,10 @@ describe( 'Selection post-fixer', () => {
 				setModelData( model,
 					'<paragraph>[]foo</paragraph>' +
 					'<table>' +
-						'<tableRow><tableCell>aaa</tableCell><tableCell>bbb</tableCell></tableRow>' +
+						'<tableRow>' +
+							'<tableCell><paragraph>aaa</paragraph></tableCell>' +
+							'<tableCell><paragraph>bbb</paragraph></tableCell>' +
+						'</tableRow>' +
 					'</table>' +
 					'<paragraph>bar</paragraph>'
 				);
@@ -739,7 +970,10 @@ describe( 'Selection post-fixer', () => {
 				expect( getModelData( model ) ).to.equal(
 					'<paragraph>foo[]</paragraph>' +
 					'<table>' +
-						'<tableRow><tableCell>aaa</tableCell><tableCell>bbb</tableCell></tableRow>' +
+						'<tableRow>' +
+							'<tableCell><paragraph>aaa</paragraph></tableCell>' +
+							'<tableCell><paragraph>bbb</paragraph></tableCell>' +
+						'</tableRow>' +
 					'</table>' +
 					'<paragraph>bar</paragraph>'
 				);
@@ -758,7 +992,10 @@ describe( 'Selection post-fixer', () => {
 				expect( getModelData( model ) ).to.equal(
 					'<paragraph>foo</paragraph>' +
 					'<table>' +
-						'<tableRow><tableCell>[]aaa</tableCell><tableCell>bbb</tableCell></tableRow>' +
+						'<tableRow>' +
+							'<tableCell><paragraph>[]aaa</paragraph></tableCell>' +
+							'<tableCell><paragraph>bbb</paragraph></tableCell>' +
+						'</tableRow>' +
 					'</table>' +
 					'<paragraph>bar</paragraph>'
 				);
@@ -778,7 +1015,10 @@ describe( 'Selection post-fixer', () => {
 				expect( getModelData( model ) ).to.equal(
 					'<paragraph>[foo]</paragraph>' +
 					'<table>' +
-						'<tableRow><tableCell>aaa</tableCell><tableCell>bbb</tableCell></tableRow>' +
+						'<tableRow>' +
+							'<tableCell><paragraph>aaa</paragraph></tableCell>' +
+							'<tableCell><paragraph>bbb</paragraph></tableCell>' +
+						'</tableRow>' +
 					'</table>' +
 					'<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 ] );
 		} );
 
+		it( 'should do nothing if empty text node is being inserted', () => {
+			const parent = createDocumentFragment();
+
+			model.enqueueChange( batch, writer => {
+				const text = writer.createText( '' );
+
+				writer.insert( text, parent );
+			} );
+
+			expect( parent.childCount ).to.equal( 0 );
+		} );
+
 		it( 'should create proper operation for inserting element', () => {
 			const parent = createDocumentFragment();
 			const element = createElement( 'child' );
@@ -1413,6 +1425,51 @@ describe( 'Writer', () => {
 			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', () => {
 			expect( () => {
 				merge( new Position( root, [ 2 ] ) );
@@ -1458,6 +1515,51 @@ describe( 'Writer', () => {
 			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', () => {
 			expect( () => {
 				move( root.getChild( 0 ), new Position( root, [ 1, 3 ] ) );
@@ -1555,6 +1657,52 @@ describe( 'Writer', () => {
 				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', () => {
 				const writer = new Writer( model, batch );
 
@@ -1922,16 +2070,16 @@ describe( 'Writer', () => {
 			range = Range._createIn( root );
 		} );
 
-		it( 'should throw if options.usingOperations is not defined', () => {
+		it( 'should throw if options.usingOperation is not defined', () => {
 			expect( () => {
 				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( () => {
 				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', () => {

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

@@ -8,14 +8,6 @@
 import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
 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 getViewData } from '../../src/dev-utils/view';
 
@@ -54,13 +46,13 @@ function WidgetPlugin( editor ) {
 	} );
 	schema.extend( 'widget', { allowIn: '$root' } );
 
-	editor.conversion.for( 'downcast' ).add( downcastElementToElement( {
+	editor.conversion.for( 'downcast' ).elementToElement( {
 		model: 'widget',
 		view: 'widget'
-	} ) );
+	} );
 
-	editor.conversion.for( 'upcast' ).add( upcastElementToElement( {
+	editor.conversion.for( 'upcast' ).elementToElement( {
 		model: '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 bindTwoStepCaretToAttribute from '../../src/utils/bindtwostepcarettoattribute';
 import Position from '../../src/model/position';
-import { upcastElementToAttribute } from '../../src/conversion/upcast-converters';
 import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
 import { setData } from '../../src/dev-utils/model';
 
@@ -37,9 +36,9 @@ describe( 'bindTwoStepCaretToAttribute()', () => {
 			} );
 
 			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' } );
 
 			bindTwoStepCaretToAttribute( editor.editing.view, editor.model, emitter, 'a' );

+ 7 - 1
packages/ckeditor5-engine/tests/view/containerelement.js

@@ -3,7 +3,7 @@
  * For licensing, see LICENSE.md.
  */
 
-import ContainerElement from '../../src/view/containerelement';
+import { default as ContainerElement, getFillerOffset } from '../../src/view/containerelement';
 import Element from '../../src/view/element';
 import { parse } from '../../src/dev-utils/view';
 
@@ -96,3 +96,9 @@ describe( 'ContainerElement', () => {
 		} );
 	} );
 } );
+
+describe( 'getFillerOffset()', () => {
+	it( 'should be a function that can be used in other places', () => {
+		expect( getFillerOffset ).is.a( 'function' );
+	} );
+} );

+ 2 - 2
packages/ckeditor5-engine/tests/view/manual/focus.js

@@ -28,8 +28,8 @@ view.change( writer => {
 	text1 = writer.createText( 'Foo bar baz' );
 	text2 = writer.createText( 'Foo bar baz' );
 
-	writer.insert( ViewPosition._createAt( editable1 ), text1 );
-	writer.insert( ViewPosition._createAt( editable2 ), text2 );
+	writer.insert( ViewPosition._createAt( editable1, 0 ), text1 );
+	writer.insert( ViewPosition._createAt( editable2, 0 ), text2 );
 } );
 
 document.getElementById( 'button1' ).addEventListener( 'click', () => {

+ 3 - 3
packages/ckeditor5-engine/tests/view/manual/focusobserver.js

@@ -30,10 +30,10 @@ viewDocument.on( 'selectionChange', ( evt, data ) => {
 } );
 
 view.change( writer => {
-	writer.insert( writer.createPositionAt( editable1 ), writer.createText( 'First editable.' ) );
-	writer.insert( writer.createPositionAt( editable2 ), writer.createText( 'Second editable.' ) );
+	writer.insert( writer.createPositionAt( editable1, 0 ), writer.createText( 'First editable.' ) );
+	writer.insert( writer.createPositionAt( editable2, 0 ), writer.createText( 'Second editable.' ) );
 
-	writer.setSelection( editable1 );
+	writer.setSelection( editable1, 'end' );
 } );
 
 editable1.on( 'change:isFocused', () => {

+ 1 - 1
packages/ckeditor5-engine/tests/view/manual/keyobserver.js

@@ -19,7 +19,7 @@ view.attachDomRoot( document.getElementById( 'editable' ), 'editable' );
 
 view.change( writer => {
 	const text = writer.createText( 'foobar' );
-	writer.insert( writer.createPositionAt( viewRoot ), text );
+	writer.insert( writer.createPositionAt( viewRoot, 0 ), text );
 	writer.setSelection( text, 3 );
 } );
 

+ 0 - 1
packages/ckeditor5-engine/tests/view/manual/noselection-iframe.html

@@ -1,2 +1 @@
-<div contenteditable="true" id="editor"></div>
 <iframe id="iframe" src="immutable.html"></iframe>

+ 13 - 7
packages/ckeditor5-engine/tests/view/manual/noselection-iframe.js

@@ -9,12 +9,18 @@ import View from '../../../src/view/view';
 import { setData } from '../../../src/dev-utils/view';
 import createViewRoot from '../_utils/createroot';
 
-const view = new View();
-const viewDocument = view.document;
-createViewRoot( viewDocument );
 const iframe = document.getElementById( 'iframe' );
-view.attachDomRoot( iframe.contentWindow.document.getElementById( 'editor' ) );
+iframe.srcdoc = '<div contenteditable="true" id="editor"></div>';
 
-setData( view,
-	'<container:p>foo</container:p>' +
-	'<container:p>bar</container:p>' );
+iframe.addEventListener( 'load', () => {
+	const view = new View();
+	const viewDocument = view.document;
+	createViewRoot( viewDocument );
+
+	view.attachDomRoot( iframe.contentWindow.document.getElementById( 'editor' ) );
+
+	setData( view,
+		'<container:p>foo</container:p>' +
+		'<container:p>bar</container:p>'
+	);
+} );

+ 3 - 0
packages/ckeditor5-engine/tests/view/manual/uielement.md

@@ -5,3 +5,6 @@
 1. When paragraph is split or new paragraph is created - new UIElement should be created too.
 1. You should not be able to place selection inside ui element or type in it.
 1. Arrow keys should work correctly around ui element.
+
+Note: You can't put a selection after `X` (UIElement). 
+See a comment in issue [#1592](https://github.com/ckeditor/ckeditor5-engine/issues/1592#issuecomment-440638618). 

+ 67 - 0
packages/ckeditor5-engine/tests/view/upcastwriter.js

@@ -3,7 +3,9 @@
  * For licensing, see LICENSE.md.
  */
 
+import DocumentFragment from '../../src/view/documentfragment';
 import Element from '../../src/view/element';
+import Text from '../../src/view/text';
 import UpcastWriter from '../../src/view/upcastwriter';
 import HtmlDataProcessor from '../../src/dataprocessor/htmldataprocessor';
 import ViewPosition from '../../src/view/position';
@@ -32,6 +34,71 @@ describe( 'UpcastWriter', () => {
 		view = dataprocessor.toView( html );
 	} );
 
+	describe( 'createDocumentFragment', () => {
+		it( 'should create empty document fragment', () => {
+			const df = writer.createDocumentFragment();
+
+			expect( df ).to.instanceOf( DocumentFragment );
+			expect( df.childCount ).to.equal( 0 );
+		} );
+
+		it( 'should create document fragment with children', () => {
+			const df = writer.createDocumentFragment( [ view.getChild( 0 ), view.getChild( 1 ) ] );
+
+			expect( df ).to.instanceOf( DocumentFragment );
+			expect( df.childCount ).to.equal( 2 );
+		} );
+	} );
+
+	describe( 'createElement', () => {
+		it( 'should create empty element', () => {
+			const el = writer.createElement( 'p' );
+
+			expect( el ).to.instanceOf( Element );
+			expect( el.name ).to.equal( 'p' );
+			expect( Array.from( el.getAttributes() ).length ).to.equal( 0 );
+			expect( el.childCount ).to.equal( 0 );
+		} );
+
+		it( 'should create element with attributes', () => {
+			const el = writer.createElement( 'a', { 'class': 'editor', 'contentEditable': 'true' } );
+
+			expect( el ).to.instanceOf( Element );
+			expect( el.name ).to.equal( 'a' );
+			expect( Array.from( el.getAttributes() ).length ).to.equal( 2 );
+			expect( el.childCount ).to.equal( 0 );
+		} );
+
+		it( 'should create element with children', () => {
+			const el = writer.createElement( 'div', null, [ view.getChild( 0 ) ] );
+
+			expect( el ).to.instanceOf( Element );
+			expect( el.name ).to.equal( 'div' );
+			expect( Array.from( el.getAttributes() ).length ).to.equal( 0 );
+			expect( el.childCount ).to.equal( 1 );
+		} );
+
+		it( 'should create element with attributes and children', () => {
+			const el = writer.createElement( 'blockquote',
+				{ 'class': 'editor', 'contentEditable': 'true' },
+				view.getChild( 2 ) );
+
+			expect( el ).to.instanceOf( Element );
+			expect( el.name ).to.equal( 'blockquote' );
+			expect( Array.from( el.getAttributes() ).length ).to.equal( 2 );
+			expect( el.childCount ).to.equal( 1 );
+		} );
+	} );
+
+	describe( 'createText', () => {
+		it( 'should create text', () => {
+			const text = writer.createText( 'FooBar' );
+
+			expect( text ).to.instanceOf( Text );
+			expect( text.data ).to.equal( 'FooBar' );
+		} );
+	} );
+
 	describe( 'clone', () => {
 		it( 'should clone simple element', () => {
 			const el = view.getChild( 0 );

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

@@ -602,6 +602,34 @@ describe( 'view', () => {
 
 			sinon.assert.callOrder( changeSpy, postFixer1, postFixer2, eventSpy );
 		} );
+
+		it( 'should return result of the callback', () => {
+			const result = view.change( () => {
+				return 'FooBar';
+			} );
+
+			expect( result ).to.equal( 'FooBar' );
+		} );
+
+		it( 'should return result of the callback with nested change block', () => {
+			let result2 = false;
+			let result3 = false;
+
+			const result1 = view.change( () => {
+				return view.change( () => {
+					result2 = view.change( () => {
+						return true;
+					} );
+					result3 = view.change( () => {} );
+
+					return 42;
+				} );
+			} );
+
+			expect( result1 ).to.equal( 42 );
+			expect( result2 ).to.equal( true );
+			expect( result3 ).to.undefined;
+		} );
 	} );
 
 	describe( 'createPositionAt()', () => {