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

Merge branch 'master' into local/single-line-experiments

Kuba Niegowski 5 лет назад
Родитель
Сommit
31126d1fb7
100 измененных файлов с 4521 добавлено и 752 удалено
  1. 1 1
      CHANGELOG.md
  2. 10 2
      docs/features/keyboard-support.md
  3. 3 3
      docs/framework/guides/contributing/git-commit-message-convention.md
  4. 1 0
      package.json
  5. 1 0
      packages/ckeditor5-core/theme/icons/object-size-full.svg
  6. 1 0
      packages/ckeditor5-core/theme/icons/object-size-large.svg
  7. 1 0
      packages/ckeditor5-core/theme/icons/object-size-medium.svg
  8. 1 0
      packages/ckeditor5-core/theme/icons/object-size-small.svg
  9. 22 0
      packages/ckeditor5-engine/src/model/element.js
  10. 16 0
      packages/ckeditor5-engine/src/model/position.js
  11. 1 1
      packages/ckeditor5-engine/src/view/domconverter.js
  12. 27 0
      packages/ckeditor5-engine/tests/model/element.js
  13. 20 0
      packages/ckeditor5-engine/tests/model/position.js
  14. 5 15
      packages/ckeditor5-font/src/documentcolorcollection.js
  15. 1 1
      packages/ckeditor5-highlight/package.json
  16. 9 6
      packages/ckeditor5-image/docs/_snippets/features/image-resizeui.js
  17. 1 1
      packages/ckeditor5-image/docs/_snippets/features/image-resizeuidropdown.js
  18. 5 3
      packages/ckeditor5-image/lang/contexts.json
  19. 66 22
      packages/ckeditor5-image/src/imageresize.js
  20. 1 1
      packages/ckeditor5-image/src/imageresize/imageresizeediting.js
  21. 156 65
      packages/ckeditor5-image/src/imageresize/imageresizeui.js
  22. 136 68
      packages/ckeditor5-image/tests/imageresize/imageresizeui.js
  23. 10 13
      packages/ckeditor5-image/tests/manual/imageresizeui.js
  24. 18 9
      packages/ckeditor5-image/tests/manual/imageresizeui.md
  25. 12 0
      packages/ckeditor5-image/theme/imageresize.css
  26. 1 1
      packages/ckeditor5-link/package.json
  27. 23 2
      packages/ckeditor5-link/src/linkcommand.js
  28. 139 3
      packages/ckeditor5-link/src/linkediting.js
  29. 2 0
      packages/ckeditor5-link/src/linkimage.js
  30. 120 4
      packages/ckeditor5-link/src/linkimageediting.js
  31. 2 2
      packages/ckeditor5-link/src/linkui.js
  32. 3 2
      packages/ckeditor5-link/src/unlinkcommand.js
  33. 15 0
      packages/ckeditor5-link/src/utils.js
  34. 41 1
      packages/ckeditor5-link/src/utils/automaticdecorators.js
  35. 21 0
      packages/ckeditor5-link/tests/linkcommand.js
  36. 355 11
      packages/ckeditor5-link/tests/linkediting.js
  37. 385 1
      packages/ckeditor5-link/tests/linkimageediting.js
  38. 8 0
      packages/ckeditor5-link/tests/linkui.js
  39. 10 8
      packages/ckeditor5-link/tests/manual/linkdecorator.js
  40. 3 1
      packages/ckeditor5-link/tests/manual/linkimage.md
  41. 31 2
      packages/ckeditor5-link/tests/utils.js
  42. 6 0
      packages/ckeditor5-link/tests/utils/automaticdecorators.js
  43. 1 1
      packages/ckeditor5-link/theme/icons/link.svg
  44. 18 0
      packages/ckeditor5-link/theme/linkimage.css
  45. 24 11
      packages/ckeditor5-page-break/src/pagebreakediting.js
  46. 30 2
      packages/ckeditor5-page-break/tests/pagebreakediting.js
  47. 1 0
      packages/ckeditor5-paste-from-office/package.json
  48. 16 0
      packages/ckeditor5-paste-from-office/tests/_data/page-break/index.js
  49. 738 0
      packages/ckeditor5-paste-from-office/tests/_data/page-break/page-break/input.html
  50. 3 0
      packages/ckeditor5-paste-from-office/tests/_data/page-break/page-break/model.html
  51. BIN
      packages/ckeditor5-paste-from-office/tests/_data/page-break/page-break/page-break.docx
  52. 3 1
      packages/ckeditor5-paste-from-office/tests/_utils/fixtures.js
  53. 10 0
      packages/ckeditor5-paste-from-office/tests/data/integration.js
  54. 3 2
      packages/ckeditor5-paste-from-office/tests/manual/integration.js
  55. 2 3
      packages/ckeditor5-table/src/commands/insertcolumncommand.js
  56. 2 3
      packages/ckeditor5-table/src/commands/insertrowcommand.js
  57. 3 4
      packages/ckeditor5-table/src/commands/inserttablecommand.js
  58. 2 2
      packages/ckeditor5-table/src/commands/mergecellcommand.js
  59. 2 2
      packages/ckeditor5-table/src/commands/mergecellscommand.js
  60. 1 2
      packages/ckeditor5-table/src/commands/removecolumncommand.js
  61. 2 3
      packages/ckeditor5-table/src/commands/removerowcommand.js
  62. 1 2
      packages/ckeditor5-table/src/commands/selectcolumncommand.js
  63. 1 2
      packages/ckeditor5-table/src/commands/selectrowcommand.js
  64. 1 2
      packages/ckeditor5-table/src/commands/setheadercolumncommand.js
  65. 2 2
      packages/ckeditor5-table/src/commands/setheaderrowcommand.js
  66. 3 3
      packages/ckeditor5-table/src/converters/table-layout-post-fixer.js
  67. 40 14
      packages/ckeditor5-table/src/tableclipboard.js
  68. 2 3
      packages/ckeditor5-table/src/tablekeyboard.js
  69. 1 6
      packages/ckeditor5-table/src/tablemouse.js
  70. 2 4
      packages/ckeditor5-table/src/tableproperties/commands/tablepropertycommand.js
  71. 2 3
      packages/ckeditor5-table/src/tableselection.js
  72. 18 4
      packages/ckeditor5-table/src/tableutils.js
  73. 4 20
      packages/ckeditor5-table/src/utils/common.js
  74. 13 8
      packages/ckeditor5-table/src/utils/selection.js
  75. 10 2
      packages/ckeditor5-table/src/utils/structure.js
  76. 2 3
      packages/ckeditor5-table/src/utils/ui/contextualballoon.js
  77. 12 1
      packages/ckeditor5-table/src/utils/ui/widget.js
  78. 15 0
      packages/ckeditor5-table/tests/commands/inserttablecommand.js
  79. 13 2
      packages/ckeditor5-table/tests/manual/tablemocking.html
  80. 5 2
      packages/ckeditor5-table/tests/manual/tablemocking.js
  81. 0 2
      packages/ckeditor5-table/tests/manual/tablemocking.md
  82. 374 10
      packages/ckeditor5-table/tests/tableclipboard-paste.js
  83. 66 0
      packages/ckeditor5-table/tests/tableutils.js
  84. 1 18
      packages/ckeditor5-table/tests/utils/common.js
  85. 1 2
      packages/ckeditor5-table/tests/utils/ui/contextualballoon.js
  86. 9 0
      packages/ckeditor5-theme-lark/theme/ckeditor5-link/linkimage.css
  87. 3 3
      packages/ckeditor5-typing/src/inputcommand.js
  88. 4 1
      packages/ckeditor5-typing/src/utils/injectunsafekeystrokeshandling.js
  89. 24 2
      packages/ckeditor5-typing/tests/inputcommand.js
  90. 46 3
      packages/ckeditor5-typing/tests/utils/injectunsafekeystrokeshandling.js
  91. 116 49
      packages/ckeditor5-utils/src/collection.js
  92. 306 0
      packages/ckeditor5-utils/tests/collection.js
  93. 1 0
      packages/ckeditor5-widget/package.json
  94. 6 3
      packages/ckeditor5-widget/src/widget.js
  95. 77 2
      packages/ckeditor5-widget/tests/widget-integration.js
  96. 292 18
      scripts/docs/build-content-styles.js
  97. 0 96
      scripts/docs/content-styles/ckeditor.js
  98. 0 91
      scripts/docs/content-styles/list-content-styles.js
  99. 1 0
      scripts/release/changelog.js
  100. 496 84
      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
 

+ 10 - 2
docs/features/keyboard-support.md

@@ -67,13 +67,21 @@ Below is a list of the most important keystrokes supported by CKEditor 5 and its
 			<th colspan="3">When a widget is selected (for example: image, table, horizontal line, etc.)</th>
 		</tr>
 		<tr>
-            <td>Insert a new paragraph directly after a widget</td>
+			<td>Insert a new paragraph directly after a widget</td>
 			<td colspan="2"><kbd>Enter</kbd></td>
-		</tr> 
+		</tr>
 		<tr>
 			<td>Insert a new paragraph directly before a widget</td>
 			<td colspan="2"><kbd>Shift</kbd> + <kbd>Enter</kbd></td>
 		</tr>
+		<tr>
+			<td>Display the caret to allow typing directly before a widget</td>
+			<td colspan="2"><kbd>↑</kbd> / <kbd>←</kbd></td>
+		</tr>
+		<tr>
+			<td>Display the caret to allow typing directly after a widget</td>
+			<td colspan="2"><kbd>↓</kbd> / <kbd>→</kbd></td>
+		</tr>
 		<tr>
 			<th colspan="3">In a table cell</th>
 		</tr>

+ 3 - 3
docs/framework/guides/contributing/git-commit-message-convention.md

@@ -24,8 +24,8 @@ Type (another-package-name): If the change affects more than one package, it's p
 
 Optional description.
 
-BREAKING CHANGE (scope): If any breaking changes were done, they need to be listed here.
-BREAKING CHANGE (scope): Another breaking change if needed. Closes #ZZZ.
+BREAKING CHANGE (package-name): If any breaking changes were done, they need to be listed here.
+BREAKING CHANGE (package-name): Another breaking change if needed. Closes #ZZZ.
 ```
 
 ### Commit types
@@ -50,7 +50,7 @@ If any change contains the `MAJOR BREAKING CHANGE` note, the next release will b
 
 For reference on how to identify minor or major breaking change see the {@link framework/guides/support/versioning-policy versioning policy guide}.
 
-Each `BREAKING CHANGE` note must be followed by the scope of changes.
+Each `BREAKING CHANGE` note must be followed by the package name.
 
 ### Package name
 

+ 1 - 0
package.json

@@ -98,6 +98,7 @@
     "lint-staged": "^10.2.6",
     "mini-css-extract-plugin": "^0.9.0",
     "minimatch": "^3.0.4",
+    "mkdirp": "^1.0.4",
     "nyc": "^15.0.1",
     "postcss-loader": "^3.0.0",
     "progress-bar-webpack-plugin": "^2.1.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>

+ 22 - 0
packages/ckeditor5-engine/src/model/element.js

@@ -209,6 +209,28 @@ export default class Element extends Node {
 		return node;
 	}
 
+	/**
+	 * Returns the parent element of the given name. Returns null if the element is not inside the desired parent.
+	 *
+	 * @param {String} parentName The name of the parent element to find.
+	 * @param {Object} [options] Options object.
+	 * @param {Boolean} [options.includeSelf=false] When set to `true` this node will be also included while searching.
+	 * @returns {module:engine/model/element~Element|null}
+	 */
+	findAncestor( parentName, options = { includeSelf: false } ) {
+		let parent = options.includeSelf ? this : this.parent;
+
+		while ( parent ) {
+			if ( parent.name === parentName ) {
+				return parent;
+			}
+
+			parent = parent.parent;
+		}
+
+		return null;
+	}
+
 	/**
 	 * Converts `Element` instance to plain object and returns it. Takes care of converting all of this element's children.
 	 *

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

@@ -354,6 +354,22 @@ export default class Position {
 		}
 	}
 
+	/**
+	 * Returns the parent element of the given name. Returns null if the position is not inside the desired parent.
+	 *
+	 * @param {String} parentName The name of the parent element to find.
+	 * @returns {module:engine/model/element~Element|null}
+	 */
+	findAncestor( parentName ) {
+		const parent = this.parent;
+
+		if ( parent.is( 'element' ) ) {
+			return parent.findAncestor( parentName, { includeSelf: true } );
+		}
+
+		return null;
+	}
+
 	/**
 	 * Returns the slice of two position {@link #path paths} which is identical. The {@link #root roots}
 	 * of these two paths must be identical.

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

@@ -82,7 +82,7 @@ export default class DomConverter {
 		 * @readonly
 		 * @member {Array.<String>} module:engine/view/domconverter~DomConverter#blockElements
 		 */
-		this.blockElements = [ 'p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'dd', 'dt', 'figcaption' ];
+		this.blockElements = [ 'p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'dd', 'dt', 'figcaption', 'td', 'th' ];
 
 		/**
 		 * Block {@link module:engine/view/filler filler} creator, which is used to create all block fillers during the

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

@@ -247,6 +247,33 @@ describe( 'Element', () => {
 		} );
 	} );
 
+	describe( 'findAncestor', () => {
+		let p, td, tr, table;
+
+		beforeEach( () => {
+			p = new Element( 'p', [], [ new Text( 'foo' ) ] );
+			td = new Element( 'td', [], [ p ] );
+			tr = new Element( 'tr', [], [ td ] );
+			table = new Element( 'table', [], [ tr ] );
+		} );
+
+		it( 'should return ancestor', () => {
+			expect( p.findAncestor( 'p' ) ).to.be.null;
+			expect( p.findAncestor( 'td' ) ).to.equal( td );
+			expect( p.findAncestor( 'tr' ) ).to.equal( tr );
+			expect( p.findAncestor( 'table' ) ).to.equal( table );
+			expect( p.findAncestor( 'abc' ) ).to.be.null;
+		} );
+
+		it( 'should return ancestor or self (includeSelf = true)', () => {
+			expect( p.findAncestor( 'p', { includeSelf: true } ) ).to.equal( p );
+			expect( p.findAncestor( 'td', { includeSelf: true } ) ).to.equal( td );
+			expect( p.findAncestor( 'tr', { includeSelf: true } ) ).to.equal( tr );
+			expect( p.findAncestor( 'table', { includeSelf: true } ) ).to.equal( table );
+			expect( p.findAncestor( 'abc', { includeSelf: true } ) ).to.be.null;
+		} );
+	} );
+
 	describe( 'getChildIndex', () => {
 		it( 'should return child index', () => {
 			const element = new Element( 'elem', [], [ new Element( 'p' ), new Text( 'bar' ), new Element( 'h' ) ] );

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

@@ -684,6 +684,26 @@ describe( 'Position', () => {
 		} );
 	} );
 
+	describe( 'findAncestor()', () => {
+		it( 'should return position parent element', () => {
+			expect( new Position( root, [ 1, 1, 1 ] ).findAncestor( 'li' ) ).to.equal( li2 );
+		} );
+
+		it( 'should return deeper ancestor element', () => {
+			expect( new Position( root, [ 1, 1, 1 ] ).findAncestor( 'ul' ) ).to.equal( ul );
+		} );
+
+		it( 'should return null if ancestor is not found', () => {
+			expect( new Position( root, [ 1, 1, 1 ] ).findAncestor( 'p' ) ).to.be.null;
+		} );
+
+		it( 'should return null if position is not in an element', () => {
+			const docFrag = new DocumentFragment();
+
+			expect( new Position( docFrag, [ 0 ] ).findAncestor( 'li' ) ).to.be.null;
+		} );
+	} );
+
 	describe( 'getCommonPath()', () => {
 		it( 'returns the common part', () => {
 			const pos1 = new Position( root, [ 1, 0, 0 ] );

+ 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",

+ 9 - 6
packages/ckeditor5-image/docs/_snippets/features/image-resizeui.js

@@ -17,26 +17,29 @@ 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'
 				}
 			],
 			toolbar: [
 				'imageStyle:full',
 				'imageStyle:side', '|',
-				'imageResize:original',
 				'imageResize:50',
-				'imageResize:75'
+				'imageResize:75',
+				'imageResize:original'
 			]
 		},
 		cloudServices: CS_CONFIG

+ 1 - 1
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
 				},
 				{

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

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

@@ -36,17 +36,14 @@ 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'
 		}
 	],
@@ -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 - 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>

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

@@ -0,0 +1,18 @@
+/*
+ * 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: 18px;
+	height: 18px;
+	padding: 5px;
+	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' ]
 		},

+ 2 - 3
packages/ckeditor5-table/src/commands/insertcolumncommand.js

@@ -9,7 +9,6 @@
 
 import Command from '@ckeditor/ckeditor5-core/src/command';
 import { getColumnIndexes, getSelectionAffectedTableCells } from '../utils/selection';
-import { findAncestor } from '../utils/common';
 
 /**
  * The insert column command.
@@ -54,7 +53,7 @@ export default class InsertColumnCommand extends Command {
 	refresh() {
 		const selection = this.editor.model.document.selection;
 
-		const tableParent = findAncestor( 'table', selection.getFirstPosition() );
+		const tableParent = selection.getFirstPosition().findAncestor( 'table' );
 
 		this.isEnabled = !!tableParent;
 	}
@@ -77,7 +76,7 @@ export default class InsertColumnCommand extends Command {
 		const columnIndexes = getColumnIndexes( affectedTableCells );
 
 		const column = insertBefore ? columnIndexes.first : columnIndexes.last;
-		const table = findAncestor( 'table', affectedTableCells[ 0 ] );
+		const table = affectedTableCells[ 0 ].findAncestor( 'table' );
 
 		tableUtils.insertColumns( table, { columns: 1, at: insertBefore ? column : column + 1 } );
 	}

+ 2 - 3
packages/ckeditor5-table/src/commands/insertrowcommand.js

@@ -9,7 +9,6 @@
 
 import Command from '@ckeditor/ckeditor5-core/src/command';
 import { getRowIndexes, getSelectionAffectedTableCells } from '../utils/selection';
-import { findAncestor } from '../utils/common';
 
 /**
  * The insert row command.
@@ -54,7 +53,7 @@ export default class InsertRowCommand extends Command {
 	refresh() {
 		const selection = this.editor.model.document.selection;
 
-		const tableParent = findAncestor( 'table', selection.getFirstPosition() );
+		const tableParent = selection.getFirstPosition().findAncestor( 'table' );
 
 		this.isEnabled = !!tableParent;
 	}
@@ -76,7 +75,7 @@ export default class InsertRowCommand extends Command {
 		const rowIndexes = getRowIndexes( affectedTableCells );
 
 		const row = insertAbove ? rowIndexes.first : rowIndexes.last;
-		const table = findAncestor( 'table', affectedTableCells[ 0 ] );
+		const table = affectedTableCells[ 0 ].findAncestor( 'table' );
 
 		tableUtils.insertRows( table, { at: insertAbove ? row : row + 1, copyStructureFromAbove: !insertAbove } );
 	}

+ 3 - 4
packages/ckeditor5-table/src/commands/inserttablecommand.js

@@ -43,6 +43,8 @@ export default class InsertTableCommand extends Command {
 	 * @param {Object} options
 	 * @param {Number} [options.rows=2] The number of rows to create in the inserted table.
 	 * @param {Number} [options.columns=2] The number of columns to create in the inserted table.
+	 * @param {Number} [options.headingRows=0] The number of heading rows.
+	 * @param {Number} [options.headingColumns=0] The number of heading columns.
 	 * @fires execute
 	 */
 	execute( options = {} ) {
@@ -50,13 +52,10 @@ export default class InsertTableCommand extends Command {
 		const selection = model.document.selection;
 		const tableUtils = this.editor.plugins.get( 'TableUtils' );
 
-		const rows = parseInt( options.rows ) || 2;
-		const columns = parseInt( options.columns ) || 2;
-
 		const insertPosition = findOptimalInsertionPosition( selection, model );
 
 		model.change( writer => {
-			const table = tableUtils.createTable( writer, rows, columns );
+			const table = tableUtils.createTable( writer, options );
 
 			model.insertContent( table, insertPosition );
 

+ 2 - 2
packages/ckeditor5-table/src/commands/mergecellcommand.js

@@ -10,7 +10,7 @@
 import Command from '@ckeditor/ckeditor5-core/src/command';
 import TableWalker from '../tablewalker';
 import { getTableCellsContainingSelection } from '../utils/selection';
-import { findAncestor, isHeadingColumnCell } from '../utils/common';
+import { isHeadingColumnCell } from '../utils/common';
 import { removeEmptyRowsColumns } from '../utils/structure';
 
 /**
@@ -106,7 +106,7 @@ export default class MergeCellCommand extends Command {
 			writer.setSelection( writer.createRangeIn( cellToExpand ) );
 
 			const tableUtils = this.editor.plugins.get( 'TableUtils' );
-			const table = findAncestor( 'table', removedTableCellRow );
+			const table = removedTableCellRow.findAncestor( 'table' );
 
 			// Remove empty rows and columns after merging.
 			removeEmptyRowsColumns( table, tableUtils );

+ 2 - 2
packages/ckeditor5-table/src/commands/mergecellscommand.js

@@ -10,7 +10,7 @@
 import Command from '@ckeditor/ckeditor5-core/src/command';
 import TableUtils from '../tableutils';
 import { getSelectedTableCells, isSelectionRectangular } from '../utils/selection';
-import { findAncestor, updateNumericAttribute } from '../utils/common';
+import { updateNumericAttribute } from '../utils/common';
 import { removeEmptyRowsColumns } from '../utils/structure';
 
 /**
@@ -57,7 +57,7 @@ export default class MergeCellsCommand extends Command {
 				mergeTableCells( tableCell, firstTableCell, writer );
 			}
 
-			const table = findAncestor( 'table', firstTableCell );
+			const table = firstTableCell.findAncestor( 'table' );
 
 			// Remove rows and columns that become empty (have no anchored cells).
 			removeEmptyRowsColumns( table, tableUtils );

+ 1 - 2
packages/ckeditor5-table/src/commands/removecolumncommand.js

@@ -11,7 +11,6 @@ import Command from '@ckeditor/ckeditor5-core/src/command';
 
 import TableWalker from '../tablewalker';
 import { getColumnIndexes, getSelectionAffectedTableCells } from '../utils/selection';
-import { findAncestor } from '../utils/common';
 
 /**
  * The remove column command.
@@ -33,7 +32,7 @@ export default class RemoveColumnCommand extends Command {
 		const firstCell = selectedCells[ 0 ];
 
 		if ( firstCell ) {
-			const table = findAncestor( 'table', firstCell );
+			const table = firstCell.findAncestor( 'table' );
 			const tableColumnCount = this.editor.plugins.get( 'TableUtils' ).getColumns( table );
 
 			const { first, last } = getColumnIndexes( selectedCells );

+ 2 - 3
packages/ckeditor5-table/src/commands/removerowcommand.js

@@ -10,7 +10,6 @@
 import Command from '@ckeditor/ckeditor5-core/src/command';
 
 import { getRowIndexes, getSelectionAffectedTableCells } from '../utils/selection';
-import { findAncestor } from '../utils/common';
 
 /**
  * The remove row command.
@@ -32,7 +31,7 @@ export default class RemoveRowCommand extends Command {
 		const firstCell = selectedCells[ 0 ];
 
 		if ( firstCell ) {
-			const table = findAncestor( 'table', firstCell );
+			const table = firstCell.findAncestor( 'table' );
 			const tableRowCount = this.editor.plugins.get( 'TableUtils' ).getRows( table );
 			const lastRowIndex = tableRowCount - 1;
 
@@ -56,7 +55,7 @@ export default class RemoveRowCommand extends Command {
 		const removedRowIndexes = getRowIndexes( referenceCells );
 
 		const firstCell = referenceCells[ 0 ];
-		const table = findAncestor( 'table', firstCell );
+		const table = firstCell.findAncestor( 'table' );
 
 		const columnIndexToFocus = this.editor.plugins.get( 'TableUtils' ).getCellLocation( firstCell ).column;
 

+ 1 - 2
packages/ckeditor5-table/src/commands/selectcolumncommand.js

@@ -11,7 +11,6 @@ import Command from '@ckeditor/ckeditor5-core/src/command';
 
 import TableWalker from '../tablewalker';
 import { getSelectionAffectedTableCells } from '../utils/selection';
-import { findAncestor } from '../utils/common';
 
 /**
  * The select column command.
@@ -42,7 +41,7 @@ export default class SelectColumnCommand extends Command {
 		const referenceCells = getSelectionAffectedTableCells( model.document.selection );
 		const firstCell = referenceCells[ 0 ];
 		const lastCell = referenceCells.pop();
-		const table = findAncestor( 'table', firstCell );
+		const table = firstCell.findAncestor( 'table' );
 
 		const tableUtils = this.editor.plugins.get( 'TableUtils' );
 		const startLocation = tableUtils.getCellLocation( firstCell );

+ 1 - 2
packages/ckeditor5-table/src/commands/selectrowcommand.js

@@ -10,7 +10,6 @@
 import Command from '@ckeditor/ckeditor5-core/src/command';
 
 import { getRowIndexes, getSelectionAffectedTableCells } from '../utils/selection';
-import { findAncestor } from '../utils/common';
 
 /**
  * The select row command.
@@ -41,7 +40,7 @@ export default class SelectRowCommand extends Command {
 		const referenceCells = getSelectionAffectedTableCells( model.document.selection );
 		const rowIndexes = getRowIndexes( referenceCells );
 
-		const table = findAncestor( 'table', referenceCells[ 0 ] );
+		const table = referenceCells[ 0 ].findAncestor( 'table' );
 		const rangesToSelect = [];
 
 		for ( let rowIndex = rowIndexes.first; rowIndex <= rowIndexes.last; rowIndex++ ) {

+ 1 - 2
packages/ckeditor5-table/src/commands/setheadercolumncommand.js

@@ -10,7 +10,6 @@
 import Command from '@ckeditor/ckeditor5-core/src/command';
 
 import {
-	findAncestor,
 	isHeadingColumnCell,
 	updateNumericAttribute
 } from '../utils/common';
@@ -74,7 +73,7 @@ export default class SetHeaderColumnCommand extends Command {
 
 		const model = this.editor.model;
 		const selectedCells = getSelectionAffectedTableCells( model.document.selection );
-		const table = findAncestor( 'table', selectedCells[ 0 ] );
+		const table = selectedCells[ 0 ].findAncestor( 'table' );
 
 		const { first, last } = getColumnIndexes( selectedCells );
 		const headingColumnsToSet = this.value ? first : last + 1;

+ 2 - 2
packages/ckeditor5-table/src/commands/setheaderrowcommand.js

@@ -9,7 +9,7 @@
 
 import Command from '@ckeditor/ckeditor5-core/src/command';
 
-import { findAncestor, updateNumericAttribute } from '../utils/common';
+import { updateNumericAttribute } from '../utils/common';
 import { getRowIndexes, getSelectionAffectedTableCells } from '../utils/selection';
 import { getVerticallyOverlappingCells, splitHorizontally } from '../utils/structure';
 
@@ -67,7 +67,7 @@ export default class SetHeaderRowCommand extends Command {
 		}
 		const model = this.editor.model;
 		const selectedCells = getSelectionAffectedTableCells( model.document.selection );
-		const table = findAncestor( 'table', selectedCells[ 0 ] );
+		const table = selectedCells[ 0 ].findAncestor( 'table' );
 
 		const { first, last } = getRowIndexes( selectedCells );
 		const headingRowsToSet = this.value ? first : last + 1;

+ 3 - 3
packages/ckeditor5-table/src/converters/table-layout-post-fixer.js

@@ -8,7 +8,7 @@
  */
 
 import TableWalker from './../tablewalker';
-import { createEmptyTableCell, findAncestor, updateNumericAttribute } from '../utils/common';
+import { createEmptyTableCell, updateNumericAttribute } from '../utils/common';
 
 /**
  * Injects a table layout post-fixer into the model.
@@ -238,12 +238,12 @@ function tableLayoutPostFixer( writer, model ) {
 
 		// Fix table on adding/removing table cells and rows.
 		if ( entry.name == 'tableRow' || entry.name == 'tableCell' ) {
-			table = findAncestor( 'table', entry.position );
+			table = entry.position.findAncestor( 'table' );
 		}
 
 		// Fix table on any table's attribute change - including attributes of table cells.
 		if ( isTableAttributeEntry( entry ) ) {
-			table = findAncestor( 'table', entry.range.start );
+			table = entry.range.start.findAncestor( 'table' );
 		}
 
 		if ( table && !analyzedTables.has( table ) ) {

+ 40 - 14
packages/ckeditor5-table/src/tableclipboard.js

@@ -11,11 +11,8 @@ import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 
 import TableSelection from './tableselection';
 import TableWalker from './tablewalker';
-import {
-	findAncestor
-} from './utils/common';
 import TableUtils from './tableutils';
-import { getColumnIndexes, getRowIndexes, getSelectionAffectedTableCells, isSelectionRectangular } from './utils/selection';
+import { getColumnIndexes, getRowIndexes, getSelectionAffectedTableCells, isSelectionRectangular, sortRanges } from './utils/selection';
 import {
 	cropTableToDimensions,
 	getHorizontallyOverlappingCells,
@@ -165,9 +162,20 @@ export default class TableClipboard extends Plugin {
 			pastedTable = cropTableToDimensions( pastedTable, cropDimensions, writer );
 
 			// Content table to which we insert a pasted table.
-			const selectedTable = findAncestor( 'table', selectedTableCells[ 0 ] );
+			const selectedTable = selectedTableCells[ 0 ].findAncestor( 'table' );
+
+			const cellsToSelect = replaceSelectedCellsWithPasted( pastedTable, pastedDimensions, selectedTable, selection, writer );
 
-			replaceSelectedCellsWithPasted( pastedTable, pastedDimensions, selectedTable, selection, writer );
+			if ( this.editor.plugins.get( 'TableSelection' ).isEnabled ) {
+				// Selection ranges must be sorted because the first and last selection ranges are considered
+				// as anchor/focus cell ranges for multi-cell selection.
+				const selectionRanges = sortRanges( cellsToSelect.map( cell => writer.createRangeOn( cell ) ) );
+
+				writer.setSelection( selectionRanges );
+			} else {
+				// Set selection inside first cell if multi-cell selection is disabled.
+				writer.setSelection( cellsToSelect[ 0 ], 0 );
+			}
 		} );
 	}
 }
@@ -186,7 +194,7 @@ export default class TableClipboard extends Plugin {
 // @returns {Number} selection.lastColumn
 // @returns {Number} selection.lastRow
 function prepareTableForPasting( selectedTableCells, pastedDimensions, writer, tableUtils ) {
-	const selectedTable = findAncestor( 'table', selectedTableCells[ 0 ] );
+	const selectedTable = selectedTableCells[ 0 ].findAncestor( 'table' );
 
 	const columnIndexes = getColumnIndexes( selectedTableCells );
 	const rowIndexes = getRowIndexes( selectedTableCells );
@@ -251,6 +259,7 @@ function prepareTableForPasting( selectedTableCells, pastedDimensions, writer, t
 // @param {Number} selection.lastColumn
 // @param {Number} selection.lastRow
 // @param {module:engine/model/writer~Writer} writer
+// @returns {Array.<module:engine/model/element~Element>}
 function replaceSelectedCellsWithPasted( pastedTable, pastedDimensions, selectedTable, selection, writer ) {
 	const { width: pastedWidth, height: pastedHeight } = pastedDimensions;
 
@@ -316,7 +325,28 @@ function replaceSelectedCellsWithPasted( pastedTable, pastedDimensions, selected
 		insertPosition = writer.createPositionAfter( cellToInsert );
 	}
 
-	writer.setSelection( cellsToSelect.map( cell => writer.createRangeOn( cell ) ) );
+	// If there are any headings, all the cells that overlap from heading must be splitted.
+	const headingRows = parseInt( selectedTable.getAttribute( 'headingRows' ) || 0 );
+	const headingColumns = parseInt( selectedTable.getAttribute( 'headingColumns' ) || 0 );
+
+	const areHeadingRowsIntersectingSelection = selection.firstRow < headingRows && headingRows <= selection.lastRow;
+	const areHeadingColumnsIntersectingSelection = selection.firstColumn < headingColumns && headingColumns <= selection.lastColumn;
+
+	if ( areHeadingRowsIntersectingSelection ) {
+		const columnsLimit = { first: selection.firstColumn, last: selection.lastColumn };
+		const newCells = doHorizontalSplit( selectedTable, headingRows, columnsLimit, writer, selection.firstRow );
+
+		cellsToSelect.push( ...newCells );
+	}
+
+	if ( areHeadingColumnsIntersectingSelection ) {
+		const rowsLimit = { first: selection.firstRow, last: selection.lastRow };
+		const newCells = doVerticalSplit( selectedTable, headingColumns, rowsLimit, writer );
+
+		cellsToSelect.push( ...newCells );
+	}
+
+	return cellsToSelect;
 }
 
 // Expand table (in place) to expected size.
@@ -487,9 +517,7 @@ function doHorizontalSplit( table, splitRow, limitColumns, writer, startRow = 0
 	// Filter out cells that are not touching insides of the rectangular selection.
 	const cellsToSplit = overlappingCells.filter( ( { column, cellWidth } ) => isAffectedBySelection( column, cellWidth, limitColumns ) );
 
-	for ( const { cell } of cellsToSplit ) {
-		splitHorizontally( cell, splitRow, writer );
-	}
+	return cellsToSplit.map( ( { cell } ) => splitHorizontally( cell, splitRow, writer ) );
 }
 
 function doVerticalSplit( table, splitColumn, limitRows, writer ) {
@@ -503,9 +531,7 @@ function doVerticalSplit( table, splitColumn, limitRows, writer ) {
 	// Filter out cells that are not touching insides of the rectangular selection.
 	const cellsToSplit = overlappingCells.filter( ( { row, cellHeight } ) => isAffectedBySelection( row, cellHeight, limitRows ) );
 
-	for ( const { cell, column } of cellsToSplit ) {
-		splitVertically( cell, column, splitColumn, writer );
-	}
+	return cellsToSplit.map( ( { cell, column } ) => splitVertically( cell, column, splitColumn, writer ) );
 }
 
 // Checks if cell at given row (column) is affected by a rectangular selection defined by first/last column (row).

+ 2 - 3
packages/ckeditor5-table/src/tablekeyboard.js

@@ -17,7 +17,6 @@ import {
 	getLocalizedArrowKeyCodeDirection
 } from '@ckeditor/ckeditor5-utils/src/keyboard';
 import { getSelectedTableCells, getTableCellsContainingSelection } from './utils/selection';
-import { findAncestor } from './utils/common';
 
 /**
  * This plugin enables keyboard navigation for tables.
@@ -213,7 +212,7 @@ export default class TableKeyboard extends Plugin {
 		}
 
 		// Abort if we're not in a table cell.
-		const tableCell = findAncestor( 'tableCell', selection.focus );
+		const tableCell = selection.focus.findAncestor( 'tableCell' );
 
 		if ( !tableCell ) {
 			return false;
@@ -271,7 +270,7 @@ export default class TableKeyboard extends Plugin {
 	_navigateFromCellInDirection( focusCell, direction, expandSelection = false ) {
 		const model = this.editor.model;
 
-		const table = findAncestor( 'table', focusCell );
+		const table = focusCell.findAncestor( 'table' );
 		const tableMap = [ ...new TableWalker( table, { includeAllSlots: true } ) ];
 		const { row: lastRow, column: lastColumn } = tableMap[ tableMap.length - 1 ];
 

+ 1 - 6
packages/ckeditor5-table/src/tablemouse.js

@@ -12,7 +12,6 @@ import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import TableSelection from './tableselection';
 import MouseEventsObserver from './tablemouse/mouseeventsobserver';
 
-import { findAncestor } from './utils/common';
 import { getTableCellsContainingSelection } from './utils/selection';
 
 /**
@@ -210,11 +209,7 @@ export default class TableMouse extends Plugin {
 		const modelPosition = this.editor.editing.mapper.toModelPosition( viewPosition );
 		const modelElement = modelPosition.parent;
 
-		if ( modelElement.is( 'tableCell' ) ) {
-			return modelElement;
-		}
-
-		return findAncestor( 'tableCell', modelElement );
+		return modelElement.findAncestor( 'tableCell', { includeSelf: true } );
 	}
 }
 

+ 2 - 4
packages/ckeditor5-table/src/tableproperties/commands/tablepropertycommand.js

@@ -9,8 +9,6 @@
 
 import Command from '@ckeditor/ckeditor5-core/src/command';
 
-import { findAncestor } from '../../utils/common';
-
 /**
  * The table cell attribute command.
  *
@@ -38,7 +36,7 @@ export default class TablePropertyCommand extends Command {
 		const editor = this.editor;
 		const selection = editor.model.document.selection;
 
-		const table = findAncestor( 'table', selection.getFirstPosition() );
+		const table = selection.getFirstPosition().findAncestor( 'table' );
 
 		this.isEnabled = !!table;
 		this.value = this._getValue( table );
@@ -60,7 +58,7 @@ export default class TablePropertyCommand extends Command {
 
 		const { value, batch } = options;
 
-		const table = findAncestor( 'table', selection.getFirstPosition() );
+		const table = selection.getFirstPosition().findAncestor( 'table' );
 		const valueToSet = this._getValueToSet( value );
 
 		model.enqueueChange( batch || 'default', writer => {

+ 2 - 3
packages/ckeditor5-table/src/tableselection.js

@@ -13,7 +13,6 @@ import first from '@ckeditor/ckeditor5-utils/src/first';
 import TableWalker from './tablewalker';
 import TableUtils from './tableutils';
 
-import { findAncestor } from './utils/common';
 import { cropTableToDimensions, adjustLastRowIndex, adjustLastColumnIndex } from './utils/structure';
 import { getColumnIndexes, getRowIndexes, getSelectedTableCells, isSelectionRectangular } from './utils/selection';
 
@@ -95,7 +94,7 @@ export default class TableSelection extends Plugin {
 			const { first: firstColumn, last: lastColumn } = getColumnIndexes( selectedCells );
 			const { first: firstRow, last: lastRow } = getRowIndexes( selectedCells );
 
-			const sourceTable = findAncestor( 'table', selectedCells[ 0 ] );
+			const sourceTable = selectedCells[ 0 ].findAncestor( 'table' );
 
 			let adjustedLastRow = lastRow;
 			let adjustedLastColumn = lastColumn;
@@ -335,7 +334,7 @@ export default class TableSelection extends Plugin {
 			endColumn
 		};
 
-		for ( const { row, cell } of new TableWalker( findAncestor( 'table', anchorCell ), walkerOptions ) ) {
+		for ( const { row, cell } of new TableWalker( anchorCell.findAncestor( 'table' ), walkerOptions ) ) {
 			selectionMap[ row - startRow ].push( cell );
 		}
 

+ 18 - 4
packages/ckeditor5-table/src/tableutils.js

@@ -74,22 +74,36 @@ export default class TableUtils extends Plugin {
 	 *
 	 *		model.change( ( writer ) => {
 	 *			// Create a table of 2 rows and 7 columns:
-	 *			const table = tableUtils.createTable( writer, 2, 7);
+	 *			const table = tableUtils.createTable( writer, { rows: 2, columns: 7 } );
 	 *
 	 *			// Insert a table to the model at the best position taking the current selection:
 	 *			model.insertContent( table );
 	 *		}
 	 *
 	 * @param {module:engine/model/writer~Writer} writer The model writer.
-	 * @param {Number} rows The number of rows to create.
-	 * @param {Number} columns The number of columns to create.
+	 * @param {Object} options
+	 * @param {Number} [options.rows=2] The number of rows to create.
+	 * @param {Number} [options.columns=2] The number of columns to create.
+	 * @param {Number} [options.headingRows=0] The number of heading rows.
+	 * @param {Number} [options.headingColumns=0] The number of heading columns.
 	 * @returns {module:engine/model/element~Element} The created table element.
 	 */
-	createTable( writer, rows, columns ) {
+	createTable( writer, options ) {
 		const table = writer.createElement( 'table' );
 
+		const rows = parseInt( options.rows ) || 2;
+		const columns = parseInt( options.columns ) || 2;
+
 		createEmptyRows( writer, table, 0, rows, columns );
 
+		if ( options.headingRows ) {
+			updateNumericAttribute( 'headingRows', options.headingRows, table, writer, 0 );
+		}
+
+		if ( options.headingColumns ) {
+			updateNumericAttribute( 'headingColumns', options.headingColumns, table, writer, 0 );
+		}
+
 		return table;
 	}
 

+ 4 - 20
packages/ckeditor5-table/src/utils/common.js

@@ -7,26 +7,6 @@
  * @module table/utils/common
  */
 
-/**
- * Returns the parent element of the given name. Returns undefined if the position or the element is not inside the desired parent.
- *
- * @param {String} parentName The name of the parent element to find.
- * @param {module:engine/model/position~Position|module:engine/model/position~Position} positionOrElement The position or
- * the parentElement to start searching.
- * @returns {module:engine/model/element~Element|module:engine/model/documentfragment~DocumentFragment}
- */
-export function findAncestor( parentName, positionOrElement ) {
-	let parent = positionOrElement.parent;
-
-	while ( parent ) {
-		if ( parent.name === parentName ) {
-			return parent;
-		}
-
-		parent = parent.parent;
-	}
-}
-
 /**
  * A common method to update the numeric value. If a value is the default one, it will be unset.
  *
@@ -50,11 +30,15 @@ export function updateNumericAttribute( key, value, item, writer, defaultValue =
  * @param {module:engine/model/writer~Writer} writer The model writer.
  * @param {module:engine/model/position~Position} insertPosition The position at which the table cell should be inserted.
  * @param {Object} attributes The element attributes.
+ * @returns {module:engine/model/element~Element} Created table cell.
  */
 export function createEmptyTableCell( writer, insertPosition, attributes = {} ) {
 	const tableCell = writer.createElement( 'tableCell', attributes );
+
 	writer.insertElement( 'paragraph', tableCell );
 	writer.insert( tableCell, insertPosition );
+
+	return tableCell;
 }
 
 /**

+ 13 - 8
packages/ckeditor5-table/src/utils/selection.js

@@ -8,7 +8,6 @@
  */
 
 import TableWalker from '../tablewalker';
-import { findAncestor } from './common';
 
 /**
  * Returns all model table cells that are fully selected (from the outside)
@@ -48,7 +47,7 @@ export function getTableCellsContainingSelection( selection ) {
 	const cells = [];
 
 	for ( const range of selection.getRanges() ) {
-		const cellWithSelection = findAncestor( 'tableCell', range.start );
+		const cellWithSelection = range.start.findAncestor( 'tableCell' );
 
 		if ( cellWithSelection ) {
 			cells.push( cellWithSelection );
@@ -110,7 +109,7 @@ export function getRowIndexes( tableCells ) {
  * @returns {Object} Returns an object with the `first` and `last` table column indexes.
  */
 export function getColumnIndexes( tableCells ) {
-	const table = findAncestor( 'table', tableCells[ 0 ] );
+	const table = tableCells[ 0 ].findAncestor( 'table' );
 	const tableMap = [ ...new TableWalker( table ) ];
 
 	const indexes = tableMap
@@ -188,6 +187,16 @@ export function isSelectionRectangular( selectedTableCells, tableUtils ) {
 	return areaOfValidSelection == areaOfSelectedCells;
 }
 
+/**
+ * Returns array of sorted ranges.
+ *
+ * @param {Iterable.<module:engine/model/range~Range>} ranges
+ * @return {Array.<module:engine/model/range~Range>}
+ */
+export function sortRanges( ranges ) {
+	return Array.from( ranges ).sort( compareRangeOrder );
+}
+
 // Helper method to get an object with `first` and `last` indexes from an unsorted array of indexes.
 function getFirstLastIndexesObject( indexes ) {
 	const allIndexesSorted = indexes.sort( ( indexA, indexB ) => indexA - indexB );
@@ -198,10 +207,6 @@ function getFirstLastIndexesObject( indexes ) {
 	return { first, last };
 }
 
-function sortRanges( rangesIterator ) {
-	return Array.from( rangesIterator ).sort( compareRangeOrder );
-}
-
 function compareRangeOrder( rangeA, rangeB ) {
 	// Since table cell ranges are disjoint, it's enough to check their start positions.
 	const posA = rangeA.start;
@@ -245,7 +250,7 @@ function getBiggestRectangleArea( rows, columns ) {
 //		│ c │ c │ d │ d │
 //		└───┴───┴───┴───┘
 function areCellInTheSameTableSection( tableCells ) {
-	const table = findAncestor( 'table', tableCells[ 0 ] );
+	const table = tableCells[ 0 ].findAncestor( 'table' );
 
 	const rowIndexes = getRowIndexes( tableCells );
 	const headingRows = parseInt( table.getAttribute( 'headingRows' ) || 0 );

+ 10 - 2
packages/ckeditor5-table/src/utils/structure.js

@@ -138,6 +138,7 @@ export function getVerticallyOverlappingCells( table, overlapRow, startRow = 0 )
  * @param {module:engine/model/element~Element} tableCell
  * @param {Number} splitRow
  * @param {module:engine/model/writer~Writer} writer
+ * @returns {module:engine/model/element~Element} Created table cell.
  */
 export function splitHorizontally( tableCell, splitRow, writer ) {
 	const tableRow = tableCell.parent;
@@ -164,6 +165,7 @@ export function splitHorizontally( tableCell, splitRow, writer ) {
 	const endRow = startRow + newRowspan;
 	const tableMap = [ ...new TableWalker( table, { startRow, endRow, includeAllSlots: true } ) ];
 
+	let newCell = null;
 	let columnIndex;
 
 	for ( const tableSlot of tableMap ) {
@@ -174,12 +176,14 @@ export function splitHorizontally( tableCell, splitRow, writer ) {
 		}
 
 		if ( columnIndex !== undefined && columnIndex === column && row === endRow ) {
-			createEmptyTableCell( writer, tableSlot.getPositionBefore(), newCellAttributes );
+			newCell = createEmptyTableCell( writer, tableSlot.getPositionBefore(), newCellAttributes );
 		}
 	}
 
 	// Update the rowspan attribute after updating table.
 	updateNumericAttribute( 'rowspan', newRowspan, tableCell, writer );
+
+	return newCell;
 }
 
 /**
@@ -232,6 +236,7 @@ export function getHorizontallyOverlappingCells( table, overlapColumn ) {
  * @param {Number} columnIndex The table cell column index.
  * @param {Number} splitColumn The index of column to split cell on.
  * @param {module:engine/model/writer~Writer} writer
+ * @returns {module:engine/model/element~Element} Created table cell.
  */
 export function splitVertically( tableCell, columnIndex, splitColumn, writer ) {
 	const colspan = parseInt( tableCell.getAttribute( 'colspan' ) );
@@ -250,9 +255,12 @@ export function splitVertically( tableCell, columnIndex, splitColumn, writer ) {
 		newCellAttributes.rowspan = rowspan;
 	}
 
-	createEmptyTableCell( writer, writer.createPositionAfter( tableCell ), newCellAttributes );
+	const newCell = createEmptyTableCell( writer, writer.createPositionAfter( tableCell ), newCellAttributes );
+
 	// Update the colspan attribute after updating table.
 	updateNumericAttribute( 'colspan', newColspan, tableCell, writer );
+
+	return newCell;
 }
 
 /**

+ 2 - 3
packages/ckeditor5-table/src/utils/ui/contextualballoon.js

@@ -10,7 +10,6 @@
 import { centeredBalloonPositionForLongWidgets } from '@ckeditor/ckeditor5-widget/src/utils';
 import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect';
 import { getTableWidgetAncestor } from './widget';
-import { findAncestor } from '../common';
 import BalloonPanelView from '@ckeditor/ckeditor5-ui/src/panel/balloon/balloonpanelview';
 
 const DEFAULT_BALLOON_POSITIONS = BalloonPanelView.defaultPositions;
@@ -64,7 +63,7 @@ export function repositionContextualBalloon( editor, target ) {
  */
 export function getBalloonTablePositionData( editor ) {
 	const firstPosition = editor.model.document.selection.getFirstPosition();
-	const modelTable = findAncestor( 'table', firstPosition );
+	const modelTable = firstPosition.findAncestor( 'table' );
 	const viewTable = editor.editing.mapper.toViewElement( modelTable );
 
 	return {
@@ -113,7 +112,7 @@ export function getBalloonCellPositionData( editor ) {
 function getTableCellAtPosition( position ) {
 	const isTableCellSelected = position.nodeAfter && position.nodeAfter.is( 'tableCell' );
 
-	return isTableCellSelected ? position.nodeAfter : findAncestor( 'tableCell', position );
+	return isTableCellSelected ? position.nodeAfter : position.findAncestor( 'tableCell' );
 }
 
 // Returns bounding rect for list of rects.

+ 12 - 1
packages/ckeditor5-table/src/utils/ui/widget.js

@@ -8,7 +8,6 @@
  */
 
 import { isWidget } from '@ckeditor/ckeditor5-widget/src/utils';
-import { findAncestor } from '../common';
 
 /**
  * Returns a table widget editing view element if one is selected.
@@ -49,3 +48,15 @@ export function getTableWidgetAncestor( selection ) {
 function isTableWidget( viewElement ) {
 	return !!viewElement.getCustomProperty( 'table' ) && isWidget( viewElement );
 }
+
+function findAncestor( parentName, positionOrElement ) {
+	let parent = positionOrElement.parent;
+
+	while ( parent ) {
+		if ( parent.name === parentName ) {
+			return parent;
+		}
+
+		parent = parent.parent;
+	}
+}

+ 15 - 0
packages/ckeditor5-table/tests/commands/inserttablecommand.js

@@ -100,6 +100,21 @@ describe( 'InsertTableCommand', () => {
 				);
 			} );
 
+			it( 'should insert table with given heading rows and heading columns after non-empty paragraph', () => {
+				setData( model, '<paragraph>foo[]</paragraph>' );
+
+				command.execute( { rows: 3, columns: 4, headingRows: 1, headingColumns: 2 } );
+
+				assertEqualMarkup( getData( model ),
+					'<paragraph>foo</paragraph>' +
+					modelTable( [
+						[ '[]', '', '', '' ],
+						[ '', '', '', '' ],
+						[ '', '', '', '' ]
+					], { headingRows: 1, headingColumns: 2 } )
+				);
+			} );
+
 			it( 'should insert table before after non-empty paragraph if selection is inside', () => {
 				setData( model, '<paragraph>f[]oo</paragraph>' );
 

+ 13 - 2
packages/ckeditor5-table/tests/manual/tablemocking.html

@@ -12,10 +12,16 @@
 		box-sizing: border-box;
 		margin: 10px 0;
 	}
-	pre,code {
+	pre, code {
 		font-size: 11px;
 		font-family: Menlo, Consolas, Lucida Console, Courier New, dejavu sans mono, monospace;
 	}
+	#table-tools pre {
+		background: hsl( 0, 0%, 95% );
+		max-height:300px;
+		overflow: auto;
+		padding: 10px;
+	}
 	.diff-add {
 		color: hsl( 120, 70%, 35% );
 	}
@@ -61,4 +67,9 @@
 <div id="editor">
 </div>
 
-<pre id="ascii-art"></pre>
+<div id="table-tools">
+	<pre id="ascii-art"></pre>
+
+	<h3>Clipboard preview:</h3>
+	<pre id="clipboard"></pre>
+</div>

+ 5 - 2
packages/ckeditor5-table/tests/manual/tablemocking.js

@@ -15,7 +15,6 @@ import { debounce } from 'lodash-es';
 import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset';
 import TableWalker from '../../src/tablewalker';
 import { getSelectionAffectedTableCells } from '../../src/utils/selection';
-import { findAncestor } from '../../src/utils/common';
 
 ClassicEditor
 	.create( document.querySelector( '#editor' ), {
@@ -33,6 +32,10 @@ ClassicEditor
 		const asciiOut = document.getElementById( 'ascii-art' );
 		const modelData = document.getElementById( 'model-data' );
 
+		editor.editing.view.document.on( 'paste', ( evt, data ) => {
+			document.getElementById( 'clipboard' ).innerText = data.dataTransfer.getData( 'text/html' ).replace( />(?=<)/g, '>\n' );
+		} );
+
 		document.getElementById( 'clear-content' ).addEventListener( 'click', () => {
 			editor.setData( '' );
 		} );
@@ -120,7 +123,7 @@ ClassicEditor
 			const tableCells = getSelectionAffectedTableCells( selection );
 
 			if ( tableCells.length ) {
-				return findAncestor( 'table', tableCells[ 0 ] );
+				return tableCells[ 0 ].findAncestor( 'table' );
 			}
 
 			const element = selection.getSelectedElement();

+ 0 - 2
packages/ckeditor5-table/tests/manual/tablemocking.md

@@ -22,5 +22,3 @@ setModelData( model, modelTable( [
     [ '40', '41', '42', '43', '44' ]
 ] ) );
 ```
-
-**Note:** Cell content is ignored while generating ASCII-art and `modelTableData`.  

+ 374 - 10
packages/ckeditor5-table/tests/tableclipboard-paste.js

@@ -108,7 +108,7 @@ describe( 'table clipboard', () => {
 					model.createRangeOn( modelRoot.getNodeByPath( [ 0, 1, 1 ] ) )
 				] );
 
-				const tableToInsert = editor.plugins.get( 'TableUtils' ).createTable( writer, 2, 2 );
+				const tableToInsert = editor.plugins.get( 'TableUtils' ).createTable( writer, { rows: 2, columns: 2 } );
 
 				for ( const { cell } of new TableWalker( tableToInsert ) ) {
 					writer.insertText( 'foo', cell.getChild( 0 ), 0 );
@@ -461,7 +461,7 @@ describe( 'table clipboard', () => {
 			);
 
 			model.change( writer => {
-				const tableToInsert = editor.plugins.get( 'TableUtils' ).createTable( writer, 2, 2 );
+				const tableToInsert = editor.plugins.get( 'TableUtils' ).createTable( writer, { rows: 2, columns: 2 } );
 
 				for ( const { cell } of new TableWalker( tableToInsert ) ) {
 					writer.insertText( 'foo', cell.getChild( 0 ), 0 );
@@ -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( [
@@ -899,6 +899,21 @@ describe( 'table clipboard', () => {
 						[ '', '', 'ba', 'bb' ]
 					] ) );
 				} );
+
+				it( 'should not set multi-cell selection if TableSelection plugin is disabled', () => {
+					editor.plugins.get( 'TableSelection' ).forceDisabled();
+
+					pasteTable( [
+						[ 'aa', 'ab' ],
+						[ 'ba', 'bb' ]
+					] );
+
+					assertEqualMarkup( getModelData( model ), modelTable( [
+						[ '[]aa', 'ab', '02' ],
+						[ 'ba', 'bb', '12' ],
+						[ '20', '21', '22' ]
+					] ) );
+				} );
 			} );
 
 			describe( 'with spanned cells', () => {
@@ -3385,6 +3400,329 @@ describe( 'table clipboard', () => {
 				}
 			);
 		} );
+
+		describe( 'headings overlapping selected area', () => {
+			beforeEach( () => {
+				setModelData( model, modelTable( [
+					[ '00', '01', '02', '03', '04', '05' ],
+					[ '10', '11', '12', '13', '14', '15' ],
+					[ '20', '21', '22', '23', '24', '25' ],
+					[ '30', '31', '32', '33', '34', '35' ],
+					[ '40', '41', '42', '43', '44', '45' ],
+					[ '50', '51', '52', '53', '54', '55' ]
+				], { headingRows: 3, headingColumns: 3 } ) );
+			} );
+
+			it( 'should not split cells if they are not overlapping from headings', () => {
+				tableSelection.setCellSelection(
+					modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+					modelRoot.getNodeByPath( [ 0, 0, 0 ] )
+				);
+
+				// +----+----+----+----+
+				// | aa           | ad |
+				// +              +----+
+				// |              | bd |
+				// +              +----+
+				// |              | cd |
+				// +----+----+----+----+
+				// | da | db | dc | dd |
+				// +----+----+----+----+
+				pasteTable( [
+					[ { contents: 'aa', colspan: 3, rowspan: 3 }, 'ad' ],
+					[ 'bd' ],
+					[ 'cd' ],
+					[ 'da', 'db', 'dc', 'dd' ]
+				] );
+
+				// +----+----+----+----+----+----+
+				// | aa           | ad | 04 | 05 |
+				// +              +----+----+----+
+				// |              | bd | 14 | 15 |
+				// +              +----+----+----+
+				// |              | cd | 24 | 25 |
+				// +----+----+----+----+----+----+ <-- heading rows
+				// | da | db | dc | dd | 34 | 35 |
+				// +----+----+----+----+----+----+
+				// | 40 | 41 | 42 | 43 | 44 | 45 |
+				// +----+----+----+----+----+----+
+				// | 50 | 51 | 52 | 53 | 54 | 55 |
+				// +----+----+----+----+----+----+
+				//                ^-- heading columns
+				assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+					[ { contents: 'aa', colspan: 3, rowspan: 3 }, 'ad', '04', '05' ],
+					[ 'bd', '14', '15' ],
+					[ 'cd', '24', '25' ],
+					[ 'da', 'db', 'dc', 'dd', '34', '35' ],
+					[ '40', '41', '42', '43', '44', '45' ],
+					[ '50', '51', '52', '53', '54', '55' ]
+				], { headingRows: 3, headingColumns: 3 } ) );
+
+				assertSelectionRangesSorted();
+
+				/* eslint-disable no-multi-spaces */
+				assertSelectedCells( model, [
+					[ 1,       1, 0, 0 ],
+					[          1, 0, 0 ],
+					[          1, 0, 0 ],
+					[ 1, 1, 1, 1, 0, 0 ],
+					[ 0, 0, 0, 0, 0, 0 ],
+					[ 0, 0, 0, 0, 0, 0 ]
+				] );
+				/* eslint-enable no-multi-spaces */
+			} );
+
+			it( 'should split cells that overlap from headings', () => {
+				tableSelection.setCellSelection(
+					modelRoot.getNodeByPath( [ 0, 1, 1 ] ),
+					modelRoot.getNodeByPath( [ 0, 1, 1 ] )
+				);
+
+				// +----+----+----+----+
+				// | aa           | ad |
+				// +              +----+
+				// |              | bd |
+				// +              +----+
+				// |              | cd |
+				// +----+----+----+----+
+				// | da | db | dc | dd |
+				// +----+----+----+----+
+				pasteTable( [
+					[ { contents: 'aa', colspan: 3, rowspan: 3 }, 'ad' ],
+					[ 'bd' ],
+					[ 'cd' ],
+					[ 'da', 'db', 'dc', 'dd' ]
+				] );
+
+				// +----+----+----+----+----+----+
+				// | 00 | 01 | 02 | 03 | 04 | 05 |
+				// +----+----+----+----+----+----+
+				// | 10 | aa      |    | ad | 15 |
+				// +----+         +    +----+----+
+				// | 20 |         |    | bd | 25 |
+				// +----+----+----+----+----+----+ <-- heading rows
+				// | 30 |         |    | cd | 35 |
+				// +----+----+----+----+----+----+
+				// | 40 | da | db | dc | dd | 45 |
+				// +----+----+----+----+----+----+
+				// | 50 | 51 | 52 | 53 | 54 | 55 |
+				// +----+----+----+----+----+----+
+				//                ^-- heading columns
+				assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+					[ '00', '01', '02', '03', '04', '05' ],
+					[ '10', { contents: 'aa', colspan: 2, rowspan: 2 }, { contents: '', rowspan: 2 }, 'ad', '15' ],
+					[ '20', 'bd', '25' ],
+					[ '30', { contents: '', colspan: 2 }, '', 'cd', '35' ],
+					[ '40', 'da', 'db', 'dc', 'dd', '45' ],
+					[ '50', '51', '52', '53', '54', '55' ]
+				], { headingRows: 3, headingColumns: 3 } ) );
+
+				assertSelectionRangesSorted();
+
+				/* eslint-disable no-multi-spaces */
+				assertSelectedCells( model, [
+					[ 0, 0, 0, 0, 0, 0 ],
+					[ 0, 1,    1, 1, 0 ],
+					[ 0,          1, 0 ],
+					[ 0, 1,    1, 1, 0 ],
+					[ 0, 1, 1, 1, 1, 0 ],
+					[ 0, 0, 0, 0, 0, 0 ]
+				] );
+				/* eslint-enable no-multi-spaces */
+			} );
+
+			it( 'should split cells that overlap from heading rows', () => {
+				tableSelection.setCellSelection(
+					modelRoot.getNodeByPath( [ 0, 2, 3 ] ),
+					modelRoot.getNodeByPath( [ 0, 2, 3 ] )
+				);
+
+				// +----+----+----+----+
+				// | aa           | ad |
+				// +              +----+
+				// |              | bd |
+				// +              +----+
+				// |              | cd |
+				// +----+----+----+----+
+				// | da | db | dc | dd |
+				// +----+----+----+----+
+				pasteTable( [
+					[ { contents: 'aa', colspan: 3, rowspan: 3 }, 'ad' ],
+					[ 'bd' ],
+					[ 'cd' ],
+					[ 'da', 'db', 'dc', 'dd' ]
+				] );
+
+				// +----+----+----+----+----+----+----+
+				// | 00 | 01 | 02 | 03 | 04 | 05 |    |
+				// +----+----+----+----+----+----+----+
+				// | 10 | 11 | 12 | 13 | 14 | 15 |    |
+				// +----+----+----+----+----+----+----+
+				// | 20 | 21 | 22 | aa           | ad |
+				// +----+----+----+----+----+----+----+ <-- heading rows
+				// | 30 | 31 | 32 |              | bd |
+				// +----+----+----+              +----+
+				// | 40 | 41 | 42 |              | cd |
+				// +----+----+----+----+----+----+----+
+				// | 50 | 51 | 52 | da | db | dc | dd |
+				// +----+----+----+----+----+----+----+
+				//                ^-- heading columns
+				assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+					[ '00', '01', '02', '03', '04', '05', '' ],
+					[ '10', '11', '12', '13', '14', '15', '' ],
+					[ '20', '21', '22', { contents: 'aa', colspan: 3 }, 'ad' ],
+					[ '30', '31', '32', { contents: '', colspan: 3, rowspan: 2 }, 'bd' ],
+					[ '40', '41', '42', 'cd' ],
+					[ '50', '51', '52', 'da', 'db', 'dc', 'dd' ]
+				], { headingRows: 3, headingColumns: 3 } ) );
+
+				assertSelectionRangesSorted();
+
+				/* eslint-disable no-multi-spaces */
+				assertSelectedCells( model, [
+					[ 0, 0, 0, 0, 0, 0, 0 ],
+					[ 0, 0, 0, 0, 0, 0, 0 ],
+					[ 0, 0, 0, 1,       1 ],
+					[ 0, 0, 0, 1,       1 ],
+					[ 0, 0, 0,          1 ],
+					[ 0, 0, 0, 1, 1, 1, 1 ]
+				] );
+				/* eslint-enable no-multi-spaces */
+			} );
+
+			it( 'should split cells that overlap from heading columns', () => {
+				tableSelection.setCellSelection(
+					modelRoot.getNodeByPath( [ 0, 3, 2 ] ),
+					modelRoot.getNodeByPath( [ 0, 3, 2 ] )
+				);
+
+				// +----+----+----+----+
+				// | aa           | ad |
+				// +              +----+
+				// |              | bd |
+				// +              +----+
+				// |              | cd |
+				// +----+----+----+----+
+				// | da | db | dc | dd |
+				// +----+----+----+----+
+				pasteTable( [
+					[ { contents: 'aa', colspan: 3, rowspan: 3 }, 'ad' ],
+					[ 'bd' ],
+					[ 'cd' ],
+					[ 'da', 'db', 'dc', 'dd' ]
+				] );
+
+				// +----+----+----+----+----+----+
+				// | 00 | 01 | 02 | 03 | 04 | 05 |
+				// +----+----+----+----+----+----+
+				// | 10 | 11 | 12 | 13 | 14 | 15 |
+				// +----+----+----+----+----+----+
+				// | 20 | 21 | 22 | 23 | 24 | 25 |
+				// +----+----+----+----+----+----+ <-- heading rows
+				// | 30 | 31 | aa |         | ad |
+				// +----+----+    +         +----+
+				// | 40 | 41 |    |         | bd |
+				// +----+----+    +         +----+
+				// | 50 | 51 |    |         | cd |
+				// +----+----+----+----+----+----+
+				// |    |    | da | db | dc | dd |
+				// +----+----+----+----+----+----+
+				//                ^-- heading columns
+				assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+					[ '00', '01', '02', '03', '04', '05' ],
+					[ '10', '11', '12', '13', '14', '15' ],
+					[ '20', '21', '22', '23', '24', '25' ],
+					[ '30', '31', { contents: 'aa', rowspan: 3 }, { contents: '', colspan: 2, rowspan: 3 }, 'ad' ],
+					[ '40', '41', 'bd' ],
+					[ '50', '51', 'cd' ],
+					[ '', '', 'da', 'db', 'dc', 'dd' ]
+				], { headingRows: 3, headingColumns: 3 } ) );
+
+				assertSelectionRangesSorted();
+
+				/* eslint-disable no-multi-spaces */
+				assertSelectedCells( model, [
+					[ 0, 0, 0, 0, 0, 0 ],
+					[ 0, 0, 0, 0, 0, 0 ],
+					[ 0, 0, 0, 0, 0, 0 ],
+					[ 0, 0, 1, 1,    1 ],
+					[ 0, 0,          1 ],
+					[ 0, 0,          1 ],
+					[ 0, 0, 1, 1, 1, 1 ]
+				] );
+				/* eslint-enable no-multi-spaces */
+			} );
+
+			it( 'should split cells that overlap from headings (repeated pasted table)', () => {
+				setModelData( model, modelTable( [
+					[ '00', '01', '02', '03', '04' ],
+					[ '10', '11', '12', '13', '14' ],
+					[ '20', '21', '22', '23', '24' ],
+					[ '30', '31', '32', '33', '34' ],
+					[ '40', '41', '42', '43', '44' ]
+				], { headingRows: 1, headingColumns: 1 } ) );
+
+				tableSelection.setCellSelection(
+					modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+					modelRoot.getNodeByPath( [ 0, 4, 4 ] )
+				);
+
+				// +----+----+----+
+				// | aa      | ac |
+				// +         +----+
+				// |         | bc |
+				// +----+----+----+
+				// | ca | cb | cc |
+				// +----+----+----+
+				pasteTable( [
+					[ { contents: 'aa', colspan: 2, rowspan: 2 }, 'ac' ],
+					[ 'bc' ],
+					[ 'ca', 'cb', 'cc' ]
+				] );
+
+				// +----+----+----+----+----+
+				// | aa |    | ac | aa      |
+				// +----+----+----+----+----+ <-- heading rows
+				// |    |    | bc |         |
+				// +----+----+----+----+----+
+				// | ca | cb | cc | ca | cb |
+				// +----+----+----+----+----+
+				// | aa |    | ac | aa      |
+				// +    +    +----+         +
+				// |    |    | bc |         |
+				// +----+----+----+----+----+
+				//      ^-- heading columns
+				assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+					[ 'aa', '', 'ac', { contents: 'aa', colspan: 2 } ],
+					[ '', '', 'bc', { contents: '', colspan: 2 } ],
+					[ 'ca', 'cb', 'cc', 'ca', 'cb' ],
+					[ { contents: 'aa', rowspan: 2 }, { contents: '', rowspan: 2 }, 'ac', { contents: 'aa', colspan: 2, rowspan: 2 } ],
+					[ 'bc' ]
+				], { headingRows: 1, headingColumns: 1 } ) );
+
+				assertSelectionRangesSorted();
+
+				/* eslint-disable no-multi-spaces */
+				assertSelectedCells( model, [
+					[ 1, 1, 1, 1    ],
+					[ 1, 1, 1, 1    ],
+					[ 1, 1, 1, 1, 1 ],
+					[ 1, 1, 1, 1    ],
+					[       1       ]
+				] );
+				/* eslint-enable no-multi-spaces */
+			} );
+
+			function assertSelectionRangesSorted() {
+				const selectionRanges = Array.from( model.document.selection.getRanges() );
+				const selectionRangesSorted = selectionRanges.slice().sort( ( a, b ) => a.start.isBefore( b.start ) ? -1 : 1 );
+
+				const selectionPaths = selectionRanges.map( ( { start } ) => start.path );
+				const sortedPaths = selectionRangesSorted.map( ( { start } ) => start.path );
+
+				expect( selectionPaths ).to.deep.equal( sortedPaths );
+			}
+		} );
 	} );
 
 	describe( 'Clipboard integration - paste (content scenarios)', () => {
@@ -3429,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' ]
 			] ) );
@@ -3454,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>`;
 
@@ -3465,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>' +
@@ -3553,6 +3891,32 @@ describe( 'table clipboard', () => {
 				[ '02', '21', '22' ]
 			] ) );
 		} );
+
+		it( 'removes block fillers from empty cells (both td and th)', async () => {
+			await createEditor();
+
+			setModelData( model, modelTable( [
+				[ '00', '01', '02' ],
+				[ '01', '11', '12' ],
+				[ '02', '21', '22' ]
+			] ) );
+
+			tableSelection.setCellSelection(
+				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+				modelRoot.getNodeByPath( [ 0, 1, 1 ] )
+			);
+
+			pasteTable( [
+				[ '&nbsp;', '&nbsp;' ],
+				[ '&nbsp;', '&nbsp;' ]
+			], { headingRows: 1 } );
+
+			assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+				[ '', '', '02' ],
+				[ '', '', '12' ],
+				[ '02', '21', '22' ]
+			] ) );
+		} );
 	} );
 
 	async function createEditor( extraPlugins = [] ) {
@@ -3566,13 +3930,13 @@ describe( 'table clipboard', () => {
 		tableSelection = editor.plugins.get( 'TableSelection' );
 	}
 
-	function pasteTable( tableData ) {
+	function pasteTable( tableData, attributes = {} ) {
 		const data = {
 			dataTransfer: createDataTransfer(),
 			preventDefault: sinon.spy(),
 			stopPropagation: sinon.spy()
 		};
-		data.dataTransfer.setData( 'text/html', viewTable( tableData ) );
+		data.dataTransfer.setData( 'text/html', viewTable( tableData, attributes ) );
 		viewDocument.fire( 'paste', data );
 
 		return data;

+ 66 - 0
packages/ckeditor5-table/tests/tableutils.js

@@ -1526,4 +1526,70 @@ describe( 'TableUtils', () => {
 			} );
 		} );
 	} );
+
+	describe( 'createTable()', () => {
+		it( 'should create table', () => {
+			setData( model, '[]' );
+
+			model.change( writer => {
+				const table = tableUtils.createTable( writer, { rows: 3, columns: 2 } );
+
+				model.insertContent( table, model.document.selection.focus );
+			} );
+
+			assertEqualMarkup( getData( model, { withoutSelection: true } ), modelTable( [
+				[ '', '' ],
+				[ '', '' ],
+				[ '', '' ]
+			] ) );
+		} );
+
+		it( 'should create table with heading rows', () => {
+			setData( model, '[]' );
+
+			model.change( writer => {
+				const table = tableUtils.createTable( writer, { rows: 3, columns: 2, headingRows: 1 } );
+
+				model.insertContent( table, model.document.selection.focus );
+			} );
+
+			assertEqualMarkup( getData( model, { withoutSelection: true } ), modelTable( [
+				[ '', '' ],
+				[ '', '' ],
+				[ '', '' ]
+			], { headingRows: 1 } ) );
+		} );
+
+		it( 'should create table with heading columns', () => {
+			setData( model, '[]' );
+
+			model.change( writer => {
+				const table = tableUtils.createTable( writer, { rows: 3, columns: 2, headingColumns: 1 } );
+
+				model.insertContent( table, model.document.selection.focus );
+			} );
+
+			assertEqualMarkup( getData( model, { withoutSelection: true } ), modelTable( [
+				[ '', '' ],
+				[ '', '' ],
+				[ '', '' ]
+			], { headingColumns: 1 } ) );
+		} );
+
+		it( 'should create table with heading rows and columns', () => {
+			setData( model, '[]' );
+
+			model.change( writer => {
+				const table = tableUtils.createTable( writer, { rows: 3, columns: 2, headingRows: 2, headingColumns: 1 } );
+
+				model.insertContent( table, model.document.selection.focus );
+			} );
+
+			assertEqualMarkup( getData( model, { withoutSelection: true } ), modelTable( [
+				[ '', '' ],
+				[ '', '' ],
+				[ '', '' ]
+			], { headingRows: 2, headingColumns: 1 } ) );
+		} );
+	} );
 } );

+ 1 - 18
packages/ckeditor5-table/tests/utils/common.js

@@ -10,7 +10,7 @@ import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 import TableEditing from '../../src/tableediting';
 import { modelTable } from '../_utils/utils';
 
-import { findAncestor, isHeadingColumnCell } from '../../src/utils/common';
+import { isHeadingColumnCell } from '../../src/utils/common';
 
 describe( 'table utils', () => {
 	let editor, model, modelRoot, tableUtils;
@@ -32,23 +32,6 @@ describe( 'table utils', () => {
 	} );
 
 	describe( 'common', () => {
-		describe( 'findAncestor()', () => {
-			it( 'should return undefined if not in table', () => {
-				setData( model, '<paragraph>foo[]</paragraph>' );
-
-				expect( findAncestor( 'table', model.document.selection.focus ) ).to.be.undefined;
-			} );
-
-			it( 'should return table if position is in tableCell', () => {
-				setData( model, modelTable( [ [ '[]' ] ] ) );
-
-				const parentTable = findAncestor( 'table', model.document.selection.focus );
-
-				expect( parentTable ).to.not.be.undefined;
-				expect( parentTable.is( 'table' ) ).to.be.true;
-			} );
-		} );
-
 		describe( 'isHeadingColumnCell()', () => {
 			it( 'should return "true" for a heading column cell', () => {
 				setData( model, modelTable( [

+ 1 - 2
packages/ckeditor5-table/tests/utils/ui/contextualballoon.js

@@ -16,7 +16,6 @@ import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
 import { centeredBalloonPositionForLongWidgets } from '@ckeditor/ckeditor5-widget/src/utils';
 import { modelTable } from '../../_utils/utils';
 import { getTableCellsContainingSelection } from '../../../src/utils/selection';
-import { findAncestor } from '../../../src/utils/common';
 import { getBalloonCellPositionData, repositionContextualBalloon } from '../../../src/utils/ui/contextualballoon';
 
 describe( 'table utils', () => {
@@ -117,7 +116,7 @@ describe( 'table utils', () => {
 						'</tableRow></table>' );
 					repositionContextualBalloon( editor, 'table' );
 
-					const modelTable = findAncestor( 'table', editor.model.document.selection.getFirstPosition() );
+					const modelTable = editor.model.document.selection.getFirstPosition().findAncestor( 'table' );
 					const viewTable = editor.editing.mapper.toViewElement( modelTable );
 
 					sinon.assert.calledWithExactly( spy, {

+ 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
packages/ckeditor5-widget/package.json

@@ -27,6 +27,7 @@
     "@ckeditor/ckeditor5-heading": "^20.0.0",
     "@ckeditor/ckeditor5-horizontal-line": "^20.0.0",
     "@ckeditor/ckeditor5-image": "^20.0.0",
+    "@ckeditor/ckeditor5-link": "^20.0.0",
     "@ckeditor/ckeditor5-media-embed": "^20.0.0",
     "@ckeditor/ckeditor5-paragraph": "^20.0.0",
     "@ckeditor/ckeditor5-table": "^20.0.0",

+ 6 - 3
packages/ckeditor5-widget/src/widget.js

@@ -149,12 +149,15 @@ export default class Widget extends Plugin {
 			// But at least triple click inside nested editable causes broken selection in Safari.
 			// For such event, we select the entire nested editable element.
 			// See: https://github.com/ckeditor/ckeditor5/issues/1463.
-			if ( env.isSafari && domEventData.domEvent.detail >= 3 ) {
+			if ( ( env.isSafari || env.isGecko ) && domEventData.domEvent.detail >= 3 ) {
 				const mapper = editor.editing.mapper;
-				const modelElement = mapper.toModelElement( element );
+				const viewElement = element.is( 'attributeElement' ) ?
+					element.findAncestor( element => !element.is( 'attributeElement' ) ) : element;
+				const modelElement = mapper.toModelElement( viewElement );
+
+				domEventData.preventDefault();
 
 				this.editor.model.change( writer => {
-					domEventData.preventDefault();
 					writer.setSelection( modelElement, 'in' );
 				} );
 			}

+ 77 - 2
packages/ckeditor5-widget/tests/widget-integration.js

@@ -8,6 +8,7 @@
 import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
 import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 import Typing from '@ckeditor/ckeditor5-typing/src/typing';
+import LinkEditing from '@ckeditor/ckeditor5-link/src/linkediting';
 import Widget from '../src/widget';
 import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata';
 
@@ -32,7 +33,7 @@ describe( 'Widget - integration', () => {
 		editorElement = document.createElement( 'div' );
 		document.body.appendChild( editorElement );
 
-		return ClassicEditor.create( editorElement, { plugins: [ Paragraph, Widget, Typing ] } )
+		return ClassicEditor.create( editorElement, { plugins: [ Paragraph, Widget, Typing, LinkEditing ] } )
 			.then( newEditor => {
 				editor = newEditor;
 				model = editor.model;
@@ -58,6 +59,9 @@ describe( 'Widget - integration', () => {
 					isObject: true,
 					isInline: true
 				} );
+				model.schema.extend( '$block', {
+					allowIn: 'nested'
+				} );
 
 				editor.conversion.for( 'downcast' )
 					.elementToElement( { model: 'inline', view: 'figure' } )
@@ -148,6 +152,76 @@ describe( 'Widget - integration', () => {
 		expect( getModelData( model ) ).to.equal( '<widget><nested>[foo bar]</nested></widget>' );
 	} );
 
+	it( 'should select the entire nested editable if triple clicked on link', () => {
+		setModelData( model, '[]<widget><nested>foo <$text linkHref="abc">bar</$text></nested></widget>' );
+
+		const viewDiv = viewDocument.getRoot().getChild( 0 );
+		const viewLink = viewDiv.getChild( 0 ).getChild( 1 );
+		const preventDefault = sinon.spy();
+		const domEventDataMock = new DomEventData( view, {
+			target: view.domConverter.mapViewToDom( viewLink ),
+			preventDefault,
+			detail: 3
+		} );
+
+		viewDocument.fire( 'mousedown', domEventDataMock );
+
+		sinon.assert.called( preventDefault );
+
+		expect( getViewData( view ) ).to.equal(
+			'<div class="ck-widget" contenteditable="false">' +
+			'<figcaption contenteditable="true">{foo <a href="abc">bar</a>]</figcaption>' +
+			'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
+			'</div>'
+		);
+		expect( getModelData( model ) ).to.equal( '<widget><nested>[foo <$text linkHref="abc">bar</$text>]</nested></widget>' );
+	} );
+
+	it( 'should select only clicked paragraph if triple clicked on link', () => {
+		setModelData( model,
+			'[]<widget>' +
+				'<nested>' +
+					'<paragraph>foo</paragraph>' +
+					'<paragraph>foo <$text linkHref="abc">bar</$text></paragraph>' +
+					'<paragraph>bar</paragraph>' +
+				'</nested>' +
+			'</widget>'
+		);
+
+		const viewDiv = viewDocument.getRoot().getChild( 0 );
+		const viewLink = viewDiv.getChild( 0 ).getChild( 1 ).getChild( 1 );
+		const preventDefault = sinon.spy();
+		const domEventDataMock = new DomEventData( view, {
+			target: view.domConverter.mapViewToDom( viewLink ),
+			preventDefault,
+			detail: 3
+		} );
+
+		viewDocument.fire( 'mousedown', domEventDataMock );
+
+		sinon.assert.called( preventDefault );
+
+		expect( getViewData( view ) ).to.equal(
+			'<div class="ck-widget" contenteditable="false">' +
+				'<figcaption contenteditable="true">' +
+					'<p>foo</p>' +
+					'<p>{foo <a href="abc">bar</a>]</p>' +
+					'<p>bar</p>' +
+				'</figcaption>' +
+				'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
+			'</div>'
+		);
+		expect( getModelData( model ) ).to.equal(
+			'<widget>' +
+				'<nested>' +
+					'<paragraph>foo</paragraph>' +
+					'<paragraph>[foo <$text linkHref="abc">bar</$text>]</paragraph>' +
+					'<paragraph>bar</paragraph>' +
+				'</nested>' +
+			'</widget>'
+		);
+	} );
+
 	it( 'should select proper nested editable if triple clicked', () => {
 		setModelData( model, '[]<widget><nested>foo</nested><nested>bar</nested></widget>' );
 
@@ -224,8 +298,9 @@ describe( 'Widget - integration', () => {
 		expect( getModelData( model ) ).to.equal( '<paragraph>Foo[<inline-widget>foo bar</inline-widget>]Bar</paragraph>' );
 	} );
 
-	it( 'should do nothing for non-Safari browser', () => {
+	it( 'should do nothing for non-Safari and non-Gecko browser', () => {
 		testUtils.sinon.stub( env, 'isSafari' ).get( () => false );
+		testUtils.sinon.stub( env, 'isGecko' ).get( () => false );
 
 		setModelData( model, '<paragraph>[]</paragraph><widget><nested>foo bar</nested></widget>' );
 

+ 292 - 18
scripts/docs/build-content-styles.js

@@ -7,26 +7,66 @@
 
 const path = require( 'path' );
 const fs = require( 'fs' );
+const chalk = require( 'chalk' );
+const glob = require( 'glob' );
+const mkdirp = require( 'mkdirp' );
+const postcss = require( 'postcss' );
 const webpack = require( 'webpack' );
-const { styles } = require( '@ckeditor/ckeditor5-dev-utils' );
+const { tools, styles } = require( '@ckeditor/ckeditor5-dev-utils' );
 const { version } = require( '../../package.json' );
 
 const DESTINATION_DIRECTORY = path.join( __dirname, '..', '..', 'build', 'content-styles' );
 const DOCUMENTATION_URL = 'https://ckeditor.com/docs/ckeditor5/latest/builds/guides/integration/content-styles.html';
 const VARIABLE_DEFINITION_REGEXP = /(--[\w-]+):\s+(.*);/g;
 const VARIABLE_USAGE_REGEXP = /var\((--[\w-]+)\)/g;
+const CONTENT_STYLES_GUIDE_PATH = path.join( __dirname, '..', '..', 'docs', 'builds', 'guides', 'integration', 'content-styles.md' );
 
 const contentRules = {
 	selector: [],
 	variables: [],
 	atRules: {}
 };
-
-const webpackConfig = getWebpackConfig();
 const packagesPath = path.join( process.cwd(), 'packages' );
+const shouldUpdateGuide = process.argv.includes( '--commit' );
+
+logProcess( 'Gathering all CKEditor 5 modules...' );
+
+getCkeditor5ModulePaths()
+	.then( files => {
+		console.log( `Found ${ files.length } files.` );
+		logProcess( 'Filtering CKEditor 5 plugins...' );
+
+		let promise = Promise.resolve();
+		const ckeditor5Modules = [];
+
+		for ( const modulePath of files ) {
+			promise = promise.then( () => {
+				return checkWhetherIsCKEditor5Plugin( modulePath )
+					.then( isModule => {
+						if ( isModule ) {
+							ckeditor5Modules.push( path.join( process.cwd(), modulePath ) );
+						}
+					} );
+			} );
+		}
 
-runWebpack( webpackConfig )
+		return promise.then( () => ckeditor5Modules );
+	} )
+	.then( ckeditor5Modules => {
+		console.log( `Found ${ ckeditor5Modules.length } plugins.` );
+		logProcess( 'Generating source file...' );
+
+		return mkdirp( DESTINATION_DIRECTORY ).then( () => generateCKEditor5Source( ckeditor5Modules ) );
+	} )
 	.then( () => {
+		logProcess( 'Building the editor...' );
+		const webpackConfig = getWebpackConfig();
+
+		return runWebpack( webpackConfig );
+	} )
+	.then( () => {
+		logProcess( 'Preparing the content styles file...' );
+
 		// All variables are placed inside the `:root` selector. Let's extract their names and values as a map.
 		const cssVariables = new Map( contentRules.variables
 			.map( rule => {
@@ -121,11 +161,130 @@ runWebpack( webpackConfig )
 	} )
 	.then( () => {
 		console.log( `Content styles have been extracted to ${ path.join( DESTINATION_DIRECTORY, 'content-styles.css' ) }` );
+
+		if ( !shouldUpdateGuide ) {
+			logProcess( 'Done.' );
+
+			return Promise.resolve();
+		}
+
+		logProcess( 'Updating the content styles guide...' );
+
+		const promises = [
+			readFile( CONTENT_STYLES_GUIDE_PATH ),
+			readFile( path.join( DESTINATION_DIRECTORY, 'content-styles.css' ) )
+		];
+
+		return Promise.all( promises )
+			.then( ( [ guideContent, newContentStyles ] ) => {
+				guideContent = guideContent.replace( /```css([^`]+)```/, newContentStyles );
+
+				return writeFile( CONTENT_STYLES_GUIDE_PATH, guideContent );
+			} )
+			.then( () => {
+				logProcess( 'Saving and committing...' );
+
+				const contentStyleFile = CONTENT_STYLES_GUIDE_PATH.replace( process.cwd() + path.sep, '' );
+
+				// Commit the documentation.
+				if ( exec( `git diff --name-only ${ contentStyleFile }` ).trim().length ) {
+					exec( `git add ${ contentStyleFile }` );
+					exec( 'git commit -m "Docs (ckeditor5): Updated the content styles stylesheet."' );
+
+					console.log( 'Successfully updated the content styles guide.' );
+				} else {
+					console.log( 'Nothing to commit. The content styles guide is up to date.' );
+				}
+
+				logProcess( 'Done.' );
+			} );
 	} )
 	.catch( err => {
 		console.log( err );
 	} );
 
+/**
+ * Resolves the promise with an array of paths to CKEditor 5 modules.
+ *
+ * @returns {Promise.<Array>}
+ */
+function getCkeditor5ModulePaths() {
+	return new Promise( ( resolve, reject ) => {
+		glob( 'packages/*/src/**/*.js', ( err, files ) => {
+			if ( err ) {
+				return reject( err );
+			}
+
+			return resolve( files );
+		} );
+	} );
+}
+
+/**
+ * Resolves the promise with a boolean value that indicates whether the module under `modulePath` is the CKEditor 5 plugin.
+ *
+ * @param modulePath
+ * @returns {Promise.<Boolean>}
+ */
+function checkWhetherIsCKEditor5Plugin( modulePath ) {
+	return readFile( path.join( process.cwd(), modulePath ) )
+		.then( content => {
+			const pluginName = path.basename( modulePath, '.js' );
+
+			if ( content.match( new RegExp( `export default class ${ pluginName } extends Plugin`, 'i' ) ) ) {
+				return Promise.resolve( true );
+			}
+
+			return Promise.resolve( false );
+		} );
+}
+
+/**
+ * Generates a source file that will be used to build the editor.
+ *
+ * @param {Array.<String>} ckeditor5Modules Paths to CKEditor 5 modules.
+ * @returns {Promise>}
+ */
+function generateCKEditor5Source( ckeditor5Modules ) {
+	ckeditor5Modules = ckeditor5Modules.map( modulePath => {
+		const pluginName = capitalize( path.basename( modulePath, '.js' ) );
+		return { modulePath, pluginName };
+	} );
+
+	const sourceFileContent = [
+		'/**',
+		` * @license Copyright (c) 2003-${ new Date().getFullYear() }, CKSource - Frederico Knabben. All rights reserved.`,
+		' * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license',
+		' */',
+		'',
+		'// The editor creator to use.',
+		'import ClassicEditorBase from \'@ckeditor/ckeditor5-editor-classic/src/classiceditor\';',
+		''
+	];
+
+	for ( const { modulePath, pluginName } of ckeditor5Modules ) {
+		sourceFileContent.push( `import ${ pluginName } from '${ modulePath }';` );
+	}
+
+	sourceFileContent.push( '' );
+	sourceFileContent.push( 'export default class ClassicEditor extends ClassicEditorBase {}' );
+	sourceFileContent.push( '' );
+	sourceFileContent.push( '// Plugins to include in the build.' );
+	sourceFileContent.push( 'ClassicEditor.builtinPlugins = [' );
+
+	for ( const { pluginName } of ckeditor5Modules ) {
+		sourceFileContent.push( '\t' + pluginName + ',' );
+	}
+
+	sourceFileContent.push( '];' );
+
+	return writeFile( path.join( DESTINATION_DIRECTORY, 'source.js' ), sourceFileContent.join( '\n' ) );
+
+	function capitalize( value ) {
+		return value.charAt( 0 ).toUpperCase() + value.slice( 1 );
+	}
+}
+
 /**
  * Prepares the configuration for webpack.
  *
@@ -139,34 +298,24 @@ function getWebpackConfig() {
 		minify: false
 	} );
 
-	const contentStylesPlugin = require( './content-styles/list-content-styles' )( { contentRules } );
-
-	postCssConfig.plugins.push( contentStylesPlugin );
+	postCssConfig.plugins.push( postCssContentStylesPlugin( contentRules ) );
 
 	return {
 		mode: 'development',
-
 		devtool: 'source-map',
-
 		entry: {
-			ckeditor5: path.join( __dirname, 'content-styles', 'ckeditor.js' )
+			ckeditor5: path.join( DESTINATION_DIRECTORY, 'source.js' )
 		},
-
 		output: {
 			path: DESTINATION_DIRECTORY,
 			filename: '[name].js'
 		},
-
-		// Configure the paths so building CKEditor 5 snippets work even if the script
-		// is triggered from a directory outside `ckeditor5` (e.g. in a multi-project case).
 		resolve: {
 			modules: getModuleResolvePaths()
 		},
-
 		resolveLoader: {
 			modules: getModuleResolvePaths()
 		},
-
 		module: {
 			rules: [
 				{
@@ -188,6 +337,92 @@ function getWebpackConfig() {
 	};
 }
 
+/**
+ * Returns the PostCSS plugin that allows intercepting CSS definition used in the editor's build.
+ *
+ * @param {Object} contentRules
+ * @param {Array.<String>} contentRules.variables Variables defined as `:root`.
+ * @param {Object} contentRules.atRules Definitions of behaves.
+ * @param {Array.<String>} contentRules.selector CSS definitions for all selectors.
+ * @returns {Function}
+ */
+function postCssContentStylesPlugin( contentRules ) {
+	return postcss.plugin( 'list-content-styles', function() {
+		const selectorStyles = contentRules.selector;
+		const variables = contentRules.variables;
+
+		return root => {
+			root.walkRules( rule => {
+				for ( const selector of rule.selectors ) {
+					const data = {
+						file: root.source.input.file,
+						css: rule.toString()
+					};
+
+					if ( selector.match( ':root' ) ) {
+						addDefinition( variables, data );
+					}
+
+					if ( selector.match( '.ck-content' ) ) {
+						if ( rule.parent.name && rule.parent.params ) {
+							const atRule = getAtRuleArray( contentRules.atRules, rule.parent.name, rule.parent.params );
+
+							addDefinition( atRule, data );
+						} else {
+							addDefinition( selectorStyles, data );
+						}
+					}
+				}
+			} );
+		};
+	} );
+
+	/**
+	 * @param {Object} collection
+	 * @param {String} name Name of an `at-rule`.
+	 * @param {String} params Parameters that describes the `at-rule`.
+	 * @returns {Array}
+	 */
+	function getAtRuleArray( collection, name, params ) {
+		const definition = `${ name } ${ params }`;
+
+		if ( !collection[ definition ] ) {
+			collection[ definition ] = [];
+		}
+
+		return collection[ definition ];
+	}
+
+	/**
+	 * Checks whether specified definition is duplicated in the colletion.
+	 *
+	 * @param {Array.<StyleStructure>} collection
+	 * @param {StyleStructure} def
+	 * @returns {Boolean}
+	 */
+	function isDuplicatedDefinition( collection, def ) {
+		for ( const item of collection ) {
+			if ( item.file === def.file && item.css === def.css ) {
+				return true;
+			}
+		}
+
+		return false;
+	}
+
+	/**
+	 * Adds definition to the collection if it does not exist in the collection.
+	 *
+	 * @param {Array.<StyleStructure>} collection
+	 * @param {StyleStructure} def
+	 */
+	function addDefinition( collection, def ) {
+		if ( !isDuplicatedDefinition( collection, def ) ) {
+			collection.push( def );
+		}
+	}
+}
+
 /**
  * @param {Object} webpackConfig
  * @returns {Promise}
@@ -216,9 +451,34 @@ function getModuleResolvePaths() {
 	];
 }
 
-function writeFile( file, data ) {
+/**
+ * Resolves the promise with the content of the file saved under the `filePath` location.
+ *
+ * @param {String} filePath The path to fhe file.
+ * @returns {Promise.<String>}
+ */
+function readFile( filePath ) {
 	return new Promise( ( resolve, reject ) => {
-		fs.writeFile( file, data, err => {
+		fs.readFile( filePath, 'utf-8', ( err, content ) => {
+			if ( err ) {
+				return reject( err );
+			}
+
+			return resolve( content );
+		} );
+	} );
+}
+
+/**
+ * Saves the `data` value to the file saved under the `filePath` location.
+ *
+ * @param {String} filePath The path to fhe file.
+ * @param {String} data The content to save.
+ * @returns {Promise.<String>}
+ */
+function writeFile( filePath, data ) {
+	return new Promise( ( resolve, reject ) => {
+		fs.writeFile( filePath, data, err => {
 			if ( err ) {
 				return reject( err );
 			}
@@ -275,3 +535,17 @@ function transformCssRules( rules ) {
 		} )
 		.join( '\n' );
 }
+
+function exec( command ) {
+	return tools.shExec( command, { verbosity: 'error' } );
+}
+
+function logProcess( message ) {
+	console.log( '\n📍 ' + chalk.cyan( message ) );
+}
+
+/**
+ * @typedef {Object} StyleStructure
+ * @property {String} file An absolute path to the file where a definition is defined.
+ * @property {String} css Definition.
+ */

+ 0 - 96
scripts/docs/content-styles/ckeditor.js

@@ -1,96 +0,0 @@
-/**
- * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
- */
-
-// The editor creator to use.
-import ClassicEditorBase from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
-
-import Essentials from '@ckeditor/ckeditor5-essentials/src/essentials';
-import UploadAdapter from '@ckeditor/ckeditor5-adapter-ckfinder/src/uploadadapter';
-import Autoformat from '@ckeditor/ckeditor5-autoformat/src/autoformat';
-import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
-import Code from '@ckeditor/ckeditor5-basic-styles/src/code';
-import CodeBlock from '@ckeditor/ckeditor5-code-block/src/codeblock';
-import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic';
-import Strikethrough from '@ckeditor/ckeditor5-basic-styles/src/strikethrough';
-import Subscript from '@ckeditor/ckeditor5-basic-styles/src/subscript';
-import Superscript from '@ckeditor/ckeditor5-basic-styles/src/superscript';
-import Underline from '@ckeditor/ckeditor5-basic-styles/src/underline';
-import BlockQuote from '@ckeditor/ckeditor5-block-quote/src/blockquote';
-import CKFinder from '@ckeditor/ckeditor5-ckfinder/src/ckfinder';
-import EasyImage from '@ckeditor/ckeditor5-easy-image/src/easyimage';
-import Heading from '@ckeditor/ckeditor5-heading/src/heading';
-import HorizontalLine from '@ckeditor/ckeditor5-horizontal-line/src/horizontalline';
-import Image from '@ckeditor/ckeditor5-image/src/image';
-import ImageCaption from '@ckeditor/ckeditor5-image/src/imagecaption';
-import ImageResize from '@ckeditor/ckeditor5-image/src/imageresize';
-import ImageStyle from '@ckeditor/ckeditor5-image/src/imagestyle';
-import ImageToolbar from '@ckeditor/ckeditor5-image/src/imagetoolbar';
-import ImageUpload from '@ckeditor/ckeditor5-image/src/imageupload';
-import Link from '@ckeditor/ckeditor5-link/src/link';
-import List from '@ckeditor/ckeditor5-list/src/list';
-import TodoList from '@ckeditor/ckeditor5-list/src/todolist';
-import MediaEmbed from '@ckeditor/ckeditor5-media-embed/src/mediaembed';
-import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
-import PageBreak from '@ckeditor/ckeditor5-page-break/src/pagebreak';
-import PasteFromOffice from '@ckeditor/ckeditor5-paste-from-office/src/pastefromoffice';
-import SpecialCharacters from '@ckeditor/ckeditor5-special-characters/src/specialcharacters';
-import SpecialCharactersEssentials from '@ckeditor/ckeditor5-special-characters/src/specialcharactersessentials';
-import StandardEditingMode from '@ckeditor/ckeditor5-restricted-editing/src/standardeditingmode';
-import RestrictedEditingMode from '@ckeditor/ckeditor5-restricted-editing/src/restrictededitingmode';
-import Table from '@ckeditor/ckeditor5-table/src/table';
-import TableProperties from '@ckeditor/ckeditor5-table/src/tableproperties';
-import TableCellProperties from '@ckeditor/ckeditor5-table/src/tablecellproperties';
-import TableSelection from '@ckeditor/ckeditor5-table/src/tableselection';
-import TableToolbar from '@ckeditor/ckeditor5-table/src/tabletoolbar';
-import Font from '@ckeditor/ckeditor5-font/src/font';
-import Highlight from '@ckeditor/ckeditor5-highlight/src/highlight';
-import Alignment from '@ckeditor/ckeditor5-alignment/src/alignment';
-
-export default class ClassicEditor extends ClassicEditorBase {}
-
-// Plugins to include in the build.
-ClassicEditor.builtinPlugins = [
-	Essentials,
-	UploadAdapter,
-	Autoformat,
-	Bold,
-	Code,
-	CodeBlock,
-	Italic,
-	Strikethrough,
-	Subscript,
-	Superscript,
-	Underline,
-	BlockQuote,
-	CKFinder,
-	EasyImage,
-	Heading,
-	HorizontalLine,
-	Image,
-	ImageCaption,
-	ImageResize,
-	ImageStyle,
-	ImageToolbar,
-	ImageUpload,
-	Link,
-	List,
-	TodoList,
-	MediaEmbed,
-	PageBreak,
-	Paragraph,
-	PasteFromOffice,
-	SpecialCharacters,
-	SpecialCharactersEssentials,
-	StandardEditingMode,
-	RestrictedEditingMode,
-	Table,
-	TableProperties,
-	TableCellProperties,
-	TableSelection,
-	TableToolbar,
-	Font,
-	Highlight,
-	Alignment
-];

+ 0 - 91
scripts/docs/content-styles/list-content-styles.js

@@ -1,91 +0,0 @@
-#!/usr/bin/env node
-
-/**
- * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
- */
-
-/* eslint-env node */
-
-const postcss = require( 'postcss' );
-
-module.exports = postcss.plugin( 'list-content-styles', function( options ) {
-	const selectorStyles = options.contentRules.selector;
-	const variables = options.contentRules.variables;
-
-	return root => {
-		root.walkRules( rule => {
-			for ( const selector of rule.selectors ) {
-				const data = {
-					file: root.source.input.file,
-					css: rule.toString()
-				};
-
-				if ( selector.match( ':root' ) ) {
-					addDefinition( variables, data );
-				}
-
-				if ( selector.match( '.ck-content' ) ) {
-					if ( rule.parent.name && rule.parent.params ) {
-						const atRule = getAtRuleArray( options.contentRules.atRules, rule.parent.name, rule.parent.params );
-
-						addDefinition( atRule, data );
-					} else {
-						addDefinition( selectorStyles, data );
-					}
-				}
-			}
-		} );
-	};
-} );
-
-/**
- * @param {Object} collection
- * @param {String} name Name of an `at-rule`.
- * @param {String} params Parameters that describes the `at-rule`.
- * @returns {Array}
- */
-function getAtRuleArray( collection, name, params ) {
-	const definition = `${ name } ${ params }`;
-
-	if ( !collection[ definition ] ) {
-		collection[ definition ] = [];
-	}
-
-	return collection[ definition ];
-}
-
-/**
- * Checks whether specified definition is duplicated in the colletion.
- *
- * @param {Array.<StyleStructure>} collection
- * @param {StyleStructure} def
- * @returns {Boolean}
- */
-function isDuplicatedDefinition( collection, def ) {
-	for ( const item of collection ) {
-		if ( item.file === def.file && item.css === def.css ) {
-			return true;
-		}
-	}
-
-	return false;
-}
-
-/**
- * Adds definition to the collection if it does not exist in the collection.
- *
- * @param {Array.<StyleStructure>} collection
- * @param {StyleStructure} def
- */
-function addDefinition( collection, def ) {
-	if ( !isDuplicatedDefinition( collection, def ) ) {
-		collection.push( def );
-	}
-}
-
-/**
- * @typedef {Object} StyleStructure
- * @property {String} file An absolute path to the file where a definition is defined.
- * @property {String} css Definition.
- */

+ 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 => {

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


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