Explorar o código

Merge pull request #1406 from ckeditor/t/1301

Fix: The `bindTwoStepCaretToAttribute` behavioral helper should not fail in more complex cases. Closes #1301. Closes #1346. Closes ckeditor/ckeditor5#937.  Closes ckeditor/ckeditor5#922.  Closes ckeditor/ckeditor5#946.
Szymon Cofalik %!s(int64=7) %!d(string=hai) anos
pai
achega
5ef96e508f

+ 53 - 34
packages/ckeditor5-engine/src/model/documentselection.js

@@ -18,6 +18,7 @@ import TextProxy from './textproxy';
 import toMap from '@ckeditor/ckeditor5-utils/src/tomap';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 import log from '@ckeditor/ckeditor5-utils/src/log';
+import uid from '@ckeditor/ckeditor5-utils/src/uid';
 
 const storePrefix = 'selection:';
 
@@ -399,31 +400,36 @@ export default class DocumentSelection {
 	}
 
 	/**
-	 * Temporarily changes the gravity of the selection from left to right. The gravity defines from which direction
-	 * the selection inherits its attributes. If it's the default left gravity, the selection (after being moved by
-	 * the user) inherits attributes from its left hand side. This method allows to temporarily override this behavior
-	 * by forcing the gravity to the right.
+	 * Temporarily changes the gravity of the selection from the left to the right.
+	 *
+	 * The gravity defines from which direction the selection inherits its attributes. If it's the default left
+	 * gravity, the selection (after being moved by the the user) inherits attributes from its left hand side.
+	 * This method allows to temporarily override this behavior by forcing the gravity to the right.
+	 *
+	 * It returns an unique identifier which is required to restore the gravity. It guarantees the symmetry
+	 * of the process.
 	 *
 	 * @see module:engine/model/writer~Writer#overrideSelectionGravity
 	 * @protected
-	 * @param {Boolean} [customRestore=false] When `true` then gravity won't be restored until
-	 * {@link ~DocumentSelection#_restoreGravity} will be called directly. When `false` then gravity is restored
-	 * after selection is moved by user.
+	 * @returns {String} The unique id which allows restoring the gravity.
 	 */
-	_overrideGravity( customRestore ) {
-		this._selection.overrideGravity( customRestore );
+	_overrideGravity() {
+		return this._selection.overrideGravity();
 	}
 
 	/**
-	 * Restores {@link ~DocumentSelection#_overrideGravity overridden gravity}.
+	 * Restores the {@link ~DocumentSelection#_overrideGravity overridden gravity}.
 	 *
-	 * Note that gravity remains overridden as long as won't be restored the same number of times as was overridden.
+	 * Restoring the gravity is only possible using the unique identifier returned by
+	 * {@link ~DocumentSelection#_overrideGravity}. Note that the gravity remains overridden as long as won't be restored
+	 * the same number of times it was overridden.
 	 *
 	 * @see module:engine/model/writer~Writer#restoreSelectionGravity
 	 * @protected
+	 * @param {String} uid The unique id returned by {@link #_overrideGravity}.
 	 */
-	_restoreGravity() {
-		this._selection.restoreGravity();
+	_restoreGravity( uid ) {
+		this._selection.restoreGravity( uid );
 	}
 
 	/**
@@ -530,12 +536,13 @@ class LiveSelection extends Selection {
 		// @member {Array} module:engine/model/liveselection~LiveSelection#_hasChangedRange
 		this._hasChangedRange = false;
 
-		// Each overriding gravity increase the counter and each restoring decrease it.
-		// Gravity is overridden when counter is greater than 0. This is to prevent conflicts when
-		// gravity is overridden by more than one feature at the same time.
+		// Each overriding gravity adds an UID to the set and each removal removes it.
+		// Gravity is overridden when there's at least one UID in the set.
+		// Gravity is restored when the set is empty.
+		// This is to prevent conflicts when gravity is overridden by more than one feature at the same time.
 		// @private
-		// @type {Number}
-		this._overriddenGravityCounter = 0;
+		// @type {Set}
+		this._overriddenGravityRegister = new Set();
 
 		// Add events that will ensure selection correctness.
 		this.on( 'change:range', () => {
@@ -612,7 +619,7 @@ class LiveSelection extends Selection {
 	// @protected
 	// @type {Boolean}
 	get isGravityOverridden() {
-		return this._overriddenGravityCounter > 0;
+		return !!this._overriddenGravityRegister.size;
 	}
 
 	// Unbinds all events previously bound by live selection.
@@ -666,28 +673,40 @@ class LiveSelection extends Selection {
 		}
 	}
 
-	overrideGravity( customRestore ) {
-		this._overriddenGravityCounter++;
+	overrideGravity() {
+		const overrideUid = uid();
 
-		if ( this._overriddenGravityCounter == 1 ) {
-			if ( !customRestore ) {
-				this.on( 'change:range', ( evt, data ) => {
-					if ( data.directChange ) {
-						this.restoreGravity();
-						evt.off();
-					}
-				} );
-			}
+		// Remember that another overriding has been requested. It will need to be removed
+		// before the gravity is to be restored.
+		this._overriddenGravityRegister.add( overrideUid );
 
-			this._updateAttributes();
+		if ( this._overriddenGravityRegister.size === 1 ) {
+			this._refreshAttributes();
 		}
+
+		return overrideUid;
 	}
 
-	restoreGravity() {
-		this._overriddenGravityCounter--;
+	restoreGravity( uid ) {
+		if ( !this._overriddenGravityRegister.has( uid ) ) {
+			/**
+			 * Restoring gravity for an unknown UID is not possible. Make sure you are using a correct
+			 * UID obtained from the {@link module:engine/model/writer~Writer#overrideSelectionGravity} to restore.
+			 *
+			 * @error document-selection-gravity-wrong-restore
+			 * @param {String} uid The unique identifier returned by {@link #overrideGravity}.
+			 */
+			throw new CKEditorError(
+				'document-selection-gravity-wrong-restore: Attempting to restore the selection gravity for an unknown UID.',
+				{ uid }
+			);
+		}
+
+		this._overriddenGravityRegister.delete( uid );
 
+		// Restore gravity only when all overriding have been restored.
 		if ( !this.isGravityOverridden ) {
-			this._updateAttributes();
+			this._refreshAttributes();
 		}
 	}
 

+ 12 - 15
packages/ckeditor5-engine/src/model/writer.js

@@ -1112,29 +1112,26 @@ export default class Writer {
 	 * * Default gravity: selection will have the `bold` and `linkHref` attributes.
 	 * * Overridden gravity: selection will have `bold` attribute.
 	 *
-	 * By default the selection's gravity is automatically restored just after a direct selection change (when user
-	 * moved the caret) but you can pass `customRestore = true` in which case you will have to call
-	 * {@link ~Writer#restoreSelectionGravity} manually.
+	 * **Note**: It returns an unique identifier which is required to restore the gravity. It guarantees the symmetry
+	 * of the process.
 	 *
-	 * When the selection's gravity is overridden more than once without being restored in the meantime then it needs
-	 * to be restored the same number of times. This is to prevent conflicts when
-	 * more than one feature want to independently override and restore the selection's gravity.
-	 *
-	 * @param {Boolean} [customRestore=false] When `true` then gravity won't be restored until
-	 * {@link ~Writer#restoreSelectionGravity} will be called directly. When `false` then gravity is restored
-	 * after selection is moved by user.
+	 * @returns {String} The unique id which allows restoring the gravity.
 	 */
-	overrideSelectionGravity( customRestore ) {
-		this.model.document.selection._overrideGravity( customRestore );
+	overrideSelectionGravity() {
+		return this.model.document.selection._overrideGravity();
 	}
 
 	/**
 	 * Restores {@link ~Writer#overrideSelectionGravity} gravity to default.
 	 *
-	 * Note that the gravity remains overridden as long as will not be restored the same number of times as it was overridden.
+	 * Restoring the gravity is only possible using the unique identifier returned by
+	 * {@link ~Writer#overrideSelectionGravity}. Note that the gravity remains overridden as long as won't be restored
+	 * the same number of times it was overridden.
+	 *
+	 * @param {String} uid The unique id returned by {@link ~Writer#overrideSelectionGravity}.
 	 */
-	restoreSelectionGravity() {
-		this.model.document.selection._restoreGravity();
+	restoreSelectionGravity( uid ) {
+		this.model.document.selection._restoreGravity( uid );
 	}
 
 	/**

+ 526 - 77
packages/ckeditor5-engine/src/utils/bindtwostepcarettoattribute.js

@@ -8,27 +8,75 @@
  */
 
 import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
+import priorities from '@ckeditor/ckeditor5-utils/src/priorities';
 
 /**
- * This helper adds two-step caret movement behavior for the given attribute.
+ * This helper enabled 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.
  *
- * For example, when this behavior is enabled for the `linkHref` attribute (which converts to `<a>` element in the view)
- * and the caret is just before an `<a>` element (at a link boundary), then pressing
- * the right arrow key will move caret into that `<a>`element instead of moving it after the next character:
+ * Thanks to this (phantom) caret movement the user is able to type before/after as well as at the
+ * beginning/end of an attribute.
  *
- * * With two-step caret movement: `<p>foo{}<a>bar</a>biz<p>` + <kbd>→</kbd> => `<p>foo<a>{}bar</a>biz<p>`
- * * Without two-step caret movement: `<p>foo{}<a>bar</a>biz<p>` + <kbd>→</kbd> => `<p>foo<a>b{}ar</a>biz<p>`
+ * # Forward movement
  *
- * The same behavior will be changed fo "leaving" an attribute element:
+ * ## "Entering" an attribute:
  *
- * * With two-step caret movement: `<p>foo<a>bar{}</a>biz<p>` + <kbd>→</kbd> => `<p>foo<a>bar</a>{}biz<p>`
- * * Without two-step caret movement: `<p>foo<a>bar{}</a>biz<p>` + <kbd>→</kbd> => `<p>foo<a>bar</a>b{}iz<p>`
+ * When this behavior is enabled 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:
  *
- * And when moving left:
+ * * When enabled:
  *
- * * With two-step caret movement: `<p>foo<a>bar</a>b{}iz<p>` + <kbd>←</kbd> => `<p>foo<a>bar</a>{}biz<p>` +
- * <kbd>←</kbd> => `<p>foo<a>bar{}</a>biz<p>`
- * * Without two-step caret movement: `<p>foo<a>bar</a>b{}iz<p>` + <kbd>←</kbd> => `<p>foo<a>bar{}</a>biz<p>`
+ *   		foo{}<$text a="true">bar</$text>
+ *
+ *    <kbd>→</kbd>
+ *
+ *   		foo<$text a="true">{}bar</$text>
+ *
+ * * When disabled:
+ *
+ *   		foo{}<$text a="true">bar</$text>
+ *
+ *   <kbd>→</kbd>
+ *
+ *   		foo<$text a="true">b{}ar</$text>
+ *
+ *
+ * ## "Leaving" an attribute:
+ *
+ * * When enabled:
+ *
+ *   		<$text a="true">bar{}</$text>baz
+ *
+ *    <kbd>→</kbd>
+ *
+ *   		<$text a="true">bar</$text>{}baz
+ *
+ * * When disabled:
+ *
+ *   		<$text a="true">bar{}</$text>baz
+ *
+ *   <kbd>→</kbd>
+ *
+ *   		<$text a="true">bar</$text>b{}az
+ *
+ * # Backward movement
+ *
+ * * When enabled:
+ *
+ *   		<$text a="true">bar</$text>{}baz
+ *
+ *    <kbd>←</kbd>
+ *
+ *   		<$text a="true">bar{}</$text>baz
+ *
+ * * When disabled:
+ *
+ *   		<$text a="true">bar</$text>{}baz
+ *
+ *   <kbd>←</kbd>
+ *
+ *   		<$text a="true">ba{}r</$text>b{}az
  *
  * @param {module:engine/view/view~View} view View controller instance.
  * @param {module:engine/model/model~Model} model Data model instance.
@@ -37,105 +85,506 @@ import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
  * @param {String} attribute Attribute for which this behavior will be added.
  */
 export default function bindTwoStepCaretToAttribute( view, model, emitter, attribute ) {
+	const twoStepCaretHandler = new TwoStepCaretHandler( model, emitter, attribute );
 	const modelSelection = model.document.selection;
 
-	// Listen to keyboard events and handle cursor before the move.
+	// 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 ) => {
-		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;
-		}
-
 		// This implementation works only for collapsed selection.
 		if ( !modelSelection.isCollapsed ) {
 			return;
 		}
 
-		// When user tries to expand selection or jump over the whole word or to the beginning/end then
+		// 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();
+		let isMovementHandled;
 
-		// Moving right.
 		if ( arrowRightPressed ) {
-			// If gravity is already overridden then do nothing.
-			// It means that we already enter `foo<a>{}bar</a>biz` or left `foo<a>bar</a>{}biz` text with attribute
-			// and gravity will be restored just after caret movement.
-			if ( modelSelection.isGravityOverridden ) {
+			isMovementHandled = twoStepCaretHandler.handleForwardMovement( position, data );
+		} else {
+			isMovementHandled = twoStepCaretHandler.handleBackwardMovement( position, data );
+		}
+
+		// Stop the keydown event if the two-step arent 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 } );
+}
+
+/**
+ * This is a private helper–class for {@link module:engine/utils/bindtwostepcarettoattribute}.
+ * 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}.
+ *
+ * @private
+ */
+class TwoStepCaretHandler {
+	/*
+	 * Creates two step handler instance.
+	 *
+	 * @param {module:engine/model/model~Model} model Data model instance.
+	 * @param {module:utils/dom/emittermixin~Emitter} emitter The emitter to which this behavior should be added
+	 * (e.g. a plugin instance).
+	 * @param {String} attribute Attribute for which the behavior will be added.
+	 */
+	constructor( model, emitter, attribute ) {
+		/**
+		 * The model instance this class instance operates on.
+		 *
+		 * @readonly
+		 * @member {module:engine/model/model~Model#schema}
+		 */
+		this.model = model;
+
+		/**
+		 * The Attribute this class instance operates on.
+		 *
+		 * @readonly
+		 * @member {String}
+		 */
+		this.attribute = attribute;
+
+		/**
+		 * A reference to the document selection.
+		 *
+		 * @private
+		 * @member {module:engine/model/selection~Selection}
+		 */
+		this._modelSelection = model.document.selection;
+
+		/**
+		 * The current UID of the overridden gravity, as returned by
+		 * {@link module:engine/model/writer~Writer#overrideSelectionGravity}.
+		 *
+		 * @private
+		 * @member {String}
+		 */
+		this._overrideUid = null;
+
+		/**
+		 * A flag indicating that the automatic gravity restoration for this attribute
+		 * should not happen upon the next
+		 * {@link module:engine/model/selection~Selection#event:change:range} event.
+		 *
+		 * @private
+		 * @member {String}
+		 */
+		this._isNextGravityRestorationSkipped = false;
+
+		// The automatic gravity restoration logic.
+		emitter.listenTo( this._modelSelection, 'change:range', ( evt, data ) => {
+			// Skipping the automatic restoration is needed if the selection should change
+			// but the gravity must remain overridden afterwards. See the #handleBackwardMovement
+			// to learn more.
+			if ( this._isNextGravityRestorationSkipped ) {
+				this._isNextGravityRestorationSkipped = false;
+
+				return;
+			}
+
+			// Skip automatic restore when the gravity is not overridden — simply, there's nothing to restore
+			// at this moment.
+			if ( !this._isGravityOverridden ) {
+				return;
+			}
+
+			// Skip automatic restore when the change is indirect AND the selection is at the attribute boundary.
+			// It means that e.g. if the change was external (collaboration) and the user had their
+			// selection around the link, its gravity should remain intact in this change:range event.
+			if ( !data.directChange && isAtBoundary( this._modelSelection.getFirstPosition(), attribute ) ) {
 				return;
 			}
 
-			// If caret sticks to the bound of Text with attribute it means that we are going to
-			// enter `foo{}<a>bar</a>biz` or leave `foo<a>bar{}</a>biz` the text with attribute.
-			if ( isAtAttributeBoundary( position.nodeAfter, position.nodeBefore, attribute ) ) {
-				// So we need to prevent caret from being moved.
-				data.preventDefault();
-				// And override default selection gravity.
-				model.change( writer => writer.overrideSelectionGravity() );
+			this._restoreGravity();
+		} );
+	}
+
+	/**
+	 * Updates the document selection and the view according to the two–step caret movement state
+	 * when moving **forwards**. Executed upon `keypress` in the {@link module:engine/view/view~View}.
+	 *
+	 * @param {module:engine/model/position~Position} position The model position at the moment of the key press.
+	 * @param {module:engine/view/observer/domeventdata~DomEventData} data Data of the key press.
+	 * @returns {Boolean} `true` when the handler prevented caret movement
+	 */
+	handleForwardMovement( position, data ) {
+		const attribute = this.attribute;
+
+		// DON'T ENGAGE 2-SCM if gravity is already overridden. It means that we just entered
+		//
+		// 		<paragraph>foo<$text attribute>{}bar</$text>baz</paragraph>
+		//
+		// or left the attribute
+		//
+		// 		<paragraph>foo<$text attribute>bar</$text>{}baz</paragraph>
+		//
+		// and the gravity will be restored automatically.
+		if ( this._isGravityOverridden ) {
+			return;
+		}
+
+		// DON'T ENGAGE 2-SCM when the selection is at the beginning of the block AND already has the
+		// attribute:
+		// * when the selection was initially set there using the mouse,
+		// * when the editor has just started
+		//
+		//		<paragraph><$text attribute>{}bar</$text>baz</paragraph>
+		//
+		if ( position.isAtStart && this._hasSelectionAttribute ) {
+			return;
+		}
+
+		// ENGAGE 2-SCM when about to leave one attribute value and enter another:
+		//
+		// 		<paragraph><$text attribute="1">foo{}</$text><$text attribute="2">bar</$text></paragraph>
+		//
+		// but DON'T when already in between of them (no attribute selection):
+		//
+		// 		<paragraph><$text attribute="1">foo</$text>{}<$text attribute="2">bar</$text></paragraph>
+		//
+		if ( isBetweenDifferentValues( position, attribute ) && this._hasSelectionAttribute ) {
+			this._preventCaretMovement( data );
+			this._removeSelectionAttribute();
+
+			return true;
+		}
+
+		// ENGAGE 2-SCM when entering an attribute:
+		//
+		// 		<paragraph>foo{}<$text attribute>bar</$text>baz</paragraph>
+		//
+		if ( isAtStartBoundary( position, attribute ) ) {
+			this._preventCaretMovement( data );
+			this._overrideGravity();
+
+			return true;
+		}
+
+		// ENGAGE 2-SCM when leaving an attribute:
+		//
+		//		<paragraph>foo<$text attribute>bar{}</$text>baz</paragraph>
+		//
+		if ( isAtEndBoundary( position, attribute ) && this._hasSelectionAttribute ) {
+			this._preventCaretMovement( data );
+			this._overrideGravity();
+
+			return true;
+		}
+	}
+
+	/**
+	 * Updates the document selection and the view according to the two–step caret movement state
+	 * when moving **backwards**. Executed upon `keypress` in the {@link module:engine/view/view~View}.
+	 *
+	 * @param {module:engine/model/position~Position} position The model position at the moment of the key press.
+	 * @param {module:engine/view/observer/domeventdata~DomEventData} data Data of the key press.
+	 * @returns {Boolean} `true` when the handler prevented caret movement
+	 */
+	handleBackwardMovement( position, data ) {
+		const attribute = this.attribute;
+
+		// When the gravity is already overridden...
+		if ( this._isGravityOverridden ) {
+			// ENGAGE 2-SCM & REMOVE SELECTION ATTRIBUTE
+			// when about to leave one attribute value and enter another:
+			//
+			// 		<paragraph><$text attribute="1">foo</$text><$text attribute="2">{}bar</$text></paragraph>
+			//
+			// but DON'T when already in between of them (no attribute selection):
+			//
+			// 		<paragraph><$text attribute="1">foo</$text>{}<$text attribute="2">bar</$text></paragraph>
+			//
+			if ( isBetweenDifferentValues( position, attribute ) && this._hasSelectionAttribute ) {
+				this._preventCaretMovement( data );
+				this._restoreGravity();
+				this._removeSelectionAttribute();
+
+				return true;
 			}
 
-		// Moving left.
+			// ENGAGE 2-SCM when at any boundary of the attribute:
+			//
+			// 		<paragraph>foo<$text attribute>bar</$text>{}baz</paragraph>
+			// 		<paragraph>foo<$text attribute>{}bar</$text>baz</paragraph>
+			//
+			else {
+				this._preventCaretMovement( data );
+				this._restoreGravity();
+
+				// REMOVE SELECTION ATRIBUTE at the beginning of the block.
+				// It's like restoring gravity but towards a non-existent content when
+				// the gravity is overridden:
+				//
+				// 		<paragraph><$text attribute>{}bar</$text></paragraph>
+				//
+				// becomes:
+				//
+				// 		<paragraph>{}<$text attribute>bar</$text></paragraph>
+				//
+				if ( position.isAtStart ) {
+					this._removeSelectionAttribute();
+				}
+
+				return true;
+			}
 		} else {
-			// If caret sticks to the bound of Text with attribute and gravity is already overridden it means that
-			// we are going to enter `foo<a>bar</a>{}biz` or leave `foo<a>{}bar</a>biz` text with attribute.
-			if ( modelSelection.isGravityOverridden && isAtAttributeBoundary( position.nodeBefore, position.nodeAfter, attribute ) ) {
-				// So we need to prevent cater from being moved.
-				data.preventDefault();
-				// And restore the gravity.
-				model.change( writer => writer.restoreSelectionGravity() );
+			// ENGAGE 2-SCM when between two different attribute values but selection has no attribute:
+			//
+			// 		<paragraph><$text attribute="1">foo</$text>{}<$text attribute="2">bar</$text></paragraph>
+			//
+			if ( isBetweenDifferentValues( position, attribute ) && !this._hasSelectionAttribute ) {
+				this._preventCaretMovement( data );
+				this._setSelectionAttributeFromTheNodeBefore( position );
 
-				return;
+				return true;
+			}
+
+			// End of block boundary cases:
+			//
+			// 		<paragraph><$text attribute>bar{}</$text></paragraph>
+			// 		<paragraph><$text attribute>bar</$text>{}</paragraph>
+			//
+			if ( position.isAtEnd && isAtEndBoundary( position, attribute ) ) {
+				// DON'T ENGAGE 2-SCM if the selection has the attribute already.
+				// This is a common selection if set using the mouse.
+				//
+				// 		<paragraph><$text attribute>bar{}</$text></paragraph>
+				//
+				if ( this._hasSelectionAttribute ) {
+					// DON'T ENGAGE 2-SCM if the attribute at the end of the block which has length == 1.
+					// Make sure the selection will not the attribute after it moves backwards.
+					//
+					// 		<paragraph>foo<$text attribute>b{}</$text></paragraph>
+					//
+					if ( isStepAfterTheAttributeBoundary( position, attribute ) ) {
+						// Skip the automatic gravity restore upon the next selection#change:range event.
+						// If not skipped, it would automatically restore the gravity, which should remain
+						// overridden.
+						this._skipNextAutomaticGravityRestoration();
+						this._overrideGravity();
+
+						// Don't return "true" here because we didn't call _preventCaretMovement.
+						// Returning here will destabilize the filler logic, which also listens to
+						// keydown (and the event would be stopped).
+					}
+
+					return;
+				}
+				// ENGAGE 2-SCM if the selection has no attribute. This may happen when the user
+				// left the attribute using a FORWARD 2-SCM.
+				//
+				// 		<paragraph><$text attribute>bar</$text>{}</paragraph>
+				//
+				else {
+					this._preventCaretMovement( data );
+					this._setSelectionAttributeFromTheNodeBefore( position );
+
+					return true;
+				}
 			}
 
-			// If we are here we need to check if caret is a one character before the text with attribute bound
-			// `foo<a>bar</a>b{}iz` or `foo<a>b{}ar</a>biz`.
-			const nextPosition = position.getShiftedBy( -1 );
+			// REMOVE SELECTION ATRIBUTE when restoring gravity towards a non-existent content at the
+			// beginning of the block.
+			//
+			// 		<paragraph>{}<$text attribute>bar</$text></paragraph>
+			//
+			if ( position.isAtStart ) {
+				if ( this._hasSelectionAttribute ) {
+					this._removeSelectionAttribute();
+					this._preventCaretMovement( data );
+
+					return true;
+				}
 
-			// When position is the same it means that parent bound has been reached.
-			if ( !nextPosition.isBefore( position ) ) {
 				return;
 			}
 
-			// When caret is going stick to the bound of Text with attribute after movement then we need to override
-			// the gravity before the move. But we need to do it in a custom way otherwise `selection#change:range`
-			// event following the overriding will restore the gravity.
-			if ( isAtAttributeBoundary( nextPosition.nodeBefore, nextPosition.nodeAfter, attribute ) ) {
-				model.change( writer => {
-					let counter = 0;
-
-					// So let's override the gravity.
-					writer.overrideSelectionGravity( true );
-
-					// But skip the following `change:range` event and restore the gravity on the next one.
-					emitter.listenTo( modelSelection, 'change:range', ( evt, data ) => {
-						if ( counter++ && data.directChange ) {
-							writer.restoreSelectionGravity();
-							evt.off();
-						}
-					} );
-				} );
+			// DON'T ENGAGE 2-SCM when about to enter of leave an attribute.
+			// We need to check if the caret is a one position before the attribute boundary:
+			//
+			// 		<paragraph>foo<$text attribute>b{}ar</$text>baz</paragraph>
+			// 		<paragraph>foo<$text attribute>bar</$text>b{}az</paragraph>
+			//
+			if ( isStepAfterTheAttributeBoundary( position, attribute ) ) {
+				// Skip the automatic gravity restore upon the next selection#change:range event.
+				// If not skipped, it would automatically restore the gravity, which should remain
+				// overridden.
+				this._skipNextAutomaticGravityRestoration();
+				this._overrideGravity();
+
+				// Don't return "true" here because we didn't call _preventCaretMovement.
+				// Returning here will destabilize the filler logic, which also listens to
+				// keydown (and the event would be stopped).
 			}
 		}
-	} );
+	}
+
+	/**
+	 * `true` when the gravity is overridden for the {@link #attribute}.
+	 *
+	 * @readonly
+	 * @private
+	 * @type {Boolean}
+	 */
+	get _isGravityOverridden() {
+		return !!this._overrideUid;
+	}
+
+	/**
+	 * `true` when the {@link module:engine/model/selection~Selection} has the {@link #attribute}.
+	 *
+	 * @readonly
+	 * @private
+	 * @type {Boolean}
+	 */
+	get _hasSelectionAttribute() {
+		return this._modelSelection.hasAttribute( this.attribute );
+	}
+
+	/**
+	 * Overrides the gravity using the {@link module:engine/model/writer~Writer model writer}
+	 * and stores the information about this fact in the {@link #_overrideUid}.
+	 *
+	 * A shorthand for {@link module:engine/model/writer~Writer#overrideSelectionGravity}.
+	 *
+	 * @private
+	 */
+	_overrideGravity() {
+		this._overrideUid = this.model.change( writer => writer.overrideSelectionGravity() );
+	}
+
+	/**
+	 * Restores the gravity using the {@link module:engine/model/writer~Writer model writer}.
+	 *
+	 * A shorthand for {@link module:engine/model/writer~Writer#restoreSelectionGravity}.
+	 *
+	 * @private
+	 */
+	_restoreGravity() {
+		this.model.change( writer => {
+			writer.restoreSelectionGravity( this._overrideUid );
+			this._overrideUid = null;
+		} );
+	}
+
+	/**
+	 * Prevents the caret movement in the view by calling `preventDefault` on the event data.
+	 *
+	 * @private
+	 */
+	_preventCaretMovement( data ) {
+		data.preventDefault();
+	}
+
+	/**
+	 * Removes the {@link #attribute} from the selection using using the
+	 * {@link module:engine/model/writer~Writer model writer}.
+	 *
+	 * @private
+	 */
+	_removeSelectionAttribute() {
+		this.model.change( writer => {
+			writer.removeSelectionAttribute( this.attribute );
+		} );
+	}
+
+	/**
+	 * Applies the {@link #attribute} to the current selection using using the
+	 * value from the node before the current position. Uses
+	 * the {@link module:engine/model/writer~Writer model writer}.
+	 *
+	 * @private
+	 * @param {module:engine/model/position~Position} position
+	 */
+	_setSelectionAttributeFromTheNodeBefore( position ) {
+		const attribute = this.attribute;
+
+		this.model.change( writer => {
+			writer.setSelectionAttribute( this.attribute, position.nodeBefore.getAttribute( attribute ) );
+		} );
+	}
+
+	/**
+	 * Skips the next automatic selection gravity restoration upon the
+	 * {@link module:engine/model/selection~Selection#event:change:range} event.
+	 *
+	 * See {@link #_isNextGravityRestorationSkipped}.
+	 *
+	 * @private
+	 */
+	_skipNextAutomaticGravityRestoration() {
+		this._isNextGravityRestorationSkipped = true;
+	}
 }
 
-// @param {module:engine/model/node~Node} nextNode Node before the position.
-// @param {module:engine/model/node~Node} prevNode Node after the position.
-// @param {String} attribute Attribute name.
+// @param {module:engine/model/position~Position} position
+// @param {String} attribute
 // @returns {Boolean} `true` when position between the nodes sticks to the bound of text with given attribute.
-function isAtAttributeBoundary( nextNode, prevNode, attribute ) {
-	const isAttrInNext = nextNode ? nextNode.hasAttribute( attribute ) : false;
-	const isAttrInPrev = prevNode ? prevNode.hasAttribute( attribute ) : false;
+function isAtBoundary( position, attribute ) {
+	return isAtStartBoundary( position, attribute ) || isAtEndBoundary( position, attribute );
+}
 
-	if ( isAttrInNext && isAttrInPrev && nextNode.getAttributeKeys( attribute ) !== prevNode.getAttribute( attribute ) ) {
-		return true;
+// @param {module:engine/model/position~Position} position
+// @param {String} attribute
+function isAtStartBoundary( position, attribute ) {
+	const { nodeBefore, nodeAfter } = position;
+	const isAttrBefore = nodeBefore ? nodeBefore.hasAttribute( attribute ) : false;
+	const isAttrAfter = nodeAfter ? nodeAfter.hasAttribute( attribute ) : false;
+
+	return isAttrAfter && ( !isAttrBefore || nodeBefore.getAttribute( attribute ) !== nodeAfter.getAttribute( attribute ) );
+}
+
+// @param {module:engine/model/position~Position} position
+// @param {String} attribute
+function isAtEndBoundary( position, attribute ) {
+	const { nodeBefore, nodeAfter } = position;
+	const isAttrBefore = nodeBefore ? nodeBefore.hasAttribute( attribute ) : false;
+	const isAttrAfter = nodeAfter ? nodeAfter.hasAttribute( attribute ) : false;
+
+	return isAttrBefore && ( !isAttrAfter || nodeBefore.getAttribute( attribute ) !== nodeAfter.getAttribute( attribute ) );
+}
+
+// @param {module:engine/model/position~Position} position
+// @param {String} attribute
+function isBetweenDifferentValues( position, attribute ) {
+	const { nodeBefore, nodeAfter } = position;
+	const isAttrBefore = nodeBefore ? nodeBefore.hasAttribute( attribute ) : false;
+	const isAttrAfter = nodeAfter ? nodeAfter.hasAttribute( attribute ) : false;
+
+	if ( !isAttrAfter || !isAttrBefore ) {
+		return;
 	}
 
-	return isAttrInNext && !isAttrInPrev || !isAttrInNext && isAttrInPrev;
+	return nodeAfter.getAttribute( attribute ) !== nodeBefore.getAttribute( attribute );
+}
+
+// @param {module:engine/model/position~Position} position
+// @param {String} attribute
+function isStepAfterTheAttributeBoundary( position, attribute ) {
+	return isAtBoundary( position.getShiftedBy( -1 ), attribute );
 }

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

@@ -0,0 +1,3 @@
+<div id="editor">
+	<p><b>bold</b><i>i</i></p>
+</div>

+ 28 - 0
packages/ckeditor5-engine/tests/manual/tickets/1301/1.js

@@ -0,0 +1,28 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global console, document */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+import Essentials from '@ckeditor/ckeditor5-essentials/src/essentials';
+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';
+
+ClassicEditor
+	.create( document.querySelector( '#editor' ), {
+		plugins: [ Essentials, Paragraph, Bold, Italic ],
+		toolbar: [ 'undo', 'redo', '|', 'bold', 'italic' ]
+	} )
+	.then( editor => {
+		const bold = editor.plugins.get( Bold );
+
+		bindTwoStepCaretToAttribute( editor.editing.view, editor.model, bold, 'bold' );
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );

+ 8 - 0
packages/ckeditor5-engine/tests/manual/tickets/1301/1.md

@@ -0,0 +1,8 @@
+## Two-steps caret movement [#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.
+
+## Expected:
+
+The selection should be `<b>bol{}d</b><i>i</i>` (after "l" but before "d").

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

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

+ 37 - 18
packages/ckeditor5-engine/tests/model/documentselection.js

@@ -772,6 +772,15 @@ describe( 'DocumentSelection', () => {
 			} );
 		} );
 
+		it( 'should return the UID', () => {
+			const overrideUidA = selection._overrideGravity();
+			const overrideUidB = selection._overrideGravity();
+
+			expect( overrideUidA ).to.be.a( 'string' );
+			expect( overrideUidB ).to.be.a( 'string' );
+			expect( overrideUidA ).to.not.equal( overrideUidB );
+		} );
+
 		it( 'should mark gravity as overridden', () => {
 			expect( selection.isGravityOverridden ).to.false;
 
@@ -800,7 +809,7 @@ describe( 'DocumentSelection', () => {
 			expect( Array.from( selection.getAttributeKeys() ) ).to.have.members( [ 'bold', 'italic' ] );
 		} );
 
-		it( 'should retain attributes that are set explicit', () => {
+		it( 'should not retain attributes that are set explicitly', () => {
 			setData( model, '<$text italic="true">foo[]</$text>' );
 
 			selection._setAttribute( 'bold', true );
@@ -809,7 +818,7 @@ describe( 'DocumentSelection', () => {
 
 			selection._overrideGravity();
 
-			expect( Array.from( selection.getAttributeKeys() ) ).to.have.members( [ 'bold' ] );
+			expect( Array.from( selection.getAttributeKeys() ) ).to.have.members( [] );
 		} );
 
 		it( 'should retain overridden until selection will not change range by a direct change', () => {
@@ -836,43 +845,53 @@ describe( 'DocumentSelection', () => {
 			} );
 		} );
 
+		it( 'should throw when a wrong, already restored, or no UID provided', () => {
+			const overrideUid = selection._overrideGravity();
+			selection._restoreGravity( overrideUid );
+
+			// Wrong UID
+			expect( () => {
+				selection._restoreGravity( 'foo' );
+			} ).to.throw( CKEditorError, /^document-selection-gravity-wrong-restore/ );
+
+			// Already restored
+			expect( () => {
+				selection._restoreGravity( overrideUid );
+			} ).to.throw( CKEditorError, /^document-selection-gravity-wrong-restore/ );
+
+			// No UID
+			expect( () => {
+				selection._restoreGravity();
+			} ).to.throw( CKEditorError, /^document-selection-gravity-wrong-restore/ );
+		} );
+
 		it( 'should revert default gravity when is overridden', () => {
 			setData( model, '<$text bold="true" italic="true">foo[]</$text>' );
 
-			selection._overrideGravity();
+			const overrideUid = selection._overrideGravity();
 
 			expect( Array.from( selection.getAttributeKeys() ) ).to.length( 0 );
 			expect( selection.isGravityOverridden ).to.true;
 
-			selection._restoreGravity();
+			selection._restoreGravity( overrideUid );
 
 			expect( Array.from( selection.getAttributeKeys() ) ).to.have.members( [ 'bold', 'italic' ] );
 			expect( selection.isGravityOverridden ).to.false;
 		} );
 
-		it( 'should do nothing when gravity is not overridden', () => {
-			setData( model, '<$text bold="true" italic="true">foo[]</$text>' );
-
-			expect( () => {
-				selection._restoreGravity();
-			} ).to.not.throw();
-
-			expect( selection.isGravityOverridden ).to.false;
-		} );
-
 		it( 'should be called the same number of times as gravity is overridden to restore it', () => {
 			setData( model, '<$text bold="true" italic="true">foo[]</$text>' );
 
-			selection._overrideGravity();
-			selection._overrideGravity();
+			const overrideUidA = selection._overrideGravity();
+			const overrideUidB = selection._overrideGravity();
 
 			expect( selection.isGravityOverridden ).to.true;
 
-			selection._restoreGravity();
+			selection._restoreGravity( overrideUidA );
 
 			expect( selection.isGravityOverridden ).to.true;
 
-			selection._restoreGravity();
+			selection._restoreGravity( overrideUidB );
 
 			expect( selection.isGravityOverridden ).to.false;
 		} );

+ 14 - 27
packages/ckeditor5-engine/tests/model/writer.js

@@ -2455,6 +2455,10 @@ describe( 'Writer', () => {
 			overrideGravitySpy.restore();
 		} );
 
+		it( 'should return the unique id', () => {
+			expect( overrideSelectionGravity() ).to.be.a( 'string' );
+		} );
+
 		it( 'should not get attributes from the node before the caret when gravity is overridden', () => {
 			const root = doc.createRoot();
 			root._appendChild( [
@@ -2472,37 +2476,20 @@ describe( 'Writer', () => {
 			expect( Array.from( model.document.selection.getAttributeKeys() ) ).to.deep.equal( [ 'foo' ] );
 			expect( model.document.selection.isGravityOverridden ).to.true;
 
-			// Disable override by moving selection.
+			// Moving selection should not restore the gravity.
 			setSelection( new Position( root, [ 5 ] ) );
 
 			expect( Array.from( model.document.selection.getAttributeKeys() ) ).to.deep.equal( [ 'foo', 'bar' ] );
-			expect( model.document.selection.isGravityOverridden ).to.false;
-		} );
-
-		it( 'should allow to restorer gravity in a custom way', () => {
-			const root = doc.createRoot();
-			root._appendChild( [ new Text( 'foobar', { foo: true } ) ] );
-
-			setSelection( new Position( root, [ 1 ] ) );
-
-			overrideSelectionGravity( true );
-
-			// Moving selection does not restore gravity.
-			setSelection( new Position( root, [ 2 ] ) );
 			expect( model.document.selection.isGravityOverridden ).to.true;
-
-			// We need to do it manually.
-			restoreSelectionGravity();
-
-			expect( model.document.selection.isGravityOverridden ).to.false;
 		} );
 	} );
 
 	describe( 'restoreSelectionGravity()', () => {
 		it( 'should use DocumentSelection#_restoreGravity', () => {
+			const overrideUid = overrideSelectionGravity();
 			const restoreGravitySpy = sinon.spy( DocumentSelection.prototype, '_restoreGravity' );
 
-			restoreSelectionGravity();
+			restoreSelectionGravity( overrideUid );
 
 			sinon.assert.calledOnce( restoreGravitySpy );
 			restoreGravitySpy.restore();
@@ -2518,11 +2505,11 @@ describe( 'Writer', () => {
 
 			setSelection( new Position( root, [ 6 ] ) );
 
-			overrideSelectionGravity();
+			const overrideUid = overrideSelectionGravity();
 
 			expect( Array.from( model.document.selection.getAttributeKeys() ) ).to.deep.equal( [ 'foo' ] );
 
-			restoreSelectionGravity();
+			restoreSelectionGravity( overrideUid );
 
 			expect( Array.from( model.document.selection.getAttributeKeys() ) ).to.deep.equal( [ 'foo', 'bar' ] );
 		} );
@@ -2694,15 +2681,15 @@ describe( 'Writer', () => {
 		} );
 	}
 
-	function overrideSelectionGravity( customRestore ) {
-		model.change( writer => {
-			writer.overrideSelectionGravity( customRestore );
+	function overrideSelectionGravity() {
+		return model.change( writer => {
+			return writer.overrideSelectionGravity();
 		} );
 	}
 
-	function restoreSelectionGravity() {
+	function restoreSelectionGravity( overrideUid ) {
 		model.change( writer => {
-			writer.restoreSelectionGravity();
+			writer.restoreSelectionGravity( overrideUid );
 		} );
 	}
 } );

+ 664 - 188
packages/ckeditor5-engine/tests/utils/bindtwostepcarettoattribute.js

@@ -7,16 +7,17 @@
 
 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 EventInfo from '@ckeditor/ckeditor5-utils/src/eventinfo';
 import bindTwoStepCaretToAttribute from '../../src/utils/bindtwostepcarettoattribute';
 import Position from '../../src/model/position';
-import Range from '../../src/model/range';
 import { upcastElementToAttribute } from '../../src/conversion/upcast-converters';
 import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
-
 import { setData } from '../../src/dev-utils/model';
 
 describe( 'bindTwoStepCaretToAttribute()', () => {
-	let editor, model, emitter, selection, viewDoc, preventDefaultSpy;
+	let editor, model, emitter, selection, view;
+	let preventDefaultSpy, evtStopSpy;
 
 	beforeEach( () => {
 		emitter = Object.create( DomEmitterMixin );
@@ -25,17 +26,21 @@ describe( 'bindTwoStepCaretToAttribute()', () => {
 			editor = newEditor;
 			model = editor.model;
 			selection = model.document.selection;
-			viewDoc = editor.editing.view.document;
+			view = editor.editing.view;
+
 			preventDefaultSpy = sinon.spy();
+			evtStopSpy = sinon.spy();
 
 			editor.model.schema.extend( '$text', {
 				allowAttributes: [ 'a', 'b', 'c' ],
 				allowIn: '$root'
 			} );
 
+			model.schema.register( 'paragraph', { inheritAllFrom: '$block' } );
 			editor.conversion.for( 'upcast' ).add( upcastElementToAttribute( { view: 'a', model: 'a' } ) );
 			editor.conversion.for( 'upcast' ).add( upcastElementToAttribute( { view: 'b', model: 'b' } ) );
 			editor.conversion.for( 'upcast' ).add( upcastElementToAttribute( { view: 'c', model: 'c' } ) );
+			editor.conversion.elementToElement( { model: 'paragraph', view: 'p' } );
 
 			bindTwoStepCaretToAttribute( editor.editing.view, editor.model, emitter, 'a' );
 		} );
@@ -46,246 +51,591 @@ describe( 'bindTwoStepCaretToAttribute()', () => {
 	} );
 
 	describe( 'moving right', () => {
-		it( 'should "enter" the text with attribute in two steps', () => {
-			setData( model, '<$text c="true">foo[]</$text><$text a="true" b="true">bar</$text>' );
+		it( 'should do nothing for unrelated attribute (at the beginning)', () => {
+			setData( model, '[]<$text c="true">foo</$text>' );
 
-			// Gravity is not overridden, caret is at the beginning of the text but is "outside" of the text.
-			expect( Array.from( selection.getAttributeKeys() ) ).to.have.members( [ 'c' ] );
-			expect( selection.isGravityOverridden ).to.false;
+			testTwoStepCaretMovement( [
+				{ selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				'→',
+				{ selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+			] );
+		} );
 
-			// Press right key.
-			fireKeyDownEvent( {
-				keyCode: keyCodes.arrowright,
-				preventDefault: preventDefaultSpy
-			} );
+		it( 'should do nothing for unrelated attribute (at the end)', () => {
+			setData( model, '<$text c="true">foo[]</$text>' );
 
-			// Gravity is overridden, caret movement is blocked, selection at the beginning but "inside" the text.
-			expect( Array.from( selection.getAttributeKeys() ) ).to.have.members( [ 'a', 'b' ] );
-			expect( selection.isGravityOverridden ).to.true;
-			sinon.assert.calledOnce( preventDefaultSpy );
+			testTwoStepCaretMovement( [
+				{ selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				'→',
+				{ selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+			] );
+		} );
 
-			// Press right key.
-			fireKeyDownEvent( {
-				keyCode: keyCodes.arrowright,
-				preventDefault: preventDefaultSpy
-			} );
+		it( 'should "enter" the text with attribute in two steps', () => {
+			setData( model, '<$text c="true">foo[]</$text><$text a="true" b="true">bar</$text>' );
 
-			// Caret movement was not blocked this time (still once) so everything works normally.
-			sinon.assert.calledOnce( preventDefaultSpy );
+			testTwoStepCaretMovement( [
+				// Gravity is not overridden, caret is at the beginning of the text but is "outside" of the text.
+				{ selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				'→',
+				// Gravity is overridden, caret movement is blocked, selection at the beginning but "inside" the text.
+				{ selectionAttributes: [ 'a', 'b' ], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 },
+				'→',
+				// Caret movement was not blocked this time (still once) so everything works normally.
+				{ selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
+			] );
 		} );
 
 		it( 'should "leave" the text with attribute in two steps', () => {
 			setData( model, '<$text a="true" b="true">bar[]</$text><$text c="true">foo</$text>' );
 
-			// Gravity is not overridden, caret is at the end of the text but is "inside" of the text.
-			expect( Array.from( selection.getAttributeKeys() ) ).to.have.members( [ 'a', 'b' ] );
-			expect( selection.isGravityOverridden ).to.false;
-
-			// Press right key.
-			fireKeyDownEvent( {
-				keyCode: keyCodes.arrowright,
-				preventDefault: preventDefaultSpy
-			} );
+			testTwoStepCaretMovement( [
+				// Gravity is not overridden, caret is at the end of the text but is "inside" of the text.
+				{ selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				'→',
+				// Gravity is overridden, caret movement is blocked, selection at the end but "outside" the text.
+				{ selectionAttributes: [ 'c' ], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 },
+				'→',
+				{ selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
+			] );
+		} );
 
-			// Gravity is overridden, caret movement is blocked, selection at the end but "outside" the text.
-			expect( Array.from( selection.getAttributeKeys() ) ).to.have.members( [ 'c' ] );
-			expect( selection.isGravityOverridden ).to.true;
-			sinon.assert.calledOnce( preventDefaultSpy );
+		it( 'should use two-steps movement when between nodes with the same attribute but different value', () => {
+			setData( model, '<$text a="1">bar[]</$text><$text a="2">foo</$text>' );
 
-			// Press right key.
-			fireKeyDownEvent( {
-				keyCode: keyCodes.arrowright,
-				preventDefault: preventDefaultSpy
-			} );
+			testTwoStepCaretMovement( [
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				'→',
+				// <$text a="1">bar</$text>[]<$text a="2">foo</$text>
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
+				'→',
+				// <$text a="1">bar</$text><$text a="2">[]foo</$text>
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 2, evtStopCalled: 2 },
+				'→',
+				// <$text a="1">bar</$text><$text a="2">f[]oo</$text>
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 2, evtStopCalled: 2 }
+			] );
+		} );
 
-			// Caret movement was not blocked this time (still once) so everything works normally.
-			sinon.assert.calledOnce( preventDefaultSpy );
+		// https://github.com/ckeditor/ckeditor5/issues/937
+		it( 'should not require two-steps between unrelated attributes inside the initial attribute', () => {
+			setData( model, '<$text a="1">fo[]o</$text><$text a="1" b="2">bar</$text><$text a="1">baz</$text>' );
+
+			testTwoStepCaretMovement( [
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				'→',
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				'→',
+				{ selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				'→',
+				{ selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				'→',
+				{ selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				'→',
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 }
+			] );
 		} );
 
-		it( 'should do nothing for not bound attribute (at the beginning)', () => {
-			setData( model, '[]<$text c="true">foo</$text>' );
+		// https://github.com/ckeditor/ckeditor5-engine/issues/1301
+		it( 'should handle passing through the only character in the block', () => {
+			setData( model, '[]<$text a="1">x</$text>' );
+
+			testTwoStepCaretMovement( [
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				'→',
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				'→',
+				{ selectionAttributes: [], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 },
+				'→',
+				{ selectionAttributes: [], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 }
+			] );
+		} );
 
-			fireKeyDownEvent( {
-				keyCode: keyCodes.arrowright,
-				preventDefault: preventDefaultSpy
-			} );
+		// https://github.com/ckeditor/ckeditor5-engine/issues/1301
+		it( 'should handle passing through the only character in the block (no attribute in the initial selection)', () => {
+			setData( model, '[]<$text a="1">x</$text>' );
+
+			model.change( writer => writer.removeSelectionAttribute( 'a' ) );
+
+			testTwoStepCaretMovement( [
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				'→',
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 },
+				'→',
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
+				'→',
+				{ selectionAttributes: [], isGravityOverridden: true, preventDefault: 2, evtStopCalled: 2 },
+				'→',
+				{ selectionAttributes: [], isGravityOverridden: true, preventDefault: 2, evtStopCalled: 2 }
+			] );
+		} );
 
-			sinon.assert.notCalled( preventDefaultSpy );
-			expect( selection.isGravityOverridden ).to.false;
+		// https://github.com/ckeditor/ckeditor5-engine/issues/1301
+		it( 'should handle passing through the only-child with an attribute (multiple characters)', () => {
+			setData( model, '[]<$text a="1">xyz</$text>' );
+
+			testTwoStepCaretMovement( [
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				'→',
+				// <$text a="1">x{}yz</$text>
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				'→',
+				// <$text a="1">xy{}z</$text>
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				'→',
+				// <$text a="1">xyz{}</$text>
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				'→',
+				// <$text a="1">xyz</$text>{}
+				{ selectionAttributes: [], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 },
+				'→',
+				// <$text a="1">xyz</$text>{}
+				{ selectionAttributes: [], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 }
+			] );
 		} );
 
-		it( 'should do nothing for not bound attribute (at the end)', () => {
-			setData( model, '<$text c="true">foo[]</$text>' );
+		it( 'should handle leaving an attribute followed by another block', () => {
+			setData( model, '<paragraph><$text a="1">foo[]</$text></paragraph><paragraph><$text b="1">bar</$text></paragraph>' );
+
+			testTwoStepCaretMovement( [
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				'→',
+				// <paragraph><$text a="1">bar</$text>[]</paragraph><paragraph>foo</paragraph>
+				{ selectionAttributes: [], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 },
+				'→',
+				// <paragraph><$text a="1">bar</$text></paragraph><paragraph>f[]oo</paragraph>
+				{ selectionAttributes: [ 'b' ], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
+				'→',
+				// <paragraph><$text a="1">bar</$text></paragraph><paragraph>fo[]o</paragraph>
+				{ selectionAttributes: [ 'b' ], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
+			] );
+		} );
+	} );
 
-			fireKeyDownEvent( {
-				keyCode: keyCodes.arrowright,
-				preventDefault: preventDefaultSpy
-			} );
+	describe( 'moving left', () => {
+		it( 'should "enter" the text with attribute in two steps', () => {
+			setData( model, '<$text>foo</$text><$text a="true" b="true">bar</$text><$text c="true">b[]iz</$text>' );
 
-			sinon.assert.notCalled( preventDefaultSpy );
-			expect( selection.isGravityOverridden ).to.false;
+			testTwoStepCaretMovement( [
+				// Gravity is not overridden, caret is a one character after the and of the text.
+				{ selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				'←',
+				// Caret movement was not blocked but the gravity is overridden.
+				{ selectionAttributes: [ 'c' ], isGravityOverridden: true, preventDefault: 0, evtStopCalled: 0 },
+				'←',
+				{ selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
+				'←',
+				{ selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
+			] );
 		} );
 
-		it( 'should require two-steps movement when caret goes between text node with the same attribute but different value', () => {
-			setData( model, '<$text a="1">bar[]</$text><$text a="2">foo</$text>' );
+		it( 'should "leave" the text with attribute in two steps', () => {
+			setData( model, '<$text c="true">foo</$text><$text a="true" b="true">b[]ar</$text>' );
 
-			// Gravity is not overridden.
-			expect( selection.isGravityOverridden ).to.false;
+			testTwoStepCaretMovement( [
+				// Gravity is not overridden, caret is a one character after the beginning of the text.
+				{ selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				'←',
+				// Caret movement was not blocked.
+				{ selectionAttributes: [ 'a', 'b' ], isGravityOverridden: true, preventDefault: 0, evtStopCalled: 0 },
+				'←',
+				{ selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
+				'←',
+				{ selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 }
+			] );
+		} );
 
-			// Press right key.
-			fireKeyDownEvent( {
-				keyCode: keyCodes.arrowright,
-				preventDefault: preventDefaultSpy
-			} );
+		it( 'should do nothing for unrelated attribute (at the beginning)', () => {
+			setData( model, '<$text c="true">[]foo</$text>' );
 
-			// Gravity is overridden, caret movement is blocked.
-			expect( selection.isGravityOverridden ).to.true;
-			sinon.assert.calledOnce( preventDefaultSpy );
+			testTwoStepCaretMovement( [
+				{ selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				'←',
+				{ selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				'←',
+				{ selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 }
+			] );
 		} );
-	} );
 
-	describe( 'moving left', () => {
-		it( 'should "enter" the text with attribute in two steps', () => {
-			setData( model, '<$text>foo</$text><$text a="true" b="true">bar</$text><$text c="true">b[]iz</$text>' );
+		it( 'should do nothing for unrelated attribute (at the end)', () => {
+			setData( model, '<$text c="true">foo</$text>[]' );
 
-			// Gravity is not overridden, caret is a one character after the and of the text.
-			expect( Array.from( selection.getAttributeKeys() ) ).to.have.members( [ 'c' ] );
-			expect( selection.isGravityOverridden ).to.false;
+			testTwoStepCaretMovement( [
+				{ selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				'←',
+				{ selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				'←',
+				{ selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 }
+			] );
+		} );
 
-			// Press left key.
-			fireKeyDownEvent( {
-				keyCode: keyCodes.arrowleft,
-				preventDefault: preventDefaultSpy
-			} );
+		it( 'should do nothing when caret is at the beginning of block element', () => {
+			setData( model, '[]foo', { lastRangeBackward: true } );
 
-			// Caret movement was not blocked.
-			sinon.assert.notCalled( preventDefaultSpy );
+			testTwoStepCaretMovement( [
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				'←',
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+			] );
+		} );
 
-			// So we need to move caret one character left like it should be done in the real world.
-			// Caret should ends up at the end of text with attribute but still outside of it.
-			model.change( writer => writer.setSelection( new Range( new Position( model.document.getRoot(), [ 6 ] ) ) ) );
+		it( 'should require two-steps movement when caret goes between text node with the same attribute but different value', () => {
+			setData( model, '<$text a="2">foo</$text><$text a="1">b[]ar</$text>' );
 
-			// Gravity is overridden.
-			expect( Array.from( selection.getAttributeKeys() ) ).to.have.members( [ 'c' ] );
-			expect( selection.isGravityOverridden ).to.true;
+			testTwoStepCaretMovement( [
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				'←',
+				// <$text a="2">foo</$text><$text a="1">[]bar</$text>
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 0, evtStopCalled: 0 },
+				'←',
+				// <$text a="2">foo</$text>[]<$text a="1">bar</$text>
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
+				'←',
+				// <$text a="2">foo[]</$text><$text a="1">bar</$text>
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 2, evtStopCalled: 2 },
+				'←',
+				// <$text a="2">fo[]o</$text><$text a="1">bar</$text>
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 2, evtStopCalled: 2 }
+			] );
+		} );
 
-			// Press left key.
-			fireKeyDownEvent( {
-				keyCode: keyCodes.arrowleft,
-				preventDefault: preventDefaultSpy
-			} );
+		// https://github.com/ckeditor/ckeditor5/issues/937
+		it( 'should not require two-steps between unrelated attributes inside the initial attribute', () => {
+			setData( model, '<$text a="1">foo</$text><$text a="1" b="2">bar</$text><$text a="1">b[]az</$text>' );
+
+			testTwoStepCaretMovement( [
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				'←',
+				{ selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				'←',
+				{ selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				'←',
+				{ selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				'←',
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 }
+			] );
+		} );
 
-			// Caret movement was blocked but now is "inside" the text.
-			expect( Array.from( selection.getAttributeKeys() ) ).to.have.members( [ 'a', 'b' ] );
-			expect( selection.isGravityOverridden ).to.false;
-			sinon.assert.calledOnce( preventDefaultSpy );
+		// https://github.com/ckeditor/ckeditor5-engine/issues/1301
+		it( 'should handle passing through the only-child with an attribute (single character)', () => {
+			setData( model, '<$text a="1">x</$text>[]' );
+
+			testTwoStepCaretMovement( [
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				'←',
+				// <$text a="1">{}x</$text>
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 0, evtStopCalled: 0 },
+				'←',
+				// {}<$text a="1">x</$text> (because it's a first-child)
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
+				'←',
+				// {}<$text a="1">x</$text>
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 }
+			] );
+		} );
 
-			// Press left key.
-			fireKeyDownEvent( {
-				keyCode: keyCodes.arrowleft,
-				preventDefault: preventDefaultSpy
-			} );
+		// https://github.com/ckeditor/ckeditor5-engine/issues/1301
+		it( 'should handle passing through the only character in the block (no attribute in the initial selection)', () => {
+			setData( model, '<$text a="1">x</$text>[]' );
+
+			model.change( writer => writer.removeSelectionAttribute( 'a' ) );
+
+			testTwoStepCaretMovement( [
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				'←',
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
+				'←',
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 },
+				'←',
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 2, evtStopCalled: 2 },
+				'←',
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 2, evtStopCalled: 2 }
+			] );
+		} );
 
-			// Caret movement was not blocked this time (still once) so everything works normally.
-			sinon.assert.calledOnce( preventDefaultSpy );
+		// https://github.com/ckeditor/ckeditor5-engine/issues/1301
+		it( 'should handle passing through the only-child with an attribute (single character, text before)', () => {
+			setData( model, 'abc<$text a="1">x</$text>[]' );
+
+			testTwoStepCaretMovement( [
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				'←',
+				// abc<$text a="1">{}x</$text>
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 0, evtStopCalled: 0 },
+				'←',
+				// abc{}<$text a="1">x</$text> (because it's a first-child)
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
+				'←',
+				// abc{}<$text a="1">x</$text>
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 }
+			] );
+		} );
 
-			// And again we need to move the caret like it should be done in the real world to be shure that everything is
-			// like it should to be.
-			model.change( writer => writer.setSelection( new Range( new Position( model.document.getRoot(), [ 5 ] ) ) ) );
+		// https://github.com/ckeditor/ckeditor5-engine/issues/1301
+		it( 'should handle passing through the only-child with an attribute (multiple characters)', () => {
+			setData( model, '<$text a="1">xyz</$text>[]' );
+
+			testTwoStepCaretMovement( [
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				'←',
+				// <$text a="1">xy{}z</$text>
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				'←',
+				// <$text a="1">x{}yz</$text>
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				'←',
+				// <$text a="1">{}xyz</$text>
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 0, evtStopCalled: 0 },
+				'←',
+				// {}<$text a="1">xyz</$text>
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
+				'←',
+				// {}<$text a="1">xyz</$text>
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 }
+			] );
 		} );
 
-		it( 'should "leave" the text with attribute in two steps', () => {
-			setData( model, '<$text c="true">foo</$text><$text a="true" b="true">b[]ar</$text>' );
+		it( 'should handle leaving an attribute preceded by another block', () => {
+			setData( model, '<paragraph><$text b="1">foo</$text></paragraph><paragraph><$text a="1">[]bar</$text></paragraph>' );
 
-			// Gravity is not overridden, caret is a one character after the beginning of the text.
-			expect( Array.from( selection.getAttributeKeys() ) ).to.have.members( [ 'a', 'b' ] );
-			expect( selection.isGravityOverridden ).to.false;
+			testTwoStepCaretMovement( [
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				'←',
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
+				'←',
+				{ selectionAttributes: [ 'b' ], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
+			] );
+		} );
+	} );
 
-			// Press left key.
-			fireKeyDownEvent( {
-				keyCode: keyCodes.arrowleft,
-				preventDefault: preventDefaultSpy
-			} );
+	describe( 'moving and typing around the attribute', () => {
+		it( 'should handle typing after the attribute', () => {
+			setData( model, '<$text a="1">x[]</$text>' );
+
+			testTwoStepCaretMovement( [
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				'y',
+				// <$text a="1">xy[]</$text>
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				'→',
+				// <$text a="1">xy</$text>[]
+				{ selectionAttributes: [], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 },
+				'z',
+				// <$text a="1">xy</$text>z[]
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
+				'←',
+				// <$text a="1">xy</$text>[]z
+				{ selectionAttributes: [], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 },
+				'←',
+				// <$text a="1">xy[]</$text>z
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 2, evtStopCalled: 2 },
+				'w',
+				// <$text a="1">xyw[]</$text>
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 2, evtStopCalled: 2 },
+			] );
+		} );
 
-			// Caret movement was not blocked.
-			sinon.assert.notCalled( preventDefaultSpy );
+		it( 'should handle typing before the attribute', () => {
+			setData( model, '<$text a="1">[]x</$text>' );
+
+			testTwoStepCaretMovement( [
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				'←',
+				// []<$text a="1">x</$text>
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
+				'z',
+				// z[]<$text a="1">x</$text>
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
+				'x',
+				// zx[]<$text a="1">x</$text>
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
+				'→',
+				// zx<$text a="1">[]x</$text>
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 2, evtStopCalled: 2 },
+				'a',
+				// zx<$text a="1">a[]x</$text>
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 2, evtStopCalled: 2 },
+			] );
+		} );
+
+		// https://github.com/ckeditor/ckeditor5-engine/issues/1346
+		// https://github.com/ckeditor/ckeditor5/issues/946
+		it( 'should correctly re-renter the attribute', () => {
+			setData( model, 'fo[]o <$text a="1">bar</$text>' );
+
+			testTwoStepCaretMovement( [
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				'→',
+				// foo[] <$text a="1">bar</$text>
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				'→',
+				// foo []<$text a="1">bar</$text>
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				'→',
+				// foo <$text a="1">[]bar</$text>
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 },
+				'→',
+				// foo <$text a="1">b[]ar</$text>
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
+				'←',
+				// foo <$text a="1">[]bar</$text>
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 },
+				'←',
+				// foo []<$text a="1">bar</$text>
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 2, evtStopCalled: 2 },
+				'←',
+				// foo[] <$text a="1">bar</$text>
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 2, evtStopCalled: 2 },
+				'←',
+				// fo[]o <$text a="1">bar</$text>
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 2, evtStopCalled: 2 },
+			] );
+		} );
 
-			// So we need to move caret one character left like it should be done in the real world.
-			// Caret should ends up at the beginning of text with attribute but still inside of it.
-			model.change( writer => writer.setSelection( new Range( new Position( model.document.getRoot(), [ 3 ] ) ) ) );
+		// https://github.com/ckeditor/ckeditor5/issues/922
+		it( 'should not lose the new attribute when typing (after)', () => {
+			setData( model, '<$text a="1">x[]</$text>' );
 
-			// Gravity is overridden, caret is at the beginning of the text and is "inside" of the text.
-			expect( Array.from( selection.getAttributeKeys() ) ).to.have.members( [ 'a', 'b' ] );
-			expect( selection.isGravityOverridden ).to.true;
+			testTwoStepCaretMovement( [
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				'→',
+				// <$text a="1">x</$text>[]
+				{ selectionAttributes: [], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 }
+			] );
 
-			// Press left key.
-			fireKeyDownEvent( {
-				keyCode: keyCodes.arrowleft,
-				preventDefault: preventDefaultSpy
+			model.change( writer => {
+				writer.setSelectionAttribute( 'b', 1 );
 			} );
 
-			// Gravity is not overridden, caret movement was blocked but now is "outside" the text.
-			expect( Array.from( selection.getAttributeKeys() ) ).to.have.members( [ 'c' ] );
-			expect( selection.isGravityOverridden ).to.false;
-			sinon.assert.calledOnce( preventDefaultSpy );
+			// <$text a="1">x</$text><$text b="1">[]</$text>
+			expect( selection.isGravityOverridden ).to.be.true;
+			expect( getSelectionAttributesArray( selection ) ).to.have.members( [ 'b' ] );
 
-			// Press left key.
-			fireKeyDownEvent( {
-				keyCode: keyCodes.arrowleft,
-				preventDefault: preventDefaultSpy
+			model.change( writer => {
+				writer.insertText( 'yz', selection.getAttributes(), selection.getFirstPosition() );
 			} );
 
-			// Caret movement was not blocked this time (still once) so everything works normally.
-			sinon.assert.calledOnce( preventDefaultSpy );
+			// <$text a="1">x</$text><$text b="1">yz[]</$text>
+			expect( selection.isGravityOverridden ).to.be.false;
+			expect( getSelectionAttributesArray( selection ) ).to.have.members( [ 'b' ] );
 		} );
 
-		it( 'should do nothing for not bound attribute (at the beginning)', () => {
-			setData( model, '<$text c="true">[]foo</$text>' );
+		// https://github.com/ckeditor/ckeditor5/issues/922
+		it( 'should not lose the new attribute when typing (before)', () => {
+			setData( model, '<$text a="1">[]x</$text>' );
 
-			fireKeyDownEvent( {
-				keyCode: keyCodes.arrowright,
-				preventDefault: preventDefaultSpy
-			} );
+			testTwoStepCaretMovement( [
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				'←',
+				// []<$text a="1">x</$text>
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 }
+			] );
 
-			sinon.assert.notCalled( preventDefaultSpy );
-			expect( selection.isGravityOverridden ).to.false;
-		} );
+			model.change( writer => {
+				writer.setSelectionAttribute( 'b', 1 );
+			} );
 
-		it( 'should do nothing for not bound attribute (at the end)', () => {
-			setData( model, '<$text c="true">foo</$text>[]' );
+			// <$text b="1">[]</$text><$text a="1">x</$text>
+			expect( selection.isGravityOverridden ).to.be.false;
+			expect( getSelectionAttributesArray( selection ) ).to.have.members( [ 'b' ] );
 
-			fireKeyDownEvent( {
-				keyCode: keyCodes.arrowright,
-				preventDefault: preventDefaultSpy
+			model.change( writer => {
+				writer.insertText( 'yz', selection.getAttributes(), selection.getFirstPosition() );
 			} );
 
-			sinon.assert.notCalled( preventDefaultSpy );
-			expect( selection.isGravityOverridden ).to.false;
+			// <$text b="1">yz[]</$text><$text a="1">x</$text>
+			expect( selection.isGravityOverridden ).to.be.false;
+			expect( getSelectionAttributesArray( selection ) ).to.have.members( [ 'b' ] );
 		} );
+	} );
 
-		it( 'should do nothing when caret is at the beginning of block element', () => {
-			setData( model, '[]foo', { lastRangeBackward: true } );
-
-			expect( () => {
-				fireKeyDownEvent( { keyCode: keyCodes.arrowleft } );
-			} ).to.not.throw();
+	describe( 'multiple attributes', () => {
+		beforeEach( () => {
+			bindTwoStepCaretToAttribute( editor.editing.view, editor.model, emitter, 'c' );
 		} );
 
-		it( 'should require two-steps movement when caret goes between text node with the same attribute but different value', () => {
-			setData( model, '<$text a="2">foo</$text><$text a="1">b[]ar</$text>' );
-
-			// Gravity is not overridden.
-			expect( selection.isGravityOverridden ).to.false;
-
-			// Press left key.
-			fireKeyDownEvent( {
-				keyCode: keyCodes.arrowleft,
-				preventDefault: preventDefaultSpy
-			} );
+		it( 'should work with the two-step caret movement (moving right)', () => {
+			setData( model, 'fo[]o<$text a="true">foo</$text><$text a="true" c="true">bar</$text><$text c="true">baz</$text>qux' );
+
+			testTwoStepCaretMovement( [
+				// fo[]o<$text a="true">foo</$text><$text a="true" c="true">bar</$text><$text c="true">baz</$text>qux
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				'→',
+				// foo[]<$text a="true">foo</$text><$text a="true" c="true">bar</$text><$text c="true">baz</$text>qux
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				'→',
+				// foo<$text a="true">[]foo</$text><$text a="true" c="true">bar</$text><$text c="true">baz</$text>qux
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 },
+				'→',
+				'→',
+				'→',
+				// foo<$text a="true">foo[]</$text><$text a="true" c="true">bar</$text><$text c="true">baz</$text>qux
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
+				'→',
+				// foo<$text a="true">foo</$text><$text a="true" c="true">[]bar</$text><$text c="true">baz</$text>qux
+				{ selectionAttributes: [ 'a', 'c' ], isGravityOverridden: true, preventDefault: 2, evtStopCalled: 2 },
+				'→',
+				'→',
+				'→',
+				// foo<$text a="true">foo</$text><$text a="true" c="true">bar[]</$text><$text c="true">baz</$text>qux
+				{ selectionAttributes: [ 'a', 'c' ], isGravityOverridden: false, preventDefault: 2, evtStopCalled: 2 },
+				'→',
+				// foo<$text a="true">foo</$text><$text a="true" c="true">bar</$text><$text c="true">[]baz</$text>qux
+				{ selectionAttributes: [ 'c' ], isGravityOverridden: true, preventDefault: 3, evtStopCalled: 3 },
+				'→',
+				'→',
+				'→',
+				// foo<$text a="true">foo</$text><$text a="true" c="true">bar</$text><$text c="true">baz[]</$text>qux
+				{ selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 3, evtStopCalled: 3 },
+				'→',
+				// foo<$text a="true">foo</$text><$text a="true" c="true">bar</$text><$text c="true">baz</$text>[]qux
+				{ selectionAttributes: [], isGravityOverridden: true, preventDefault: 4, evtStopCalled: 4 },
+				'→',
+				// foo<$text a="true">foo</$text><$text a="true" c="true">bar</$text><$text c="true">baz</$text>q[]ux
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 4, evtStopCalled: 4 },
+			] );
+		} );
 
-			// Gravity is overridden, caret movement was not blocked.
-			sinon.assert.notCalled( preventDefaultSpy );
-			expect( selection.isGravityOverridden ).to.true;
+		it( 'should work with the two-step caret movement (moving left)', () => {
+			setData( model, 'foo<$text a="true">foo</$text><$text a="true" c="true">bar</$text><$text c="true">baz</$text>q[]ux' );
+
+			testTwoStepCaretMovement( [
+				// foo<$text a="true">foo</$text><$text a="true" c="true">bar</$text><$text c="true">baz</$text>q[]ux
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+				'←',
+				// foo<$text a="true">foo</$text><$text a="true" c="true">bar</$text><$text c="true">baz</$text>[]qux
+				{ selectionAttributes: [], isGravityOverridden: true, preventDefault: 0, evtStopCalled: 0 },
+				'←',
+				// foo<$text a="true">foo</$text><$text a="true" c="true">bar</$text><$text c="true">baz[]</$text>qux
+				{ selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
+				'←',
+				'←',
+				'←',
+				// foo<$text a="true">foo</$text><$text a="true" c="true">bar</$text><$text c="true">[]baz</$text>qux
+				{ selectionAttributes: [ 'c' ], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 },
+				'←',
+				// foo<$text a="true">foo</$text><$text a="true" c="true">bar[]</$text><$text c="true">baz</$text>qux
+				{ selectionAttributes: [ 'a', 'c' ], isGravityOverridden: false, preventDefault: 2, evtStopCalled: 2 },
+				'←',
+				'←',
+				'←',
+				// foo<$text a="true">foo</$text><$text a="true" c="true">[]bar</$text><$text c="true">baz</$text>qux
+				{ selectionAttributes: [ 'a', 'c' ], isGravityOverridden: true, preventDefault: 2, evtStopCalled: 2 },
+				'←',
+				// foo<$text a="true">foo[]</$text><$text a="true" c="true">bar</$text><$text c="true">baz</$text>qux
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 3, evtStopCalled: 3 },
+				'←',
+				'←',
+				'←',
+				// foo<$text a="true">[]foo</$text><$text a="true" c="true">bar</$text><$text c="true">baz</$text>qux
+				{ selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 3, evtStopCalled: 3 },
+				'←',
+				// foo[]<$text a="true">foo</$text><$text a="true" c="true">bar</$text><$text c="true">baz</$text>qux
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 4, evtStopCalled: 4 },
+				'←',
+				// fo[]o<$text a="true">foo</$text><$text a="true" c="true">bar</$text><$text c="true">baz</$text>qux
+				{ selectionAttributes: [], isGravityOverridden: false, preventDefault: 4, evtStopCalled: 4 },
+			] );
 		} );
 	} );
 
@@ -293,16 +643,40 @@ describe( 'bindTwoStepCaretToAttribute()', () => {
 		it( 'should not override gravity when selection is placed at the beginning of text', () => {
 			setData( model, '<$text a="true">[]foo</$text>' );
 
-			expect( selection.isGravityOverridden ).to.false;
+			expect( selection.isGravityOverridden ).to.be.false;
 		} );
 
 		it( 'should not override gravity when selection is placed at the end of text', () => {
 			setData( model, '<$text a="true">foo[]</$text>' );
 
-			expect( selection.isGravityOverridden ).to.false;
+			expect( selection.isGravityOverridden ).to.be.false;
 		} );
 	} );
 
+	it( 'should listen with the high+1 priority on view.document#keydown', () => {
+		const highestPrioritySpy = sinon.spy();
+		const highPrioritySpy = sinon.spy();
+		const normalPrioritySpy = sinon.spy();
+
+		setData( model, '<$text c="true">foo[]</$text><$text a="true" b="true">bar</$text>' );
+
+		emitter.listenTo( view.document, 'keydown', highestPrioritySpy, { priority: 'highest' } );
+		emitter.listenTo( view.document, 'keydown', highPrioritySpy, { priority: 'high' } );
+		emitter.listenTo( view.document, 'keydown', normalPrioritySpy, { priority: 'normal' } );
+
+		fireKeyDownEvent( {
+			keyCode: keyCodes.arrowright,
+			preventDefault: preventDefaultSpy
+		} );
+
+		sinon.assert.callOrder(
+			highestPrioritySpy,
+			preventDefaultSpy );
+
+		sinon.assert.notCalled( highPrioritySpy );
+		sinon.assert.notCalled( normalPrioritySpy );
+	} );
+
 	it( 'should do nothing when key other then arrow left and right is pressed', () => {
 		setData( model, '<$text a="true">foo[]</$text>' );
 
@@ -316,7 +690,7 @@ describe( 'bindTwoStepCaretToAttribute()', () => {
 
 		fireKeyDownEvent( { keyCode: keyCodes.arrowright } );
 
-		expect( selection.isGravityOverridden ).to.false;
+		expect( selection.isGravityOverridden ).to.be.false;
 	} );
 
 	it( 'should do nothing when shift key is pressed', () => {
@@ -327,7 +701,7 @@ describe( 'bindTwoStepCaretToAttribute()', () => {
 			shiftKey: true
 		} );
 
-		expect( selection.isGravityOverridden ).to.false;
+		expect( selection.isGravityOverridden ).to.be.false;
 	} );
 
 	it( 'should do nothing when alt key is pressed', () => {
@@ -338,7 +712,7 @@ describe( 'bindTwoStepCaretToAttribute()', () => {
 			altKey: true
 		} );
 
-		expect( selection.isGravityOverridden ).to.false;
+		expect( selection.isGravityOverridden ).to.be.false;
 	} );
 
 	it( 'should do nothing when ctrl key is pressed', () => {
@@ -349,12 +723,114 @@ describe( 'bindTwoStepCaretToAttribute()', () => {
 			ctrlKey: true
 		} );
 
-		expect( selection.isGravityOverridden ).to.false;
+		expect( selection.isGravityOverridden ).to.be.false;
 	} );
 
+	it( 'should do nothing when the not a direct selection change but at the attribute boundary', () => {
+		setData( model, '<$text a="true">foo[]</$text>bar' );
+
+		testTwoStepCaretMovement( [
+			{ selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
+			'→',
+			{ selectionAttributes: [], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 },
+		] );
+
+		// Simulate an external text insertion BEFORE the user selection to trigger #change:range.
+		model.enqueueChange( 'transparent', writer => {
+			writer.insertText( 'x', selection.getFirstPosition().getShiftedBy( -2 ) );
+		} );
+
+		expect( selection.isGravityOverridden ).to.be.true;
+		expect( getSelectionAttributesArray( selection ) ).to.have.members( [] );
+	} );
+
+	const keyMap = {
+		'→': 'arrowright',
+		'←': 'arrowleft'
+	};
+
 	function fireKeyDownEvent( options ) {
-		const eventData = Object.assign( { domTarget: document.body }, options );
+		const eventInfo = new EventInfo( view.document, 'keydown' );
+		const eventData = new DomEventData( view.document, {
+			target: document.body,
+		}, options );
 
-		viewDoc.fire( 'keydown', eventData );
+		sinon.stub( eventInfo, 'stop' ).callsFake( evtStopSpy );
+
+		view.document.fire( eventInfo, eventData );
+	}
+
+	function getSelectionAttributesArray( selection ) {
+		return Array.from( selection.getAttributeKeys() );
+	}
+
+	function testTwoStepCaretMovement( scenario ) {
+		for ( const step of scenario ) {
+			if ( typeof step == 'string' ) {
+				// An arrow key pressed. Fire the view event and update the model selection.
+				if ( keyMap[ step ] ) {
+					let preventDefaultCalled;
+
+					fireKeyDownEvent( {
+						keyCode: keyCodes[ keyMap[ step ] ],
+						preventDefault: () => {
+							preventDefaultSpy();
+
+							preventDefaultCalled = true;
+						}
+					} );
+
+					const position = selection.getFirstPosition();
+
+					if ( !preventDefaultCalled ) {
+						if ( step == '→' ) {
+							model.change( writer => {
+								if ( position.isAtEnd ) {
+									const nextBlock = position.parent.nextSibling;
+
+									if ( nextBlock ) {
+										writer.setSelection( Position.createAt( nextBlock, 0 ) );
+									}
+								} else {
+									writer.setSelection( selection.getFirstPosition().getShiftedBy( 1 ) );
+								}
+							} );
+						} else if ( step == '←' ) {
+							model.change( writer => {
+								if ( position.isAtStart ) {
+									const previousBlock = position.parent.previousSibling;
+
+									if ( previousBlock ) {
+										writer.setSelection( Position.createAt( previousBlock, 'end' ) );
+									}
+								} else {
+									writer.setSelection( selection.getFirstPosition().getShiftedBy( -1 ) );
+								}
+							} );
+						}
+					}
+				}
+
+				// A regular key pressed. Type some text in the model.
+				else {
+					model.change( writer => {
+						writer.insertText( step, selection.getAttributes(), selection.getFirstPosition() );
+					} );
+				}
+			}
+
+			// If not a key, then it's an assertion.
+			else {
+				const stepIndex = scenario.indexOf( step );
+				const stepString = `in step #${ stepIndex } ${ JSON.stringify( step ) }`;
+
+				expect( getSelectionAttributesArray( selection ) ).to.have.members(
+					step.selectionAttributes, `#attributes ${ stepString }` );
+				expect( selection.isGravityOverridden ).to.equal( step.isGravityOverridden, `#isGravityOverridden ${ stepString }` );
+				expect( preventDefaultSpy.callCount ).to.equal( step.preventDefault, `#preventDefault ${ stepString }` );
+				expect( evtStopSpy.callCount ).to.equal( step.evtStopCalled, `#evtStopCalled ${ stepString }` );
+			}
+		}
 	}
 } );
+