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

Merge branch 'master' into i/7336

# Conflicts:
#	packages/ckeditor5-engine/src/conversion/upcasthelpers.js
Maciej Gołaszewski 5 лет назад
Родитель
Сommit
30d1c25184
78 измененных файлов с 4750 добавлено и 1005 удалено
  1. 1 1
      CHANGELOG.md
  2. 4 2
      docs/framework/guides/architecture/editing-engine.md
  3. 5 5
      package.json
  4. 1 0
      packages/ckeditor5-core/theme/icons/object-size-full.svg
  5. 1 0
      packages/ckeditor5-core/theme/icons/object-size-large.svg
  6. 1 0
      packages/ckeditor5-core/theme/icons/object-size-medium.svg
  7. 1 0
      packages/ckeditor5-core/theme/icons/object-size-small.svg
  8. 2 1
      packages/ckeditor5-engine/src/controller/datacontroller.js
  9. 2 1
      packages/ckeditor5-engine/src/controller/editingcontroller.js
  10. 6 0
      packages/ckeditor5-engine/src/conversion/downcastdispatcher.js
  11. 283 6
      packages/ckeditor5-engine/src/conversion/downcasthelpers.js
  12. 194 3
      packages/ckeditor5-engine/src/conversion/upcasthelpers.js
  13. 10 0
      packages/ckeditor5-engine/src/model/markercollection.js
  14. 2 2
      packages/ckeditor5-engine/tests/controller/datacontroller.js
  15. 481 0
      packages/ckeditor5-engine/tests/conversion/downcasthelpers.js
  16. 196 11
      packages/ckeditor5-engine/tests/conversion/upcasthelpers.js
  17. 6 0
      packages/ckeditor5-engine/tests/model/markercollection.js
  18. 5 15
      packages/ckeditor5-font/src/documentcolorcollection.js
  19. 1 1
      packages/ckeditor5-highlight/package.json
  20. 30 1
      packages/ckeditor5-image/docs/_snippets/features/image-resize-px.js
  21. 14 0
      packages/ckeditor5-image/docs/_snippets/features/image-resize.js
  22. 0 6
      packages/ckeditor5-image/docs/_snippets/features/image-resizeui.html
  23. 18 8
      packages/ckeditor5-image/docs/_snippets/features/image-resizeui.js
  24. 0 6
      packages/ckeditor5-image/docs/_snippets/features/image-resizeuidropdown.html
  25. 13 2
      packages/ckeditor5-image/docs/_snippets/features/image-resizeuidropdown.js
  26. 7 1
      packages/ckeditor5-image/docs/_snippets/features/image-style-presentational.html
  27. 8 8
      packages/ckeditor5-image/docs/_snippets/features/image-style-presentational.js
  28. 90 39
      packages/ckeditor5-image/docs/features/image.md
  29. 5 3
      packages/ckeditor5-image/lang/contexts.json
  30. 66 22
      packages/ckeditor5-image/src/imageresize.js
  31. 1 1
      packages/ckeditor5-image/src/imageresize/imageresizeediting.js
  32. 156 65
      packages/ckeditor5-image/src/imageresize/imageresizeui.js
  33. 136 68
      packages/ckeditor5-image/tests/imageresize/imageresizeui.js
  34. 3 3
      packages/ckeditor5-image/tests/manual/imageresize.js
  35. 3 3
      packages/ckeditor5-image/tests/manual/imageresizepx.js
  36. 13 16
      packages/ckeditor5-image/tests/manual/imageresizeui.js
  37. 18 9
      packages/ckeditor5-image/tests/manual/imageresizeui.md
  38. 12 0
      packages/ckeditor5-image/theme/imageresize.css
  39. 1 7
      packages/ckeditor5-image/theme/imagestyle.css
  40. 1 1
      packages/ckeditor5-link/package.json
  41. 23 2
      packages/ckeditor5-link/src/linkcommand.js
  42. 139 3
      packages/ckeditor5-link/src/linkediting.js
  43. 2 0
      packages/ckeditor5-link/src/linkimage.js
  44. 120 4
      packages/ckeditor5-link/src/linkimageediting.js
  45. 2 2
      packages/ckeditor5-link/src/linkui.js
  46. 3 2
      packages/ckeditor5-link/src/unlinkcommand.js
  47. 15 0
      packages/ckeditor5-link/src/utils.js
  48. 41 1
      packages/ckeditor5-link/src/utils/automaticdecorators.js
  49. 21 0
      packages/ckeditor5-link/tests/linkcommand.js
  50. 355 11
      packages/ckeditor5-link/tests/linkediting.js
  51. 385 1
      packages/ckeditor5-link/tests/linkimageediting.js
  52. 8 0
      packages/ckeditor5-link/tests/linkui.js
  53. 10 8
      packages/ckeditor5-link/tests/manual/linkdecorator.js
  54. 3 1
      packages/ckeditor5-link/tests/manual/linkimage.md
  55. 31 2
      packages/ckeditor5-link/tests/utils.js
  56. 6 0
      packages/ckeditor5-link/tests/utils/automaticdecorators.js
  57. 1 1
      packages/ckeditor5-link/theme/icons/link.svg
  58. 19 0
      packages/ckeditor5-link/theme/linkimage.css
  59. 24 11
      packages/ckeditor5-page-break/src/pagebreakediting.js
  60. 30 2
      packages/ckeditor5-page-break/tests/pagebreakediting.js
  61. 1 0
      packages/ckeditor5-paste-from-office/package.json
  62. 16 0
      packages/ckeditor5-paste-from-office/tests/_data/page-break/index.js
  63. 738 0
      packages/ckeditor5-paste-from-office/tests/_data/page-break/page-break/input.html
  64. 3 0
      packages/ckeditor5-paste-from-office/tests/_data/page-break/page-break/model.html
  65. BIN
      packages/ckeditor5-paste-from-office/tests/_data/page-break/page-break/page-break.docx
  66. 3 1
      packages/ckeditor5-paste-from-office/tests/_utils/fixtures.js
  67. 10 0
      packages/ckeditor5-paste-from-office/tests/data/integration.js
  68. 3 2
      packages/ckeditor5-paste-from-office/tests/manual/integration.js
  69. 6 6
      packages/ckeditor5-table/tests/tableclipboard-paste.js
  70. 9 0
      packages/ckeditor5-theme-lark/theme/ckeditor5-link/linkimage.css
  71. 3 3
      packages/ckeditor5-typing/src/inputcommand.js
  72. 4 1
      packages/ckeditor5-typing/src/utils/injectunsafekeystrokeshandling.js
  73. 24 2
      packages/ckeditor5-typing/tests/inputcommand.js
  74. 46 3
      packages/ckeditor5-typing/tests/utils/injectunsafekeystrokeshandling.js
  75. 116 49
      packages/ckeditor5-utils/src/collection.js
  76. 306 0
      packages/ckeditor5-utils/tests/collection.js
  77. 1 0
      scripts/release/changelog.js
  78. 426 570
      yarn.lock

+ 1 - 1
CHANGELOG.md

@@ -19,7 +19,7 @@ New features were also accompanied by a set of bug fixes, to name a few:
 * [Autoformatting will no longer change formatting when typing in an inline code](https://github.com/ckeditor/ckeditor5/issues/1239).
 * Editor will no longer [crash if there's a HTML comment in the source data](https://github.com/ckeditor/ckeditor5/issues/5734).
 
-TODO: Add a link to the blog post.
+Read more in the blog post: https://ckeditor.com/blog/CKEditor-5-v20.0.0-with-linking-images-and-multi-cell-comments-released/
 
 ### Collaboration features
 

+ 4 - 2
docs/framework/guides/architecture/editing-engine.md

@@ -159,9 +159,11 @@ Markers are a special type of ranges.
 * They can only be created and changed through the {@link module:engine/model/writer~Writer model writer}.
 * They can be synchronized over the network with other collaborating clients.
 * They are automatically updated when the document's structure is changed.
-* They can be converted to attributes or elements in the [view](#view).
+* They can be converted to the editing view, to show them in the editor (as {@link module:engine/conversion/downcasthelpers~DowncastHelpers#markerToHighlight highlights} or {@link module:engine/conversion/downcasthelpers~DowncastHelpers#markerToElement elements}).
+* They can be {@link module:engine/conversion/downcasthelpers~DowncastHelpers#markerToData converted to the data view}, to store them with the document data.
+* They can be {@link module:engine/conversion/upcasthelpers~UpcastHelpers#dataToMarker loaded with the document data}.
 
-This makes them ideal for storing and maintaining additional data in the model — such as comments, selections of other users, etc.
+Markers are ideal for storing and maintaining additional data related to portions of the document — such as comments or selections of other users.
 
 ### Schema
 

+ 5 - 5
package.json

@@ -77,11 +77,11 @@
   },
   "devDependencies": {
     "@ckeditor/ckeditor5-comments": "^20.0.0",
-    "@ckeditor/ckeditor5-dev-docs": "^21.0.0",
-    "@ckeditor/ckeditor5-dev-env": "^21.0.0",
-    "@ckeditor/ckeditor5-dev-tests": "^21.0.0",
-    "@ckeditor/ckeditor5-dev-utils": "^21.0.0",
-    "@ckeditor/ckeditor5-dev-webpack-plugin": "^21.0.0",
+    "@ckeditor/ckeditor5-dev-docs": "^22.0.0",
+    "@ckeditor/ckeditor5-dev-env": "^22.0.0",
+    "@ckeditor/ckeditor5-dev-tests": "^22.0.0",
+    "@ckeditor/ckeditor5-dev-utils": "^22.0.0",
+    "@ckeditor/ckeditor5-dev-webpack-plugin": "^22.0.0",
     "@ckeditor/ckeditor5-inspector": "^2.1.0",
     "@ckeditor/ckeditor5-react": "^2.1.0",
     "@ckeditor/ckeditor5-real-time-collaboration": "^20.0.0",

+ 1 - 0
packages/ckeditor5-core/theme/icons/object-size-full.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M2.5 17v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zM1 15.5v1H0v-1h1zm19 0v1h-1v-1h1zm-19-2v1H0v-1h1zm19 0v1h-1v-1h1zm-19-2v1H0v-1h1zm19 0v1h-1v-1h1zm-19-2v1H0v-1h1zm19 0v1h-1v-1h1zm-19-2v1H0v-1h1zm19 0v1h-1v-1h1zm-19-2v1H0v-1h1zm19 0v1h-1v-1h1zm0-2v1h-1v-1h1zm-19 0v1H0v-1h1zM14.5 2v1h-1V2h1zm2 0v1h-1V2h1zm2 0v1h-1V2h1zm-8 0v1h-1V2h1zm-2 0v1h-1V2h1zm-2 0v1h-1V2h1zm-2 0v1h-1V2h1zm8 0v1h-1V2h1zm-10 0v1h-1V2h1z"/><path d="M18.095 2H1.905C.853 2 0 2.895 0 4v12c0 1.105.853 2 1.905 2h16.19C19.147 18 20 17.105 20 16V4c0-1.105-.853-2-1.905-2zm0 1.5c.263 0 .476.224.476.5v12c0 .276-.213.5-.476.5H1.905a.489.489 0 0 1-.476-.5V4c0-.276.213-.5.476-.5h16.19z"/></svg>

+ 1 - 0
packages/ckeditor5-core/theme/icons/object-size-large.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M2.5 17v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zM1 15.5v1H0v-1h1zm19 0v1h-1v-1h1zm-19-2v1H0v-1h1zm19 0v1h-1v-1h1zm-19-2v1H0v-1h1zm19 0v1h-1v-1h1zm-19-2v1H0v-1h1zm19 0v1h-1v-1h1zm-19-2v1H0v-1h1zm19 0v1h-1v-1h1zm-19-2v1H0v-1h1zm19 0v1h-1v-1h1zm0-2v1h-1v-1h1zm-19 0v1H0v-1h1zM14.5 2v1h-1V2h1zm2 0v1h-1V2h1zm2 0v1h-1V2h1zm-8 0v1h-1V2h1zm-2 0v1h-1V2h1zm-2 0v1h-1V2h1zm-2 0v1h-1V2h1zm8 0v1h-1V2h1zm-10 0v1h-1V2h1z"/><path d="M13 6H2a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2zm0 1.5a.5.5 0 0 1 .5.5v8a.5.5 0 0 1-.5.5H2a.5.5 0 0 1-.5-.5V8a.5.5 0 0 1 .5-.5h11z"/></svg>

+ 1 - 0
packages/ckeditor5-core/theme/icons/object-size-medium.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M2.5 17v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zM1 15.5v1H0v-1h1zm19 0v1h-1v-1h1zm-19-2v1H0v-1h1zm19 0v1h-1v-1h1zm-19-2v1H0v-1h1zm19 0v1h-1v-1h1zm-19-2v1H0v-1h1zm19 0v1h-1v-1h1zm-19-2v1H0v-1h1zm19 0v1h-1v-1h1zm-19-2v1H0v-1h1zm19 0v1h-1v-1h1zm0-2v1h-1v-1h1zm-19 0v1H0v-1h1zM14.5 2v1h-1V2h1zm2 0v1h-1V2h1zm2 0v1h-1V2h1zm-8 0v1h-1V2h1zm-2 0v1h-1V2h1zm-2 0v1h-1V2h1zm-2 0v1h-1V2h1zm8 0v1h-1V2h1zm-10 0v1h-1V2h1z"/><path d="M10 8H2a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-6a2 2 0 0 0-2-2zm0 1.5a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-.5.5H2a.5.5 0 0 1-.5-.5v-6a.5.5 0 0 1 .5-.5h8z"/></svg>

+ 1 - 0
packages/ckeditor5-core/theme/icons/object-size-small.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M2.5 17v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zM1 15.5v1H0v-1h1zm19 0v1h-1v-1h1zm-19-2v1H0v-1h1zm19 0v1h-1v-1h1zm-19-2v1H0v-1h1zm19 0v1h-1v-1h1zm-19-2v1H0v-1h1zm19 0v1h-1v-1h1zm-19-2v1H0v-1h1zm19 0v1h-1v-1h1zm-19-2v1H0v-1h1zm19 0v1h-1v-1h1zm0-2v1h-1v-1h1zm-19 0v1H0v-1h1zM14.5 2v1h-1V2h1zm2 0v1h-1V2h1zm2 0v1h-1V2h1zm-8 0v1h-1V2h1zm-2 0v1h-1V2h1zm-2 0v1h-1V2h1zm-2 0v1h-1V2h1zm8 0v1h-1V2h1zm-10 0v1h-1V2h1z"/><path d="M7 10H2a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h5a2 2 0 0 0 2-2v-4a2 2 0 0 0-2-2zm0 1.5a.5.5 0 0 1 .5.5v4a.5.5 0 0 1-.5.5H2a.5.5 0 0 1-.5-.5v-4a.5.5 0 0 1 .5-.5h5z"/></svg>

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

@@ -90,7 +90,8 @@ export default class DataController {
 		 * @member {module:engine/conversion/downcastdispatcher~DowncastDispatcher}
 		 */
 		this.downcastDispatcher = new DowncastDispatcher( {
-			mapper: this.mapper
+			mapper: this.mapper,
+			schema: model.schema
 		} );
 		this.downcastDispatcher.on( 'insert:$text', insertText(), { priority: 'lowest' } );
 

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

@@ -65,7 +65,8 @@ export default class EditingController {
 		 * @member {module:engine/conversion/downcastdispatcher~DowncastDispatcher} #downcastDispatcher
 		 */
 		this.downcastDispatcher = new DowncastDispatcher( {
-			mapper: this.mapper
+			mapper: this.mapper,
+			schema: model.schema
 		} );
 
 		const doc = this.model.document;

+ 6 - 0
packages/ckeditor5-engine/src/conversion/downcastdispatcher.js

@@ -658,6 +658,12 @@ function shouldMarkerChangeBeConverted( modelPosition, marker, mapper ) {
  * @member {module:engine/conversion/mapper~Mapper} #mapper
  */
 
+/**
+ * The {@link module:engine/model/schema~Schema} instance set for the model that is downcast.
+ *
+ * @member {module:engine/model/schema~Schema} #schema
+ */
+
 /**
  * The {@link module:engine/view/downcastwriter~DowncastWriter} instance used to manipulate data during conversion.
  *

+ 283 - 6
packages/ckeditor5-engine/src/conversion/downcasthelpers.js

@@ -239,22 +239,25 @@ export default class DowncastHelpers extends ConversionHelpers {
 	/**
 	 * Model marker to view element conversion helper.
 	 *
+	 * **Note**: This method should be used only for editing downcast. For data downcast, use
+	 * {@link #markerToData `#markerToData()`} that produces valid HTML data.
+	 *
 	 * 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( {
+	 *		editor.conversion.for( 'editingDowncast' ).markerToElement( {
 	 *			model: 'search',
 	 *			view: 'marker-search'
 	 *		} );
 	 *
-	 *		editor.conversion.for( 'downcast' ).markerToElement( {
+	 *		editor.conversion.for( 'editingDowncast' ).markerToElement( {
 	 *			model: 'search',
 	 *			view: 'search-result',
 	 *			converterPriority: 'high'
 	 *		} );
 	 *
-	 *		editor.conversion.for( 'downcast' ).markerToElement( {
+	 *		editor.conversion.for( 'editingDowncast' ).markerToElement( {
 	 *			model: 'search',
 	 *			view: {
 	 *				name: 'span',
@@ -264,7 +267,7 @@ export default class DowncastHelpers extends ConversionHelpers {
 	 *			}
 	 *		} );
 	 *
-	 *		editor.conversion.for( 'downcast' ).markerToElement( {
+	 *		editor.conversion.for( 'editingDowncast' ).markerToElement( {
 	 *			model: 'search',
 	 *			view: ( markerData, viewWriter ) => {
 	 *				return viewWriter.createUIElement( 'span', {
@@ -282,8 +285,6 @@ export default class DowncastHelpers extends ConversionHelpers {
 	 * 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.
 	 *
@@ -357,6 +358,119 @@ export default class DowncastHelpers extends ConversionHelpers {
 	markerToHighlight( config ) {
 		return this.add( downcastMarkerToHighlight( config ) );
 	}
+
+	/**
+	 * Model marker converter for data downcast.
+	 *
+	 * This conversion creates a representation for model marker boundaries in the view:
+	 *
+	 * * if the marker boundary is at a position where text nodes are allowed, then a view element with specified tag name
+	 * and `name` attribute is added at that position,
+	 * * in other cases, a specified attribute is set on a view element that is before/after marker boundary.
+	 *
+	 * Typically, the marker names use `group:uniqueId:otherData` convention. For example: `comment:e34zfk9k2n459df53sjl34:zx32c`.
+	 * The default configuration for this conversion is that the first part is `group` part and the rest of
+	 * the marker name becomes `name` part.
+	 *
+	 * Tag and attribute names and values are generated from the marker name:
+	 *
+	 * * templates for attributes are `data-[group]-start-before="[name]"`, `data-[group]-start-after="[name]"`,
+	 * `data-[group]-end-before="[name]"` and `data-[group]-end-after="[name]"`,
+	 * * templates for view elements are `<[group]-start name="[name]">` and `<[group]-end name="[name]">`.
+	 *
+	 * Attributes mark whether given marker start or end boundary is before or after given element.
+	 * Attributes `data-[group]-start-before` and `data-[group]-end-after` are favored.
+	 * The other two are used when the former two cannot be used.
+	 *
+	 * The conversion configuration can take a function that will generate different group and name parts.
+	 * If such function is set as the `config.view` parameter, it is passed a marker name and it is expected to return an object with two
+	 * properties: `group` and `name`. If the function returns falsy value, the conversion will not take place.
+	 *
+	 * Basic usage:
+	 *
+	 *		// Using the default conversion.
+	 *		// In this case, all markers which name starts with 'comment:' will be converted.
+	 *		// The `group` parameter will be set to `comment`.
+	 *		// The `name` parameter will be the rest of the marker name (without `:`).
+	 *		editor.conversion.for( 'dataDowncast' ).markerToData( {
+	 *			model: 'comment'
+	 *		} );
+	 *
+	 * An example of a view that may be generated by this conversion (assuming a marker with name `comment:commentId:uid` marked by `[]`):
+	 *
+	 *		// Model:
+	 *		<paragraph>Foo[bar</paragraph>
+	 *		<image src="abc.jpg"></image>]
+	 *
+	 *		// View:
+	 *		<p>Foo<comment-start name="commentId:uid"></comment-start>bar</p>
+	 *		<figure data-comment-end-after="commentId:uid" class="image"><img src="abc.jpg" /></figure>
+	 *
+	 * In the example above, the comment starts before "bar" and ends after the image.
+	 *
+	 * If `name` part is empty, following view may be generated:
+	 *
+	 *		<p>Foo <myMarker-start></myMarker-start>bar</p>
+	 *		<figure data-myMarker-end-after="" class="image"><img src="abc.jpg" /></figure>
+	 *
+	 * **Note:** situation when some markers have `name` part and some don't is incorrect and should be avoided.
+	 *
+	 * Examples where `data-group-start-after` and `data-group-end-before` are used:
+	 *
+	 *		// Model:
+	 *		<blockQuote>[]<paragraph>Foo</paragraph></blockQuote>
+	 *
+	 * 		// View:
+	 *		<blockquote><p data-group-end-before="name" data-group-start-before="name">Foo</p></blockquote>
+	 *
+	 * Similarly, when marker is collapsed after the last element:
+	 *
+	 *		// Model:
+	 *		<blockQuote><paragraph>Foo</paragraph>[]</blockQuote>
+	 *
+	 *		// View:
+	 *		<blockquote><p data-group-end-after="name" data-group-start-after="name">Foo</p></blockquote>
+	 *
+	 * When there are multiple markers from the same group stored in the same attribute of the same element, their
+	 * name parts are put together in the attribute value, for example: `data-group-start-before="name1,name2,name3"`.
+	 *
+	 * Other examples of usage:
+	 *
+	 *		// Using custom function which is the same as the default conversion:
+	 *		editor.conversion.for( 'dataDowncast' ).markerToData( {
+	 *			model: 'comment'
+	 *			view: markerName => ( {
+	 *				group: 'comment',
+	 *				name: markerName.substr( 8 ) // Removes 'comment:' part.
+	 *			} )
+	 *		} );
+	 *
+	 *		// Using converter priority:
+	 *		editor.conversion.for( 'dataDowncast' ).markerToData( {
+	 *			model: 'comment'
+	 *			view: markerName => ( {
+	 *				group: 'comment',
+	 *				name: markerName.substr( 8 ) // Removes 'comment:' part.
+	 *			} ),
+	 *			converterPriority: 'high'
+	 *		} );
+	 *
+	 * 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 #markerToData
+	 * @param {Object} config Conversion configuration.
+	 * @param {String} config.model The name of the model marker (or model marker group) to convert.
+	 * @param {Function} [config.view] Function that takes the model marker name as a parameter and returns an object with `group`
+	 * and `name` properties.
+	 * @param {module:utils/priorities~PriorityString} [config.converterPriority='normal'] Converter priority.
+	 * @returns {module:engine/conversion/downcasthelpers~DowncastHelpers}
+	 */
+	markerToData( config ) {
+		return this.add( downcastMarkerToData( config ) );
+	}
 }
 
 /**
@@ -755,6 +869,141 @@ function removeUIElement() {
 	};
 }
 
+// Function factory that creates a default converter for model markers.
+//
+// See {@link DowncastHelpers#markerToData} for more information what type of view is generated.
+//
+// This converter binds created UI elements and affected view elements with the marker name
+// using {@link module:engine/conversion/mapper~Mapper#bindElementToMarker}.
+//
+// @returns {Function} Add marker converter.
+function insertMarkerData( viewCreator ) {
+	return ( evt, data, conversionApi ) => {
+		const viewMarkerData = viewCreator( data.markerName );
+
+		if ( !viewMarkerData ) {
+			return;
+		}
+
+		const markerRange = data.markerRange;
+
+		if ( !conversionApi.consumable.consume( markerRange, evt.name ) ) {
+			return;
+		}
+
+		// Adding closing data first to keep the proper order in the view.
+		handleMarkerBoundary( markerRange, false, conversionApi, data, viewMarkerData );
+		handleMarkerBoundary( markerRange, true, conversionApi, data, viewMarkerData );
+
+		evt.stop();
+	};
+}
+
+// Helper function for `insertMarkerData()` that marks a marker boundary at the beginning or end of given `range`.
+function handleMarkerBoundary( range, isStart, conversionApi, data, viewMarkerData ) {
+	const modelPosition = isStart ? range.start : range.end;
+	const canInsertElement = conversionApi.schema.checkChild( modelPosition, '$text' );
+
+	if ( canInsertElement ) {
+		const viewPosition = conversionApi.mapper.toViewPosition( modelPosition );
+
+		insertMarkerAsElement( viewPosition, isStart, conversionApi, data, viewMarkerData );
+	} else {
+		let modelElement;
+		let isBefore;
+
+		// If possible, we want to add `data-group-start-before` and `data-group-end-after` attributes.
+		// Below `if` is constructed in a way that will favor adding these attributes.
+		//
+		// Also, I assume that there will be always an element either after or before the position.
+		// If not, then it is a case when we are not in a position where text is allowed and also there are no elements around...
+		if ( isStart && modelPosition.nodeAfter || !isStart && !modelPosition.nodeBefore ) {
+			modelElement = modelPosition.nodeAfter;
+			isBefore = true;
+		} else {
+			modelElement = modelPosition.nodeBefore;
+			isBefore = false;
+		}
+
+		const viewElement = conversionApi.mapper.toViewElement( modelElement );
+
+		insertMarkerAsAttribute( viewElement, isStart, isBefore, conversionApi, data, viewMarkerData );
+	}
+}
+
+// Helper function for `insertMarkerData()` that marks a marker boundary in the view as an attribute on a view element.
+function insertMarkerAsAttribute( viewElement, isStart, isBefore, conversionApi, data, viewMarkerData ) {
+	const attributeName = `data-${ viewMarkerData.group }-${ isStart ? 'start' : 'end' }-${ isBefore ? 'before' : 'after' }`;
+
+	const markerNames = viewElement.hasAttribute( attributeName ) ? viewElement.getAttribute( attributeName ).split( ',' ) : [];
+
+	// Adding marker name at the beginning to have the same order in the attribute as there is with marker elements.
+	markerNames.unshift( viewMarkerData.name );
+
+	conversionApi.writer.setAttribute( attributeName, markerNames.join( ',' ), viewElement );
+	conversionApi.mapper.bindElementToMarker( viewElement, data.markerName );
+}
+
+// Helper function for `insertMarkerData()` that marks a marker boundary in the view as a separate view ui element.
+function insertMarkerAsElement( position, isStart, conversionApi, data, viewMarkerData ) {
+	const viewElementName = `${ viewMarkerData.group }-${ isStart ? 'start' : 'end' }`;
+
+	const attrs = viewMarkerData.name ? { 'name': viewMarkerData.name } : null;
+	const viewElement = conversionApi.writer.createUIElement( viewElementName, attrs );
+
+	conversionApi.writer.insert( position, viewElement );
+	conversionApi.mapper.bindElementToMarker( viewElement, data.markerName );
+}
+
+// Function factory that creates a converter for removing a model marker data added by the {@link #insertMarkerData} converter.
+//
+// @returns {Function} Remove marker converter.
+function removeMarkerData( viewCreator ) {
+	return ( evt, data, conversionApi ) => {
+		const viewData = viewCreator( data.markerName );
+
+		if ( !viewData ) {
+			return;
+		}
+
+		const elements = conversionApi.mapper.markerNameToElements( data.markerName );
+
+		if ( !elements ) {
+			return;
+		}
+
+		for ( const element of elements ) {
+			conversionApi.mapper.unbindElementFromMarkerName( element, data.markerName );
+
+			if ( element.is( 'containerElement' ) ) {
+				removeMarkerFromAttribute( `data-${ viewData.group }-start-before`, element );
+				removeMarkerFromAttribute( `data-${ viewData.group }-start-after`, element );
+				removeMarkerFromAttribute( `data-${ viewData.group }-end-before`, element );
+				removeMarkerFromAttribute( `data-${ viewData.group }-end-after`, element );
+			} else {
+				conversionApi.writer.clear( conversionApi.writer.createRangeOn( element ), element );
+			}
+		}
+
+		conversionApi.writer.clearClonedElementsGroup( data.markerName );
+
+		evt.stop();
+
+		function removeMarkerFromAttribute( attributeName, element ) {
+			if ( element.hasAttribute( attributeName ) ) {
+				const markerNames = new Set( element.getAttribute( attributeName ).split( ',' ) );
+				markerNames.delete( viewData.name );
+
+				if ( markerNames.size == 0 ) {
+					conversionApi.writer.removeAttribute( attributeName, element );
+				} else {
+					conversionApi.writer.setAttribute( attributeName, Array.from( markerNames ).join( ',' ), element );
+				}
+			}
+		}
+	};
+}
+
 // 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
@@ -1179,6 +1428,34 @@ function downcastMarkerToElement( config ) {
 	};
 }
 
+// Model marker to view data conversion helper.
+//
+// See {@link ~DowncastHelpers#markerToData `markerToData()` downcast helper} to learn more.
+//
+// @param {Object} config
+// @param {String} config.model
+// @param {Function} [config.view]
+// @param {module:utils/priorities~PriorityString} [config.converterPriority='normal']
+// @returns {Function} Conversion helper.
+function downcastMarkerToData( config ) {
+	config = cloneDeep( config );
+
+	const group = config.model;
+
+	// Default conversion.
+	if ( !config.view ) {
+		config.view = markerName => ( {
+			group,
+			name: markerName.substr( config.model.length + 1 )
+		} );
+	}
+
+	return dispatcher => {
+		dispatcher.on( 'addMarker:' + group, insertMarkerData( config.view ), { priority: config.converterPriority || 'normal' } );
+		dispatcher.on( 'removeMarker:' + group, removeMarkerData( config.view ), { priority: config.converterPriority || 'normal' } );
+	};
+}
+
 // Model marker to highlight conversion helper.
 //
 // See {@link ~DowncastHelpers#markerToElement `.markerToElement()` downcast helper} for examples.

+ 194 - 3
packages/ckeditor5-engine/src/conversion/upcasthelpers.js

@@ -7,6 +7,9 @@ import Matcher from '../view/matcher';
 import ConversionHelpers from './conversionhelpers';
 
 import { cloneDeep } from 'lodash-es';
+import { attachLinkToDocumentation } from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
+
+/* global console */
 
 /**
  * Contains {@link module:engine/view/view view} to {@link module:engine/model/model model} converters for
@@ -285,6 +288,8 @@ export default class UpcastHelpers extends ConversionHelpers {
 	/**
 	 * View element to model marker conversion helper.
 	 *
+	 * **Note**: This method was deprecated. Please use {@link #dataToMarker} instead.
+	 *
 	 * 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
@@ -319,6 +324,7 @@ export default class UpcastHelpers extends ConversionHelpers {
 	 * See {@link module:engine/conversion/conversion~Conversion#for `conversion.for()`} to learn how to add a converter
 	 * to the conversion process.
 	 *
+	 * @deprecated
 	 * @method #elementToMarker
 	 * @param {Object} config Conversion configuration.
 	 * @param {module:engine/view/matcher~MatcherPattern} config.view Pattern matching all view elements which should be converted.
@@ -328,8 +334,93 @@ export default class UpcastHelpers extends ConversionHelpers {
 	 * @returns {module:engine/conversion/upcasthelpers~UpcastHelpers}
 	 */
 	elementToMarker( config ) {
+		/**
+		 * The {@link module:engine/conversion/upcasthelpers~UpcastHelpers#elementToMarker `UpcastHelpers#elementToMarker()`}
+		 * method has been deprecated and will be removed in the near future.
+		 * Please use {@link module:engine/conversion/upcasthelpers~UpcastHelpers#dataToMarker `UpcastHelpers#dataToMarker()`} instead.
+		 *
+		 * @error upcast-helpers-element-to-marker-deprecated
+		 */
+		console.warn(
+			attachLinkToDocumentation(
+				'upcast-helpers-element-to-marker-deprecated: ' +
+				'The UpcastHelpers#elementToMarker() method has been deprecated and will be removed in the near future. ' +
+				'Please use UpcastHelpers#dataToMarker() instead.'
+			)
+		);
+
 		return this.add( upcastElementToMarker( config ) );
 	}
+
+	/**
+	 * View to model marker conversion helper.
+	 *
+	 * Converts view data created by {@link module:engine/conversion/downcasthelpers~DowncastHelpers#markerToData `#markerToData()`}
+	 * back to a model marker.
+	 *
+	 * This converter looks for specific view elements and view attributes that mark marker boundaries. See
+	 * {@link module:engine/conversion/downcasthelpers~DowncastHelpers#markerToData `#markerToData()`} to learn what view data
+	 * is expected by this converter.
+	 *
+	 * The `config.view` property is equal to the marker group name to convert.
+	 *
+	 * By default, this converter creates markers with `group:name` name convention (to match the default `markerToData` conversion).
+	 *
+	 * The conversion configuration can take a function that will generate a marker name.
+	 * If such function is set as the `config.model` parameter, it is passed the `name` part from the view element or attribute and it is
+	 * expected to return a string with the marker name.
+	 *
+	 * Basic usage:
+	 *
+	 *		// Using the default conversion.
+	 *		// In this case, all markers from `comment` group will be converted.
+	 *		// The conversion will look for `<comment-start>` and `<comment-end>` tags and
+	 *		// `data-comment-start-before`, `data-comment-start-after`,
+	 *		// `data-comment-end-before` and `data-comment-end-after` attributes.
+	 *		editor.conversion.for( 'upcast' ).dataToMarker( {
+	 *			view: 'comment'
+	 *		} );
+	 *
+	 * An example of a model that may be generated by this conversion:
+	 *
+	 *		// View:
+	 *		<p>Foo<comment-start name="commentId:uid"></comment-start>bar</p>
+	 *		<figure data-comment-end-after="commentId:uid" class="image"><img src="abc.jpg" /></figure>
+	 *
+	 *		// Model:
+	 *		<paragraph>Foo[bar</paragraph>
+	 *		<image src="abc.jpg"></image>]
+	 *
+	 * Where `[]` are boundaries of a marker that will receive `comment:commentId:uid` name.
+	 *
+	 * Other examples of usage:
+	 *
+	 *		// Using custom function which is the same as the default conversion:
+	 *		editor.conversion.for( 'upcast' ).dataToMarker( {
+	 *			view: 'comment',
+	 *			model: name => 'comment:' + name,
+	 *		} );
+	 *
+	 *		// Using converter priority:
+	 *		editor.conversion.for( 'upcast' ).dataToMarker( {
+	 *			view: 'comment',
+	 *			model: name => 'comment:' + name,
+	 *			converterPriority: 'high'
+	 *		} );
+	 *
+	 * See {@link module:engine/conversion/conversion~Conversion#for `conversion.for()`} to learn how to add a converter
+	 * to the conversion process.
+	 *
+	 * @method #dataToMarker
+	 * @param {Object} config Conversion configuration.
+	 * @param {String} config.view Marker group name to convert.
+	 * @param {Function} [config.model] Function that takes `name` part from the view element or attribute and returns the marker name.
+	 * @param {module:utils/priorities~PriorityString} [config.converterPriority='normal'] Converter priority.
+	 * @returns {module:engine/conversion/upcasthelpers~UpcastHelpers}
+	 */
+	dataToMarker( config ) {
+		return this.add( upcastDataToMarker( config ) );
+	}
 }
 
 /**
@@ -514,11 +605,91 @@ function upcastAttributeToAttribute( config ) {
 function upcastElementToMarker( config ) {
 	config = cloneDeep( config );
 
-	normalizeToMarkerConfig( config );
+	normalizeElementToMarkerConfig( config );
 
 	return upcastElementToElement( config );
 }
 
+// View data to model marker conversion helper.
+//
+// See {@link ~UpcastHelpers#dataToMarker} to learn more.
+//
+// @param {Object} config
+// @param {String} config.view
+// @param {Function} [config.model]
+// @param {module:utils/priorities~PriorityString} [config.converterPriority='normal']
+// @returns {Function} Conversion helper.
+function upcastDataToMarker( config ) {
+	config = cloneDeep( config );
+
+	// Default conversion.
+	if ( !config.model ) {
+		config.model = name => {
+			return name ? config.view + ':' + name : config.view;
+		};
+	}
+
+	const converterStart = prepareToElementConverter( normalizeDataToMarkerConfig( config, 'start' ) );
+	const converterEnd = prepareToElementConverter( normalizeDataToMarkerConfig( config, 'end' ) );
+
+	return dispatcher => {
+		dispatcher.on( 'element:' + config.view + '-start', converterStart, { priority: config.converterPriority || 'normal' } );
+		dispatcher.on( 'element:' + config.view + '-end', converterEnd, { priority: config.converterPriority || 'normal' } );
+
+		// This is attribute upcast so it has to be done after the element upcast.
+		dispatcher.on( 'element', upcastAttributeToMarker( config ), { priority: config.converterPriority || 'low' } );
+	};
+}
+
+// Function factory, returns a callback function which converts view attributes to a model marker.
+//
+// The converter looks for elements with `data-group-start-before`, `data-group-start-after`, `data-group-end-before`
+// and `data-group-end-after` attributes and inserts `$marker` model elements before/after those elements.
+// `group` part is specified in `config.view`.
+//
+// @param {Object} config
+// @param {String} config.view
+// @param {Function} [config.model]
+// @returns {Function} Marker converter.
+function upcastAttributeToMarker( config ) {
+	return ( evt, data, conversionApi ) => {
+		const attrName = `data-${ config.view }`;
+
+		if ( conversionApi.consumable.consume( data.viewItem, { attributes: attrName + '-end-after' } ) ) {
+			addMarkerElements( data.modelRange.end, data.viewItem.getAttribute( attrName + '-end-after' ).split( ',' ) );
+		}
+
+		if ( conversionApi.consumable.consume( data.viewItem, { attributes: attrName + '-start-after' } ) ) {
+			addMarkerElements( data.modelRange.end, data.viewItem.getAttribute( attrName + '-start-after' ).split( ',' ) );
+		}
+
+		if ( conversionApi.consumable.consume( data.viewItem, { attributes: attrName + '-end-before' } ) ) {
+			addMarkerElements( data.modelRange.start, data.viewItem.getAttribute( attrName + '-end-before' ).split( ',' ) );
+		}
+
+		if ( conversionApi.consumable.consume( data.viewItem, { attributes: attrName + '-start-before' } ) ) {
+			addMarkerElements( data.modelRange.start, data.viewItem.getAttribute( attrName + '-start-before' ).split( ',' ) );
+		}
+
+		function addMarkerElements( position, markerViewNames ) {
+			for ( const markerViewName of markerViewNames ) {
+				const markerName = config.model( markerViewName );
+				const element = conversionApi.writer.createElement( '$marker', { 'data-name': markerName } );
+
+				conversionApi.writer.insert( element, position );
+
+				if ( data.modelCursor.isEqual( position ) ) {
+					data.modelCursor = data.modelCursor.getShiftedBy( 1 );
+				} else {
+					data.modelCursor = data.modelCursor._getTransformedByInsertion( position, 1 );
+				}
+
+				data.modelRange = data.modelRange._getTransformedByInsertion( position, 1 )[ 0 ];
+			}
+		}
+	};
+}
+
 // Helper function for from-view-element conversion. Checks if `config.view` directly specifies converted view element's name
 // and if so, returns it.
 //
@@ -738,10 +909,10 @@ function setAttributeOn( modelRange, modelAttribute, shallow, conversionApi ) {
 }
 
 // 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.
+// function and converts it to a format that is supported by `upcastElementToElement()` function.
 //
 // @param {Object} config Conversion configuration.
-function normalizeToMarkerConfig( config ) {
+function normalizeElementToMarkerConfig( config ) {
 	const oldModel = config.model;
 
 	config.model = ( viewElement, modelWriter ) => {
@@ -750,3 +921,23 @@ function normalizeToMarkerConfig( config ) {
 		return modelWriter.createElement( '$marker', { 'data-name': markerName } );
 	};
 }
+
+// Helper function for upcasting-to-marker conversion. Takes the config in a format requested by `upcastDataToMarker()`
+// function and converts it to a format that is supported by `upcastElementToElement()` function.
+//
+// @param {Object} config Conversion configuration.
+function normalizeDataToMarkerConfig( config, type ) {
+	const configForElements = {};
+
+	// Upcast <markerGroup-start> and <markerGroup-end> elements.
+	configForElements.view = config.view + '-' + type;
+
+	configForElements.model = ( viewElement, modelWriter ) => {
+		const viewName = viewElement.getAttribute( 'name' );
+		const markerName = config.model( viewName );
+
+		return modelWriter.createElement( '$marker', { 'data-name': markerName } );
+	};
+
+	return configForElements;
+}

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

@@ -92,6 +92,16 @@ export default class MarkerCollection {
 	 */
 	_set( markerOrName, range, managedUsingOperations = false, affectsData = false ) {
 		const markerName = markerOrName instanceof Marker ? markerOrName.name : markerOrName;
+
+		if ( markerName.includes( ',' ) ) {
+			/**
+			 * Marker name cannot contain the "," character.
+			 *
+			 * @error markercollection-incorrect-marker-name
+			 */
+			throw new CKEditorError( 'markercollection-incorrect-marker-name: Marker name cannot contain "," character.', this );
+		}
+
 		const oldMarker = this._markers.get( markerName );
 
 		if ( oldMarker ) {

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

@@ -342,8 +342,8 @@ describe( 'DataController', () => {
 
 			data.set( 'foo' );
 
-			downcastHelpers.markerToElement( { model: 'marker', view: 'marker' } );
-			upcastHelpers.elementToMarker( { view: 'marker', model: 'marker' } );
+			downcastHelpers.markerToData( { model: 'marker' } );
+			upcastHelpers.dataToMarker( { view: 'marker' } );
 
 			model.change( writer => {
 				writer.addMarker( 'marker', { range: writer.createRangeIn( modelDocument.getRoot() ), usingOperation: true } );

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

@@ -996,6 +996,487 @@ describe( 'DowncastHelpers', () => {
 		} );
 	} );
 
+	describe( 'markerToData()', () => {
+		let root;
+
+		beforeEach( () => {
+			root = model.document.getRoot();
+
+			model.schema.register( 'paragraph', { inheritAllFrom: '$block' } );
+			downcastHelpers.elementToElement( { model: 'paragraph', view: 'p' } );
+		} );
+
+		it( 'should be chainable', () => {
+			expect( downcastHelpers.markerToData( { model: 'search' } ) ).to.equal( downcastHelpers );
+		} );
+
+		it( 'default conversion, inside text, non-collapsed, no name', () => {
+			downcastHelpers.markerToData( { model: 'search' } );
+
+			setModelData( model, '<paragraph>Fo[ob]ar</paragraph>' );
+
+			model.change( writer => {
+				writer.addMarker( 'search', { range: model.document.selection.getFirstRange(), usingOperation: false } );
+			} );
+
+			expectResult( '<p>Fo<search-start></search-start>ob<search-end></search-end>ar</p>' );
+
+			model.change( writer => {
+				writer.removeMarker( 'search' );
+			} );
+
+			expectResult( '<p>Foobar</p>' );
+		} );
+
+		it( 'default conversion, inside text, non-collapsed, name', () => {
+			downcastHelpers.markerToData( { model: 'group' } );
+
+			setModelData( model, '<paragraph>Fo[ob]ar</paragraph>' );
+
+			model.change( writer => {
+				writer.addMarker( 'group:foo:bar:baz', { range: model.document.selection.getFirstRange(), usingOperation: false } );
+			} );
+
+			expectResult( '<p>Fo<group-start name="foo:bar:baz"></group-start>ob<group-end name="foo:bar:baz"></group-end>ar</p>' );
+
+			model.change( writer => {
+				writer.removeMarker( 'group:foo:bar:baz' );
+			} );
+
+			expectResult( '<p>Foobar</p>' );
+		} );
+
+		it( 'default conversion, inside text, collapsed, no name', () => {
+			downcastHelpers.markerToData( { model: 'search' } );
+
+			setModelData( model, '<paragraph>Foo[]bar</paragraph>' );
+
+			model.change( writer => {
+				writer.addMarker( 'search', { range: model.document.selection.getFirstRange(), usingOperation: false } );
+			} );
+
+			expectResult( '<p>Foo<search-start></search-start><search-end></search-end>bar</p>' );
+
+			model.change( writer => {
+				writer.removeMarker( 'search' );
+			} );
+
+			expectResult( '<p>Foobar</p>' );
+		} );
+
+		it( 'default conversion, inside text, collapsed, multiple markers, no name', () => {
+			downcastHelpers.markerToData( { model: 'group' } );
+
+			setModelData( model, '<paragraph>Foo[]bar</paragraph>' );
+
+			model.change( writer => {
+				writer.addMarker( 'group:foo', { range: model.document.selection.getFirstRange(), usingOperation: false } );
+				writer.addMarker( 'group:abc', { range: model.document.selection.getFirstRange(), usingOperation: false } );
+			} );
+
+			expectResult(
+				'<p>' +
+					'Foo' +
+					'<group-start name="abc"></group-start><group-end name="abc"></group-end>' +
+					'<group-start name="foo"></group-start><group-end name="foo"></group-end>' +
+					'bar' +
+				'</p>'
+			);
+
+			model.change( writer => {
+				writer.removeMarker( 'group:foo' );
+				writer.removeMarker( 'group:abc' );
+			} );
+
+			expectResult( '<p>Foobar</p>' );
+		} );
+
+		it( 'default conversion, on two elements, no name', () => {
+			downcastHelpers.markerToData( { model: 'search' } );
+
+			setModelData( model, '<paragraph>Foo</paragraph><paragraph>Bar</paragraph>' );
+
+			model.change( writer => {
+				const range = writer.createRangeIn( root );
+				writer.addMarker( 'search', { range, usingOperation: false } );
+			} );
+
+			expectResult( '<p data-search-start-before="">Foo</p><p data-search-end-after="">Bar</p>' );
+
+			model.change( writer => {
+				writer.removeMarker( 'search' );
+			} );
+
+			expectResult( '<p>Foo</p><p>Bar</p>' );
+		} );
+
+		it( 'default conversion, on two elements, name', () => {
+			downcastHelpers.markerToData( { model: 'group' } );
+
+			setModelData( model, '<paragraph>Foo</paragraph><paragraph>Bar</paragraph>' );
+
+			model.change( writer => {
+				const range = writer.createRangeIn( root );
+				writer.addMarker( 'group:foo:bar:baz', { range, usingOperation: false } );
+			} );
+
+			expectResult( '<p data-group-start-before="foo:bar:baz">Foo</p><p data-group-end-after="foo:bar:baz">Bar</p>' );
+
+			model.change( writer => {
+				writer.removeMarker( 'group:foo:bar:baz' );
+			} );
+
+			expectResult( '<p>Foo</p><p>Bar</p>' );
+		} );
+
+		it( 'default conversion, on one element, name', () => {
+			downcastHelpers.markerToData( { model: 'group' } );
+
+			setModelData( model, '<paragraph>Foobar</paragraph>' );
+
+			model.change( writer => {
+				const range = writer.createRangeIn( root );
+				writer.addMarker( 'group:foo:bar:baz', { range, usingOperation: false } );
+			} );
+
+			expectResult( '<p data-group-end-after="foo:bar:baz" data-group-start-before="foo:bar:baz">Foobar</p>' );
+
+			model.change( writer => {
+				writer.removeMarker( 'group:foo:bar:baz' );
+			} );
+
+			expectResult( '<p>Foobar</p>' );
+		} );
+
+		it( 'default conversion, collapsed before element, name', () => {
+			downcastHelpers.markerToData( { model: 'group' } );
+
+			setModelData( model, '<paragraph>Foobar</paragraph>' );
+
+			model.change( writer => {
+				// Collapsed before <paragraph>.
+				const range = writer.createRange(
+					writer.createPositionFromPath( root, [ 0 ] )
+				);
+
+				writer.addMarker( 'group:foo:bar:baz', { range, usingOperation: false } );
+			} );
+
+			expectResult( '<p data-group-end-before="foo:bar:baz" data-group-start-before="foo:bar:baz">Foobar</p>' );
+
+			model.change( writer => {
+				writer.removeMarker( 'group:foo:bar:baz' );
+			} );
+
+			expectResult( '<p>Foobar</p>' );
+		} );
+
+		it( 'default conversion, collapsed after element, name', () => {
+			downcastHelpers.markerToData( { model: 'group' } );
+
+			setModelData( model, '<paragraph>Foobar</paragraph>' );
+
+			model.change( writer => {
+				// Collapsed before <paragraph>.
+				const range = writer.createRange(
+					writer.createPositionFromPath( root, [ 1 ] )
+				);
+
+				writer.addMarker( 'group:foo:bar:baz', { range, usingOperation: false } );
+			} );
+
+			expectResult( '<p data-group-end-after="foo:bar:baz" data-group-start-after="foo:bar:baz">Foobar</p>' );
+
+			model.change( writer => {
+				writer.removeMarker( 'group:foo:bar:baz' );
+			} );
+
+			expectResult( '<p>Foobar</p>' );
+		} );
+
+		it( 'default conversion, mixed, multiple markers, name', () => {
+			downcastHelpers.markerToData( { model: 'group' } );
+
+			setModelData( model, '<paragraph>Foo</paragraph><paragraph>Bar</paragraph>' );
+
+			model.change( writer => {
+				const range = writer.createRange(
+					writer.createPositionFromPath( root, [ 0 ] ),
+					writer.createPositionFromPath( root, [ 1, 2 ] )
+				);
+
+				writer.addMarker( 'group:foo:bar', { range, usingOperation: false } );
+				writer.addMarker( 'group:abc:xyz', { range, usingOperation: false } );
+			} );
+
+			expectResult(
+				'<p data-group-start-before="abc:xyz,foo:bar">Foo</p>' +
+				'<p>Ba<group-end name="abc:xyz"></group-end><group-end name="foo:bar"></group-end>r</p>'
+			);
+
+			model.change( writer => {
+				writer.removeMarker( 'group:foo:bar' );
+				writer.removeMarker( 'group:abc:xyz' );
+			} );
+
+			expectResult( '<p>Foo</p><p>Bar</p>' );
+		} );
+
+		it( 'default conversion, mixed #2, multiple markers, name', () => {
+			downcastHelpers.markerToData( { model: 'group' } );
+
+			setModelData( model, '<paragraph>Foo</paragraph><paragraph>Bar</paragraph>' );
+
+			model.change( writer => {
+				const range = writer.createRange(
+					writer.createPositionFromPath( root, [ 0, 1 ] ),
+					writer.createPositionFromPath( root, [ 2 ] )
+				);
+
+				writer.addMarker( 'group:foo:bar', { range, usingOperation: false } );
+				writer.addMarker( 'group:abc:xyz', { range, usingOperation: false } );
+			} );
+
+			expectResult(
+				'<p>F<group-start name="abc:xyz"></group-start><group-start name="foo:bar"></group-start>oo</p>' +
+				'<p data-group-end-after="abc:xyz,foo:bar">Bar</p>'
+			);
+
+			model.change( writer => {
+				writer.removeMarker( 'group:foo:bar' );
+				writer.removeMarker( 'group:abc:xyz' );
+			} );
+
+			expectResult( '<p>Foo</p><p>Bar</p>' );
+		} );
+
+		it( 'default conversion, mixed #3, multiple markers, name', () => {
+			downcastHelpers.markerToData( { model: 'group' } );
+
+			setModelData( model, '<paragraph>Foo</paragraph>' );
+
+			model.change( writer => {
+				const range = writer.createRange(
+					writer.createPositionFromPath( root, [ 0 ] ),
+					writer.createPositionFromPath( root, [ 0, 2 ] )
+				);
+
+				writer.addMarker( 'group:foo:bar', { range, usingOperation: false } );
+				writer.addMarker( 'group:abc:xyz', { range, usingOperation: false } );
+			} );
+
+			expectResult(
+				'<p data-group-start-before="abc:xyz,foo:bar">' +
+					'Fo<group-end name="abc:xyz"></group-end><group-end name="foo:bar"></group-end>o' +
+				'</p>'
+			);
+
+			model.change( writer => {
+				writer.removeMarker( 'group:foo:bar' );
+				writer.removeMarker( 'group:abc:xyz' );
+			} );
+
+			expectResult( '<p>Foo</p>' );
+		} );
+
+		it( 'default conversion, mixed #4, multiple markers, name', () => {
+			downcastHelpers.markerToData( { model: 'group' } );
+
+			setModelData( model, '<paragraph>Foo</paragraph>' );
+
+			model.change( writer => {
+				const range = writer.createRange(
+					writer.createPositionFromPath( root, [ 0, 2 ] ),
+					writer.createPositionFromPath( root, [ 1 ] )
+				);
+
+				writer.addMarker( 'group:foo:bar', { range, usingOperation: false } );
+				writer.addMarker( 'group:abc:xyz', { range, usingOperation: false } );
+			} );
+
+			expectResult(
+				'<p data-group-end-after="abc:xyz,foo:bar">' +
+					'Fo<group-start name="abc:xyz"></group-start><group-start name="foo:bar"></group-start>o' +
+				'</p>'
+			);
+
+			model.change( writer => {
+				writer.removeMarker( 'group:foo:bar' );
+				writer.removeMarker( 'group:abc:xyz' );
+			} );
+
+			expectResult( '<p>Foo</p>' );
+		} );
+
+		it( 'conversion callback, mixed, multiple markers, name', () => {
+			const customData = {
+				foo: 'bar',
+				abc: 'xyz'
+			};
+
+			downcastHelpers.markerToData( {
+				model: 'group',
+				view: markerName => {
+					const namePart = markerName.split( ':' )[ 1 ];
+
+					return {
+						group: 'g',
+						name: namePart + '_' + customData[ namePart ]
+					};
+				}
+			} );
+
+			setModelData( model, '<paragraph>Foo</paragraph><paragraph>Bar</paragraph>' );
+
+			model.change( writer => {
+				const range = writer.createRange(
+					writer.createPositionFromPath( root, [ 0 ] ),
+					writer.createPositionFromPath( root, [ 1, 2 ] )
+				);
+
+				writer.addMarker( 'group:foo', { range, usingOperation: false } );
+				writer.addMarker( 'group:abc', { range, usingOperation: false } );
+			} );
+
+			expectResult(
+				'<p data-g-start-before="abc_xyz,foo_bar">Foo</p>' +
+				'<p>Ba<g-end name="abc_xyz"></g-end><g-end name="foo_bar"></g-end>r</p>'
+			);
+
+			model.change( writer => {
+				writer.removeMarker( 'group:foo' );
+				writer.removeMarker( 'group:abc' );
+			} );
+
+			expectResult( '<p>Foo</p><p>Bar</p>' );
+		} );
+
+		it( 'conversion callback, mixed #2, multiple markers, name', () => {
+			const customData = {
+				foo: 'bar',
+				abc: 'xyz'
+			};
+
+			downcastHelpers.markerToData( {
+				model: 'group',
+				view: markerName => {
+					const namePart = markerName.split( ':' )[ 1 ];
+
+					return {
+						group: 'g',
+						name: namePart + '_' + customData[ namePart ]
+					};
+				}
+			} );
+
+			setModelData( model, '<paragraph>Foo</paragraph><paragraph>Bar</paragraph>' );
+
+			model.change( writer => {
+				const range = writer.createRange(
+					writer.createPositionFromPath( root, [ 0, 1 ] ),
+					writer.createPositionFromPath( root, [ 2 ] )
+				);
+
+				writer.addMarker( 'group:foo', { range, usingOperation: false } );
+				writer.addMarker( 'group:abc', { range, usingOperation: false } );
+			} );
+
+			expectResult(
+				'<p>F<g-start name="abc_xyz"></g-start><g-start name="foo_bar"></g-start>oo</p>' +
+				'<p data-g-end-after="abc_xyz,foo_bar">Bar</p>'
+			);
+
+			model.change( writer => {
+				writer.removeMarker( 'group:foo' );
+				writer.removeMarker( 'group:abc' );
+			} );
+
+			expectResult( '<p>Foo</p><p>Bar</p>' );
+		} );
+
+		it( 'can be overwritten using converterPriority', () => {
+			downcastHelpers.markerToData( {
+				model: 'group'
+			} );
+
+			downcastHelpers.markerToData( {
+				model: 'group',
+				view: markerName => {
+					const name = markerName.split( ':' )[ 1 ];
+
+					return {
+						group: 'g',
+						name
+					};
+				},
+				converterPriority: 'high'
+			} );
+
+			setModelData( model, '<paragraph>F[ooba]r</paragraph>' );
+
+			model.change( writer => {
+				writer.addMarker( 'group:foo', { range: model.document.selection.getFirstRange(), usingOperation: false } );
+			} );
+
+			expectResult(
+				'<p>F<g-start name="foo"></g-start>ooba<g-end name="foo"></g-end>r</p>'
+			);
+
+			model.change( writer => {
+				writer.removeMarker( 'group:foo' );
+			} );
+
+			expectResult( '<p>Foobar</p>' );
+		} );
+
+		it( 'can be overwritten by custom callback', () => {
+			downcastHelpers.markerToData( {
+				model: 'group'
+			} );
+
+			downcastHelpers.add( dispatcher => {
+				dispatcher.on( 'addMarker:group', ( evt, data, conversionApi ) => {
+					conversionApi.consumable.consume( data.markerRange, evt.name );
+				}, { priority: 'high' } );
+			} );
+
+			setModelData( model, '<paragraph>Foo[]bar</paragraph>' );
+
+			model.change( writer => {
+				writer.addMarker( 'group:foo', { range: model.document.selection.getFirstRange(), usingOperation: false } );
+			} );
+
+			expectResult( '<p>Foobar</p>' );
+
+			model.change( writer => {
+				writer.removeMarker( 'group:foo' );
+			} );
+
+			expectResult( '<p>Foobar</p>' );
+		} );
+
+		it( 'should not perform conversion if the callback returned falsy value', () => {
+			downcastHelpers.markerToData( {
+				model: 'group',
+				view: () => false
+			} );
+
+			setModelData( model, '<paragraph>F[ooba]r</paragraph>' );
+
+			model.change( writer => {
+				writer.addMarker( 'group:foo', { range: model.document.selection.getFirstRange(), usingOperation: false } );
+			} );
+
+			expectResult( '<p>Foobar</p>' );
+
+			model.change( writer => {
+				writer.removeMarker( 'group:foo' );
+			} );
+
+			expectResult( '<p>Foobar</p>' );
+		} );
+	} );
+
 	describe( 'markerToHighlight()', () => {
 		it( 'should be chainable', () => {
 			expect( downcastHelpers.markerToHighlight( { model: 'comment', view: { classes: 'comment' } } ) ).to.equal( downcastHelpers );

+ 196 - 11
packages/ckeditor5-engine/tests/conversion/upcasthelpers.js

@@ -24,12 +24,14 @@ import UpcastHelpers, { convertToModelFragment, convertText, convertSelectionCha
 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 { setData as viewSetData, parse as viewParse } from '../../src/dev-utils/view';
 import Mapper from '../../src/conversion/mapper';
 import ViewSelection from '../../src/view/selection';
 import ViewRange from '../../src/view/range';
 import { StylesProcessor } from '../../src/view/stylesmap';
 
+/* globals console */
+
 describe( 'UpcastHelpers', () => {
 	let upcastDispatcher, model, schema, upcastHelpers, viewDocument;
 
@@ -56,7 +58,7 @@ describe( 'UpcastHelpers', () => {
 		upcastHelpers = new UpcastHelpers( [ upcastDispatcher ] );
 	} );
 
-	describe( '.elementToElement()', () => {
+	describe( 'elementToElement()', () => {
 		it( 'should be chainable', () => {
 			expect( upcastHelpers.elementToElement( { view: 'p', model: 'paragraph' } ) ).to.equal( upcastHelpers );
 		} );
@@ -169,7 +171,7 @@ describe( 'UpcastHelpers', () => {
 		} );
 	} );
 
-	describe( '.elementToAttribute()', () => {
+	describe( 'elementToAttribute()', () => {
 		it( 'should be chainable', () => {
 			expect( upcastHelpers.elementToAttribute( { view: 'strong', model: 'bold' } ) ).to.equal( upcastHelpers );
 		} );
@@ -370,7 +372,7 @@ describe( 'UpcastHelpers', () => {
 		} );
 	} );
 
-	describe( '.attributeToAttribute()', () => {
+	describe( 'attributeToAttribute()', () => {
 		beforeEach( () => {
 			upcastHelpers.elementToElement( { view: 'img', model: 'image' } );
 
@@ -585,7 +587,17 @@ describe( 'UpcastHelpers', () => {
 		} );
 	} );
 
-	describe( '.elementToMarker()', () => {
+	describe( 'elementToMarker()', () => {
+		beforeEach( () => {
+			// Silence warning about deprecated method.
+			// This whole suite will be removed when the deprecated method is removed.
+			sinon.stub( console, 'warn' );
+		} );
+
+		afterEach( () => {
+			console.warn.restore();
+		} );
+
 		it( 'should be chainable', () => {
 			expect( upcastHelpers.elementToMarker( { view: 'marker-search', model: 'search' } ) ).to.equal( upcastHelpers );
 		} );
@@ -683,16 +695,189 @@ describe( 'UpcastHelpers', () => {
 		} );
 	} );
 
-	function expectResult( viewToConvert, modelString, marker ) {
+	describe( 'dataToMarker()', () => {
+		beforeEach( () => {
+			upcastHelpers.elementToElement( { view: 'p', model: 'paragraph' } );
+		} );
+
+		it( 'should be chainable', () => {
+			expect( upcastHelpers.dataToMarker( { view: 'search' } ) ).to.equal( upcastHelpers );
+		} );
+
+		it( 'default conversion, inside text, non-collapsed, no name', () => {
+			upcastHelpers.dataToMarker( { view: 'search' } );
+
+			expectResult(
+				viewParse( '<p>Fo<search-start></search-start>ob<search-end></search-end>ar</p>' ),
+				'<paragraph>Foobar</paragraph>',
+				{ name: 'search', start: [ 0, 2 ], end: [ 0, 4 ] }
+			);
+		} );
+
+		it( 'default conversion, inside text, non-collapsed, name', () => {
+			upcastHelpers.dataToMarker( { view: 'group' } );
+
+			expectResult(
+				viewParse( '<p>Fo<group-start name="foo:bar:baz"></group-start>ob<group-end name="foo:bar:baz"></group-end>ar</p>' ),
+				'<paragraph>Foobar</paragraph>',
+				{ name: 'group:foo:bar:baz', start: [ 0, 2 ], end: [ 0, 4 ] }
+			);
+		} );
+
+		it( 'default conversion, inside text, collapsed, no name', () => {
+			upcastHelpers.dataToMarker( { view: 'search' } );
+
+			expectResult(
+				viewParse( '<p>Foo<search-start></search-start><search-end></search-end>bar</p>' ),
+				'<paragraph>Foobar</paragraph>',
+				{ name: 'search', start: [ 0, 3 ], end: [ 0, 3 ] }
+			);
+		} );
+
+		it( 'default conversion, inside text, collapsed, multiple markers, no name', () => {
+			upcastHelpers.dataToMarker( { view: 'group' } );
+
+			expectResult(
+				viewParse(
+					'<p>' +
+						'Foo' +
+						'<group-start name="abc"></group-start><group-end name="abc"></group-end>' +
+						'<group-start name="foo"></group-start><group-end name="foo"></group-end>' +
+						'bar' +
+					'</p>'
+				),
+				'<paragraph>Foobar</paragraph>',
+				[
+					{ name: 'group:abc', start: [ 0, 3 ], end: [ 0, 3 ] },
+					{ name: 'group:foo', start: [ 0, 3 ], end: [ 0, 3 ] }
+				]
+			);
+		} );
+
+		it( 'default conversion, on two elements, no name', () => {
+			upcastHelpers.dataToMarker( { view: 'search' } );
+
+			expectResult(
+				viewParse( '<p data-search-start-before="">Foo</p><p data-search-end-after="">Bar</p>' ),
+				'<paragraph>Foo</paragraph><paragraph>Bar</paragraph>',
+				{ name: 'search', start: [ 0 ], end: [ 2 ] }
+			);
+		} );
+
+		it( 'default conversion, on two elements, name', () => {
+			upcastHelpers.dataToMarker( { view: 'group' } );
+
+			expectResult(
+				viewParse( '<p data-group-start-before="foo:bar:baz">Foo</p><p data-group-end-after="foo:bar:baz">Bar</p>' ),
+				'<paragraph>Foo</paragraph><paragraph>Bar</paragraph>',
+				{ name: 'group:foo:bar:baz', start: [ 0 ], end: [ 2 ] }
+			);
+		} );
+
+		it( 'default conversion, on one element, name', () => {
+			upcastHelpers.dataToMarker( { view: 'group' } );
+
+			expectResult(
+				viewParse( '<p data-group-end-after="foo:bar:baz" data-group-start-before="foo:bar:baz">Foobar</p>' ),
+				'<paragraph>Foobar</paragraph>',
+				{ name: 'group:foo:bar:baz', start: [ 0 ], end: [ 1 ] }
+			);
+		} );
+
+		it( 'default conversion, collapsed before element, name', () => {
+			upcastHelpers.dataToMarker( { view: 'group' } );
+
+			expectResult(
+				viewParse( '<p data-group-end-before="foo:bar:baz" data-group-start-before="foo:bar:baz">Foobar</p>' ),
+				'<paragraph>Foobar</paragraph>',
+				{ name: 'group:foo:bar:baz', start: [ 0 ], end: [ 0 ] }
+			);
+		} );
+
+		it( 'default conversion, collapsed after element, name', () => {
+			upcastHelpers.dataToMarker( { view: 'group' } );
+
+			expectResult(
+				viewParse( '<p data-group-end-after="foo:bar:baz" data-group-start-after="foo:bar:baz">Foobar</p>' ),
+				'<paragraph>Foobar</paragraph>',
+				{ name: 'group:foo:bar:baz', start: [ 1 ], end: [ 1 ] }
+			);
+		} );
+
+		it( 'default conversion, mixed, multiple markers, name', () => {
+			upcastHelpers.dataToMarker( { view: 'group' } );
+
+			expectResult(
+				viewParse(
+					'<p data-group-start-before="abc:xyz,foo:bar">Foo</p>' +
+					'<p>Ba<group-end name="abc:xyz"></group-end><group-end name="foo:bar"></group-end>r</p>'
+				),
+				'<paragraph>Foo</paragraph><paragraph>Bar</paragraph>',
+				[
+					{ name: 'group:foo:bar', start: [ 0 ], end: [ 1, 2 ] },
+					{ name: 'group:abc:xyz', start: [ 0 ], end: [ 1, 2 ] }
+				]
+			);
+		} );
+
+		it( 'default conversion, mixed #2, multiple markers, name', () => {
+			upcastHelpers.dataToMarker( { view: 'group' } );
+
+			expectResult(
+				viewParse(
+					'<p>F<group-start name="abc:xyz"></group-start><group-start name="foo:bar"></group-start>oo</p>' +
+					'<p data-group-end-after="abc:xyz,foo:bar">Bar</p>'
+				),
+				'<paragraph>Foo</paragraph><paragraph>Bar</paragraph>',
+				[
+					{ name: 'group:foo:bar', start: [ 0, 1 ], end: [ 2 ] },
+					{ name: 'group:abc:xyz', start: [ 0, 1 ], end: [ 2 ] }
+				]
+			);
+		} );
+
+		it( 'conversion callback, mixed, multiple markers, name', () => {
+			upcastHelpers.dataToMarker( { view: 'g', model: name => 'group:' + name.split( '_' )[ 0 ] } );
+
+			expectResult(
+				viewParse(
+					'<p data-g-start-before="abc_xyz,foo_bar">Foo</p>' +
+					'<p>Ba<g-end name="abc_xyz"></g-end><g-end name="foo_bar"></g-end>r</p>'
+				),
+				'<paragraph>Foo</paragraph><paragraph>Bar</paragraph>',
+				[
+					{ name: 'group:foo', start: [ 0 ], end: [ 1, 2 ] },
+					{ name: 'group:abc', start: [ 0 ], end: [ 1, 2 ] }
+				]
+			);
+		} );
+
+		it( 'can be overwritten using converterPriority', () => {
+			upcastHelpers.dataToMarker( { view: 'group' } );
+			upcastHelpers.dataToMarker( { view: 'group', model: name => 'g:' + name, converterPriority: 'high' } );
+
+			expectResult(
+				viewParse( '<p>Foo<group-start name="foo"></group-start><group-end name="foo"></group-end>bar</p>' ),
+				'<paragraph>Foobar</paragraph>',
+				{ name: 'g:foo', start: [ 0, 3 ], end: [ 0, 3 ] }
+			);
+		} );
+	} );
+
+	function expectResult( viewToConvert, modelString, markers ) {
 		const conversionResult = model.change( writer => upcastDispatcher.convert( viewToConvert, writer ) );
 
-		if ( marker ) {
-			expect( conversionResult.markers.has( marker.name ) ).to.be.true;
+		if ( markers ) {
+			markers = Array.isArray( markers ) ? markers : [ markers ];
+
+			for ( const marker of markers ) {
+				expect( conversionResult.markers.has( marker.name ) ).to.be.true;
 
-			const convertedMarker = conversionResult.markers.get( marker.name );
+				const convertedMarker = conversionResult.markers.get( marker.name );
 
-			expect( convertedMarker.start.path ).to.deep.equal( marker.start );
-			expect( convertedMarker.end.path ).to.deep.equal( marker.end );
+				expect( convertedMarker.start.path ).to.deep.equal( marker.start );
+				expect( convertedMarker.end.path ).to.deep.equal( marker.end );
+			}
 		}
 
 		expect( stringify( conversionResult ) ).to.equal( modelString );

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

@@ -123,6 +123,12 @@ describe( 'MarkerCollection', () => {
 
 			expect( marker.getRange().isEqual( range2 ) ).to.be.true;
 		} );
+
+		it( 'should throw if marker name with "," is added', () => {
+			expectToThrowCKEditorError( () => {
+				markers._set( 'foo,bar', range );
+			}, /^markercollection-incorrect-marker-name:/, markers );
+		} );
 	} );
 
 	describe( 'has', () => {

+ 5 - 15
packages/ckeditor5-font/src/documentcolorcollection.js

@@ -29,6 +29,10 @@ export default class DocumentColorCollection extends Collection {
 		 * @member {Boolean} #isEmpty
 		 */
 		this.set( 'isEmpty', true );
+
+		this.on( 'change', () => {
+			this.set( 'isEmpty', this.length === 0 );
+		} );
 	}
 
 	/**
@@ -44,6 +48,7 @@ export default class DocumentColorCollection extends Collection {
 	 * @param {Number} [index] The position of the item in the collection. The item
 	 * is pushed to the collection when `index` is not specified.
 	 * @fires add
+	 * @fires change
 	 */
 	add( item, index ) {
 		if ( this.find( element => element.color === item.color ) ) {
@@ -52,21 +57,6 @@ export default class DocumentColorCollection extends Collection {
 		}
 
 		super.add( item, index );
-
-		this.set( 'isEmpty', false );
-	}
-
-	/**
-	 * @inheritdoc
-	 */
-	remove( subject ) {
-		const ret = super.remove( subject );
-
-		if ( this.length === 0 ) {
-			this.set( 'isEmpty', true );
-		}
-
-		return ret;
 	}
 
 	/**

+ 1 - 1
packages/ckeditor5-highlight/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@ckeditor/ckeditor5-highlight",
   "version": "20.0.0",
-  "description": "Highilght feature for CKEditor 5.",
+  "description": "Highlight feature for CKEditor 5.",
   "keywords": [
     "ckeditor",
     "ckeditor5",

+ 30 - 1
packages/ckeditor5-image/docs/_snippets/features/image-resize-px.js

@@ -11,7 +11,36 @@ ClassicEditor
 	.create( document.querySelector( '#snippet-image-resize-px' ), {
 		removePlugins: [ 'LinkImage' ],
 		image: {
-			resizeUnit: 'px'
+			resizeUnit: 'px',
+			resizeOptions: [
+				{
+					name: 'imageResize:original',
+					label: 'Original',
+					value: null
+				},
+				{
+					name: 'imageResize:250',
+					label: '250px',
+					value: '250'
+				},
+				{
+					name: 'imageResize:400',
+					label: '400px',
+					value: '400'
+				}
+			],
+			styles: [
+				'alignLeft',
+				'alignCenter',
+				'alignRight'
+			],
+			toolbar: [
+				'imageStyle:alignLeft',
+				'imageStyle:alignCenter',
+				'imageStyle:alignRight',
+				'|',
+				'imageResize'
+			]
 		},
 		toolbar: {
 			viewportTopOffset: window.getViewportTopOffsetConfig()

+ 14 - 0
packages/ckeditor5-image/docs/_snippets/features/image-resize.js

@@ -13,6 +13,20 @@ ClassicEditor
 		toolbar: {
 			viewportTopOffset: window.getViewportTopOffsetConfig()
 		},
+		image: {
+			styles: [
+				'alignLeft',
+				'alignCenter',
+				'alignRight'
+			],
+			toolbar: [
+				'imageStyle:alignLeft',
+				'imageStyle:alignCenter',
+				'imageStyle:alignRight',
+				'|',
+				'imageTextAlternative'
+			]
+		},
 		cloudServices: CS_CONFIG
 	} )
 	.then( editor => {

+ 0 - 6
packages/ckeditor5-image/docs/_snippets/features/image-resizeui.html

@@ -5,10 +5,4 @@
 		<img src="%BASE_PATH%/assets/img/fields.jpg" alt="Autumn fields">
 		<figcaption>Autumn fields by Aleksander Nowodziński</figcaption>
 	</figure>
-
-	<h3>Resized image (width: 75%):</h3>
-
-	<figure class="image image_resized" style="width:75%;">
-		<img src="%BASE_PATH%/assets/img/fields.jpg" alt="Autumn fields">
-	</figure>
 </div>

+ 18 - 8
packages/ckeditor5-image/docs/_snippets/features/image-resizeui.js

@@ -17,26 +17,36 @@ ClassicEditor
 			resizeOptions: [
 				{
 					name: 'imageResize:original',
-					label: 'Original size',
-					value: null
+					label: 'Original',
+					value: null,
+					icon: 'original'
 				},
 				{
 					name: 'imageResize:50',
 					label: '50%',
-					value: '50'
+					value: '50',
+					icon: 'medium'
 				},
 				{
 					name: 'imageResize:75',
 					label: '75%',
-					value: '75'
+					value: '75',
+					icon: 'large'
 				}
 			],
+			styles: [
+				'alignLeft',
+				'alignCenter',
+				'alignRight'
+			],
 			toolbar: [
-				'imageStyle:full',
-				'imageStyle:side', '|',
-				'imageResize:original',
+				'imageStyle:alignLeft',
+				'imageStyle:alignCenter',
+				'imageStyle:alignRight',
+				'|',
 				'imageResize:50',
-				'imageResize:75'
+				'imageResize:75',
+				'imageResize:original'
 			]
 		},
 		cloudServices: CS_CONFIG

+ 0 - 6
packages/ckeditor5-image/docs/_snippets/features/image-resizeuidropdown.html

@@ -5,10 +5,4 @@
 		<img src="%BASE_PATH%/assets/img/fields.jpg" alt="Autumn fields">
 		<figcaption>Autumn fields by Aleksander Nowodziński</figcaption>
 	</figure>
-
-	<h3>Resized image (width: 75%):</h3>
-
-	<figure class="image image_resized" style="width:75%;">
-		<img src="%BASE_PATH%/assets/img/fields.jpg" alt="Autumn fields">
-	</figure>
 </div>

+ 13 - 2
packages/ckeditor5-image/docs/_snippets/features/image-resizeuidropdown.js

@@ -17,7 +17,7 @@ ClassicEditor
 			resizeOptions: [
 				{
 					name: 'imageResize:original',
-					label: 'Original size',
+					label: 'Original',
 					value: null
 				},
 				{
@@ -31,7 +31,18 @@ ClassicEditor
 					value: '75'
 				}
 			],
-			toolbar: [ 'imageStyle:full', 'imageStyle:side', '|', 'imageResize' ]
+			styles: [
+				'alignLeft',
+				'alignCenter',
+				'alignRight'
+			],
+			toolbar: [
+				'imageStyle:alignLeft',
+				'imageStyle:alignCenter',
+				'imageStyle:alignRight',
+				'|',
+				'imageResize'
+			]
 		},
 		cloudServices: CS_CONFIG
 	} )

+ 7 - 1
packages/ckeditor5-image/docs/_snippets/features/image-style-custom.html → packages/ckeditor5-image/docs/_snippets/features/image-style-presentational.html

@@ -1,9 +1,15 @@
 <div id="snippet-image-style-custom">
-	<p>An image to play with:</p>
+	<p>This is a default image (no style):</p>
 
 	<figure class="image">
 		<img src="%BASE_PATH%/assets/img/fields.jpg" alt="Autumn fields">
 	</figure>
 
+	<p>This is a right-aligned image:</p>
+
+	<figure class="image image-style-align-right">
+		<img src="%BASE_PATH%/assets/img/fields.jpg" alt="Autumn fields">
+	</figure>
+
 	<p>Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here.</p>
 </div>

+ 8 - 8
packages/ckeditor5-image/docs/_snippets/features/image-style-custom.js → packages/ckeditor5-image/docs/_snippets/features/image-style-presentational.js

@@ -12,17 +12,17 @@ ClassicEditor
 		removePlugins: [ 'ImageResize', 'LinkImage' ],
 		image: {
 			styles: [
-				// This option is equal to a situation where no style is applied.
-				'full',
-
-				// This represents an image aligned to left.
 				'alignLeft',
-
-				// This represents an image aligned to right.
+				'alignCenter',
 				'alignRight'
 			],
-
-			toolbar: [ 'imageTextAlternative', '|', 'imageStyle:alignLeft', 'imageStyle:full', 'imageStyle:alignRight' ]
+			toolbar: [
+				'imageStyle:alignLeft',
+				'imageStyle:alignCenter',
+				'imageStyle:alignRight',
+				'|',
+				'imageTextAlternative'
+			]
 		},
 		toolbar: {
 			viewportTopOffset: window.getViewportTopOffsetConfig()

+ 90 - 39
packages/ckeditor5-image/docs/features/image.md

@@ -78,17 +78,31 @@ By default, if the image caption is empty, the `<figcaption>` element is not vis
 
 {@snippet features/image-caption}
 
+## Image upload
+
+See the {@link features/image-upload Image upload} guide.
+
+## Responsive images
+
+Support for responsive images in CKEditor 5 is brought by the {@link features/easy-image Easy Image} feature without any additional configuration. Learn more how to use the feature in your project in the {@link features/easy-image#responsive-images Easy Image integration} guide.
+
 ## Image styles
 
 In simple integrations it is enough to let the user insert images, set their text alternative and the editor's job is done. An example of such a simple solution are e.g. [GitHub](https://github.com) comments. The styling of the images (for example, their maximum width and margins) is controlled by GitHub through stylesheets.
 
 In more advanced scenarios, the user may need to be able to decide whether the image should take the whole width (if it is the article's main photo) or it should take, for example, 50% of the width and be pulled out of the content (so called "pulled images"). Various integration scenarios require different types of images to be used.
 
-This is what the {@link module:image/imagestyle~ImageStyle} feature is designed for.
+Finally, in certain situations, the user should be able to granularly control how an image is presented so they should be able to set the size and alignment separately.
 
-However, unlike in CKEditor 4, in CKEditor 5 the end user does not set the image border, alignment, margins, width, etc. separately. Instead, they can pick one of the styles defined by the developer who prepared the WYSIWYG editor integration. This gives the developer control over how the users style images and makes the user's life easier by setting multiple properties at once.
+The {@link module:image/imagestyle~ImageStyle} feature solves the last two scenarios. The former is handled by so-called ["semantical styles"](#semantical-styles) and the latter by ["presentational styles"](#presentational-styles) in combination with [image resize](#resizing-images).
 
-A style is applied to the image in form of a class. By default, CKEditor 5 is configured to support two styles: "full width" (which does not apply any class &mdash; it is the default style) and "side image" (which applies the `image-style-side` class).
+The available image styles can be configured using the {@link module:image/image~ImageConfig#styles `config.image.styles`} option. Respective buttons should also be added to the image toolbar via {@link module:image/image~ImageConfig#toolbar `config.image.toolbar`}.
+
+### Semantical styles
+
+A semantical style let the user choose from a predefined "types" of images. The user is not able to set the image border, alignment, margins, width, etc. separately. Instead, they can pick one of the styles defined by the developer who prepared the WYSIWYG editor integration. This gives the developer control over how the users style images and makes the user's life easier by setting multiple properties at once.
+
+A style is applied to the image in form of a class. By default, CKEditor 5 is configured to support two default semantical styles: **"full width"** (which does not apply any class &mdash; it is the default style) and **"side image"** (which applies the `image-style-side` class).
 
 A normal (full width) image:
 
@@ -103,37 +117,72 @@ A side image:
 ```
 
 <info-box>
-	The actual styling of the images is the developer's job. CKEditor 5 WYSIWYG editor comes with some default styles, but they will only be applied to images inside the editor. The developer needs to style them appropriately on the target pages.
+	The actual styling of the images is the integrator's job. CKEditor 5 WYSIWYG editor comes with some default styles, but they will only be applied to images inside the editor. The integrator needs to style them appropriately on the target pages.
+
+	You can find the source of the default styles applied by the editor here: [`ckeditor5-image/theme/imagestyle.css`](https://github.com/ckeditor/ckeditor5/blob/master/packages/ckeditor5-image/theme/imagestyle.css).
 
-	You can find the source of the default styles applied by the editor here: [`ckeditor5-image/theme/imagestyle.css`](https://github.com/ckeditor/ckeditor5-image/blob/master/theme/imagestyle.css).
+	Read more about {@link builds/guides/integration/content-styles styling the content of the editor}.
 </info-box>
 
-Below you can see a demo of the WYSIWYG editor with the image styles feature enabled. The default configuration is used. You can change the styles of images through the image's contextual toolbar.
+Below you can see a demo of the WYSIWYG editor with the semantical image styles. The "full" and "side" styles are the default value of {@link module:image/image~ImageConfig#styles `config.image.styles`} so you do not need to set it.
+
+```js
+ClassicEditor
+	.create( document.querySelector( '#editor' ), {
+		plugins: [ Image, ImageToolbar, ImageCaption, ImageStyle ],
+		image: {
+			toolbar: [
+				'imageStyle:full',
+				'imageStyle:side',
+				'|',
+				'imageTextAlternative'
+			],
+
+			// The default value,
+			styles: [
+				'full',
+				'side'
+			]
+		}
+	} )
+	.then( ... )
+	.catch( ... );
+```
+
+See the result below. You can change the styles of images through the image's contextual toolbar.
 
 {@snippet features/image-style}
 
-### Configuring image styles
+<info-box hint>
+Try to understand what use cases the system needs to support and define semantic options accordingly. Defining useful and clear styles is one of the steps towards a good user experience and clear, portable output. For example, the "side image" style can be displayed as a floated image on wide screens and as a normal image on low resolution screens (e.g. mobile browsers).
+</info-box>
+
+<info-box warning>
+	While semantical styles can be combined with manual [image resizing](#resizing-images), these features were not designed to be used together.
 
-The available image styles can be configured using the {@link module:image/image~ImageConfig#styles `image.styles`} option.
+	If you want to enable image resizing, use [presentational image styles](#presentational-styles).
+</info-box>
+
+### Presentational styles
 
-The following WYSIWYG editor supports the default full image style plus left- and right-aligned images:
+Presentational styles do not add any special meaning to the content. They directly control the visual aspect of an image.
+
+Currently, the available presentational styles are "align center", "align left" and "align right".
 
 ```js
 ClassicEditor
 	.create( document.querySelector( '#editor' ), {
 		image: {
-			// You need to configure the image toolbar, too, so it uses the new style buttons.
-			toolbar: [ 'imageTextAlternative', '|', 'imageStyle:alignLeft', 'imageStyle:full', 'imageStyle:alignRight' ],
-
+			// Configure the available styles.
 			styles: [
-				// This option is equal to a situation where no style is applied.
-				'full',
-
-				// This represents an image aligned to the left.
-				'alignLeft',
+				'alignLeft', 'alignCenter', 'alignRight'
+			],
 
-				// This represents an image aligned to the right.
-				'alignRight'
+			// You need to configure the image toolbar, too, so it shows the new style buttons.
+			toolbar: [
+				'imageStyle:alignLeft', 'imageStyle:alignCenter', 'imageStyle:alignRight',
+				'|',
+				'imageTextAlternative'
 			]
 		}
 	} )
@@ -141,15 +190,11 @@ ClassicEditor
 	.catch( ... );
 ```
 
-The code sample above uses predefined image styles: `'full'`, `'alignLeft'` and `'alignRight'`. The latter two apply, respectively, the `.image-style-align-left` and  `.image-style-align-right` classes to the `<figure>` element.
+The code sample above uses predefined presentational image styles: `'alignLeft'`, `'alignCenter'` and `'alignRight'`. They apply, respectively, the `.image-style-align-left`, `.image-style-align-center` and  `.image-style-align-right` classes to the `<figure>` element.
 
 See the result below:
 
-{@snippet features/image-style-custom}
-
-<info-box hint>
-	In the example above the options represent simple "align left" and "align right" styles. Most text editors support left, center and right alignments, however, it is better not to think about CKEditor 5's image styles in this way. Try to understand what use cases the system needs to support and define semantic options accordingly. Defining useful and clear styles is one of the steps towards a good user experience and clear, portable output. For example, the "side image" style can be displayed as a floated image on wide screens and as a normal image on low resolution screens.
-</info-box>
+{@snippet features/image-style-presentational}
 
 ### Defining custom styles
 
@@ -167,18 +212,10 @@ you can also define your own styles or modify the existing ones.
 	Reusing (or modifying) predefined styles has the following advantage: CKEditor 5 will use its official translations for the defined button titles.
 </info-box>
 
-You can find advanced examples in the {@link module:image/image~ImageConfig#styles `image.styles`} configuration option documentation.
+You can find advanced examples in the {@link module:image/image~ImageConfig#styles `config.image.styles`} configuration option documentation.
 
 <!-- TODO (live example)... -->
 
-## Image upload
-
-See the {@link features/image-upload Image upload} guide.
-
-## Responsive images
-
-Support for responsive images in CKEditor 5 is brought by the {@link features/easy-image Easy Image} feature without any additional configuration. Learn more how to use the feature in your project in the {@link features/easy-image#responsive-images Easy Image integration} guide.
-
 ## Resizing images
 
 The [image styles](#image-styles) feature is meant to give the user the choice between a set of styling options provided by the system (so by the developer or administrator who created it). There are also scenarios where the user should be able to freely set the width of an image. And that is where the image resize feature comes to play.
@@ -187,11 +224,21 @@ It is implemented by the {@link module:image/imageresize~ImageResize} plugin and
 
 The plugin also gives you an ability to change the size of the image through the image toolbar. You can set an optional static configuration with {@link module:image/image~ImageConfig#resizeOptions} and choose whether you want to use a dropdown or set of the standalone buttons.
 
-### Resize image using handles
+### Methods to resize images
+
+The editor offers three ways to resize images. One of them (resize handles) is
+
+#### Using handles
+
+In this case, the user is able to resize images via dragging square handles displayed in each corner of the image. Once [image resizing was enabled](#enabling-image-resizing), this option does not require any additional configuration.
 
 {@snippet features/image-resize}
 
-### Resize image using the plugin dropdown
+#### Using the dropdown
+
+In this case, the user is able to choose from a set of predefined options. These options can be displayed in the image toolbar in form of a dropdown.
+
+To use this option, you need to [enable image resizing](#enabling-image-resizing) and configure the available {@link module:image/image~ImageConfig#resizeOptions resize options}.
 
 ```js
 const imageConfiguration = {
@@ -212,13 +259,17 @@ const imageConfiguration = {
 			value: '75'
 		}
 	],
-	toolbar: [ ... , 'imageResize' ]
+	toolbar: [ ..., 'imageResize' ]
 }
 ```
 
 {@snippet features/image-resizeuidropdown}
 
-### Resize image using the standalone buttons
+#### Using standalone buttons
+
+In this case, the resize options are displayed in form of separate buttons. The benefit of this solution is the smoothest UX as the user needs just one click to resize an image.
+
+To use this option, you need to [enabling image resizing](#enabling-image-resizing) and configure the available {@link module:image/image~ImageConfig#resizeOptions resize options}.
 
 ```js
 const imageConfiguration = {
@@ -240,7 +291,7 @@ const imageConfiguration = {
 		}
 	],
 	toolbar: [
-		// ...,
+		...,
 		'imageResize:original',
 		'imageResize:50',
 		'imageResize:75'

+ 5 - 3
packages/ckeditor5-image/lang/contexts.json

@@ -11,7 +11,9 @@
 	"Insert image": "Label for the insert image toolbar button.",
 	"Upload failed": "Title of the notification displayed when upload fails.",
 	"Image toolbar": "The label used by assistive technologies describing an image toolbar attached to an image widget.",
-	"Resize image to": "The label used for the standalone resize option buttons in the image toolbar",
-	"Resize image": "The label used for the dropdown in the image toolbar, containing defined resize options",
-	"Resize image to the original size": "The label used for the standalone resize reset option button in the image toolbar, when user set the value to `null`"
+	"Resize image": "The label used for the dropdown in the image toolbar containing defined resize options",
+	"Resize image to %0": "The label used for the standalone resize options buttons in the image toolbar",
+	"Resize image to the original size": "The accessibility label of the standalone image resize reset option button in the image toolbar for the screen readers",
+	"Original": "Default label for the resize option that resets the size of the image.",
+	"Image resize list": "The accessibility label of the image resize dropdown list for the screen readers."
 }

+ 66 - 22
packages/ckeditor5-image/src/imageresize.js

@@ -58,16 +58,21 @@ export default class ImageResize extends Plugin {
  */
 
 /**
- * The resize options.
+ * The image resize options.
  *
- * Each option should have its `name`, which is a component name definition that will be
- * used in the {@link module:image/imageresize/imageresizeui~ImageResizeUI} plugin.
- * Other properties like `label` and `value` define the following:
- * a text label for the option button and the value that will be applied to the image's width.
+ * Each option should have at least these two properties:
  *
- * The value property is combined with the `resizeUnit` (`%` by default), eg: `value: '50'` and `resizeUnit: '%'` is `50%`.
+ * * name: The name of the UI component registered in the global
+ * {@link module:core/editor/editorui~EditorUI#componentFactory component factory} of the editor,
+ * representing the button a user can click to change the size of an image,
+ * * value: An actual image width applied when a user clicks the mentioned button
+ * ({@link module:image/imageresize/imageresizecommand~ImageResizeCommand} gets executed).
+ * The value property is combined with the {@link module:image/image~ImageConfig#resizeUnit `config.image.resizeUnit`} (`%` by default).
+ * For instance: `value: '50'` and `resizeUnit: '%'` will render as `'50%'` in the UI.
  *
- * **NOTE:** If you want to set an option that will reset image to its original size, you need to pass a `null` value
+ * **Resetting the image size**
+ *
+ * If you want to set an option that will reset image to its original size, you need to pass a `null` value
  * to one of the options. The `:original` token is not mandatory, you can call it anything you wish, but it must reflect
  * in the standalone buttons configuration for the image toolbar.
  *
@@ -77,17 +82,14 @@ export default class ImageResize extends Plugin {
  *					resizeUnit: "%",
  *					resizeOptions: [ {
  *						name: 'imageResize:original',
- *						label: 'Original size',
  *						value: null
  *					},
  *					{
  *						name: 'imageResize:50',
- *						label: '50%',
  *						value: '50'
  *					},
  *					{
  *						name: 'imageResize:75',
- *						label: '75%',
  *						value: '75'
  *					} ]
  *				}
@@ -95,27 +97,26 @@ export default class ImageResize extends Plugin {
  *			.then( ... )
  *			.catch( ... );
  *
+ * **Resizing images using a dropdown**
+ *
  * With resize options defined, you can decide whether you want to display them as a dropdown or as standalone buttons.
- * For the dropdown, you need to pass only the `imageResize` token to the `image.toolbar`.
- * The dropdown contains all defined options by default:
+ * For the dropdown, you need to pass only the `imageResize` token to the
+{@link module:image/image~ImageConfig#toolbar `config.image.toolbar`}. The dropdown contains all defined options by default:
  *
- *			ClassicEditor
+ *		ClassicEditor
  *			.create( editorElement, {
  *				image: {
  *					resizeUnit: "%",
  *					resizeOptions: [ {
  *						name: 'imageResize:original',
- *						label: 'Original size',
  *						value: null
  *					},
  *					{
  *						name: 'imageResize:50',
- *						label: '50%',
  *						value: '50'
  *					},
  *					{
  *						name: 'imageResize:75',
- *						label: '75%',
  *						value: '75'
  *					} ],
  *					toolbar: [ 'imageResize', ... ],
@@ -124,33 +125,76 @@ export default class ImageResize extends Plugin {
  *			.then( ... )
  *			.catch( ... );
  *
- * If you want to have separate buttons for each option, pass their names instead:
+ * **Resizing images using individual buttons**
+ *
+ * If you want to have separate buttons for {@link module:image/imageresize/imageresizeui~ImageResizeOption each option},
+ * pass their names to the {@link module:image/image~ImageConfig#toolbar `config.image.toolbar`} instead. Please keep in mind
+ * that this time **you must define the additional
+ * {@link module:image/imageresize/imageresizeui~ImageResizeOption `icon` property}**:
  *
- *			ClassicEditor
+ *		ClassicEditor
  *			.create( editorElement, {
  *				image: {
  *					resizeUnit: "%",
  *					resizeOptions: [ {
  *						name: 'imageResize:original',
- *						label: 'Original size',
  *						value: null
+ *						icon: 'original'
+ *					},
+ *					{
+ *						name: 'imageResize:25',
+ *						value: '25'
+ *						icon: 'small'
  *					},
  *					{
  *						name: 'imageResize:50',
- *						label: '50%',
  *						value: '50'
+ *						icon: 'medium'
  *					},
  *					{
  *						name: 'imageResize:75',
- *						label: '75%',
  *						value: '75'
+ *						icon: 'large'
  *					} ],
- *					toolbar: [ 'imageResize:original', 'imageResize:50', 'imageResize:75', ... ],
+ *					toolbar: [ 'imageResize:25', 'imageResize:50', 'imageResize:75', 'imageResize:original', ... ],
  *				}
  *			} )
  *			.then( ... )
  *			.catch( ... );
  *
+ * **Customizing resize button labels**
+ *
+ * You can set your own label for each resize button. To do that, add the `label` property like in the example below.
+ *
+ * * When using the **dropdown**, the labels are displayed on the list of all options when you open the dropdown.
+ * * When using **standalone buttons**, the labels will are displayed as tooltips when a user hovers over the button.
+ *
+ *		ClassicEditor
+ *			.create( editorElement, {
+ *				image: {
+ *					resizeUnit: "%",
+ *					resizeOptions: [ {
+ *						name: 'imageResize:original',
+ *						value: null,
+ *						label: 'Original size'
+ *						// Note: add the "icon" property if you're configuring a standalone button.
+ *					},
+ *					{
+ *						name: 'imageResize:50',
+ *						value: '50',
+ *						label: 'Medium size'
+ *						// Note: add the "icon" property if you're configuring a standalone button.
+ *					},
+ *					{
+ *						name: 'imageResize:75',
+ *						value: '75',
+ *						label: 'Large size'
+ *						// Note: add the "icon" property if you're configuring a standalone button.
+ *					} ]
+ *				}
+ *			} )
+ *			.then( ... )
+ *			.catch( ... );
  *
  * @member {Array.<module:image/imageresize/imageresizeui~ImageResizeOption>} module:image/image~ImageConfig#resizeOptions
  */

+ 1 - 1
packages/ckeditor5-image/src/imageresize/imageresizeediting.js

@@ -4,7 +4,7 @@
  */
 
 /**
- * @module image/imageresizeediting
+ * @module image/imageresize/imageresizeediting
  */
 
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';

+ 156 - 65
packages/ckeditor5-image/src/imageresize/imageresizeui.js

@@ -16,10 +16,24 @@ import DropdownButtonView from '@ckeditor/ckeditor5-ui/src/dropdown/button/dropd
 import Model from '@ckeditor/ckeditor5-ui/src/model';
 import Collection from '@ckeditor/ckeditor5-utils/src/collection';
 
+import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
+
+import iconSmall from '@ckeditor/ckeditor5-core/theme/icons/object-size-small.svg';
+import iconMedium from '@ckeditor/ckeditor5-core/theme/icons/object-size-medium.svg';
+import iconLarge from '@ckeditor/ckeditor5-core/theme/icons/object-size-large.svg';
+import iconFull from '@ckeditor/ckeditor5-core/theme/icons/object-size-full.svg';
+
+const RESIZE_ICONS = {
+	small: iconSmall,
+	medium: iconMedium,
+	large: iconLarge,
+	original: iconFull
+};
+
 /**
  * The `ImageResizeUI` plugin.
  *
- * It adds a possibility to resize each image using toolbar dropdown or separate buttons, depends on the plugin configuration.
+ * It adds a possibility to resize images using the toolbar dropdown or individual buttons, depending on the plugin configuration.
  *
  * @extends module:core/plugin~Plugin
  */
@@ -38,6 +52,23 @@ export default class ImageResizeUI extends Plugin {
 		return 'ImageResizeUI';
 	}
 
+	/**
+	 * @inheritDoc
+	 */
+	constructor( editor ) {
+		super( editor );
+
+		/**
+		 * The resize unit.
+		 *
+		 * @readonly
+		 * @private
+		 * @type {module:image/image~ImageConfig#resizeUnit}
+		 * @default '%'
+		 */
+		this._resizeUnit = editor.config.get( 'image.resizeUnit' ) || '%';
+	}
+
 	/**
 	 * @inheritDoc
 	 */
@@ -45,7 +76,6 @@ export default class ImageResizeUI extends Plugin {
 		const editor = this.editor;
 		const options = editor.config.get( 'image.resizeOptions' );
 		const command = editor.commands.get( 'imageResize' );
-		const resizeUnit = editor.config.get( 'image.resizeUnit' ) || '%';
 
 		if ( !options ) {
 			return;
@@ -54,44 +84,62 @@ export default class ImageResizeUI extends Plugin {
 		this.bind( 'isEnabled' ).to( command );
 
 		for ( const option of options ) {
-			this._addButton( option, resizeUnit );
+			this._registerImageResizeButton( option );
 		}
 
-		this._addDropdown( options, resizeUnit );
+		this._registerImageResizeDropdown( options );
 	}
 
 	/**
 	 * A helper function that creates a standalone button component for the plugin.
 	 *
 	 * @private
-	 *
 	 * @param {module:image/imageresize/imageresizeui~ImageResizeOption} resizeOption A model of resize option.
-	 * @param {String} unit A resize unit.
 	 */
-	_addButton( { name, label, value }, unit ) {
+	_registerImageResizeButton( option ) {
 		const editor = this.editor;
-		const t = editor.t;
-		const parsedValue = value ? value + unit : null;
+		const { name, value, icon } = option;
+		const optionValueWithUnit = value ? value + this._resizeUnit : null;
 
 		editor.ui.componentFactory.add( name, locale => {
 			const button = new ButtonView( locale );
 			const command = editor.commands.get( 'imageResize' );
-			const commandCallback = setOptionOn( parsedValue );
+			const labelText = this._getOptionLabelValue( option, true );
+
+			if ( !RESIZE_ICONS[ icon ] ) {
+				/**
+				 * When configuring {@link module:image/image~ImageConfig#resizeOptions `config.image.resizeOptions`} for standalone
+				 * buttons, a valid `icon` token must be set for each option.
+				 *
+				 * See all valid options described in the
+				 * {@link module:image/imageresize/imageresizeui~ImageResizeOption plugin configuration}.
+				 *
+				 * @error imageresizeui-missing-icon
+				 * @param {module:image/imageresize/imageresizeui~ImageResizeOption} option Invalid image resize option.
+				*/
+				throw new CKEditorError(
+					'imageresizeui-missing-icon: ' +
+					'The resize option "' + name + '" misses the "icon" property ' +
+					'or the property value doesn\'t match any of available icons.',
+					editor,
+					option
+				);
+			}
 
 			button.set( {
-				label: t( label ),
-				withText: true,
-				tooltip: parsedValue ? t( 'Resize image to' ) + ' ' + parsedValue : t( 'Resize image to the original size' ),
-				isToggleable: true,
-				commandValue: parsedValue
+				// Use the `label` property for a verbose description (because of ARIA).
+				label: labelText,
+				icon: RESIZE_ICONS[ icon ],
+				tooltip: labelText,
+				isToggleable: true
 			} );
 
 			// Bind button to the command.
 			button.bind( 'isEnabled' ).to( this );
-			button.bind( 'isOn' ).to( command, 'value', commandCallback );
+			button.bind( 'isOn' ).to( command, 'value', getIsOnButtonCallback( optionValueWithUnit ) );
 
-			this.listenTo( button, 'execute', evt => {
-				editor.execute( 'imageResize', { width: evt.source.commandValue } );
+			this.listenTo( button, 'execute', () => {
+				editor.execute( 'imageResize', { width: optionValueWithUnit } );
 			} );
 
 			return button;
@@ -99,19 +147,16 @@ export default class ImageResizeUI extends Plugin {
 	}
 
 	/**
-	 * A helper function that creates a dropdown component for the plugin containing all resize options created through the
-	 * plugin configuration settings.
+	 * A helper function that creates a dropdown component for the plugin containing all resize options defined in
+	 * the editor configuration.
 	 *
 	 * @private
-	 *
-	 * @param {Array.<module:image/imageresize/imageresizeui~ImageResizeOption>} options An array of the configured options.
-	 * @param {String} unit A resize unit.
+	 * @param {Array.<module:image/imageresize/imageresizeui~ImageResizeOption>} options An array of configured options.
 	 */
-	_addDropdown( options, unit ) {
+	_registerImageResizeDropdown( options ) {
 		const editor = this.editor;
 		const t = editor.t;
-		const firstOption = options[ 0 ];
-		const resetOption = options.find( option => option.value === null );
+		const originalSizeOption = options.find( option => !option.value );
 
 		// Register dropdown.
 		editor.ui.componentFactory.add( 'imageResize', locale => {
@@ -121,19 +166,25 @@ export default class ImageResizeUI extends Plugin {
 
 			dropdownButton.set( {
 				tooltip: t( 'Resize image' ),
-				commandValue: firstOption.value,
+				commandValue: originalSizeOption.value,
+				icon: iconMedium,
 				isToggleable: true,
-				label: firstOption.label,
-				withText: true
+				label: this._getOptionLabelValue( originalSizeOption ),
+				withText: true,
+				class: 'ck-resize-image-button'
 			} );
 
 			dropdownButton.bind( 'label' ).to( command, 'value', commandValue => {
-				return commandValue && commandValue.width || resetOption.label;
+				if ( commandValue && commandValue.width ) {
+					return commandValue.width;
+				} else {
+					return this._getOptionLabelValue( originalSizeOption );
+				}
 			} );
 			dropdownView.bind( 'isOn' ).to( command );
 			dropdownView.bind( 'isEnabled' ).to( this );
 
-			addListToDropdown( dropdownView, prepareListDefinitions( options, command, unit ) );
+			addListToDropdown( dropdownView, this._getResizeDropdownListItemDefinitions( options, command ) );
 
 			dropdownView.listView.ariaLabel = t( 'Image resize list' );
 
@@ -146,40 +197,73 @@ export default class ImageResizeUI extends Plugin {
 			return dropdownView;
 		} );
 	}
-}
 
-// A helper function for parsing resize options definitions.
-function prepareListDefinitions( definitions, command, resizeUnit ) {
-	const itemDefinitions = new Collection();
-
-	definitions.map( itemDefinition => {
-		const parsedValue = itemDefinition.value ? itemDefinition.value + resizeUnit : null;
-		const definition = {
-			type: 'button',
-			model: new Model( {
-				commandName: 'imageResize',
-				commandValue: parsedValue,
-				label: itemDefinition.label,
-				withText: true,
-				icon: null
-			} )
-		};
-
-		const commandCallback = setOptionOn( parsedValue );
-
-		definition.model.bind( 'isOn' ).to( command, 'value', commandCallback );
+	/**
+	 * A helper function for creating an option label value string.
+	 *
+	 * @private
+	 * @param {module:image/imageresize/imageresizeui~ImageResizeOption} option A resize option object.
+	 * @param {Boolean} [forTooltip] An optional flag for creating a tooltip label.
+	 * @returns {String} A user-defined label, a label combined from the value and resize unit or the default label
+	 * for reset options (`Original`).
+	 */
+	_getOptionLabelValue( option, forTooltip ) {
+		const t = this.editor.t;
+
+		if ( option.label ) {
+			return option.label;
+		} else if ( forTooltip ) {
+			if ( option.value ) {
+				return t( 'Resize image to %0', option.value + this._resizeUnit );
+			} else {
+				return t( 'Resize image to the original size' );
+			}
+		} else {
+			if ( option.value ) {
+				return option.value + this._resizeUnit;
+			} else {
+				return t( 'Original' );
+			}
+		}
+	}
 
-		itemDefinitions.add( definition );
-	} );
+	/**
+	 * A helper function that parses resize options and returns list item definitions ready for use in a dropdown.
+	 *
+	 * @private
+	 * @param {Array.<module:image/imageresize/imageresizeui~ImageResizeOption>} options The resize options.
+	 * @param {module:image/imageresize/imageresizecommand~ImageResizeCommand} command A resize image command.
+	 * @returns {Iterable.<module:ui/dropdown/utils~ListDropdownItemDefinition>} Dropdown item definitions.
+	 */
+	_getResizeDropdownListItemDefinitions( options, command ) {
+		const itemDefinitions = new Collection();
+
+		options.map( option => {
+			const optionValueWithUnit = option.value ? option.value + this._resizeUnit : null;
+			const definition = {
+				type: 'button',
+				model: new Model( {
+					commandName: 'imageResize',
+					commandValue: optionValueWithUnit,
+					label: this._getOptionLabelValue( option ),
+					withText: true,
+					icon: null
+				} )
+			};
+
+			definition.model.bind( 'isOn' ).to( command, 'value', getIsOnButtonCallback( optionValueWithUnit ) );
+
+			itemDefinitions.add( definition );
+		} );
 
-	return itemDefinitions;
+		return itemDefinitions;
+	}
 }
 
-// A helper function for setting the `isOn` state used for creating a callback function in a value binding.
-function setOptionOn( value ) {
+// A helper function for setting the `isOn` state of buttons in value bindings.
+function getIsOnButtonCallback( value ) {
 	return commandValue => {
-		// Set reseting option on when command equals `null`.
-		if ( commandValue === value ) {
+		if ( value === null && commandValue === value ) {
 			return true;
 		}
 
@@ -188,12 +272,19 @@ function setOptionOn( value ) {
 }
 
 /**
- * A resize option type.
+ * An image resize option used in the {@link module:image/image~ImageConfig#resizeOptions image resize configuration}.
  *
  * @typedef {Object} module:image/imageresize/imageresizeui~ImageResizeOption
- *
- * @property {String} resizeOption.name A name of the option used for creating a component.
- * You refer to that name later in the {@link module:image/image~ImageConfig#toolbar}.
- * @property {String} resizeOption.label A label to be displayed with a button.
- * @property {String} resizeOption.value A value of a resize option. `null` value is for resetting an image to its original size.
+ * @property {String} name A name of the UI component that changes the image size.
+ * * If you configure the feature using individual resize buttons, you can refer to this name in the
+ * {@link module:image/image~ImageConfig#toolbar image toolbar configuration}.
+ * * If you configure the feature using the resize dropdown, this name will be used for a list item in the dropdown.
+ * @property {String} value A value of a resize option without the unit
+ * ({@link module:image/image~ImageConfig#resizeUnit configured separately}). `null` resets an image to its original size.
+ * @property {String} [resizeOptions.icon] An icon used by an individual resize button (see the `name` property to learn more).
+ * Available icons are: `'small'`, `'medium'`, `'large'`, `'original'`.
+ * @property {String} [label] A label of the option displayed in the dropdown or, if the feature is configured using
+ * individual buttons, a {@link module:ui/button/buttonview~ButtonView#tooltip} and an ARIA attribute of a button.
+ * If not specified, the label is generated automatically based on the option `value` and the
+ * {@link module:image/image~ImageConfig#resizeUnit `config.image.resizeUnit`}.
  */

+ 136 - 68
packages/ckeditor5-image/tests/imageresize/imageresizeui.js

@@ -17,23 +17,30 @@ import Undo from '@ckeditor/ckeditor5-undo/src/undo';
 import Table from '@ckeditor/ckeditor5-table/src/table';
 
 import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+import { expectToThrowCKEditorError } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
+
+import iconSmall from '@ckeditor/ckeditor5-core/theme/icons/object-size-small.svg';
+import iconMedium from '@ckeditor/ckeditor5-core/theme/icons/object-size-medium.svg';
+import iconLarge from '@ckeditor/ckeditor5-core/theme/icons/object-size-large.svg';
+import iconFull from '@ckeditor/ckeditor5-core/theme/icons/object-size-full.svg';
 
 describe( 'ImageResizeUI', () => {
 	let plugin, command, editor, editorElement;
 
 	const resizeOptions = [ {
 		name: 'imageResize:original',
-		label: 'Original size',
 		value: null
 	},
+	{
+		name: 'imageResize:25',
+		value: '25'
+	},
 	{
 		name: 'imageResize:50',
-		label: '50%',
 		value: '50'
 	},
 	{
 		name: 'imageResize:75',
-		label: '75%',
 		value: '75'
 	} ];
 
@@ -47,7 +54,6 @@ describe( 'ImageResizeUI', () => {
 			.create( editorElement, {
 				plugins: [ Image, ImageStyle, Paragraph, Undo, Table, ImageResizeUI ],
 				image: {
-					resizeUnit: '%',
 					resizeOptions
 				}
 			} );
@@ -70,66 +76,25 @@ describe( 'ImageResizeUI', () => {
 		it( 'should be named', () => {
 			expect( ImageResizeUI.pluginName ).to.equal( 'ImageResizeUI' );
 		} );
-
-		it( 'should be disabled when command is disabled', () => {
-			command.isEnabled = true;
-
-			expect( plugin.isEnabled ).to.be.true;
-
-			command.isEnabled = false;
-
-			expect( plugin.isEnabled ).to.be.false;
-		} );
 	} );
 
-	describe( 'init()', () => {
-		it( 'should have set "%" resize unit', () => {
-			const unit = editor.config.get( 'image.resizeUnit' );
+	describe( 'constructor()', () => {
+		it( 'should create `_resizeUnit` with default value of `%`', () => {
+			const unit = plugin._resizeUnit;
 
 			expect( unit ).to.equal( '%' );
 		} );
+	} );
 
-		it( 'should have set "%" resize unit if not defined', async () => {
-			const editor = await ClassicTestEditor
-				.create( editorElement, {
-					plugins: [ Image, ImageStyle, Paragraph, Undo, Table, ImageResizeUI ],
-					image: {
-						resizeOptions
-					}
-				} );
-
-			const button = editor.ui.componentFactory.create( 'imageResize:50' );
-			const command = editor.commands.get( 'imageResize' );
-
-			command.isEnabled = true;
-
-			button.fire( 'execute' );
-
-			expect( command.value.width.includes( '%' ) ).to.be.true;
-
-			await editor.destroy();
-		} );
-
-		it( 'should have set "px" resize unit', async () => {
-			const editor = await ClassicTestEditor
-				.create( editorElement, {
-					plugins: [ Image, ImageStyle, Paragraph, Undo, Table, ImageResizeUI ],
-					image: {
-						resizeUnit: 'px',
-						resizeOptions
-					}
-				} );
-
-			const button = editor.ui.componentFactory.create( 'imageResize:50' );
-			const command = editor.commands.get( 'imageResize' );
-
+	describe( 'init()', () => {
+		it( 'should be disabled when command is disabled', () => {
 			command.isEnabled = true;
 
-			button.fire( 'execute' );
+			expect( plugin.isEnabled ).to.be.true;
 
-			expect( command.value.width.includes( 'px' ) ).to.be.true;
+			command.isEnabled = false;
 
-			await editor.destroy();
+			expect( plugin.isEnabled ).to.be.false;
 		} );
 
 		it( 'should not register a dropdown or buttons if no resize options passed', async () => {
@@ -167,12 +132,12 @@ describe( 'ImageResizeUI', () => {
 			expect( editor.ui.componentFactory.create( 'imageResize' ) ).to.be.instanceof( DropdownView );
 		} );
 
-		it( 'should have 3 resize options in the `imageResize` dropdown', () => {
+		it( 'should have 4 resize options in the `imageResize` dropdown', () => {
 			const dropdownView = editor.ui.componentFactory.create( 'imageResize' );
 
-			expect( dropdownView.listView.items.length ).to.equal( 3 );
-			expect( dropdownView.listView.items.first.element.textContent ).to.equal( 'Original size' );
-			expect( dropdownView.listView.items._items[ 1 ].element.textContent ).to.equal( '50%' );
+			expect( dropdownView.listView.items.length ).to.equal( 4 );
+			expect( dropdownView.listView.items.first.element.textContent ).to.equal( 'Original' );
+			expect( dropdownView.listView.items._items[ 1 ].element.textContent ).to.equal( '25%' );
 			expect( dropdownView.listView.items.last.element.textContent ).to.equal( '75%' );
 		} );
 
@@ -192,11 +157,56 @@ describe( 'ImageResizeUI', () => {
 			resizeBy50Percent.fire( 'execute' );
 
 			sinon.assert.calledOnce( commandSpy );
-			expect( command.value.width ).to.equal( '50%' );
+			expect( command.value.width ).to.equal( '25%' );
 		} );
 	} );
 
 	describe( 'resize option button', () => {
+		let editor, plugin;
+
+		beforeEach( async () => {
+			editor = await ClassicTestEditor
+				.create( editorElement, {
+					plugins: [ Image, ImageStyle, Paragraph, Undo, Table, ImageResizeUI ],
+					image: {
+						resizeUnit: '%',
+						resizeOptions: [ {
+							name: 'imageResize:original',
+							value: null,
+							icon: 'original'
+						},
+						{
+							name: 'imageResize:25',
+							value: '25',
+							icon: 'small'
+						},
+						{
+							name: 'imageResize:50',
+							value: '50',
+							icon: 'medium'
+						},
+						{
+							name: 'imageResize:75',
+							value: '75',
+							icon: 'large'
+						} ],
+						toolbar: [ 'imageResize:original', 'imageResize:25', 'imageResize:50', 'imageResize:75' ]
+					}
+				} );
+
+			plugin = editor.plugins.get( 'ImageResizeUI' );
+		} );
+
+		afterEach( async () => {
+			if ( editorElement ) {
+				editorElement.remove();
+			}
+
+			if ( editor ) {
+				await editor.destroy();
+			}
+		} );
+
 		it( 'should be bound to `#isEnabled`', () => {
 			const buttonView = editor.ui.componentFactory.create( 'imageResize:50' );
 
@@ -213,13 +223,41 @@ describe( 'ImageResizeUI', () => {
 			expect( editor.ui.componentFactory.create( 'imageResize:50' ) ).to.be.instanceof( ButtonView );
 		} );
 
-		it( 'should be created with visible "50%" label', () => {
+		it( 'should be created with invisible "Resize image: 30%" label when is provided', async () => {
+			const editor = await ClassicTestEditor
+				.create( editorElement, {
+					plugins: [ Image, ImageStyle, Paragraph, Undo, Table, ImageResizeUI ],
+					image: {
+						resizeUnit: '%',
+						resizeOptions: [ {
+							name: 'imageResize:30',
+							value: '30',
+							label: 'Resize image: 30%',
+							icon: 'small'
+						} ],
+						toolbar: [ 'imageResize:30' ]
+					}
+				} );
+
+			const buttonView = editor.ui.componentFactory.create( 'imageResize:30' );
+			buttonView.render();
+
+			expect( buttonView.withText ).to.be.false;
+			expect( buttonView.label ).to.equal( 'Resize image: 30%' );
+			expect( buttonView.labelView ).to.be.instanceOf( View );
+
+			editor.destroy();
+		} );
+
+		it( 'should be created with invisible "Resize image to 50%" label when is not provided', async () => {
 			const buttonView = editor.ui.componentFactory.create( 'imageResize:50' );
 			buttonView.render();
 
-			expect( buttonView.withText ).to.be.true;
-			expect( buttonView.label ).to.equal( '50%' );
+			expect( buttonView.withText ).to.be.false;
+			expect( buttonView.label ).to.equal( 'Resize image to 50%' );
 			expect( buttonView.labelView ).to.be.instanceOf( View );
+
+			editor.destroy();
 		} );
 
 		it( 'should be created with a proper tooltip, depends on the set value', () => {
@@ -233,12 +271,6 @@ describe( 'ImageResizeUI', () => {
 			expect( buttonView50.tooltip ).to.equal( 'Resize image to 50%' );
 		} );
 
-		it( 'should have `commandValue` equal "50%"', () => {
-			const buttonView = editor.ui.componentFactory.create( 'imageResize:50' );
-
-			expect( buttonView.commandValue ).to.equal( '50%' );
-		} );
-
 		it( 'should execute `imageResize` command with "50%" value', () => {
 			const buttonView = editor.ui.componentFactory.create( 'imageResize:50' );
 			const command = editor.commands.get( 'imageResize' );
@@ -251,5 +283,41 @@ describe( 'ImageResizeUI', () => {
 			sinon.assert.calledOnce( commandSpy );
 			expect( command.value.width ).to.equal( '50%' );
 		} );
+
+		it( 'should have set a proper icon', () => {
+			const buttonOriginal = editor.ui.componentFactory.create( 'imageResize:original' );
+			const button25 = editor.ui.componentFactory.create( 'imageResize:25' );
+			const button50 = editor.ui.componentFactory.create( 'imageResize:50' );
+			const button75 = editor.ui.componentFactory.create( 'imageResize:75' );
+
+			expect( buttonOriginal.icon ).to.deep.equal( iconFull );
+			expect( button25.icon ).to.deep.equal( iconSmall );
+			expect( button50.icon ).to.deep.equal( iconMedium );
+			expect( button75.icon ).to.deep.equal( iconLarge );
+		} );
+
+		it( 'should throw the CKEditorError if no `icon` is provided', async () => {
+			const editor = await ClassicTestEditor
+				.create( editorElement, {
+					plugins: [ Image, ImageStyle, Paragraph, Undo, Table, ImageResizeUI ],
+					image: {
+						resizeUnit: '%',
+						resizeOptions: [ {
+							name: 'imageResize:noicon',
+							value: '100'
+						} ],
+						toolbar: [ 'imageResize:noicon' ]
+					}
+				} );
+
+			const errMsg = 'The resize option "imageResize:noicon" misses the "icon" property ' +
+				'or the property value doesn\'t match any of available icons.';
+
+			expectToThrowCKEditorError( () => {
+				editor.ui.componentFactory.create( 'imageResize:noicon' );
+			}, errMsg, editor );
+
+			editor.destroy();
+		} );
 	} );
 } );

+ 3 - 3
packages/ckeditor5-image/tests/manual/imageresize.js

@@ -25,11 +25,11 @@ const commonConfig = {
 	toolbar: [ 'heading', '|', 'bold', 'italic', 'link',
 		'bulletedList', 'numberedList', 'blockQuote', 'insertTable', 'undo', 'redo', 'outdent', 'indent' ],
 	image: {
-		toolbar: [ 'imageStyle:alignLeft', 'imageStyle:full', 'imageStyle:side' ],
+		toolbar: [ 'imageStyle:alignLeft', 'imageStyle:alignCenter', 'imageStyle:alignRight', '|', 'imageResize' ],
 		styles: [
-			'full',
 			'alignLeft',
-			'side' // Purposely using side image instead right aligned image to make sure it works well with both style types.
+			'alignCenter',
+			'alignRight'
 		]
 	},
 	table: {

+ 3 - 3
packages/ckeditor5-image/tests/manual/imageresizepx.js

@@ -26,11 +26,11 @@ const commonConfig = {
 		'bulletedList', 'numberedList', 'blockQuote', 'insertTable', 'undo', 'redo', 'outdent', 'indent' ],
 	image: {
 		resizeUnit: 'px',
-		toolbar: [ 'imageStyle:alignLeft', 'imageStyle:full', 'imageStyle:side' ],
+		toolbar: [ 'imageStyle:alignLeft', 'imageStyle:alignCenter', 'imageStyle:alignRight', '|', 'imageResize' ],
 		styles: [
-			'full',
 			'alignLeft',
-			'side' // Purposely using side image instead right aligned image to make sure it works well with both style types.
+			'alignCenter',
+			'alignRight'
 		]
 	},
 	cloudServices: CS_CONFIG

+ 13 - 16
packages/ckeditor5-image/tests/manual/imageresizeui.js

@@ -36,25 +36,22 @@ const imageConfig1 = {
 	resizeOptions: [
 		{
 			name: 'imageResize:original',
-			label: 'Original size',
 			value: null
 		},
 		{
 			name: 'imageResize:50',
-			label: '50%',
 			value: '50'
 		},
 		{
 			name: 'imageResize:75',
-			label: '75%',
 			value: '75'
 		}
 	],
-	toolbar: [ 'imageStyle:alignLeft', 'imageStyle:full', 'imageStyle:side', '|', 'imageResize' ],
+	toolbar: [ 'imageStyle:alignLeft', 'imageStyle:alignCenter', 'imageStyle:alignRight', '|', 'imageResize' ],
 	styles: [
-		'full',
 		'alignLeft',
-		'side' // Purposely using side image instead right aligned image to make sure it works well with both style types.
+		'alignCenter',
+		'alignRight'
 	]
 };
 
@@ -66,7 +63,7 @@ const config1 = {
 ClassicEditor
 	.create( document.querySelector( '#editor1' ), config1 )
 	.then( editor => {
-		window.editor = editor;
+		window.editor1 = editor;
 	} )
 	.catch( err => {
 		console.error( err.stack );
@@ -77,27 +74,27 @@ const imageConfig2 = {
 	resizeOptions: [
 		{
 			name: 'imageResize:original',
-			label: 'Original size',
-			value: null
+			value: null,
+			icon: 'original'
 		},
 		{
 			name: 'imageResize:50',
-			label: '50%',
-			value: '50'
+			value: '50',
+			icon: 'medium'
 		},
 		{
 			name: 'imageResize:75',
-			label: '75%',
-			value: '75'
+			value: '75',
+			icon: 'large'
 		}
 	],
 	toolbar: [
 		'imageStyle:alignLeft',
 		'imageStyle:full',
 		'imageStyle:side', '|',
-		'imageResize:original',
 		'imageResize:50',
-		'imageResize:75'
+		'imageResize:75',
+		'imageResize:original'
 	],
 	styles: [
 		'full',
@@ -114,7 +111,7 @@ const config2 = {
 ClassicEditor
 	.create( document.querySelector( '#editor2' ), config2 )
 	.then( editor => {
-		window.editor = editor;
+		window.editor2 = editor;
 	} )
 	.catch( err => {
 		console.error( err.stack );

+ 18 - 9
packages/ckeditor5-image/tests/manual/imageresizeui.md

@@ -1,11 +1,20 @@
 ## Image Resize UI
 
-The tests for manual image resizing.
-- The first test should have the dropdown with configured options in the image toolbar (using `imageResize`).
-	- Plugin icon should appear only in the dropbdown button.
-	- Each option should have a label text represented an option value defined in the plugin configuration.
-	- Selected options should be set "on" when dropdown is open.
-- The second one should have the standalone buttons instead of dropdown (from the first test) in the image toolbar (using
-`imageResize:option`).
-	- Each option should have the plugin icon "stretched" over the label text which represents an option value defined in the plugin configuration.
-	- Selected option should be set "on".
+Tests for manual image resizing using the UI in the image toolbar.
+
+## Dropdown
+
+1. Select an image in the editor.
+2. A dropdown with configured options (`config.image.resizeOptions`) should be visible in the toolbar.
+	- The plugin icon should appear only next to the dropdown button.
+	- Each option should have a label text corresponding to an option value defined in the configuration.
+	- The selected option should be "on" when the dropdown is open.
+
+## Buttons
+
+1. Select an image in the editor.
+2. Standalone buttons should be displayed in the image toolbar (corresponding to `config.image.resizeOptions`).
+	- Each button should have an icon as in the configuration (`small`, `medium`, `large` or `original`).
+	- No label should be rendered,
+	- The tooltip text and the `aria-label` attribute should be the same (and more verbose).
+	- The selected option button should be "on".

+ 12 - 0
packages/ckeditor5-image/theme/imageresize.css

@@ -23,3 +23,15 @@
 		display: block;
 	}
 }
+
+[dir="ltr"] .ck.ck-button.ck-button_with-text.ck-resize-image-button .ck-button__icon {
+	margin-right: var(--ck-spacing-standard);
+}
+
+[dir="rtl"] .ck.ck-button.ck-button_with-text.ck-resize-image-button .ck-button__icon {
+	margin-left: var(--ck-spacing-standard);
+}
+
+.ck.ck-dropdown .ck-button.ck-resize-image-button .ck-button__label {
+	width: 4em;
+}

+ 1 - 7
packages/ckeditor5-image/theme/imagestyle.css

@@ -8,16 +8,10 @@
 }
 
 .ck-content {
-	& .image-style-side,
-	& .image-style-align-left,
-	& .image-style-align-center,
-	& .image-style-align-right {
-		max-width: 50%;
-	}
-
 	& .image-style-side {
 		float: right;
 		margin-left: var(--ck-image-style-spacing);
+		max-width: 50%;
 	}
 
 	& .image-style-align-left {

+ 1 - 1
packages/ckeditor5-link/package.json

@@ -10,6 +10,7 @@
     "ckeditor5-plugin"
   ],
   "dependencies": {
+    "@ckeditor/ckeditor5-clipboard": "^20.0.0",
     "@ckeditor/ckeditor5-core": "^20.0.0",
     "@ckeditor/ckeditor5-engine": "^20.0.0",
     "@ckeditor/ckeditor5-image": "^20.0.0",
@@ -21,7 +22,6 @@
   "devDependencies": {
     "@ckeditor/ckeditor5-basic-styles": "^20.0.0",
     "@ckeditor/ckeditor5-block-quote": "^20.0.0",
-    "@ckeditor/ckeditor5-clipboard": "^20.0.0",
     "@ckeditor/ckeditor5-code-block": "^20.0.0",
     "@ckeditor/ckeditor5-editor-classic": "^20.0.0",
     "@ckeditor/ckeditor5-enter": "^20.0.0",

+ 23 - 2
packages/ckeditor5-link/src/linkcommand.js

@@ -12,6 +12,8 @@ import findLinkRange from './findlinkrange';
 import toMap from '@ckeditor/ckeditor5-utils/src/tomap';
 import Collection from '@ckeditor/ckeditor5-utils/src/collection';
 import first from '@ckeditor/ckeditor5-utils/src/first';
+import AutomaticDecorators from './utils/automaticdecorators';
+import { isImageAllowed } from './utils';
 
 /**
  * The link command. It is used by the {@link module:link/link~Link link feature}.
@@ -40,6 +42,15 @@ export default class LinkCommand extends Command {
 		 * @type {module:utils/collection~Collection}
 		 */
 		this.manualDecorators = new Collection();
+
+		/**
+		 * An instance of the helper that ties together all {@link module:link/link~LinkDecoratorAutomaticDefinition}
+		 * that are used by the {@glink features/link link} and the {@glink features/image#linking-images linking images} features.
+		 *
+		 * @readonly
+		 * @type {module:link/utils~AutomaticDecorators}
+		 */
+		this.automaticDecorators = new AutomaticDecorators();
 	}
 
 	/**
@@ -62,7 +73,7 @@ export default class LinkCommand extends Command {
 
 		// A check for the `LinkImage` plugin. If the selection contains an element, get values from the element.
 		// Currently the selection reads attributes from text nodes only. See #7429 and #7465.
-		if ( selectedElement && selectedElement.is( 'image' ) && model.schema.checkAttribute( 'image', 'linkHref' ) ) {
+		if ( isImageAllowed( selectedElement, model.schema ) ) {
 			this.value = selectedElement.getAttribute( 'linkHref' );
 			this.isEnabled = model.schema.checkAttribute( selectedElement, 'linkHref' );
 		} else {
@@ -248,7 +259,17 @@ export default class LinkCommand extends Command {
 	 * @returns {Boolean} The information whether a given decorator is currently present in the selection.
 	 */
 	_getDecoratorStateFromModel( decoratorName ) {
-		const doc = this.editor.model.document;
+		const model = this.editor.model;
+		const doc = model.document;
+
+		const selectedElement = first( doc.selection.getSelectedBlocks() );
+
+		// A check for the `LinkImage` plugin. If the selection contains an element, get values from the element.
+		// Currently the selection reads attributes from text nodes only. See #7429 and #7465.
+		if ( isImageAllowed( selectedElement, model.schema ) ) {
+			return selectedElement.getAttribute( decoratorName );
+		}
+
 		return doc.selection.getAttribute( decoratorName );
 	}
 

+ 139 - 3
packages/ckeditor5-link/src/linkediting.js

@@ -10,9 +10,10 @@
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import MouseObserver from '@ckeditor/ckeditor5-engine/src/view/observer/mouseobserver';
 import TwoStepCaretMovement from '@ckeditor/ckeditor5-typing/src/twostepcaretmovement';
+import Input from '@ckeditor/ckeditor5-typing/src/input';
+import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard';
 import LinkCommand from './linkcommand';
 import UnlinkCommand from './unlinkcommand';
-import AutomaticDecorators from './utils/automaticdecorators';
 import ManualDecorator from './utils/manualdecorator';
 import findLinkRange from './findlinkrange';
 import { createLinkElement, ensureSafeUrl, getLocalizedDecorators, normalizeDecorators } from './utils';
@@ -44,7 +45,8 @@ export default class LinkEditing extends Plugin {
 	 * @inheritDoc
 	 */
 	static get requires() {
-		return [ TwoStepCaretMovement ];
+		// Clipboard is required for handling cut and paste events while typing over the link.
+		return [ TwoStepCaretMovement, Input, Clipboard ];
 	}
 
 	/**
@@ -110,6 +112,9 @@ export default class LinkEditing extends Plugin {
 
 		// Handle a click at the beginning/end of a link element.
 		this._enableClickingAfterLink();
+
+		// Handle typing over the link.
+		this._enableTypingOverLink();
 	}
 
 	/**
@@ -126,7 +131,10 @@ export default class LinkEditing extends Plugin {
 	 */
 	_enableAutomaticDecorators( automaticDecoratorDefinitions ) {
 		const editor = this.editor;
-		const automaticDecorators = new AutomaticDecorators();
+		// Store automatic decorators in the command instance as we do the same with manual decorators.
+		// Thanks to that, `LinkImageEditing` plugin can re-use the same definitions.
+		const command = editor.commands.get( 'link' );
+		const automaticDecorators = command.automaticDecorators;
 
 		// Adds a default decorator for external links.
 		if ( editor.config.get( 'link.addTargetToExternalLinks' ) ) {
@@ -414,4 +422,132 @@ export default class LinkEditing extends Plugin {
 			}
 		} );
 	}
+
+	/**
+	 * Starts listening to {@link module:engine/model/model~Model#deleteContent} and {@link module:engine/model/model~Model#insertContent}
+	 * and checks whether typing over the link. If so, attributes of removed text are preserved and applied to the inserted text.
+	 *
+	 * The purpose of this action is to allow modifying a text without loosing the `linkHref` attribute (and other).
+	 *
+	 * See https://github.com/ckeditor/ckeditor5/issues/4762.
+	 *
+	 * @private
+	 */
+	_enableTypingOverLink() {
+		const editor = this.editor;
+		const view = editor.editing.view;
+
+		// Selection attributes when started typing over the link.
+		let selectionAttributes;
+
+		// Whether pressed `Backspace` or `Delete`. If so, attributes should not be preserved.
+		let deletedContent;
+
+		// Detect pressing `Backspace` / `Delete`.
+		this.listenTo( view.document, 'delete', () => {
+			deletedContent = true;
+		}, { priority: 'high' } );
+
+		// Listening to `model#deleteContent` allows detecting whether selected content was a link.
+		// If so, before removing the element, we will copy its attributes.
+		this.listenTo( editor.model, 'deleteContent', () => {
+			const selection = editor.model.document.selection;
+
+			// Copy attributes only if anything is selected.
+			if ( selection.isCollapsed ) {
+				return;
+			}
+
+			// When the content was deleted, do not preserve attributes.
+			if ( deletedContent ) {
+				deletedContent = false;
+
+				return;
+			}
+
+			// Enabled only when typing.
+			if ( !isTyping( editor ) ) {
+				return;
+			}
+
+			if ( shouldCopyAttributes( editor.model ) ) {
+				selectionAttributes = selection.getAttributes();
+			}
+		}, { priority: 'high' } );
+
+		// Listening to `model#insertContent` allows detecting the content insertion.
+		// We want to apply attributes that were removed while typing over the link.
+		this.listenTo( editor.model, 'insertContent', ( evt, [ element ] ) => {
+			deletedContent = false;
+
+			// Enabled only when typing.
+			if ( !isTyping( editor ) ) {
+				return;
+			}
+
+			if ( !selectionAttributes ) {
+				return;
+			}
+
+			editor.model.change( writer => {
+				for ( const [ attribute, value ] of selectionAttributes ) {
+					writer.setAttribute( attribute, value, element );
+				}
+			} );
+
+			selectionAttributes = null;
+		}, { priority: 'high' } );
+	}
+}
+
+// Checks whether selection's attributes should be copied to the new inserted text.
+//
+// @param {module:engine/model/model~Model} model
+// @returns {Boolean}
+function shouldCopyAttributes( model ) {
+	const selection = model.document.selection;
+	const firstPosition = selection.getFirstPosition();
+	const lastPosition = selection.getLastPosition();
+	const nodeAtFirstPosition = firstPosition.nodeAfter;
+
+	// The text link node does not exist...
+	if ( !nodeAtFirstPosition ) {
+		return false;
+	}
+
+	// ...or it isn't the text node...
+	if ( !nodeAtFirstPosition.is( 'text' ) ) {
+		return false;
+	}
+
+	// ...or isn't the link.
+	if ( !nodeAtFirstPosition.hasAttribute( 'linkHref' ) ) {
+		return false;
+	}
+
+	// `textNode` = the position is inside the link element.
+	// `nodeBefore` = the position is at the end of the link element.
+	const nodeAtLastPosition = lastPosition.textNode || lastPosition.nodeBefore;
+
+	// If both references the same node selection contains a single text node.
+	if ( nodeAtFirstPosition === nodeAtLastPosition ) {
+		return true;
+	}
+
+	// If nodes are not equal, maybe the link nodes has defined additional attributes inside.
+	// First, we need to find the entire link range.
+	const linkRange = findLinkRange( firstPosition, nodeAtFirstPosition.getAttribute( 'linkHref' ), model );
+
+	// Then we can check whether selected range is inside the found link range. If so, attributes should be preserved.
+	return linkRange.containsRange( model.createRange( firstPosition, lastPosition ), true );
+}
+
+// Checks whether provided changes were caused by typing.
+//
+// @params {module:core/editor/editor~Editor} editor
+// @returns {Boolean}
+function isTyping( editor ) {
+	const input = editor.plugins.get( 'Input' );
+
+	return input.isInput( editor.model.change( writer => writer.batch ) );
 }

+ 2 - 0
packages/ckeditor5-link/src/linkimage.js

@@ -11,6 +11,8 @@ import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import LinkImageEditing from './linkimageediting';
 import LinkImageUI from './linkimageui';
 
+import '../theme/linkimage.css';
+
 /**
  * The `LinkImage` plugin.
  *

+ 120 - 4
packages/ckeditor5-link/src/linkimageediting.js

@@ -9,8 +9,12 @@
 
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import ImageEditing from '@ckeditor/ckeditor5-image/src/image/imageediting';
+import Matcher from '@ckeditor/ckeditor5-engine/src/view/matcher';
+import toMap from '@ckeditor/ckeditor5-utils/src/tomap';
 import LinkEditing from './linkediting';
 
+import linkIcon from '../theme/icons/link.svg';
+
 /**
  * The link image engine feature.
  *
@@ -40,7 +44,46 @@ export default class LinkImageEditing extends Plugin {
 		editor.model.schema.extend( 'image', { allowAttributes: [ 'linkHref' ] } );
 
 		editor.conversion.for( 'upcast' ).add( upcastLink() );
-		editor.conversion.for( 'downcast' ).add( downcastImageLink() );
+		editor.conversion.for( 'editingDowncast' ).add( downcastImageLink( { attachIconIndicator: true } ) );
+		editor.conversion.for( 'dataDowncast' ).add( downcastImageLink( { attachIconIndicator: false } ) );
+
+		// Definitions for decorators are provided by the `link` command and the `LinkEditing` plugin.
+		this._enableAutomaticDecorators();
+		this._enableManualDecorators();
+	}
+
+	/**
+	 * Processes {@link module:link/link~LinkDecoratorAutomaticDefinition automatic decorators} definitions and
+	 * attaches proper converters that will work when linking an image.`
+	 *
+	 * @private
+	 */
+	_enableAutomaticDecorators() {
+		const editor = this.editor;
+		const command = editor.commands.get( 'link' );
+		const automaticDecorators = command.automaticDecorators;
+
+		if ( automaticDecorators.length ) {
+			editor.conversion.for( 'downcast' ).add( automaticDecorators.getDispatcherForLinkedImage() );
+		}
+	}
+
+	/**
+	 * Processes transformed {@link module:link/utils~ManualDecorator} instances and attaches proper converters
+	 * that will work when linking an image.
+	 *
+	 * @private
+	 */
+	_enableManualDecorators() {
+		const editor = this.editor;
+		const command = editor.commands.get( 'link' );
+		const manualDecorators = command.manualDecorators;
+
+		for ( const decorator of command.manualDecorators ) {
+			editor.model.schema.extend( 'image', { allowAttributes: decorator.id } );
+			editor.conversion.for( 'downcast' ).add( downcastImageLinkManualDecorator( manualDecorators, decorator ) );
+			editor.conversion.for( 'upcast' ).add( upcastImageLinkManualDecorator( manualDecorators, decorator ) );
+		}
 	}
 }
 
@@ -48,7 +91,6 @@ export default class LinkImageEditing extends Plugin {
 //
 // @private
 // @returns {Function}
-//
 function upcastLink() {
 	return dispatcher => {
 		dispatcher.on( 'element:a', ( evt, data, conversionApi ) => {
@@ -97,15 +139,18 @@ function upcastLink() {
 				conversionApi.writer.setAttribute( 'linkHref', linkHref, modelElement );
 			}
 		}, { priority: 'high' } );
+		// Using the same priority that `upcastImageLinkManualDecorator()` converter guarantees
+		// that manual decorators will decorate the proper element.
 	};
 }
 
 // Return a converter that adds the `<a>` element to data.
 //
 // @private
+// @params {Object} options
+// @params {Boolean} options.attachIconIndicator=false If set to `true`, an icon that informs about the linked image will be added.
 // @returns {Function}
-//
-function downcastImageLink() {
+function downcastImageLink( options ) {
 	return dispatcher => {
 		dispatcher.on( 'attribute:linkHref:image', ( evt, data, conversionApi ) => {
 			// The image will be already converted - so it will be present in the view.
@@ -115,6 +160,18 @@ function downcastImageLink() {
 			// But we need to check whether the link element exists.
 			const linkInImage = Array.from( viewFigure.getChildren() ).find( child => child.name === 'a' );
 
+			let linkIconIndicator;
+
+			if ( options.attachIconIndicator ) {
+				// Create an icon indicator for a linked image.
+				linkIconIndicator = writer.createUIElement( 'span', { class: 'ck ck-link-image_icon' }, function( domDocument ) {
+					const domElement = this.toDomElement( domDocument );
+					domElement.innerHTML = linkIcon;
+
+					return domElement;
+				} );
+			}
+
 			// If so, update the attribute if it's defined or remove the entire link if the attribute is empty.
 			if ( linkInImage ) {
 				if ( data.attributeNewValue ) {
@@ -135,7 +192,66 @@ function downcastImageLink() {
 
 				// 3. Move the image to the link.
 				writer.move( writer.createRangeOn( viewFigure.getChild( 1 ) ), writer.createPositionAt( linkElement, 0 ) );
+
+				// 4. Inset the linked image icon indicator while downcast to editing.
+				if ( linkIconIndicator ) {
+					writer.insert( writer.createPositionAt( linkElement, 'end' ), linkIconIndicator );
+				}
 			}
 		} );
 	};
 }
+
+// Returns a converter that decorates the `<a>` element when the image is the link label.
+//
+// @private
+// @returns {Function}
+function downcastImageLinkManualDecorator( manualDecorators, decorator ) {
+	return dispatcher => {
+		dispatcher.on( `attribute:${ decorator.id }:image`, ( evt, data, conversionApi ) => {
+			const attributes = manualDecorators.get( decorator.id ).attributes;
+
+			const viewFigure = conversionApi.mapper.toViewElement( data.item );
+			const linkInImage = Array.from( viewFigure.getChildren() ).find( child => child.name === 'a' );
+
+			for ( const [ key, val ] of toMap( attributes ) ) {
+				conversionApi.writer.setAttribute( key, val, linkInImage );
+			}
+		} );
+	};
+}
+
+// Returns a converter that checks whether manual decorators should be applied to the link.
+//
+// @private
+// @returns {Function}
+function upcastImageLinkManualDecorator( manualDecorators, decorator ) {
+	return dispatcher => {
+		dispatcher.on( 'element:a', ( evt, data, conversionApi ) => {
+			const viewLink = data.viewItem;
+
+			const consumableAttributes = {
+				attributes: manualDecorators.get( decorator.id ).attributes
+			};
+
+			const matcher = new Matcher( consumableAttributes );
+			const result = matcher.match( viewLink );
+
+			// The link element does not have required attributes or/and proper values.
+			if ( !result ) {
+				return;
+			}
+
+			// Check whether we can consume those attributes.
+			if ( !conversionApi.consumable.consume( viewLink, result.match ) ) {
+				return;
+			}
+
+			// At this stage we can assume that we have the `<image>` element.
+			const modelElement = data.modelCursor.parent;
+
+			conversionApi.writer.setAttribute( decorator.id, true, modelElement );
+		}, { priority: 'high' } );
+		// Using the same priority that `upcastLink()` converter guarantees that the linked image was properly converted.
+	};
+}

+ 2 - 2
packages/ckeditor5-link/src/linkui.js

@@ -85,7 +85,7 @@ export default class LinkUI extends Plugin {
 		this._enableUserBalloonInteractions();
 
 		// Renders a fake visual selection marker on an expanded selection.
-		editor.conversion.for( 'downcast' ).markerToHighlight( {
+		editor.conversion.for( 'editingDowncast' ).markerToHighlight( {
 			model: VISUAL_SELECTION_MARKER_NAME,
 			view: {
 				classes: [ 'ck-fake-link-selection' ]
@@ -93,7 +93,7 @@ export default class LinkUI extends Plugin {
 		} );
 
 		// Renders a fake visual selection marker on a collapsed selection.
-		editor.conversion.for( 'downcast' ).markerToElement( {
+		editor.conversion.for( 'editingDowncast' ).markerToElement( {
 			model: VISUAL_SELECTION_MARKER_NAME,
 			view: {
 				name: 'span',

+ 3 - 2
packages/ckeditor5-link/src/unlinkcommand.js

@@ -8,8 +8,9 @@
  */
 
 import Command from '@ckeditor/ckeditor5-core/src/command';
-import findLinkRange from './findlinkrange';
 import first from '@ckeditor/ckeditor5-utils/src/first';
+import findLinkRange from './findlinkrange';
+import { isImageAllowed } from './utils';
 
 /**
  * The unlink command. It is used by the {@link module:link/link~Link link plugin}.
@@ -28,7 +29,7 @@ export default class UnlinkCommand extends Command {
 
 		// A check for the `LinkImage` plugin. If the selection contains an image element, get values from the element.
 		// Currently the selection reads attributes from text nodes only. See #7429 and #7465.
-		if ( selectedElement && selectedElement.is( 'image' ) && model.schema.checkAttribute( 'image', 'linkHref' ) ) {
+		if ( isImageAllowed( selectedElement, model.schema ) ) {
 			this.isEnabled = model.schema.checkAttribute( selectedElement, 'linkHref' );
 		} else {
 			this.isEnabled = model.schema.checkAttributeInSelection( doc.selection, 'linkHref' );

+ 15 - 0
packages/ckeditor5-link/src/utils.js

@@ -119,3 +119,18 @@ export function normalizeDecorators( decorators ) {
 
 	return retArray;
 }
+
+/**
+ * Returns `true` if the specified `element` is an image and it can be linked (the element allows having the `linkHref` attribute).
+ *
+ * @params {module:engine/model/element~Element|null} element
+ * @params {module:engine/model/schema~Schema} schema
+ * @returns {Boolean}
+ */
+export function isImageAllowed( element, schema ) {
+	if ( !element ) {
+		return false;
+	}
+
+	return element.is( 'image' ) && schema.checkAttribute( 'image', 'linkHref' );
+}

+ 41 - 1
packages/ckeditor5-link/src/utils/automaticdecorators.js

@@ -7,9 +7,11 @@
  * @module link/utils
  */
 
+import toMap from '@ckeditor/ckeditor5-utils/src/tomap';
+
 /**
  * Helper class that ties together all {@link module:link/link~LinkDecoratorAutomaticDefinition} and provides
- * a {@link module:engine/conversion/downcasthelpers~DowncastHelpers#attributeToElement downcast dispatcher} for them.
+ * the {@link module:engine/conversion/downcasthelpers~DowncastHelpers#attributeToElement downcast dispatchers} for them.
  */
 export default class AutomaticDecorators {
 	constructor() {
@@ -85,4 +87,42 @@ export default class AutomaticDecorators {
 			}, { priority: 'high' } );
 		};
 	}
+
+	/**
+	 * Provides the conversion helper used in the {@link module:engine/conversion/downcasthelpers~DowncastHelpers#add} method
+	 * when linking images.
+	 *
+	 * @returns {Function} A dispatcher function used as conversion helper
+	 * in {@link module:engine/conversion/downcasthelpers~DowncastHelpers#add}.
+	 */
+	getDispatcherForLinkedImage() {
+		return dispatcher => {
+			dispatcher.on( 'attribute:linkHref:image', ( evt, data, conversionApi ) => {
+				const viewFigure = conversionApi.mapper.toViewElement( data.item );
+				const linkInImage = Array.from( viewFigure.getChildren() ).find( child => child.name === 'a' );
+
+				for ( const item of this._definitions ) {
+					const attributes = toMap( item.attributes );
+
+					if ( item.callback( data.attributeNewValue ) ) {
+						for ( const [ key, val ] of attributes ) {
+							if ( key === 'class' ) {
+								conversionApi.writer.addClass( val, linkInImage );
+							} else {
+								conversionApi.writer.setAttribute( key, val, linkInImage );
+							}
+						}
+					} else {
+						for ( const [ key, val ] of attributes ) {
+							if ( key === 'class' ) {
+								conversionApi.writer.removeClass( val, linkInImage );
+							} else {
+								conversionApi.writer.removeAttribute( key, linkInImage );
+							}
+						}
+					}
+				}
+			} );
+		};
+	}
 }

+ 21 - 0
packages/ckeditor5-link/tests/linkcommand.js

@@ -7,6 +7,7 @@ import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltestedit
 import LinkCommand from '../src/linkcommand';
 import ManualDecorator from '../src/utils/manualdecorator';
 import { setData, getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import AutomaticDecorators from '../src/utils/automaticdecorators';
 
 describe( 'LinkCommand', () => {
 	let editor, model, command;
@@ -440,6 +441,13 @@ describe( 'LinkCommand', () => {
 						allowAttributes: [ 'linkHref', 'linkIsFoo', 'linkIsBar', 'linkIsSth' ]
 					} );
 
+					model.schema.register( 'image', {
+						allowIn: '$root',
+						isObject: true,
+						isBlock: true,
+						allowAttributes: [ 'linkHref', 'linkIsFoo', 'linkIsBar', 'linkIsSth' ]
+					} );
+
 					model.schema.register( 'p', { inheritAllFrom: '$block' } );
 				} );
 		} );
@@ -565,6 +573,19 @@ describe( 'LinkCommand', () => {
 				expect( command._getDecoratorStateFromModel( 'linkIsFoo' ) ).to.be.undefined;
 				expect( command._getDecoratorStateFromModel( 'linkIsBar' ) ).to.be.true;
 			} );
+
+			it( 'obtain current values from the image element', () => {
+				setData( model, '[<image linkHref="url" linkIsBar="true"></image>]' );
+
+				expect( command._getDecoratorStateFromModel( 'linkIsFoo' ) ).to.be.undefined;
+				expect( command._getDecoratorStateFromModel( 'linkIsBar' ) ).to.be.true;
+			} );
+		} );
+	} );
+
+	describe( '#automaticDecorators', () => {
+		it( 'is defined', () => {
+			expect( command.automaticDecorators ).to.be.an.instanceOf( AutomaticDecorators );
 		} );
 	} );
 } );

+ 355 - 11
packages/ckeditor5-link/tests/linkediting.js

@@ -8,15 +8,19 @@ import LinkCommand from '../src/linkcommand';
 import UnlinkCommand from '../src/unlinkcommand';
 
 import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
-import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
-import Enter from '@ckeditor/ckeditor5-enter/src/enter';
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import BoldEditing from '@ckeditor/ckeditor5-basic-styles/src/bold/boldediting';
+import ItalicEditing from '@ckeditor/ckeditor5-basic-styles/src/italic/italicediting';
+import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard';
+import Enter from '@ckeditor/ckeditor5-enter/src/enter';
+import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata';
+import ImageEditing from '@ckeditor/ckeditor5-image/src/image/imageediting';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import Input from '@ckeditor/ckeditor5-typing/src/input';
 import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
-import { isLinkElement } from '../src/utils';
 import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
-import Typing from '@ckeditor/ckeditor5-typing/src/typing';
-import BoldEditing from '@ckeditor/ckeditor5-basic-styles/src/bold/boldediting';
+import { isLinkElement } from '../src/utils';
 
 /* global document */
 
@@ -720,6 +724,10 @@ describe( 'LinkEditing', () => {
 						expect( editor.getData() ).to.equal( `<p><a ${ reducedAttr }href="${ link.url }">foo</a>bar</p>` );
 					} );
 				} );
+
+				it( 'stores decorators in LinkCommand#automaticDecorators collection', () => {
+					expect( editor.commands.get( 'link' ).automaticDecorators.length ).to.equal( 3 );
+				} );
 			} );
 		} );
 
@@ -869,7 +877,7 @@ describe( 'LinkEditing', () => {
 
 		beforeEach( async () => {
 			editor = await ClassicTestEditor.create( element, {
-				plugins: [ Paragraph, LinkEditing, Enter, Typing, BoldEditing ],
+				plugins: [ Paragraph, LinkEditing, Enter, Input, BoldEditing ],
 				link: {
 					decorators: {
 						isFoo: {
@@ -892,6 +900,11 @@ describe( 'LinkEditing', () => {
 
 			model = editor.model;
 			view = editor.editing.view;
+
+			model.schema.extend( '$text', {
+				allowIn: '$root',
+				allowAttributes: [ 'linkIsFoo', 'linkIsBar' ]
+			} );
 		} );
 
 		afterEach( async () => {
@@ -1033,11 +1046,6 @@ describe( 'LinkEditing', () => {
 		} );
 
 		it( 'should remove manual decorators', () => {
-			model.schema.extend( '$text', {
-				allowIn: '$root',
-				allowAttributes: [ 'linkIsFoo', 'linkIsBar' ]
-			} );
-
 			setModelData( model, '<paragraph><$text linkIsFoo="true" linkIsBar="true" linkHref="url">Bar[]</$text></paragraph>' );
 
 			editor.editing.view.document.fire( 'mousedown' );
@@ -1056,4 +1064,340 @@ describe( 'LinkEditing', () => {
 			);
 		} );
 	} );
+
+	// https://github.com/ckeditor/ckeditor5/issues/4762
+	describe( 'typing over the link', () => {
+		let editor;
+
+		beforeEach( async () => {
+			editor = await ClassicTestEditor.create( element, {
+				plugins: [ Paragraph, LinkEditing, Enter, BoldEditing, ItalicEditing, ImageEditing ],
+				link: {
+					decorators: {
+						isFoo: {
+							mode: 'manual',
+							label: 'Foo',
+							attributes: {
+								class: 'foo'
+							}
+						},
+						isBar: {
+							mode: 'manual',
+							label: 'Bar',
+							attributes: {
+								target: '_blank'
+							}
+						}
+					}
+				}
+			} );
+
+			model = editor.model;
+			view = editor.editing.view;
+
+			model.schema.extend( '$text', {
+				allowIn: '$root',
+				allowAttributes: [ 'linkIsFoo', 'linkIsBar' ]
+			} );
+		} );
+
+		afterEach( async () => {
+			await editor.destroy();
+		} );
+
+		it( 'should require Clipboard plugin', () => {
+			expect( LinkEditing.requires.includes( Clipboard ) ).to.equal( true );
+		} );
+
+		it( 'should require Input plugin', () => {
+			expect( LinkEditing.requires.includes( Input ) ).to.equal( true );
+		} );
+
+		it( 'should preserve selection attributes when the entire link is selected', () => {
+			setModelData( model,
+				'<paragraph>This is [<$text linkHref="foo">Foo</$text>] from <$text linkHref="bar">Bar</$text>.</paragraph>'
+			);
+
+			editor.execute( 'input', {
+				text: 'Abcde'
+			} );
+
+			expect( getModelData( model ) ).to.equal(
+				'<paragraph>This is <$text linkHref="foo">Abcde</$text>[] from <$text linkHref="bar">Bar</$text>.</paragraph>'
+			);
+		} );
+
+		it( 'should preserve selection attributes when the entire link is selected (mixed attributes in the link)', () => {
+			setModelData( model,
+				'<paragraph>' +
+					'This is [' +
+					'<$text linkHref="foo" italic="true">F</$text>' +
+					'<$text linkHref="foo" bold="true">o</$text>' +
+					'<$text linkHref="foo" bold="true" italic="true">o</$text>' +
+					'<$text linkHref="foo" bold="true">B</$text>' +
+					'<$text linkHref="foo" bold="true" italic="true">a</$text>' +
+					'<$text linkHref="foo">r</$text>]' +
+					' from ' +
+					'<$text linkHref="bar">Bar</$text>' +
+					'.' +
+				'</paragraph>'
+			);
+
+			editor.execute( 'input', {
+				text: 'Abcde'
+			} );
+
+			expect( getModelData( model ) ).to.equal(
+				'<paragraph>' +
+					'This is ' +
+					'<$text italic="true" linkHref="foo">Abcde</$text>' +
+					'<$text italic="true">[]</$text>' +
+					' from ' +
+					'<$text linkHref="bar">Bar</$text>' +
+					'.' +
+				'</paragraph>'
+			);
+		} );
+
+		it( 'should preserve selection attributes when the selection starts at the beginning of the link', () => {
+			setModelData( model,
+				'<paragraph>This is [<$text linkHref="foo">Fo]o</$text> from <$text linkHref="bar">Bar</$text>.</paragraph>'
+			);
+
+			editor.execute( 'input', {
+				text: 'Abcde'
+			} );
+
+			expect( getModelData( model ) ).to.equal(
+				'<paragraph>This is <$text linkHref="foo">Abcde[]o</$text> from <$text linkHref="bar">Bar</$text>.</paragraph>'
+			);
+		} );
+
+		it( 'should preserve selection attributes when it starts at the beginning of the link (mixed attributes in the link)', () => {
+			setModelData( model,
+				'<paragraph>' +
+					'This is [' +
+					'<$text linkHref="foo" italic="true">F</$text>' +
+					'<$text linkHref="foo" bold="true">o</$text>' +
+					'<$text linkHref="foo" bold="true" italic="true">o</$text>' +
+					'<$text linkHref="foo" bold="true">B</$text>' +
+					'<$text linkHref="foo" bold="true" italic="true">a]</$text>' +
+					'<$text linkHref="foo">r</$text>' +
+					' from ' +
+					'<$text linkHref="bar">Bar</$text>' +
+					'.' +
+				'</paragraph>'
+			);
+
+			editor.execute( 'input', {
+				text: 'Abcde'
+			} );
+
+			expect( getModelData( model ) ).to.equal(
+				'<paragraph>' +
+					'This is ' +
+					'<$text italic="true" linkHref="foo">Abcde[]</$text>' +
+					'<$text linkHref="foo">r</$text>' +
+					' from ' +
+					'<$text linkHref="bar">Bar</$text>' +
+					'.' +
+				'</paragraph>'
+			);
+		} );
+
+		it( 'should preserve all attributes of the link node (decorators check)', () => {
+			setModelData( model,
+				'<paragraph>' +
+					'This is ' +
+					'<$text linkIsFoo="true" linkIsBar="true" linkHref="foo">[Foo]</$text>' +
+					' from ' +
+					'<$text linkHref="bar">Bar</$text>' +
+					'.' +
+				'</paragraph>'
+			);
+
+			editor.execute( 'input', {
+				text: 'Abcde'
+			} );
+
+			expect( getModelData( model ) ).to.equal(
+				'<paragraph>' +
+					'This is ' +
+					'<$text linkHref="foo" linkIsBar="true" linkIsFoo="true">Abcde</$text>[]' +
+					' from ' +
+					'<$text linkHref="bar">Bar</$text>' +
+					'.' +
+				'</paragraph>'
+			);
+		} );
+
+		it( 'should not preserve selection attributes when the changes are not caused by typing', () => {
+			setModelData( model,
+				'<paragraph>This is [<$text linkHref="foo">Foo</$text>] from <$text linkHref="bar">Bar</$text>.</paragraph>'
+			);
+
+			model.change( writer => {
+				model.deleteContent( model.document.selection );
+				model.insertContent( writer.createText( 'Abcde' ) );
+			} );
+
+			expect( getModelData( model ) ).to.equal(
+				'<paragraph>This is Abcde[] from <$text linkHref="bar">Bar</$text>.</paragraph>'
+			);
+		} );
+
+		it( 'should not preserve selection attributes when the changes are not caused by typing (pasting check)', () => {
+			setModelData( model,
+				'<paragraph>This is [<$text linkHref="foo">Foo</$text>] from <$text linkHref="bar">Bar</$text>.</paragraph>'
+			);
+
+			view.document.fire( 'paste', {
+				dataTransfer: createDataTransfer( {
+					'text/html': '<p>Abcde</p>',
+					'text/plain': 'Abcde'
+				} ),
+				preventDefault: sinon.spy(),
+				stopPropagation: sinon.spy()
+			} );
+
+			expect( getModelData( model ) ).to.equal(
+				'<paragraph>This is Abcde[] from <$text linkHref="bar">Bar</$text>.</paragraph>'
+			);
+		} );
+
+		it( 'should not preserve selection attributes when typed after cutting the content', () => {
+			setModelData( model,
+				'<paragraph>This is [<$text linkHref="foo">Foo</$text>] from <$text linkHref="bar">Bar</$text>.</paragraph>'
+			);
+
+			view.document.fire( 'cut', {
+				dataTransfer: createDataTransfer(),
+				preventDefault: sinon.spy(),
+				stopPropagation: sinon.spy()
+			} );
+
+			editor.execute( 'input', {
+				text: 'Abcde'
+			} );
+
+			expect( getModelData( model ) ).to.equal(
+				'<paragraph>This is Abcde[] from <$text linkHref="bar">Bar</$text>.</paragraph>'
+			);
+		} );
+
+		it( 'should not preserve anything if selected an element instead of text', () => {
+			setModelData( model,
+				'[<image src="/assets/sample.png"></image>]'
+			);
+
+			editor.execute( 'input', {
+				text: 'Abcde'
+			} );
+
+			expect( getModelData( model ) ).to.equal(
+				'Abcde[]'
+			);
+		} );
+
+		it( 'should not preserve anything if selected text does not have the `linkHref` attribute`', () => {
+			setModelData( model,
+				'<paragraph>This is [<$text bold="foo">Foo</$text>] from <$text linkHref="bar">Bar</$text>.</paragraph>'
+			);
+
+			editor.execute( 'input', {
+				text: 'Abcde'
+			} );
+
+			expect( getModelData( model ) ).to.equal(
+				'<paragraph>This is Abcde[] from <$text linkHref="bar">Bar</$text>.</paragraph>'
+			);
+		} );
+
+		it( 'should not preserve selection attributes when the entire link is selected and pressed "Backspace"', () => {
+			setModelData( model,
+				'<paragraph>This is [<$text linkHref="foo">Foo</$text>] from <$text linkHref="bar">Bar</$text>.</paragraph>'
+			);
+
+			view.document.fire( 'delete', new DomEventData( view.document, {
+				keyCode: keyCodes.backspace,
+				preventDefault: () => {}
+			} ) );
+
+			editor.execute( 'input', {
+				text: 'Abcde'
+			} );
+
+			expect( getModelData( model ) ).to.equal(
+				'<paragraph>This is Abcde[] from <$text linkHref="bar">Bar</$text>.</paragraph>'
+			);
+		} );
+
+		it( 'should not preserve selection attributes when the entire link is selected and pressed "Delete"', () => {
+			setModelData( model,
+				'<paragraph>This is [<$text linkHref="foo">Foo</$text>] from <$text linkHref="bar">Bar</$text>.</paragraph>'
+			);
+
+			view.document.fire( 'delete', new DomEventData( view.document, {
+				keyCode: keyCodes.delete,
+				preventDefault: () => {}
+			} ) );
+
+			editor.execute( 'input', {
+				text: 'Abcde'
+			} );
+
+			expect( getModelData( model ) ).to.equal(
+				'<paragraph>This is Abcde[] from <$text linkHref="bar">Bar</$text>.</paragraph>'
+			);
+		} );
+
+		it( 'should not preserve selection attributes when selected different links', () => {
+			setModelData( model,
+				'<paragraph>This is <$text linkHref="foo">[Foo</$text> from <$text linkHref="bar">Bar]</$text>.</paragraph>'
+			);
+
+			editor.execute( 'input', {
+				text: 'Abcde'
+			} );
+
+			expect( getModelData( model ) ).to.equal( '<paragraph>This is Abcde[].</paragraph>' );
+		} );
+
+		it( 'should not preserve selection attributes when selected more than single link (start of the selection)', () => {
+			setModelData( model,
+				'<paragraph>This is[ <$text linkHref="foo">Foo]</$text> from <$text linkHref="bar">Bar</$text>.</paragraph>'
+			);
+
+			editor.execute( 'input', {
+				text: 'Abcde'
+			} );
+
+			expect( getModelData( model ) ).to.equal(
+				'<paragraph>This isAbcde[] from <$text linkHref="bar">Bar</$text>.</paragraph>'
+			);
+		} );
+
+		it( 'should not preserve selection attributes when selected more than single link (end of the selection)', () => {
+			setModelData( model,
+				'<paragraph>This is <$text linkHref="foo">[Foo</$text> ]from <$text linkHref="bar">Bar</$text>.</paragraph>'
+			);
+
+			editor.execute( 'input', {
+				text: 'Abcde'
+			} );
+
+			expect( getModelData( model ) ).to.equal(
+				'<paragraph>This is Abcde[]from <$text linkHref="bar">Bar</$text>.</paragraph>'
+			);
+		} );
+
+		function createDataTransfer( data ) {
+			return {
+				getData( type ) {
+					return data[ type ];
+				},
+				setData() {}
+			};
+		}
+	} );
 } );

+ 385 - 1
packages/ckeditor5-link/tests/linkimageediting.js

@@ -11,6 +11,12 @@ import normalizeHtml from '@ckeditor/ckeditor5-utils/tests/_utils/normalizehtml'
 import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
 import ImageCaptionEditing from '@ckeditor/ckeditor5-image/src/imagecaption/imagecaptionediting';
 
+import linkIcon from '../theme/icons/link.svg';
+
+// We can import the SVG code directly to avoid re-edit, but we have to make sure that the icon code has `</path>` closing tag.
+// After cleaning up the icons, the closing tag can be re-parsed and some tests will fail.
+const linkIconInditatorElement = '<span class="ck ck-link-image_icon">' + linkIcon + '</span>';
+
 describe( 'LinkImageEditing', () => {
 	let editor, model, view;
 
@@ -44,6 +50,21 @@ describe( 'LinkImageEditing', () => {
 
 	describe( 'conversion in data pipeline', () => {
 		describe( 'model to view', () => {
+			it( 'should attach a link indicator to the image element', () => {
+				setModelData( model, '<image src="/assets/sample.png" alt="alt text" linkHref="http://ckeditor.com"></image>' );
+
+				expect( getViewData( view, { withoutSelection: true, renderUIElements: true } ) ).to.equal(
+					'<figure class="ck-widget image" contenteditable="false">' +
+						'<a href="http://ckeditor.com">' +
+							'<img alt="alt text" src="/assets/sample.png"></img>' +
+							linkIconInditatorElement +
+						'</a>' +
+					'</figure>'
+				);
+			} );
+		} );
+
+		describe( 'model to data', () => {
 			it( 'should convert an image with a link', () => {
 				setModelData( model, '<image src="/assets/sample.png" alt="alt text" linkHref="http://ckeditor.com"></image>' );
 
@@ -264,6 +285,8 @@ describe( 'LinkImageEditing', () => {
 					'<figure class="ck-widget image" contenteditable="false">' +
 						'<a href="http://ckeditor.com">' +
 							'<img alt="alt text" src="/assets/sample.png"></img>' +
+							// Content of the UIElement is skipped here.
+							'<span class="ck ck-link-image_icon"></span>' +
 						'</a>' +
 					'</figure>'
 				);
@@ -281,6 +304,8 @@ describe( 'LinkImageEditing', () => {
 					'<figure class="ck-widget image" contenteditable="false">' +
 						'<a href="https://ckeditor.com/why-ckeditor/">' +
 							'<img alt="alt text" src="/assets/sample.png"></img>' +
+							// Content of the UIElement is skipped here.
+							'<span class="ck ck-link-image_icon"></span>' +
 						'</a>' +
 					'</figure>'
 				);
@@ -302,7 +327,7 @@ describe( 'LinkImageEditing', () => {
 			} );
 		} );
 
-		describe( 'figure > a > img + figcaption', () => {
+		describe( 'figure > a > img + span + figcaption', () => {
 			it( 'should convert a link and the caption element', () => {
 				return VirtualTestEditor
 					.create( {
@@ -319,6 +344,8 @@ describe( 'LinkImageEditing', () => {
 							'<figure class="ck-widget image" contenteditable="false">' +
 								'<a href="http://ckeditor.com">' +
 									'<img alt="alt text" src="/assets/sample.png"></img>' +
+									// Content of the UIElement is skipped here.
+									'<span class="ck ck-link-image_icon"></span>' +
 								'</a>' +
 								'<figcaption class="ck-editor__editable ck-editor__nested-editable" ' +
 									'contenteditable="true" data-placeholder="Enter image caption">' +
@@ -331,4 +358,361 @@ describe( 'LinkImageEditing', () => {
 			} );
 		} );
 	} );
+
+	describe( 'link attributes decorator', () => {
+		describe( 'default behavior', () => {
+			const testLinks = [
+				{
+					external: true,
+					url: 'http://example.com'
+				}, {
+					external: true,
+					url: 'https://cksource.com'
+				}, {
+					external: false,
+					url: 'ftp://server.io'
+				}, {
+					external: true,
+					url: '//schemaless.org'
+				}, {
+					external: false,
+					url: 'www.ckeditor.com'
+				}, {
+					external: false,
+					url: '/relative/url.html'
+				}, {
+					external: false,
+					url: 'another/relative/url.html'
+				}, {
+					external: false,
+					url: '#anchor'
+				}, {
+					external: false,
+					url: 'mailto:some@user.org'
+				}, {
+					external: false,
+					url: 'tel:123456789'
+				}
+			];
+
+			describe( 'for link.addTargetToExternalLinks=false', () => {
+				let editor, model;
+
+				beforeEach( async () => {
+					editor = await VirtualTestEditor.create( {
+						plugins: [ Paragraph, LinkImageEditing ],
+						link: {
+							addTargetToExternalLinks: false
+						}
+					} );
+
+					model = editor.model;
+					view = editor.editing.view;
+				} );
+
+				afterEach( async () => {
+					await editor.destroy();
+				} );
+
+				testLinks.forEach( link => {
+					it( `link: ${ link.url } should not get 'target' and 'rel' attributes`, () => {
+						// Upcast check.
+						editor.setData(
+							'<figure class="image">' +
+								`<a href="${ link.url }" target="_blank" rel="noopener noreferrer">` +
+									'<img src="/assets/sample.png">' +
+								'</a>' +
+							'</figure>'
+						);
+
+						expect( getModelData( model, { withoutSelection: true } ) )
+							.to.equal( `<image linkHref="${ link.url }" src="/assets/sample.png"></image>` );
+
+						// Downcast check.
+						expect( editor.getData() ).to.equal(
+							'<figure class="image">' +
+								`<a href="${ link.url }">` +
+									'<img src="/assets/sample.png">' +
+								'</a>' +
+							'</figure>'
+						);
+					} );
+				} );
+			} );
+
+			describe( 'for link.addTargetToExternalLinks=true', () => {
+				let editor, model;
+
+				beforeEach( async () => {
+					editor = await VirtualTestEditor.create( {
+						plugins: [ Paragraph, LinkImageEditing ],
+						link: {
+							addTargetToExternalLinks: true
+						}
+					} );
+
+					model = editor.model;
+					view = editor.editing.view;
+				} );
+
+				afterEach( async () => {
+					await editor.destroy();
+				} );
+
+				testLinks.forEach( link => {
+					it( `link: ${ link.url } should be treat as ${ link.external ? 'external' : 'non-external' } link`, () => {
+						// Upcast check.
+						editor.setData(
+							`<a href="${ link.url }" target="_blank" rel="noopener noreferrer"><img src="/assets/sample.png"></a>`
+						);
+
+						expect( getModelData( model, { withoutSelection: true } ) )
+							.to.equal( `<image linkHref="${ link.url }" src="/assets/sample.png"></image>` );
+
+						// Downcast check.
+						if ( link.external ) {
+							expect( editor.getData() ).to.equal(
+								'<figure class="image">' +
+									`<a href="${ link.url }" target="_blank" rel="noopener noreferrer">` +
+										'<img src="/assets/sample.png">' +
+									'</a>' +
+								'</figure>'
+							);
+						} else {
+							expect( editor.getData() ).to.equal(
+								'<figure class="image">' +
+									`<a href="${ link.url }">` +
+										'<img src="/assets/sample.png">' +
+									'</a>' +
+								'</figure>'
+							);
+						}
+					} );
+				} );
+			} );
+		} );
+
+		describe( 'custom config', () => {
+			describe( 'mode: automatic', () => {
+				let editor;
+
+				const testLinks = [
+					{
+						url: 'relative/url.html',
+						attributes: {}
+					}, {
+						url: 'http://exmaple.com',
+						attributes: {
+							target: '_blank'
+						}
+					}, {
+						url: 'https://example.com/download/link.pdf',
+						attributes: {
+							target: '_blank',
+							download: 'download'
+						}
+					}, {
+						url: 'mailto:some@person.io',
+						attributes: {
+							class: 'mail-url'
+						}
+					}
+				];
+
+				beforeEach( async () => {
+					editor = await VirtualTestEditor.create( {
+						plugins: [ Paragraph, LinkImageEditing ],
+						link: {
+							addTargetToExternalLinks: false,
+							decorators: {
+								isExternal: {
+									mode: 'automatic',
+									callback: url => url.startsWith( 'http' ),
+									attributes: {
+										target: '_blank'
+									}
+								},
+								isDownloadable: {
+									mode: 'automatic',
+									callback: url => url.includes( 'download' ),
+									attributes: {
+										download: 'download'
+									}
+								},
+								isMail: {
+									mode: 'automatic',
+									callback: url => url.startsWith( 'mailto:' ),
+									attributes: {
+										class: 'mail-url'
+									}
+								}
+							}
+						}
+					} );
+
+					model = editor.model;
+				} );
+
+				afterEach( () => {
+					return editor.destroy();
+				} );
+
+				testLinks.forEach( link => {
+					it( `Link: ${ link.url } should get attributes: ${ JSON.stringify( link.attributes ) }`, () => {
+						const ORDER = [ 'class', 'href', 'target', 'download' ];
+						const attributes = Object.assign( {}, link.attributes, {
+							href: link.url
+						} );
+						const attr = Object.entries( attributes ).sort( ( a, b ) => {
+							const aIndex = ORDER.indexOf( a[ 0 ] );
+							const bIndex = ORDER.indexOf( b[ 0 ] );
+							return aIndex - bIndex;
+						} );
+						const reducedAttr = attr.reduce( ( acc, cur ) => {
+							return acc + `${ cur[ 0 ] }="${ cur[ 1 ] }" `;
+						}, '' ).trim();
+
+						editor.setData( `<a href="${ link.url }"><img src="/assets/sample.png"></a>` );
+
+						expect( getModelData( model, { withoutSelection: true } ) )
+							.to.equal( `<image linkHref="${ link.url }" src="/assets/sample.png"></image>` );
+
+						// Order of attributes is important, that's why this is assert is construct in such way.
+						expect( editor.getData() ).to.equal(
+							'<figure class="image">' +
+								`<a ${ reducedAttr }>` +
+									'<img src="/assets/sample.png">' +
+								'</a>' +
+							'</figure>'
+						);
+					} );
+				} );
+			} );
+		} );
+
+		describe( 'upcast converter', () => {
+			let editor;
+
+			it( 'should upcast attributes from initial data', async () => {
+				editor = await VirtualTestEditor.create( {
+					initialData: '<figure class="image"><a href="url" target="_blank" rel="noopener noreferrer" download="file">' +
+						'<img src="/assets/sample.png"></a></figure>',
+					plugins: [ Paragraph, LinkImageEditing ],
+					link: {
+						decorators: {
+							isExternal: {
+								mode: 'manual',
+								label: 'Open in a new window',
+								attributes: {
+									target: '_blank',
+									rel: 'noopener noreferrer'
+								}
+							},
+							isDownloadable: {
+								mode: 'manual',
+								label: 'Downloadable',
+								attributes: {
+									download: 'file'
+								}
+							}
+						}
+					}
+				} );
+
+				model = editor.model;
+
+				expect( getModelData( model, { withoutSelection: true } ) ).to.equal(
+					'<image linkHref="url" linkIsDownloadable="true" linkIsExternal="true" src="/assets/sample.png"></image>'
+				);
+
+				await editor.destroy();
+			} );
+
+			it( 'should not upcast partial and incorrect attributes', async () => {
+				editor = await VirtualTestEditor.create( {
+					initialData: '<figure class="image"><a href="url" target="_blank" rel="noopener noreferrer" download="something">' +
+						'<img src="/assets/sample.png"></a></figure>',
+					plugins: [ Paragraph, LinkImageEditing ],
+					link: {
+						decorators: {
+							isExternal: {
+								mode: 'manual',
+								label: 'Open in a new window',
+								attributes: {
+									target: '_blank',
+									rel: 'noopener noreferrer'
+								}
+							},
+							isDownloadable: {
+								mode: 'manual',
+								label: 'Downloadable',
+								attributes: {
+									download: 'file'
+								}
+							}
+						}
+					}
+				} );
+
+				model = editor.model;
+
+				expect( getModelData( model, { withoutSelection: true } ) ).to.equal(
+					'<image linkHref="url" linkIsExternal="true" src="/assets/sample.png"></image>'
+				);
+
+				await editor.destroy();
+			} );
+
+			it( 'allows overwriting conversion process using highest priority', async () => {
+				editor = await VirtualTestEditor.create( {
+					initialData: '',
+					plugins: [ Paragraph, LinkImageEditing ],
+					link: {
+						decorators: {
+							isExternal: {
+								mode: 'manual',
+								label: 'Open in a new window',
+								attributes: {
+									target: '_blank',
+									rel: 'noopener noreferrer'
+								}
+							},
+							isDownloadable: {
+								mode: 'manual',
+								label: 'Downloadable',
+								attributes: {
+									download: 'file'
+								}
+							}
+						}
+					}
+				} );
+
+				model = editor.model;
+
+				// Block manual decorator converter. Consume all attributes and do nothing with them.
+				editor.conversion.for( 'upcast' ).add( dispatcher => {
+					dispatcher.on( 'element:a', ( evt, data, conversionApi ) => {
+						const consumableAttributes = {
+							attributes: [ 'target', 'rel', 'download' ]
+						};
+
+						conversionApi.consumable.consume( data.viewItem, consumableAttributes );
+					}, { priority: 'highest' } );
+				} );
+
+				editor.setData(
+					'<figure class="image">' +
+						'<a href="url" target="_blank" rel="noopener noreferrer" download="something">' +
+							'<img src="/assets/sample.png">' +
+						'</a>' +
+					'</figure>'
+				);
+
+				expect( editor.getData() ).to.equal( '<figure class="image"><a href="url"><img src="/assets/sample.png"></a></figure>' );
+
+				await editor.destroy();
+			} );
+		} );
+	} );
 } );

+ 8 - 0
packages/ckeditor5-link/tests/linkui.js

@@ -481,6 +481,9 @@ describe( 'LinkUI', () => {
 			const markerRange = editor.model.markers.get( 'link-ui' ).getRange();
 
 			expect( markerRange.isEqual( expectedRange ) ).to.be.true;
+
+			expect( getViewData( editor.editing.view ) ).to.equal( '<p>f{<span class="ck-fake-link-selection">o</span>}o</p>' );
+			expect( editor.getData() ).to.equal( '<p>foo</p>' );
 		} );
 
 		it( 'should display a fake visual selection on a collapsed selection', () => {
@@ -498,6 +501,11 @@ describe( 'LinkUI', () => {
 			const markerRange = editor.model.markers.get( 'link-ui' ).getRange();
 
 			expect( markerRange.isEqual( expectedRange ) ).to.be.true;
+
+			expect( getViewData( editor.editing.view ) ).to.equal(
+				'<p>f{}<span class="ck-fake-link-selection ck-fake-link-selection_collapsed"></span>o</p>'
+			);
+			expect( editor.getData() ).to.equal( '<p>fo</p>' );
 		} );
 	} );
 

+ 10 - 8
packages/ckeditor5-link/tests/manual/linkdecorator.js

@@ -6,12 +6,8 @@
 /* globals console:false, window, document, CKEditorInspector */
 
 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';
-import Link from '../../src/link';
-import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
-import Undo from '@ckeditor/ckeditor5-undo/src/undo';
-import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard';
+import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset';
+import LinkImage from '../../src/linkimage';
 
 // Just to have nicely styles switchbutton;
 import '@ckeditor/ckeditor5-theme-lark/theme/ckeditor5-ui/components/list/list.css';
@@ -20,7 +16,7 @@ window.editors = {};
 
 ClassicEditor
 	.create( document.querySelector( '#editor' ), {
-		plugins: [ Link, Typing, Paragraph, Clipboard, Undo, Enter ],
+		plugins: [ ArticlePluginSet, LinkImage ],
 		toolbar: [ 'link', 'undo', 'redo' ],
 		link: {
 			decorators: {
@@ -47,6 +43,9 @@ ClassicEditor
 					}
 				}
 			}
+		},
+		image: {
+			toolbar: [ 'imageStyle:full', 'imageStyle:side', '|', 'imageTextAlternative', '|', 'linkImage' ]
 		}
 	} )
 	.then( editor => {
@@ -59,7 +58,7 @@ ClassicEditor
 
 ClassicEditor
 	.create( document.querySelector( '#editor2' ), {
-		plugins: [ Link, Typing, Paragraph, Clipboard, Undo, Enter ],
+		plugins: [ ArticlePluginSet, LinkImage ],
 		toolbar: [ 'link', 'undo', 'redo' ],
 		link: {
 			decorators: {
@@ -79,6 +78,9 @@ ClassicEditor
 				}
 			},
 			addTargetToExternalLinks: true
+		},
+		image: {
+			toolbar: [ 'imageStyle:full', 'imageStyle:side', '|', 'imageTextAlternative', '|', 'linkImage' ]
 		}
 	} )
 	.then( editor => {

+ 3 - 1
packages/ckeditor5-link/tests/manual/linkimage.md

@@ -1,3 +1,5 @@
 # Linking Image
 
-Both images should have defined the `linkHref` attribute.
+Only the first image should have:
+- defined the `linkHref` attribute.
+- the linked image icon indicator in the top-right corner (the element should be placed inside the `<a>` element).

+ 31 - 2
packages/ckeditor5-link/tests/utils.js

@@ -8,8 +8,9 @@ import ViewDowncastWriter from '@ckeditor/ckeditor5-engine/src/view/downcastwrit
 import AttributeElement from '@ckeditor/ckeditor5-engine/src/view/attributeelement';
 import ContainerElement from '@ckeditor/ckeditor5-engine/src/view/containerelement';
 import Text from '@ckeditor/ckeditor5-engine/src/view/text';
-
-import { createLinkElement, isLinkElement, ensureSafeUrl, normalizeDecorators } from '../src/utils';
+import Schema from '@ckeditor/ckeditor5-engine/src/model/schema';
+import ModelElement from '@ckeditor/ckeditor5-engine/src/model/element';
+import { createLinkElement, isLinkElement, ensureSafeUrl, normalizeDecorators, isImageAllowed } from '../src/utils';
 
 describe( 'utils', () => {
 	describe( 'isLinkElement()', () => {
@@ -215,4 +216,32 @@ describe( 'utils', () => {
 			] );
 		} );
 	} );
+
+	describe( 'isImageAllowed()', () => {
+		it( 'returns false when passed "null" as element', () => {
+			expect( isImageAllowed( null, new Schema() ) ).to.equal( false );
+		} );
+
+		it( 'returns false when passed an element that is not the image element', () => {
+			const element = new ModelElement( 'paragraph' );
+			expect( isImageAllowed( element, new Schema() ) ).to.equal( false );
+		} );
+
+		it( 'returns false when schema does not allow linking images', () => {
+			const element = new ModelElement( 'image' );
+			expect( isImageAllowed( element, new Schema() ) ).to.equal( false );
+		} );
+
+		it( 'returns true when passed an image element and it can be linked', () => {
+			const element = new ModelElement( 'image' );
+			const schema = new Schema();
+
+			schema.register( 'image', {
+				allowIn: '$root',
+				allowAttributes: [ 'linkHref' ]
+			} );
+
+			expect( isImageAllowed( element, schema ) ).to.equal( true );
+		} );
+	} );
 } );

+ 6 - 0
packages/ckeditor5-link/tests/utils/automaticdecorators.js

@@ -95,4 +95,10 @@ describe( 'Automatic Decorators', () => {
 			expect( automaticDecorators.getDispatcher() ).to.be.a( 'function' );
 		} );
 	} );
+
+	describe( 'getDispatcherForLinkedImage()', () => {
+		it( 'should return a dispatcher function', () => {
+			expect( automaticDecorators.getDispatcherForLinkedImage() ).to.be.a( 'function' );
+		} );
+	} );
 } );

+ 1 - 1
packages/ckeditor5-link/theme/icons/link.svg

@@ -1 +1 @@
-<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M11.077 15l.991-1.416a.75.75 0 1 1 1.229.86l-1.148 1.64a.748.748 0 0 1-.217.206 5.251 5.251 0 0 1-8.503-5.955.741.741 0 0 1 .12-.274l1.147-1.639a.75.75 0 1 1 1.228.86L4.933 10.7l.006.003a3.75 3.75 0 0 0 6.132 4.294l.006.004zm5.494-5.335a.748.748 0 0 1-.12.274l-1.147 1.639a.75.75 0 1 1-1.228-.86l.86-1.23a3.75 3.75 0 0 0-6.144-4.301l-.86 1.229a.75.75 0 0 1-1.229-.86l1.148-1.64a.748.748 0 0 1 .217-.206 5.251 5.251 0 0 1 8.503 5.955zm-4.563-2.532a.75.75 0 0 1 .184 1.045l-3.155 4.505a.75.75 0 1 1-1.229-.86l3.155-4.506a.75.75 0 0 1 1.045-.184z"/></svg>
+<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M11.077 15l.991-1.416a.75.75 0 1 1 1.229.86l-1.148 1.64a.748.748 0 0 1-.217.206 5.251 5.251 0 0 1-8.503-5.955.741.741 0 0 1 .12-.274l1.147-1.639a.75.75 0 1 1 1.228.86L4.933 10.7l.006.003a3.75 3.75 0 0 0 6.132 4.294l.006.004zm5.494-5.335a.748.748 0 0 1-.12.274l-1.147 1.639a.75.75 0 1 1-1.228-.86l.86-1.23a3.75 3.75 0 0 0-6.144-4.301l-.86 1.229a.75.75 0 0 1-1.229-.86l1.148-1.64a.748.748 0 0 1 .217-.206 5.251 5.251 0 0 1 8.503 5.955zm-4.563-2.532a.75.75 0 0 1 .184 1.045l-3.155 4.505a.75.75 0 1 1-1.229-.86l3.155-4.506a.75.75 0 0 1 1.045-.184z"></path></svg>

+ 19 - 0
packages/ckeditor5-link/theme/linkimage.css

@@ -0,0 +1,19 @@
+/*
+ * Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+.ck.ck-link-image_icon {
+	position: absolute;
+	top: var(--ck-spacing-medium);
+	right: var(--ck-spacing-medium);
+	width: 28px;
+	height: 28px;
+	padding: 4px;
+	box-sizing: border-box;
+	border-radius: var(--ck-border-radius);
+
+	& svg {
+		fill: currentColor;
+	}
+}

+ 24 - 11
packages/ckeditor5-page-break/src/pagebreakediting.js

@@ -10,7 +10,6 @@
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import PageBreakCommand from './pagebreakcommand';
 import { toWidget } from '@ckeditor/ckeditor5-widget/src/utils';
-import first from '@ckeditor/ckeditor5-utils/src/first';
 
 import '../theme/pagebreak.css';
 
@@ -85,27 +84,41 @@ export default class PageBreakEditing extends Plugin {
 		conversion.for( 'upcast' )
 			.elementToElement( {
 				view: element => {
-					// The "page break" div must have specified value for the 'page-break-after' definition and single child only.
-					if ( !element.is( 'div' ) || element.getStyle( 'page-break-after' ) != 'always' || element.childCount != 1 ) {
-						return;
-					}
+					// For upcast conversion it's enough if we check for element style and verify if it's empty
+					// or contains only hidden span element.
 
-					const viewSpan = first( element.getChildren() );
+					const hasPageBreakBefore = element.getStyle( 'page-break-before' ) == 'always';
+					const hasPageBreakAfter = element.getStyle( 'page-break-after' ) == 'always';
 
-					// The child must be the "span" element that is not displayed and has a space inside.
-					if ( !viewSpan.is( 'span' ) || viewSpan.getStyle( 'display' ) != 'none' || viewSpan.childCount != 1 ) {
+					if ( !hasPageBreakBefore && !hasPageBreakAfter ) {
 						return;
 					}
 
-					const text = first( viewSpan.getChildren() );
+					// The "page break" div accepts only single child or no child at all.
+					if ( element.childCount == 1 ) {
+						const viewSpan = element.getChild( 0 );
+
+						// The child must be the "span" element that is not displayed and has a space inside.
+						if ( !viewSpan.is( 'span' ) || viewSpan.getStyle( 'display' ) != 'none' || viewSpan.childCount != 1 ) {
+							return;
+						}
 
-					if ( !text.is( 'text' ) || text.data !== ' ' ) {
+						const text = viewSpan.getChild( 0 );
+
+						if ( !text.is( 'text' ) || text.data !== ' ' ) {
+							return;
+						}
+					} else if ( element.childCount > 1 ) {
 						return;
 					}
 
 					return { name: true };
 				},
-				model: 'pageBreak'
+				model: 'pageBreak',
+
+				// This conversion must be checked before <br> conversion because some editors use
+				// <br style="page-break-before:always"> as a page break marker.
+				converterPriority: 'high'
 			} );
 
 		editor.commands.add( 'pageBreak', new PageBreakCommand( editor ) );

+ 30 - 2
packages/ckeditor5-page-break/tests/pagebreakediting.js

@@ -113,11 +113,39 @@ describe( 'PageBreakEditing', () => {
 					.to.equal( '' );
 			} );
 
-			it( 'should not convert if outer div has no children', () => {
+			it( 'should convert if outer div has no children', () => {
 				editor.setData( '<div class="page-break" style="page-break-after:always;"></div>' );
 
 				expect( getModelData( model, { withoutSelection: true } ) )
-					.to.equal( '' );
+					.to.equal( '<pageBreak></pageBreak>' );
+			} );
+
+			it( 'should convert if outer div has page-break-before style', () => {
+				editor.setData( '<div class="page-break" style="page-break-before:always;"></div>' );
+
+				expect( getModelData( model, { withoutSelection: true } ) )
+					.to.equal( '<pageBreak></pageBreak>' );
+			} );
+
+			it( 'should convert if outer div has page-break-after style', () => {
+				editor.setData( '<div class="page-break" style="page-break-after:always;"></div>' );
+
+				expect( getModelData( model, { withoutSelection: true } ) )
+					.to.equal( '<pageBreak></pageBreak>' );
+			} );
+
+			it( 'should convert if page-break-after style is on br element', () => {
+				editor.setData( '<br style="page-break-after:always;"/>' );
+
+				expect( getModelData( model, { withoutSelection: true } ) )
+					.to.equal( '<pageBreak></pageBreak>' );
+			} );
+
+			it( 'should convert if page-break-before style is on br element', () => {
+				editor.setData( '<br style="page-break-after:always;"/>' );
+
+				expect( getModelData( model, { withoutSelection: true } ) )
+					.to.equal( '<pageBreak></pageBreak>' );
 			} );
 
 			it( 'should not convert if outer div has too many children', () => {

+ 1 - 0
packages/ckeditor5-paste-from-office/package.json

@@ -26,6 +26,7 @@
     "@ckeditor/ckeditor5-link": "^20.0.0",
     "@ckeditor/ckeditor5-list": "^20.0.0",
     "@ckeditor/ckeditor5-paragraph": "^20.0.0",
+    "@ckeditor/ckeditor5-page-break": "^20.0.0",
     "@ckeditor/ckeditor5-table": "^20.0.0",
     "@ckeditor/ckeditor5-utils": "^20.0.0"
   },

+ 16 - 0
packages/ckeditor5-paste-from-office/tests/_data/page-break/index.js

@@ -0,0 +1,16 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import pageBreak from './page-break/input.html';
+import pageBreakModel from './page-break/model.html';
+
+export const fixtures = {
+	input: {
+		pageBreak
+	},
+	model: {
+		pageBreak: pageBreakModel
+	}
+};

+ 738 - 0
packages/ckeditor5-paste-from-office/tests/_data/page-break/page-break/input.html

@@ -0,0 +1,738 @@
+<html xmlns:o="urn:schemas-microsoft-com:office:office"
+	  xmlns:w="urn:schemas-microsoft-com:office:word"
+	  xmlns:m="http://schemas.microsoft.com/office/2004/12/omml"
+	  xmlns="http://www.w3.org/TR/REC-html40">
+
+<head>
+	<meta http-equiv=Content-Type content="text/html; charset=utf-8">
+	<meta name=ProgId content=Word.Document>
+	<meta name=Generator content="Microsoft Word 15">
+	<meta name=Originator content="Microsoft Word 15">
+	<link rel=File-List
+		  href="file:////Users/kuba/Library/Group%20Containers/UBF8T346G9.Office/TemporaryItems/msohtmlclip/clip_filelist.xml">
+	<!--[if gte mso 9]><xml>
+	<o:OfficeDocumentSettings>
+		<o:AllowPNG/>
+	</o:OfficeDocumentSettings>
+</xml><![endif]-->
+	<link rel=themeData
+		  href="file:////Users/kuba/Library/Group%20Containers/UBF8T346G9.Office/TemporaryItems/msohtmlclip/clip_themedata.thmx">
+	<link rel=colorSchemeMapping
+		  href="file:////Users/kuba/Library/Group%20Containers/UBF8T346G9.Office/TemporaryItems/msohtmlclip/clip_colorschememapping.xml">
+	<!--[if gte mso 9]><xml>
+	<w:WordDocument>
+	<w:View>Normal</w:View>
+	<w:Zoom>0</w:Zoom>
+	<w:TrackMoves/>
+	<w:TrackFormatting/>
+	<w:HyphenationZone>21</w:HyphenationZone>
+	<w:PunctuationKerning/>
+	<w:ValidateAgainstSchemas/>
+	<w:SaveIfXMLInvalid>false</w:SaveIfXMLInvalid>
+	<w:IgnoreMixedContent>false</w:IgnoreMixedContent>
+	<w:AlwaysShowPlaceholderText>false</w:AlwaysShowPlaceholderText>
+	<w:DoNotPromoteQF/>
+	<w:LidThemeOther>PL</w:LidThemeOther>
+	<w:LidThemeAsian>X-NONE</w:LidThemeAsian>
+	<w:LidThemeComplexScript>X-NONE</w:LidThemeComplexScript>
+	<w:Compatibility>
+	<w:BreakWrappedTables/>
+	<w:SnapToGridInCell/>
+	<w:WrapTextWithPunct/>
+	<w:UseAsianBreakRules/>
+	<w:DontGrowAutofit/>
+	<w:SplitPgBreakAndParaMark/>
+	<w:EnableOpenTypeKerning/>
+	<w:DontFlipMirrorIndents/>
+	<w:OverrideTableStyleHps/>
+	</w:Compatibility>
+	<m:mathPr>
+	<m:mathFont m:val="Cambria Math"/>
+	<m:brkBin m:val="before"/>
+	<m:brkBinSub m:val="&#45;-"/>
+	<m:smallFrac m:val="off"/>
+	<m:dispDef/>
+	<m:lMargin m:val="0"/>
+	<m:rMargin m:val="0"/>
+	<m:defJc m:val="centerGroup"/>
+	<m:wrapIndent m:val="1440"/>
+	<m:intLim m:val="subSup"/>
+	<m:naryLim m:val="undOvr"/>
+	</m:mathPr></w:WordDocument>
+	</xml><![endif]--><!--[if gte mso 9]><xml>
+	<w:LatentStyles DefLockedState="false" DefUnhideWhenUsed="false"
+					DefSemiHidden="false" DefQFormat="false" DefPriority="99"
+					LatentStyleCount="376">
+		<w:LsdException Locked="false" Priority="0" QFormat="true" Name="Normal"/>
+		<w:LsdException Locked="false" Priority="9" QFormat="true" Name="heading 1"/>
+		<w:LsdException Locked="false" Priority="9" SemiHidden="true"
+						UnhideWhenUsed="true" QFormat="true" Name="heading 2"/>
+		<w:LsdException Locked="false" Priority="9" SemiHidden="true"
+						UnhideWhenUsed="true" QFormat="true" Name="heading 3"/>
+		<w:LsdException Locked="false" Priority="9" SemiHidden="true"
+						UnhideWhenUsed="true" QFormat="true" Name="heading 4"/>
+		<w:LsdException Locked="false" Priority="9" SemiHidden="true"
+						UnhideWhenUsed="true" QFormat="true" Name="heading 5"/>
+		<w:LsdException Locked="false" Priority="9" SemiHidden="true"
+						UnhideWhenUsed="true" QFormat="true" Name="heading 6"/>
+		<w:LsdException Locked="false" Priority="9" SemiHidden="true"
+						UnhideWhenUsed="true" QFormat="true" Name="heading 7"/>
+		<w:LsdException Locked="false" Priority="9" SemiHidden="true"
+						UnhideWhenUsed="true" QFormat="true" Name="heading 8"/>
+		<w:LsdException Locked="false" Priority="9" SemiHidden="true"
+						UnhideWhenUsed="true" QFormat="true" Name="heading 9"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="index 1"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="index 2"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="index 3"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="index 4"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="index 5"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="index 6"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="index 7"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="index 8"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="index 9"/>
+		<w:LsdException Locked="false" Priority="39" SemiHidden="true"
+						UnhideWhenUsed="true" Name="toc 1"/>
+		<w:LsdException Locked="false" Priority="39" SemiHidden="true"
+						UnhideWhenUsed="true" Name="toc 2"/>
+		<w:LsdException Locked="false" Priority="39" SemiHidden="true"
+						UnhideWhenUsed="true" Name="toc 3"/>
+		<w:LsdException Locked="false" Priority="39" SemiHidden="true"
+						UnhideWhenUsed="true" Name="toc 4"/>
+		<w:LsdException Locked="false" Priority="39" SemiHidden="true"
+						UnhideWhenUsed="true" Name="toc 5"/>
+		<w:LsdException Locked="false" Priority="39" SemiHidden="true"
+						UnhideWhenUsed="true" Name="toc 6"/>
+		<w:LsdException Locked="false" Priority="39" SemiHidden="true"
+						UnhideWhenUsed="true" Name="toc 7"/>
+		<w:LsdException Locked="false" Priority="39" SemiHidden="true"
+						UnhideWhenUsed="true" Name="toc 8"/>
+		<w:LsdException Locked="false" Priority="39" SemiHidden="true"
+						UnhideWhenUsed="true" Name="toc 9"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Normal Indent"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="footnote text"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="annotation text"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="header"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="footer"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="index heading"/>
+		<w:LsdException Locked="false" Priority="35" SemiHidden="true"
+						UnhideWhenUsed="true" QFormat="true" Name="caption"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="table of figures"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="envelope address"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="envelope return"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="footnote reference"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="annotation reference"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="line number"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="page number"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="endnote reference"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="endnote text"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="table of authorities"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="macro"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="toa heading"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="List"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="List Bullet"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="List Number"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="List 2"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="List 3"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="List 4"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="List 5"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="List Bullet 2"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="List Bullet 3"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="List Bullet 4"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="List Bullet 5"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="List Number 2"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="List Number 3"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="List Number 4"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="List Number 5"/>
+		<w:LsdException Locked="false" Priority="10" QFormat="true" Name="Title"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Closing"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Signature"/>
+		<w:LsdException Locked="false" Priority="1" SemiHidden="true"
+						UnhideWhenUsed="true" Name="Default Paragraph Font"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Body Text"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Body Text Indent"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="List Continue"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="List Continue 2"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="List Continue 3"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="List Continue 4"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="List Continue 5"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Message Header"/>
+		<w:LsdException Locked="false" Priority="11" QFormat="true" Name="Subtitle"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Salutation"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Date"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Body Text First Indent"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Body Text First Indent 2"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Note Heading"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Body Text 2"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Body Text 3"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Body Text Indent 2"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Body Text Indent 3"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Block Text"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Hyperlink"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="FollowedHyperlink"/>
+		<w:LsdException Locked="false" Priority="22" QFormat="true" Name="Strong"/>
+		<w:LsdException Locked="false" Priority="20" QFormat="true" Name="Emphasis"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Document Map"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Plain Text"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="E-mail Signature"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="HTML Top of Form"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="HTML Bottom of Form"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Normal (Web)"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="HTML Acronym"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="HTML Address"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="HTML Cite"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="HTML Code"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="HTML Definition"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="HTML Keyboard"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="HTML Preformatted"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="HTML Sample"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="HTML Typewriter"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="HTML Variable"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Normal Table"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="annotation subject"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="No List"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Outline List 1"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Outline List 2"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Outline List 3"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Table Simple 1"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Table Simple 2"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Table Simple 3"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Table Classic 1"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Table Classic 2"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Table Classic 3"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Table Classic 4"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Table Colorful 1"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Table Colorful 2"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Table Colorful 3"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Table Columns 1"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Table Columns 2"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Table Columns 3"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Table Columns 4"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Table Columns 5"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Table Grid 1"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Table Grid 2"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Table Grid 3"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Table Grid 4"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Table Grid 5"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Table Grid 6"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Table Grid 7"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Table Grid 8"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Table List 1"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Table List 2"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Table List 3"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Table List 4"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Table List 5"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Table List 6"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Table List 7"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Table List 8"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Table 3D effects 1"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Table 3D effects 2"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Table 3D effects 3"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Table Contemporary"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Table Elegant"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Table Professional"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Table Subtle 1"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Table Subtle 2"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Table Web 1"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Table Web 2"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Table Web 3"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Balloon Text"/>
+		<w:LsdException Locked="false" Priority="39" Name="Table Grid"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Table Theme"/>
+		<w:LsdException Locked="false" SemiHidden="true" Name="Placeholder Text"/>
+		<w:LsdException Locked="false" Priority="1" QFormat="true" Name="No Spacing"/>
+		<w:LsdException Locked="false" Priority="60" Name="Light Shading"/>
+		<w:LsdException Locked="false" Priority="61" Name="Light List"/>
+		<w:LsdException Locked="false" Priority="62" Name="Light Grid"/>
+		<w:LsdException Locked="false" Priority="63" Name="Medium Shading 1"/>
+		<w:LsdException Locked="false" Priority="64" Name="Medium Shading 2"/>
+		<w:LsdException Locked="false" Priority="65" Name="Medium List 1"/>
+		<w:LsdException Locked="false" Priority="66" Name="Medium List 2"/>
+		<w:LsdException Locked="false" Priority="67" Name="Medium Grid 1"/>
+		<w:LsdException Locked="false" Priority="68" Name="Medium Grid 2"/>
+		<w:LsdException Locked="false" Priority="69" Name="Medium Grid 3"/>
+		<w:LsdException Locked="false" Priority="70" Name="Dark List"/>
+		<w:LsdException Locked="false" Priority="71" Name="Colorful Shading"/>
+		<w:LsdException Locked="false" Priority="72" Name="Colorful List"/>
+		<w:LsdException Locked="false" Priority="73" Name="Colorful Grid"/>
+		<w:LsdException Locked="false" Priority="60" Name="Light Shading Accent 1"/>
+		<w:LsdException Locked="false" Priority="61" Name="Light List Accent 1"/>
+		<w:LsdException Locked="false" Priority="62" Name="Light Grid Accent 1"/>
+		<w:LsdException Locked="false" Priority="63" Name="Medium Shading 1 Accent 1"/>
+		<w:LsdException Locked="false" Priority="64" Name="Medium Shading 2 Accent 1"/>
+		<w:LsdException Locked="false" Priority="65" Name="Medium List 1 Accent 1"/>
+		<w:LsdException Locked="false" SemiHidden="true" Name="Revision"/>
+		<w:LsdException Locked="false" Priority="34" QFormat="true"
+						Name="List Paragraph"/>
+		<w:LsdException Locked="false" Priority="29" QFormat="true" Name="Quote"/>
+		<w:LsdException Locked="false" Priority="30" QFormat="true"
+						Name="Intense Quote"/>
+		<w:LsdException Locked="false" Priority="66" Name="Medium List 2 Accent 1"/>
+		<w:LsdException Locked="false" Priority="67" Name="Medium Grid 1 Accent 1"/>
+		<w:LsdException Locked="false" Priority="68" Name="Medium Grid 2 Accent 1"/>
+		<w:LsdException Locked="false" Priority="69" Name="Medium Grid 3 Accent 1"/>
+		<w:LsdException Locked="false" Priority="70" Name="Dark List Accent 1"/>
+		<w:LsdException Locked="false" Priority="71" Name="Colorful Shading Accent 1"/>
+		<w:LsdException Locked="false" Priority="72" Name="Colorful List Accent 1"/>
+		<w:LsdException Locked="false" Priority="73" Name="Colorful Grid Accent 1"/>
+		<w:LsdException Locked="false" Priority="60" Name="Light Shading Accent 2"/>
+		<w:LsdException Locked="false" Priority="61" Name="Light List Accent 2"/>
+		<w:LsdException Locked="false" Priority="62" Name="Light Grid Accent 2"/>
+		<w:LsdException Locked="false" Priority="63" Name="Medium Shading 1 Accent 2"/>
+		<w:LsdException Locked="false" Priority="64" Name="Medium Shading 2 Accent 2"/>
+		<w:LsdException Locked="false" Priority="65" Name="Medium List 1 Accent 2"/>
+		<w:LsdException Locked="false" Priority="66" Name="Medium List 2 Accent 2"/>
+		<w:LsdException Locked="false" Priority="67" Name="Medium Grid 1 Accent 2"/>
+		<w:LsdException Locked="false" Priority="68" Name="Medium Grid 2 Accent 2"/>
+		<w:LsdException Locked="false" Priority="69" Name="Medium Grid 3 Accent 2"/>
+		<w:LsdException Locked="false" Priority="70" Name="Dark List Accent 2"/>
+		<w:LsdException Locked="false" Priority="71" Name="Colorful Shading Accent 2"/>
+		<w:LsdException Locked="false" Priority="72" Name="Colorful List Accent 2"/>
+		<w:LsdException Locked="false" Priority="73" Name="Colorful Grid Accent 2"/>
+		<w:LsdException Locked="false" Priority="60" Name="Light Shading Accent 3"/>
+		<w:LsdException Locked="false" Priority="61" Name="Light List Accent 3"/>
+		<w:LsdException Locked="false" Priority="62" Name="Light Grid Accent 3"/>
+		<w:LsdException Locked="false" Priority="63" Name="Medium Shading 1 Accent 3"/>
+		<w:LsdException Locked="false" Priority="64" Name="Medium Shading 2 Accent 3"/>
+		<w:LsdException Locked="false" Priority="65" Name="Medium List 1 Accent 3"/>
+		<w:LsdException Locked="false" Priority="66" Name="Medium List 2 Accent 3"/>
+		<w:LsdException Locked="false" Priority="67" Name="Medium Grid 1 Accent 3"/>
+		<w:LsdException Locked="false" Priority="68" Name="Medium Grid 2 Accent 3"/>
+		<w:LsdException Locked="false" Priority="69" Name="Medium Grid 3 Accent 3"/>
+		<w:LsdException Locked="false" Priority="70" Name="Dark List Accent 3"/>
+		<w:LsdException Locked="false" Priority="71" Name="Colorful Shading Accent 3"/>
+		<w:LsdException Locked="false" Priority="72" Name="Colorful List Accent 3"/>
+		<w:LsdException Locked="false" Priority="73" Name="Colorful Grid Accent 3"/>
+		<w:LsdException Locked="false" Priority="60" Name="Light Shading Accent 4"/>
+		<w:LsdException Locked="false" Priority="61" Name="Light List Accent 4"/>
+		<w:LsdException Locked="false" Priority="62" Name="Light Grid Accent 4"/>
+		<w:LsdException Locked="false" Priority="63" Name="Medium Shading 1 Accent 4"/>
+		<w:LsdException Locked="false" Priority="64" Name="Medium Shading 2 Accent 4"/>
+		<w:LsdException Locked="false" Priority="65" Name="Medium List 1 Accent 4"/>
+		<w:LsdException Locked="false" Priority="66" Name="Medium List 2 Accent 4"/>
+		<w:LsdException Locked="false" Priority="67" Name="Medium Grid 1 Accent 4"/>
+		<w:LsdException Locked="false" Priority="68" Name="Medium Grid 2 Accent 4"/>
+		<w:LsdException Locked="false" Priority="69" Name="Medium Grid 3 Accent 4"/>
+		<w:LsdException Locked="false" Priority="70" Name="Dark List Accent 4"/>
+		<w:LsdException Locked="false" Priority="71" Name="Colorful Shading Accent 4"/>
+		<w:LsdException Locked="false" Priority="72" Name="Colorful List Accent 4"/>
+		<w:LsdException Locked="false" Priority="73" Name="Colorful Grid Accent 4"/>
+		<w:LsdException Locked="false" Priority="60" Name="Light Shading Accent 5"/>
+		<w:LsdException Locked="false" Priority="61" Name="Light List Accent 5"/>
+		<w:LsdException Locked="false" Priority="62" Name="Light Grid Accent 5"/>
+		<w:LsdException Locked="false" Priority="63" Name="Medium Shading 1 Accent 5"/>
+		<w:LsdException Locked="false" Priority="64" Name="Medium Shading 2 Accent 5"/>
+		<w:LsdException Locked="false" Priority="65" Name="Medium List 1 Accent 5"/>
+		<w:LsdException Locked="false" Priority="66" Name="Medium List 2 Accent 5"/>
+		<w:LsdException Locked="false" Priority="67" Name="Medium Grid 1 Accent 5"/>
+		<w:LsdException Locked="false" Priority="68" Name="Medium Grid 2 Accent 5"/>
+		<w:LsdException Locked="false" Priority="69" Name="Medium Grid 3 Accent 5"/>
+		<w:LsdException Locked="false" Priority="70" Name="Dark List Accent 5"/>
+		<w:LsdException Locked="false" Priority="71" Name="Colorful Shading Accent 5"/>
+		<w:LsdException Locked="false" Priority="72" Name="Colorful List Accent 5"/>
+		<w:LsdException Locked="false" Priority="73" Name="Colorful Grid Accent 5"/>
+		<w:LsdException Locked="false" Priority="60" Name="Light Shading Accent 6"/>
+		<w:LsdException Locked="false" Priority="61" Name="Light List Accent 6"/>
+		<w:LsdException Locked="false" Priority="62" Name="Light Grid Accent 6"/>
+		<w:LsdException Locked="false" Priority="63" Name="Medium Shading 1 Accent 6"/>
+		<w:LsdException Locked="false" Priority="64" Name="Medium Shading 2 Accent 6"/>
+		<w:LsdException Locked="false" Priority="65" Name="Medium List 1 Accent 6"/>
+		<w:LsdException Locked="false" Priority="66" Name="Medium List 2 Accent 6"/>
+		<w:LsdException Locked="false" Priority="67" Name="Medium Grid 1 Accent 6"/>
+		<w:LsdException Locked="false" Priority="68" Name="Medium Grid 2 Accent 6"/>
+		<w:LsdException Locked="false" Priority="69" Name="Medium Grid 3 Accent 6"/>
+		<w:LsdException Locked="false" Priority="70" Name="Dark List Accent 6"/>
+		<w:LsdException Locked="false" Priority="71" Name="Colorful Shading Accent 6"/>
+		<w:LsdException Locked="false" Priority="72" Name="Colorful List Accent 6"/>
+		<w:LsdException Locked="false" Priority="73" Name="Colorful Grid Accent 6"/>
+		<w:LsdException Locked="false" Priority="19" QFormat="true"
+						Name="Subtle Emphasis"/>
+		<w:LsdException Locked="false" Priority="21" QFormat="true"
+						Name="Intense Emphasis"/>
+		<w:LsdException Locked="false" Priority="31" QFormat="true"
+						Name="Subtle Reference"/>
+		<w:LsdException Locked="false" Priority="32" QFormat="true"
+						Name="Intense Reference"/>
+		<w:LsdException Locked="false" Priority="33" QFormat="true" Name="Book Title"/>
+		<w:LsdException Locked="false" Priority="37" SemiHidden="true"
+						UnhideWhenUsed="true" Name="Bibliography"/>
+		<w:LsdException Locked="false" Priority="39" SemiHidden="true"
+						UnhideWhenUsed="true" QFormat="true" Name="TOC Heading"/>
+		<w:LsdException Locked="false" Priority="41" Name="Plain Table 1"/>
+		<w:LsdException Locked="false" Priority="42" Name="Plain Table 2"/>
+		<w:LsdException Locked="false" Priority="43" Name="Plain Table 3"/>
+		<w:LsdException Locked="false" Priority="44" Name="Plain Table 4"/>
+		<w:LsdException Locked="false" Priority="45" Name="Plain Table 5"/>
+		<w:LsdException Locked="false" Priority="40" Name="Grid Table Light"/>
+		<w:LsdException Locked="false" Priority="46" Name="Grid Table 1 Light"/>
+		<w:LsdException Locked="false" Priority="47" Name="Grid Table 2"/>
+		<w:LsdException Locked="false" Priority="48" Name="Grid Table 3"/>
+		<w:LsdException Locked="false" Priority="49" Name="Grid Table 4"/>
+		<w:LsdException Locked="false" Priority="50" Name="Grid Table 5 Dark"/>
+		<w:LsdException Locked="false" Priority="51" Name="Grid Table 6 Colorful"/>
+		<w:LsdException Locked="false" Priority="52" Name="Grid Table 7 Colorful"/>
+		<w:LsdException Locked="false" Priority="46"
+						Name="Grid Table 1 Light Accent 1"/>
+		<w:LsdException Locked="false" Priority="47" Name="Grid Table 2 Accent 1"/>
+		<w:LsdException Locked="false" Priority="48" Name="Grid Table 3 Accent 1"/>
+		<w:LsdException Locked="false" Priority="49" Name="Grid Table 4 Accent 1"/>
+		<w:LsdException Locked="false" Priority="50" Name="Grid Table 5 Dark Accent 1"/>
+		<w:LsdException Locked="false" Priority="51"
+						Name="Grid Table 6 Colorful Accent 1"/>
+		<w:LsdException Locked="false" Priority="52"
+						Name="Grid Table 7 Colorful Accent 1"/>
+		<w:LsdException Locked="false" Priority="46"
+						Name="Grid Table 1 Light Accent 2"/>
+		<w:LsdException Locked="false" Priority="47" Name="Grid Table 2 Accent 2"/>
+		<w:LsdException Locked="false" Priority="48" Name="Grid Table 3 Accent 2"/>
+		<w:LsdException Locked="false" Priority="49" Name="Grid Table 4 Accent 2"/>
+		<w:LsdException Locked="false" Priority="50" Name="Grid Table 5 Dark Accent 2"/>
+		<w:LsdException Locked="false" Priority="51"
+						Name="Grid Table 6 Colorful Accent 2"/>
+		<w:LsdException Locked="false" Priority="52"
+						Name="Grid Table 7 Colorful Accent 2"/>
+		<w:LsdException Locked="false" Priority="46"
+						Name="Grid Table 1 Light Accent 3"/>
+		<w:LsdException Locked="false" Priority="47" Name="Grid Table 2 Accent 3"/>
+		<w:LsdException Locked="false" Priority="48" Name="Grid Table 3 Accent 3"/>
+		<w:LsdException Locked="false" Priority="49" Name="Grid Table 4 Accent 3"/>
+		<w:LsdException Locked="false" Priority="50" Name="Grid Table 5 Dark Accent 3"/>
+		<w:LsdException Locked="false" Priority="51"
+						Name="Grid Table 6 Colorful Accent 3"/>
+		<w:LsdException Locked="false" Priority="52"
+						Name="Grid Table 7 Colorful Accent 3"/>
+		<w:LsdException Locked="false" Priority="46"
+						Name="Grid Table 1 Light Accent 4"/>
+		<w:LsdException Locked="false" Priority="47" Name="Grid Table 2 Accent 4"/>
+		<w:LsdException Locked="false" Priority="48" Name="Grid Table 3 Accent 4"/>
+		<w:LsdException Locked="false" Priority="49" Name="Grid Table 4 Accent 4"/>
+		<w:LsdException Locked="false" Priority="50" Name="Grid Table 5 Dark Accent 4"/>
+		<w:LsdException Locked="false" Priority="51"
+						Name="Grid Table 6 Colorful Accent 4"/>
+		<w:LsdException Locked="false" Priority="52"
+						Name="Grid Table 7 Colorful Accent 4"/>
+		<w:LsdException Locked="false" Priority="46"
+						Name="Grid Table 1 Light Accent 5"/>
+		<w:LsdException Locked="false" Priority="47" Name="Grid Table 2 Accent 5"/>
+		<w:LsdException Locked="false" Priority="48" Name="Grid Table 3 Accent 5"/>
+		<w:LsdException Locked="false" Priority="49" Name="Grid Table 4 Accent 5"/>
+		<w:LsdException Locked="false" Priority="50" Name="Grid Table 5 Dark Accent 5"/>
+		<w:LsdException Locked="false" Priority="51"
+						Name="Grid Table 6 Colorful Accent 5"/>
+		<w:LsdException Locked="false" Priority="52"
+						Name="Grid Table 7 Colorful Accent 5"/>
+		<w:LsdException Locked="false" Priority="46"
+						Name="Grid Table 1 Light Accent 6"/>
+		<w:LsdException Locked="false" Priority="47" Name="Grid Table 2 Accent 6"/>
+		<w:LsdException Locked="false" Priority="48" Name="Grid Table 3 Accent 6"/>
+		<w:LsdException Locked="false" Priority="49" Name="Grid Table 4 Accent 6"/>
+		<w:LsdException Locked="false" Priority="50" Name="Grid Table 5 Dark Accent 6"/>
+		<w:LsdException Locked="false" Priority="51"
+						Name="Grid Table 6 Colorful Accent 6"/>
+		<w:LsdException Locked="false" Priority="52"
+						Name="Grid Table 7 Colorful Accent 6"/>
+		<w:LsdException Locked="false" Priority="46" Name="List Table 1 Light"/>
+		<w:LsdException Locked="false" Priority="47" Name="List Table 2"/>
+		<w:LsdException Locked="false" Priority="48" Name="List Table 3"/>
+		<w:LsdException Locked="false" Priority="49" Name="List Table 4"/>
+		<w:LsdException Locked="false" Priority="50" Name="List Table 5 Dark"/>
+		<w:LsdException Locked="false" Priority="51" Name="List Table 6 Colorful"/>
+		<w:LsdException Locked="false" Priority="52" Name="List Table 7 Colorful"/>
+		<w:LsdException Locked="false" Priority="46"
+						Name="List Table 1 Light Accent 1"/>
+		<w:LsdException Locked="false" Priority="47" Name="List Table 2 Accent 1"/>
+		<w:LsdException Locked="false" Priority="48" Name="List Table 3 Accent 1"/>
+		<w:LsdException Locked="false" Priority="49" Name="List Table 4 Accent 1"/>
+		<w:LsdException Locked="false" Priority="50" Name="List Table 5 Dark Accent 1"/>
+		<w:LsdException Locked="false" Priority="51"
+						Name="List Table 6 Colorful Accent 1"/>
+		<w:LsdException Locked="false" Priority="52"
+						Name="List Table 7 Colorful Accent 1"/>
+		<w:LsdException Locked="false" Priority="46"
+						Name="List Table 1 Light Accent 2"/>
+		<w:LsdException Locked="false" Priority="47" Name="List Table 2 Accent 2"/>
+		<w:LsdException Locked="false" Priority="48" Name="List Table 3 Accent 2"/>
+		<w:LsdException Locked="false" Priority="49" Name="List Table 4 Accent 2"/>
+		<w:LsdException Locked="false" Priority="50" Name="List Table 5 Dark Accent 2"/>
+		<w:LsdException Locked="false" Priority="51"
+						Name="List Table 6 Colorful Accent 2"/>
+		<w:LsdException Locked="false" Priority="52"
+						Name="List Table 7 Colorful Accent 2"/>
+		<w:LsdException Locked="false" Priority="46"
+						Name="List Table 1 Light Accent 3"/>
+		<w:LsdException Locked="false" Priority="47" Name="List Table 2 Accent 3"/>
+		<w:LsdException Locked="false" Priority="48" Name="List Table 3 Accent 3"/>
+		<w:LsdException Locked="false" Priority="49" Name="List Table 4 Accent 3"/>
+		<w:LsdException Locked="false" Priority="50" Name="List Table 5 Dark Accent 3"/>
+		<w:LsdException Locked="false" Priority="51"
+						Name="List Table 6 Colorful Accent 3"/>
+		<w:LsdException Locked="false" Priority="52"
+						Name="List Table 7 Colorful Accent 3"/>
+		<w:LsdException Locked="false" Priority="46"
+						Name="List Table 1 Light Accent 4"/>
+		<w:LsdException Locked="false" Priority="47" Name="List Table 2 Accent 4"/>
+		<w:LsdException Locked="false" Priority="48" Name="List Table 3 Accent 4"/>
+		<w:LsdException Locked="false" Priority="49" Name="List Table 4 Accent 4"/>
+		<w:LsdException Locked="false" Priority="50" Name="List Table 5 Dark Accent 4"/>
+		<w:LsdException Locked="false" Priority="51"
+						Name="List Table 6 Colorful Accent 4"/>
+		<w:LsdException Locked="false" Priority="52"
+						Name="List Table 7 Colorful Accent 4"/>
+		<w:LsdException Locked="false" Priority="46"
+						Name="List Table 1 Light Accent 5"/>
+		<w:LsdException Locked="false" Priority="47" Name="List Table 2 Accent 5"/>
+		<w:LsdException Locked="false" Priority="48" Name="List Table 3 Accent 5"/>
+		<w:LsdException Locked="false" Priority="49" Name="List Table 4 Accent 5"/>
+		<w:LsdException Locked="false" Priority="50" Name="List Table 5 Dark Accent 5"/>
+		<w:LsdException Locked="false" Priority="51"
+						Name="List Table 6 Colorful Accent 5"/>
+		<w:LsdException Locked="false" Priority="52"
+						Name="List Table 7 Colorful Accent 5"/>
+		<w:LsdException Locked="false" Priority="46"
+						Name="List Table 1 Light Accent 6"/>
+		<w:LsdException Locked="false" Priority="47" Name="List Table 2 Accent 6"/>
+		<w:LsdException Locked="false" Priority="48" Name="List Table 3 Accent 6"/>
+		<w:LsdException Locked="false" Priority="49" Name="List Table 4 Accent 6"/>
+		<w:LsdException Locked="false" Priority="50" Name="List Table 5 Dark Accent 6"/>
+		<w:LsdException Locked="false" Priority="51"
+						Name="List Table 6 Colorful Accent 6"/>
+		<w:LsdException Locked="false" Priority="52"
+						Name="List Table 7 Colorful Accent 6"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Mention"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Smart Hyperlink"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Hashtag"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Unresolved Mention"/>
+		<w:LsdException Locked="false" SemiHidden="true" UnhideWhenUsed="true"
+						Name="Smart Link"/>
+	</w:LatentStyles>
+</xml><![endif]-->
+	<style>
+		<!--
+		/* Font Definitions */
+		@font-face
+		{font-family:"Cambria Math";
+			panose-1:2 4 5 3 5 4 6 3 2 4;
+			mso-font-charset:0;
+			mso-generic-font-family:roman;
+			mso-font-pitch:variable;
+			mso-font-signature:3 0 0 0 1 0;}
+		@font-face
+		{font-family:Calibri;
+			panose-1:2 15 5 2 2 2 4 3 2 4;
+			mso-font-charset:0;
+			mso-generic-font-family:swiss;
+			mso-font-pitch:variable;
+			mso-font-signature:-536859905 -1073732485 9 0 511 0;}
+		/* Style Definitions */
+		p.MsoNormal, li.MsoNormal, div.MsoNormal
+		{mso-style-unhide:no;
+			mso-style-qformat:yes;
+			mso-style-parent:"";
+			margin:0cm;
+			mso-pagination:widow-orphan;
+			font-size:12.0pt;
+			font-family:"Calibri",sans-serif;
+			mso-ascii-font-family:Calibri;
+			mso-ascii-theme-font:minor-latin;
+			mso-fareast-font-family:Calibri;
+			mso-fareast-theme-font:minor-latin;
+			mso-hansi-font-family:Calibri;
+			mso-hansi-theme-font:minor-latin;
+			mso-bidi-font-family:"Times New Roman";
+			mso-bidi-theme-font:minor-bidi;
+			mso-fareast-language:EN-US;}
+		.MsoChpDefault
+		{mso-style-type:export-only;
+			mso-default-props:yes;
+			font-family:"Calibri",sans-serif;
+			mso-ascii-font-family:Calibri;
+			mso-ascii-theme-font:minor-latin;
+			mso-fareast-font-family:Calibri;
+			mso-fareast-theme-font:minor-latin;
+			mso-hansi-font-family:Calibri;
+			mso-hansi-theme-font:minor-latin;
+			mso-bidi-font-family:"Times New Roman";
+			mso-bidi-theme-font:minor-bidi;
+			mso-fareast-language:EN-US;}
+		@page WordSection1
+		{size:595.3pt 841.9pt;
+			margin:70.85pt 70.85pt 70.85pt 70.85pt;
+			mso-header-margin:35.4pt;
+			mso-footer-margin:35.4pt;
+			mso-paper-source:0;}
+		div.WordSection1
+		{page:WordSection1;}
+		-->
+	</style>
+	<!--[if gte mso 10]>
+	<style>
+		/* Style Definitions */
+		table.MsoNormalTable
+		{mso-style-name:Standardowy;
+			mso-tstyle-rowband-size:0;
+			mso-tstyle-colband-size:0;
+			mso-style-noshow:yes;
+			mso-style-priority:99;
+			mso-style-parent:"";
+			mso-padding-alt:0cm 5.4pt 0cm 5.4pt;
+			mso-para-margin:0cm;
+			mso-pagination:widow-orphan;
+			font-size:12.0pt;
+			font-family:"Calibri",sans-serif;
+			mso-ascii-font-family:Calibri;
+			mso-ascii-theme-font:minor-latin;
+			mso-hansi-font-family:Calibri;
+			mso-hansi-theme-font:minor-latin;
+			mso-bidi-font-family:"Times New Roman";
+			mso-bidi-theme-font:minor-bidi;
+			mso-fareast-language:EN-US;}
+	</style>
+	<![endif]-->
+</head>
+
+<body lang=PL style='tab-interval:35.4pt'>
+<!--StartFragment-->
+
+<p class=MsoNormal>foo<br clear=all style='mso-special-character:line-break;
+page-break-before:always'>
+	<o:p></o:p></p>
+
+<p class=MsoNormal>bar<o:p></o:p></p>
+
+<!--EndFragment-->
+</body>
+
+</html>

+ 3 - 0
packages/ckeditor5-paste-from-office/tests/_data/page-break/page-break/model.html

@@ -0,0 +1,3 @@
+<paragraph>foo</paragraph>
+<pageBreak></pageBreak>
+<paragraph>bar</paragraph>

BIN
packages/ckeditor5-paste-from-office/tests/_data/page-break/page-break/page-break.docx


+ 3 - 1
packages/ckeditor5-paste-from-office/tests/_utils/fixtures.js

@@ -13,6 +13,7 @@ import { fixtures as googleDocsBoldWrapper, browserFixtures as googleDocsBoldWra
 	from '../_data/paste-from-google-docs/bold-wrapper/index';
 import { fixtures as googleDocsList, browserFixtures as googleDocsListBrowser } from '../_data/paste-from-google-docs/lists/index.js';
 import { fixtures as table } from '../_data/table/index.js';
+import { fixtures as pageBreak } from '../_data/page-break/index.js';
 
 // Generic fixtures.
 export const fixtures = {
@@ -23,7 +24,8 @@ export const fixtures = {
 	spacing,
 	'google-docs-bold-wrapper': googleDocsBoldWrapper,
 	'google-docs-list': googleDocsList,
-	table
+	table,
+	'page-break': pageBreak
 };
 
 // Browser specific fixtures.

+ 10 - 0
packages/ckeditor5-paste-from-office/tests/data/integration.js

@@ -22,6 +22,7 @@ import FontColor from '@ckeditor/ckeditor5-font/src/fontcolor';
 
 import PasteFromOffice from '../../src/pastefromoffice';
 import { generateTests } from '../_utils/utils';
+import PageBreak from '@ckeditor/ckeditor5-page-break/src/pagebreak';
 
 const browsers = [ 'chrome', 'firefox', 'safari', 'edge' ];
 
@@ -122,4 +123,13 @@ describe( 'PasteFromOffice - integration', () => {
 				FontColor, FontBackgroundColor ]
 		}
 	} );
+
+	generateTests( {
+		input: 'page-break',
+		type: 'integration',
+		browsers,
+		editorConfig: {
+			plugins: [ Clipboard, Paragraph, Bold, PasteFromOffice, PageBreak ]
+		}
+	} );
 } );

+ 3 - 2
packages/ckeditor5-paste-from-office/tests/manual/integration.js

@@ -15,6 +15,7 @@ import TableToolbar from '@ckeditor/ckeditor5-table/src/tabletoolbar';
 import EasyImage from '@ckeditor/ckeditor5-easy-image/src/easyimage';
 import FontColor from '@ckeditor/ckeditor5-font/src/fontcolor';
 import FontBackgroundColor from '@ckeditor/ckeditor5-font/src/fontbackgroundcolor';
+import PageBreak from '@ckeditor/ckeditor5-page-break/src/pagebreak';
 import TableProperties from '@ckeditor/ckeditor5-table/src/tableproperties';
 import TableCellProperties from '@ckeditor/ckeditor5-table/src/tablecellproperties';
 
@@ -30,10 +31,10 @@ const dataDiv = document.querySelector( '#data' );
 
 ClassicEditor
 	.create( document.querySelector( '#editor' ), {
-		plugins: [ ArticlePluginSet, Strikethrough, Underline, Table, TableToolbar,
+		plugins: [ ArticlePluginSet, Strikethrough, Underline, Table, TableToolbar, PageBreak,
 			TableProperties, TableCellProperties, EasyImage, PasteFromOffice, FontColor, FontBackgroundColor ],
 		toolbar: [ 'heading', '|', 'bold', 'italic', 'strikethrough', 'underline', 'link',
-			'bulletedList', 'numberedList', 'blockQuote', 'insertTable', 'undo', 'redo' ],
+			'bulletedList', 'numberedList', 'blockQuote', 'insertTable', 'pageBreak', 'undo', 'redo' ],
 		table: {
 			contentToolbar: [ 'tableColumn', 'tableRow', 'mergeTableCells', 'tableProperties', 'tableCellProperties' ]
 		},

+ 6 - 6
packages/ckeditor5-table/tests/tableclipboard-paste.js

@@ -861,7 +861,7 @@ describe( 'table clipboard', () => {
 					setModelData( model, modelTable( [
 						[ '00', '01', '02' ],
 						[ '10', '11', '12' ],
-						[ '20', '21', '[<image src="/assets/sample.jpg"><caption></caption></image>]' ]
+						[ '20', '21', '[<image src="/assets/sample.png"><caption></caption></image>]' ]
 					] ) );
 
 					pasteTable( [
@@ -884,7 +884,7 @@ describe( 'table clipboard', () => {
 					setModelData( model, modelTable( [
 						[ '00', '01', '02' ],
 						[ '10', '11', '12' ],
-						[ '20', '21', '<image src="/assets/sample.jpg"><caption>fo[]o</caption></image>' ]
+						[ '20', '21', '<image src="/assets/sample.png"><caption>fo[]o</caption></image>' ]
 					] ) );
 
 					pasteTable( [
@@ -3767,12 +3767,12 @@ describe( 'table clipboard', () => {
 			);
 
 			pasteTable( [
-				[ '<img src="/assets/sample.jpg">', 'ab' ],
+				[ '<img src="/assets/sample.png">', 'ab' ],
 				[ 'ba', 'bb' ]
 			] );
 
 			assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
-				[ '<image src="/assets/sample.jpg"><caption></caption></image>', 'ab', '02' ],
+				[ '<image src="/assets/sample.png"><caption></caption></image>', 'ab', '02' ],
 				[ 'ba', 'bb', '12' ],
 				[ '02', '21', '22' ]
 			] ) );
@@ -3792,7 +3792,7 @@ describe( 'table clipboard', () => {
 				modelRoot.getNodeByPath( [ 0, 1, 1 ] )
 			);
 
-			const img = '<img src="/assets/sample.jpg">';
+			const img = '<img src="/assets/sample.png">';
 			const list = '<ul><li>foo</li><li>bar</li></ul>';
 			const blockquote = `<blockquote><p>baz</p>${ list }</blockquote>`;
 
@@ -3803,7 +3803,7 @@ describe( 'table clipboard', () => {
 
 			assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
 				[
-					'<image src="/assets/sample.jpg"><caption></caption></image>' +
+					'<image src="/assets/sample.png"><caption></caption></image>' +
 					'<listItem listIndent="0" listType="bulleted">foo</listItem>' +
 					'<listItem listIndent="0" listType="bulleted">bar</listItem>' +
 					'<blockQuote>' +

+ 9 - 0
packages/ckeditor5-theme-lark/theme/ckeditor5-link/linkimage.css

@@ -0,0 +1,9 @@
+/*
+ * Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+.ck.ck-link-image_icon {
+	color: hsl(0, 0%, 100%);
+	background: hsla(0, 0%, 0%, .4);
+}

+ 3 - 3
packages/ckeditor5-typing/src/inputcommand.js

@@ -89,6 +89,9 @@ export default class InputCommand extends Command {
 		model.enqueueChange( this._buffer.batch, writer => {
 			this._buffer.lock();
 
+			// Store the batch as an 'input' batch for the Input.isInput( batch ) check.
+			this._batches.add( this._buffer.batch );
+
 			model.deleteContent( selection );
 
 			if ( text ) {
@@ -104,9 +107,6 @@ export default class InputCommand extends Command {
 			this._buffer.unlock();
 
 			this._buffer.input( textInsertions );
-
-			// Store the batch as an 'input' batch for the Input.isInput( batch ) check.
-			this._batches.add( this._buffer.batch );
 		} );
 	}
 }

+ 4 - 1
packages/ckeditor5-typing/src/utils/injectunsafekeystrokeshandling.js

@@ -110,7 +110,10 @@ export default function injectUnsafeKeystrokesHandling( editor ) {
 
 		buffer.lock();
 
-		model.enqueueChange( buffer.batch, () => {
+		const batch = buffer.batch;
+		inputCommand._batches.add( batch );
+
+		model.enqueueChange( batch, () => {
 			model.deleteContent( model.document.selection );
 		} );
 

+ 24 - 2
packages/ckeditor5-typing/tests/inputcommand.js

@@ -12,7 +12,7 @@ import ChangeBuffer from '../src/utils/changebuffer';
 import Input from '../src/input';
 
 describe( 'InputCommand', () => {
-	let editor, model, doc, buffer;
+	let editor, model, doc, buffer, inputCommand;
 
 	testUtils.createSinonSandbox();
 
@@ -23,7 +23,7 @@ describe( 'InputCommand', () => {
 				model = editor.model;
 				doc = model.document;
 
-				const inputCommand = new InputCommand( editor, 20 );
+				inputCommand = new InputCommand( editor, 20 );
 				editor.commands.add( 'input', inputCommand );
 
 				buffer = inputCommand.buffer;
@@ -281,6 +281,28 @@ describe( 'InputCommand', () => {
 				'<paragraph>z</paragraph>'
 			);
 		} );
+
+		it( 'uses typing batch while removing and inserting the content', () => {
+			expect( inputCommand._batches.has( getCurrentBatch() ), 'batch before typing' ).to.equal( false );
+
+			model.on( 'deleteContent', () => {
+				expect( inputCommand._batches.has( getCurrentBatch() ), 'batch when deleting content' ).to.equal( true );
+			}, { priority: 'highest' } );
+
+			model.on( 'insertContent', () => {
+				expect( inputCommand._batches.has( getCurrentBatch() ), 'batch when inserting content' ).to.equal( true );
+			}, { priority: 'lowest' } );
+
+			setData( model, '<paragraph>[foo]</paragraph>' );
+
+			editor.execute( 'input', { text: 'bar' } );
+
+			expect( getData( model ) ).to.equal( '<paragraph>bar[]</paragraph>' );
+
+			function getCurrentBatch() {
+				return editor.model.change( writer => writer.batch );
+			}
+		} );
 	} );
 
 	describe( 'destroy()', () => {

+ 46 - 3
packages/ckeditor5-typing/tests/utils/injectunsafekeystrokeshandling.js

@@ -3,10 +3,12 @@
  * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
 
-import {
-	keyCodes
-} from '@ckeditor/ckeditor5-utils/src/keyboard';
+import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
+import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata';
+import { getData, setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import { keyCodes, getCode } from '@ckeditor/ckeditor5-utils/src/keyboard';
 import { isNonTypingKeystroke } from '../../src/utils/injectunsafekeystrokeshandling';
+import Typing from '../../src/typing';
 
 describe( 'unsafe keystroke handling utils', () => {
 	describe( 'isNonTypingKeystroke()', () => {
@@ -82,4 +84,45 @@ describe( 'unsafe keystroke handling utils', () => {
 			expect( isNonTypingKeystroke( { keyCode: keyCodes.a, altKey: true } ), 'Alt+a' ).to.be.false;
 		} );
 	} );
+
+	describe( 'injectUnsafeKeystrokesHandling()', () => {
+		let editor, model;
+
+		beforeEach( () => {
+			return ModelTestEditor.create( { plugins: [ Typing ] } )
+				.then( newEditor => {
+					editor = newEditor;
+					model = editor.model;
+
+					model.schema.register( 'paragraph', { inheritAllFrom: '$block' } );
+				} );
+		} );
+
+		afterEach( () => {
+			return editor.destroy();
+		} );
+
+		it( 'uses typing batch while removing the content', () => {
+			const inputCommand = editor.commands.get( 'input' );
+
+			expect( inputCommand._batches.has( getCurrentBatch() ), 'batch before typing' ).to.equal( false );
+
+			model.on( 'deleteContent', () => {
+				expect( inputCommand._batches.has( getCurrentBatch() ), 'batch when deleting content' ).to.equal( true );
+			}, { priority: 'highest' } );
+
+			setData( model, '<paragraph>[foo]</paragraph>' );
+
+			editor.editing.view.document.fire( 'keydown', new DomEventData( editor.editing.view.document, {
+				preventDefault: () => {},
+				keyCode: getCode( 'A' )
+			} ) );
+
+			expect( getData( model ) ).to.equal( '<paragraph>[]</paragraph>' );
+
+			function getCurrentBatch() {
+				return editor.model.change( writer => writer.batch );
+			}
+		} );
+	} );
 } );

+ 116 - 49
packages/ckeditor5-utils/src/collection.js

@@ -179,11 +179,24 @@ export default class Collection {
 	 * @param {Number} [index] The position of the item in the collection. The item
 	 * is pushed to the collection when `index` not specified.
 	 * @fires add
+	 * @fires change
 	 */
 	add( item, index ) {
-		const itemId = this._getItemIdBeforeAdding( item );
+		return this.addMany( [ item ], index );
+	}
 
-		// TODO: Use ES6 default function argument.
+	/**
+	 * Adds multiple items into the collection.
+	 *
+	 * Any item not containing an id will get an automatically generated one.
+	 *
+	 * @chainable
+	 * @param {Iterable.<Object>} item
+	 * @param {Number} [index] The position of the insertion. Items will be appended if no `index` is specified.
+	 * @fires add
+	 * @fires change
+	 */
+	addMany( items, index ) {
 		if ( index === undefined ) {
 			index = this._items.length;
 		} else if ( index > this._items.length || index < 0 ) {
@@ -195,11 +208,22 @@ export default class Collection {
 			throw new CKEditorError( 'collection-add-item-invalid-index', this );
 		}
 
-		this._items.splice( index, 0, item );
+		for ( let offset = 0; offset < items.length; offset++ ) {
+			const item = items[ offset ];
+			const itemId = this._getItemIdBeforeAdding( item );
+			const currentItemIndex = index + offset;
 
-		this._itemMap.set( itemId, item );
+			this._items.splice( currentItemIndex, 0, item );
+			this._itemMap.set( itemId, item );
 
-		this.fire( 'add', item, index );
+			this.fire( 'add', item, currentItemIndex );
+		}
+
+		this.fire( 'change', {
+			added: items,
+			removed: [],
+			index
+		} );
 
 		return this;
 	}
@@ -271,52 +295,16 @@ export default class Collection {
 	 * @param {Object|Number|String} subject The item to remove, its id or index in the collection.
 	 * @returns {Object} The removed item.
 	 * @fires remove
+	 * @fires change
 	 */
 	remove( subject ) {
-		let index, id, item;
-		let itemDoesNotExist = false;
-		const idProperty = this._idProperty;
-
-		if ( typeof subject == 'string' ) {
-			id = subject;
-			item = this._itemMap.get( id );
-			itemDoesNotExist = !item;
-
-			if ( item ) {
-				index = this._items.indexOf( item );
-			}
-		} else if ( typeof subject == 'number' ) {
-			index = subject;
-			item = this._items[ index ];
-			itemDoesNotExist = !item;
-
-			if ( item ) {
-				id = item[ idProperty ];
-			}
-		} else {
-			item = subject;
-			id = item[ idProperty ];
-			index = this._items.indexOf( item );
-			itemDoesNotExist = ( index == -1 || !this._itemMap.get( id ) );
-		}
+		const [ item, index ] = this._remove( subject );
 
-		if ( itemDoesNotExist ) {
-			/**
-			 * Item not found.
-			 *
-			 * @error collection-remove-404
-			 */
-			throw new CKEditorError( 'collection-remove-404: Item not found.', this );
-		}
-
-		this._items.splice( index, 1 );
-		this._itemMap.delete( id );
-
-		const externalItem = this._bindToInternalToExternalMap.get( item );
-		this._bindToInternalToExternalMap.delete( item );
-		this._bindToExternalToInternalMap.delete( externalItem );
-
-		this.fire( 'remove', item, index );
+		this.fire( 'change', {
+			added: [],
+			removed: [ item ],
+			index
+		} );
 
 		return item;
 	}
@@ -363,6 +351,9 @@ export default class Collection {
 	/**
 	 * Removes all items from the collection and destroys the binding created using
 	 * {@link #bindTo}.
+	 *
+	 * @fires remove
+	 * @fires change
 	 */
 	clear() {
 		if ( this._bindToCollection ) {
@@ -370,9 +361,17 @@ export default class Collection {
 			this._bindToCollection = null;
 		}
 
+		const removedItems = Array.from( this._items );
+
 		while ( this.length ) {
-			this.remove( 0 );
+			this._remove( 0 );
 		}
+
+		this.fire( 'change', {
+			added: [],
+			removed: removedItems,
+			index: 0
+		} );
 	}
 
 	/**
@@ -664,6 +663,65 @@ export default class Collection {
 		return itemId;
 	}
 
+	/**
+	 * Core {@link #remove} method implementation shared in other functions.
+	 *
+	 * In contrast this method **does not** fire the {@link #event:change} event.
+	 *
+	 * @private
+	 * @param {Object} subject The item to remove, its id or index in the collection.
+	 * @returns {Array} Returns an array with the removed item and its index.
+	 * @fires remove
+	 */
+	_remove( subject ) {
+		let index, id, item;
+		let itemDoesNotExist = false;
+		const idProperty = this._idProperty;
+
+		if ( typeof subject == 'string' ) {
+			id = subject;
+			item = this._itemMap.get( id );
+			itemDoesNotExist = !item;
+
+			if ( item ) {
+				index = this._items.indexOf( item );
+			}
+		} else if ( typeof subject == 'number' ) {
+			index = subject;
+			item = this._items[ index ];
+			itemDoesNotExist = !item;
+
+			if ( item ) {
+				id = item[ idProperty ];
+			}
+		} else {
+			item = subject;
+			id = item[ idProperty ];
+			index = this._items.indexOf( item );
+			itemDoesNotExist = ( index == -1 || !this._itemMap.get( id ) );
+		}
+
+		if ( itemDoesNotExist ) {
+			/**
+			 * Item not found.
+			 *
+			 * @error collection-remove-404
+			 */
+			throw new CKEditorError( 'collection-remove-404: Item not found.', this );
+		}
+
+		this._items.splice( index, 1 );
+		this._itemMap.delete( id );
+
+		const externalItem = this._bindToInternalToExternalMap.get( item );
+		this._bindToInternalToExternalMap.delete( item );
+		this._bindToExternalToInternalMap.delete( externalItem );
+
+		this.fire( 'remove', item, index );
+
+		return [ item, index ];
+	}
+
 	/**
 	 * Iterable interface.
 	 *
@@ -680,6 +738,15 @@ export default class Collection {
 	 * @param {Object} item The added item.
 	 */
 
+	/**
+	 * Fired when the collection was changed due to adding or removing items.
+	 *
+	 * @event change
+	 * @param {Iterable.<Object>} added A list of added items.
+	 * @param {Iterable.<Object>} removed A list of removed items.
+	 * @param {Number} index An index where the addition or removal occurred.
+	 */
+
 	/**
 	 * Fired when an item is removed from the collection.
 	 *

+ 306 - 0
packages/ckeditor5-utils/tests/collection.js

@@ -365,6 +365,277 @@ describe( 'Collection', () => {
 		} );
 	} );
 
+	describe( 'addMany()', () => {
+		it( 'should be chainable', () => {
+			expect( collection.addMany( [ {} ] ) ).to.equal( collection );
+		} );
+
+		it( 'should change the length', () => {
+			expect( collection ).to.have.length( 0 );
+
+			collection.addMany( [ {}, {} ] );
+			expect( collection ).to.have.length( 2 );
+
+			collection.addMany( [ {} ] );
+			expect( collection ).to.have.length( 3 );
+		} );
+
+		it( 'should enable get( index )', () => {
+			const item1 = {};
+			const item2 = {};
+
+			collection.addMany( [ item1, item2 ] );
+			expect( collection.get( 0 ) ).to.equal( item1 );
+			expect( collection.get( 1 ) ).to.equal( item2 );
+		} );
+
+		it( 'should enable get( id )', () => {
+			const item1 = getItem( 'foo' );
+			const item2 = getItem( 'bar' );
+
+			collection.addMany( [ item1, item2 ] );
+
+			expect( collection.get( 'foo' ) ).to.equal( item1 );
+			expect( collection.get( 'bar' ) ).to.equal( item2 );
+		} );
+
+		it( 'should enable get( id ) - custom id property', () => {
+			const collection = new Collection( { idProperty: 'name' } );
+			const item1 = getItem( 'foo', 'name' );
+			const item2 = getItem( 'bar', 'name' );
+
+			collection.add( item1 );
+			collection.add( item2 );
+
+			expect( collection.get( 'foo' ) ).to.equal( item1 );
+			expect( collection.get( 'bar' ) ).to.equal( item2 );
+		} );
+
+		it( 'should generate an id when not defined', () => {
+			const item = {};
+
+			collection.addMany( [ item ] );
+
+			expect( item.id ).to.be.a( 'string' );
+			expect( collection.get( item.id ) ).to.equal( item );
+		} );
+
+		it( 'should generate an id when not defined - custom id property', () => {
+			const collection = new Collection( { idProperty: 'name' } );
+			const item = {};
+
+			collection.addMany( [ item ] );
+
+			expect( item.name ).to.be.a( 'string' );
+			expect( collection.get( item.name ) ).to.equal( item );
+		} );
+
+		it( 'should not change an existing id of an item', () => {
+			const item = getItem( 'foo' );
+
+			collection.addMany( [ item ] );
+
+			expect( item.id ).to.equal( 'foo' );
+		} );
+
+		it( 'should throw when item with this id already exists - single call', () => {
+			const item1 = getItem( 'foo' );
+
+			expectToThrowCKEditorError( () => {
+				collection.addMany( [ item1, item1 ] );
+			}, /^collection-add-item-already-exists/ );
+		} );
+
+		it( 'should throw when item with this id already exists - multiple calls', () => {
+			const item1 = getItem( 'foo' );
+			const item2 = getItem( 'foo' );
+
+			collection.addMany( [ item1 ] );
+
+			expectToThrowCKEditorError( () => {
+				collection.addMany( [ item2 ] );
+			}, /^collection-add-item-already-exists/ );
+		} );
+
+		it( 'should throw when item\'s id is not a string', () => {
+			const item = { id: 1 };
+
+			expectToThrowCKEditorError( () => {
+				collection.addMany( [ item ] );
+			}, /^collection-add-invalid-id/ );
+		} );
+
+		it(
+			'should generate an id when not defined, which is globally unique ' +
+			'so it is possible to move items between collections and avoid id collisions',
+			() => {
+				const collectionA = new Collection();
+				const collectionB = new Collection();
+				const itemA = {};
+				const itemB = {};
+
+				collectionA.addMany( [ itemA ] );
+				collectionB.addMany( [ itemB ] );
+				collectionB.addMany( [ collectionA.remove( itemA ) ] );
+
+				expect( collectionA.length ).to.equal( 0 );
+				expect( collectionB.length ).to.equal( 2 );
+				expect( collectionB.get( 0 ) ).to.equal( itemB );
+				expect( collectionB.get( 1 ) ).to.equal( itemA );
+
+				expect( itemA.id ).to.not.equal( itemB.id );
+			}
+		);
+
+		it(
+			'should generate an id when not defined, which is globally unique ' +
+			'so it is possible to move items between collections and avoid id collisions ' +
+			'– custom id property',
+			() => {
+				const collectionA = new Collection( { idProperty: 'foo' } );
+				const collectionB = new Collection( { idProperty: 'foo' } );
+				const itemA = {};
+				const itemB = {};
+
+				collectionA.addMany( [ itemA ] );
+				collectionB.addMany( [ itemB ] );
+				collectionB.addMany( [ collectionA.remove( itemA ) ] );
+
+				expect( collectionA.length ).to.equal( 0 );
+				expect( collectionB.length ).to.equal( 2 );
+				expect( collectionB.get( 0 ) ).to.equal( itemB );
+				expect( collectionB.get( 1 ) ).to.equal( itemA );
+
+				expect( itemA.foo ).to.not.equal( itemB.foo );
+			}
+		);
+
+		it( 'should allow an item which is already in some other collection', () => {
+			const collectionA = new Collection();
+			const collectionB = new Collection();
+			const item = {};
+
+			collectionA.addMany( [ item ] );
+			collectionB.addMany( [ item ] );
+
+			expect( collectionA.length ).to.equal( 1 );
+			expect( collectionB.length ).to.equal( 1 );
+			expect( collectionA.get( item.id ) ).to.equal( collectionB.get( 0 ) );
+		} );
+
+		it( 'should fire the "add" event', () => {
+			const spy = sinon.spy();
+			const item = {};
+
+			collection.on( 'add', spy );
+
+			collection.addMany( [ item ] );
+
+			sinon.assert.calledWithExactly( spy, sinon.match.has( 'source', collection ), item, 0 );
+		} );
+
+		it( 'should fire the "add" event for each item', () => {
+			const spy = sinon.spy();
+			const items = [ {}, {} ];
+
+			collection.on( 'add', spy );
+
+			collection.addMany( items );
+
+			sinon.assert.calledWithExactly( spy, sinon.match.has( 'source', collection ), items[ 0 ], 0 );
+			sinon.assert.calledWithExactly( spy, sinon.match.has( 'source', collection ), items[ 1 ], 1 );
+
+			expect( spy.callCount ).to.equal( 2 );
+		} );
+
+		it( 'should fire the "add" event with the index argument', () => {
+			const spy = sinon.spy();
+
+			collection.addMany( [ {} ] );
+			collection.addMany( [ {} ] );
+
+			collection.on( 'add', spy );
+
+			const item = {};
+			collection.addMany( [ item ], 1 );
+
+			sinon.assert.calledWithExactly( spy, sinon.match.has( 'source', collection ), item, 1 );
+		} );
+
+		it( 'should fire the "change" event', () => {
+			const spy = sinon.spy();
+			const items = [ {}, {} ];
+
+			collection.on( 'change', spy );
+
+			collection.addMany( items );
+
+			sinon.assert.calledOnce( spy );
+			expect( spy.args[ 0 ][ 1 ] ).to.deep.eql( {
+				added: items,
+				removed: [],
+				index: 0
+			} );
+		} );
+
+		it( 'should fire the "change" event with the index argument', () => {
+			const spy = sinon.spy();
+			const firstBatch = [ {}, {} ];
+			const secondBatch = [ {}, {} ];
+
+			collection.addMany( firstBatch );
+
+			collection.on( 'change', spy );
+
+			collection.addMany( secondBatch, 1 );
+
+			expect( spy.callCount, 'call count' ).to.equal( 1 );
+			expect( spy.args[ 0 ][ 1 ] ).to.deep.eql( {
+				added: secondBatch,
+				removed: [],
+				index: 1
+			} );
+		} );
+
+		it( 'should support an optional index argument', () => {
+			const item1 = getItem( 'foo' );
+			const item2 = getItem( 'bar' );
+			const item3 = getItem( 'baz' );
+			const item4 = getItem( 'abc' );
+
+			collection.addMany( [ item1 ] );
+			collection.addMany( [ item2 ], 0 );
+			collection.addMany( [ item3 ], 1 );
+			collection.addMany( [ item4 ], 3 );
+
+			expect( collection.get( 0 ) ).to.equal( item2 );
+			expect( collection.get( 1 ) ).to.equal( item3 );
+			expect( collection.get( 2 ) ).to.equal( item1 );
+			expect( collection.get( 3 ) ).to.equal( item4 );
+		} );
+
+		it( 'should throw when index argument is invalid', () => {
+			const item1 = getItem( 'foo' );
+			const item2 = getItem( 'bar' );
+			const item3 = getItem( 'baz' );
+
+			collection.addMany( [ item1 ] );
+
+			expectToThrowCKEditorError( () => {
+				collection.addMany( [ item2 ], -1 );
+			}, /^collection-add-item-invalid-index/ );
+
+			expectToThrowCKEditorError( () => {
+				collection.addMany( [ item2 ], 2 );
+			}, /^collection-add-item-invalid-index/ );
+
+			collection.addMany( [ item2 ], 1 );
+			collection.addMany( [ item3 ], 0 );
+
+			expect( collection.length ).to.equal( 3 );
+		} );
+	} );
+
 	describe( 'get()', () => {
 		it( 'should return an item', () => {
 			const item = getItem( 'foo' );
@@ -546,6 +817,23 @@ describe( 'Collection', () => {
 			sinon.assert.calledWithExactly( spy, sinon.match.has( 'source', collection ), item3, 0 );
 		} );
 
+		it( 'should fire the "change" event', () => {
+			const item = getItem( 'foo' );
+			const spy = sinon.spy();
+
+			collection.add( item );
+			collection.on( 'change', spy );
+
+			collection.remove( item );
+
+			sinon.assert.calledOnce( spy );
+			expect( spy.args[ 0 ][ 1 ] ).to.deep.eql( {
+				added: [],
+				removed: [ item ],
+				index: 0
+			} );
+		} );
+
 		it( 'should throw an error on invalid index', () => {
 			collection.add( getItem( 'foo' ) );
 
@@ -656,6 +944,24 @@ describe( 'Collection', () => {
 
 			expect( collection._bindToCollection ).to.be.null;
 		} );
+
+		it( 'should fire the "change" event', () => {
+			const items = [ {}, {}, {} ];
+			const spy = sinon.spy();
+
+			collection.addMany( items );
+			collection.on( 'change', spy );
+
+			collection.clear();
+
+			sinon.assert.calledOnce( spy );
+
+			expect( spy.args[ 0 ][ 1 ] ).to.deep.eql( {
+				added: [],
+				removed: items,
+				index: 0
+			} );
+		} );
 	} );
 
 	describe( 'bindTo()', () => {

+ 1 - 0
scripts/release/changelog.js

@@ -15,6 +15,7 @@ Promise.resolve()
 	.then( () => devEnv.generateChangelogForMonoRepository( {
 		cwd: process.cwd(),
 		packages: 'packages',
+		releaseBranch: 'release',
 		highlightsPlaceholder: true,
 		collaborationFeatures: true,
 		transformScope: name => {

Разница между файлами не показана из-за своего большого размера
+ 426 - 570
yarn.lock


Некоторые файлы не были показаны из-за большого количества измененных файлов