Bläddra i källkod

Merge branch 'master' into i/6170

Marek Lewandowski 5 år sedan
förälder
incheckning
bb593ffd0b
58 ändrade filer med 2173 tillägg och 496 borttagningar
  1. 0 10
      .github/ISSUE_TEMPLATE/4-security-issue.md
  2. 10 0
      .github/ISSUE_TEMPLATE/config.yml
  3. 13 0
      docs/framework/guides/contributing/code-style.md
  4. 79 0
      packages/ckeditor5-core/tests/_utils-tests/assertions/attribute.js
  5. 61 0
      packages/ckeditor5-core/tests/_utils/assertions/attribute.js
  6. 6 7
      packages/ckeditor5-engine/src/conversion/mapper.js
  7. 57 0
      packages/ckeditor5-engine/src/model/range.js
  8. 3 0
      packages/ckeditor5-engine/src/view/downcastwriter.js
  9. 6 2
      packages/ckeditor5-engine/tests/conversion/mapper.js
  10. 110 0
      packages/ckeditor5-engine/tests/model/range.js
  11. 14 0
      packages/ckeditor5-image/docs/_snippets/features/image-resizeui.html
  12. 49 0
      packages/ckeditor5-image/docs/_snippets/features/image-resizeui.js
  13. 14 0
      packages/ckeditor5-image/docs/_snippets/features/image-resizeuidropdown.html
  14. 43 0
      packages/ckeditor5-image/docs/_snippets/features/image-resizeuidropdown.js
  15. 69 2
      packages/ckeditor5-image/docs/features/image.md
  16. 4 1
      packages/ckeditor5-image/lang/contexts.json
  17. 101 109
      packages/ckeditor5-image/src/imageresize.js
  18. 10 3
      packages/ckeditor5-image/src/imageresize/imageresizecommand.js
  19. 142 0
      packages/ckeditor5-image/src/imageresize/imageresizeediting.js
  20. 199 0
      packages/ckeditor5-image/src/imageresize/imageresizeui.js
  21. 18 0
      packages/ckeditor5-image/tests/imageresize/imageresize.js
  22. 14 10
      packages/ckeditor5-image/tests/imageresize/imageresizeediting.js
  23. 255 0
      packages/ckeditor5-image/tests/imageresize/imageresizeui.js
  24. 37 0
      packages/ckeditor5-image/tests/manual/imageresizeui.html
  25. 121 0
      packages/ckeditor5-image/tests/manual/imageresizeui.js
  26. 11 0
      packages/ckeditor5-image/tests/manual/imageresizeui.md
  27. 6 2
      packages/ckeditor5-link/docs/api/link.md
  28. 7 5
      packages/ckeditor5-link/docs/features/link.md
  29. 1 1
      packages/ckeditor5-link/package.json
  30. 4 4
      packages/ckeditor5-link/src/link.js
  31. 10 9
      packages/ckeditor5-link/src/linkediting.js
  32. 3 3
      packages/ckeditor5-link/src/linkimageui.js
  33. 18 9
      packages/ckeditor5-link/tests/linkediting.js
  34. 59 62
      packages/ckeditor5-list/src/todolistconverters.js
  35. 33 25
      packages/ckeditor5-list/src/todolistediting.js
  36. 29 0
      packages/ckeditor5-list/src/utils.js
  37. 4 2
      packages/ckeditor5-list/tests/manual/todo-list.js
  38. 189 38
      packages/ckeditor5-list/tests/todolistediting.js
  39. 0 5
      packages/ckeditor5-table/src/commands/mergecellscommand.js
  40. 1 4
      packages/ckeditor5-table/src/commands/removerowcommand.js
  41. 8 4
      packages/ckeditor5-table/tests/tableselection-integration.js
  42. 1 1
      packages/ckeditor5-typing/src/input.js
  43. 101 59
      packages/ckeditor5-typing/src/twostepcaretmovement.js
  44. 0 0
      packages/ckeditor5-typing/tests/manual/1301/1.html
  45. 4 10
      packages/ckeditor5-typing/tests/manual/1301/1.js
  46. 1 1
      packages/ckeditor5-typing/tests/manual/1301/1.md
  47. 0 0
      packages/ckeditor5-typing/tests/manual/two-step-caret.html
  48. 9 35
      packages/ckeditor5-typing/tests/manual/two-step-caret.js
  49. 1 1
      packages/ckeditor5-typing/tests/manual/two-step-caret.md
  50. 12 30
      packages/ckeditor5-typing/tests/twostepcaretmovement.js
  51. 2 1
      packages/ckeditor5-undo/package.json
  52. 30 23
      packages/ckeditor5-undo/src/basecommand.js
  53. 86 7
      packages/ckeditor5-undo/tests/undoediting-integration.js
  54. 13 4
      packages/ckeditor5-widget/src/widgetresize.js
  55. 55 0
      packages/ckeditor5-widget/tests/widgetresize-integration.js
  56. 22 1
      packages/ckeditor5-widget/tests/widgetresize.js
  57. 5 4
      packages/ckeditor5-widget/tests/widgetresize/_utils/utils.js
  58. 13 2
      scripts/bump-year.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

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

+ 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 \'bar\', but got \'baz\'' );
+	} );
+
+	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}`,
+			attributeValue,
+			value
+		);
+	}
+} );

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

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

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

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

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

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

+ 1 - 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"
@@ -26,7 +27,6 @@
     "@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": {

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

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

+ 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 - 5
packages/ckeditor5-table/src/commands/mergecellscommand.js

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

+ 1 - 4
packages/ckeditor5-table/src/commands/removerowcommand.js

@@ -63,10 +63,7 @@ export default class RemoveRowCommand extends Command {
 		// 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.enqueueChange( batch, () => {
 			const rowsToRemove = removedRowIndexes.last - removedRowIndexes.first + 1;
 
 			this.editor.plugins.get( 'TableUtils' ).removeRows( table, {

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

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

+ 0 - 0
packages/ckeditor5-engine/tests/manual/tickets/1301/1.html → packages/ckeditor5-typing/tests/manual/1301/1.html


+ 4 - 10
packages/ckeditor5-engine/tests/manual/tickets/1301/1.js → packages/ckeditor5-typing/tests/manual/1301/1.js

@@ -11,23 +11,17 @@ import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
 import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic';
 
-import bindTwoStepCaretToAttribute from '../../../../src/utils/bindtwostepcarettoattribute';
+import TwoStepCaretMovement from '../../../src/twostepcaretmovement';
 
 ClassicEditor
 	.create( document.querySelector( '#editor' ), {
-		plugins: [ Essentials, Paragraph, Bold, Italic ],
+		plugins: [ Essentials, Paragraph, Bold, Italic, TwoStepCaretMovement ],
 		toolbar: [ 'undo', 'redo', '|', 'bold', 'italic' ]
 	} )
 	.then( editor => {
-		const bold = editor.plugins.get( Bold );
+		const twoStepCaretMovement = editor.plugins.get( TwoStepCaretMovement );
 
-		bindTwoStepCaretToAttribute( {
-			view: editor.editing.view,
-			model: editor.model,
-			emitter: bold,
-			attribute: 'bold',
-			locale: editor.locale
-		} );
+		twoStepCaretMovement.registerAttribute( 'bold' );
 	} )
 	.catch( err => {
 		console.error( err.stack );

+ 1 - 1
packages/ckeditor5-engine/tests/manual/tickets/1301/1.md → packages/ckeditor5-typing/tests/manual/1301/1.md

@@ -1,4 +1,4 @@
-## Two-steps caret movement [#1301](https://github.com/ckeditor/ckeditor5-engine/issues/1301)
+## Two-steps caret movement [ckeditor5-engine#1301](https://github.com/ckeditor/ckeditor5-engine/issues/1301)
 
 1. Put the selection at the end of the content.
 2. Press the <kbd>←</kbd> key 3 times.

+ 0 - 0
packages/ckeditor5-engine/tests/manual/two-step-caret.html → packages/ckeditor5-typing/tests/manual/two-step-caret.html


+ 9 - 35
packages/ckeditor5-engine/tests/manual/two-step-caret.js → packages/ckeditor5-typing/tests/manual/two-step-caret.js

@@ -12,31 +12,18 @@ import Underline from '@ckeditor/ckeditor5-basic-styles/src/underline';
 import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
 import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic';
 
-import bindTwoStepCaretToAttribute from '../../src/utils/bindtwostepcarettoattribute';
+import TwoStepCaretMovement from '../../src/twostepcaretmovement';
 
 ClassicEditor
 	.create( document.querySelector( '#editor-ltr' ), {
-		plugins: [ Essentials, Paragraph, Underline, Bold, Italic ],
+		plugins: [ Essentials, Paragraph, Underline, Bold, Italic, TwoStepCaretMovement ],
 		toolbar: [ 'undo', 'redo', '|', 'bold', 'underline', 'italic' ]
 	} )
 	.then( editor => {
-		const bold = editor.plugins.get( Italic );
-		const underline = editor.plugins.get( Underline );
+		const twoStepCaretMovement = editor.plugins.get( TwoStepCaretMovement );
 
-		bindTwoStepCaretToAttribute( {
-			view: editor.editing.view,
-			model: editor.model,
-			emitter: bold,
-			attribute: 'italic',
-			locale: editor.locale
-		} );
-		bindTwoStepCaretToAttribute( {
-			view: editor.editing.view,
-			model: editor.model,
-			emitter: underline,
-			attribute: 'underline',
-			locale: editor.locale
-		} );
+		twoStepCaretMovement.registerAttribute( 'italic' );
+		twoStepCaretMovement.registerAttribute( 'underline' );
 	} )
 	.catch( err => {
 		console.error( err.stack );
@@ -47,27 +34,14 @@ ClassicEditor
 		language: {
 			content: 'he'
 		},
-		plugins: [ Essentials, Paragraph, Underline, Bold, Italic ],
+		plugins: [ Essentials, Paragraph, Underline, Bold, Italic, TwoStepCaretMovement ],
 		toolbar: [ 'undo', 'redo', '|', 'bold', 'underline', 'italic' ]
 	} )
 	.then( editor => {
-		const bold = editor.plugins.get( Italic );
-		const underline = editor.plugins.get( Underline );
+		const twoStepCaretMovement = editor.plugins.get( TwoStepCaretMovement );
 
-		bindTwoStepCaretToAttribute( {
-			view: editor.editing.view,
-			model: editor.model,
-			emitter: bold,
-			attribute: 'italic',
-			locale: editor.locale
-		} );
-		bindTwoStepCaretToAttribute( {
-			view: editor.editing.view,
-			model: editor.model,
-			emitter: underline,
-			attribute: 'underline',
-			locale: editor.locale
-		} );
+		twoStepCaretMovement.registerAttribute( 'italic' );
+		twoStepCaretMovement.registerAttribute( 'underline' );
 	} )
 	.catch( err => {
 		console.error( err.stack );

+ 1 - 1
packages/ckeditor5-engine/tests/manual/two-step-caret.md → packages/ckeditor5-typing/tests/manual/two-step-caret.md

@@ -1,4 +1,4 @@
-## Two-steps caret movement [#1286](https://github.com/ckeditor/ckeditor5-engine/issues/1289)
+## Two-steps caret movement [ckeditor5-engine#1286](https://github.com/ckeditor/ckeditor5-engine/issues/1289)
 
 ### Moving right
 1. Put selection one character before the underline

+ 12 - 30
packages/ckeditor5-engine/tests/utils/bindtwostepcarettoattribute.js → packages/ckeditor5-typing/tests/twostepcaretmovement.js

@@ -7,16 +7,16 @@
 
 import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
 import DomEmitterMixin from '@ckeditor/ckeditor5-utils/src/dom/emittermixin';
-import DomEventData from '../../src/view/observer/domeventdata';
+import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata';
 import EventInfo from '@ckeditor/ckeditor5-utils/src/eventinfo';
-import bindTwoStepCaretToAttribute, { TwoStepCaretHandler } from '../../src/utils/bindtwostepcarettoattribute';
-import Position from '../../src/model/position';
+import TwoStepCaretMovement, { TwoStepCaretHandler } from '../src/twostepcaretmovement';
+import Position from '@ckeditor/ckeditor5-engine/src/model/position';
 import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
 import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
-import { setData } from '../../src/dev-utils/model';
+import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 
-describe( 'bindTwoStepCaretToAttribute()', () => {
-	let editor, model, emitter, selection, view, locale;
+describe( 'TwoStepCaretMovement()', () => {
+	let editor, model, emitter, selection, view, plugin;
 	let preventDefaultSpy, evtStopSpy;
 
 	testUtils.createSinonSandbox();
@@ -24,12 +24,12 @@ describe( 'bindTwoStepCaretToAttribute()', () => {
 	beforeEach( () => {
 		emitter = Object.create( DomEmitterMixin );
 
-		return VirtualTestEditor.create().then( newEditor => {
+		return VirtualTestEditor.create( { plugins: [ TwoStepCaretMovement ] } ).then( newEditor => {
 			editor = newEditor;
 			model = editor.model;
 			selection = model.document.selection;
 			view = editor.editing.view;
-			locale = editor.locale;
+			plugin = editor.plugins.get( TwoStepCaretMovement );
 
 			preventDefaultSpy = sinon.spy();
 			evtStopSpy = sinon.spy();
@@ -45,13 +45,7 @@ describe( 'bindTwoStepCaretToAttribute()', () => {
 			editor.conversion.for( 'upcast' ).elementToAttribute( { view: 'c', model: 'c' } );
 			editor.conversion.elementToElement( { model: 'paragraph', view: 'p' } );
 
-			bindTwoStepCaretToAttribute( {
-				view: editor.editing.view,
-				model: editor.model,
-				emitter,
-				attribute: 'a',
-				locale
-			} );
+			plugin.registerAttribute( 'a' );
 		} );
 	} );
 
@@ -560,13 +554,7 @@ describe( 'bindTwoStepCaretToAttribute()', () => {
 
 	describe( 'multiple attributes', () => {
 		beforeEach( () => {
-			bindTwoStepCaretToAttribute( {
-				view: editor.editing.view,
-				model: editor.model,
-				emitter,
-				attribute: 'c',
-				locale
-			} );
+			plugin.registerAttribute( 'c' );
 		} );
 
 		it( 'should work with the two-step caret movement (moving right)', () => {
@@ -786,12 +774,12 @@ describe( 'bindTwoStepCaretToAttribute()', () => {
 		it( 'should use the opposite helper methods (RTL content direction)', () => {
 			const forwardStub = testUtils.sinon.stub( TwoStepCaretHandler.prototype, 'handleForwardMovement' );
 			const backwardStub = testUtils.sinon.stub( TwoStepCaretHandler.prototype, 'handleBackwardMovement' );
-			const emitter = Object.create( DomEmitterMixin );
 
 			let model;
 
 			return VirtualTestEditor
 				.create( {
+					plugins: [ TwoStepCaretMovement ],
 					language: {
 						content: 'ar'
 					}
@@ -812,13 +800,7 @@ describe( 'bindTwoStepCaretToAttribute()', () => {
 					newEditor.conversion.for( 'upcast' ).elementToAttribute( { view: 'c', model: 'c' } );
 					newEditor.conversion.elementToElement( { model: 'paragraph', view: 'p' } );
 
-					bindTwoStepCaretToAttribute( {
-						view: newEditor.editing.view,
-						model: newEditor.model,
-						emitter,
-						attribute: 'a',
-						locale: newEditor.locale
-					} );
+					newEditor.plugins.get( TwoStepCaretMovement ).registerAttribute( 'a' );
 
 					return newEditor;
 				} )

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

@@ -22,7 +22,8 @@
     "@ckeditor/ckeditor5-heading": "^20.0.0",
     "@ckeditor/ckeditor5-paragraph": "^20.0.0",
     "@ckeditor/ckeditor5-typing": "^20.0.0",
-    "@ckeditor/ckeditor5-utils": "^20.0.0"
+    "@ckeditor/ckeditor5-utils": "^20.0.0",
+    "@ckeditor/ckeditor5-table": "^20.0.0"
   },
   "engines": {
     "node": ">=12.0.0",

+ 30 - 23
packages/ckeditor5-undo/src/basecommand.js

@@ -93,15 +93,23 @@ export default class BaseCommand extends Command {
 		const selectionRanges = [];
 
 		// Transform all ranges from the restored selection.
-		for ( const range of ranges ) {
-			const transformed = transformSelectionRange( range, operations );
+		const transformedRangeGroups = ranges.map( range => range.getTransformedByOperations( operations ) );
+		const allRanges = transformedRangeGroups.flat();
+
+		for ( const rangeGroup of transformedRangeGroups ) {
+			// While transforming there could appear ranges that are contained by other ranges, we shall ignore them.
+			const transformed = rangeGroup.filter( range => !isRangeContainedByAnyOtherRange( range, allRanges ) );
+
+			// After the range got transformed, we have an array of ranges. Some of those
+			// ranges may be "touching" -- they can be next to each other and could be merged.
+			normalizeRanges( transformed );
 
 			// For each `range` from `ranges`, we take only one transformed range.
 			// This is because we want to prevent situation where single-range selection
 			// got transformed to multi-range selection. We will take the first range that
 			// is not in the graveyard.
 			const newRange = transformed.find(
-				range => range.start.root != document.graveyard
+				range => range.root != document.graveyard
 			);
 
 			// `transformedRange` might be `undefined` if transformed range ended up in graveyard.
@@ -110,6 +118,8 @@ export default class BaseCommand extends Command {
 			}
 		}
 
+		// @if CK_DEBUG_ENGINE // console.log( `Restored selection by undo: ${ selectionRanges.join( ', ' ) }` );
+
 		// `selectionRanges` may be empty if all ranges ended up in graveyard. If that is the case, do not restore selection.
 		if ( selectionRanges.length ) {
 			model.change( writer => {
@@ -167,28 +177,25 @@ export default class BaseCommand extends Command {
 	}
 }
 
-// Transforms given range `range` by given `operations`.
-// Returns an array containing one or more ranges, which are result of the transformation.
-function transformSelectionRange( range, operations ) {
-	const transformed = range.getTransformedByOperations( operations );
-
-	// After `range` got transformed, we have an array of ranges. Some of those
-	// ranges may be "touching" -- they can be next to each other and could be merged.
-	// First, we have to sort those ranges to assure that they are in order.
-	transformed.sort( ( a, b ) => a.start.isBefore( b.start ) ? -1 : 1 );
-
-	// Then, we check if two consecutive ranges are touching.
-	for ( let i = 1; i < transformed.length; i++ ) {
-		const a = transformed[ i - 1 ];
-		const b = transformed[ i ];
-
-		if ( a.end.isTouching( b.start ) ) {
-			// And join them together if they are.
-			a.end = b.end;
-			transformed.splice( i, 1 );
+// Normalizes list of ranges by joining intersecting or "touching" ranges.
+//
+// @param {Array.<module:engine/model/range~Range>} ranges
+//
+function normalizeRanges( ranges ) {
+	ranges.sort( ( a, b ) => a.start.isBefore( b.start ) ? -1 : 1 );
+
+	for ( let i = 1; i < ranges.length; i++ ) {
+		const previousRange = ranges[ i - 1 ];
+		const joinedRange = previousRange.getJoined( ranges[ i ], true );
+
+		if ( joinedRange ) {
+			// Replace the ranges on the list with the new joined range.
 			i--;
+			ranges.splice( i, 2, joinedRange );
 		}
 	}
+}
 
-	return transformed;
+function isRangeContainedByAnyOtherRange( range, ranges ) {
+	return ranges.some( otherRange => otherRange !== range && otherRange.containsRange( range, true ) );
 }

+ 86 - 7
packages/ckeditor5-undo/tests/undoediting-integration.js

@@ -15,6 +15,7 @@ import Typing from '@ckeditor/ckeditor5-typing/src/typing';
 import Enter from '@ckeditor/ckeditor5-enter/src/enter';
 import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard';
 import BoldEditing from '@ckeditor/ckeditor5-basic-styles/src/bold/boldediting';
+import TableEditing from '@ckeditor/ckeditor5-table/src/tableediting';
 
 import { setData, getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 
@@ -25,15 +26,16 @@ describe( 'UndoEditing integration', () => {
 		div = document.createElement( 'div' );
 		document.body.appendChild( div );
 
-		return ClassicEditor.create( div, { plugins: [ Paragraph, HeadingEditing, Typing, Enter, Clipboard, BoldEditing, UndoEditing ] } )
-			.then( newEditor => {
-				editor = newEditor;
+		return ClassicEditor.create( div, {
+			plugins: [ Paragraph, HeadingEditing, Typing, Enter, Clipboard, BoldEditing, UndoEditing, TableEditing ]
+		} ).then( newEditor => {
+			editor = newEditor;
 
-				model = editor.model;
-				doc = model.document;
+			model = editor.model;
+			doc = model.document;
 
-				root = doc.getRoot();
-			} );
+			root = doc.getRoot();
+		} );
 	} );
 
 	afterEach( () => {
@@ -1021,6 +1023,83 @@ describe( 'UndoEditing integration', () => {
 			expect( p.root ).to.equal( gy );
 			expect( p.getAttribute( 'bold' ) ).to.be.true;
 		} );
+
+		it( 'undo table cells merge', () => {
+			input(
+				'<table>' +
+					'<tableRow>' +
+						'<tableCell><paragraph>00</paragraph></tableCell>' +
+						'[<tableCell><paragraph>01</paragraph></tableCell>]' +
+						'[<tableCell><paragraph>02</paragraph></tableCell>]' +
+						'<tableCell><paragraph>03</paragraph></tableCell>' +
+					'</tableRow>' +
+					'<tableRow>' +
+						'<tableCell><paragraph>10</paragraph></tableCell>' +
+						'[<tableCell><paragraph>11</paragraph></tableCell>]' +
+						'[<tableCell><paragraph>12</paragraph></tableCell>]' +
+						'<tableCell><paragraph>13</paragraph></tableCell>' +
+					'</tableRow>' +
+					'<tableRow>' +
+						'<tableCell><paragraph>20</paragraph></tableCell>' +
+						'<tableCell><paragraph>21</paragraph></tableCell>' +
+						'<tableCell><paragraph>22</paragraph></tableCell>' +
+						'<tableCell><paragraph>23</paragraph></tableCell>' +
+					'</tableRow>' +
+				'</table>'
+			);
+
+			editor.execute( 'mergeTableCells' );
+
+			output(
+				'<table>' +
+					'<tableRow>' +
+						'<tableCell><paragraph>00</paragraph></tableCell>' +
+						'<tableCell colspan="2" rowspan="2">' +
+							'<paragraph>[01</paragraph>' +
+							'<paragraph>02</paragraph>' +
+							'<paragraph>11</paragraph>' +
+							'<paragraph>12]</paragraph>' +
+						'</tableCell>' +
+						'<tableCell><paragraph>03</paragraph></tableCell>' +
+					'</tableRow>' +
+					'<tableRow>' +
+						'<tableCell><paragraph>10</paragraph></tableCell>' +
+						'<tableCell><paragraph>13</paragraph></tableCell>' +
+					'</tableRow>' +
+					'<tableRow>' +
+						'<tableCell><paragraph>20</paragraph></tableCell>' +
+						'<tableCell><paragraph>21</paragraph></tableCell>' +
+						'<tableCell><paragraph>22</paragraph></tableCell>' +
+						'<tableCell><paragraph>23</paragraph></tableCell>' +
+					'</tableRow>' +
+				'</table>'
+			);
+
+			editor.execute( 'undo' );
+
+			output(
+				'<table>' +
+					'<tableRow>' +
+						'<tableCell><paragraph>00</paragraph></tableCell>' +
+						'[<tableCell><paragraph>01</paragraph></tableCell>]' +
+						'[<tableCell><paragraph>02</paragraph></tableCell>]' +
+						'<tableCell><paragraph>03</paragraph></tableCell>' +
+					'</tableRow>' +
+					'<tableRow>' +
+						'<tableCell><paragraph>10</paragraph></tableCell>' +
+						'[<tableCell><paragraph>11</paragraph></tableCell>]' +
+						'[<tableCell><paragraph>12</paragraph></tableCell>]' +
+						'<tableCell><paragraph>13</paragraph></tableCell>' +
+					'</tableRow>' +
+					'<tableRow>' +
+						'<tableCell><paragraph>20</paragraph></tableCell>' +
+						'<tableCell><paragraph>21</paragraph></tableCell>' +
+						'<tableCell><paragraph>22</paragraph></tableCell>' +
+						'<tableCell><paragraph>23</paragraph></tableCell>' +
+					'</tableRow>' +
+				'</table>'
+			);
+		} );
 	} );
 
 	it( 'postfixers should not add another undo step when fixing undo changes', () => {

+ 13 - 4
packages/ckeditor5-widget/src/widgetresize.js

@@ -12,6 +12,7 @@ import Resizer from './widgetresize/resizer';
 import DomEmitterMixin from '@ckeditor/ckeditor5-utils/src/dom/emittermixin';
 import global from '@ckeditor/ckeditor5-utils/src/dom/global';
 import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin';
+import MouseObserver from '@ckeditor/ckeditor5-engine/src/view/observer/mouseobserver';
 import mix from '@ckeditor/ckeditor5-utils/src/mix';
 import { throttle } from 'lodash-es';
 
@@ -71,12 +72,13 @@ export default class WidgetResize extends Plugin {
 			isFormatting: true
 		} );
 
+		this.editor.editing.view.addObserver( MouseObserver );
+
 		this._observer = Object.create( DomEmitterMixin );
 
-		this._observer.listenTo( domDocument, 'mousedown', this._mouseDownListener.bind( this ) );
+		this.listenTo( this.editor.editing.view.document, 'mousedown', this._mouseDownListener.bind( this ), { priority: 'high' } );
 
 		this._observer.listenTo( domDocument, 'mousemove', this._mouseMoveListener.bind( this ) );
-
 		this._observer.listenTo( domDocument, 'mouseup', this._mouseUpListener.bind( this ) );
 
 		const redrawFocusedResizer = () => {
@@ -182,13 +184,20 @@ export default class WidgetResize extends Plugin {
 	 * @param {Event} domEventData Native DOM event.
 	 */
 	_mouseDownListener( event, domEventData ) {
-		if ( !Resizer.isResizeHandle( domEventData.target ) ) {
+		const resizeHandle = domEventData.domTarget;
+
+		if ( !Resizer.isResizeHandle( resizeHandle ) ) {
 			return;
 		}
-		const resizeHandle = domEventData.target;
+
 		this._activeResizer = this._getResizerByHandle( resizeHandle );
+
 		if ( this._activeResizer ) {
 			this._activeResizer.begin( resizeHandle );
+
+			// Do not call other events when resizing. See: #6755.
+			event.stop();
+			domEventData.preventDefault();
 		}
 	}
 

+ 55 - 0
packages/ckeditor5-widget/tests/widgetresize-integration.js

@@ -0,0 +1,55 @@
+/**
+ * @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, Event */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+
+import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+import Image from '@ckeditor/ckeditor5-image/src/image';
+import ImageResize from '@ckeditor/ckeditor5-image/src/imageresize';
+
+describe( 'WidgetResize - integration', () => {
+	let editor, model, view, viewDocument, editorElement;
+
+	testUtils.createSinonSandbox();
+
+	beforeEach( () => {
+		editorElement = document.createElement( 'div' );
+		document.body.appendChild( editorElement );
+
+		return ClassicEditor.create( editorElement, { plugins: [ Image, ImageResize ] } )
+			.then( newEditor => {
+				editor = newEditor;
+				model = editor.model;
+				view = editor.editing.view;
+				viewDocument = view.document;
+			} );
+	} );
+
+	afterEach( () => {
+		editorElement.remove();
+
+		return editor.destroy();
+	} );
+
+	it( 'should not fire viewDocument#mousedown events after starting resizing', () => {
+		const eventSpy = sinon.spy().named( 'ViewDocument#mousedown' );
+
+		setModelData( model, '[<image src="/assets/sample.png"></image>]' );
+
+		const resizeSquareUI = [ ...viewDocument.getRoot().getChild( 0 ).getChildren() ]
+			.find( element => element.hasClass( 'ck-widget__resizer' ) );
+
+		const squareDomElement = view.domConverter.mapViewToDom( resizeSquareUI ).querySelector( '.ck-widget__resizer__handle-top-left' );
+
+		viewDocument.on( 'mousedown', eventSpy );
+
+		squareDomElement.dispatchEvent( new Event( 'mousedown' ) );
+
+		expect( eventSpy.called ).to.equal( false );
+	} );
+} );

+ 22 - 1
packages/ckeditor5-widget/tests/widgetresize.js

@@ -72,7 +72,8 @@ describe( 'WidgetResize', () => {
 			const unrelatedElement = document.createElement( 'div' );
 
 			editor.plugins.get( WidgetResize )._mouseDownListener( {}, {
-				target: unrelatedElement
+				domTarget: unrelatedElement,
+				preventDefault: sinon.spy()
 			} );
 		} );
 
@@ -114,6 +115,26 @@ describe( 'WidgetResize', () => {
 			resizerMouseSimulator.dragTo( editor, domParts.resizeHandle, initialPointerPosition );
 			// No exception should be thrown.
 		} );
+
+		it( 'stops the event after starting resizing', () => {
+			const stopSpy = sinon.spy().named( 'stop' );
+
+			const domParts = getWidgetDomParts( editor, widget, 'top-right' );
+
+			resizerMouseSimulator.down( editor, domParts.resizeHandle, { stop: stopSpy } );
+
+			expect( stopSpy.called ).to.be.equal( true );
+		} );
+
+		it( 'prevents default action after starting resizing', () => {
+			const preventDefaultSpy = sinon.spy().named( 'preventDefault' );
+
+			const domParts = getWidgetDomParts( editor, widget, 'top-right' );
+
+			resizerMouseSimulator.down( editor, domParts.resizeHandle, { preventDefault: preventDefaultSpy } );
+
+			expect( preventDefaultSpy.called ).to.be.equal( true );
+		} );
 	} );
 
 	describe( 'visibility', () => {

+ 5 - 4
packages/ckeditor5-widget/tests/widgetresize/_utils/utils.js

@@ -8,10 +8,11 @@ import WidgetResize from '../../../src/widgetresize';
 import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect';
 
 export const resizerMouseSimulator = {
-	down( editor, domTarget ) {
-		this._getPlugin( editor )._mouseDownListener( {}, {
-			target: domTarget
-		} );
+	down( editor, domTarget, options = {} ) {
+		const preventDefault = options.preventDefault || sinon.spy().named( 'preventDefault' );
+		const stop = options.stop || sinon.spy().named( 'stop' );
+
+		this._getPlugin( editor )._mouseDownListener( { stop }, { domTarget, preventDefault } );
 	},
 
 	/**

+ 13 - 2
scripts/bump-year.js

@@ -28,18 +28,29 @@ const includeDotFiles = {
 	dot: true
 };
 
-glob( '!(build|coverage|node_modules|packages)/**', updateYear );
+glob( '!(build|coverage|node_modules|external)/**', updateYear );
 
 // LICENSE.md, .eslintrc.js, etc.
 glob( '*', includeDotFiles, updateYear );
 
 function updateYear( err, fileNames ) {
 	const filteredFileNames = fileNames.filter( fileName => {
-		// Filter out stuff from ckeditor5-utils/src/lib.
+		// Filter out nested `node_modules`.
+		if ( minimatch( fileName, '**/node_modules/**' ) ) {
+			return false;
+		}
+
+		// Filter out stuff from `src/lib/`.
 		if ( minimatch( fileName, '**/src/lib/**' ) ) {
 			return false;
 		}
 
+		// Filter out builds.
+		if ( minimatch( fileName, '**/ckeditor5-build-*/build/**' ) ) {
+			return false;
+		}
+
+		// Filter out directories.
 		if ( fs.statSync( fileName ).isDirectory() ) {
 			return false;
 		}