瀏覽代碼

Merge remote-tracking branch 'origin/master' into i/7492

Marek Lewandowski 5 年之前
父節點
當前提交
e66f9f3f15

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

+ 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-image/docs/features/image.md

@@ -259,7 +259,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 +288,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
 ```
 

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

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

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