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

Merge branch 'master' into i/6170

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

+ 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
 

+ 0 - 6
docs/_snippets/examples/document-editor.html

@@ -195,12 +195,6 @@
 		margin-right: calc( 2 * var(--ck-spacing-large) );
 	}
 
-	/* Some table cells have a lot content and some not. Align them vertically
-	to make reading easier. */
-	.document-editor .ck-content table td {
-		vertical-align: middle;
-	}
-
 	@media only screen and (max-width: 960px) {
 		/* Because on mobile 2cm paddings are to big. */
 		.document-editor__editable-container .document-editor__editable.ck-editor__editable {

+ 0 - 4
docs/_snippets/framework/tutorials/using-react-in-widget.html

@@ -141,10 +141,6 @@
 	animation: slideUp 0.3s ease;
 }
 
-.app .ck-content .table td {
-	vertical-align: middle;
-}
-
 @keyframes slideUp {
 	0% {
 		opacity: 0;

+ 6 - 0
docs/assets/snippet-styles.css

@@ -232,6 +232,12 @@ It breaks CKEditor 5 (see how <p><code>[]</code></p> looks). */
 	display: table;
 }
 
+/* https://github.com/ckeditor/ckeditor5/issues/7310 */
+.live-snippet .ck.ck-content .table td,
+.live-snippet .ck.ck-content .table th {
+	vertical-align: middle;
+}
+
 /* https://github.com/ckeditor/ckeditor5/issues/1282 */
 .live-snippet .ck.ck-content .table p:first-child {
 	padding-top: 0;

+ 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
 

+ 6 - 5
package.json

@@ -77,11 +77,11 @@
   },
   "devDependencies": {
     "@ckeditor/ckeditor5-comments": "^20.0.0",
-    "@ckeditor/ckeditor5-dev-docs": "^21.0.0",
-    "@ckeditor/ckeditor5-dev-env": "^21.0.0",
-    "@ckeditor/ckeditor5-dev-tests": "^21.0.0",
-    "@ckeditor/ckeditor5-dev-utils": "^21.0.0",
-    "@ckeditor/ckeditor5-dev-webpack-plugin": "^21.0.0",
+    "@ckeditor/ckeditor5-dev-docs": "^22.0.0",
+    "@ckeditor/ckeditor5-dev-env": "^22.0.0",
+    "@ckeditor/ckeditor5-dev-tests": "^22.0.0",
+    "@ckeditor/ckeditor5-dev-utils": "^22.0.0",
+    "@ckeditor/ckeditor5-dev-webpack-plugin": "^22.0.0",
     "@ckeditor/ckeditor5-inspector": "^2.1.0",
     "@ckeditor/ckeditor5-react": "^2.1.0",
     "@ckeditor/ckeditor5-real-time-collaboration": "^20.0.0",
@@ -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 - 1
packages/ckeditor5-core/tests/_utils-tests/assertions/attribute.js

@@ -54,7 +54,7 @@ describe( 'attribute chai assertion', () => {
 				hasAttribute: () => true,
 				getAttribute: () => 'bar'
 			} ).to.have.attribute( 'foo', 'baz' );
-		} ).to.throw( 'expected { Object (hasAttribute, getAttribute) } to have attribute \'foo\' of \'bar\', but got \'baz\'' );
+		} ).to.throw( 'expected { Object (hasAttribute, getAttribute) } to have attribute \'foo\' of \'baz\', but got \'bar\'' );
 	} );
 
 	it( 'negated, should assert for the given type the \'target.getAttribute\' returns a value different than the given one', () => {

+ 2 - 2
packages/ckeditor5-core/tests/_utils/assertions/attribute.js

@@ -54,8 +54,8 @@ chai.Assertion.addMethod( 'attribute', function attributeAssertion( key, value,
 			attributeValue === value,
 			`expected #{this} to have attribute '${ key }' of #{exp}, but got #{act}`,
 			`expected #{this} to not have attribute '${ key }' of #{exp}`,
-			attributeValue,
-			value
+			value,
+			attributeValue
 		);
 	}
 } );

+ 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' ]
 		},

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

@@ -46,9 +46,3 @@
 	</table>
 </div>
 
-<style>
-	.ck.ck-content table td,
-	.ck.ck-content table th {
-		vertical-align: middle;
-	}
-</style>

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

+ 3 - 3
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,10 +106,10 @@ 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, writer.batch );
+			removeEmptyRowsColumns( table, tableUtils );
 		} );
 	}
 

+ 3 - 3
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,10 +57,10 @@ 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, writer.batch );
+			removeEmptyRowsColumns( table, tableUtils );
 
 			writer.setSelection( firstTableCell, 'in' );
 		} );

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

+ 4 - 11
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,24 +55,18 @@ 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;
 
-		// Use single batch to modify table in steps but in one undo step.
-		const batch = model.createBatch();
-
-		model.enqueueChange( batch, () => {
+		model.change( writer => {
 			const rowsToRemove = removedRowIndexes.last - removedRowIndexes.first + 1;
 
 			this.editor.plugins.get( 'TableUtils' ).removeRows( table, {
 				at: removedRowIndexes.first,
-				rows: rowsToRemove,
-				batch
+				rows: rowsToRemove
 			} );
-		} );
 
-		model.enqueueChange( batch, writer => {
 			const cellToFocus = getCellToFocus( table, removedRowIndexes.first, columnIndexToFocus );
 
 			writer.setSelection( writer.createPositionAt( cellToFocus, 0 ) );

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

+ 0 - 100
packages/ckeditor5-table/src/converters/downcast.js

@@ -173,79 +173,6 @@ export function downcastInsertCell() {
 	} );
 }
 
-/**
- * Conversion helper that acts on heading row table attribute change.
- *
- * This converter will:
- *
- * * Rename `<td>` to `<th>` elements or vice versa depending on headings.
- * * Create `<thead>` or `<tbody>` elements if needed.
- * * Remove empty `<thead>` or `<tbody>` if needed.
- *
- * @returns {Function} Conversion helper.
- */
-export function downcastTableHeadingRowsChange() {
-	return dispatcher => dispatcher.on( 'attribute:headingRows:table', ( evt, data, conversionApi ) => {
-		const table = data.item;
-
-		if ( !conversionApi.consumable.consume( data.item, evt.name ) ) {
-			return;
-		}
-
-		const figureElement = conversionApi.mapper.toViewElement( table );
-		const viewTable = getViewTable( figureElement );
-
-		const oldRows = data.attributeOldValue;
-		const newRows = data.attributeNewValue;
-
-		// The head section has grown so move rows from <tbody> to <thead>.
-		if ( newRows > oldRows ) {
-			// Filter out only those rows that are in wrong section.
-			const rowsToMove = Array.from( table.getChildren() ).filter( ( { index } ) => isBetween( index, oldRows - 1, newRows ) );
-
-			const viewTableHead = getOrCreateTableSection( 'thead', viewTable, conversionApi );
-			moveViewRowsToTableSection( rowsToMove, viewTableHead, conversionApi, 'end' );
-
-			// Rename all table cells from moved rows to 'th' as they lands in <thead>.
-			for ( const tableRow of rowsToMove ) {
-				for ( const tableCell of tableRow.getChildren() ) {
-					renameViewTableCell( tableCell, 'th', conversionApi );
-				}
-			}
-		}
-		// The head section has shrunk so move rows from <thead> to <tbody>.
-		else {
-			// Filter out only those rows that are in wrong section.
-			const rowsToMove = Array.from( table.getChildren() )
-				.filter( ( { index } ) => isBetween( index, newRows - 1, oldRows ) )
-				.reverse(); // The rows will be moved from <thead> to <tbody> in reverse order at the beginning of a <tbody>.
-
-			const viewTableBody = getOrCreateTableSection( 'tbody', viewTable, conversionApi );
-			moveViewRowsToTableSection( rowsToMove, viewTableBody, conversionApi, 0 );
-
-			// Check if cells moved from <thead> to <tbody> requires renaming to <td> as this depends on current heading columns attribute.
-			const tableWalker = new TableWalker( table, { startRow: newRows ? newRows - 1 : newRows, endRow: oldRows - 1 } );
-
-			const tableAttributes = {
-				headingRows: table.getAttribute( 'headingRows' ) || 0,
-				headingColumns: table.getAttribute( 'headingColumns' ) || 0
-			};
-
-			for ( const tableSlot of tableWalker ) {
-				renameViewTableCellIfRequired( tableSlot, tableAttributes, conversionApi );
-			}
-		}
-
-		// Cleanup: Ensure that thead & tbody sections are removed if left empty after moving rows. See #6437, #6391.
-		removeTableSectionIfEmpty( 'thead', viewTable, conversionApi );
-		removeTableSectionIfEmpty( 'tbody', viewTable, conversionApi );
-
-		function isBetween( index, lower, upper ) {
-			return index > lower && index < upper;
-		}
-	} );
-}
-
 /**
  * Conversion helper that acts on heading column table attribute change.
  *
@@ -333,11 +260,6 @@ function renameViewTableCell( tableCell, desiredCellElementName, conversionApi )
 	const viewWriter = conversionApi.writer;
 	const viewCell = conversionApi.mapper.toViewElement( tableCell );
 
-	// View cell might be not yet converted - skip it as it will be properly created by cell converter later on.
-	if ( !viewCell ) {
-		return;
-	}
-
 	const editable = viewWriter.createEditableElement( desiredCellElementName, viewCell.getAttributes() );
 	const renamedCell = toWidgetEditable( editable, viewWriter );
 
@@ -545,28 +467,6 @@ function removeTableSectionIfEmpty( sectionName, tableElement, conversionApi ) {
 	}
 }
 
-// Moves view table rows associated with passed model rows to the provided table section element.
-//
-// **Note**: This method will skip not converted table rows.
-//
-// @param {Array.<module:engine/model/element~Element>} rowsToMove
-// @param {module:engine/view/element~Element} viewTableSection
-// @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi
-// @param {Number|'end'|'before'|'after'} offset Offset or one of the flags.
-function moveViewRowsToTableSection( rowsToMove, viewTableSection, conversionApi, offset ) {
-	for ( const tableRow of rowsToMove ) {
-		const viewTableRow = conversionApi.mapper.toViewElement( tableRow );
-
-		// View table row might be not yet converted - skip it as it will be properly created by cell converter later on.
-		if ( viewTableRow ) {
-			conversionApi.writer.move(
-				conversionApi.writer.createRangeOn( viewTableRow ),
-				conversionApi.writer.createPositionAt( viewTableSection, offset )
-			);
-		}
-	}
-}
-
 // Finds a '<table>' element inside the `<figure>` widget.
 //
 // @param {module:engine/view/element~Element} viewFigure

+ 54 - 0
packages/ckeditor5-table/src/converters/table-heading-rows-refresh-post-fixer.js

@@ -0,0 +1,54 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module table/converters/table-heading-rows-refresh-post-fixer
+ */
+
+/**
+ * Injects a table post-fixer into the model which marks the table in the differ to have it re-rendered.
+ *
+ * Table heading rows are represented in the model by a `headingRows` attribute. However, in the view, it's represented as separate
+ * sections of the table (`<thead>` or `<tbody>`) and changing `headingRows` attribute requires moving table rows between two sections.
+ * This causes problems with structural changes in a table (like adding and removing rows) thus atomic converters cannot be used.
+ *
+ * When table `headingRows` attribute changes, the entire table is re-rendered.
+ *
+ * @param {module:engine/model/model~Model} model
+ */
+export default function injectTableHeadingRowsRefreshPostFixer( model ) {
+	model.document.registerPostFixer( () => tableHeadingRowsRefreshPostFixer( model ) );
+}
+
+function tableHeadingRowsRefreshPostFixer( model ) {
+	const differ = model.document.differ;
+
+	// Stores tables to be refreshed so the table will be refreshed once for multiple changes.
+	const tablesToRefresh = new Set();
+
+	for ( const change of differ.getChanges() ) {
+		if ( change.type != 'attribute' ) {
+			continue;
+		}
+
+		const element = change.range.start.nodeAfter;
+
+		if ( element && element.is( 'table' ) && change.attributeKey == 'headingRows' ) {
+			tablesToRefresh.add( element );
+		}
+	}
+
+	if ( tablesToRefresh.size ) {
+		// @if CK_DEBUG_TABLE // console.log( `Post-fixing table: refreshing heading rows (${ tablesToRefresh.size }).` );
+
+		for ( const table of tablesToRefresh.values() ) {
+			differ.refreshItem( table );
+		}
+
+		return true;
+	}
+
+	return false;
+}

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

+ 42 - 18
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 );
@@ -205,7 +213,7 @@ function prepareTableForPasting( selectedTableCells, pastedDimensions, writer, t
 		selection.lastRow += pastedDimensions.height - 1;
 		selection.lastColumn += pastedDimensions.width - 1;
 
-		expandTableSize( selectedTable, selection.lastRow + 1, selection.lastColumn + 1, writer, tableUtils );
+		expandTableSize( selectedTable, selection.lastRow + 1, selection.lastColumn + 1, tableUtils );
 	}
 
 	// In case of expanding selection we do not reset the selection so in this case we will always try to fix selection
@@ -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,17 +325,37 @@ 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.
-function expandTableSize( table, expectedHeight, expectedWidth, writer, tableUtils ) {
+function expandTableSize( table, expectedHeight, expectedWidth, tableUtils ) {
 	const tableWidth = tableUtils.getColumns( table );
 	const tableHeight = tableUtils.getRows( table );
 
 	if ( expectedWidth > tableWidth ) {
 		tableUtils.insertColumns( table, {
-			batch: writer.batch,
 			at: tableWidth,
 			columns: expectedWidth - tableWidth
 		} );
@@ -334,7 +363,6 @@ function expandTableSize( table, expectedHeight, expectedWidth, writer, tableUti
 
 	if ( expectedHeight > tableHeight ) {
 		tableUtils.insertRows( table, {
-			batch: writer.batch,
 			at: tableHeight,
 			rows: expectedHeight - tableHeight
 		} );
@@ -489,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 ) {
@@ -505,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).

+ 4 - 4
packages/ckeditor5-table/src/tableediting.js

@@ -15,8 +15,7 @@ import {
 	downcastInsertRow,
 	downcastInsertTable,
 	downcastRemoveRow,
-	downcastTableHeadingColumnsChange,
-	downcastTableHeadingRowsChange
+	downcastTableHeadingColumnsChange
 } from './converters/downcast';
 
 import InsertTableCommand from './commands/inserttablecommand';
@@ -36,6 +35,7 @@ import TableUtils from '../src/tableutils';
 import injectTableLayoutPostFixer from './converters/table-layout-post-fixer';
 import injectTableCellParagraphPostFixer from './converters/table-cell-paragraph-post-fixer';
 import injectTableCellRefreshPostFixer from './converters/table-cell-refresh-post-fixer';
+import injectTableHeadingRowsRefreshPostFixer from './converters/table-heading-rows-refresh-post-fixer';
 
 import '../theme/tableediting.css';
 
@@ -113,9 +113,8 @@ export default class TableEditing extends Plugin {
 		conversion.attributeToAttribute( { model: 'colspan', view: 'colspan' } );
 		conversion.attributeToAttribute( { model: 'rowspan', view: 'rowspan' } );
 
-		// Table heading rows and columns conversion.
+		// Table heading columns conversion (change of heading rows requires reconversion of the whole table).
 		conversion.for( 'editingDowncast' ).add( downcastTableHeadingColumnsChange() );
-		conversion.for( 'editingDowncast' ).add( downcastTableHeadingRowsChange() );
 
 		// Define all the commands.
 		editor.commands.add( 'insertTable', new InsertTableCommand( editor ) );
@@ -143,6 +142,7 @@ export default class TableEditing extends Plugin {
 		editor.commands.add( 'selectTableRow', new SelectRowCommand( editor ) );
 		editor.commands.add( 'selectTableColumn', new SelectColumnCommand( editor ) );
 
+		injectTableHeadingRowsRefreshPostFixer( model );
 		injectTableLayoutPostFixer( model );
 		injectTableCellRefreshPostFixer( model );
 		injectTableCellParagraphPostFixer( model );

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

@@ -18,7 +18,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.
@@ -214,7 +213,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;
@@ -418,7 +417,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 );
 		}
 

+ 41 - 27
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;
 	}
 
@@ -136,7 +150,7 @@ export default class TableUtils extends Plugin {
 
 			// Inserting rows inside heading section requires to update `headingRows` attribute as the heading section will grow.
 			if ( headingRows > insertAt ) {
-				writer.setAttribute( 'headingRows', headingRows + rowsToInsert, table );
+				updateNumericAttribute( 'headingRows', headingRows + rowsToInsert, table, writer, 0 );
 			}
 
 			// Inserting at the end or at the beginning of a table doesn't require to calculate anything special.
@@ -309,9 +323,8 @@ export default class TableUtils extends Plugin {
 		const rowsToRemove = options.rows || 1;
 		const first = options.at;
 		const last = first + rowsToRemove - 1;
-		const batch = options.batch || 'default';
 
-		model.enqueueChange( batch, writer => {
+		model.change( writer => {
 			// Removing rows from the table require that most calculations to be done prior to changing table structure.
 			// Preparations must be done in the same enqueueChange callback to use the current table structure.
 
@@ -337,11 +350,15 @@ export default class TableUtils extends Plugin {
 				updateNumericAttribute( 'rowspan', rowspan, cell, writer );
 			}
 
-			// 2d. Remove empty columns (without anchored cells) if there are any.
-			removeEmptyColumns( table, this );
+			// 2d. Adjust heading rows if removed rows were in a heading section.
+			updateHeadingRows( table, first, last, writer );
 
-			// 2e. Adjust heading rows if removed rows were in a heading section.
-			updateHeadingRows( table, first, last, model, batch );
+			// 2e. Remove empty columns (without anchored cells) if there are any.
+			if ( !removeEmptyColumns( table, this ) ) {
+				// If there wasn't any empty columns then we still need to check if this wasn't called
+				// because of cleaning empty rows and we only removed one of them.
+				removeEmptyRows( table, this );
+			}
 		} );
 	}
 
@@ -396,7 +413,11 @@ export default class TableUtils extends Plugin {
 			}
 
 			// Remove empty rows that could appear after removing columns.
-			removeEmptyRows( table, this, writer.batch );
+			if ( !removeEmptyRows( table, this ) ) {
+				// If there wasn't any empty rows then we still need to check if this wasn't called
+				// because of cleaning empty columns and we only removed one of them.
+				removeEmptyColumns( table, this );
+			}
 		} );
 	}
 
@@ -776,21 +797,14 @@ function adjustHeadingColumns( table, removedColumnIndexes, writer ) {
 }
 
 // Calculates a new heading rows value for removing rows from heading section.
-function updateHeadingRows( table, first, last, model, batch ) {
-	// Must be done after the changes in table structure (removing rows).
-	// Otherwise the downcast converter for headingRows attribute will fail.
-	// See https://github.com/ckeditor/ckeditor5/issues/6391.
-	//
-	// Must be completely wrapped in enqueueChange to get the current table state (after applying other enqueued changes).
-	model.enqueueChange( batch, writer => {
-		const headingRows = table.getAttribute( 'headingRows' ) || 0;
-
-		if ( first < headingRows ) {
-			const newRows = last < headingRows ? headingRows - ( last - first + 1 ) : first;
-
-			updateNumericAttribute( 'headingRows', newRows, table, writer, 0 );
-		}
-	} );
+function updateHeadingRows( table, first, last, writer ) {
+	const headingRows = table.getAttribute( 'headingRows' ) || 0;
+
+	if ( first < headingRows ) {
+		const newRows = last < headingRows ? headingRows - ( last - first + 1 ) : first;
+
+		updateNumericAttribute( 'headingRows', newRows, table, writer, 0 );
+	}
 }
 
 // Finds cells that will be:

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

+ 31 - 17
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;
 }
 
 /**
@@ -342,13 +350,17 @@ export function removeEmptyColumns( table, tableUtils ) {
 		return cellsCount ? result : [ ...result, column ];
 	}, [] );
 
-	// @if CK_DEBUG_TABLE // emptyColumns.length > 0 && console.log( `Removing empty columns: ${ emptyColumns.join( ', ' ) }.` );
+	if ( emptyColumns.length > 0 ) {
+		// Remove only last empty column because it will recurrently trigger removing empty rows.
+		const emptyColumn = emptyColumns[ emptyColumns.length - 1 ];
 
-	emptyColumns.reverse().forEach( column => {
-		tableUtils.removeColumns( table, { at: column } );
-	} );
+		// @if CK_DEBUG_TABLE // console.log( `Removing empty column: ${ emptyColumn }.` );
+		tableUtils.removeColumns( table, { at: emptyColumn } );
 
-	return emptyColumns.length > 0;
+		return true;
+	}
+
+	return false;
 }
 
 /**
@@ -380,10 +392,9 @@ export function removeEmptyColumns( table, tableUtils ) {
  * @protected
  * @param {module:engine/model/element~Element} table
  * @param {module:table/tableutils~TableUtils} tableUtils
- * @param {module:engine/model/batch~Batch|null} [batch] Batch that should be used for removing empty rows.
  * @returns {Boolean} True if removed some rows.
  */
-export function removeEmptyRows( table, tableUtils, batch ) {
+export function removeEmptyRows( table, tableUtils ) {
 	const emptyRows = [];
 
 	for ( let rowIndex = 0; rowIndex < table.childCount; rowIndex++ ) {
@@ -394,13 +405,17 @@ export function removeEmptyRows( table, tableUtils, batch ) {
 		}
 	}
 
-	// @if CK_DEBUG_TABLE // emptyRows.length > 0 && console.log( `Removing empty rows: ${ emptyRows.join( ', ' ) }.` );
+	if ( emptyRows.length > 0 ) {
+		// Remove only last empty row because it will recurrently trigger removing empty columns.
+		const emptyRow = emptyRows[ emptyRows.length - 1 ];
+
+		// @if CK_DEBUG_TABLE // console.log( `Removing empty row: ${ emptyRow }.` );
+		tableUtils.removeRows( table, { at: emptyRow } );
 
-	emptyRows.reverse().forEach( row => {
-		tableUtils.removeRows( table, { at: row, batch } );
-	} );
+		return true;
+	}
 
-	return emptyRows.length > 0;
+	return false;
 }
 
 /**
@@ -428,14 +443,13 @@ export function removeEmptyRows( table, tableUtils, batch ) {
  * @protected
  * @param {module:engine/model/element~Element} table
  * @param {module:table/tableutils~TableUtils} tableUtils
- * @param {module:engine/model/batch~Batch|null} [batch] Batch that should be used for removing empty rows.
  */
-export function removeEmptyRowsColumns( table, tableUtils, batch ) {
+export function removeEmptyRowsColumns( table, tableUtils ) {
 	const removedColumns = removeEmptyColumns( table, tableUtils );
 
 	// If there was some columns removed then cleaning empty rows was already triggered.
 	if ( !removedColumns ) {
-		removeEmptyRows( table, tableUtils, batch );
+		removeEmptyRows( table, tableUtils );
 	}
 }
 

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

+ 47 - 0
packages/ckeditor5-table/tests/commands/mergecellscommand.js

@@ -706,6 +706,53 @@ describe( 'MergeCellsCommand', () => {
 					]
 				] ) );
 			} );
+
+			it( 'should remove all empty rows and columns', () => {
+				setData( model, modelTable( [
+					[ '00', '01', '02' ],
+					[ '10', '11', '12' ],
+					[ '20', '21', '22' ]
+				] ) );
+
+				tableSelection.setCellSelection(
+					root.getNodeByPath( [ 0, 0, 0 ] ),
+					root.getNodeByPath( [ 0, 2, 2 ] )
+				);
+
+				command.execute();
+
+				assertEqualMarkup( getData( model ), modelTable( [
+					[
+						'<paragraph>[00</paragraph><paragraph>01</paragraph><paragraph>02</paragraph>' +
+						'<paragraph>10</paragraph><paragraph>11</paragraph><paragraph>12</paragraph>' +
+						'<paragraph>20</paragraph><paragraph>21</paragraph><paragraph>22]</paragraph>'
+					]
+				] ) );
+			} );
+
+			it( 'should remove all empty rows and columns (asymmetrical case)', () => {
+				setData( model, modelTable( [
+					[ '00', '01', { contents: '02', rowspan: 3 } ],
+					[ '10', '11' ],
+					[ '20', '21' ]
+				] ) );
+
+				tableSelection.setCellSelection(
+					root.getNodeByPath( [ 0, 0, 0 ] ),
+					root.getNodeByPath( [ 0, 2, 1 ] )
+				);
+
+				command.execute();
+
+				assertEqualMarkup( getData( model ), modelTable( [
+					[
+						'<paragraph>[00</paragraph><paragraph>01</paragraph>' +
+						'<paragraph>10</paragraph><paragraph>11</paragraph>' +
+						'<paragraph>20</paragraph><paragraph>21]</paragraph>',
+						'02'
+					]
+				] ) );
+			} );
 		} );
 	} );
 

+ 33 - 32
packages/ckeditor5-table/tests/converters/downcast.js

@@ -5,6 +5,7 @@
 
 import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
 import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import UndoEditing from '@ckeditor/ckeditor5-undo/src/undoediting';
 import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
 import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
 import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
@@ -19,7 +20,7 @@ describe( 'downcast converters', () => {
 	testUtils.createSinonSandbox();
 
 	beforeEach( async () => {
-		editor = await VirtualTestEditor.create( { plugins: [ Paragraph, TableEditing ] } );
+		editor = await VirtualTestEditor.create( { plugins: [ Paragraph, TableEditing, UndoEditing ] } );
 
 		model = editor.model;
 		root = model.document.getRoot( 'main' );
@@ -840,8 +841,10 @@ describe( 'downcast converters', () => {
 		} );
 	} );
 
-	describe( 'downcastTableHeadingRowsChange()', () => {
+	describe( 'downcastTableHeadingRowsChange', () => {
 		// The heading rows change downcast conversion is not executed in data pipeline.
+		// Note that headingRows table attribute triggers whole table downcast.
+
 		describe( 'editing pipeline', () => {
 			it( 'should work for adding heading rows', () => {
 				setModelData( model, modelTable( [
@@ -941,36 +944,6 @@ describe( 'downcast converters', () => {
 				], { headingRows: 2, asWidget: true } ) );
 			} );
 
-			it( 'should be possible to overwrite', () => {
-				editor.conversion.attributeToAttribute( {
-					model: 'headingRows',
-					view: 'headingRows',
-					converterPriority: 'high'
-				} );
-				setModelData( model, modelTable( [ [ '00' ] ] ) );
-
-				const table = root.getChild( 0 );
-
-				model.change( writer => {
-					writer.setAttribute( 'headingRows', 1, table );
-				} );
-
-				assertEqualMarkup( getViewData( view, { withoutSelection: true } ),
-					'<figure class="ck-widget ck-widget_with-selection-handle table" contenteditable="false" headingRows="1">' +
-						'<div class="ck ck-widget__selection-handle"></div>' +
-						'<table>' +
-							'<tbody>' +
-								'<tr>' +
-									'<td class="ck-editor__editable ck-editor__nested-editable" contenteditable="true">' +
-										'<span style="display:inline-block">00</span>' +
-									'</td>' +
-								'</tr>' +
-							'</tbody>' +
-						'</table>' +
-					'</figure>'
-				);
-			} );
-
 			it( 'should work with adding table rows at the beginning of a table', () => {
 				setModelData( model, modelTable( [
 					[ '00', '01' ],
@@ -1047,6 +1020,34 @@ describe( 'downcast converters', () => {
 					'</figure>'
 				);
 			} );
+
+			it( 'should properly integrate with undo', () => {
+				setModelData( model, modelTable( [
+					[ '00', '01' ],
+					[ '10', '11' ],
+					[ '20', '21' ]
+				], { headingRows: 1 } ) );
+
+				const table = root.getChild( 0 );
+
+				model.change( writer => {
+					writer.setAttribute( 'headingRows', 2, table );
+				} );
+
+				assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
+					[ '00', '01' ],
+					[ '10', '11' ],
+					[ '20', '21' ]
+				], { headingRows: 2, asWidget: true } ) );
+
+				editor.execute( 'undo' );
+
+				assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
+					[ '00', '01' ],
+					[ '10', '11' ],
+					[ '20', '21' ]
+				], { headingRows: 1, asWidget: true } ) );
+			} );
 		} );
 	} );
 

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

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

@@ -959,6 +959,21 @@ describe( 'TableUtils', () => {
 				], { headingRows: 1 } ) );
 			} );
 
+			it( 'should change heading rows if removing a heading row (and cell below is row-spanned)', () => {
+				setData( model, modelTable( [
+					[ '00', '01' ],
+					[ '10', { contents: '11', rowspan: 2 } ],
+					[ '20' ]
+				], { headingRows: 1 } ) );
+
+				tableUtils.removeRows( root.getChild( 0 ), { at: 0 } );
+
+				assertEqualMarkup( getData( model, { withoutSelection: true } ), modelTable( [
+					[ '10', { contents: '11', rowspan: 2 } ],
+					[ '20' ]
+				] ) );
+			} );
+
 			it( 'should decrease rowspan of table cells from previous rows', () => {
 				// +----+----+----+----+----+
 				// | 00 | 01 | 02 | 03 | 04 |
@@ -1511,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);
+}

+ 7 - 2
packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/toolbar/toolbar.css

@@ -17,9 +17,14 @@
 		align-self: stretch;
 		width: 1px;
 		min-width: 1px;
-		margin-top: 0;
-		margin-bottom: 0;
 		background: var(--ck-color-toolbar-border);
+
+		/*
+		 * These margins make the separators look better in balloon toolbars (when aligned with the "tip").
+		 * See https://github.com/ckeditor/ckeditor5/issues/7493.
+		 */
+		margin-top: var(--ck-spacing-small);
+		margin-bottom: var(--ck-spacing-small);
 	}
 
 	& > .ck-toolbar__items {

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

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