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

Merge branch 'master' into i/7492

Marek Lewandowski 5 лет назад
Родитель
Сommit
8451dd685e
100 измененных файлов с 4028 добавлено и 626 удалено
  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. 1 0
      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. 6 7
      packages/ckeditor5-engine/src/conversion/mapper.js
  15. 22 0
      packages/ckeditor5-engine/src/model/element.js
  16. 16 0
      packages/ckeditor5-engine/src/model/position.js
  17. 57 0
      packages/ckeditor5-engine/src/model/range.js
  18. 1 1
      packages/ckeditor5-engine/src/view/domconverter.js
  19. 6 2
      packages/ckeditor5-engine/tests/conversion/mapper.js
  20. 27 0
      packages/ckeditor5-engine/tests/model/element.js
  21. 20 0
      packages/ckeditor5-engine/tests/model/position.js
  22. 110 0
      packages/ckeditor5-engine/tests/model/range.js
  23. 1 1
      packages/ckeditor5-highlight/package.json
  24. 14 0
      packages/ckeditor5-image/docs/_snippets/features/image-resizeui.html
  25. 52 0
      packages/ckeditor5-image/docs/_snippets/features/image-resizeui.js
  26. 14 0
      packages/ckeditor5-image/docs/_snippets/features/image-resizeuidropdown.html
  27. 43 0
      packages/ckeditor5-image/docs/_snippets/features/image-resizeuidropdown.js
  28. 63 0
      packages/ckeditor5-image/docs/features/image.md
  29. 6 1
      packages/ckeditor5-image/lang/contexts.json
  30. 145 109
      packages/ckeditor5-image/src/imageresize.js
  31. 10 3
      packages/ckeditor5-image/src/imageresize/imageresizecommand.js
  32. 142 0
      packages/ckeditor5-image/src/imageresize/imageresizeediting.js
  33. 290 0
      packages/ckeditor5-image/src/imageresize/imageresizeui.js
  34. 18 0
      packages/ckeditor5-image/tests/imageresize/imageresize.js
  35. 14 10
      packages/ckeditor5-image/tests/imageresize/imageresizeediting.js
  36. 323 0
      packages/ckeditor5-image/tests/imageresize/imageresizeui.js
  37. 37 0
      packages/ckeditor5-image/tests/manual/imageresizeui.html
  38. 118 0
      packages/ckeditor5-image/tests/manual/imageresizeui.js
  39. 20 0
      packages/ckeditor5-image/tests/manual/imageresizeui.md
  40. 12 0
      packages/ckeditor5-image/theme/imageresize.css
  41. 1 1
      packages/ckeditor5-link/package.json
  42. 135 1
      packages/ckeditor5-link/src/linkediting.js
  43. 2 0
      packages/ckeditor5-link/src/linkimage.js
  44. 13 0
      packages/ckeditor5-link/src/linkimageediting.js
  45. 2 2
      packages/ckeditor5-link/src/linkui.js
  46. 351 11
      packages/ckeditor5-link/tests/linkediting.js
  47. 20 3
      packages/ckeditor5-link/tests/linkimageediting.js
  48. 8 0
      packages/ckeditor5-link/tests/linkui.js
  49. 3 1
      packages/ckeditor5-link/tests/manual/linkimage.md
  50. 1 1
      packages/ckeditor5-link/theme/icons/link.svg
  51. 18 0
      packages/ckeditor5-link/theme/linkimage.css
  52. 59 62
      packages/ckeditor5-list/src/todolistconverters.js
  53. 33 25
      packages/ckeditor5-list/src/todolistediting.js
  54. 29 0
      packages/ckeditor5-list/src/utils.js
  55. 4 2
      packages/ckeditor5-list/tests/manual/todo-list.js
  56. 189 38
      packages/ckeditor5-list/tests/todolistediting.js
  57. 24 11
      packages/ckeditor5-page-break/src/pagebreakediting.js
  58. 30 2
      packages/ckeditor5-page-break/tests/pagebreakediting.js
  59. 1 0
      packages/ckeditor5-paste-from-office/package.json
  60. 16 0
      packages/ckeditor5-paste-from-office/tests/_data/page-break/index.js
  61. 738 0
      packages/ckeditor5-paste-from-office/tests/_data/page-break/page-break/input.html
  62. 3 0
      packages/ckeditor5-paste-from-office/tests/_data/page-break/page-break/model.html
  63. BIN
      packages/ckeditor5-paste-from-office/tests/_data/page-break/page-break/page-break.docx
  64. 3 1
      packages/ckeditor5-paste-from-office/tests/_utils/fixtures.js
  65. 10 0
      packages/ckeditor5-paste-from-office/tests/data/integration.js
  66. 3 2
      packages/ckeditor5-paste-from-office/tests/manual/integration.js
  67. 0 6
      packages/ckeditor5-table/docs/_snippets/features/table.html
  68. 2 3
      packages/ckeditor5-table/src/commands/insertcolumncommand.js
  69. 2 3
      packages/ckeditor5-table/src/commands/insertrowcommand.js
  70. 3 4
      packages/ckeditor5-table/src/commands/inserttablecommand.js
  71. 3 3
      packages/ckeditor5-table/src/commands/mergecellcommand.js
  72. 3 8
      packages/ckeditor5-table/src/commands/mergecellscommand.js
  73. 1 2
      packages/ckeditor5-table/src/commands/removecolumncommand.js
  74. 4 14
      packages/ckeditor5-table/src/commands/removerowcommand.js
  75. 1 2
      packages/ckeditor5-table/src/commands/selectcolumncommand.js
  76. 1 2
      packages/ckeditor5-table/src/commands/selectrowcommand.js
  77. 1 2
      packages/ckeditor5-table/src/commands/setheadercolumncommand.js
  78. 2 2
      packages/ckeditor5-table/src/commands/setheaderrowcommand.js
  79. 0 100
      packages/ckeditor5-table/src/converters/downcast.js
  80. 54 0
      packages/ckeditor5-table/src/converters/table-heading-rows-refresh-post-fixer.js
  81. 3 3
      packages/ckeditor5-table/src/converters/table-layout-post-fixer.js
  82. 42 18
      packages/ckeditor5-table/src/tableclipboard.js
  83. 4 4
      packages/ckeditor5-table/src/tableediting.js
  84. 2 3
      packages/ckeditor5-table/src/tablekeyboard.js
  85. 1 6
      packages/ckeditor5-table/src/tablemouse.js
  86. 2 4
      packages/ckeditor5-table/src/tableproperties/commands/tablepropertycommand.js
  87. 2 3
      packages/ckeditor5-table/src/tableselection.js
  88. 41 27
      packages/ckeditor5-table/src/tableutils.js
  89. 4 20
      packages/ckeditor5-table/src/utils/common.js
  90. 13 8
      packages/ckeditor5-table/src/utils/selection.js
  91. 31 17
      packages/ckeditor5-table/src/utils/structure.js
  92. 2 3
      packages/ckeditor5-table/src/utils/ui/contextualballoon.js
  93. 12 1
      packages/ckeditor5-table/src/utils/ui/widget.js
  94. 15 0
      packages/ckeditor5-table/tests/commands/inserttablecommand.js
  95. 47 0
      packages/ckeditor5-table/tests/commands/mergecellscommand.js
  96. 33 32
      packages/ckeditor5-table/tests/converters/downcast.js
  97. 13 2
      packages/ckeditor5-table/tests/manual/tablemocking.html
  98. 5 2
      packages/ckeditor5-table/tests/manual/tablemocking.js
  99. 0 2
      packages/ckeditor5-table/tests/manual/tablemocking.md
  100. 368 4
      packages/ckeditor5-table/tests/tableclipboard-paste.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
 

+ 1 - 0
package.json

@@ -98,6 +98,7 @@
     "lint-staged": "^10.2.6",
     "mini-css-extract-plugin": "^0.9.0",
     "minimatch": "^3.0.4",
+    "mkdirp": "^1.0.4",
     "nyc": "^15.0.1",
     "postcss-loader": "^3.0.0",
     "progress-bar-webpack-plugin": "^2.1.0",

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

+ 6 - 7
packages/ckeditor5-engine/src/conversion/mapper.js

@@ -104,7 +104,7 @@ export default class Mapper {
 
 			const viewContainer = this._modelToViewMapping.get( data.modelPosition.parent );
 
-			data.viewPosition = this._findPositionIn( viewContainer, data.modelPosition.offset );
+			data.viewPosition = this.findPositionIn( viewContainer, data.modelPosition.offset );
 		}, { priority: 'low' } );
 
 		// Default mapper algorithm for mapping view position to model position.
@@ -510,25 +510,24 @@ export default class Mapper {
 	 *
 	 *		<p>fo<b>bar</b>bom</p> -> expected offset: 4
 	 *
-	 *		_findPositionIn( p, 4 ):
+	 *		findPositionIn( p, 4 ):
 	 *		<p>|fo<b>bar</b>bom</p> -> expected offset: 4, actual offset: 0
 	 *		<p>fo|<b>bar</b>bom</p> -> expected offset: 4, actual offset: 2
 	 *		<p>fo<b>bar</b>|bom</p> -> expected offset: 4, actual offset: 5 -> we are too far
 	 *
-	 *		_findPositionIn( b, 4 - ( 5 - 3 ) ):
+	 *		findPositionIn( b, 4 - ( 5 - 3 ) ):
 	 *		<p>fo<b>|bar</b>bom</p> -> expected offset: 2, actual offset: 0
 	 *		<p>fo<b>bar|</b>bom</p> -> expected offset: 2, actual offset: 3 -> we are too far
 	 *
-	 *		_findPositionIn( bar, 2 - ( 3 - 3 ) ):
+	 *		findPositionIn( bar, 2 - ( 3 - 3 ) ):
 	 *		We are in the text node so we can simple find the offset.
 	 *		<p>fo<b>ba|r</b>bom</p> -> expected offset: 2, actual offset: 2 -> position found
 	 *
-	 * @private
 	 * @param {module:engine/view/element~Element} viewParent Tree view element in which we are looking for the position.
 	 * @param {Number} expectedOffset Expected offset.
 	 * @returns {module:engine/view/position~Position} Found position.
 	 */
-	_findPositionIn( viewParent, expectedOffset ) {
+	findPositionIn( viewParent, expectedOffset ) {
 		// Last scanned view node.
 		let viewNode;
 		// Length of the last scanned view node.
@@ -560,7 +559,7 @@ export default class Mapper {
 		else {
 			// ( modelOffset - lastLength ) is the offset to the child we enter,
 			// so we subtract it from the expected offset to fine the offset in the child.
-			return this._findPositionIn( viewNode, expectedOffset - ( modelOffset - lastLength ) );
+			return this.findPositionIn( viewNode, expectedOffset - ( modelOffset - lastLength ) );
 		}
 	}
 

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

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

@@ -276,6 +276,63 @@ export default class Range {
 		return null;
 	}
 
+	/**
+	 * Returns a range created by joining this {@link ~Range range} with the given {@link ~Range range}.
+	 * If ranges have no common part, returns `null`.
+	 *
+	 * Examples:
+	 *
+	 *		let range = model.createRange(
+	 *			model.createPositionFromPath( root, [ 2, 7 ] ),
+	 *			model.createPositionFromPath( root, [ 4, 0, 1 ] )
+	 *		);
+	 *		let otherRange = model.createRange(
+	 *			model.createPositionFromPath( root, [ 1 ] ),
+	 *			model.createPositionFromPath( root, [ 2 ] )
+ 	 *		);
+	 *		let transformed = range.getJoined( otherRange ); // null - ranges have no common part
+	 *
+	 *		otherRange = model.createRange(
+	 *			model.createPositionFromPath( root, [ 3 ] ),
+	 *			model.createPositionFromPath( root, [ 5 ] )
+	 *		);
+	 *		transformed = range.getJoined( otherRange ); // range from [ 2, 7 ] to [ 5 ]
+	 *
+	 * @param {module:engine/model/range~Range} otherRange Range to be joined.
+	 * @param {Boolean} [loose=false] Whether the intersection check is loose or strict. If the check is strict (`false`),
+	 * ranges are tested for intersection or whether start/end positions are equal. If the check is loose (`true`),
+	 * compared range is also checked if it's {@link module:engine/model/position~Position#isTouching touching} current range.
+	 * @returns {module:engine/model/range~Range|null} A sum of given ranges or `null` if ranges have no common part.
+	 */
+	getJoined( otherRange, loose = false ) {
+		let shouldJoin = this.isIntersecting( otherRange );
+
+		if ( !shouldJoin ) {
+			if ( this.start.isBefore( otherRange.start ) ) {
+				shouldJoin = loose ? this.end.isTouching( otherRange.start ) : this.end.isEqual( otherRange.start );
+			} else {
+				shouldJoin = loose ? otherRange.end.isTouching( this.start ) : otherRange.end.isEqual( this.start );
+			}
+		}
+
+		if ( !shouldJoin ) {
+			return null;
+		}
+
+		let startPosition = this.start;
+		let endPosition = this.end;
+
+		if ( otherRange.start.isBefore( startPosition ) ) {
+			startPosition = otherRange.start;
+		}
+
+		if ( otherRange.end.isAfter( endPosition ) ) {
+			endPosition = otherRange.end;
+		}
+
+		return new Range( startPosition, endPosition );
+	}
+
 	/**
 	 * Computes and returns the smallest set of {@link #isFlat flat} ranges, that covers this range in whole.
 	 *

+ 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

+ 6 - 2
packages/ckeditor5-engine/tests/conversion/mapper.js

@@ -536,7 +536,7 @@ describe( 'Mapper', () => {
 			it( 'should transform viewTextFOO 3', () => createToModelTest( viewTextFOO, 3, modelCaption, 3 ) );
 		} );
 
-		describe( 'toViewPosition', () => {
+		describe( 'toViewPosition and findPositionIn', () => {
 			it( 'should transform modelDiv 0', () => createToViewTest( modelDiv, 0, viewTextX, 0 ) );
 			it( 'should transform modelDiv 1', () => createToViewTest( modelDiv, 1, viewTextX, 1 ) );
 			it( 'should transform modelDiv 2', () => createToViewTest( modelDiv, 2, viewTextZZ, 0 ) );
@@ -553,7 +553,11 @@ describe( 'Mapper', () => {
 
 		function createToViewTest( modelElement, modelOffset, viewElement, viewOffset ) {
 			const modelPosition = ModelPosition._createAt( modelElement, modelOffset );
-			const viewPosition = mapper.toViewPosition( modelPosition );
+			let viewPosition = mapper.toViewPosition( modelPosition );
+			expect( viewPosition.parent ).to.equal( viewElement );
+			expect( viewPosition.offset ).to.equal( viewOffset );
+
+			viewPosition = mapper.findPositionIn( mapper.toViewElement( modelElement ), modelOffset );
 			expect( viewPosition.parent ).to.equal( viewElement );
 			expect( viewPosition.offset ).to.equal( viewOffset );
 		}

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

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

@@ -789,6 +789,116 @@ describe( 'Range', () => {
 		} );
 	} );
 
+	describe( 'getJoined()', () => {
+		let range;
+
+		beforeEach( () => {
+			range = new Range( new Position( root, [ 3, 2 ] ), new Position( root, [ 5, 4 ] ) );
+		} );
+
+		it( 'should return null if ranges do not intersect nor have equal start/end', () => {
+			const otherRange = new Range( new Position( root, [ 5, 5 ] ), new Position( root, [ 7 ] ) );
+			const sum = range.getJoined( otherRange );
+
+			expect( sum ).to.be.null;
+		} );
+
+		it( 'should return a range spanning both of the ranges if the ranges have equal start/end positions', () => {
+			const otherRange = new Range( new Position( root, [ 5, 4 ] ), new Position( root, [ 7 ] ) );
+			const sum = range.getJoined( otherRange );
+
+			expect( sum.start.path ).to.deep.equal( [ 3, 2 ] );
+			expect( sum.end.path ).to.deep.equal( [ 7 ] );
+		} );
+
+		it( 'should return a range spanning both of the ranges if the ranges have equal start/end positions (different order)', () => {
+			const otherRange = new Range( new Position( root, [ 1, 4 ] ), new Position( root, [ 3, 2 ] ) );
+			const sum = range.getJoined( otherRange );
+
+			expect( sum.start.path ).to.deep.equal( [ 1, 4 ] );
+			expect( sum.end.path ).to.deep.equal( [ 5, 4 ] );
+		} );
+
+		it( 'should return a range spanning both of the ranges - original range contains the other range', () => {
+			const otherRange = new Range( new Position( root, [ 4 ] ), new Position( root, [ 5 ] ) );
+			const sum = range.getJoined( otherRange );
+
+			expect( sum.isEqual( range ) ).to.be.true;
+		} );
+
+		it( 'should return a range spanning both of the ranges - original range is contained by the other range', () => {
+			const otherRange = new Range( new Position( root, [ 3 ] ), new Position( root, [ 6 ] ) );
+			const sum = range.getJoined( otherRange );
+
+			expect( sum.isEqual( otherRange ) ).to.be.true;
+		} );
+
+		it( 'should return a range spanning both of the ranges - original range intersects with the other range', () => {
+			const otherRange = new Range( new Position( root, [ 3 ] ), new Position( root, [ 4, 7 ] ) );
+			const sum = range.getJoined( otherRange );
+
+			expect( sum.start.path ).to.deep.equal( [ 3 ] );
+			expect( sum.end.path ).to.deep.equal( [ 5, 4 ] );
+		} );
+
+		it( 'should return a range spanning both of the ranges if both ranges are equal', () => {
+			const otherRange = range.clone();
+			const sum = range.getJoined( otherRange );
+
+			expect( sum.isEqual( range ) ).to.be.true;
+		} );
+
+		describe( 'with `loose` option enabled', () => {
+			beforeEach( () => {
+				prepareRichRoot( root );
+			} );
+
+			it( 'should return null if ranges are not intersecting nor touching', () => {
+				const range = new Range( new Position( root, [ 0, 1 ] ), new Position( root, [ 3 ] ) );
+				const otherRange = new Range( new Position( root, [ 3, 1 ] ), new Position( root, [ 3, 2 ] ) );
+				const sum = range.getJoined( otherRange, true );
+
+				expect( sum ).to.be.null;
+			} );
+
+			it( 'should return a range spanning both of the ranges - original range end is equal to other range start position', () => {
+				const range = new Range( new Position( root, [ 0, 1 ] ), new Position( root, [ 3 ] ) );
+				const otherRange = new Range( new Position( root, [ 3 ] ), new Position( root, [ 3, 2 ] ) );
+				const sum = range.getJoined( otherRange, true );
+
+				expect( sum.start.path ).to.deep.equal( [ 0, 1 ] );
+				expect( sum.end.path ).to.deep.equal( [ 3, 2 ] );
+			} );
+
+			it( 'should return a range spanning both of the ranges - original range start is equal to other range end position', () => {
+				const range = new Range( new Position( root, [ 3 ] ), new Position( root, [ 3, 2 ] ) );
+				const otherRange = new Range( new Position( root, [ 0, 1 ] ), new Position( root, [ 3 ] ) );
+				const sum = range.getJoined( otherRange, true );
+
+				expect( sum.start.path ).to.deep.equal( [ 0, 1 ] );
+				expect( sum.end.path ).to.deep.equal( [ 3, 2 ] );
+			} );
+
+			it( 'should return a range spanning both of the ranges - original range is touching other range on the right side', () => {
+				const range = new Range( new Position( root, [ 0, 1 ] ), new Position( root, [ 3 ] ) );
+				const otherRange = new Range( new Position( root, [ 3, 0 ] ), new Position( root, [ 3, 2 ] ) );
+				const sum = range.getJoined( otherRange, true );
+
+				expect( sum.start.path ).to.deep.equal( [ 0, 1 ] );
+				expect( sum.end.path ).to.deep.equal( [ 3, 2 ] );
+			} );
+
+			it( 'should return a range spanning both of the ranges - original range is touching other range on the left side', () => {
+				const range = new Range( new Position( root, [ 1, 0 ] ), new Position( root, [ 3, 2 ] ) );
+				const otherRange = new Range( new Position( root, [ 0, 1 ] ), new Position( root, [ 0, 2 ] ) );
+				const sum = range.getJoined( otherRange, true );
+
+				expect( sum.start.path ).to.deep.equal( [ 0, 1 ] );
+				expect( sum.end.path ).to.deep.equal( [ 3, 2 ] );
+			} );
+		} );
+	} );
+
 	// Note: We don't create model element structure in these tests because this method
 	// is used by OT so it must not check the structure.
 	describe( 'getTransformedByOperation()', () => {

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

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

@@ -0,0 +1,14 @@
+<div id="snippet-image-resizeui">
+	<h3>Resize me using image toolbar buttons!</h3>
+
+	<figure class="image">
+		<img src="%BASE_PATH%/assets/img/fields.jpg" alt="Autumn fields">
+		<figcaption>Autumn fields by Aleksander Nowodziński</figcaption>
+	</figure>
+
+	<h3>Resized image (width: 75%):</h3>
+
+	<figure class="image image_resized" style="width:75%;">
+		<img src="%BASE_PATH%/assets/img/fields.jpg" alt="Autumn fields">
+	</figure>
+</div>

+ 52 - 0
packages/ckeditor5-image/docs/_snippets/features/image-resizeui.js

@@ -0,0 +1,52 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* globals ClassicEditor, console, window, document */
+
+import { CS_CONFIG } from '@ckeditor/ckeditor5-cloud-services/tests/_utils/cloud-services-config.js';
+
+ClassicEditor
+	.create( document.querySelector( '#snippet-image-resizeui' ), {
+		removePlugins: [ 'LinkImage' ],
+		toolbar: {
+			viewportTopOffset: window.getViewportTopOffsetConfig()
+		},
+		image: {
+			resizeOptions: [
+				{
+					name: 'imageResize:original',
+					label: 'Original',
+					value: null,
+					icon: 'original'
+				},
+				{
+					name: 'imageResize:50',
+					label: '50%',
+					value: '50',
+					icon: 'medium'
+				},
+				{
+					name: 'imageResize:75',
+					label: '75%',
+					value: '75',
+					icon: 'large'
+				}
+			],
+			toolbar: [
+				'imageStyle:full',
+				'imageStyle:side', '|',
+				'imageResize:50',
+				'imageResize:75',
+				'imageResize:original'
+			]
+		},
+		cloudServices: CS_CONFIG
+	} )
+	.then( editor => {
+		window.editorResizeUI = editor;
+	} )
+	.catch( err => {
+		console.error( err );
+	} );

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

@@ -0,0 +1,14 @@
+<div id="snippet-image-resizeui-dropdown">
+	<h3>Resize me using the dropdown!</h3>
+
+	<figure class="image">
+		<img src="%BASE_PATH%/assets/img/fields.jpg" alt="Autumn fields">
+		<figcaption>Autumn fields by Aleksander Nowodziński</figcaption>
+	</figure>
+
+	<h3>Resized image (width: 75%):</h3>
+
+	<figure class="image image_resized" style="width:75%;">
+		<img src="%BASE_PATH%/assets/img/fields.jpg" alt="Autumn fields">
+	</figure>
+</div>

+ 43 - 0
packages/ckeditor5-image/docs/_snippets/features/image-resizeuidropdown.js

@@ -0,0 +1,43 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* globals ClassicEditor, console, window, document */
+
+import { CS_CONFIG } from '@ckeditor/ckeditor5-cloud-services/tests/_utils/cloud-services-config.js';
+
+ClassicEditor
+	.create( document.querySelector( '#snippet-image-resizeui-dropdown' ), {
+		removePlugins: [ 'LinkImage' ],
+		toolbar: {
+			viewportTopOffset: window.getViewportTopOffsetConfig()
+		},
+		image: {
+			resizeOptions: [
+				{
+					name: 'imageResize:original',
+					label: 'Original',
+					value: null
+				},
+				{
+					name: 'imageResize:50',
+					label: '50%',
+					value: '50'
+				},
+				{
+					name: 'imageResize:75',
+					label: '75%',
+					value: '75'
+				}
+			],
+			toolbar: [ 'imageStyle:full', 'imageStyle:side', '|', 'imageResize' ]
+		},
+		cloudServices: CS_CONFIG
+	} )
+	.then( editor => {
+		window.editorResizeUIDropdown = editor;
+	} )
+	.catch( err => {
+		console.error( err );
+	} );

+ 63 - 0
packages/ckeditor5-image/docs/features/image.md

@@ -185,8 +185,71 @@ The [image styles](#image-styles) feature is meant to give the user the choice b
 
 It is implemented by the {@link module:image/imageresize~ImageResize} plugin and enables four "resize handles" displayed over the selected image. The user can freely resize the image by dragging them. The feature can be configured to use either percentage (default) or pixel values.
 
+The plugin also gives you an ability to change the size of the image through the image toolbar. You can set an optional static configuration with {@link module:image/image~ImageConfig#resizeOptions} and choose whether you want to use a dropdown or set of the standalone buttons.
+
+### Resize image using handles
+
 {@snippet features/image-resize}
 
+### Resize image using the plugin dropdown
+
+```js
+const imageConfiguration = {
+	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' ]
+}
+```
+
+{@snippet features/image-resizeuidropdown}
+
+### Resize image using the standalone buttons
+
+```js
+const imageConfiguration = {
+	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:original',
+		'imageResize:50',
+		'imageResize:75'
+	]
+}
+```
+
+{@snippet features/image-resizeui}
+
 ### Enabling image resizing
 
 The image resize feature is not enabled by default in any of the editor builds. In order to enable it, you need to load the {@link module:image/imageresize~ImageResize} plugin. Read more in the [Installation](#installation) section.

+ 6 - 1
packages/ckeditor5-image/lang/contexts.json

@@ -10,5 +10,10 @@
 	"Enter image caption": "Placeholder text for image caption displayed when caption is empty.",
 	"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."
+	"Image toolbar": "The label used by assistive technologies describing an image toolbar attached to an image widget.",
+	"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."
 }

+ 145 - 109
packages/ckeditor5-image/src/imageresize.js

@@ -8,8 +8,8 @@
  */
 
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
-import WidgetResize from '@ckeditor/ckeditor5-widget/src/widgetresize';
-import ImageResizeCommand from './imageresize/imageresizecommand';
+import ImageResizeUI from './imageresize/imageresizeui';
+import ImageResizeEditing from './imageresize/imageresizeediting';
 
 import '../theme/imageresize.css';
 
@@ -25,7 +25,7 @@ export default class ImageResize extends Plugin {
 	 * @inheritDoc
 	 */
 	static get requires() {
-		return [ WidgetResize ];
+		return [ ImageResizeEditing, ImageResizeUI ];
 	}
 
 	/**
@@ -34,112 +34,6 @@ export default class ImageResize extends Plugin {
 	static get pluginName() {
 		return 'ImageResize';
 	}
-
-	/**
-	 * @inheritDoc
-	 */
-	init() {
-		const editor = this.editor;
-		const command = new ImageResizeCommand( editor );
-
-		this._registerSchema();
-		this._registerConverters();
-
-		editor.commands.add( 'imageResize', command );
-
-		editor.editing.downcastDispatcher.on( 'insert:image', ( evt, data, conversionApi ) => {
-			const widget = conversionApi.mapper.toViewElement( data.item );
-
-			const resizer = editor.plugins
-				.get( WidgetResize )
-				.attachTo( {
-					unit: editor.config.get( 'image.resizeUnit' ) || '%',
-
-					modelElement: data.item,
-					viewElement: widget,
-					editor,
-
-					getHandleHost( domWidgetElement ) {
-						return domWidgetElement.querySelector( 'img' );
-					},
-					getResizeHost( domWidgetElement ) {
-						return domWidgetElement;
-					},
-					// TODO consider other positions.
-					isCentered() {
-						const imageStyle = data.item.getAttribute( 'imageStyle' );
-
-						return !imageStyle || imageStyle == 'full' || imageStyle == 'alignCenter';
-					},
-
-					onCommit( newValue ) {
-						editor.execute( 'imageResize', { width: newValue } );
-					}
-				} );
-
-			resizer.on( 'updateSize', () => {
-				if ( !widget.hasClass( 'image_resized' ) ) {
-					editor.editing.view.change( writer => {
-						writer.addClass( 'image_resized', widget );
-					} );
-				}
-			} );
-
-			resizer.bind( 'isEnabled' ).to( command );
-		}, { priority: 'low' } );
-	}
-
-	/**
-	 * @private
-	 */
-	_registerSchema() {
-		this.editor.model.schema.extend( 'image', {
-			allowAttributes: 'width'
-		} );
-	}
-
-	/**
-	 * Registers image resize converters.
-	 *
-	 * @private
-	 */
-	_registerConverters() {
-		const editor = this.editor;
-
-		// Dedicated converter to propagate image's attribute to the img tag.
-		editor.conversion.for( 'downcast' ).add( dispatcher =>
-			dispatcher.on( 'attribute:width:image', ( evt, data, conversionApi ) => {
-				if ( !conversionApi.consumable.consume( data.item, evt.name ) ) {
-					return;
-				}
-
-				const viewWriter = conversionApi.writer;
-				const figure = conversionApi.mapper.toViewElement( data.item );
-
-				if ( data.attributeNewValue !== null ) {
-					viewWriter.setStyle( 'width', data.attributeNewValue, figure );
-					viewWriter.addClass( 'image_resized', figure );
-				} else {
-					viewWriter.removeStyle( 'width', figure );
-					viewWriter.removeClass( 'image_resized', figure );
-				}
-			} )
-		);
-
-		editor.conversion.for( 'upcast' )
-			.attributeToAttribute( {
-				view: {
-					name: 'figure',
-					styles: {
-						width: /.+/
-					}
-				},
-				model: {
-					key: 'width',
-					value: viewElement => viewElement.getStyle( 'width' )
-				}
-			} );
-	}
 }
 
 /**
@@ -162,3 +56,145 @@ export default class ImageResize extends Plugin {
  * @default '%'
  * @member {String} module:image/image~ImageConfig#resizeUnit
  */
+
+/**
+ * The image resize options.
+ *
+ * Each option should have at least these two properties:
+ *
+ * * 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.
+ *
+ * **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.
+ *
+ *		ClassicEditor
+ *			.create( editorElement, {
+ *				image: {
+ *					resizeUnit: "%",
+ *					resizeOptions: [ {
+ *						name: 'imageResize:original',
+ *						value: null
+ *					},
+ *					{
+ *						name: 'imageResize:50',
+ *						value: '50'
+ *					},
+ *					{
+ *						name: 'imageResize:75',
+ *						value: '75'
+ *					} ]
+ *				}
+ *			} )
+ *			.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
+{@link module:image/image~ImageConfig#toolbar `config.image.toolbar`}. The dropdown contains all defined options by default:
+ *
+ *		ClassicEditor
+ *			.create( editorElement, {
+ *				image: {
+ *					resizeUnit: "%",
+ *					resizeOptions: [ {
+ *						name: 'imageResize:original',
+ *						value: null
+ *					},
+ *					{
+ *						name: 'imageResize:50',
+ *						value: '50'
+ *					},
+ *					{
+ *						name: 'imageResize:75',
+ *						value: '75'
+ *					} ],
+ *					toolbar: [ 'imageResize', ... ],
+ *				}
+ *			} )
+ *			.then( ... )
+ *			.catch( ... );
+ *
+ * **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
+ *			.create( editorElement, {
+ *				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: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
+ */

+ 10 - 3
packages/ckeditor5-image/src/imageresize/imageresizecommand.js

@@ -51,8 +51,15 @@ export default class ImageResizeCommand extends Command {
 		const model = this.editor.model;
 		const imageElement = model.document.selection.getSelectedElement();
 
-		model.change( writer => {
-			writer.setAttribute( 'width', options.width, imageElement );
-		} );
+		this.value = {
+			width: options.width,
+			height: null
+		};
+
+		if ( imageElement ) {
+			model.change( writer => {
+				writer.setAttribute( 'width', options.width, imageElement );
+			} );
+		}
 	}
 }

+ 142 - 0
packages/ckeditor5-image/src/imageresize/imageresizeediting.js

@@ -0,0 +1,142 @@
+/**
+ * @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 image/imageresize/imageresizeediting
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import WidgetResize from '@ckeditor/ckeditor5-widget/src/widgetresize';
+import ImageResizeCommand from './imageresizecommand';
+
+/**
+ * The image resize feature.
+ *
+ * It adds a possibility to resize each image using handles or manually by
+ * {@link module:image/imageresize/imageresizeui~ImageResizeUI} buttons.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class ImageResizeEditing extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ WidgetResize ];
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'ImageResizeEditing';
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+		const command = new ImageResizeCommand( editor );
+
+		this._registerSchema();
+		this._registerConverters();
+
+		editor.commands.add( 'imageResize', command );
+
+		editor.editing.downcastDispatcher.on( 'insert:image', ( evt, data, conversionApi ) => {
+			const widget = conversionApi.mapper.toViewElement( data.item );
+
+			const resizer = editor.plugins
+				.get( WidgetResize )
+				.attachTo( {
+					unit: editor.config.get( 'image.resizeUnit' ) || '%',
+
+					modelElement: data.item,
+					viewElement: widget,
+					editor,
+
+					getHandleHost( domWidgetElement ) {
+						return domWidgetElement.querySelector( 'img' );
+					},
+					getResizeHost( domWidgetElement ) {
+						return domWidgetElement;
+					},
+					// TODO consider other positions.
+					isCentered() {
+						const imageStyle = data.item.getAttribute( 'imageStyle' );
+
+						return !imageStyle || imageStyle == 'full' || imageStyle == 'alignCenter';
+					},
+
+					onCommit( newValue ) {
+						editor.execute( 'imageResize', { width: newValue } );
+					}
+				} );
+
+			resizer.on( 'updateSize', () => {
+				if ( !widget.hasClass( 'image_resized' ) ) {
+					editor.editing.view.change( writer => {
+						writer.addClass( 'image_resized', widget );
+					} );
+				}
+			} );
+
+			resizer.bind( 'isEnabled' ).to( command );
+		}, { priority: 'low' } );
+	}
+
+	/**
+	 * @private
+	 */
+	_registerSchema() {
+		this.editor.model.schema.extend( 'image', {
+			allowAttributes: 'width'
+		} );
+	}
+
+	/**
+	 * Registers image resize converters.
+	 *
+	 * @private
+	 */
+	_registerConverters() {
+		const editor = this.editor;
+
+		// Dedicated converter to propagate image's attribute to the img tag.
+		editor.conversion.for( 'downcast' ).add( dispatcher =>
+			dispatcher.on( 'attribute:width:image', ( evt, data, conversionApi ) => {
+				if ( !conversionApi.consumable.consume( data.item, evt.name ) ) {
+					return;
+				}
+
+				const viewWriter = conversionApi.writer;
+				const figure = conversionApi.mapper.toViewElement( data.item );
+
+				if ( data.attributeNewValue !== null ) {
+					viewWriter.setStyle( 'width', data.attributeNewValue, figure );
+					viewWriter.addClass( 'image_resized', figure );
+				} else {
+					viewWriter.removeStyle( 'width', figure );
+					viewWriter.removeClass( 'image_resized', figure );
+				}
+			} )
+		);
+
+		editor.conversion.for( 'upcast' )
+			.attributeToAttribute( {
+				view: {
+					name: 'figure',
+					styles: {
+						width: /.+/
+					}
+				},
+				model: {
+					key: 'width',
+					value: viewElement => viewElement.getStyle( 'width' )
+				}
+			} );
+	}
+}

+ 290 - 0
packages/ckeditor5-image/src/imageresize/imageresizeui.js

@@ -0,0 +1,290 @@
+/**
+ * @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 image/imageresize/imageresizeui
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
+import ImageResizeEditing from './imageresizeediting';
+import { createDropdown, addListToDropdown } from '@ckeditor/ckeditor5-ui/src/dropdown/utils';
+import DropdownButtonView from '@ckeditor/ckeditor5-ui/src/dropdown/button/dropdownbuttonview';
+
+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 images using the toolbar dropdown or individual buttons, depending on the plugin configuration.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class ImageResizeUI extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ ImageResizeEditing ];
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		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
+	 */
+	init() {
+		const editor = this.editor;
+		const options = editor.config.get( 'image.resizeOptions' );
+		const command = editor.commands.get( 'imageResize' );
+
+		if ( !options ) {
+			return;
+		}
+
+		this.bind( 'isEnabled' ).to( command );
+
+		for ( const option of options ) {
+			this._registerImageResizeButton( option );
+		}
+
+		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.
+	 */
+	_registerImageResizeButton( option ) {
+		const editor = this.editor;
+		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 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( {
+				// 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', getIsOnButtonCallback( optionValueWithUnit ) );
+
+			this.listenTo( button, 'execute', () => {
+				editor.execute( 'imageResize', { width: optionValueWithUnit } );
+			} );
+
+			return button;
+		} );
+	}
+
+	/**
+	 * 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 configured options.
+	 */
+	_registerImageResizeDropdown( options ) {
+		const editor = this.editor;
+		const t = editor.t;
+		const originalSizeOption = options.find( option => !option.value );
+
+		// Register dropdown.
+		editor.ui.componentFactory.add( 'imageResize', locale => {
+			const command = editor.commands.get( 'imageResize' );
+			const dropdownView = createDropdown( locale, DropdownButtonView );
+			const dropdownButton = dropdownView.buttonView;
+
+			dropdownButton.set( {
+				tooltip: t( 'Resize image' ),
+				commandValue: originalSizeOption.value,
+				icon: iconMedium,
+				isToggleable: true,
+				label: this._getOptionLabelValue( originalSizeOption ),
+				withText: true,
+				class: 'ck-resize-image-button'
+			} );
+
+			dropdownButton.bind( 'label' ).to( command, 'value', commandValue => {
+				if ( commandValue && commandValue.width ) {
+					return commandValue.width;
+				} else {
+					return this._getOptionLabelValue( originalSizeOption );
+				}
+			} );
+			dropdownView.bind( 'isOn' ).to( command );
+			dropdownView.bind( 'isEnabled' ).to( this );
+
+			addListToDropdown( dropdownView, this._getResizeDropdownListItemDefinitions( options, command ) );
+
+			dropdownView.listView.ariaLabel = t( 'Image resize list' );
+
+			// Execute command when an item from the dropdown is selected.
+			this.listenTo( dropdownView, 'execute', evt => {
+				editor.execute( evt.source.commandName, { width: evt.source.commandValue } );
+				editor.editing.view.focus();
+			} );
+
+			return dropdownView;
+		} );
+	}
+
+	/**
+	 * 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' );
+			}
+		}
+	}
+
+	/**
+	 * 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;
+	}
+}
+
+// A helper function for setting the `isOn` state of buttons in value bindings.
+function getIsOnButtonCallback( value ) {
+	return commandValue => {
+		if ( value === null && commandValue === value ) {
+			return true;
+		}
+
+		return commandValue && commandValue.width === value;
+	};
+}
+
+/**
+ * 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} 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`}.
+ */

+ 18 - 0
packages/ckeditor5-image/tests/imageresize/imageresize.js

@@ -0,0 +1,18 @@
+/**
+ * @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 ImageResize from '../../src/imageresize';
+import ImageResizeUI from '../../src/imageresize/imageresizeui';
+import ImageResizeEditing from '../../src/imageresize/imageresizeediting';
+
+describe( 'ImageResize', () => {
+	it( 'should require "ImageResizeEditing" and "ImageResizeUI"', () => {
+		expect( ImageResize.requires ).to.deep.equal( [ ImageResizeEditing, ImageResizeUI ] );
+	} );
+
+	it( 'should be named', () => {
+		expect( ImageResize.pluginName ).to.equal( 'ImageResize' );
+	} );
+} );

+ 14 - 10
packages/ckeditor5-image/tests/imageresize.js → packages/ckeditor5-image/tests/imageresize/imageresizeediting.js

@@ -9,12 +9,12 @@
 import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
 
 import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
-import Image from '../src/image';
-import ImageResize from '../src/imageresize';
-import ImageResizeCommand from '../src/imageresize/imageresizecommand';
-import ImageStyle from '../src/imagestyle';
-import ImageToolbar from '../src/imagetoolbar';
-import ImageTextAlternative from '../src/imagetextalternative';
+import Image from '../../src/image';
+import ImageResizeEditing from '../../src/imageresize/imageresizeediting';
+import ImageResizeCommand from '../../src/imageresize/imageresizecommand';
+import ImageStyle from '../../src/imagestyle';
+import ImageToolbar from '../../src/imagetoolbar';
+import ImageTextAlternative from '../../src/imagetextalternative';
 import Undo from '@ckeditor/ckeditor5-undo/src/undo';
 import Table from '@ckeditor/ckeditor5-table/src/table';
 
@@ -30,7 +30,7 @@ import {
 
 import WidgetResize from '@ckeditor/ckeditor5-widget/src/widgetresize';
 
-describe( 'ImageResize', () => {
+describe( 'ImageResizeEditing', () => {
 	// 100x50 black png image
 	const IMAGE_SRC_FIXTURE = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAAAyCAQAAAAAPLY1AAAAQklEQVR42u3PQREAAAgDoK1/' +
 		'aM3g14MGNJMXKiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiJysRFNMgH0RpujAAAAAElFTkSuQmCC';
@@ -63,6 +63,10 @@ describe( 'ImageResize', () => {
 		}
 	} );
 
+	it( 'should be named', () => {
+		expect( ImageResizeEditing.pluginName ).to.equal( 'ImageResizeEditing' );
+	} );
+
 	describe( 'conversion', () => {
 		beforeEach( () => createEditor() );
 
@@ -195,7 +199,7 @@ describe( 'ImageResize', () => {
 
 	it( 'uses percents by default', async () => {
 		const localEditor = await createEditor( {
-			plugins: [ Image, ImageStyle, Paragraph, Undo, Table, ImageResize ]
+			plugins: [ Image, ImageStyle, Paragraph, Undo, Table, ImageResizeEditing ]
 		} );
 
 		const attachToSpy = sinon.spy( localEditor.plugins.get( WidgetResize ), 'attachTo' );
@@ -402,7 +406,7 @@ describe( 'ImageResize', () => {
 
 		beforeEach( async () => {
 			await createEditor( {
-				plugins: [ Image, ImageStyle, Paragraph, Undo, Table, ImageResize, ImageToolbar, ImageTextAlternative ],
+				plugins: [ Image, ImageStyle, Paragraph, Undo, Table, ImageResizeEditing, ImageToolbar, ImageTextAlternative ],
 				image: {
 					toolbar: [ 'imageTextAlternative' ],
 					resizeUnit: 'px'
@@ -481,7 +485,7 @@ describe( 'ImageResize', () => {
 
 		return ClassicEditor
 			.create( editorElement, config || {
-				plugins: [ Image, ImageStyle, Paragraph, Undo, Table, ImageResize ],
+				plugins: [ Image, ImageStyle, Paragraph, Undo, Table, ImageResizeEditing ],
 				image: {
 					resizeUnit: 'px'
 				}

+ 323 - 0
packages/ckeditor5-image/tests/imageresize/imageresizeui.js

@@ -0,0 +1,323 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* global document */
+
+import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import Image from '../../src/image';
+import DropdownView from '@ckeditor/ckeditor5-ui/src/dropdown/dropdownview';
+import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
+import View from '@ckeditor/ckeditor5-ui/src/view';
+import ImageResizeUI from '../../src/imageresize/imageresizeui';
+import ImageStyle from '../../src/imagestyle';
+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',
+		value: null
+	},
+	{
+		name: 'imageResize:25',
+		value: '25'
+	},
+	{
+		name: 'imageResize:50',
+		value: '50'
+	},
+	{
+		name: 'imageResize:75',
+		value: '75'
+	} ];
+
+	testUtils.createSinonSandbox();
+
+	beforeEach( async () => {
+		editorElement = document.createElement( 'div' );
+		document.body.appendChild( editorElement );
+
+		editor = await ClassicTestEditor
+			.create( editorElement, {
+				plugins: [ Image, ImageStyle, Paragraph, Undo, Table, ImageResizeUI ],
+				image: {
+					resizeOptions
+				}
+			} );
+
+		command = editor.commands.get( 'imageResize' );
+		plugin = editor.plugins.get( 'ImageResizeUI' );
+	} );
+
+	afterEach( async () => {
+		if ( editorElement ) {
+			editorElement.remove();
+		}
+
+		if ( editor ) {
+			await editor.destroy();
+		}
+	} );
+
+	describe( 'plugin', () => {
+		it( 'should be named', () => {
+			expect( ImageResizeUI.pluginName ).to.equal( 'ImageResizeUI' );
+		} );
+	} );
+
+	describe( 'constructor()', () => {
+		it( 'should create `_resizeUnit` with default value of `%`', () => {
+			const unit = plugin._resizeUnit;
+
+			expect( unit ).to.equal( '%' );
+		} );
+	} );
+
+	describe( 'init()', () => {
+		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;
+		} );
+
+		it( 'should not register a dropdown or buttons if no resize options passed', async () => {
+			const editor = await ClassicTestEditor
+				.create( editorElement, {
+					plugins: [ Image, ImageStyle, Paragraph, Undo, Table, ImageResizeUI ],
+					image: {
+						resizeUnit: 'px'
+					}
+				} );
+
+			const resizeOptions = editor.config.get( 'image.resizeOptions' );
+
+			expect( resizeOptions ).to.be.undefined;
+			expect( editor.ui.componentFactory.has( 'imageResize' ) ).to.be.false;
+
+			await editor.destroy();
+		} );
+	} );
+
+	describe( 'resize options dropdown', () => {
+		it( 'should be disabled when plugin is disabled', () => {
+			const dropdownView = editor.ui.componentFactory.create( 'imageResize' );
+
+			plugin.isEnabled = true;
+
+			expect( dropdownView.isEnabled ).to.be.true;
+
+			plugin.isEnabled = false;
+
+			expect( dropdownView.isEnabled ).to.be.false;
+		} );
+
+		it( 'should be an instance of `DropdownView` if component is created without a value suffix', () => {
+			expect( editor.ui.componentFactory.create( 'imageResize' ) ).to.be.instanceof( DropdownView );
+		} );
+
+		it( 'should have 4 resize options in the `imageResize` dropdown', () => {
+			const dropdownView = editor.ui.componentFactory.create( 'imageResize' );
+
+			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%' );
+		} );
+
+		it( 'should be created with a proper tooltip', () => {
+			const dropdownView = editor.ui.componentFactory.create( 'imageResize' );
+
+			expect( dropdownView.buttonView.tooltip ).to.equal( 'Resize image' );
+		} );
+
+		it( 'should execute resize command with a proper value', () => {
+			const dropdownView = editor.ui.componentFactory.create( 'imageResize' );
+			const commandSpy = sinon.spy( command, 'execute' );
+			const resizeBy50Percent = dropdownView.listView.items._items[ 1 ].children._items[ 0 ];
+
+			command.isEnabled = true;
+
+			resizeBy50Percent.fire( 'execute' );
+
+			sinon.assert.calledOnce( commandSpy );
+			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' );
+
+			plugin.isEnabled = true;
+
+			expect( buttonView.isEnabled ).to.be.true;
+
+			plugin.isEnabled = false;
+
+			expect( buttonView.isEnabled ).to.be.false;
+		} );
+
+		it( 'should be an instance of `ButtonView` if component is created with a value suffix', () => {
+			expect( editor.ui.componentFactory.create( 'imageResize:50' ) ).to.be.instanceof( ButtonView );
+		} );
+
+		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.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', () => {
+			const buttonViewOriginal = editor.ui.componentFactory.create( 'imageResize:original' );
+			const buttonView50 = editor.ui.componentFactory.create( 'imageResize:50' );
+
+			buttonViewOriginal.render();
+			buttonView50.render();
+
+			expect( buttonViewOriginal.tooltip ).to.equal( 'Resize image to the original size' );
+			expect( buttonView50.tooltip ).to.equal( 'Resize image to 50%' );
+		} );
+
+		it( 'should execute `imageResize` command with "50%" value', () => {
+			const buttonView = editor.ui.componentFactory.create( 'imageResize:50' );
+			const command = editor.commands.get( 'imageResize' );
+			const commandSpy = sinon.spy( command, 'execute' );
+
+			command.isEnabled = true;
+
+			buttonView.fire( 'execute' );
+
+			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();
+		} );
+	} );
+} );

+ 37 - 0
packages/ckeditor5-image/tests/manual/imageresizeui.html

@@ -0,0 +1,37 @@
+<style>
+	.fancy-editor-wrapper figure {
+		padding: 20px;
+	}
+
+	.ck-editor {
+		max-width: 800px;
+	}
+
+	h2.header {
+		background: hsl(0, 0%, 90%);
+	}
+</style>
+
+<h2 class="header">Dropdown</h2>
+
+<div id="editor1">
+	<h2>Responsive image (width style: not set, width attribute present on the image)</h2>
+
+	<figure class="image">
+		<img alt="Picture of the Warsaw Old Town." src="sample.jpg">
+	</figure>
+
+	<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla finibus consequat placerat. Vestibulum id telluset mauris sagittis tincidunt quis id mauris. Curabitur consectetur lectus sit amet tellus mattis, non lobortisleo interdum. </p>
+</div>
+
+<h2 class="header">Standalone buttons</h2>
+
+<div id="editor2">
+	<h2>Responsive image (width style: not set, width attribute present on the image)</h2>
+
+	<figure class="image">
+		<img alt="Picture of the Warsaw Old Town." src="sample.jpg">
+	</figure>
+
+	<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla finibus consequat placerat. Vestibulum id telluset mauris sagittis tincidunt quis id mauris. Curabitur consectetur lectus sit amet tellus mattis, non lobortisleo interdum. </p>
+</div>

+ 118 - 0
packages/ckeditor5-image/tests/manual/imageresizeui.js

@@ -0,0 +1,118 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* global document, console, window */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset';
+import ImageResize from '../../src/imageresize';
+import Indent from '@ckeditor/ckeditor5-indent/src/indent';
+import IndentBlock from '@ckeditor/ckeditor5-indent/src/indentblock';
+import EasyImage from '@ckeditor/ckeditor5-easy-image/src/easyimage';
+
+import { CS_CONFIG } from '@ckeditor/ckeditor5-cloud-services/tests/_utils/cloud-services-config';
+
+const commonConfig = {
+	plugins: [
+		ArticlePluginSet,
+		ImageResize,
+		Indent,
+		IndentBlock,
+		EasyImage
+	],
+	toolbar: [ 'heading', '|', 'bold', 'italic', 'link',
+		'bulletedList', 'numberedList', 'blockQuote', 'insertTable', 'undo', 'redo', 'outdent', 'indent' ],
+	table: {
+		contentToolbar: [ 'tableColumn', 'tableRow', 'mergeTableCells' ],
+		tableToolbar: [ 'bold', 'italic' ]
+	},
+	cloudServices: CS_CONFIG
+};
+
+const imageConfig1 = {
+	resizeUnit: '%',
+	resizeOptions: [
+		{
+			name: 'imageResize:original',
+			value: null
+		},
+		{
+			name: 'imageResize:50',
+			value: '50'
+		},
+		{
+			name: 'imageResize:75',
+			value: '75'
+		}
+	],
+	toolbar: [ 'imageStyle:alignLeft', 'imageStyle:full', 'imageStyle:side', '|', 'imageResize' ],
+	styles: [
+		'full',
+		'alignLeft',
+		'side' // Purposely using side image instead right aligned image to make sure it works well with both style types.
+	]
+};
+
+const config1 = {
+	...commonConfig,
+	image: imageConfig1
+};
+
+ClassicEditor
+	.create( document.querySelector( '#editor1' ), config1 )
+	.then( editor => {
+		window.editor1 = editor;
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );
+
+const imageConfig2 = {
+	resizeUnit: '%',
+	resizeOptions: [
+		{
+			name: 'imageResize:original',
+			value: null,
+			icon: 'original'
+		},
+		{
+			name: 'imageResize:50',
+			value: '50',
+			icon: 'medium'
+		},
+		{
+			name: 'imageResize:75',
+			value: '75',
+			icon: 'large'
+		}
+	],
+	toolbar: [
+		'imageStyle:alignLeft',
+		'imageStyle:full',
+		'imageStyle:side', '|',
+		'imageResize:50',
+		'imageResize:75',
+		'imageResize:original'
+	],
+	styles: [
+		'full',
+		'alignLeft',
+		'side' // Purposely using side image instead right aligned image to make sure it works well with both style types.
+	]
+};
+
+const config2 = {
+	...commonConfig,
+	image: imageConfig2
+};
+
+ClassicEditor
+	.create( document.querySelector( '#editor2' ), config2 )
+	.then( editor => {
+		window.editor2 = editor;
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );

+ 20 - 0
packages/ckeditor5-image/tests/manual/imageresizeui.md

@@ -0,0 +1,20 @@
+## Image Resize UI
+
+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",

+ 135 - 1
packages/ckeditor5-link/src/linkediting.js

@@ -10,6 +10,8 @@
 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';
@@ -44,7 +46,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 +113,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();
 	}
 
 	/**
@@ -414,4 +420,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.
  *

+ 13 - 0
packages/ckeditor5-link/src/linkimageediting.js

@@ -11,6 +11,8 @@ import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import ImageEditing from '@ckeditor/ckeditor5-image/src/image/imageediting';
 import LinkEditing from './linkediting';
 
+import linkIcon from '../theme/icons/link.svg';
+
 /**
  * The link image engine feature.
  *
@@ -115,6 +117,14 @@ function downcastImageLink() {
 			// But we need to check whether the link element exists.
 			const linkInImage = Array.from( viewFigure.getChildren() ).find( child => child.name === 'a' );
 
+			// Create an icon indicator for a linked image.
+			const 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,6 +145,9 @@ 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.
+				writer.insert( writer.createPositionAt( linkElement, 'end' ), linkIconIndicator );
 			}
 		} );
 	};

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

+ 351 - 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 */
 
@@ -869,7 +873,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 +896,11 @@ describe( 'LinkEditing', () => {
 
 			model = editor.model;
 			view = editor.editing.view;
+
+			model.schema.extend( '$text', {
+				allowIn: '$root',
+				allowAttributes: [ 'linkIsFoo', 'linkIsBar' ]
+			} );
 		} );
 
 		afterEach( async () => {
@@ -1033,11 +1042,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 +1060,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() {}
+			};
+		}
+	} );
 } );

+ 20 - 3
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;
 
@@ -48,7 +54,9 @@ describe( 'LinkImageEditing', () => {
 				setModelData( model, '<image src="/assets/sample.png" alt="alt text" linkHref="http://ckeditor.com"></image>' );
 
 				expect( editor.getData() ).to.equal(
-					'<figure class="image"><a href="http://ckeditor.com"><img alt="alt text" src="/assets/sample.png"></a></figure>'
+					'<figure class="image"><a href="http://ckeditor.com"><img alt="alt text" src="/assets/sample.png">' +
+					linkIconInditatorElement +
+					'</a></figure>'
 				);
 			} );
 
@@ -56,7 +64,9 @@ describe( 'LinkImageEditing', () => {
 				setModelData( model, '<image src="/assets/sample.png" linkHref="http://ckeditor.com"></image>' );
 
 				expect( editor.getData() ).to.equal(
-					'<figure class="image"><a href="http://ckeditor.com"><img src="/assets/sample.png"></a></figure>'
+					'<figure class="image"><a href="http://ckeditor.com"><img src="/assets/sample.png">' +
+					linkIconInditatorElement +
+					'</a></figure>'
 				);
 			} );
 
@@ -72,6 +82,7 @@ describe( 'LinkImageEditing', () => {
 					'<figure class="image">' +
 						'<a href="http://ckeditor.com">' +
 							'<img sizes="100vw" src="/assets/sample.png" srcset="small.png 148w, big.png 1024w"></img>' +
+							linkIconInditatorElement +
 						'</a>' +
 					'</figure>'
 				);
@@ -264,6 +275,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 +294,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 +317,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 +334,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">' +

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

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

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

+ 59 - 62
packages/ckeditor5-list/src/todolistconverters.js

@@ -9,7 +9,7 @@
 
 /* global document */
 
-import { generateLiInUl, injectViewList } from './utils';
+import { generateLiInUl, injectViewList, positionAfterUiElements, findNestedList } from './utils';
 import createElement from '@ckeditor/ckeditor5-utils/src/dom/createelement';
 
 /**
@@ -22,6 +22,7 @@ import createElement from '@ckeditor/ckeditor5-utils/src/dom/createelement';
  *
  * @see module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:insert
  * @param {module:engine/model/model~Model} model Model instance.
+ * @param {Function} onCheckboxChecked Callback function.
  * @returns {Function} Returns a conversion callback.
  */
 export function modelViewInsertion( model, onCheckboxChecked ) {
@@ -52,8 +53,13 @@ export function modelViewInsertion( model, onCheckboxChecked ) {
 		const isChecked = !!modelItem.getAttribute( 'todoListChecked' );
 		const checkmarkElement = createCheckmarkElement( modelItem, viewWriter, isChecked, onCheckboxChecked );
 
+		const span = viewWriter.createContainerElement( 'span', {
+			class: 'todo-list__label__description'
+		} );
+
 		viewWriter.addClass( 'todo-list', viewItem.parent );
 		viewWriter.insert( viewWriter.createPositionAt( viewItem, 0 ), checkmarkElement );
+		viewWriter.insert( viewWriter.createPositionAfter( checkmarkElement ), span );
 
 		injectViewList( modelItem, viewItem, conversionApi, model );
 	};
@@ -83,17 +89,19 @@ export function dataModelViewInsertion( model ) {
 			return;
 		}
 
-		consumable.consume( data.item, 'insert' );
-		consumable.consume( data.item, 'attribute:listType' );
-		consumable.consume( data.item, 'attribute:listIndent' );
+		const modelItem = data.item;
+
+		consumable.consume( modelItem, 'insert' );
+		consumable.consume( modelItem, 'attribute:listType' );
+		consumable.consume( modelItem, 'attribute:listIndent' );
+		consumable.consume( modelItem, 'attribute:todoListChecked' );
 
 		const viewWriter = conversionApi.writer;
-		const modelItem = data.item;
 		const viewItem = generateLiInUl( modelItem, conversionApi );
 
 		viewWriter.addClass( 'todo-list', viewItem.parent );
 
-		const label = viewWriter.createAttributeElement( 'label', {
+		const label = viewWriter.createContainerElement( 'label', {
 			class: 'todo-list__label'
 		} );
 
@@ -102,51 +110,22 @@ export function dataModelViewInsertion( model ) {
 			disabled: 'disabled'
 		} );
 
-		if ( data.item.getAttribute( 'todoListChecked' ) ) {
+		const span = viewWriter.createContainerElement( 'span', {
+			class: 'todo-list__label__description'
+		} );
+
+		if ( modelItem.getAttribute( 'todoListChecked' ) ) {
 			viewWriter.setAttribute( 'checked', 'checked', checkbox );
-			viewWriter.addClass( 'todo-list__label', label );
 		}
 
-		viewWriter.insert( viewWriter.createPositionAt( viewItem, 0 ), checkbox );
-		viewWriter.wrap( viewWriter.createRangeOn( checkbox ), label );
+		viewWriter.insert( viewWriter.createPositionAt( viewItem, 0 ), label );
+		viewWriter.insert( viewWriter.createPositionAt( label, 0 ), checkbox );
+		viewWriter.insert( viewWriter.createPositionAfter( checkbox ), span );
 
 		injectViewList( modelItem, viewItem, conversionApi, model );
 	};
 }
 
-/**
- * A model-to-view converter for the model `$text` element inside a to-do list item.
- *
- * It is used by {@link module:engine/controller/datacontroller~DataController}.
- *
- * @see module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:insert
- * @param {module:utils/eventinfo~EventInfo} evt An object containing information about the fired event.
- * @param {Object} data Additional information about the change.
- * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi Conversion interface.
- */
-export function dataModelViewTextInsertion( evt, data, conversionApi ) {
-	const parent = data.range.start.parent;
-
-	if ( parent.name != 'listItem' || parent.getAttribute( 'listType' ) != 'todo' ) {
-		return;
-	}
-
-	if ( !conversionApi.consumable.consume( data.item, 'insert' ) ) {
-		return;
-	}
-
-	const viewWriter = conversionApi.writer;
-	const viewPosition = conversionApi.mapper.toViewPosition( data.range.start );
-	const viewText = viewWriter.createText( data.item.data );
-
-	const span = viewWriter.createAttributeElement( 'span', { class: 'todo-list__label__description' } );
-	const label = viewPosition.parent.getChild( 0 );
-
-	viewWriter.insert( viewWriter.createPositionAt( viewPosition.parent, 'end' ), viewText );
-	viewWriter.wrap( viewWriter.createRangeOn( viewText ), span );
-	viewWriter.wrap( viewWriter.createRangeOn( viewText.parent ), label );
-}
-
 /**
  * A view-to-model converter for the checkbox element inside a view list item.
  *
@@ -198,6 +177,7 @@ export function dataViewModelCheckmarkInsertion( evt, data, conversionApi ) {
  *
  * @see module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:attribute
  * @param {Function} onCheckedChange Callback fired after clicking the checkbox UI element.
+ * @param {module:engine/view/view~View} view Editing view controller.
  * @returns {Function} Returns a conversion callback.
  */
 export function modelViewChangeType( onCheckedChange, view ) {
@@ -205,15 +185,34 @@ export function modelViewChangeType( onCheckedChange, view ) {
 		const viewItem = conversionApi.mapper.toViewElement( data.item );
 		const viewWriter = conversionApi.writer;
 
+		const labelElement = findLabel( viewItem, view );
+
 		if ( data.attributeNewValue == 'todo' ) {
 			const isChecked = !!data.item.getAttribute( 'todoListChecked' );
 			const checkmarkElement = createCheckmarkElement( data.item, viewWriter, isChecked, onCheckedChange );
 
+			const span = viewWriter.createContainerElement( 'span', {
+				class: 'todo-list__label__description'
+			} );
+
+			const itemRange = viewWriter.createRangeIn( viewItem );
+			const nestedList = findNestedList( viewItem );
+
+			const descriptionStart = positionAfterUiElements( itemRange.start );
+			const descriptionEnd = nestedList ? viewWriter.createPositionBefore( nestedList ) : itemRange.end;
+			const descriptionRange = viewWriter.createRange( descriptionStart, descriptionEnd );
+
 			viewWriter.addClass( 'todo-list', viewItem.parent );
+			viewWriter.move( descriptionRange, viewWriter.createPositionAt( span, 0 ) );
 			viewWriter.insert( viewWriter.createPositionAt( viewItem, 0 ), checkmarkElement );
+			viewWriter.insert( viewWriter.createPositionAfter( checkmarkElement ), span );
 		} else if ( data.attributeOldValue == 'todo' ) {
+			const descriptionSpan = findDescription( viewItem, view );
+
 			viewWriter.removeClass( 'todo-list', viewItem.parent );
-			viewWriter.remove( findLabel( viewItem, view ) );
+			viewWriter.remove( labelElement );
+			viewWriter.move( viewWriter.createRangeIn( descriptionSpan ), viewWriter.createPositionBefore( descriptionSpan ) );
+			viewWriter.remove( descriptionSpan );
 		}
 	};
 }
@@ -261,34 +260,22 @@ export function modelViewChangeChecked( onCheckedChange ) {
  * It only handles the position at the beginning of a list item as other positions are properly mapped be the default mapper.
  *
  * @param {module:engine/view/view~View} view
- * @param {module:engine/conversion/mapper~Mapper} mapper
  * @return {Function}
  */
-export function mapModelToViewZeroOffsetPosition( view, mapper ) {
+export function mapModelToViewPosition( view ) {
 	return ( evt, data ) => {
 		const modelPosition = data.modelPosition;
 		const parent = modelPosition.parent;
 
-		// Handle only position at the beginning of a todo list item.
-		if ( !parent.is( 'listItem' ) || parent.getAttribute( 'listType' ) != 'todo' || modelPosition.offset !== 0 ) {
+		if ( !parent.is( 'listItem' ) || parent.getAttribute( 'listType' ) != 'todo' ) {
 			return;
 		}
 
-		const viewLi = mapper.toViewElement( parent );
-		const label = findLabel( viewLi, view );
+		const viewLi = data.mapper.toViewElement( parent );
+		const descSpan = findDescription( viewLi, view );
 
-		// If there is no label then most probably the default converter was overridden.
-		if ( !label ) {
-			return;
-		}
-
-		// Map the position to the next sibling (if it is not a marker) - most likely it will be a text node...
-		if ( label.nextSibling && !label.nextSibling.is( 'uiElement' ) ) {
-			data.viewPosition = view.createPositionAt( label.nextSibling, 0 );
-		}
-		// ... otherwise return position after the label.
-		else {
-			data.viewPosition = view.createPositionAfter( label );
+		if ( descSpan ) {
+			data.viewPosition = data.mapper.findPositionIn( descSpan, modelPosition.offset );
 		}
 	};
 }
@@ -338,3 +325,13 @@ function findLabel( viewItem, view ) {
 		}
 	}
 }
+
+function findDescription( viewItem, view ) {
+	const range = view.createRangeIn( viewItem );
+
+	for ( const value of range ) {
+		if ( value.item.is( 'containerElement', 'span' ) && value.item.hasClass( 'todo-list__label__description' ) ) {
+			return value.item;
+		}
+	}
+}

+ 33 - 25
packages/ckeditor5-list/src/todolistediting.js

@@ -15,13 +15,13 @@ import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 
 import {
 	dataModelViewInsertion,
-	dataModelViewTextInsertion,
 	dataViewModelCheckmarkInsertion,
-	mapModelToViewZeroOffsetPosition,
+	mapModelToViewPosition,
 	modelViewChangeChecked,
 	modelViewChangeType,
 	modelViewInsertion
 } from './todolistconverters';
+import { getLocalizedArrowKeyCodeDirection } from '@ckeditor/ckeditor5-utils/src/keyboard';
 
 /**
  * The engine of the to-do list feature. It handles creating, editing and removing to-do lists and their items.
@@ -76,7 +76,7 @@ export default class TodoListEditing extends Plugin {
 
 		// Define converters.
 		data.downcastDispatcher.on( 'insert:listItem', dataModelViewInsertion( model ), { priority: 'high' } );
-		data.downcastDispatcher.on( 'insert:$text', dataModelViewTextInsertion, { priority: 'high' } );
+		data.upcastDispatcher.on( 'element:input', dataViewModelCheckmarkInsertion, { priority: 'high' } );
 
 		editing.downcastDispatcher.on(
 			'insert:listItem',
@@ -92,9 +92,8 @@ export default class TodoListEditing extends Plugin {
 			modelViewChangeChecked( listItem => this._handleCheckmarkChange( listItem ) )
 		);
 
-		editing.mapper.on( 'modelToViewPosition', mapModelToViewZeroOffsetPosition( editing.view, editing.mapper ) );
-
-		data.upcastDispatcher.on( 'element:input', dataViewModelCheckmarkInsertion, { priority: 'high' } );
+		editing.mapper.on( 'modelToViewPosition', mapModelToViewPosition( editing.view ) );
+		data.mapper.on( 'modelToViewPosition', mapModelToViewPosition( editing.view ) );
 
 		// Jump at the end of the previous node on left arrow key press, when selection is after the checkbox.
 		//
@@ -106,10 +105,7 @@ export default class TodoListEditing extends Plugin {
 		// <blockquote><p>Foo{}</p></blockquote>
 		// <ul><li><checkbox/>Bar</li></ul>
 		//
-		// Note: When content language direction is RTL, the behaviour is mirrored.
-		const localizedJumpOverCheckmarkKey = editor.locale.contentLanguageDirection === 'ltr' ? 'arrowleft' : 'arrowright';
-
-		editor.keystrokes.set( localizedJumpOverCheckmarkKey, ( evt, stop ) => jumpOverCheckmarkOnSideArrowKeyPress( stop, model ) );
+		this.listenTo( editing.view.document, 'keydown', jumpOverCheckmarkOnSideArrowKeyPress( model, editor.locale ) );
 
 		// Toggle check state of selected to-do list items on keystroke.
 		editor.keystrokes.set( 'Ctrl+space', () => editor.execute( 'todoListCheck' ) );
@@ -178,25 +174,37 @@ export default class TodoListEditing extends Plugin {
 // moving the selection to the left/right (LTR/RTL).
 //
 // @private
-// @param {Function} stopKeyEvent
 // @param {module:engine/model/model~Model} model
-function jumpOverCheckmarkOnSideArrowKeyPress( stopKeyEvent, model ) {
-	const schema = model.schema;
-	const selection = model.document.selection;
+// @param {module:utils/locale~Locale} locale
+// @returns {Function} Callback for 'keydown' events.
+function jumpOverCheckmarkOnSideArrowKeyPress( model, locale ) {
+	return ( eventInfo, domEventData ) => {
+		const direction = getLocalizedArrowKeyCodeDirection( domEventData.keyCode, locale.contentLanguageDirection );
+
+		if ( direction != 'left' ) {
+			return;
+		}
 
-	if ( !selection.isCollapsed ) {
-		return;
-	}
+		const schema = model.schema;
+		const selection = model.document.selection;
 
-	const position = selection.getFirstPosition();
-	const parent = position.parent;
+		if ( !selection.isCollapsed ) {
+			return;
+		}
+
+		const position = selection.getFirstPosition();
+		const parent = position.parent;
 
-	if ( parent.name === 'listItem' && parent.getAttribute( 'listType' ) == 'todo' && position.isAtStart ) {
-		const newRange = schema.getNearestSelectionRange( model.createPositionBefore( parent ), 'backward' );
+		if ( parent.name === 'listItem' && parent.getAttribute( 'listType' ) == 'todo' && position.isAtStart ) {
+			const newRange = schema.getNearestSelectionRange( model.createPositionBefore( parent ), 'backward' );
 
-		if ( newRange ) {
-			stopKeyEvent();
-			model.change( writer => writer.setSelection( newRange ) );
+			if ( newRange ) {
+				model.change( writer => writer.setSelection( newRange ) );
+			}
+
+			domEventData.preventDefault();
+			domEventData.stopPropagation();
+			eventInfo.stop();
 		}
-	}
+	};
 }

+ 29 - 0
packages/ckeditor5-list/src/utils.js

@@ -88,6 +88,19 @@ export function injectViewList( modelItem, injectedItem, conversionApi, model )
 			// If it is a list item, it has to have a lower indent.
 			// It means that the inserted item should be added to it as its nested item.
 			insertPosition = mapper.toViewPosition( model.createPositionAt( prevItem, 'end' ) );
+
+			// There could be some not mapped elements (eg. span in to-do list) but we need to insert
+			// a nested list directly inside the li element.
+			const mappedViewAncestor = mapper.findMappedViewAncestor( insertPosition );
+			const nestedList = findNestedList( mappedViewAncestor );
+
+			// If there already is some nested list, then use it's position.
+			if ( nestedList ) {
+				insertPosition = viewWriter.createPositionBefore( nestedList );
+			} else {
+				// Else just put new list on the end of list item content.
+				insertPosition = viewWriter.createPositionAt( mappedViewAncestor, 'end' );
+			}
 		} else {
 			// The previous item is not a list item (or does not exist at all).
 			// Just map the position and insert the view item at the mapped position.
@@ -250,6 +263,22 @@ export function createUIComponent( editor, commandName, label, icon ) {
 	} );
 }
 
+/**
+ * Returns a first list view element that is direct child of the given view element.
+ *
+ * @param {module:engine/view/element~Element} viewElement
+ * @return {module:engine/view/element~Element|null}
+ */
+export function findNestedList( viewElement ) {
+	for ( const node of viewElement.getChildren() ) {
+		if ( node.name == 'ul' || node.name == 'ol' ) {
+			return node;
+		}
+	}
+
+	return null;
+}
+
 // Implementation of getFillerOffset for view list item element.
 //
 // @returns {Number|null} Block filler offset or `null` if block filler is not needed.

+ 4 - 2
packages/ckeditor5-list/tests/manual/todo-list.js

@@ -16,13 +16,15 @@ import Table from '@ckeditor/ckeditor5-table/src/table';
 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 List from '../../src/list';
+import ShiftEnter from '@ckeditor/ckeditor5-enter/src/shiftenter';
 import FontSize from '@ckeditor/ckeditor5-font/src/fontsize';
+
+import List from '../../src/list';
 import TodoList from '../../src/todolist';
 
 ClassicEditor
 	.create( document.querySelector( '#editor' ), {
-		plugins: [ Enter, Typing, Heading, Highlight, Table, Bold, Paragraph, Undo, List, TodoList, Clipboard, Link, FontSize ],
+		plugins: [ Enter, Typing, Heading, Highlight, Table, Bold, Paragraph, Undo, List, TodoList, Clipboard, Link, FontSize, ShiftEnter ],
 		toolbar: [
 			'heading',
 			'|',

+ 189 - 38
packages/ckeditor5-list/tests/todolistediting.js

@@ -13,6 +13,8 @@ import TodoListCheckCommand from '../src/todolistcheckcommand';
 import ModelElement from '@ckeditor/ckeditor5-engine/src/model/element';
 import InlineEditableUIView from '@ckeditor/ckeditor5-ui/src/editableui/inline/inlineeditableuiview';
 import LinkEditing from '@ckeditor/ckeditor5-link/src/linkediting';
+import Enter from '@ckeditor/ckeditor5-enter/src/enter';
+import ShiftEnter from '@ckeditor/ckeditor5-enter/src/shiftenter';
 
 import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
 import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
@@ -29,7 +31,7 @@ describe( 'TodoListEditing', () => {
 	beforeEach( () => {
 		return VirtualTestEditor
 			.create( {
-				plugins: [ TodoListEditing, Typing, BoldEditing, BlockQuoteEditing, LinkEditing ]
+				plugins: [ TodoListEditing, Typing, BoldEditing, BlockQuoteEditing, LinkEditing, Enter, ShiftEnter ]
 			} )
 			.then( newEditor => {
 				editor = newEditor;
@@ -87,7 +89,9 @@ describe( 'TodoListEditing', () => {
 			assertEqualMarkup( getModelData( model ), '<listItem listIndent="0" listType="todo">[]</listItem>' );
 			assertEqualMarkup( getViewData( view ),
 				'<ul class="todo-list">' +
-					'<li><label class="todo-list__label" contenteditable="false"></label>[]</li>' +
+					'<li><label class="todo-list__label" contenteditable="false"></label>' +
+						'<span class="todo-list__label__description">[]</span>' +
+					'</li>' +
 				'</ul>'
 			);
 
@@ -96,7 +100,9 @@ describe( 'TodoListEditing', () => {
 			assertEqualMarkup( getModelData( model ), '<listItem listIndent="0" listType="todo">a[]</listItem>' );
 			assertEqualMarkup( getViewData( view ),
 				'<ul class="todo-list">' +
-					'<li><label class="todo-list__label" contenteditable="false"></label>a{}</li>' +
+					'<li><label class="todo-list__label" contenteditable="false"></label>' +
+						'<span class="todo-list__label__description">a{}</span>' +
+					'</li>' +
 				'</ul>'
 			);
 
@@ -105,7 +111,9 @@ describe( 'TodoListEditing', () => {
 			assertEqualMarkup( getModelData( model ), '<listItem listIndent="0" listType="todo">ab[]</listItem>' );
 			assertEqualMarkup( getViewData( view ),
 				'<ul class="todo-list">' +
-					'<li><label class="todo-list__label" contenteditable="false"></label>ab{}</li>' +
+					'<li><label class="todo-list__label" contenteditable="false"></label>' +
+						'<span class="todo-list__label__description">ab{}</span>' +
+					'</li>' +
 				'</ul>'
 			);
 
@@ -129,8 +137,12 @@ describe( 'TodoListEditing', () => {
 
 			assertEqualMarkup( getViewData( view ),
 				'<ul class="todo-list">' +
-					'<li><label class="todo-list__label" contenteditable="false"></label>{}1</li>' +
-					'<li><label class="todo-list__label" contenteditable="false"></label>2</li>' +
+					'<li><label class="todo-list__label" contenteditable="false"></label>' +
+						'<span class="todo-list__label__description">{}1</span>' +
+					'</li>' +
+					'<li><label class="todo-list__label" contenteditable="false"></label>' +
+						'<span class="todo-list__label__description">2</span>' +
+					'</li>' +
 				'</ul>'
 			);
 		} );
@@ -148,17 +160,31 @@ describe( 'TodoListEditing', () => {
 			assertEqualMarkup( getViewData( view ),
 				'<ul class="todo-list">' +
 					'<li>' +
-						'<label class="todo-list__label" contenteditable="false"></label>{}1.0' +
+						'<label class="todo-list__label" contenteditable="false"></label>' +
+						'<span class="todo-list__label__description">{}1.0</span>' +
 						'<ul class="todo-list">' +
-							'<li><label class="todo-list__label" contenteditable="false"></label>2.1</li>' +
 							'<li>' +
-								'<label class="todo-list__label" contenteditable="false"></label>3.1' +
+								'<label class="todo-list__label" contenteditable="false"></label>' +
+								'<span class="todo-list__label__description">2.1</span>' +
+							'</li>' +
+							'<li>' +
+								'<label class="todo-list__label" contenteditable="false"></label>' +
+								'<span class="todo-list__label__description">3.1</span>' +
 								'<ul class="todo-list">' +
-									'<li><label class="todo-list__label" contenteditable="false"></label>4.2</li>' +
-									'<li><label class="todo-list__label" contenteditable="false"></label>5.2</li>' +
+									'<li>' +
+										'<label class="todo-list__label" contenteditable="false"></label>' +
+										'<span class="todo-list__label__description">4.2</span>' +
+									'</li>' +
+									'<li>' +
+										'<label class="todo-list__label" contenteditable="false"></label>' +
+										'<span class="todo-list__label__description">5.2</span>' +
+									'</li>' +
 								'</ul>' +
 							'</li>' +
-							'<li><label class="todo-list__label" contenteditable="false"></label>6.1</li>' +
+							'<li>' +
+								'<label class="todo-list__label" contenteditable="false"></label>' +
+								'<span class="todo-list__label__description">6.1</span>' +
+							'</li>' +
 						'</ul>' +
 					'</li>' +
 				'</ul>'
@@ -176,18 +202,25 @@ describe( 'TodoListEditing', () => {
 
 			assertEqualMarkup( getViewData( view ),
 				'<ul class="todo-list">' +
-					'<li><label class="todo-list__label" contenteditable="false"></label>{}1.0</li>' +
+					'<li>' +
+						'<label class="todo-list__label" contenteditable="false"></label>' +
+						'<span class="todo-list__label__description">{}1.0</span>' +
+					'</li>' +
 				'</ul>' +
 				'<ul>' +
 					'<li>2.0' +
 						'<ul class="todo-list">' +
 							'<li>' +
-								'<label class="todo-list__label" contenteditable="false"></label>3.1' +
+								'<label class="todo-list__label" contenteditable="false"></label>' +
+								'<span class="todo-list__label__description">3.1</span>' +
 								'<ul>' +
 									'<li>4.2</li>' +
 								'</ul>' +
 							'</li>' +
-							'<li><label class="todo-list__label" contenteditable="false"></label>5.1</li>' +
+							'<li>' +
+								'<label class="todo-list__label" contenteditable="false"></label>' +
+								'<span class="todo-list__label__description">5.1</span>' +
+							'</li>' +
 						'</ul>' +
 					'</li>' +
 				'</ul>'
@@ -205,18 +238,25 @@ describe( 'TodoListEditing', () => {
 
 			assertEqualMarkup( getViewData( view ),
 				'<ul class="todo-list">' +
-					'<li><label class="todo-list__label" contenteditable="false"></label>{}1.0</li>' +
+					'<li>' +
+						'<label class="todo-list__label" contenteditable="false"></label>' +
+						'<span class="todo-list__label__description">{}1.0</span>' +
+					'</li>' +
 				'</ul>' +
 				'<ol>' +
 					'<li>2.0' +
 						'<ul class="todo-list">' +
 							'<li>' +
-								'<label class="todo-list__label" contenteditable="false"></label>3.1' +
+								'<label class="todo-list__label" contenteditable="false"></label>' +
+								'<span class="todo-list__label__description">3.1</span>' +
 								'<ol>' +
 									'<li>4.2</li>' +
 								'</ol>' +
 							'</li>' +
-							'<li><label class="todo-list__label" contenteditable="false"></label>5.1</li>' +
+							'<li>' +
+								'<label class="todo-list__label" contenteditable="false"></label>' +
+								'<span class="todo-list__label__description">5.1</span>' +
+							'</li>' +
 						'</ul>' +
 					'</li>' +
 				'</ol>'
@@ -237,7 +277,10 @@ describe( 'TodoListEditing', () => {
 					'<li>1.0</li>' +
 				'</ol>' +
 				'<ul class="todo-list">' +
-					'<li><label class="todo-list__label" contenteditable="false"></label>{}2.0</li>' +
+					'<li>' +
+						'<label class="todo-list__label" contenteditable="false"></label>' +
+						'<span class="todo-list__label__description">{}2.0</span>' +
+					'</li>' +
 				'</ul>' +
 				'<ol>' +
 					'<li>3.0</li>' +
@@ -256,13 +299,19 @@ describe( 'TodoListEditing', () => {
 
 			assertEqualMarkup( getViewData( view ),
 				'<ul class="todo-list">' +
-					'<li><label class="todo-list__label" contenteditable="false"></label>1.0</li>' +
+					'<li>' +
+						'<label class="todo-list__label" contenteditable="false"></label>' +
+						'<span class="todo-list__label__description">1.0</span>' +
+					'</li>' +
 				'</ul>' +
 				'<ol>' +
 					'<li>{}2.0</li>' +
 				'</ol>' +
 				'<ul class="todo-list">' +
-					'<li><label class="todo-list__label" contenteditable="false"></label>3.0</li>' +
+					'<li>' +
+						'<label class="todo-list__label" contenteditable="false"></label>' +
+						'<span class="todo-list__label__description">3.0</span>' +
+					'</li>' +
 				'</ul>'
 			);
 		} );
@@ -278,17 +327,71 @@ describe( 'TodoListEditing', () => {
 
 			assertEqualMarkup( getViewData( view ),
 				'<ul class="todo-list">' +
-					'<li><label class="todo-list__label" contenteditable="false"></label>1.0</li>' +
+					'<li>' +
+						'<label class="todo-list__label" contenteditable="false"></label>' +
+						'<span class="todo-list__label__description">1.0</span>' +
+					'</li>' +
 				'</ul>' +
 				'<ul>' +
 					'<li>{}2.0</li>' +
 				'</ul>' +
 				'<ul class="todo-list">' +
-					'<li><label class="todo-list__label" contenteditable="false"></label>3.0</li>' +
+					'<li>' +
+						'<label class="todo-list__label" contenteditable="false"></label>' +
+						'<span class="todo-list__label__description">3.0</span>' +
+					'</li>' +
 				'</ul>'
 			);
 		} );
 
+		it( 'should properly convert list type change (when next list item is nested)', () => {
+			setModelData( model,
+				'<listItem listType="todo" listIndent="0">1.0</listItem>' +
+				'<listItem listType="numbered" listIndent="0">[]2.0</listItem>' +
+				'<listItem listType="todo" listIndent="1">3.0</listItem>'
+			);
+
+			assertEqualMarkup( getViewData( view ),
+				'<ul class="todo-list">' +
+					'<li>' +
+						'<label class="todo-list__label" contenteditable="false"></label>' +
+						'<span class="todo-list__label__description">1.0</span>' +
+					'</li>' +
+				'</ul>' +
+				'<ol>' +
+					'<li>' +
+						'{}2.0' +
+						'<ul class="todo-list">' +
+							'<li>' +
+								'<label class="todo-list__label" contenteditable="false"></label>' +
+								'<span class="todo-list__label__description">3.0</span>' +
+							'</li>' +
+						'</ul>' +
+					'</li>' +
+				'</ol>'
+			);
+
+			editor.execute( 'todoList' );
+
+			assertEqualMarkup( getViewData( view ),
+				'<ul class="todo-list">' +
+					'<li>' +
+						'<label class="todo-list__label" contenteditable="false"></label>' +
+						'<span class="todo-list__label__description">1.0</span>' +
+					'</li>' +
+					'<li>' +
+						'<label class="todo-list__label" contenteditable="false"></label>' +
+						'<span class="todo-list__label__description">{}2.0</span>' +
+						'<ul class="todo-list">' +
+							'<li>' +
+								'<label class="todo-list__label" contenteditable="false"></label>' +
+								'<span class="todo-list__label__description">3.0</span>' +
+							'</li>' +
+						'</ul>' +
+					'</li>' +
+				'</ul>'
+			);
+		} );
 		it( 'should properly convert list type change - inner text with attribute', () => {
 			setModelData( model,
 				'<listItem listType="todo" listIndent="0">1[.0</listItem>' +
@@ -330,7 +433,10 @@ describe( 'TodoListEditing', () => {
 
 			assertEqualMarkup( getViewData( view ),
 				'<ul class="todo-list">' +
-					'<li><label class="todo-list__label" contenteditable="false"></label>{}1.0</li>' +
+					'<li>' +
+						'<label class="todo-list__label" contenteditable="false"></label>' +
+						'<span class="todo-list__label__description">{}1.0</span>' +
+					'</li>' +
 				'</ul>'
 			);
 
@@ -340,7 +446,10 @@ describe( 'TodoListEditing', () => {
 
 			assertEqualMarkup( getViewData( view ),
 				'<ul class="todo-list">' +
-					'<li><label class="todo-list__label" contenteditable="false"></label>{}1.0</li>' +
+					'<li>' +
+						'<label class="todo-list__label" contenteditable="false"></label>' +
+						'<span class="todo-list__label__description">{}1.0</span>' +
+					'</li>' +
 				'</ul>'
 			);
 
@@ -350,7 +459,10 @@ describe( 'TodoListEditing', () => {
 
 			assertEqualMarkup( getViewData( view ),
 				'<ul class="todo-list">' +
-					'<li><label class="todo-list__label" contenteditable="false"></label>{}1.0</li>' +
+					'<li>' +
+						'<label class="todo-list__label" contenteditable="false"></label>' +
+						'<span class="todo-list__label__description">{}1.0</span>' +
+					'</li>' +
 				'</ul>'
 			);
 		} );
@@ -418,7 +530,10 @@ describe( 'TodoListEditing', () => {
 
 			assertEqualMarkup( getViewData( view ),
 				'<ul class="todo-list">' +
-					'<li><label class="todo-list__label" contenteditable="false"></label>{}Foo</li>' +
+					'<li>' +
+						'<label class="todo-list__label" contenteditable="false"></label>' +
+						'<span class="todo-list__label__description">{}Foo</span>' +
+					'</li>' +
 				'</ul>'
 			);
 		} );
@@ -428,7 +543,10 @@ describe( 'TodoListEditing', () => {
 
 			assertEqualMarkup( getViewData( view ),
 				'<ul class="todo-list">' +
-					'<li><label class="todo-list__label" contenteditable="false"></label>[]</li>' +
+					'<li>' +
+						'<label class="todo-list__label" contenteditable="false"></label>' +
+						'<span class="todo-list__label__description">[]</span>' +
+					'</li>' +
 				'</ul>'
 			);
 		} );
@@ -474,14 +592,17 @@ describe( 'TodoListEditing', () => {
 				'<ul class="todo-list">' +
 					'<li>' +
 						'<label class="todo-list__label" contenteditable="false"></label>' +
-						'[]<span class="highlight">' +
-							'<element2></element2>' +
-							'<element1></element1>' +
-							'foo' +
+						'<span class="todo-list__label__description">' +
+							'[]<span class="highlight">' +
+								'<element2></element2>' +
+								'<element1></element1>' +
+								'foo' +
+							'</span>' +
 						'</span>' +
 					'</li>' +
 					'<li>' +
-						'<label class="todo-list__label" contenteditable="false"></label>bar' +
+						'<label class="todo-list__label" contenteditable="false"></label>' +
+						'<span class="todo-list__label__description">bar</span>' +
 					'</li>' +
 				'</ul>'
 			);
@@ -503,7 +624,9 @@ describe( 'TodoListEditing', () => {
 				'<ul class="todo-list">' +
 					'<li>' +
 						'<label class="todo-list__label" contenteditable="false"></label>' +
-						'<strong>b{}foo</strong>' +
+						'<span class="todo-list__label__description">' +
+							'<strong>b{}foo</strong>' +
+						'</span>' +
 					'</li>' +
 				'</ul>'
 			);
@@ -524,11 +647,24 @@ describe( 'TodoListEditing', () => {
 				'<ul class="todo-list">' +
 					'<li>' +
 						'<label class="todo-list__label" contenteditable="false"></label>' +
-						'<a href="foo"><strong>b{}foo</strong></a>' +
+						'<span class="todo-list__label__description">' +
+							'<a class="ck-link_selected" href="foo"><strong>b{}foo</strong></a>' +
+						'</span>' +
 					'</li>' +
 				'</ul>'
 			);
 		} );
+
+		it( 'should properly handle enter key in list item containing soft-breaks', () => {
+			setModelData( model, '<listItem listType="todo" listIndent="0">[]Foo<softBreak></softBreak>bar</listItem>' );
+
+			editor.execute( 'enter' );
+
+			assertEqualMarkup( getModelData( model ),
+				'<listItem listIndent="0" listType="todo"></listItem>' +
+				'<listItem listIndent="0" listType="todo">[]Foo<softBreak></softBreak>bar</listItem>'
+			);
+		} );
 	} );
 
 	describe( 'data pipeline m -> v', () => {
@@ -697,6 +833,21 @@ describe( 'TodoListEditing', () => {
 
 			expect( editor.getData() ).to.equal( '<test>Foo</test>' );
 		} );
+
+		it( 'should handle links inside to-do list item', () => {
+			setModelData( model, '<listItem listType="todo" listIndent="0"><$text linkHref="foo">Foo</$text> Bar</listItem>' );
+
+			expect( editor.getData() ).to.equal(
+				'<ul class="todo-list">' +
+					'<li>' +
+						'<label class="todo-list__label">' +
+							'<input type="checkbox" disabled="disabled">' +
+							'<span class="todo-list__label__description"><a href="foo">Foo</a> Bar</span>' +
+						'</label>' +
+					'</li>' +
+				'</ul>'
+			);
+		} );
 	} );
 
 	describe( 'data pipeline v -> m', () => {
@@ -970,13 +1121,13 @@ describe( 'TodoListEditing', () => {
 				sinon.assert.calledOnce( domEvtDataStub.stopPropagation );
 			} );
 
-			it( 'should do nothing when list item is a first block element in the root', () => {
+			it( 'should prevent default handler when list item is a first block element in the root', () => {
 				setModelData( model, '<listItem listIndent="0" listType="todo">[]bar</listItem>' );
 
 				viewDoc.fire( 'keydown', domEvtDataStub );
 
-				sinon.assert.notCalled( domEvtDataStub.preventDefault );
-				sinon.assert.notCalled( domEvtDataStub.stopPropagation );
+				sinon.assert.calledOnce( domEvtDataStub.preventDefault );
+				sinon.assert.calledOnce( domEvtDataStub.stopPropagation );
 
 				assertEqualMarkup( getModelData( model ), '<listItem listIndent="0" listType="todo">[]bar</listItem>' );
 			} );

+ 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 - 8
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';
 
 /**
@@ -48,11 +48,6 @@ export default class MergeCellsCommand extends Command {
 			// All cells will be merged into the first one.
 			const firstTableCell = selectedTableCells.shift();
 
-			// Set the selection in cell that other cells are being merged to prevent model-selection-range-intersects error in undo.
-			// See https://github.com/ckeditor/ckeditor5/issues/6634.
-			// May be fixed by: https://github.com/ckeditor/ckeditor5/issues/6639.
-			writer.setSelection( firstTableCell, 0 );
-
 			// Update target cell dimensions.
 			const { mergeWidth, mergeHeight } = getMergeDimensions( firstTableCell, selectedTableCells, tableUtils );
 			updateNumericAttribute( 'colspan', mergeWidth, firstTableCell, writer );
@@ -62,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 - 14
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,27 +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, writer => {
-			// This prevents the "model-selection-range-intersects" error, caused by removing row selected cells.
-			writer.setSelection( writer.createSelection( table, 'on' ) );
-
+		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`.  

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

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