Przeglądaj źródła

Merge pull request #1300 from ckeditor/t/1289

Feature: Introduced two-step caret movement mechanism. Closes #1289.
Piotrek Koszuliński 8 lat temu
rodzic
commit
7f02a4b3b1

+ 88 - 3
packages/ckeditor5-engine/src/model/documentselection.js

@@ -139,6 +139,18 @@ export default class DocumentSelection {
 		return this._selection.isBackward;
 	}
 
+	/**
+	 * Describes whether the gravity is overridden (using {@link module:engine/model/writer~Writer#overrideSelectionGravity}) or not.
+	 *
+	 * Note that the gravity remains overridden as long as will not be restored the same number of times as it was overridden.
+	 *
+	 * @readonly
+	 * @return {Boolean}
+	 */
+	get isGravityOverridden() {
+		return this._selection.isGravityOverridden;
+	}
+
 	/**
 	 * Used for the compatibility with the {@link module:engine/model/selection~Selection#isEqual} method.
 	 *
@@ -388,6 +400,34 @@ export default class DocumentSelection {
 		return this._selection._getStoredAttributes();
 	}
 
+	/**
+	 * 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.
+	 *
+	 * @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.
+	 */
+	_overrideGravity( customRestore ) {
+		this._selection.overrideGravity( customRestore );
+	}
+
+	/**
+	 * Restores {@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.
+	 *
+	 * @see module:engine/model/writer~Writer#restoreSelectionGravity
+	 * @protected
+	 */
+	_restoreGravity() {
+		this._selection.restoreGravity();
+	}
+
 	/**
 	 * Generates and returns an attribute key for selection attributes store, basing on original attribute key.
 	 *
@@ -465,6 +505,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.
+		// @private
+		// @type {Number}
+		this._overriddenGravityCounter = 0;
+
 		// Add events that will ensure selection correctness.
 		this.on( 'change:range', () => {
 			for ( const range of this.getRanges() ) {
@@ -550,6 +597,15 @@ class LiveSelection extends Selection {
 		return this._ranges.length > 0;
 	}
 
+	// When set to `true` then selection attributes on node before the caret won't be taken
+	// into consideration while updating selection attributes.
+	//
+	// @protected
+	// @type {Boolean}
+	get isGravityOverridden() {
+		return this._overriddenGravityCounter > 0;
+	}
+
 	// Unbinds all events previously bound by live selection.
 	destroy() {
 		for ( let i = 0; i < this._ranges.length; i++ ) {
@@ -601,6 +657,31 @@ class LiveSelection extends Selection {
 		}
 	}
 
+	overrideGravity( customRestore ) {
+		this._overriddenGravityCounter++;
+
+		if ( this._overriddenGravityCounter == 1 ) {
+			if ( !customRestore ) {
+				this.on( 'change:range', ( evt, data ) => {
+					if ( data.directChange ) {
+						this.restoreGravity();
+						evt.off();
+					}
+				} );
+			}
+
+			this._updateAttributes();
+		}
+	}
+
+	restoreGravity() {
+		this._overriddenGravityCounter--;
+
+		if ( !this.isGravityOverridden ) {
+			this._updateAttributes();
+		}
+	}
+
 	// Removes all attributes from the selection and sets attributes according to the surrounding nodes.
 	_refreshAttributes() {
 		this._updateAttributes( true );
@@ -851,8 +932,11 @@ class LiveSelection extends Selection {
 			const nodeBefore = position.textNode ? position.textNode : position.nodeBefore;
 			const nodeAfter = position.textNode ? position.textNode : position.nodeAfter;
 
-			// ...look at the node before caret and take attributes from it if it is a character node.
-			attrs = getAttrsIfCharacter( nodeBefore );
+			// When gravity is overridden then don't take node before into consideration.
+			if ( !this.isGravityOverridden ) {
+				// ...look at the node before caret and take attributes from it if it is a character node.
+				attrs = getAttrsIfCharacter( nodeBefore );
+			}
 
 			// 3. If not, look at the node after caret...
 			if ( !attrs ) {
@@ -860,7 +944,8 @@ class LiveSelection extends Selection {
 			}
 
 			// 4. If not, try to find the first character on the left, that is in the same node.
-			if ( !attrs ) {
+			// When gravity is overridden then don't take node before into consideration.
+			if ( !this.isGravityOverridden && !attrs ) {
 				let node = nodeBefore;
 
 				while ( node && !attrs ) {

+ 40 - 0
packages/ckeditor5-engine/src/model/writer.js

@@ -1016,6 +1016,46 @@ export default class Writer {
 		}
 	}
 
+	/**
+	 * Temporarily changes the {@link module:engine/model/documentselection~DocumentSelection#isGravityOverridden 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,
+	 * then 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.
+	 *
+	 * For the following model fragment:
+	 *
+	 *		<$text bold="true" linkHref="url">bar[]</$text><$text bold="true">biz</$text>
+	 *
+	 * * 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.
+	 *
+	 * 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.
+	 */
+	overrideSelectionGravity( customRestore ) {
+		this.model.document.selection._overrideGravity( customRestore );
+	}
+
+	/**
+	 * 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.
+	 */
+	restoreSelectionGravity() {
+		this.model.document.selection._restoreGravity();
+	}
+
 	/**
 	 * @private
 	 * @param {String} key Key of the attribute to remove.

+ 141 - 0
packages/ckeditor5-engine/src/utils/bindtwostepcarettoattribute.js

@@ -0,0 +1,141 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module engine/utils/bindtwostepcarettoattribute
+ */
+
+import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
+
+/**
+ * This helper adds two-step caret movement behavior for the given attribute.
+ *
+ * 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:
+ *
+ * * 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>`
+ *
+ * The same behavior will be changed fo "leaving" an attribute element:
+ *
+ * * 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>`
+ *
+ * And when moving left:
+ *
+ * * 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>`
+ *
+ * @param {module:engine/view/view~View} view View controller 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 this behavior will be added.
+ */
+export default function bindTwoStepCaretToAttribute( view, model, emitter, attribute ) {
+	const modelSelection = model.document.selection;
+
+	// Listen to keyboard events and handle cursor before the move.
+	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
+		// two-steps movement is not necessary.
+		if ( data.shiftKey || data.altKey || data.ctrlKey ) {
+			return;
+		}
+
+		const position = modelSelection.getFirstPosition();
+
+		// 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 ) {
+				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() );
+			}
+
+		// Moving left.
+		} 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() );
+
+				return;
+			}
+
+			// 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 );
+
+			// 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();
+						}
+					} );
+				} );
+			}
+		}
+	} );
+}
+
+// @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.
+// @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;
+
+	if ( isAttrInNext && isAttrInPrev && nextNode.getAttributeKeys( attribute ) !== prevNode.getAttribute( attribute ) ) {
+		return true;
+	}
+
+	return isAttrInNext && !isAttrInPrev || !isAttrInNext && isAttrInPrev;
+}

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

@@ -0,0 +1,5 @@
+<div id="editor">
+	<p>Foo <u>bar</u> biz</p>
+	<p>Foo <u>bar</u><i>biz</i> buz?</p>
+	<p>Foo <b>bar</b> biz</p>
+</div>

+ 31 - 0
packages/ckeditor5-engine/tests/manual/two-step-caret.js

@@ -0,0 +1,31 @@
+/**
+ * @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 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';
+
+ClassicEditor
+	.create( document.querySelector( '#editor' ), {
+		plugins: [ Essentials, Paragraph, Underline, Bold, Italic ],
+		toolbar: [ 'undo', 'redo', '|', 'bold', 'underline', 'italic' ]
+	} )
+	.then( editor => {
+		const bold = editor.plugins.get( Italic );
+		const underline = editor.plugins.get( Underline );
+
+		bindTwoStepCaretToAttribute( editor.editing.view, editor.model, bold, 'italic' );
+		bindTwoStepCaretToAttribute( editor.editing.view, editor.model, underline, 'underline' );
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );

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

@@ -0,0 +1,49 @@
+## Two-steps caret movment [#1286](https://github.com/ckeditor/ckeditor5-engine/issues/1289)
+
+### Moving right
+1. Put selection one character before the underline
+2. Move selection by one character to the right using right arrow
+	- underline button should be not selected
+3. Press right arrow once again
+	- selection should be at the same position
+	- underline button should be selected
+4. Using right arrow move selection at the end of the underline
+	- underline button should be selected
+5. Press right arrow once again
+	- selection should be at the same position
+	- underline button should be not selected
+
+### Moving left
+1. Put selection one character after the underline
+2. Move selection by one character to the left using left arrow
+	- underline button should be not selected
+3. Press left arrow once again
+	- selection should be at the same position
+	- underline button should be selected
+4. Using left arrow move selection at the beginning of the underline
+	- underline button should be selected
+5. Press left arrow once again
+	- selection should be at the same position
+	- underline button should be not selected
+
+### Mouse
+1. Put selection at the beginning of the underline
+	- underline button should be not selected
+2. Put selection at the end of the underline
+	- underline button should be  selected
+
+### Attributes set explicit
+1. Put selection one character before the end of the underline
+2. Move selection by one character to the right using right arrow
+	- underline button should be selected
+3. Turn on bold attribute (`Ctrl + B`)
+3. Press right arrow once again
+	- selection should be at the same position
+	- underline button should be not selected
+	- bold button should stay selected
+
+### Moving from one bound attribute to another
+1. Make sure that moving between underline and italic text from second paragraph works the same way as above.
+
+### Not bounded attribute
+Just make sure that two-steps caret movement is disabled for bold text from the third paragraph.

+ 686 - 573
packages/ckeditor5-engine/tests/model/documentselection.js

@@ -199,17 +199,6 @@ describe( 'DocumentSelection', () => {
 		} );
 	} );
 
-	describe( 'setTo - set collapsed at', () => {
-		it( 'detaches all existing ranges', () => {
-			selection._setTo( [ range, liveRange ] );
-
-			const spy = testUtils.sinon.spy( LiveRange.prototype, 'detach' );
-			selection._setTo( root );
-
-			expect( spy.calledTwice ).to.be.true;
-		} );
-	} );
-
 	describe( 'destroy()', () => {
 		it( 'should unbind all events', () => {
 			selection._setTo( [ range, liveRange ] );
@@ -229,7 +218,45 @@ describe( 'DocumentSelection', () => {
 		} );
 	} );
 
-	describe( 'setFocus()', () => {
+	describe( 'getFirstRange()', () => {
+		it( 'should return default range if no ranges were added', () => {
+			const firstRange = selection.getFirstRange();
+
+			expect( firstRange.start.isEqual( new Position( root, [ 0, 0 ] ) ) );
+			expect( firstRange.end.isEqual( new Position( root, [ 0, 0 ] ) ) );
+		} );
+	} );
+
+	describe( 'getLastRange()', () => {
+		it( 'should return default range if no ranges were added', () => {
+			const lastRange = selection.getLastRange();
+
+			expect( lastRange.start.isEqual( new Position( root, [ 0, 0 ] ) ) );
+			expect( lastRange.end.isEqual( new Position( root, [ 0, 0 ] ) ) );
+		} );
+	} );
+
+	describe( 'getSelectedElement()', () => {
+		it( 'should return selected element', () => {
+			selection._setTo( liveRange );
+			const p = root.getChild( 0 );
+
+			expect( selection.getSelectedElement() ).to.equal( p );
+		} );
+	} );
+
+	describe( '_setTo() - set collapsed at', () => {
+		it( 'detaches all existing ranges', () => {
+			selection._setTo( [ range, liveRange ] );
+
+			const spy = testUtils.sinon.spy( LiveRange.prototype, 'detach' );
+			selection._setTo( root );
+
+			expect( spy.calledTwice ).to.be.true;
+		} );
+	} );
+
+	describe( '_setFocus()', () => {
 		it( 'modifies default range', () => {
 			const startPos = selection.getFirstPosition();
 			const endPos = Position.createAt( root, 'end' );
@@ -262,7 +289,7 @@ describe( 'DocumentSelection', () => {
 		} );
 	} );
 
-	describe( 'setTo - remove all ranges', () => {
+	describe( '_setTo() - remove all ranges', () => {
 		let spy, ranges;
 
 		beforeEach( () => {
@@ -304,7 +331,7 @@ describe( 'DocumentSelection', () => {
 		} );
 	} );
 
-	describe( 'setTo()', () => {
+	describe( '_setTo()', () => {
 		it( 'should throw an error when range is invalid', () => {
 			expect( () => {
 				selection._setTo( [ { invalid: 'range' } ] );
@@ -364,24 +391,6 @@ describe( 'DocumentSelection', () => {
 		} );
 	} );
 
-	describe( 'getFirstRange()', () => {
-		it( 'should return default range if no ranges were added', () => {
-			const firstRange = selection.getFirstRange();
-
-			expect( firstRange.start.isEqual( new Position( root, [ 0, 0 ] ) ) );
-			expect( firstRange.end.isEqual( new Position( root, [ 0, 0 ] ) ) );
-		} );
-	} );
-
-	describe( 'getLastRange()', () => {
-		it( 'should return default range if no ranges were added', () => {
-			const lastRange = selection.getLastRange();
-
-			expect( lastRange.start.isEqual( new Position( root, [ 0, 0 ] ) ) );
-			expect( lastRange.end.isEqual( new Position( root, [ 0, 0 ] ) ) );
-		} );
-	} );
-
 	describe( '_isStoreAttributeKey', () => {
 		it( 'should return true if given key is a key of an attribute stored in element by DocumentSelection', () => {
 			expect( DocumentSelection._isStoreAttributeKey( fooStoreAttrKey ) ).to.be.true;
@@ -392,627 +401,250 @@ describe( 'DocumentSelection', () => {
 		} );
 	} );
 
-	describe( 'getSelectedElement()', () => {
-		it( 'should return selected element', () => {
-			selection._setTo( liveRange );
-			const p = root.getChild( 0 );
-
-			expect( selection.getSelectedElement() ).to.equal( p );
-		} );
-	} );
-
-	// DocumentSelection uses LiveRanges so here are only simple test to see if integration is
-	// working well, without getting into complicated corner cases.
-	describe( 'after applying an operation should get updated and fire events', () => {
-		let spyRange;
+	describe( 'attributes', () => {
+		let fullP, emptyP, rangeInFullP, rangeInEmptyP;
 
 		beforeEach( () => {
 			root.removeChildren( 0, root.childCount );
-			root.insertChildren( 0, [
-				new Element( 'p', [], new Text( 'abcdef' ) ),
+			root.appendChildren( [
 				new Element( 'p', [], new Text( 'foobar' ) ),
-				new Text( 'xyz' )
+				new Element( 'p', [], [] )
 			] );
 
-			selection._setTo( new Range( new Position( root, [ 0, 2 ] ), new Position( root, [ 1, 4 ] ) ) );
-
-			spyRange = sinon.spy();
-			selection.on( 'change:range', spyRange );
-		} );
+			fullP = root.getChild( 0 );
+			emptyP = root.getChild( 1 );
 
-		describe( 'InsertOperation', () => {
-			it( 'before selection', () => {
-				selection.on( 'change:range', ( evt, data ) => {
-					expect( data.directChange ).to.be.false;
-				} );
+			rangeInFullP = new Range( new Position( root, [ 0, 4 ] ), new Position( root, [ 0, 4 ] ) );
+			rangeInEmptyP = new Range( new Position( root, [ 1, 0 ] ), new Position( root, [ 1, 0 ] ) );
 
-				model.applyOperation( wrapInDelta(
-					new InsertOperation(
-						new Position( root, [ 0, 1 ] ),
-						'xyz',
-						doc.version
-					)
-				) );
+			// I've lost 30 mins debugging why my tests fail and that was due to the above code reusing
+			// a root which wasn't empty (so the ranges didn't actually make much sense).
+			expect( root.childCount ).to.equal( 2 );
+		} );
 
-				const range = selection.getFirstRange();
+		describe( '_setAttribute()', () => {
+			it( 'should set attribute', () => {
+				selection._setTo( [ rangeInEmptyP ] );
+				selection._setAttribute( 'foo', 'bar' );
 
-				expect( range.start.path ).to.deep.equal( [ 0, 5 ] );
-				expect( range.end.path ).to.deep.equal( [ 1, 4 ] );
-				expect( spyRange.calledOnce ).to.be.true;
+				expect( selection.getAttribute( 'foo' ) ).to.equal( 'bar' );
 			} );
+		} );
 
-			it( 'inside selection', () => {
-				selection.on( 'change:range', ( evt, data ) => {
-					expect( data.directChange ).to.be.false;
-				} );
+		describe( '_removeAttribute()', () => {
+			it( 'should remove attribute set on the text fragment', () => {
+				selection._setTo( [ rangeInFullP ] );
+				selection._setAttribute( 'foo', 'bar' );
+				selection._removeAttribute( 'foo' );
 
-				model.applyOperation( wrapInDelta(
-					new InsertOperation(
-						new Position( root, [ 1, 0 ] ),
-						'xyz',
-						doc.version
-					)
-				) );
+				expect( selection.getAttribute( 'foo' ) ).to.be.undefined;
+			} );
+		} );
 
-				const range = selection.getFirstRange();
+		describe( '_getStoredAttributes()', () => {
+			it( 'should return no values if there are no ranges in selection', () => {
+				const values = Array.from( selection._getStoredAttributes() );
 
-				expect( range.start.path ).to.deep.equal( [ 0, 2 ] );
-				expect( range.end.path ).to.deep.equal( [ 1, 7 ] );
-				expect( spyRange.calledOnce ).to.be.true;
+				expect( values ).to.deep.equal( [] );
 			} );
 		} );
 
-		describe( 'MoveOperation', () => {
-			it( 'move range from before a selection', () => {
-				selection.on( 'change:range', ( evt, data ) => {
-					expect( data.directChange ).to.be.false;
-				} );
+		describe( 'are updated on a direct range change', () => {
+			beforeEach( () => {
+				root.insertChildren( 0, [
+					new Element( 'p', { p: true } ),
+					new Text( 'a', { a: true } ),
+					new Element( 'p', { p: true } ),
+					new Text( 'b', { b: true } ),
+					new Text( 'c', { c: true } ),
+					new Element( 'p', [], [
+						new Text( 'd', { d: true } )
+					] ),
+					new Element( 'p', { p: true } ),
+					new Text( 'e', { e: true } )
+				] );
+			} );
 
-				model.applyOperation( wrapInDelta(
-					new MoveOperation(
-						new Position( root, [ 0, 0 ] ),
-						2,
-						new Position( root, [ 2 ] ),
-						doc.version
-					)
-				) );
+			it( 'if selection is a range, should find first character in it and copy it\'s attributes', () => {
+				selection._setTo( [ new Range( new Position( root, [ 2 ] ), new Position( root, [ 5 ] ) ) ] );
 
-				const range = selection.getFirstRange();
+				expect( Array.from( selection.getAttributes() ) ).to.deep.equal( [ [ 'b', true ] ] );
 
-				expect( range.start.path ).to.deep.equal( [ 0, 0 ] );
-				expect( range.end.path ).to.deep.equal( [ 1, 4 ] );
-				expect( spyRange.calledOnce ).to.be.true;
+				// Step into elements when looking for first character:
+				selection._setTo( [ new Range( new Position( root, [ 5 ] ), new Position( root, [ 7 ] ) ) ] );
+
+				expect( Array.from( selection.getAttributes() ) ).to.deep.equal( [ [ 'd', true ] ] );
 			} );
 
-			it( 'moved into before a selection', () => {
-				selection.on( 'change:range', ( evt, data ) => {
-					expect( data.directChange ).to.be.false;
-				} );
+			it( 'if selection is collapsed it should seek a character to copy that character\'s attributes', () => {
+				// Take styles from character before selection.
+				selection._setTo( [ new Range( new Position( root, [ 2 ] ), new Position( root, [ 2 ] ) ) ] );
+				expect( Array.from( selection.getAttributes() ) ).to.deep.equal( [ [ 'a', true ] ] );
 
-				model.applyOperation( wrapInDelta(
-					new MoveOperation(
-						new Position( root, [ 2 ] ),
-						2,
-						new Position( root, [ 0, 0 ] ),
-						doc.version
-					)
-				) );
+				// If there are none,
+				// Take styles from character after selection.
+				selection._setTo( [ new Range( new Position( root, [ 3 ] ), new Position( root, [ 3 ] ) ) ] );
+				expect( Array.from( selection.getAttributes() ) ).to.deep.equal( [ [ 'b', true ] ] );
 
-				const range = selection.getFirstRange();
+				// If there are none,
+				// Look from the selection position to the beginning of node looking for character to take attributes from.
+				selection._setTo( [ new Range( new Position( root, [ 6 ] ), new Position( root, [ 6 ] ) ) ] );
+				expect( Array.from( selection.getAttributes() ) ).to.deep.equal( [ [ 'c', true ] ] );
 
-				expect( range.start.path ).to.deep.equal( [ 0, 4 ] );
-				expect( range.end.path ).to.deep.equal( [ 1, 4 ] );
-				expect( spyRange.calledOnce ).to.be.true;
+				// If there are none,
+				// Look from the selection position to the end of node looking for character to take attributes from.
+				selection._setTo( [ new Range( new Position( root, [ 0 ] ), new Position( root, [ 0 ] ) ) ] );
+				expect( Array.from( selection.getAttributes() ) ).to.deep.equal( [ [ 'a', true ] ] );
+
+				// If there are no characters to copy attributes from, use stored attributes.
+				selection._setTo( [ new Range( new Position( root, [ 0, 0 ] ), new Position( root, [ 0, 0 ] ) ) ] );
+				expect( Array.from( selection.getAttributes() ) ).to.deep.equal( [] );
 			} );
 
-			it( 'move range from inside of selection', () => {
-				selection.on( 'change:range', ( evt, data ) => {
-					expect( data.directChange ).to.be.false;
-				} );
+			it( 'should overwrite any previously set attributes', () => {
+				selection._setTo( new Position( root, [ 5, 0 ] ) );
 
-				model.applyOperation( wrapInDelta(
-					new MoveOperation(
-						new Position( root, [ 1, 0 ] ),
-						2,
-						new Position( root, [ 2 ] ),
-						doc.version
-					)
-				) );
+				selection._setAttribute( 'x', true );
+				selection._setAttribute( 'y', true );
 
-				const range = selection.getFirstRange();
+				expect( Array.from( selection.getAttributes() ) ).to.deep.equal( [ [ 'd', true ], [ 'x', true ], [ 'y', true ] ] );
 
-				expect( range.start.path ).to.deep.equal( [ 0, 2 ] );
-				expect( range.end.path ).to.deep.equal( [ 1, 2 ] );
-				expect( spyRange.calledOnce ).to.be.true;
+				selection._setTo( new Position( root, [ 1 ] ) );
+
+				expect( Array.from( selection.getAttributes() ) ).to.deep.equal( [ [ 'a', true ] ] );
 			} );
 
-			it( 'moved range intersects with selection', () => {
-				selection.on( 'change:range', ( evt, data ) => {
-					expect( data.directChange ).to.be.false;
-				} );
+			it( 'should fire change:attribute event', () => {
+				const spy = sinon.spy();
+				selection.on( 'change:attribute', spy );
 
-				model.applyOperation( wrapInDelta(
-					new MoveOperation(
-						new Position( root, [ 1, 3 ] ),
-						2,
-						new Position( root, [ 4 ] ),
-						doc.version
-					)
-				) );
-
-				const range = selection.getFirstRange();
+				selection._setTo( [ new Range( new Position( root, [ 2 ] ), new Position( root, [ 5 ] ) ) ] );
 
-				expect( range.start.path ).to.deep.equal( [ 0, 2 ] );
-				expect( range.end.path ).to.deep.equal( [ 5 ] );
-				expect( spyRange.calledOnce ).to.be.true;
+				expect( spy.calledOnce ).to.be.true;
 			} );
 
-			it( 'split inside selection (do not break selection)', () => {
-				selection.on( 'change:range', ( evt, data ) => {
-					expect( data.directChange ).to.be.false;
-				} );
-
-				const batch = new Batch();
-				const splitDelta = new SplitDelta();
-
-				const insertOperation = new InsertOperation(
-					new Position( root, [ 2 ] ),
-					new Element( 'p' ),
-					0
-				);
-
-				const moveOperation = new MoveOperation(
-					new Position( root, [ 1, 2 ] ),
-					4,
-					new Position( root, [ 2, 0 ] ),
-					1
-				);
-
-				batch.addDelta( splitDelta );
+			it( 'should not fire change:attribute event if attributes did not change', () => {
+				selection._setTo( new Position( root, [ 5, 0 ] ) );
 
-				splitDelta.addOperation( insertOperation );
-				splitDelta.addOperation( moveOperation );
+				expect( Array.from( selection.getAttributes() ) ).to.deep.equal( [ [ 'd', true ] ] );
 
-				model.applyOperation( insertOperation );
-				model.applyOperation( moveOperation );
+				const spy = sinon.spy();
+				selection.on( 'change:attribute', spy );
 
-				const range = selection.getFirstRange();
+				selection._setTo( new Position( root, [ 5, 1 ] ) );
 
-				expect( range.start.path ).to.deep.equal( [ 0, 2 ] );
-				expect( range.end.path ).to.deep.equal( [ 2, 2 ] );
-				expect( spyRange.calledOnce ).to.be.true;
+				expect( Array.from( selection.getAttributes() ) ).to.deep.equal( [ [ 'd', true ] ] );
+				expect( spy.called ).to.be.false;
 			} );
 		} );
 
-		describe( 'AttributeOperation', () => {
-			it( 'changed range includes selection anchor', () => {
-				const spyAttribute = sinon.spy();
-				selection.on( 'change:attribute', spyAttribute );
-
-				selection.on( 'change:attribute', ( evt, data ) => {
-					expect( data.directChange ).to.be.false;
-					expect( data.attributeKeys ).to.deep.equal( [ 'foo' ] );
+		// #986
+		describe( 'are not inherited from the inside of object elements', () => {
+			beforeEach( () => {
+				model.schema.register( 'image', {
+					isObject: true
 				} );
+				model.schema.extend( 'image', { allowIn: '$root' } );
+				model.schema.extend( 'image', { allowIn: '$block' } );
 
-				model.applyOperation( wrapInDelta(
-					new AttributeOperation(
-						new Range( new Position( root, [ 0, 1 ] ), new Position( root, [ 0, 5 ] ) ),
-						'foo',
-						null,
-						'bar',
-						doc.version
-					)
-				) );
-
-				expect( selection.getAttribute( 'foo' ) ).to.equal( 'bar' );
-				expect( spyAttribute.calledOnce ).to.be.true;
+				model.schema.register( 'caption' );
+				model.schema.extend( 'caption', { allowIn: 'image' } );
+				model.schema.extend( '$text', {
+					allowIn: [ 'image', 'caption' ],
+					allowAttributes: 'bold'
+				} );
 			} );
 
-			it( 'should not overwrite previously set attributes', () => {
-				selection._setAttribute( 'foo', 'xyz' );
+			it( 'ignores attributes inside an object if selection contains that object', () => {
+				setData( model, '<p>[<image><$text bold="true">Caption for the image.</$text></image>]</p>' );
 
-				const spyAttribute = sinon.spy();
-				selection.on( 'change:attribute', spyAttribute );
+				expect( selection.hasAttribute( 'bold' ) ).to.equal( false );
+			} );
 
-				model.applyOperation( wrapInDelta(
-					new AttributeOperation(
-						new Range( new Position( root, [ 0, 1 ] ), new Position( root, [ 0, 5 ] ) ),
-						'foo',
-						null,
-						'bar',
-						doc.version
-					)
-				) );
+			it( 'ignores attributes inside an object if selection contains that object (deeper structure)', () => {
+				setData( model, '<p>[<image><caption><$text bold="true">Caption for the image.</$text></caption></image>]</p>' );
 
-				expect( selection.getAttribute( 'foo' ) ).to.equal( 'xyz' );
-				expect( spyAttribute.called ).to.be.false;
+				expect( selection.hasAttribute( 'bold' ) ).to.equal( false );
 			} );
 
-			it( 'should not overwrite previously set attributes with same values', () => {
-				selection._setAttribute( 'foo', 'xyz' );
+			it( 'ignores attributes inside an object if selection contains that object (block level)', () => {
+				setData( model, '<p>foo</p>[<image><$text bold="true">Caption for the image.</$text></image>]<p>foo</p>' );
 
-				const spyAttribute = sinon.spy();
-				selection.on( 'change:attribute', spyAttribute );
+				expect( selection.hasAttribute( 'bold' ) ).to.equal( false );
+			} );
 
-				model.applyOperation( wrapInDelta(
-					new AttributeOperation(
-						new Range( new Position( root, [ 0, 1 ] ), new Position( root, [ 0, 5 ] ) ),
-						'foo',
-						null,
-						'xyz',
-						doc.version
-					)
-				) );
+			it( 'reads attributes from text even if the selection contains an object', () => {
+				setData( model, '<p>x<$text bold="true">[bar</$text><image></image>foo]</p>' );
 
-				expect( selection.getAttribute( 'foo' ) ).to.equal( 'xyz' );
-				expect( spyAttribute.called ).to.be.false;
+				expect( selection.getAttribute( 'bold' ) ).to.equal( true );
 			} );
 
-			it( 'should not overwrite previously removed attributes', () => {
-				selection._setAttribute( 'foo', 'xyz' );
-				selection._removeAttribute( 'foo' );
+			it( 'reads attributes when the entire selection inside an object', () => {
+				setData( model, '<p><image><caption><$text bold="true">[bar]</$text></caption></image></p>' );
 
-				const spyAttribute = sinon.spy();
-				selection.on( 'change:attribute', spyAttribute );
+				expect( selection.getAttribute( 'bold' ) ).to.equal( true );
+			} );
 
-				model.applyOperation( wrapInDelta(
-					new AttributeOperation(
-						new Range( new Position( root, [ 0, 1 ] ), new Position( root, [ 0, 5 ] ) ),
-						'foo',
-						null,
-						'bar',
-						doc.version
-					)
-				) );
+			it( 'stops reading attributes if selection starts with an object', () => {
+				setData( model, '<p>[<image></image><$text bold="true">bar]</$text></p>' );
 
-				expect( selection.hasAttribute( 'foo' ) ).to.be.false;
-				expect( spyAttribute.called ).to.be.false;
+				expect( selection.hasAttribute( 'bold' ) ).to.equal( false );
 			} );
 		} );
 
-		describe( 'RemoveOperation', () => {
-			it( 'fix selection range if it ends up in graveyard #1', () => {
-				selection._setTo( new Position( root, [ 1, 3 ] ) );
+		describe( 'parent element\'s attributes', () => {
+			it( 'are set using a normal batch', () => {
+				const batchTypes = [];
 
-				model.applyOperation( wrapInDelta(
-					new RemoveOperation(
-						new Position( root, [ 1, 2 ] ),
-						2,
-						new Position( doc.graveyard, [ 0 ] ),
-						doc.version
-					)
-				) );
+				model.on( 'applyOperation', ( event, args ) => {
+					const operation = args[ 0 ];
+					const batch = operation.delta.batch;
 
-				expect( selection.getFirstPosition().path ).to.deep.equal( [ 1, 2 ] );
-			} );
+					batchTypes.push( batch.type );
+				} );
 
-			it( 'fix selection range if it ends up in graveyard #2', () => {
-				selection._setTo( [ new Range( new Position( root, [ 1, 2 ] ), new Position( root, [ 1, 4 ] ) ) ] );
+				selection._setTo( [ rangeInEmptyP ] );
 
-				model.applyOperation( wrapInDelta(
-					new RemoveOperation(
-						new Position( root, [ 1, 2 ] ),
-						2,
-						new Position( doc.graveyard, [ 0 ] ),
-						doc.version
-					)
-				) );
+				model.change( writer => {
+					writer.setSelectionAttribute( 'foo', 'bar' );
+				} );
 
-				expect( selection.getFirstPosition().path ).to.deep.equal( [ 1, 2 ] );
+				expect( batchTypes ).to.deep.equal( [ 'default' ] );
+				expect( emptyP.getAttribute( fooStoreAttrKey ) ).to.equal( 'bar' );
 			} );
 
-			it( 'fix selection range if it ends up in graveyard #3', () => {
-				selection._setTo( [ new Range( new Position( root, [ 1, 1 ] ), new Position( root, [ 1, 2 ] ) ) ] );
+			it( 'are removed when any content is inserted (reuses the same batch)', () => {
+				// Dedupe batches by using a map (multiple change events will be fired).
+				const batchTypes = new Map();
 
-				model.applyOperation( wrapInDelta(
-					new RemoveOperation(
-						new Position( root, [ 1 ] ),
-						2,
-						new Position( doc.graveyard, [ 0 ] ),
-						doc.version
-					)
-				) );
+				selection._setTo( rangeInEmptyP );
+				selection._setAttribute( 'foo', 'bar' );
+				selection._setAttribute( 'abc', 'bar' );
 
-				expect( selection.getFirstPosition().path ).to.deep.equal( [ 0, 6 ] );
-			} );
+				model.on( 'applyOperation', ( event, args ) => {
+					const operation = args[ 0 ];
+					const batch = operation.delta.batch;
 
-			it( 'fix selection range if it ends up in graveyard #4 - whole content removed', () => {
-				model.applyOperation( wrapInDelta(
-					new RemoveOperation(
-						new Position( root, [ 0 ] ),
-						3,
-						new Position( doc.graveyard, [ 0 ] ),
-						doc.version
-					)
-				) );
+					batchTypes.set( batch, batch.type );
+				} );
 
-				expect( selection.getFirstPosition().path ).to.deep.equal( [ 0 ] );
+				model.change( writer => {
+					writer.insertText( 'x', rangeInEmptyP.start );
+				} );
 
-				model.applyOperation( wrapInDelta(
-					new InsertOperation(
-						new Position( root, [ 0 ] ),
-						new Element( 'p' ),
-						doc.version
-					)
-				) );
+				expect( emptyP.hasAttribute( fooStoreAttrKey ) ).to.be.false;
+				expect( emptyP.hasAttribute( abcStoreAttrKey ) ).to.be.false;
 
-				// Now it's clear that it's the default range.
-				expect( selection.getFirstPosition().path ).to.deep.equal( [ 0, 0 ] );
+				expect( Array.from( batchTypes.values() ) ).to.deep.equal( [ 'default' ] );
 			} );
-		} );
-
-		it( '`DocumentSelection#change:range` event should be fire once even if selection contains multi-ranges', () => {
-			root.removeChildren( 0, root.childCount );
-			root.insertChildren( 0, [
-				new Element( 'p', [], new Text( 'abcdef' ) ),
-				new Element( 'p', [], new Text( 'foobar' ) ),
-				new Text( 'xyz #2' )
-			] );
 
-			selection._setTo( [
-				Range.createIn( root.getNodeByPath( [ 0 ] ) ),
-				Range.createIn( root.getNodeByPath( [ 1 ] ) )
-			] );
+			it( 'are removed when any content is moved into', () => {
+				selection._setTo( rangeInEmptyP );
+				selection._setAttribute( 'foo', 'bar' );
 
-			spyRange = sinon.spy();
-			selection.on( 'change:range', spyRange );
-
-			model.applyOperation( wrapInDelta(
-				new InsertOperation(
-					new Position( root, [ 0 ] ),
-					'xyz #1',
-					doc.version
-				)
-			) );
-
-			expect( spyRange.calledOnce ).to.be.true;
-		} );
-	} );
-
-	describe( 'attributes', () => {
-		let fullP, emptyP, rangeInFullP, rangeInEmptyP;
-
-		beforeEach( () => {
-			root.removeChildren( 0, root.childCount );
-			root.appendChildren( [
-				new Element( 'p', [], new Text( 'foobar' ) ),
-				new Element( 'p', [], [] )
-			] );
-
-			fullP = root.getChild( 0 );
-			emptyP = root.getChild( 1 );
-
-			rangeInFullP = new Range( new Position( root, [ 0, 4 ] ), new Position( root, [ 0, 4 ] ) );
-			rangeInEmptyP = new Range( new Position( root, [ 1, 0 ] ), new Position( root, [ 1, 0 ] ) );
-
-			// I've lost 30 mins debugging why my tests fail and that was due to the above code reusing
-			// a root which wasn't empty (so the ranges didn't actually make much sense).
-			expect( root.childCount ).to.equal( 2 );
-		} );
-
-		describe( '_setAttribute()', () => {
-			it( 'should set attribute', () => {
-				selection._setTo( [ rangeInEmptyP ] );
-				selection._setAttribute( 'foo', 'bar' );
-
-				expect( selection.getAttribute( 'foo' ) ).to.equal( 'bar' );
-			} );
-		} );
-
-		describe( 'removeAttribute()', () => {
-			it( 'should remove attribute set on the text fragment', () => {
-				selection._setTo( [ rangeInFullP ] );
-				selection._setAttribute( 'foo', 'bar' );
-				selection._removeAttribute( 'foo' );
-
-				expect( selection.getAttribute( 'foo' ) ).to.be.undefined;
-			} );
-		} );
-
-		describe( '_getStoredAttributes()', () => {
-			it( 'should return no values if there are no ranges in selection', () => {
-				const values = Array.from( selection._getStoredAttributes() );
-
-				expect( values ).to.deep.equal( [] );
-			} );
-		} );
-
-		describe( 'are updated on a direct range change', () => {
-			beforeEach( () => {
-				root.insertChildren( 0, [
-					new Element( 'p', { p: true } ),
-					new Text( 'a', { a: true } ),
-					new Element( 'p', { p: true } ),
-					new Text( 'b', { b: true } ),
-					new Text( 'c', { c: true } ),
-					new Element( 'p', [], [
-						new Text( 'd', { d: true } )
-					] ),
-					new Element( 'p', { p: true } ),
-					new Text( 'e', { e: true } )
-				] );
-			} );
-
-			it( 'if selection is a range, should find first character in it and copy it\'s attributes', () => {
-				selection._setTo( [ new Range( new Position( root, [ 2 ] ), new Position( root, [ 5 ] ) ) ] );
-
-				expect( Array.from( selection.getAttributes() ) ).to.deep.equal( [ [ 'b', true ] ] );
-
-				// Step into elements when looking for first character:
-				selection._setTo( [ new Range( new Position( root, [ 5 ] ), new Position( root, [ 7 ] ) ) ] );
-
-				expect( Array.from( selection.getAttributes() ) ).to.deep.equal( [ [ 'd', true ] ] );
-			} );
-
-			it( 'if selection is collapsed it should seek a character to copy that character\'s attributes', () => {
-				// Take styles from character before selection.
-				selection._setTo( [ new Range( new Position( root, [ 2 ] ), new Position( root, [ 2 ] ) ) ] );
-				expect( Array.from( selection.getAttributes() ) ).to.deep.equal( [ [ 'a', true ] ] );
-
-				// If there are none,
-				// Take styles from character after selection.
-				selection._setTo( [ new Range( new Position( root, [ 3 ] ), new Position( root, [ 3 ] ) ) ] );
-				expect( Array.from( selection.getAttributes() ) ).to.deep.equal( [ [ 'b', true ] ] );
-
-				// If there are none,
-				// Look from the selection position to the beginning of node looking for character to take attributes from.
-				selection._setTo( [ new Range( new Position( root, [ 6 ] ), new Position( root, [ 6 ] ) ) ] );
-				expect( Array.from( selection.getAttributes() ) ).to.deep.equal( [ [ 'c', true ] ] );
-
-				// If there are none,
-				// Look from the selection position to the end of node looking for character to take attributes from.
-				selection._setTo( [ new Range( new Position( root, [ 0 ] ), new Position( root, [ 0 ] ) ) ] );
-				expect( Array.from( selection.getAttributes() ) ).to.deep.equal( [ [ 'a', true ] ] );
-
-				// If there are no characters to copy attributes from, use stored attributes.
-				selection._setTo( [ new Range( new Position( root, [ 0, 0 ] ), new Position( root, [ 0, 0 ] ) ) ] );
-				expect( Array.from( selection.getAttributes() ) ).to.deep.equal( [] );
-			} );
-
-			it( 'should overwrite any previously set attributes', () => {
-				selection._setTo( new Position( root, [ 5, 0 ] ) );
-
-				selection._setAttribute( 'x', true );
-				selection._setAttribute( 'y', true );
-
-				expect( Array.from( selection.getAttributes() ) ).to.deep.equal( [ [ 'd', true ], [ 'x', true ], [ 'y', true ] ] );
-
-				selection._setTo( new Position( root, [ 1 ] ) );
-
-				expect( Array.from( selection.getAttributes() ) ).to.deep.equal( [ [ 'a', true ] ] );
-			} );
-
-			it( 'should fire change:attribute event', () => {
-				const spy = sinon.spy();
-				selection.on( 'change:attribute', spy );
-
-				selection._setTo( [ new Range( new Position( root, [ 2 ] ), new Position( root, [ 5 ] ) ) ] );
-
-				expect( spy.calledOnce ).to.be.true;
-			} );
-
-			it( 'should not fire change:attribute event if attributes did not change', () => {
-				selection._setTo( new Position( root, [ 5, 0 ] ) );
-
-				expect( Array.from( selection.getAttributes() ) ).to.deep.equal( [ [ 'd', true ] ] );
-
-				const spy = sinon.spy();
-				selection.on( 'change:attribute', spy );
-
-				selection._setTo( new Position( root, [ 5, 1 ] ) );
-
-				expect( Array.from( selection.getAttributes() ) ).to.deep.equal( [ [ 'd', true ] ] );
-				expect( spy.called ).to.be.false;
-			} );
-		} );
-
-		// #986
-		describe( 'are not inherited from the inside of object elements', () => {
-			beforeEach( () => {
-				model.schema.register( 'image', {
-					isObject: true
-				} );
-				model.schema.extend( 'image', { allowIn: '$root' } );
-				model.schema.extend( 'image', { allowIn: '$block' } );
-
-				model.schema.register( 'caption' );
-				model.schema.extend( 'caption', { allowIn: 'image' } );
-				model.schema.extend( '$text', {
-					allowIn: [ 'image', 'caption' ],
-					allowAttributes: 'bold'
-				} );
-			} );
-
-			it( 'ignores attributes inside an object if selection contains that object', () => {
-				setData( model, '<p>[<image><$text bold="true">Caption for the image.</$text></image>]</p>' );
-
-				expect( selection.hasAttribute( 'bold' ) ).to.equal( false );
-			} );
-
-			it( 'ignores attributes inside an object if selection contains that object (deeper structure)', () => {
-				setData( model, '<p>[<image><caption><$text bold="true">Caption for the image.</$text></caption></image>]</p>' );
-
-				expect( selection.hasAttribute( 'bold' ) ).to.equal( false );
-			} );
-
-			it( 'ignores attributes inside an object if selection contains that object (block level)', () => {
-				setData( model, '<p>foo</p>[<image><$text bold="true">Caption for the image.</$text></image>]<p>foo</p>' );
-
-				expect( selection.hasAttribute( 'bold' ) ).to.equal( false );
-			} );
-
-			it( 'reads attributes from text even if the selection contains an object', () => {
-				setData( model, '<p>x<$text bold="true">[bar</$text><image></image>foo]</p>' );
-
-				expect( selection.getAttribute( 'bold' ) ).to.equal( true );
-			} );
-
-			it( 'reads attributes when the entire selection inside an object', () => {
-				setData( model, '<p><image><caption><$text bold="true">[bar]</$text></caption></image></p>' );
-
-				expect( selection.getAttribute( 'bold' ) ).to.equal( true );
-			} );
-
-			it( 'stops reading attributes if selection starts with an object', () => {
-				setData( model, '<p>[<image></image><$text bold="true">bar]</$text></p>' );
-
-				expect( selection.hasAttribute( 'bold' ) ).to.equal( false );
-			} );
-		} );
-
-		describe( 'parent element\'s attributes', () => {
-			it( 'are set using a normal batch', () => {
-				const batchTypes = [];
-
-				model.on( 'applyOperation', ( event, args ) => {
-					const operation = args[ 0 ];
-					const batch = operation.delta.batch;
-
-					batchTypes.push( batch.type );
-				} );
-
-				selection._setTo( [ rangeInEmptyP ] );
-
-				model.change( writer => {
-					writer.setSelectionAttribute( 'foo', 'bar' );
-				} );
-
-				expect( batchTypes ).to.deep.equal( [ 'default' ] );
-				expect( emptyP.getAttribute( fooStoreAttrKey ) ).to.equal( 'bar' );
-			} );
-
-			it( 'are removed when any content is inserted (reuses the same batch)', () => {
-				// Dedupe batches by using a map (multiple change events will be fired).
-				const batchTypes = new Map();
-
-				selection._setTo( rangeInEmptyP );
-				selection._setAttribute( 'foo', 'bar' );
-				selection._setAttribute( 'abc', 'bar' );
-
-				model.on( 'applyOperation', ( event, args ) => {
-					const operation = args[ 0 ];
-					const batch = operation.delta.batch;
-
-					batchTypes.set( batch, batch.type );
-				} );
-
-				model.change( writer => {
-					writer.insertText( 'x', rangeInEmptyP.start );
-				} );
-
-				expect( emptyP.hasAttribute( fooStoreAttrKey ) ).to.be.false;
-				expect( emptyP.hasAttribute( abcStoreAttrKey ) ).to.be.false;
-
-				expect( Array.from( batchTypes.values() ) ).to.deep.equal( [ 'default' ] );
-			} );
-
-			it( 'are removed when any content is moved into', () => {
-				selection._setTo( rangeInEmptyP );
-				selection._setAttribute( 'foo', 'bar' );
-
-				model.change( writer => {
-					writer.move( Range.createOn( fullP.getChild( 0 ) ), rangeInEmptyP.start );
-				} );
+				model.change( writer => {
+					writer.move( Range.createOn( fullP.getChild( 0 ) ), rangeInEmptyP.start );
+				} );
 
 				expect( emptyP.hasAttribute( fooStoreAttrKey ) ).to.be.false;
 			} );
@@ -1118,6 +750,487 @@ describe( 'DocumentSelection', () => {
 		} );
 	} );
 
+	describe( '_overrideGravity()', () => {
+		beforeEach( () => {
+			model.schema.extend( '$text', {
+				allowIn: '$root'
+			} );
+		} );
+
+		it( 'should mark gravity as overridden', () => {
+			expect( selection.isGravityOverridden ).to.false;
+
+			selection._overrideGravity();
+
+			expect( selection.isGravityOverridden ).to.true;
+		} );
+
+		it( 'should not inherit attributes from node before the caret', () => {
+			setData( model, '<$text bold="true" italic="true">foo[]</$text>' );
+
+			expect( Array.from( selection.getAttributeKeys() ) ).to.have.members( [ 'bold', 'italic' ] );
+
+			selection._overrideGravity();
+
+			expect( Array.from( selection.getAttributeKeys() ) ).to.length( 0 );
+		} );
+
+		it( 'should inherit attributes from node after the caret', () => {
+			setData( model, '<$text>foo[]</$text><$text bold="true" italic="true">bar</$text>' );
+
+			expect( Array.from( selection.getAttributeKeys() ) ).to.length( 0 );
+
+			selection._overrideGravity();
+
+			expect( Array.from( selection.getAttributeKeys() ) ).to.have.members( [ 'bold', 'italic' ] );
+		} );
+
+		it( 'should retain attributes that are set explicit', () => {
+			setData( model, '<$text italic="true">foo[]</$text>' );
+
+			selection._setAttribute( 'bold', true );
+
+			expect( Array.from( selection.getAttributeKeys() ) ).to.have.members( [ 'bold', 'italic' ] );
+
+			selection._overrideGravity();
+
+			expect( Array.from( selection.getAttributeKeys() ) ).to.have.members( [ 'bold' ] );
+		} );
+
+		it( 'should retain overridden until selection will not change range by a direct change', () => {
+			setData( model, '<$text bold="true" italic="true">foo[]</$text><$text italic="true">bar</$text>' );
+
+			selection._overrideGravity();
+
+			// Changed range but not directly.
+			model.change( writer => writer.insertText( 'abc', new Position( root, [ 0 ] ) ) );
+
+			expect( Array.from( selection.getAttributeKeys() ) ).to.have.members( [ 'italic' ] );
+
+			// Changed range directly.
+			model.change( writer => writer.setSelection( new Position( root, [ 5 ] ) ) );
+
+			expect( Array.from( selection.getAttributeKeys() ) ).to.have.members( [ 'bold', 'italic' ] );
+		} );
+	} );
+
+	describe( '_restoreGravity()', () => {
+		beforeEach( () => {
+			model.schema.extend( '$text', {
+				allowIn: '$root'
+			} );
+		} );
+
+		it( 'should revert default gravity when is overridden', () => {
+			setData( model, '<$text bold="true" italic="true">foo[]</$text>' );
+
+			selection._overrideGravity();
+
+			expect( Array.from( selection.getAttributeKeys() ) ).to.length( 0 );
+			expect( selection.isGravityOverridden ).to.true;
+
+			selection._restoreGravity();
+
+			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();
+
+			expect( selection.isGravityOverridden ).to.true;
+
+			selection._restoreGravity();
+
+			expect( selection.isGravityOverridden ).to.true;
+
+			selection._restoreGravity();
+
+			expect( selection.isGravityOverridden ).to.false;
+		} );
+	} );
+
+	// DocumentSelection uses LiveRanges so here are only simple test to see if integration is
+	// working well, without getting into complicated corner cases.
+	describe( 'after applying an operation should get updated and fire events', () => {
+		let spyRange;
+
+		beforeEach( () => {
+			root.removeChildren( 0, root.childCount );
+			root.insertChildren( 0, [
+				new Element( 'p', [], new Text( 'abcdef' ) ),
+				new Element( 'p', [], new Text( 'foobar' ) ),
+				new Text( 'xyz' )
+			] );
+
+			selection._setTo( new Range( new Position( root, [ 0, 2 ] ), new Position( root, [ 1, 4 ] ) ) );
+
+			spyRange = sinon.spy();
+			selection.on( 'change:range', spyRange );
+		} );
+
+		describe( 'InsertOperation', () => {
+			it( 'before selection', () => {
+				selection.on( 'change:range', ( evt, data ) => {
+					expect( data.directChange ).to.be.false;
+				} );
+
+				model.applyOperation( wrapInDelta(
+					new InsertOperation(
+						new Position( root, [ 0, 1 ] ),
+						'xyz',
+						doc.version
+					)
+				) );
+
+				const range = selection.getFirstRange();
+
+				expect( range.start.path ).to.deep.equal( [ 0, 5 ] );
+				expect( range.end.path ).to.deep.equal( [ 1, 4 ] );
+				expect( spyRange.calledOnce ).to.be.true;
+			} );
+
+			it( 'inside selection', () => {
+				selection.on( 'change:range', ( evt, data ) => {
+					expect( data.directChange ).to.be.false;
+				} );
+
+				model.applyOperation( wrapInDelta(
+					new InsertOperation(
+						new Position( root, [ 1, 0 ] ),
+						'xyz',
+						doc.version
+					)
+				) );
+
+				const range = selection.getFirstRange();
+
+				expect( range.start.path ).to.deep.equal( [ 0, 2 ] );
+				expect( range.end.path ).to.deep.equal( [ 1, 7 ] );
+				expect( spyRange.calledOnce ).to.be.true;
+			} );
+		} );
+
+		describe( 'MoveOperation', () => {
+			it( 'move range from before a selection', () => {
+				selection.on( 'change:range', ( evt, data ) => {
+					expect( data.directChange ).to.be.false;
+				} );
+
+				model.applyOperation( wrapInDelta(
+					new MoveOperation(
+						new Position( root, [ 0, 0 ] ),
+						2,
+						new Position( root, [ 2 ] ),
+						doc.version
+					)
+				) );
+
+				const range = selection.getFirstRange();
+
+				expect( range.start.path ).to.deep.equal( [ 0, 0 ] );
+				expect( range.end.path ).to.deep.equal( [ 1, 4 ] );
+				expect( spyRange.calledOnce ).to.be.true;
+			} );
+
+			it( 'moved into before a selection', () => {
+				selection.on( 'change:range', ( evt, data ) => {
+					expect( data.directChange ).to.be.false;
+				} );
+
+				model.applyOperation( wrapInDelta(
+					new MoveOperation(
+						new Position( root, [ 2 ] ),
+						2,
+						new Position( root, [ 0, 0 ] ),
+						doc.version
+					)
+				) );
+
+				const range = selection.getFirstRange();
+
+				expect( range.start.path ).to.deep.equal( [ 0, 4 ] );
+				expect( range.end.path ).to.deep.equal( [ 1, 4 ] );
+				expect( spyRange.calledOnce ).to.be.true;
+			} );
+
+			it( 'move range from inside of selection', () => {
+				selection.on( 'change:range', ( evt, data ) => {
+					expect( data.directChange ).to.be.false;
+				} );
+
+				model.applyOperation( wrapInDelta(
+					new MoveOperation(
+						new Position( root, [ 1, 0 ] ),
+						2,
+						new Position( root, [ 2 ] ),
+						doc.version
+					)
+				) );
+
+				const range = selection.getFirstRange();
+
+				expect( range.start.path ).to.deep.equal( [ 0, 2 ] );
+				expect( range.end.path ).to.deep.equal( [ 1, 2 ] );
+				expect( spyRange.calledOnce ).to.be.true;
+			} );
+
+			it( 'moved range intersects with selection', () => {
+				selection.on( 'change:range', ( evt, data ) => {
+					expect( data.directChange ).to.be.false;
+				} );
+
+				model.applyOperation( wrapInDelta(
+					new MoveOperation(
+						new Position( root, [ 1, 3 ] ),
+						2,
+						new Position( root, [ 4 ] ),
+						doc.version
+					)
+				) );
+
+				const range = selection.getFirstRange();
+
+				expect( range.start.path ).to.deep.equal( [ 0, 2 ] );
+				expect( range.end.path ).to.deep.equal( [ 5 ] );
+				expect( spyRange.calledOnce ).to.be.true;
+			} );
+
+			it( 'split inside selection (do not break selection)', () => {
+				selection.on( 'change:range', ( evt, data ) => {
+					expect( data.directChange ).to.be.false;
+				} );
+
+				const batch = new Batch();
+				const splitDelta = new SplitDelta();
+
+				const insertOperation = new InsertOperation(
+					new Position( root, [ 2 ] ),
+					new Element( 'p' ),
+					0
+				);
+
+				const moveOperation = new MoveOperation(
+					new Position( root, [ 1, 2 ] ),
+					4,
+					new Position( root, [ 2, 0 ] ),
+					1
+				);
+
+				batch.addDelta( splitDelta );
+
+				splitDelta.addOperation( insertOperation );
+				splitDelta.addOperation( moveOperation );
+
+				model.applyOperation( insertOperation );
+				model.applyOperation( moveOperation );
+
+				const range = selection.getFirstRange();
+
+				expect( range.start.path ).to.deep.equal( [ 0, 2 ] );
+				expect( range.end.path ).to.deep.equal( [ 2, 2 ] );
+				expect( spyRange.calledOnce ).to.be.true;
+			} );
+		} );
+
+		describe( 'AttributeOperation', () => {
+			it( 'changed range includes selection anchor', () => {
+				const spyAttribute = sinon.spy();
+				selection.on( 'change:attribute', spyAttribute );
+
+				selection.on( 'change:attribute', ( evt, data ) => {
+					expect( data.directChange ).to.be.false;
+					expect( data.attributeKeys ).to.deep.equal( [ 'foo' ] );
+				} );
+
+				model.applyOperation( wrapInDelta(
+					new AttributeOperation(
+						new Range( new Position( root, [ 0, 1 ] ), new Position( root, [ 0, 5 ] ) ),
+						'foo',
+						null,
+						'bar',
+						doc.version
+					)
+				) );
+
+				expect( selection.getAttribute( 'foo' ) ).to.equal( 'bar' );
+				expect( spyAttribute.calledOnce ).to.be.true;
+			} );
+
+			it( 'should not overwrite previously set attributes', () => {
+				selection._setAttribute( 'foo', 'xyz' );
+
+				const spyAttribute = sinon.spy();
+				selection.on( 'change:attribute', spyAttribute );
+
+				model.applyOperation( wrapInDelta(
+					new AttributeOperation(
+						new Range( new Position( root, [ 0, 1 ] ), new Position( root, [ 0, 5 ] ) ),
+						'foo',
+						null,
+						'bar',
+						doc.version
+					)
+				) );
+
+				expect( selection.getAttribute( 'foo' ) ).to.equal( 'xyz' );
+				expect( spyAttribute.called ).to.be.false;
+			} );
+
+			it( 'should not overwrite previously set attributes with same values', () => {
+				selection._setAttribute( 'foo', 'xyz' );
+
+				const spyAttribute = sinon.spy();
+				selection.on( 'change:attribute', spyAttribute );
+
+				model.applyOperation( wrapInDelta(
+					new AttributeOperation(
+						new Range( new Position( root, [ 0, 1 ] ), new Position( root, [ 0, 5 ] ) ),
+						'foo',
+						null,
+						'xyz',
+						doc.version
+					)
+				) );
+
+				expect( selection.getAttribute( 'foo' ) ).to.equal( 'xyz' );
+				expect( spyAttribute.called ).to.be.false;
+			} );
+
+			it( 'should not overwrite previously removed attributes', () => {
+				selection._setAttribute( 'foo', 'xyz' );
+				selection._removeAttribute( 'foo' );
+
+				const spyAttribute = sinon.spy();
+				selection.on( 'change:attribute', spyAttribute );
+
+				model.applyOperation( wrapInDelta(
+					new AttributeOperation(
+						new Range( new Position( root, [ 0, 1 ] ), new Position( root, [ 0, 5 ] ) ),
+						'foo',
+						null,
+						'bar',
+						doc.version
+					)
+				) );
+
+				expect( selection.hasAttribute( 'foo' ) ).to.be.false;
+				expect( spyAttribute.called ).to.be.false;
+			} );
+		} );
+
+		describe( 'RemoveOperation', () => {
+			it( 'fix selection range if it ends up in graveyard #1', () => {
+				selection._setTo( new Position( root, [ 1, 3 ] ) );
+
+				model.applyOperation( wrapInDelta(
+					new RemoveOperation(
+						new Position( root, [ 1, 2 ] ),
+						2,
+						new Position( doc.graveyard, [ 0 ] ),
+						doc.version
+					)
+				) );
+
+				expect( selection.getFirstPosition().path ).to.deep.equal( [ 1, 2 ] );
+			} );
+
+			it( 'fix selection range if it ends up in graveyard #2', () => {
+				selection._setTo( [ new Range( new Position( root, [ 1, 2 ] ), new Position( root, [ 1, 4 ] ) ) ] );
+
+				model.applyOperation( wrapInDelta(
+					new RemoveOperation(
+						new Position( root, [ 1, 2 ] ),
+						2,
+						new Position( doc.graveyard, [ 0 ] ),
+						doc.version
+					)
+				) );
+
+				expect( selection.getFirstPosition().path ).to.deep.equal( [ 1, 2 ] );
+			} );
+
+			it( 'fix selection range if it ends up in graveyard #3', () => {
+				selection._setTo( [ new Range( new Position( root, [ 1, 1 ] ), new Position( root, [ 1, 2 ] ) ) ] );
+
+				model.applyOperation( wrapInDelta(
+					new RemoveOperation(
+						new Position( root, [ 1 ] ),
+						2,
+						new Position( doc.graveyard, [ 0 ] ),
+						doc.version
+					)
+				) );
+
+				expect( selection.getFirstPosition().path ).to.deep.equal( [ 0, 6 ] );
+			} );
+
+			it( 'fix selection range if it ends up in graveyard #4 - whole content removed', () => {
+				model.applyOperation( wrapInDelta(
+					new RemoveOperation(
+						new Position( root, [ 0 ] ),
+						3,
+						new Position( doc.graveyard, [ 0 ] ),
+						doc.version
+					)
+				) );
+
+				expect( selection.getFirstPosition().path ).to.deep.equal( [ 0 ] );
+
+				model.applyOperation( wrapInDelta(
+					new InsertOperation(
+						new Position( root, [ 0 ] ),
+						new Element( 'p' ),
+						doc.version
+					)
+				) );
+
+				// Now it's clear that it's the default range.
+				expect( selection.getFirstPosition().path ).to.deep.equal( [ 0, 0 ] );
+			} );
+		} );
+
+		it( '`DocumentSelection#change:range` event should be fire once even if selection contains multi-ranges', () => {
+			root.removeChildren( 0, root.childCount );
+			root.insertChildren( 0, [
+				new Element( 'p', [], new Text( 'abcdef' ) ),
+				new Element( 'p', [], new Text( 'foobar' ) ),
+				new Text( 'xyz #2' )
+			] );
+
+			selection._setTo( [
+				Range.createIn( root.getNodeByPath( [ 0 ] ) ),
+				Range.createIn( root.getNodeByPath( [ 1 ] ) )
+			] );
+
+			spyRange = sinon.spy();
+			selection.on( 'change:range', spyRange );
+
+			model.applyOperation( wrapInDelta(
+				new InsertOperation(
+					new Position( root, [ 0 ] ),
+					'xyz #1',
+					doc.version
+				)
+			) );
+
+			expect( spyRange.calledOnce ).to.be.true;
+		} );
+	} );
+
 	it( 'should throw if one of ranges starts or ends inside surrogate pair', () => {
 		root.removeChildren( 0, root.childCount );
 		root.appendChildren( '\uD83D\uDCA9' );

+ 95 - 0
packages/ckeditor5-engine/tests/model/writer.js

@@ -2347,6 +2347,89 @@ describe( 'Writer', () => {
 		} );
 	} );
 
+	describe( 'overrideSelectionGravity()', () => {
+		it( 'should use DocumentSelection#_overrideGravity', () => {
+			const overrideGravitySpy = sinon.spy( DocumentSelection.prototype, '_overrideGravity' );
+
+			overrideSelectionGravity();
+
+			sinon.assert.calledOnce( overrideGravitySpy );
+			overrideGravitySpy.restore();
+		} );
+
+		it( 'should not get attributes from the node before the caret when gravity is overridden', () => {
+			const root = doc.createRoot();
+			root.appendChildren( [
+				new Text( 'foo', { foo: true } ),
+				new Text( 'bar', { foo: true, bar: true } ),
+				new Text( 'biz', { foo: true } )
+			] );
+
+			setSelection( new Position( root, [ 6 ] ) );
+
+			expect( Array.from( model.document.selection.getAttributeKeys() ) ).to.deep.equal( [ 'foo', 'bar' ] );
+
+			overrideSelectionGravity();
+
+			expect( Array.from( model.document.selection.getAttributeKeys() ) ).to.deep.equal( [ 'foo' ] );
+			expect( model.document.selection.isGravityOverridden ).to.true;
+
+			// Disable override by moving selection.
+			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.appendChildren( [ 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 restoreGravitySpy = sinon.spy( DocumentSelection.prototype, '_restoreGravity' );
+
+			restoreSelectionGravity();
+
+			sinon.assert.calledOnce( restoreGravitySpy );
+			restoreGravitySpy.restore();
+		} );
+
+		it( 'should restore overridden gravity to default', () => {
+			const root = doc.createRoot();
+			root.appendChildren( [
+				new Text( 'foo', { foo: true } ),
+				new Text( 'bar', { foo: true, bar: true } ),
+				new Text( 'biz', { foo: true } )
+			] );
+
+			setSelection( new Position( root, [ 6 ] ) );
+
+			overrideSelectionGravity();
+
+			expect( Array.from( model.document.selection.getAttributeKeys() ) ).to.deep.equal( [ 'foo' ] );
+
+			restoreSelectionGravity();
+
+			expect( Array.from( model.document.selection.getAttributeKeys() ) ).to.deep.equal( [ 'foo', 'bar' ] );
+		} );
+	} );
+
 	function createText( data, attributes ) {
 		return model.change( writer => {
 			return writer.createText( data, attributes );
@@ -2506,4 +2589,16 @@ describe( 'Writer', () => {
 			writer.removeSelectionAttribute( key );
 		} );
 	}
+
+	function overrideSelectionGravity( customRestore ) {
+		model.change( writer => {
+			writer.overrideSelectionGravity( customRestore );
+		} );
+	}
+
+	function restoreSelectionGravity() {
+		model.change( writer => {
+			writer.restoreSelectionGravity();
+		} );
+	}
 } );

+ 360 - 0
packages/ckeditor5-engine/tests/utils/bindtwostepcarettoattribute.js

@@ -0,0 +1,360 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global document */
+
+import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
+import DomEmitterMixin from '@ckeditor/ckeditor5-utils/src/dom/emittermixin';
+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;
+
+	beforeEach( () => {
+		emitter = Object.create( DomEmitterMixin );
+
+		return VirtualTestEditor.create().then( newEditor => {
+			editor = newEditor;
+			model = editor.model;
+			selection = model.document.selection;
+			viewDoc = editor.editing.view.document;
+			preventDefaultSpy = sinon.spy();
+
+			editor.model.schema.extend( '$text', {
+				allowAttributes: [ 'a', 'b', 'c' ],
+				allowIn: '$root'
+			} );
+
+			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' } ) );
+
+			bindTwoStepCaretToAttribute( editor.editing.view, editor.model, emitter, 'a' );
+		} );
+	} );
+
+	afterEach( () => {
+		return editor.destroy();
+	} );
+
+	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>' );
+
+			// 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;
+
+			// Press right key.
+			fireKeyDownEvent( {
+				keyCode: keyCodes.arrowright,
+				preventDefault: preventDefaultSpy
+			} );
+
+			// 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 );
+
+			// Press right key.
+			fireKeyDownEvent( {
+				keyCode: keyCodes.arrowright,
+				preventDefault: preventDefaultSpy
+			} );
+
+			// Caret movement was not blocked this time (still once) so everything works normally.
+			sinon.assert.calledOnce( preventDefaultSpy );
+		} );
+
+		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
+			} );
+
+			// 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 );
+
+			// Press right key.
+			fireKeyDownEvent( {
+				keyCode: keyCodes.arrowright,
+				preventDefault: preventDefaultSpy
+			} );
+
+			// Caret movement was not blocked this time (still once) so everything works normally.
+			sinon.assert.calledOnce( preventDefaultSpy );
+		} );
+
+		it( 'should do nothing for not bound attribute (at the beginning)', () => {
+			setData( model, '[]<$text c="true">foo</$text>' );
+
+			fireKeyDownEvent( {
+				keyCode: keyCodes.arrowright,
+				preventDefault: preventDefaultSpy
+			} );
+
+			sinon.assert.notCalled( preventDefaultSpy );
+			expect( selection.isGravityOverridden ).to.false;
+		} );
+
+		it( 'should do nothing for not bound attribute (at the end)', () => {
+			setData( model, '<$text c="true">foo[]</$text>' );
+
+			fireKeyDownEvent( {
+				keyCode: keyCodes.arrowright,
+				preventDefault: preventDefaultSpy
+			} );
+
+			sinon.assert.notCalled( preventDefaultSpy );
+			expect( selection.isGravityOverridden ).to.false;
+		} );
+
+		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>' );
+
+			// Gravity is not overridden.
+			expect( selection.isGravityOverridden ).to.false;
+
+			// Press right key.
+			fireKeyDownEvent( {
+				keyCode: keyCodes.arrowright,
+				preventDefault: preventDefaultSpy
+			} );
+
+			// Gravity is overridden, caret movement is blocked.
+			expect( selection.isGravityOverridden ).to.true;
+			sinon.assert.calledOnce( 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>' );
+
+			// 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;
+
+			// Press left key.
+			fireKeyDownEvent( {
+				keyCode: keyCodes.arrowleft,
+				preventDefault: preventDefaultSpy
+			} );
+
+			// Caret movement was not blocked.
+			sinon.assert.notCalled( preventDefaultSpy );
+
+			// 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 ] ) ) ) );
+
+			// Gravity is overridden.
+			expect( Array.from( selection.getAttributeKeys() ) ).to.have.members( [ 'c' ] );
+			expect( selection.isGravityOverridden ).to.true;
+
+			// Press left key.
+			fireKeyDownEvent( {
+				keyCode: keyCodes.arrowleft,
+				preventDefault: preventDefaultSpy
+			} );
+
+			// 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 );
+
+			// Press left key.
+			fireKeyDownEvent( {
+				keyCode: keyCodes.arrowleft,
+				preventDefault: preventDefaultSpy
+			} );
+
+			// Caret movement was not blocked this time (still once) so everything works normally.
+			sinon.assert.calledOnce( preventDefaultSpy );
+
+			// 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 ] ) ) ) );
+		} );
+
+		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, 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;
+
+			// Press left key.
+			fireKeyDownEvent( {
+				keyCode: keyCodes.arrowleft,
+				preventDefault: preventDefaultSpy
+			} );
+
+			// Caret movement was not blocked.
+			sinon.assert.notCalled( preventDefaultSpy );
+
+			// 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 ] ) ) ) );
+
+			// 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;
+
+			// Press left key.
+			fireKeyDownEvent( {
+				keyCode: keyCodes.arrowleft,
+				preventDefault: preventDefaultSpy
+			} );
+
+			// 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 );
+
+			// Press left key.
+			fireKeyDownEvent( {
+				keyCode: keyCodes.arrowleft,
+				preventDefault: preventDefaultSpy
+			} );
+
+			// Caret movement was not blocked this time (still once) so everything works normally.
+			sinon.assert.calledOnce( preventDefaultSpy );
+		} );
+
+		it( 'should do nothing for not bound attribute (at the beginning)', () => {
+			setData( model, '<$text c="true">[]foo</$text>' );
+
+			fireKeyDownEvent( {
+				keyCode: keyCodes.arrowright,
+				preventDefault: preventDefaultSpy
+			} );
+
+			sinon.assert.notCalled( preventDefaultSpy );
+			expect( selection.isGravityOverridden ).to.false;
+		} );
+
+		it( 'should do nothing for not bound attribute (at the end)', () => {
+			setData( model, '<$text c="true">foo</$text>[]' );
+
+			fireKeyDownEvent( {
+				keyCode: keyCodes.arrowright,
+				preventDefault: preventDefaultSpy
+			} );
+
+			sinon.assert.notCalled( preventDefaultSpy );
+			expect( selection.isGravityOverridden ).to.false;
+		} );
+
+		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();
+		} );
+
+		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
+			} );
+
+			// Gravity is overridden, caret movement was not blocked.
+			sinon.assert.notCalled( preventDefaultSpy );
+			expect( selection.isGravityOverridden ).to.true;
+		} );
+	} );
+
+	describe( 'mouse', () => {
+		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;
+		} );
+
+		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;
+		} );
+	} );
+
+	it( 'should do nothing when key other then arrow left and right is pressed', () => {
+		setData( model, '<$text a="true">foo[]</$text>' );
+
+		expect( () => {
+			fireKeyDownEvent( { keyCode: keyCodes.arrowup } );
+		} ).to.not.throw();
+	} );
+
+	it( 'should do nothing for non-collapsed selection', () => {
+		setData( model, '<$text c="true">fo[o]</$text><$text a="true" b="true">bar</$text>' );
+
+		fireKeyDownEvent( { keyCode: keyCodes.arrowright } );
+
+		expect( selection.isGravityOverridden ).to.false;
+	} );
+
+	it( 'should do nothing when shift key is pressed', () => {
+		setData( model, '<$text c="true">foo</$text><$text a="true" b="true">b[]ar</$text>' );
+
+		fireKeyDownEvent( {
+			keyCode: keyCodes.arrowleft,
+			shiftKey: true
+		} );
+
+		expect( selection.isGravityOverridden ).to.false;
+	} );
+
+	it( 'should do nothing when alt key is pressed', () => {
+		setData( model, '<$text c="true">foo</$text><$text a="true" b="true">b[]ar</$text>' );
+
+		fireKeyDownEvent( {
+			keyCode: keyCodes.arrowleft,
+			altKey: true
+		} );
+
+		expect( selection.isGravityOverridden ).to.false;
+	} );
+
+	it( 'should do nothing when ctrl key is pressed', () => {
+		setData( model, '<$text c="true">foo</$text><$text a="true" b="true">b[]ar</$text>' );
+
+		fireKeyDownEvent( {
+			keyCode: keyCodes.arrowleft,
+			ctrlKey: true
+		} );
+
+		expect( selection.isGravityOverridden ).to.false;
+	} );
+
+	function fireKeyDownEvent( options ) {
+		const eventData = Object.assign( { domTarget: document.body }, options );
+
+		viewDoc.fire( 'keydown', eventData );
+	}
+} );