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

Merge branch 'master' into i/7336

Maciej Gołaszewski 5 лет назад
Родитель
Сommit
5934eda7fb
100 измененных файлов с 3576 добавлено и 722 удалено
  1. 0 10
      .github/ISSUE_TEMPLATE/4-security-issue.md
  2. 10 0
      .github/ISSUE_TEMPLATE/config.yml
  3. 1 1
      .travis.yml
  4. 0 2
      CHANGELOG.md
  5. 0 6
      docs/_snippets/examples/document-editor.html
  6. 0 4
      docs/_snippets/framework/tutorials/using-react-in-widget.html
  7. 6 0
      docs/assets/snippet-styles.css
  8. 10 2
      docs/features/keyboard-support.md
  9. 13 0
      docs/framework/guides/contributing/code-style.md
  10. 3 3
      docs/framework/guides/contributing/git-commit-message-convention.md
  11. 4 3
      package.json
  12. 79 0
      packages/ckeditor5-core/tests/_utils-tests/assertions/attribute.js
  13. 61 0
      packages/ckeditor5-core/tests/_utils/assertions/attribute.js
  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. 3 0
      packages/ckeditor5-engine/src/view/downcastwriter.js
  20. 6 2
      packages/ckeditor5-engine/tests/conversion/mapper.js
  21. 27 0
      packages/ckeditor5-engine/tests/model/element.js
  22. 20 0
      packages/ckeditor5-engine/tests/model/position.js
  23. 110 0
      packages/ckeditor5-engine/tests/model/range.js
  24. 14 0
      packages/ckeditor5-image/docs/_snippets/features/image-resizeui.html
  25. 49 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. 69 2
      packages/ckeditor5-image/docs/features/image.md
  29. 4 1
      packages/ckeditor5-image/lang/contexts.json
  30. 101 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. 199 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. 255 0
      packages/ckeditor5-image/tests/imageresize/imageresizeui.js
  37. 37 0
      packages/ckeditor5-image/tests/manual/imageresizeui.html
  38. 121 0
      packages/ckeditor5-image/tests/manual/imageresizeui.js
  39. 11 0
      packages/ckeditor5-image/tests/manual/imageresizeui.md
  40. 4 0
      packages/ckeditor5-link/docs/_snippets/features/autolink.html
  41. 23 0
      packages/ckeditor5-link/docs/_snippets/features/autolink.js
  42. 5 0
      packages/ckeditor5-link/docs/_snippets/features/build-link-source.js
  43. 6 2
      packages/ckeditor5-link/docs/api/link.md
  44. 28 5
      packages/ckeditor5-link/docs/features/link.md
  45. 2 1
      packages/ckeditor5-link/package.json
  46. 248 0
      packages/ckeditor5-link/src/autolink.js
  47. 4 4
      packages/ckeditor5-link/src/link.js
  48. 10 9
      packages/ckeditor5-link/src/linkediting.js
  49. 3 3
      packages/ckeditor5-link/src/linkimageui.js
  50. 372 0
      packages/ckeditor5-link/tests/autolink.js
  51. 18 9
      packages/ckeditor5-link/tests/linkediting.js
  52. 11 0
      packages/ckeditor5-link/tests/manual/autolink.html
  53. 30 0
      packages/ckeditor5-link/tests/manual/autolink.js
  54. 25 0
      packages/ckeditor5-link/tests/manual/autolink.md
  55. 59 62
      packages/ckeditor5-list/src/todolistconverters.js
  56. 33 25
      packages/ckeditor5-list/src/todolistediting.js
  57. 29 0
      packages/ckeditor5-list/src/utils.js
  58. 4 2
      packages/ckeditor5-list/tests/manual/todo-list.js
  59. 189 38
      packages/ckeditor5-list/tests/todolistediting.js
  60. 0 6
      packages/ckeditor5-table/docs/_snippets/features/table.html
  61. 2 3
      packages/ckeditor5-table/src/commands/insertcolumncommand.js
  62. 2 3
      packages/ckeditor5-table/src/commands/insertrowcommand.js
  63. 3 4
      packages/ckeditor5-table/src/commands/inserttablecommand.js
  64. 3 3
      packages/ckeditor5-table/src/commands/mergecellcommand.js
  65. 3 8
      packages/ckeditor5-table/src/commands/mergecellscommand.js
  66. 1 2
      packages/ckeditor5-table/src/commands/removecolumncommand.js
  67. 4 14
      packages/ckeditor5-table/src/commands/removerowcommand.js
  68. 1 2
      packages/ckeditor5-table/src/commands/selectcolumncommand.js
  69. 1 2
      packages/ckeditor5-table/src/commands/selectrowcommand.js
  70. 1 2
      packages/ckeditor5-table/src/commands/setheadercolumncommand.js
  71. 2 2
      packages/ckeditor5-table/src/commands/setheaderrowcommand.js
  72. 0 100
      packages/ckeditor5-table/src/converters/downcast.js
  73. 54 0
      packages/ckeditor5-table/src/converters/table-heading-rows-refresh-post-fixer.js
  74. 3 3
      packages/ckeditor5-table/src/converters/table-layout-post-fixer.js
  75. 42 18
      packages/ckeditor5-table/src/tableclipboard.js
  76. 4 4
      packages/ckeditor5-table/src/tableediting.js
  77. 2 3
      packages/ckeditor5-table/src/tablekeyboard.js
  78. 1 6
      packages/ckeditor5-table/src/tablemouse.js
  79. 2 4
      packages/ckeditor5-table/src/tableproperties/commands/tablepropertycommand.js
  80. 2 3
      packages/ckeditor5-table/src/tableselection.js
  81. 41 27
      packages/ckeditor5-table/src/tableutils.js
  82. 4 20
      packages/ckeditor5-table/src/utils/common.js
  83. 13 8
      packages/ckeditor5-table/src/utils/selection.js
  84. 31 17
      packages/ckeditor5-table/src/utils/structure.js
  85. 2 3
      packages/ckeditor5-table/src/utils/ui/contextualballoon.js
  86. 12 1
      packages/ckeditor5-table/src/utils/ui/widget.js
  87. 15 0
      packages/ckeditor5-table/tests/commands/inserttablecommand.js
  88. 47 0
      packages/ckeditor5-table/tests/commands/mergecellscommand.js
  89. 33 32
      packages/ckeditor5-table/tests/converters/downcast.js
  90. 13 2
      packages/ckeditor5-table/tests/manual/tablemocking.html
  91. 5 2
      packages/ckeditor5-table/tests/manual/tablemocking.js
  92. 0 2
      packages/ckeditor5-table/tests/manual/tablemocking.md
  93. 368 4
      packages/ckeditor5-table/tests/tableclipboard-paste.js
  94. 8 4
      packages/ckeditor5-table/tests/tableselection-integration.js
  95. 81 0
      packages/ckeditor5-table/tests/tableutils.js
  96. 1 18
      packages/ckeditor5-table/tests/utils/common.js
  97. 1 2
      packages/ckeditor5-table/tests/utils/ui/contextualballoon.js
  98. 7 2
      packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/toolbar/toolbar.css
  99. 1 1
      packages/ckeditor5-typing/src/input.js
  100. 101 59
      packages/ckeditor5-typing/src/twostepcaretmovement.js

+ 0 - 10
.github/ISSUE_TEMPLATE/4-security-issue.md

@@ -1,10 +0,0 @@
----
-name: "\U0001F6A8 Security issue"
-about: Report a security issue.
-title: ''
-labels: ''
-assignees: ''
-
----
-
-⚠️ Please **DO NOT report security issues here**, use the contact form at [https://ckeditor.com/contact/](https://ckeditor.com/contact/) instead. ⚠️

+ 10 - 0
.github/ISSUE_TEMPLATE/config.yml

@@ -0,0 +1,10 @@
+blank_issues_enabled: false
+contact_links:
+  - name: "\U0001F6A8 Security issue"
+    url: https://ckeditor.com/contact
+    about: |
+      Please **DO NOT report security issues here**,
+      use the contact form at https://ckeditor.com/contact/ instead.
+  - name: "\U0001F4F1 Licensed Customers support"
+    about: Support channel for Licensed Customers.
+    url: https://support.ckeditor.com/hc/en-us/requests/new

+ 1 - 1
.travis.yml

@@ -22,7 +22,7 @@ script:
 - node ./scripts/continuous-integration-script.js
 - yarn run lint
 - yarn run stylelint
-- yarn run docs:api --validate-only
+- yarn run docs --strict
 - 'if [ $TRAVIS_TEST_RESULT -eq 0 ]; then
     travis_wait 30 yarn run docs:build-and-publish-nightly;
   fi'

+ 0 - 2
CHANGELOG.md

@@ -9,7 +9,6 @@ We are happy to announce the release of CKEditor 5 v20.0.0.
 
 This release brings some highly anticipated features:
 
-* [PDF export](https://ckeditor.com/blog/How-to-print-WYSIWYG-editor-content-to-PDF-Export-to-PDF-feature-released/) feature.
 * Support for [linking images](https://github.com/ckeditor/ckeditor5/issues/702).
 * [Typing around widgets](https://github.com/ckeditor/ckeditor5/issues/407).
 * An option to [automatically set link protocol](https://github.com/ckeditor/ckeditor5/issues/4858).
@@ -18,7 +17,6 @@ This release brings some highly anticipated features:
 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).
-* Fixed [table cell background retention when pasting from Microsoft Word](https://github.com/ckeditor/ckeditor5/issues/7275).
 * 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.

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

+ 13 - 0
docs/framework/guides/contributing/code-style.md

@@ -510,6 +510,19 @@ There are some special rules and tips for tests.
 
 	Think about this &mdash; when you fix a bug by adding a parameter to an existing function call you do not affect code coverage (that line was called anyway). However, you had a bug, meaning that your test suite did not cover it. Therefore, a test must be created for that code change.
 * It should be `expect( x ).to.equal( y )`. **NOT**: ~~`expect( x ).to.be.equal( y )`~~.
+* When using Sinon spies, pay attention to the readability of assertions and failure messages.
+   * Use named spies, for example:
+
+		```js
+		const someCallbackSpy = sinon.spy().named( 'someCallback' );
+		const myMethodSpy = sinon.spy( obj, 'myMethod' );
+		```
+   * Use [sinon-chai assertions](https://www.chaijs.com/plugins/sinon-chai/)
+
+		```js
+		expect( myMethodSpy ).to.be.calledOnce 
+		// expected myMethod to be called once but was called twice
+		```
 
 ## Naming
 

+ 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
 

+ 4 - 3
package.json

@@ -76,7 +76,7 @@
     "@ckeditor/ckeditor5-word-count": "^20.0.0"
   },
   "devDependencies": {
-    "@ckeditor/ckeditor5-comments": "^19.0.2",
+    "@ckeditor/ckeditor5-comments": "^20.0.0",
     "@ckeditor/ckeditor5-dev-docs": "^21.0.0",
     "@ckeditor/ckeditor5-dev-env": "^21.0.0",
     "@ckeditor/ckeditor5-dev-tests": "^21.0.0",
@@ -84,8 +84,8 @@
     "@ckeditor/ckeditor5-dev-webpack-plugin": "^21.0.0",
     "@ckeditor/ckeditor5-inspector": "^2.1.0",
     "@ckeditor/ckeditor5-react": "^2.1.0",
-    "@ckeditor/ckeditor5-real-time-collaboration": "^19.0.2",
-    "@ckeditor/ckeditor5-track-changes": "^19.1.0",
+    "@ckeditor/ckeditor5-real-time-collaboration": "^20.0.0",
+    "@ckeditor/ckeditor5-track-changes": "^20.0.0",
     "@wiris/mathtype-ckeditor5": "7.20.0",
     "babel-standalone": "^6.26.0",
     "coveralls": "^3.1.0",
@@ -98,6 +98,7 @@
     "lint-staged": "^10.2.6",
     "mini-css-extract-plugin": "^0.9.0",
     "minimatch": "^3.0.4",
+    "mkdirp": "^1.0.4",
     "nyc": "^15.0.1",
     "postcss-loader": "^3.0.0",
     "progress-bar-webpack-plugin": "^2.1.0",

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

@@ -0,0 +1,79 @@
+/**
+ * @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 chai */
+
+import '../../_utils/assertions/attribute';
+
+describe( 'attribute chai assertion', () => {
+	it( 'should be added to chai assertions', () => {
+		const assertion = new chai.Assertion();
+
+		expect( assertion ).to.have.property( 'attribute' );
+		expect( assertion.attribute ).to.be.instanceof( Function );
+	} );
+
+	it( 'should assert the target has a \'hasAttribute\' method', () => {
+		expect( { hasAttribute: () => true } ).to.have.attribute( 'foo' );
+
+		expect( function() {
+			expect( {} ).not.to.have.attribute( 'bar' );
+		} ).to.throw( 'expected {} to respond to \'hasAttribute\'' );
+
+		expect( function() {
+			expect( {} ).to.have.attribute( 'bar' );
+		} ).to.throw( 'expected {} to respond to \'hasAttribute\'' );
+	} );
+
+	it( 'should assert the \'target.hasAttribute\' returns \'true\' for the given type', () => {
+		expect( { hasAttribute: () => true } ).to.have.attribute( 'foo' );
+
+		expect( function() {
+			expect( { hasAttribute: () => false } ).to.have.attribute( 'bar' );
+		} ).to.throw( 'expected { Object (hasAttribute) } to have attribute \'bar\'' );
+	} );
+
+	it( 'negated, should assert the \'target.hasAttribute\' returns \'false\' for the given type', () => {
+		expect( { hasAttribute: () => false } ).not.to.have.attribute( 'foo' );
+
+		expect( function() {
+			expect( { hasAttribute: () => true } ).not.to.have.attribute( 'bar' );
+		} ).to.throw( 'expected { Object (hasAttribute) } to not have attribute \'bar\'' );
+	} );
+
+	it( 'should assert the \'target.getAttribute\' returns the given value for the given type', () => {
+		expect( {
+			hasAttribute: () => true,
+			getAttribute: () => 'bar'
+		} ).to.have.attribute( 'foo', 'bar' );
+
+		expect( function() {
+			expect( {
+				hasAttribute: () => true,
+				getAttribute: () => 'bar'
+			} ).to.have.attribute( 'foo', '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', () => {
+		expect( {
+			hasAttribute: () => true,
+			getAttribute: () => 'bar'
+		} ).to.not.have.attribute( 'foo', 'baz' );
+
+		expect( function() {
+			expect( {
+				hasAttribute: () => true,
+				getAttribute: () => 'baz'
+			} ).to.not.have.attribute( 'foo', 'baz' );
+		} ).to.throw( 'expected { Object (hasAttribute, getAttribute) } to not have attribute \'foo\' of \'baz\'' );
+	} );
+
+	it( 'should prefix failure message with the given one', () => {
+		expect( function() {
+			expect( {} ).to.have.attribute( 'foo', 'baz', 'Illegal salmon' );
+		} ).to.throw( /^Illegal salmon: / );
+	} );
+} );

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

@@ -0,0 +1,61 @@
+/**
+ * @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 chai */
+
+/**
+ * Asserts that the target has an attribute with the given key name.
+ * See {@link module:engine/model/documentselection~DocumentSelection#hasAttribute hasAttribute}.
+ *
+ *		expect( selection ).to.have.attribute( 'linkHref' );
+ *
+ * When `value` is provided, .attribute also asserts that the attribute's value is equal to the given `value`.
+ * See {@link module:engine/model/documentselection~DocumentSelection#getAttribute getAttribute}.
+ *
+ *		expect( selection ).to.have.attribute( 'linkHref', 'example.com' );
+ *
+ * Negations works as well.
+ *
+ * @param {String} key Key of attribute to assert.
+ * @param {String} [value] Attribute value to assert.
+ * @param {String} [message] Additional message.
+ */
+chai.Assertion.addMethod( 'attribute', function attributeAssertion( key, value, message ) {
+	if ( message ) {
+		chai.util.flag( this, 'message', message );
+	}
+
+	const obj = this._obj;
+
+	if ( arguments.length === 1 ) {
+		// Check if it has the method at all.
+		new chai.Assertion( obj ).to.respondTo( 'hasAttribute' );
+
+		// Check if it has the attribute.
+		const hasAttribute = obj.hasAttribute( key );
+		this.assert(
+			hasAttribute === true,
+			`expected #{this} to have attribute '${ key }'`,
+			`expected #{this} to not have attribute '${ key }'`,
+			!chai.util.flag( this, 'negate' ),
+			hasAttribute
+		);
+	}
+
+	// If a value was given.
+	if ( arguments.length >= 2 ) {
+		// Check if it has the method at all.
+		new chai.Assertion( obj ).to.respondTo( 'getAttribute', message );
+
+		const attributeValue = obj.getAttribute( key );
+		this.assert(
+			attributeValue === value,
+			`expected #{this} to have attribute '${ key }' of #{exp}, but got #{act}`,
+			`expected #{this} to not have attribute '${ key }' of #{exp}`,
+			value,
+			attributeValue
+		);
+	}
+} );

+ 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

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

@@ -214,6 +214,9 @@ export default class DowncastWriter {
 	 *		writer.createEditableElement( 'div' );
 	 *		writer.createEditableElement( 'div', { id: 'foo-1234' } );
 	 *
+	 * Note: The editable element is to be used in the editing pipeline. Usually, together with
+	 * {@link module:widget/utils~toWidgetEditable `toWidgetEditable()`}.
+	 *
 	 * @param {String} name Name of the element.
 	 * @param {Object} [attributes] Elements attributes.
 	 * @returns {module:engine/view/editableelement~EditableElement} Created element.

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

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

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

@@ -0,0 +1,49 @@
+/**
+ * @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 size',
+					value: null
+				},
+				{
+					name: 'imageResize:50',
+					label: '50%',
+					value: '50'
+				},
+				{
+					name: 'imageResize:75',
+					label: '75%',
+					value: '75'
+				}
+			],
+			toolbar: [
+				'imageStyle:full',
+				'imageStyle:side', '|',
+				'imageResize:original',
+				'imageResize:50',
+				'imageResize:75'
+			]
+		},
+		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 size',
+					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 );
+	} );

+ 69 - 2
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.
@@ -259,7 +322,11 @@ ClassicEditor
 
 ## Linking images
 
-The {@link module:link/linkimage~LinkImage} plugin adds support for linking images:
+The {@link module:link/linkimage~LinkImage} plugin adds support for linking images. Some use cases where this is needed are:
+
+* Linking to a high-resolution version of an image.
+* Using images as thumbnails linking to an article or product page.
+* Creating banners linking to other pages.
 
 ```html
 <figure class="image">
@@ -284,7 +351,7 @@ The image linking feature is not enabled by default in any of the editor builds.
 
 To add image features to your rich-text editor, install the [`@ckeditor/ckeditor5-image`](https://www.npmjs.com/package/@ckeditor/ckeditor5-image) package:
 
-```bash
+```plaintext
 npm install --save @ckeditor/ckeditor5-image @ckeditor/ckeditor5-link
 ```
 

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

@@ -10,5 +10,8 @@
 	"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 to": "The label used for the standalone resize option buttons in the image toolbar",
+	"Resize image": "The label used for the dropdown in the image toolbar, containing defined resize options",
+	"Resize image to the original size": "The label used for the standalone resize reset option button in the image toolbar, when user set the value to `null`"
 }

+ 101 - 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,101 @@ export default class ImageResize extends Plugin {
  * @default '%'
  * @member {String} module:image/image~ImageConfig#resizeUnit
  */
+
+/**
+ * The resize options.
+ *
+ * Each option should have its `name`, which is a component name definition that will be
+ * used in the {@link module:image/imageresize/imageresizeui~ImageResizeUI} plugin.
+ * Other properties like `label` and `value` define the following:
+ * a text label for the option button and the value that will be applied to the image's width.
+ *
+ * The value property is combined with the `resizeUnit` (`%` by default), eg: `value: '50'` and `resizeUnit: '%'` is `50%`.
+ *
+ * **NOTE:** 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',
+ *						label: 'Original size',
+ *						value: null
+ *					},
+ *					{
+ *						name: 'imageResize:50',
+ *						label: '50%',
+ *						value: '50'
+ *					},
+ *					{
+ *						name: 'imageResize:75',
+ *						label: '75%',
+ *						value: '75'
+ *					} ]
+ *				}
+ *			} )
+ *			.then( ... )
+ *			.catch( ... );
+ *
+ * With resize options defined, you can decide whether you want to display them as a dropdown or as standalone buttons.
+ * For the dropdown, you need to pass only the `imageResize` token to the `image.toolbar`.
+ * The dropdown contains all defined options by default:
+ *
+ *			ClassicEditor
+ *			.create( editorElement, {
+ *				image: {
+ *					resizeUnit: "%",
+ *					resizeOptions: [ {
+ *						name: 'imageResize:original',
+ *						label: 'Original size',
+ *						value: null
+ *					},
+ *					{
+ *						name: 'imageResize:50',
+ *						label: '50%',
+ *						value: '50'
+ *					},
+ *					{
+ *						name: 'imageResize:75',
+ *						label: '75%',
+ *						value: '75'
+ *					} ],
+ *					toolbar: [ 'imageResize', ... ],
+ *				}
+ *			} )
+ *			.then( ... )
+ *			.catch( ... );
+ *
+ * If you want to have separate buttons for each option, pass their names instead:
+ *
+ *			ClassicEditor
+ *			.create( editorElement, {
+ *				image: {
+ *					resizeUnit: "%",
+ *					resizeOptions: [ {
+ *						name: 'imageResize:original',
+ *						label: 'Original size',
+ *						value: null
+ *					},
+ *					{
+ *						name: 'imageResize:50',
+ *						label: '50%',
+ *						value: '50'
+ *					},
+ *					{
+ *						name: 'imageResize:75',
+ *						label: '75%',
+ *						value: '75'
+ *					} ],
+ *					toolbar: [ 'imageResize:original', 'imageResize:50', 'imageResize:75', ... ],
+ *				}
+ *			} )
+ *			.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/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' )
+				}
+			} );
+	}
+}

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

@@ -0,0 +1,199 @@
+/**
+ * @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';
+
+/**
+ * The `ImageResizeUI` plugin.
+ *
+ * It adds a possibility to resize each image using toolbar dropdown or separate buttons, depends 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
+	 */
+	init() {
+		const editor = this.editor;
+		const options = editor.config.get( 'image.resizeOptions' );
+		const command = editor.commands.get( 'imageResize' );
+		const resizeUnit = editor.config.get( 'image.resizeUnit' ) || '%';
+
+		if ( !options ) {
+			return;
+		}
+
+		this.bind( 'isEnabled' ).to( command );
+
+		for ( const option of options ) {
+			this._addButton( option, resizeUnit );
+		}
+
+		this._addDropdown( options, resizeUnit );
+	}
+
+	/**
+	 * A helper function that creates a standalone button component for the plugin.
+	 *
+	 * @private
+	 *
+	 * @param {module:image/imageresize/imageresizeui~ImageResizeOption} resizeOption A model of resize option.
+	 * @param {String} unit A resize unit.
+	 */
+	_addButton( { name, label, value }, unit ) {
+		const editor = this.editor;
+		const t = editor.t;
+		const parsedValue = value ? value + unit : null;
+
+		editor.ui.componentFactory.add( name, locale => {
+			const button = new ButtonView( locale );
+			const command = editor.commands.get( 'imageResize' );
+			const commandCallback = setOptionOn( parsedValue );
+
+			button.set( {
+				label: t( label ),
+				withText: true,
+				tooltip: parsedValue ? t( 'Resize image to' ) + ' ' + parsedValue : t( 'Resize image to the original size' ),
+				isToggleable: true,
+				commandValue: parsedValue
+			} );
+
+			// Bind button to the command.
+			button.bind( 'isEnabled' ).to( this );
+			button.bind( 'isOn' ).to( command, 'value', commandCallback );
+
+			this.listenTo( button, 'execute', evt => {
+				editor.execute( 'imageResize', { width: evt.source.commandValue } );
+			} );
+
+			return button;
+		} );
+	}
+
+	/**
+	 * A helper function that creates a dropdown component for the plugin containing all resize options created through the
+	 * plugin configuration settings.
+	 *
+	 * @private
+	 *
+	 * @param {Array.<module:image/imageresize/imageresizeui~ImageResizeOption>} options An array of the configured options.
+	 * @param {String} unit A resize unit.
+	 */
+	_addDropdown( options, unit ) {
+		const editor = this.editor;
+		const t = editor.t;
+		const firstOption = options[ 0 ];
+		const resetOption = options.find( option => option.value === null );
+
+		// 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: firstOption.value,
+				isToggleable: true,
+				label: firstOption.label,
+				withText: true
+			} );
+
+			dropdownButton.bind( 'label' ).to( command, 'value', commandValue => {
+				return commandValue && commandValue.width || resetOption.label;
+			} );
+			dropdownView.bind( 'isOn' ).to( command );
+			dropdownView.bind( 'isEnabled' ).to( this );
+
+			addListToDropdown( dropdownView, prepareListDefinitions( options, command, unit ) );
+
+			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 parsing resize options definitions.
+function prepareListDefinitions( definitions, command, resizeUnit ) {
+	const itemDefinitions = new Collection();
+
+	definitions.map( itemDefinition => {
+		const parsedValue = itemDefinition.value ? itemDefinition.value + resizeUnit : null;
+		const definition = {
+			type: 'button',
+			model: new Model( {
+				commandName: 'imageResize',
+				commandValue: parsedValue,
+				label: itemDefinition.label,
+				withText: true,
+				icon: null
+			} )
+		};
+
+		const commandCallback = setOptionOn( parsedValue );
+
+		definition.model.bind( 'isOn' ).to( command, 'value', commandCallback );
+
+		itemDefinitions.add( definition );
+	} );
+
+	return itemDefinitions;
+}
+
+// A helper function for setting the `isOn` state used for creating a callback function in a value binding.
+function setOptionOn( value ) {
+	return commandValue => {
+		// Set reseting option on when command equals `null`.
+		if ( commandValue === value ) {
+			return true;
+		}
+
+		return commandValue && commandValue.width === value;
+	};
+}
+
+/**
+ * A resize option type.
+ *
+ * @typedef {Object} module:image/imageresize/imageresizeui~ImageResizeOption
+ *
+ * @property {String} resizeOption.name A name of the option used for creating a component.
+ * You refer to that name later in the {@link module:image/image~ImageConfig#toolbar}.
+ * @property {String} resizeOption.label A label to be displayed with a button.
+ * @property {String} resizeOption.value A value of a resize option. `null` value is for resetting an image to its original size.
+ */

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

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

@@ -0,0 +1,255 @@
+/**
+ * @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';
+
+describe( 'ImageResizeUI', () => {
+	let plugin, command, editor, editorElement;
+
+	const resizeOptions = [ {
+		name: 'imageResize:original',
+		label: 'Original size',
+		value: null
+	},
+	{
+		name: 'imageResize:50',
+		label: '50%',
+		value: '50'
+	},
+	{
+		name: 'imageResize:75',
+		label: '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: {
+					resizeUnit: '%',
+					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' );
+		} );
+
+		it( 'should be disabled when command is disabled', () => {
+			command.isEnabled = true;
+
+			expect( plugin.isEnabled ).to.be.true;
+
+			command.isEnabled = false;
+
+			expect( plugin.isEnabled ).to.be.false;
+		} );
+	} );
+
+	describe( 'init()', () => {
+		it( 'should have set "%" resize unit', () => {
+			const unit = editor.config.get( 'image.resizeUnit' );
+
+			expect( unit ).to.equal( '%' );
+		} );
+
+		it( 'should have set "%" resize unit if not defined', async () => {
+			const editor = await ClassicTestEditor
+				.create( editorElement, {
+					plugins: [ Image, ImageStyle, Paragraph, Undo, Table, ImageResizeUI ],
+					image: {
+						resizeOptions
+					}
+				} );
+
+			const button = editor.ui.componentFactory.create( 'imageResize:50' );
+			const command = editor.commands.get( 'imageResize' );
+
+			command.isEnabled = true;
+
+			button.fire( 'execute' );
+
+			expect( command.value.width.includes( '%' ) ).to.be.true;
+
+			await editor.destroy();
+		} );
+
+		it( 'should have set "px" resize unit', async () => {
+			const editor = await ClassicTestEditor
+				.create( editorElement, {
+					plugins: [ Image, ImageStyle, Paragraph, Undo, Table, ImageResizeUI ],
+					image: {
+						resizeUnit: 'px',
+						resizeOptions
+					}
+				} );
+
+			const button = editor.ui.componentFactory.create( 'imageResize:50' );
+			const command = editor.commands.get( 'imageResize' );
+
+			command.isEnabled = true;
+
+			button.fire( 'execute' );
+
+			expect( command.value.width.includes( 'px' ) ).to.be.true;
+
+			await editor.destroy();
+		} );
+
+		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 3 resize options in the `imageResize` dropdown', () => {
+			const dropdownView = editor.ui.componentFactory.create( 'imageResize' );
+
+			expect( dropdownView.listView.items.length ).to.equal( 3 );
+			expect( dropdownView.listView.items.first.element.textContent ).to.equal( 'Original size' );
+			expect( dropdownView.listView.items._items[ 1 ].element.textContent ).to.equal( '50%' );
+			expect( dropdownView.listView.items.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( '50%' );
+		} );
+	} );
+
+	describe( 'resize option button', () => {
+		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 visible "50%" label', () => {
+			const buttonView = editor.ui.componentFactory.create( 'imageResize:50' );
+			buttonView.render();
+
+			expect( buttonView.withText ).to.be.true;
+			expect( buttonView.label ).to.equal( '50%' );
+			expect( buttonView.labelView ).to.be.instanceOf( View );
+		} );
+
+		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 have `commandValue` equal "50%"', () => {
+			const buttonView = editor.ui.componentFactory.create( 'imageResize:50' );
+
+			expect( buttonView.commandValue ).to.equal( '50%' );
+		} );
+
+		it( 'should execute `imageResize` command with "50%" value', () => {
+			const buttonView = editor.ui.componentFactory.create( 'imageResize:50' );
+			const command = editor.commands.get( 'imageResize' );
+			const commandSpy = sinon.spy( command, 'execute' );
+
+			command.isEnabled = true;
+
+			buttonView.fire( 'execute' );
+
+			sinon.assert.calledOnce( commandSpy );
+			expect( command.value.width ).to.equal( '50%' );
+		} );
+	} );
+} );

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

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

@@ -0,0 +1,121 @@
+/**
+ * @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',
+			label: 'Original size',
+			value: null
+		},
+		{
+			name: 'imageResize:50',
+			label: '50%',
+			value: '50'
+		},
+		{
+			name: 'imageResize:75',
+			label: '75%',
+			value: '75'
+		}
+	],
+	toolbar: [ 'imageStyle:alignLeft', 'imageStyle:full', 'imageStyle:side', '|', 'imageResize' ],
+	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.editor = editor;
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );
+
+const imageConfig2 = {
+	resizeUnit: '%',
+	resizeOptions: [
+		{
+			name: 'imageResize:original',
+			label: 'Original size',
+			value: null
+		},
+		{
+			name: 'imageResize:50',
+			label: '50%',
+			value: '50'
+		},
+		{
+			name: 'imageResize:75',
+			label: '75%',
+			value: '75'
+		}
+	],
+	toolbar: [
+		'imageStyle:alignLeft',
+		'imageStyle:full',
+		'imageStyle:side', '|',
+		'imageResize:original',
+		'imageResize:50',
+		'imageResize:75'
+	],
+	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.editor = editor;
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );

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

@@ -0,0 +1,11 @@
+## Image Resize UI
+
+The tests for manual image resizing.
+- The first test should have the dropdown with configured options in the image toolbar (using `imageResize`).
+	- Plugin icon should appear only in the dropbdown button.
+	- Each option should have a label text represented an option value defined in the plugin configuration.
+	- Selected options should be set "on" when dropdown is open.
+- The second one should have the standalone buttons instead of dropdown (from the first test) in the image toolbar (using
+`imageResize:option`).
+	- Each option should have the plugin icon "stretched" over the label text which represents an option value defined in the plugin configuration.
+	- Selected option should be set "on".

+ 4 - 0
packages/ckeditor5-link/docs/_snippets/features/autolink.html

@@ -0,0 +1,4 @@
+<div id="snippet-autolink">
+	<p>Type space, or Enter or Shift+Enter after a link: ckeditor.com</p>
+	<p>Will link to E-mail addresses also: example@example.com</p>
+</div>

+ 23 - 0
packages/ckeditor5-link/docs/_snippets/features/autolink.js

@@ -0,0 +1,23 @@
+/**
+ * @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 console, window, document, ClassicEditor, CS_CONFIG, CKEditorPlugins */
+
+ClassicEditor
+	.create( document.querySelector( '#snippet-autolink' ), {
+		cloudServices: CS_CONFIG,
+		extraPlugins: [
+			CKEditorPlugins.AutoLink
+		],
+		toolbar: {
+			viewportTopOffset: window.getViewportTopOffsetConfig()
+		}
+	} )
+	.then( editor => {
+		window.editor = editor;
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );

+ 5 - 0
packages/ckeditor5-link/docs/_snippets/features/build-link-source.js

@@ -7,6 +7,11 @@
 
 import ClassicEditor from '@ckeditor/ckeditor5-build-classic/src/ckeditor';
 import { CS_CONFIG } from '@ckeditor/ckeditor5-cloud-services/tests/_utils/cloud-services-config';
+import AutoLink from '@ckeditor/ckeditor5-link/src/autolink';
+
+window.CKEditorPlugins = {
+	AutoLink
+};
 
 window.ClassicEditor = ClassicEditor;
 window.CS_CONFIG = CS_CONFIG;

+ 6 - 2
packages/ckeditor5-link/docs/api/link.md

@@ -8,13 +8,17 @@ category: api-reference
 
 This package implements the link feature for CKEditor 5. It allows for inserting hyperlinks into the edited content and offers the UI to create and edit them.
 
+## Demo
+
+Check out the demo in the {@link features/link Link feature} guide.
+
 ## Documentation
 
-See the {@link module:link/link~Link} plugin documentation.
+See the {@link features/link Link feature} guide and the {@link module:link/link~Link} plugin documentation.
 
 ## Installation
 
-```bash
+```plaintext
 npm install --save @ckeditor/ckeditor5-link
 ```
 

+ 28 - 5
packages/ckeditor5-link/docs/features/link.md

@@ -147,11 +147,13 @@ ClassicEditor
 	.catch( ... );
 ```
 
-#### Adding default link protocol for the external links
+#### Adding default link protocol to external links
 
-Default link protocol can be usefull when user forget to type a full URL address to an external source, site etc. Sometimes copying the text, like for example `ckeditor.com` and converting it to a link may cause some issues. When you do this, the created link will direct you to `yourdomain.com/ckeditor.com`, because you forgot to pass the right protocol which makes the link relative to the site where it appears.
+A default link protocol can be useful when the user forgets to type the full URL address to an external source or website. Sometimes copying the text, like for example `ckeditor.com`, and converting it to a link may cause some issues. As a result, the created link will direct you to `yourdomain.com/ckeditor.com` because of the missing protocol. This makes the link relative to the site where it appears.
 
-Enabling the `{@link module:link/link~LinkConfig#defaultProtocol config.link.defaultProtocol}`, the {@link module:link/link~Link} feature will handle this issue for you. By default it doesn't fix the passed link value, but when you set `{@link module:link/link~LinkConfig#defaultProtocol config.link.defaultProtocol}` to — for example — `http://`, the plugin will add the given protocol to the every link that may need it (like `ckeditor.com`, `example.com` etc. where `[protocol://]example.com` is missing). Here's the basic configuration example:
+After you enable the {@link module:link/link~LinkConfig#defaultProtocol `config.link.defaultProtocol`} configuration option, the link feature will be able to handle this issue for you. By default it does not fix the passed link value, but when you set {@link module:link/link~LinkConfig#defaultProtocol `config.link.defaultProtocol`} to, for example, `http://`, the plugin will add the given protocol to every link that may need it (like `ckeditor.com`, `example.com`, etc. where `[protocol://]example.com` is missing).
+
+See a basic configuration example:
 
 ```js
 ClassicEditor
@@ -166,9 +168,9 @@ ClassicEditor
 ```
 
 <info-box>
-	Having `config.link.defaultProtocol` enabled you are still able to link things locally using `#` or `/`. Protocol won't be added to those links.
+	With the `config.link.defaultProtocol` option enabled, you are still able to link things locally using `#` or `/`. The protocol will not be added to these links.
 
-	Enabled feature also gives you an **email addresses auto-detection** feature. When you submit `hello@example.com`, the plugin will change it automatically to `mailto:hello@example.com`.
+	When enabled, this feature also provides the **email address auto-detection** feature. When you submit `hello@example.com` in your content, the plugin will automatically change it to `mailto:hello@example.com`.
 </info-box>
 
 #### Adding attributes to links based on pre–defined rules (automatic decorators)
@@ -236,6 +238,27 @@ ClassicEditor
 	.catch( ... );
 ```
 
+## Autolink feature
+
+You can enable automatic linking of URLs typed or pasted into editor. The `AutoLink` feature will automatically add links to URLs or e-mail addresses.
+
+<info-box>
+	Autolink action can be always reverted using undo (<kbd>CTRL</kbd>+<kbd>Z</kbd>).
+</info-box>
+
+{@snippet features/autolink}
+
+```js
+import AutoLink from '@ckeditor/ckeditor5-link/src/autolink';
+
+ClassicEditor
+	.create( document.querySelector( '#editor' ), {
+		plugins: [ Link, AutoLink, ... ]
+	} )
+	.then( ... )
+	.catch( ... );
+```
+
 ## Installation
 
 <info-box info>

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

@@ -13,6 +13,7 @@
     "@ckeditor/ckeditor5-core": "^20.0.0",
     "@ckeditor/ckeditor5-engine": "^20.0.0",
     "@ckeditor/ckeditor5-image": "^20.0.0",
+    "@ckeditor/ckeditor5-typing": "^20.0.0",
     "@ckeditor/ckeditor5-ui": "^20.0.0",
     "@ckeditor/ckeditor5-utils": "^20.0.0",
     "lodash-es": "^4.17.15"
@@ -21,11 +22,11 @@
     "@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",
     "@ckeditor/ckeditor5-paragraph": "^20.0.0",
     "@ckeditor/ckeditor5-theme-lark": "^20.0.0",
-    "@ckeditor/ckeditor5-typing": "^20.0.0",
     "@ckeditor/ckeditor5-undo": "^20.0.0"
   },
   "engines": {

+ 248 - 0
packages/ckeditor5-link/src/autolink.js

@@ -0,0 +1,248 @@
+/**
+ * @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 link/autolink
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import TextWatcher from '@ckeditor/ckeditor5-typing/src/textwatcher';
+import getLastTextLine from '@ckeditor/ckeditor5-typing/src/utils/getlasttextline';
+
+const MIN_LINK_LENGTH_WITH_SPACE_AT_END = 4; // Ie: "t.co " (length 5).
+
+// This was tweak from https://gist.github.com/dperini/729294.
+const URL_REG_EXP = new RegExp(
+	// Group 1: Line start or after a space.
+	'(^|\\s)' +
+	// Group 2: Detected URL (or e-mail).
+	'(' +
+		// Protocol identifier or short syntax "//"
+		// a. Full form http://user@foo.bar.baz:8080/foo/bar.html#baz?foo=bar
+		'(' +
+			'(?:(?:(?:https?|ftp):)?\\/\\/)' +
+			// BasicAuth using user:pass (optional)
+			'(?:\\S+(?::\\S*)?@)?' +
+			'(?:' +
+				// Host & domain names.
+				'(?![-_])(?:[-\\w\\u00a1-\\uffff]{0,63}[^-_]\\.)+' +
+				// TLD identifier name.
+				'(?:[a-z\\u00a1-\\uffff]{2,})' +
+			')' +
+			// port number (optional)
+			'(?::\\d{2,5})?' +
+			// resource path (optional)
+			'(?:[/?#]\\S*)?' +
+		')' +
+		'|' +
+		// b. Short form (either www.example.com or example@example.com)
+		'(' +
+			'(www.|(\\S+@))' +
+			// Host & domain names.
+			'((?![-_])(?:[-\\w\\u00a1-\\uffff]{0,63}[^-_]\\.))+' +
+	// TLD identifier name.
+	'(?:[a-z\\u00a1-\\uffff]{2,})' +
+	')' +
+	')$', 'i' );
+
+const URL_GROUP_IN_MATCH = 2;
+
+// Simplified email test - should be run over previously found URL.
+const EMAIL_REG_EXP = /^[\S]+@((?![-_])(?:[-\w\u00a1-\uffff]{0,63}[^-_]\.))+(?:[a-z\u00a1-\uffff]{2,})$/i;
+
+/**
+ * The auto link plugin.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class AutoLink extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'AutoLink';
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+		const selection = editor.model.document.selection;
+
+		selection.on( 'change:range', () => {
+			// Disable plugin when selection is inside a code block.
+			this.isEnabled = !selection.anchor.parent.is( 'codeBlock' );
+		} );
+
+		this._enableTypingHandling();
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	afterInit() {
+		this._enableEnterHandling();
+		this._enableShiftEnterHandling();
+	}
+
+	/**
+	 * Enables auto-link on typing.
+	 *
+	 * @private
+	 */
+	_enableTypingHandling() {
+		const editor = this.editor;
+
+		const watcher = new TextWatcher( editor.model, text => {
+			// 1. Detect "space" after a text with a potential link.
+			if ( !isSingleSpaceAtTheEnd( text ) ) {
+				return;
+			}
+
+			// 2. Check text before last typed "space".
+			const url = getUrlAtTextEnd( text.substr( 0, text.length - 1 ) );
+
+			if ( url ) {
+				return { url };
+			}
+		} );
+
+		const input = editor.plugins.get( 'Input' );
+
+		watcher.on( 'matched:data', ( evt, data ) => {
+			const { batch, range, url } = data;
+
+			if ( !input.isInput( batch ) ) {
+				return;
+			}
+
+			const linkEnd = range.end.getShiftedBy( -1 ); // Executed after a space character.
+			const linkStart = linkEnd.getShiftedBy( -url.length );
+
+			const linkRange = editor.model.createRange( linkStart, linkEnd );
+
+			this._applyAutoLink( url, linkRange );
+		} );
+
+		watcher.bind( 'isEnabled' ).to( this );
+	}
+
+	/**
+	 * Enables auto-link on <kbd>enter</kbd> key.
+	 *
+	 * @private
+	 */
+	_enableEnterHandling() {
+		const editor = this.editor;
+		const model = editor.model;
+		const enterCommand = editor.commands.get( 'enter' );
+
+		if ( !enterCommand ) {
+			return;
+		}
+
+		enterCommand.on( 'execute', () => {
+			const position = model.document.selection.getFirstPosition();
+
+			const rangeToCheck = model.createRange(
+				model.createPositionAt( position.parent.previousSibling, 0 ),
+				model.createPositionAt( position.parent.previousSibling, 'end' )
+			);
+
+			this._checkAndApplyAutoLinkOnRange( rangeToCheck );
+		} );
+	}
+
+	/**
+	 * Enables auto-link on <kbd>shift</kbd>+<kbd>enter</kbd> key.
+	 *
+	 * @private
+	 */
+	_enableShiftEnterHandling() {
+		const editor = this.editor;
+		const model = editor.model;
+
+		const shiftEnterCommand = editor.commands.get( 'shiftEnter' );
+
+		if ( !shiftEnterCommand ) {
+			return;
+		}
+
+		shiftEnterCommand.on( 'execute', () => {
+			const position = model.document.selection.getFirstPosition();
+
+			const rangeToCheck = model.createRange(
+				model.createPositionAt( position.parent, 0 ),
+				position.getShiftedBy( -1 )
+			);
+
+			this._checkAndApplyAutoLinkOnRange( rangeToCheck );
+		} );
+	}
+
+	/**
+	 * Checks passed range if it contains a linkable text.
+	 *
+	 * @param {module:engine/model/range~Range} rangeToCheck
+	 * @private
+	 */
+	_checkAndApplyAutoLinkOnRange( rangeToCheck ) {
+		const model = this.editor.model;
+		const { text, range } = getLastTextLine( rangeToCheck, model );
+
+		const url = getUrlAtTextEnd( text );
+
+		if ( url ) {
+			const linkRange = model.createRange(
+				range.end.getShiftedBy( -url.length ),
+				range.end
+			);
+
+			this._applyAutoLink( url, linkRange );
+		}
+	}
+
+	/**
+	 * Applies link on a given range.
+	 *
+	 * @param {String} url URL to link.
+	 * @param {module:engine/model/range~Range} range Text range to apply link attribute.
+	 * @private
+	 */
+	_applyAutoLink( url, range ) {
+		const model = this.editor.model;
+
+		if ( !this.isEnabled || !isLinkAllowedOnRange( range, model ) ) {
+			return;
+		}
+
+		// Enqueue change to make undo step.
+		model.enqueueChange( writer => {
+			const linkHrefValue = isEmail( url ) ? `mailto://${ url }` : url;
+
+			writer.setAttribute( 'linkHref', linkHrefValue, range );
+		} );
+	}
+}
+
+// Check if text should be evaluated by the plugin in order to reduce number of RegExp checks on whole text.
+function isSingleSpaceAtTheEnd( text ) {
+	return text.length > MIN_LINK_LENGTH_WITH_SPACE_AT_END && text[ text.length - 1 ] === ' ' && text[ text.length - 2 ] !== ' ';
+}
+
+function getUrlAtTextEnd( text ) {
+	const match = URL_REG_EXP.exec( text );
+
+	return match ? match[ URL_GROUP_IN_MATCH ] : null;
+}
+
+function isEmail( linkHref ) {
+	return EMAIL_REG_EXP.exec( linkHref );
+}
+
+function isLinkAllowedOnRange( range, model ) {
+	return model.schema.checkAttributeInSelection( model.createSelection( range ), 'linkHref' );
+}

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

@@ -59,10 +59,10 @@ export default class Link extends Plugin {
 
 /**
  * When set, the editor will add the given protocol to the link when the user creates a link without one.
- * For example, when the user is creating a link and types `ckeditor.com` in the link form input — during link submission —
- * the editor will automatically add the `http://` protocol, so the link will be as follows: `http://ckeditor.com`.
+ * For example, when the user is creating a link and types `ckeditor.com` in the link form input, during link submission
+ * the editor will automatically add the `http://` protocol, so the link will look as follows: `http://ckeditor.com`.
  *
- * The feature also comes with an email auto-detection. When you submit `hello@example.com`
+ * The feature also provides email address auto-detection. When you submit `hello@example.com`,
  * the plugin will automatically change it to `mailto:hello@example.com`.
  *
  * 		ClassicEditor
@@ -74,7 +74,7 @@ export default class Link extends Plugin {
  *			.then( ... )
  *			.catch( ... );
  *
- * **NOTE:** In case no configuration is provided, the editor won't auto-fix the links.
+ * **NOTE:** If no configuration is provided, the editor will not auto-fix the links.
  *
  * @member {String} module:link/link~LinkConfig#defaultProtocol
  */

+ 10 - 9
packages/ckeditor5-link/src/linkediting.js

@@ -9,7 +9,7 @@
 
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import MouseObserver from '@ckeditor/ckeditor5-engine/src/view/observer/mouseobserver';
-import bindTwoStepCaretToAttribute from '@ckeditor/ckeditor5-engine/src/utils/bindtwostepcarettoattribute';
+import TwoStepCaretMovement from '@ckeditor/ckeditor5-typing/src/twostepcaretmovement';
 import LinkCommand from './linkcommand';
 import UnlinkCommand from './unlinkcommand';
 import AutomaticDecorators from './utils/automaticdecorators';
@@ -40,6 +40,13 @@ export default class LinkEditing extends Plugin {
 		return 'LinkEditing';
 	}
 
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ TwoStepCaretMovement ];
+	}
+
 	/**
 	 * @inheritDoc
 	 */
@@ -56,7 +63,6 @@ export default class LinkEditing extends Plugin {
 	 */
 	init() {
 		const editor = this.editor;
-		const locale = editor.locale;
 
 		// Allow link attribute on all inline nodes.
 		editor.model.schema.extend( '$text', { allowAttributes: 'linkHref' } );
@@ -93,13 +99,8 @@ export default class LinkEditing extends Plugin {
 		this._enableManualDecorators( linkDecorators.filter( item => item.mode === DECORATOR_MANUAL ) );
 
 		// Enable two-step caret movement for `linkHref` attribute.
-		bindTwoStepCaretToAttribute( {
-			view: editor.editing.view,
-			model: editor.model,
-			emitter: this,
-			attribute: 'linkHref',
-			locale
-		} );
+		const twoStepCaretMovementPlugin = editor.plugins.get( TwoStepCaretMovement );
+		twoStepCaretMovementPlugin.registerAttribute( 'linkHref' );
 
 		// Setup highlight over selected link.
 		this._setupLinkHighlight();

+ 3 - 3
packages/ckeditor5-link/src/linkimageui.js

@@ -20,8 +20,8 @@ import linkIcon from '../theme/icons/link.svg';
 /**
  * The link image UI plugin.
  *
- * This plugin brings a `'linkImage'` button that can be displayed in the {@link module:image/imagetoolbar~ImageToolbar}
- * and used to wrap images in links.
+ * This plugin provides the `'linkImage'` button that can be displayed in the {@link module:image/imagetoolbar~ImageToolbar}.
+ * It can be used to wrap images in links.
  *
  * @extends module:core/plugin~Plugin
  */
@@ -63,7 +63,7 @@ export default class LinkImageUI extends Plugin {
 	 *
 	 * Clicking this button shows a {@link module:link/linkui~LinkUI#_balloon} attached to the selection.
 	 * When an image is already linked, the view shows {@link module:link/linkui~LinkUI#actionsView} or
-	 * {@link module:link/linkui~LinkUI#formView} if it's not.
+	 * {@link module:link/linkui~LinkUI#formView} if it is not.
 	 *
 	 * @private
 	 */

+ 372 - 0
packages/ckeditor5-link/tests/autolink.js

@@ -0,0 +1,372 @@
+/**
+ * @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 ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
+import CodeBlockEditing from '@ckeditor/ckeditor5-code-block/src/codeblockediting';
+import Enter from '@ckeditor/ckeditor5-enter/src/enter';
+import Input from '@ckeditor/ckeditor5-typing/src/input';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import ShiftEnter from '@ckeditor/ckeditor5-enter/src/shiftenter';
+import UndoEditing from '@ckeditor/ckeditor5-undo/src/undoediting';
+import { getData, setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+
+import LinkEditing from '../src/linkediting';
+import AutoLink from '../src/autolink';
+
+describe( 'AutoLink', () => {
+	let editor;
+
+	it( 'should be named', () => {
+		expect( AutoLink.pluginName ).to.equal( 'AutoLink' );
+	} );
+
+	it( 'should be loaded without Enter & ShiftEnter features', async () => {
+		const editor = await ModelTestEditor.create( {
+			plugins: [ Paragraph, Input, LinkEditing, AutoLink ]
+		} );
+
+		await editor.destroy();
+	} );
+
+	describe( 'auto link behavior', () => {
+		let model;
+
+		beforeEach( async () => {
+			editor = await ModelTestEditor.create( {
+				plugins: [ Paragraph, Input, Enter, ShiftEnter, LinkEditing, AutoLink ]
+			} );
+
+			model = editor.model;
+
+			setData( model, '<paragraph>[]</paragraph>' );
+		} );
+
+		it( 'does nothing on typing normal text', () => {
+			simulateTyping( 'Cupcake ipsum dolor. Sit amet caramels. Pie jelly-o lemon drops fruitcake.' );
+
+			expect( getData( model ) ).to.equal(
+				'<paragraph>Cupcake ipsum dolor. Sit amet caramels. Pie jelly-o lemon drops fruitcake.[]</paragraph>'
+			);
+		} );
+
+		it( 'does not add linkHref attribute to a text link while typing', () => {
+			simulateTyping( 'https://www.cksource.com' );
+
+			expect( getData( model ) ).to.equal(
+				'<paragraph>https://www.cksource.com[]</paragraph>'
+			);
+		} );
+
+		it( 'adds linkHref attribute to a text link after space', () => {
+			simulateTyping( 'https://www.cksource.com ' );
+
+			expect( getData( model ) ).to.equal(
+				'<paragraph><$text linkHref="https://www.cksource.com">https://www.cksource.com</$text> []</paragraph>'
+			);
+		} );
+
+		it( 'does not add linkHref attribute if linkHref is not allowed', () => {
+			model.schema.addAttributeCheck( () => false ); // Disable all attributes.
+
+			simulateTyping( 'https://www.cksource.com ' );
+
+			expect( getData( model ) ).to.equal(
+				'<paragraph>https://www.cksource.com []</paragraph>'
+			);
+		} );
+
+		it( 'does not add linkHref attribute if plugin is force-disabled (on space)', () => {
+			editor.plugins.get( 'AutoLink' ).forceDisabled( 'test' );
+
+			simulateTyping( 'https://www.cksource.com ' );
+
+			expect( getData( model ) ).to.equal(
+				'<paragraph>https://www.cksource.com []</paragraph>'
+			);
+		} );
+
+		it( 'does not add linkHref attribute if plugin is force-disabled (on enter)', () => {
+			setData( model, '<paragraph>https://www.cksource.com[]</paragraph>' );
+			editor.plugins.get( 'AutoLink' ).forceDisabled( 'test' );
+
+			editor.execute( 'enter' );
+
+			expect( getData( model ) ).to.equal(
+				'<paragraph>https://www.cksource.com</paragraph>' +
+				'<paragraph>[]</paragraph>'
+			);
+		} );
+
+		it( 'does not add linkHref attribute if plugin is force-disabled (on shift enter)', () => {
+			setData( model, '<paragraph>https://www.cksource.com[]</paragraph>' );
+			editor.plugins.get( 'AutoLink' ).forceDisabled( 'test' );
+
+			editor.execute( 'shiftEnter' );
+
+			expect( getData( model ) ).to.equal(
+				'<paragraph>https://www.cksource.com<softBreak></softBreak>[]</paragraph>'
+			);
+		} );
+
+		it( 'adds linkHref attribute to a text link after space (inside paragraph)', () => {
+			setData( model, '<paragraph>Foo Bar [] Baz</paragraph>' );
+
+			simulateTyping( 'https://www.cksource.com ' );
+
+			expect( getData( model ) ).to.equal(
+				'<paragraph>Foo Bar <$text linkHref="https://www.cksource.com">https://www.cksource.com</$text> [] Baz</paragraph>'
+			);
+		} );
+
+		it( 'adds linkHref attribute to a text link on shift enter', () => {
+			setData( model, '<paragraph>https://www.cksource.com[]</paragraph>' );
+
+			editor.execute( 'shiftEnter' );
+
+			// TODO: should test with selection but master has a bug. See: https://github.com/ckeditor/ckeditor5/issues/7459.
+			expect( getData( model, { withoutSelection: true } ) ).to.equal(
+				'<paragraph>' +
+				'<$text linkHref="https://www.cksource.com">https://www.cksource.com</$text>' +
+				'<softBreak></softBreak>' +
+				'</paragraph>'
+			);
+		} );
+
+		it( 'does not add linkHref attribute to a text link after double soft break', () => {
+			setData( model, '<paragraph>https://www.cksource.com<softBreak></softBreak>[]</paragraph>' );
+
+			editor.execute( 'shiftEnter' );
+
+			expect( getData( model ) ).to.equal(
+				'<paragraph>https://www.cksource.com<softBreak></softBreak><softBreak></softBreak>[]</paragraph>'
+			);
+		} );
+
+		it( 'adds linkHref attribute to a text link on enter', () => {
+			setData( model, '<paragraph>https://www.cksource.com[]</paragraph>' );
+
+			editor.execute( 'enter' );
+
+			expect( getData( model ) ).to.equal(
+				'<paragraph>' +
+					'<$text linkHref="https://www.cksource.com">https://www.cksource.com</$text>' +
+				'</paragraph>' +
+				'<paragraph>[]</paragraph>'
+			);
+		} );
+
+		it( 'adds "mailto://" to link of detected email addresses', () => {
+			simulateTyping( 'newsletter@cksource.com ' );
+
+			expect( getData( model ) ).to.equal(
+				'<paragraph><$text linkHref="mailto://newsletter@cksource.com">newsletter@cksource.com</$text> []</paragraph>'
+			);
+		} );
+
+		// Some examples came from https://mathiasbynens.be/demo/url-regex.
+		describe( 'supported URL', () => {
+			const supportedURLs = [
+				'http://cksource.com',
+				'https://cksource.com',
+				'https://cksource.com:8080',
+				'http://www.cksource.com',
+				'hTtP://WwW.cKsOuRcE.cOm',
+				'www.cksource.com',
+				'http://foo.bar.cksource.com',
+				'http://www.cksource.com/some/path/index.html#abc',
+				'http://www.cksource.com/some/path/index.html?foo=bar',
+				'http://www.cksource.com/some/path/index.html?foo=bar#abc',
+				'http://www.cksource.com:8080/some/path/index.html?foo=bar#abc',
+				'http://www.cksource.com/some/path/index.html#abc?foo=bar',
+				'ftp://cksource.com',
+				'http://cksource.com/foo_bar',
+				'http://cksource.com/foo_bar/',
+				'http://cksource.com/foo_bar_(wikipedia)',
+				'http://cksource.com/foo_bar_(wikipedia)_(again)',
+				'http://www.cksource.com/wpstyle/?p=364',
+				'http://www.cksource.com/wpstyle/?bar=baz&inga=42&quux',
+				'http://userid:password@example.com:8080' +
+				'http://userid:password@example.com:8080/' +
+				'http://userid@cksource.com' +
+				'http://userid@cksource.com/' +
+				'http://userid@cksource.com:8080' +
+				'http://userid@cksource.com:8080/' +
+				'http://userid:password@cksource.com' +
+				'http://userid:password@cksource.com/' +
+				'http://🥳df.ws/123',
+				'http://🥳.ws/富',
+				'http://🥳.ws',
+				'http://🥳.ws/',
+				'http://cksource.com/blah_(wikipedia)#cite-1',
+				'http://cksource.com/blah_(wikipedia)_blah#cite-1',
+				'http://cksource.com/unicode_(🥳)_in_parens',
+				'http://cksource.com/(something)?after=parens',
+				'http://🥳.cksource.com/',
+				'http://code.cksource.com/woot/#&product=browser',
+				'http://j.mp',
+				'ftp://cksource.com/baz',
+				'http://cksource.com/?q=Test%20URL-encoded%20stuff',
+				'http://مثال.إختبار',
+				'http://例子.测试',
+				'http://उदाहरण.परीक्षा',
+				'http://1337.net',
+				'http://a.b-c.de'
+			];
+
+			for ( const supportedURL of supportedURLs ) {
+				it( `should detect "${ supportedURL }" as a valid URL`, () => {
+					simulateTyping( supportedURL + ' ' );
+
+					expect( getData( model ) ).to.equal(
+						`<paragraph><$text linkHref="${ supportedURL }">${ supportedURL }</$text> []</paragraph>` );
+				} );
+			}
+		} );
+
+		describe( 'invalid or supported URL', () => {
+			// Some examples came from https://mathiasbynens.be/demo/url-regex.
+			const unsupportedOrInvalid = [
+				'http://',
+				'http://.',
+				'http://..',
+				'http://../',
+				'http://🥳',
+				'http://?',
+				'http://??',
+				'http://??/',
+				'http://#',
+				'http://##',
+				'http://##/',
+				'//',
+				'//a',
+				'///a',
+				'///',
+				'http:///a',
+				'rdar://1234',
+				'h://test',
+				':// foo bar',
+				'ftps://foo.bar/',
+				'http://-error-.invalid/',
+				'http://localhost',
+				'http:/cksource.com',
+				'cksource.com',
+				'ww.cksource.com'
+			];
+
+			for ( const unsupportedURL of unsupportedOrInvalid ) {
+				it( `should not detect "${ unsupportedURL }" as a valid URL`, () => {
+					simulateTyping( unsupportedURL + ' ' );
+
+					expect( getData( model ) ).to.equal(
+						`<paragraph>${ unsupportedURL } []</paragraph>` );
+				} );
+			}
+		} );
+	} );
+
+	describe( 'Undo integration', () => {
+		let model;
+
+		beforeEach( async () => {
+			editor = await ModelTestEditor.create( {
+				plugins: [ Paragraph, Input, Enter, ShiftEnter, LinkEditing, AutoLink, UndoEditing ]
+			} );
+
+			model = editor.model;
+
+			setData( model, '<paragraph>https://www.cksource.com[]</paragraph>' );
+		} );
+
+		it( 'should undo auto-linking (after space)', () => {
+			simulateTyping( ' ' );
+
+			editor.commands.execute( 'undo' );
+
+			expect( getData( model ) ).to.equal(
+				'<paragraph>https://www.cksource.com []</paragraph>'
+			);
+		} );
+
+		it( 'should undo auto-linking (after <softBreak>)', () => {
+			editor.execute( 'shiftEnter' );
+
+			editor.commands.execute( 'undo' );
+
+			expect( getData( model ) ).to.equal(
+				'<paragraph>https://www.cksource.com<softBreak></softBreak>[]</paragraph>'
+			);
+		} );
+
+		it( 'should undo auto-linking (after enter)', () => {
+			editor.execute( 'enter' );
+
+			editor.commands.execute( 'undo' );
+
+			expect( getData( model ) ).to.equal(
+				'<paragraph>https://www.cksource.com</paragraph>' +
+				'<paragraph>[]</paragraph>'
+			);
+		} );
+	} );
+
+	describe( 'Code blocks integration', () => {
+		let model;
+
+		beforeEach( async () => {
+			editor = await ModelTestEditor.create( {
+				plugins: [ Paragraph, Input, Enter, ShiftEnter, LinkEditing, AutoLink, CodeBlockEditing ]
+			} );
+
+			model = editor.model;
+		} );
+
+		it( 'should be disabled inside code blocks (on space)', () => {
+			setData( model, '<codeBlock language="plaintext">some [] code</codeBlock>' );
+
+			const plugin = editor.plugins.get( 'AutoLink' );
+
+			simulateTyping( 'www.cksource.com' );
+
+			expect( plugin.isEnabled ).to.be.false;
+			expect( getData( model, { withoutSelection: true } ) )
+				.to.equal( '<codeBlock language="plaintext">some www.cksource.com code</codeBlock>' );
+		} );
+
+		it( 'should be disabled inside code blocks (on enter)', () => {
+			setData( model, '<codeBlock language="plaintext">some www.cksource.com[] code</codeBlock>' );
+
+			const plugin = editor.plugins.get( 'AutoLink' );
+
+			editor.execute( 'enter' );
+
+			expect( plugin.isEnabled ).to.be.false;
+			expect( getData( model, { withoutSelection: true } ) ).to.equal(
+				'<codeBlock language="plaintext">some www.cksource.com</codeBlock>' +
+				'<codeBlock language="plaintext"> code</codeBlock>'
+			);
+		} );
+
+		it( 'should be disabled inside code blocks (on shift-enter)', () => {
+			setData( model, '<codeBlock language="plaintext">some www.cksource.com[] code</codeBlock>' );
+
+			const plugin = editor.plugins.get( 'AutoLink' );
+
+			editor.execute( 'shiftEnter' );
+
+			expect( plugin.isEnabled ).to.be.false;
+			expect( getData( model, { withoutSelection: true } ) ).to.equal(
+				'<codeBlock language="plaintext">some www.cksource.com<softBreak></softBreak> code</codeBlock>'
+			);
+		} );
+	} );
+
+	function simulateTyping( text ) {
+		const letters = text.split( '' );
+
+		for ( const letter of letters ) {
+			editor.execute( 'input', { text: letter } );
+		}
+	}
+} );

+ 18 - 9
packages/ckeditor5-link/tests/linkediting.js

@@ -75,15 +75,17 @@ describe( 'LinkEditing', () => {
 		expect( model.schema.checkAttribute( [ '$block' ], 'linkHref' ) ).to.be.false;
 	} );
 
-	// Let's check only the minimum to not duplicate `bindTwoStepCaretToAttribute()` tests.
+	// Let's check only the minimum to not duplicate `TwoStepCaretMovement` tests.
 	// Testing minimum is better than testing using spies that might give false positive results.
 	describe( 'two-step caret movement', () => {
-		it( 'should be bound to th `linkHref` attribute (LTR)', () => {
+		it( 'should be bound to the `linkHref` attribute (LTR)', () => {
+			const selection = editor.model.document.selection;
+
 			// Put selection before the link element.
 			setModelData( editor.model, '<paragraph>foo[]<$text linkHref="url">b</$text>ar</paragraph>' );
 
-			// The selection's gravity is not overridden because selection landed here not because of `keydown`.
-			expect( editor.model.document.selection.isGravityOverridden ).to.false;
+			// The selection's gravity should read attributes from the left.
+			expect( selection.hasAttribute( 'linkHref' ), 'hasAttribute( \'linkHref\' )' ).to.be.false;
 
 			// So let's simulate the `keydown` event.
 			editor.editing.view.document.fire( 'keydown', {
@@ -92,10 +94,13 @@ describe( 'LinkEditing', () => {
 				domTarget: document.body
 			} );
 
-			expect( editor.model.document.selection.isGravityOverridden ).to.true;
+			expect( getModelData( model ) ).to.equal( '<paragraph>foo<$text linkHref="url">[]b</$text>ar</paragraph>' );
+			// Selection should get the attributes from the right.
+			expect( selection.hasAttribute( 'linkHref' ), 'hasAttribute( \'linkHref\' )' ).to.be.true;
+			expect( selection.getAttribute( 'linkHref' ), 'linkHref attribute' ).to.equal( 'url' );
 		} );
 
-		it( 'should be bound to th `linkHref` attribute (RTL)', async () => {
+		it( 'should be bound to the `linkHref` attribute (RTL)', async () => {
 			const editor = await ClassicTestEditor.create( element, {
 				plugins: [ Paragraph, LinkEditing, Enter ],
 				language: {
@@ -105,12 +110,13 @@ describe( 'LinkEditing', () => {
 
 			model = editor.model;
 			view = editor.editing.view;
+			const selection = editor.model.document.selection;
 
 			// Put selection before the link element.
 			setModelData( editor.model, '<paragraph>foo[]<$text linkHref="url">b</$text>ar</paragraph>' );
 
-			// The selection's gravity is not overridden because selection landed here not because of `keydown`.
-			expect( editor.model.document.selection.isGravityOverridden ).to.false;
+			// The selection's gravity should read attributes from the left.
+			expect( selection.hasAttribute( 'linkHref' ), 'hasAttribute( \'linkHref\' )' ).to.be.false;
 
 			// So let's simulate the `keydown` event.
 			editor.editing.view.document.fire( 'keydown', {
@@ -119,7 +125,10 @@ describe( 'LinkEditing', () => {
 				domTarget: document.body
 			} );
 
-			expect( editor.model.document.selection.isGravityOverridden ).to.true;
+			expect( getModelData( model ) ).to.equal( '<paragraph>foo<$text linkHref="url">[]b</$text>ar</paragraph>' );
+			// Selection should get the attributes from the right.
+			expect( selection.hasAttribute( 'linkHref' ), 'hasAttribute( \'linkHref\' )' ).to.be.true;
+			expect( selection.getAttribute( 'linkHref' ), 'linkHref attribute' ).to.equal( 'url' );
 
 			await editor.destroy();
 		} );

+ 11 - 0
packages/ckeditor5-link/tests/manual/autolink.html

@@ -0,0 +1,11 @@
+<div id="editor">
+	<p>Should auto link: http://ckeditor.com</p>
+	<p>
+		Danish tootsie roll muffin bonbon muffin candy. Croissant cupcake muffin pastry jujubes sweet roll. Gingerbread jelly donut chocolate muffin ice cream cheesecake pastry. Caramels tiramisu muffin cookie. Tootsie roll liquorice cupcake jelly-o lemon drops lollipop. Cupcake soufflé candy canes danish biscuit tiramisu chocolate chocolate. Sesame snaps caramels brownie. Cookie biscuit biscuit apple pie candy. Chocolate apple pie sweet roll marshmallow wafer jelly beans sweet cake. Bear claw pastry wafer macaroon cake soufflé gummi bears cheesecake sweet. Jelly-o jelly beans halvah apple pie. Powder soufflé donut chocolate. Chocolate cake pie chupa chups donut dessert tootsie roll fruitcake. Apple pie cheesecake bonbon sweet roll tiramisu chupa chups ice cream gummies dessert.
+		Caramels sweet pie cake carrot cake liquorice. Dessert gingerbread chocolate cake macaroon gummi bears carrot cake sesame snaps. Marshmallow jujubes cake jelly. Tiramisu lollipop chocolate cake. Jelly beans topping gingerbread jelly. Ice cream jujubes liquorice caramels candy canes. Marshmallow fruitcake danish jelly beans macaroon tart chupa chups cake. Dragée cheesecake danish sugar plum marshmallow sweet roll jujubes. Gummi bears marzipan marzipan. Sweet roll jujubes chocolate. Pastry lemon drops dragée sesame snaps ice cream. Donut candy dragée sweet roll.
+		Candy cupcake carrot cake dragée. Brownie oat cake candy. Fruitcake candy canes cookie muffin sweet roll dessert. Sweet icing halvah dragée muffin. Cotton candy carrot cake croissant sweet caramels halvah jelly beans lemon drops danish. Fruitcake dessert pudding marshmallow sugar plum. Cake cotton candy jelly-o sweet tootsie roll halvah chocolate cake. Lollipop cake marshmallow chocolate chocolate bar. Sesame snaps halvah fruitcake lollipop bonbon bear claw danish chocolate cake. Chupa chups sweet roll candy canes jelly. Danish macaroon ice cream cheesecake cake. Jelly beans caramels fruitcake donut ice cream cookie chupa chups pie. Toffee danish jelly beans chupa chups sweet topping chupa chups lollipop. Oat cake jelly-o pie fruitcake chupa chups.
+		Gingerbread caramels gummi bears chupa chups topping pie macaroon. Toffee apple pie carrot cake. Cake muffin sesame snaps candy canes cake marzipan carrot cake oat cake. Liquorice tootsie roll chupa chups cake sweet. Fruitcake tootsie roll tart. Caramels lemon drops cookie sweet roll halvah icing carrot cake jelly-o. Chocolate cake jelly muffin candy apple pie tiramisu. Chocolate lollipop gummi bears pie cake marshmallow toffee cheesecake. Gingerbread tootsie roll topping cake pastry. Candy lemon drops bonbon icing fruitcake chupa chups sugar plum. Jelly beans biscuit sugar plum jelly-o cupcake. Macaroon sesame snaps tiramisu.
+		Cookie jujubes jelly-o candy icing pie bonbon. Chocolate soufflé apple pie jelly beans jujubes. Macaroon pastry danish. Lemon drops lollipop cake bear claw cake. Pastry lemon drops chocolate cake liquorice chocolate toffee. Carrot cake dragée liquorice powder gingerbread bonbon jelly halvah. Lollipop candy canes lollipop candy sugar plum. Danish cake candy. Tiramisu candy jelly. Pudding cookie jelly brownie icing cupcake gingerbread sweet. Powder donut jelly-o sugar plum. Sweet pie gummi bears cake chupa chups bonbon chocolate cake cake danish. Tart cheesecake cheesecake wafer cotton candy.
+		<strong>Should auto link</strong>: http://ckeditor.com
+	</p>
+</div>

+ 30 - 0
packages/ckeditor5-link/tests/manual/autolink.js

@@ -0,0 +1,30 @@
+/**
+ * @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 console, window, document */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+
+import Enter from '@ckeditor/ckeditor5-enter/src/enter';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import ShiftEnter from '@ckeditor/ckeditor5-enter/src/shiftenter';
+import Typing from '@ckeditor/ckeditor5-typing/src/typing';
+import Undo from '@ckeditor/ckeditor5-undo/src/undo';
+
+import Link from '../../src/link';
+import AutoLink from '../../src/autolink';
+import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
+
+ClassicEditor
+	.create( document.querySelector( '#editor' ), {
+		plugins: [ Bold, Typing, Paragraph, Undo, Enter, ShiftEnter, Link, AutoLink ],
+		toolbar: [ 'link', 'undo', 'redo' ]
+	} )
+	.then( editor => {
+		window.editor = editor;
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );

+ 25 - 0
packages/ckeditor5-link/tests/manual/autolink.md

@@ -0,0 +1,25 @@
+## AutoLink feature
+
+### After a space
+
+1. Type a URL:
+    - Staring with `http://`.
+    - staring with `https://`.
+    - staring without a protocol (www.cksource.com).
+    - e-mail address should be linked using `mailto://` (in `linkHref` attribute value only).
+2. Type space after a URL.
+3. Check if text typed before space get converted to link.
+
+### After a soft break/new paragraph
+
+1. Type a URL as in base scenario.
+2. Press <kbd>Enter</kbd> or <kbd>Shift</kbd>+<kbd>Enter</kbd> after a link.
+3. Check if text typed pressed key get converted to link.
+
+### Undo integration
+
+1. Execute auto link either with "space" or with "enter" scenarios.
+2. Execute undo.
+3. Check if *only* created link was removed:
+    - For "space" - the space after the text link should be preserved.
+    - For "enter" - the new block or `<softBreak>` should be preserved.

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

+ 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';
 
@@ -115,9 +115,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 ) );
@@ -145,6 +144,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;

+ 8 - 4
packages/ckeditor5-table/tests/tableselection-integration.js

@@ -18,7 +18,7 @@ import TableClipboard from '../src/tableclipboard';
 
 import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 
-import { modelTable } from './_utils/utils';
+import { assertSelectedCells, modelTable } from './_utils/utils';
 import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
 import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata';
 import { getCode } from '@ckeditor/ckeditor5-utils/src/keyboard';
@@ -227,7 +227,6 @@ describe( 'TableSelection - integration', () => {
 			await setupEditor( [ UndoEditing ] );
 		} );
 
-		// See https://github.com/ckeditor/ckeditor5/issues/6634.
 		it( 'works with merge cells command', () => {
 			setModelData( editor.model, modelTable( [
 				[ '00', '01' ],
@@ -248,10 +247,15 @@ describe( 'TableSelection - integration', () => {
 
 			editor.execute( 'undo' );
 
-			assertEqualMarkup( getModelData( model ), modelTable( [
-				[ '[]00', '01' ],
+			assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+				[ '00', '01' ],
 				[ '10', '11' ]
 			] ) );
+
+			assertSelectedCells( model, [
+				[ 1, 1 ],
+				[ 0, 0 ]
+			] );
 		} );
 	} );
 

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

@@ -959,6 +959,21 @@ describe( 'TableUtils', () => {
 				], { headingRows: 1 } ) );
 			} );
 
+			it( 'should change heading rows if removing a heading row (and cell below is row-spanned)', () => {
+				setData( model, modelTable( [
+					[ '00', '01' ],
+					[ '10', { contents: '11', rowspan: 2 } ],
+					[ '20' ]
+				], { headingRows: 1 } ) );
+
+				tableUtils.removeRows( root.getChild( 0 ), { at: 0 } );
+
+				assertEqualMarkup( getData( model, { withoutSelection: true } ), modelTable( [
+					[ '10', { contents: '11', rowspan: 2 } ],
+					[ '20' ]
+				] ) );
+			} );
+
 			it( 'should decrease rowspan of table cells from previous rows', () => {
 				// +----+----+----+----+----+
 				// | 00 | 01 | 02 | 03 | 04 |
@@ -1511,4 +1526,70 @@ describe( 'TableUtils', () => {
 			} );
 		} );
 	} );
+
+	describe( 'createTable()', () => {
+		it( 'should create table', () => {
+			setData( model, '[]' );
+
+			model.change( writer => {
+				const table = tableUtils.createTable( writer, { rows: 3, columns: 2 } );
+
+				model.insertContent( table, model.document.selection.focus );
+			} );
+
+			assertEqualMarkup( getData( model, { withoutSelection: true } ), modelTable( [
+				[ '', '' ],
+				[ '', '' ],
+				[ '', '' ]
+			] ) );
+		} );
+
+		it( 'should create table with heading rows', () => {
+			setData( model, '[]' );
+
+			model.change( writer => {
+				const table = tableUtils.createTable( writer, { rows: 3, columns: 2, headingRows: 1 } );
+
+				model.insertContent( table, model.document.selection.focus );
+			} );
+
+			assertEqualMarkup( getData( model, { withoutSelection: true } ), modelTable( [
+				[ '', '' ],
+				[ '', '' ],
+				[ '', '' ]
+			], { headingRows: 1 } ) );
+		} );
+
+		it( 'should create table with heading columns', () => {
+			setData( model, '[]' );
+
+			model.change( writer => {
+				const table = tableUtils.createTable( writer, { rows: 3, columns: 2, headingColumns: 1 } );
+
+				model.insertContent( table, model.document.selection.focus );
+			} );
+
+			assertEqualMarkup( getData( model, { withoutSelection: true } ), modelTable( [
+				[ '', '' ],
+				[ '', '' ],
+				[ '', '' ]
+			], { headingColumns: 1 } ) );
+		} );
+
+		it( 'should create table with heading rows and columns', () => {
+			setData( model, '[]' );
+
+			model.change( writer => {
+				const table = tableUtils.createTable( writer, { rows: 3, columns: 2, headingRows: 2, headingColumns: 1 } );
+
+				model.insertContent( table, model.document.selection.focus );
+			} );
+
+			assertEqualMarkup( getData( model, { withoutSelection: true } ), modelTable( [
+				[ '', '' ],
+				[ '', '' ],
+				[ '', '' ]
+			], { headingRows: 2, headingColumns: 1 } ) );
+		} );
+	} );
 } );

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

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

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

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

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

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

+ 1 - 1
packages/ckeditor5-typing/src/input.js

@@ -47,7 +47,7 @@ export default class Input extends Plugin {
 	 *		const input = editor.plugins.get( 'Input' );
 	 *
 	 *		editor.model.document.on( 'change:data', ( evt, batch ) => {
-	 *			if ( input.isTyping( batch ) ) {
+	 *			if ( input.isInput( batch ) ) {
 	 *				console.log( 'The user typed something...' );
 	 *			}
 	 *		} );

+ 101 - 59
packages/ckeditor5-engine/src/utils/bindtwostepcarettoattribute.js → packages/ckeditor5-typing/src/twostepcaretmovement.js

@@ -4,27 +4,30 @@
  */
 
 /**
- * @module engine/utils/bindtwostepcarettoattribute
+ * @module typing/twostepcaretmovement
  */
 
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+
 import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
 import priorities from '@ckeditor/ckeditor5-utils/src/priorities';
 
 /**
- * This helper enables the two-step caret (phantom) movement behavior for the given {@link module:engine/model/model~Model}
- * attribute on arrow right (<kbd>→</kbd>) and left (<kbd>←</kbd>) key press.
+ * This plugin enables the two-step caret (phantom) movement behavior for
+ * {@link module:typing/twostepcaretmovement~TwoStepCaretMovement#registerAttribute registered attributes}
+ * on arrow right (<kbd>→</kbd>) and left (<kbd>←</kbd>) key press.
  *
  * Thanks to this (phantom) caret movement the user is able to type before/after as well as at the
  * beginning/end of an attribute.
  *
- * **Note:** This helper support right–to–left (Arabic, Hebrew, etc.) content by mirroring its behavior
+ * **Note:** This plugin support right–to–left (Arabic, Hebrew, etc.) content by mirroring its behavior
  * but for the sake of simplicity examples showcase only left–to–right use–cases.
  *
  * # Forward movement
  *
  * ## "Entering" an attribute:
  *
- * When this behavior is enabled for the `a` attribute and the selection is right before it
+ * When this plugin is enabled and registered for the `a` attribute and the selection is right before it
  * (at the attribute boundary), pressing the right arrow key will not move the selection but update its
  * attributes accordingly:
  *
@@ -80,70 +83,109 @@ import priorities from '@ckeditor/ckeditor5-utils/src/priorities';
  *   <kbd>←</kbd>
  *
  *   		<$text a="true">ba{}r</$text>b{}az
- *
- * @param {Object} options Helper options.
- * @param {module:engine/view/view~View} options.view View controller instance.
- * @param {module:engine/model/model~Model} options.model Data model instance.
- * @param {module:utils/dom/emittermixin~Emitter} options.emitter The emitter to which this behavior should be added
- * (e.g. a plugin instance).
- * @param {String} options.attribute Attribute for which this behavior will be added.
- * @param {module:utils/locale~Locale} options.locale The {@link module:core/editor/editor~Editor#locale} instance.
  */
-export default function bindTwoStepCaretToAttribute( { view, model, emitter, attribute, locale } ) {
-	const twoStepCaretHandler = new TwoStepCaretHandler( model, emitter, attribute );
-	const modelSelection = model.document.selection;
-
-	// Listen to keyboard events and handle the caret movement according to the 2-step caret logic.
-	//
-	// Note: This listener has the "high+1" priority:
-	// * "high" because of the filler logic implemented in the renderer which also engages on #keydown.
-	// When the gravity is overridden the attributes of the (model) selection attributes are reset.
-	// It may end up with the filler kicking in and breaking the selection.
-	// * "+1" because we would like to avoid collisions with other features (like Widgets), which
-	// take over the keydown events with the "high" priority. Two-step caret movement takes precedence
-	// over Widgets in that matter.
-	//
-	// Find out more in https://github.com/ckeditor/ckeditor5-engine/issues/1301.
-	emitter.listenTo( view.document, 'keydown', ( evt, data ) => {
-		// This implementation works only for collapsed selection.
-		if ( !modelSelection.isCollapsed ) {
-			return;
-		}
+export default class TwoStepCaretMovement extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'TwoStepCaretMovement';
+	}
 
-		// When user tries to expand the selection or jump over the whole word or to the beginning/end then
-		// two-steps movement is not necessary.
-		if ( data.shiftKey || data.altKey || data.ctrlKey ) {
-			return;
-		}
+	/**
+	 * @inheritDoc
+	 */
+	constructor( editor ) {
+		super( editor );
 
-		const arrowRightPressed = data.keyCode == keyCodes.arrowright;
-		const arrowLeftPressed = data.keyCode == keyCodes.arrowleft;
+		/**
+		 * A map of handlers for each attribute.
+		 *
+		 * @protected
+		 * @property {module:typing/twostepcaretmovement~TwoStepCaretMovement}
+		 */
+		this._handlers = new Map();
+	}
 
-		// When neither left or right arrow has been pressed then do noting.
-		if ( !arrowRightPressed && !arrowLeftPressed ) {
-			return;
-		}
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+		const model = editor.model;
+		const view = editor.editing.view;
+		const locale = editor.locale;
 
-		const position = modelSelection.getFirstPosition();
-		const contentDirection = locale.contentLanguageDirection;
-		let isMovementHandled;
+		const modelSelection = model.document.selection;
 
-		if ( ( contentDirection === 'ltr' && arrowRightPressed ) || ( contentDirection === 'rtl' && arrowLeftPressed ) ) {
-			isMovementHandled = twoStepCaretHandler.handleForwardMovement( position, data );
-		} else {
-			isMovementHandled = twoStepCaretHandler.handleBackwardMovement( position, data );
-		}
+		// Listen to keyboard events and handle the caret movement according to the 2-step caret logic.
+		//
+		// Note: This listener has the "high+1" priority:
+		// * "high" because of the filler logic implemented in the renderer which also engages on #keydown.
+		// When the gravity is overridden the attributes of the (model) selection attributes are reset.
+		// It may end up with the filler kicking in and breaking the selection.
+		// * "+1" because we would like to avoid collisions with other features (like Widgets), which
+		// take over the keydown events with the "high" priority. Two-step caret movement takes precedence
+		// over Widgets in that matter.
+		//
+		// Find out more in https://github.com/ckeditor/ckeditor5-engine/issues/1301.
+		this.listenTo( view.document, 'keydown', ( evt, data ) => {
+			// This implementation works only for collapsed selection.
+			if ( !modelSelection.isCollapsed ) {
+				return;
+			}
 
-		// Stop the keydown event if the two-step caret movement handled it. Avoid collisions
-		// with other features which may also take over the caret movement (e.g. Widget).
-		if ( isMovementHandled ) {
-			evt.stop();
-		}
-	}, { priority: priorities.get( 'high' ) + 1 } );
+			// When user tries to expand the selection or jump over the whole word or to the beginning/end then
+			// two-steps movement is not necessary.
+			if ( data.shiftKey || data.altKey || data.ctrlKey ) {
+				return;
+			}
+
+			const arrowRightPressed = data.keyCode == keyCodes.arrowright;
+			const arrowLeftPressed = data.keyCode == keyCodes.arrowleft;
+
+			// When neither left or right arrow has been pressed then do noting.
+			if ( !arrowRightPressed && !arrowLeftPressed ) {
+				return;
+			}
+
+			const position = modelSelection.getFirstPosition();
+			const contentDirection = locale.contentLanguageDirection;
+			let isMovementHandled = false;
+
+			if ( ( contentDirection === 'ltr' && arrowRightPressed ) || ( contentDirection === 'rtl' && arrowLeftPressed ) ) {
+				for ( const [ , handler ] of this._handlers ) {
+					isMovementHandled = isMovementHandled || handler.handleForwardMovement( position, data );
+				}
+			} else {
+				for ( const [ , handler ] of this._handlers ) {
+					isMovementHandled = isMovementHandled || handler.handleBackwardMovement( position, data );
+				}
+			}
+
+			// Stop the keydown event if the two-step caret movement handled it. Avoid collisions
+			// with other features which may also take over the caret movement (e.g. Widget).
+			if ( isMovementHandled ) {
+				evt.stop();
+			}
+		}, { priority: priorities.get( 'high' ) + 1 } );
+	}
+
+	/**
+	 * Registers a given attribute for the two-step caret movement.
+	 *
+	 * @param {String} attribute Name of the attribute to handle.
+	 */
+	registerAttribute( attribute ) {
+		this._handlers.set(
+			attribute,
+			new TwoStepCaretHandler( this.editor.model, this, attribute )
+		);
+	}
 }
 
 /**
- * This is a protected helper–class for {@link module:engine/utils/bindtwostepcarettoattribute}.
+ * This is a protected helper–class for {@link module:typing/twostepcaretmovement}.
  * It handles the state of the 2-step caret movement for a single {@link module:engine/model/model~Model}
  * attribute upon the `keypress` in the {@link module:engine/view/view~View}.
  *

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