Răsfoiți Sursa

Introduced DocumentSelection method for overriding default gravity behaviour.

Oskar Wróbel 8 ani în urmă
părinte
comite
f0823ca196

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

@@ -388,6 +388,14 @@ export default class DocumentSelection {
 		return this._selection._getStoredAttributes();
 	}
 
+	/**
+	 * Temporarily and partially disables default gravity behaviour that tries to get attributes from nodes surrounding the caret.
+	 * @see module:engine/model/writer~Writer#overrideGravity
+	 */
+	_overrideGravity() {
+		this._selection.overrideGravity();
+	}
+
 	/**
 	 * Generates and returns an attribute key for selection attributes store, basing on original attribute key.
 	 *
@@ -465,6 +473,13 @@ class LiveSelection extends Selection {
 		// @member {Array} module:engine/model/liveselection~LiveSelection#_hasChangedRange
 		this._hasChangedRange = false;
 
+		// When is set as `true` then selection attributes on node before the caret won't be taken
+		// into consideration while updating selection attributes.
+		//
+		// @private
+		// @type {Boolean}
+		this._isGravityOverriden = false;
+
 		// Add events that will ensure selection correctness.
 		this.on( 'change:range', () => {
 			for ( const range of this.getRanges() ) {
@@ -601,6 +616,19 @@ class LiveSelection extends Selection {
 		}
 	}
 
+	overrideGravity() {
+		this._isGravityOverriden = true;
+
+		this.on( 'change:range', ( evt, directChange ) => {
+			if ( directChange ) {
+				this._isGravityOverriden = false;
+				evt.off();
+			}
+		} );
+
+		this._updateAttributes();
+	}
+
 	// Removes all attributes from the selection and sets attributes according to the surrounding nodes.
 	_refreshAttributes() {
 		this._updateAttributes( true );
@@ -851,8 +879,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._isGravityOverriden ) {
+				// ...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 +891,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._isGravityOverriden && !attrs ) {
 				let node = nodeBefore;
 
 				while ( node && !attrs ) {

+ 581 - 525
packages/ckeditor5-engine/tests/model/documentselection.js

@@ -401,720 +401,776 @@ describe( 'DocumentSelection', () => {
 		} );
 	} );
 
-	// 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;
-				} );
-
-				model.applyOperation( wrapInDelta(
-					new InsertOperation(
-						new Position( root, [ 1, 0 ] ),
-						'xyz',
-						doc.version
-					)
-				) );
-
-				const range = selection.getFirstRange();
+		describe( '_removeAttribute()', () => {
+			it( 'should remove attribute set on the text fragment', () => {
+				selection._setTo( [ rangeInFullP ] );
+				selection._setAttribute( 'foo', 'bar' );
+				selection._removeAttribute( 'foo' );
 
-				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( selection.getAttribute( 'foo' ) ).to.be.undefined;
 			} );
 		} );
 
-		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
-					)
-				) );
+		describe( '_getStoredAttributes()', () => {
+			it( 'should return no values if there are no ranges in selection', () => {
+				const values = Array.from( selection._getStoredAttributes() );
 
-				const range = selection.getFirstRange();
+				expect( values ).to.deep.equal( [] );
+			} );
+		} );
 
-				expect( range.start.path ).to.deep.equal( [ 0, 0 ] );
-				expect( range.end.path ).to.deep.equal( [ 1, 4 ] );
-				expect( spyRange.calledOnce ).to.be.true;
+		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( 'moved into before a selection', () => {
-				selection.on( 'change:range', ( evt, data ) => {
-					expect( data.directChange ).to.be.false;
-				} );
+			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 ] ) ) ] );
 
-				model.applyOperation( wrapInDelta(
-					new MoveOperation(
-						new Position( root, [ 2 ] ),
-						2,
-						new Position( root, [ 0, 0 ] ),
-						doc.version
-					)
-				) );
+				expect( Array.from( selection.getAttributes() ) ).to.deep.equal( [ [ 'b', true ] ] );
 
-				const range = selection.getFirstRange();
+				// Step into elements when looking for first character:
+				selection._setTo( [ new Range( new Position( root, [ 5 ] ), new Position( root, [ 7 ] ) ) ] );
 
-				expect( range.start.path ).to.deep.equal( [ 0, 4 ] );
-				expect( range.end.path ).to.deep.equal( [ 1, 4 ] );
-				expect( spyRange.calledOnce ).to.be.true;
+				expect( Array.from( selection.getAttributes() ) ).to.deep.equal( [ [ 'd', true ] ] );
 			} );
 
-			it( 'move range from inside of 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, [ 1, 0 ] ),
-						2,
-						new Position( root, [ 2 ] ),
-						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, 2 ] );
-				expect( range.end.path ).to.deep.equal( [ 1, 2 ] );
-				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( 'moved range intersects with 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, 3 ] ),
-						2,
-						new Position( root, [ 4 ] ),
-						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( [ 5 ] );
-				expect( spyRange.calledOnce ).to.be.true;
-			} );
+				selection._setTo( new Position( root, [ 1 ] ) );
 
-			it( 'split inside selection (do not break selection)', () => {
-				selection.on( 'change:range', ( evt, data ) => {
-					expect( data.directChange ).to.be.false;
-				} );
+				expect( Array.from( selection.getAttributes() ) ).to.deep.equal( [ [ 'a', true ] ] );
+			} );
 
-				const batch = new Batch();
-				const splitDelta = new SplitDelta();
+			it( 'should fire change:attribute event', () => {
+				const spy = sinon.spy();
+				selection.on( 'change:attribute', spy );
 
-				const insertOperation = new InsertOperation(
-					new Position( root, [ 2 ] ),
-					new Element( 'p' ),
-					0
-				);
+				selection._setTo( [ new Range( new Position( root, [ 2 ] ), new Position( root, [ 5 ] ) ) ] );
 
-				const moveOperation = new MoveOperation(
-					new Position( root, [ 1, 2 ] ),
-					4,
-					new Position( root, [ 2, 0 ] ),
-					1
-				);
+				expect( spy.calledOnce ).to.be.true;
+			} );
 
-				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 );
+		// #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' } );
 
-				selection.on( 'change:attribute', ( evt, data ) => {
-					expect( data.directChange ).to.be.false;
-					expect( data.attributeKeys ).to.deep.equal( [ 'foo' ] );
+				model.schema.register( 'caption' );
+				model.schema.extend( 'caption', { allowIn: 'image' } );
+				model.schema.extend( '$text', {
+					allowIn: [ 'image', 'caption' ],
+					allowAttributes: 'bold'
 				} );
+			} );
 
-				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', () => {
+				setData( model, '<p>[<image><$text bold="true">Caption for the image.</$text></image>]</p>' );
 
-				expect( selection.getAttribute( 'foo' ) ).to.equal( 'bar' );
-				expect( spyAttribute.calledOnce ).to.be.true;
+				expect( selection.hasAttribute( 'bold' ) ).to.equal( false );
 			} );
 
-			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
-					)
-				) );
+			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.change( writer => {
+					writer.move( Range.createOn( fullP.getChild( 0 ) ), rangeInEmptyP.start );
+				} );
 
-			model.applyOperation( wrapInDelta(
-				new InsertOperation(
-					new Position( root, [ 0 ] ),
-					'xyz #1',
-					doc.version
-				)
-			) );
+				expect( emptyP.hasAttribute( fooStoreAttrKey ) ).to.be.false;
+			} );
 
-			expect( spyRange.calledOnce ).to.be.true;
-		} );
-	} );
+			it( 'are removed when containing element is merged with a non-empty element', () => {
+				const emptyP2 = new Element( 'p', null, 'x' );
+				root.appendChildren( emptyP2 );
 
-	describe( 'attributes', () => {
-		let fullP, emptyP, rangeInFullP, rangeInEmptyP;
+				emptyP.setAttribute( fooStoreAttrKey, 'bar' );
+				emptyP2.setAttribute( fooStoreAttrKey, 'bar' );
 
-		beforeEach( () => {
-			root.removeChildren( 0, root.childCount );
-			root.appendChildren( [
-				new Element( 'p', [], new Text( 'foobar' ) ),
-				new Element( 'p', [], [] )
-			] );
+				model.change( writer => {
+					// <emptyP>{}<emptyP2>
+					writer.merge( Position.createAfter( emptyP ) );
+				} );
 
-			fullP = root.getChild( 0 );
-			emptyP = root.getChild( 1 );
+				expect( emptyP.hasAttribute( fooStoreAttrKey ) ).to.be.false;
+				expect( emptyP.parent ).to.equal( root ); // Just to be sure we're checking the right element.
+			} );
 
-			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 ] ) );
+			it( 'are removed even when there is no selection in it', () => {
+				emptyP.setAttribute( fooStoreAttrKey, 'bar' );
 
-			// 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 );
-		} );
+				selection._setTo( [ rangeInFullP ] );
 
-		describe( '_setAttribute()', () => {
-			it( 'should set attribute', () => {
-				selection._setTo( [ rangeInEmptyP ] );
-				selection._setAttribute( 'foo', 'bar' );
+				model.change( writer => {
+					writer.insertText( 'x', rangeInEmptyP.start );
+				} );
 
-				expect( selection.getAttribute( 'foo' ) ).to.equal( 'bar' );
+				expect( emptyP.hasAttribute( fooStoreAttrKey ) ).to.be.false;
 			} );
-		} );
 
-		describe( '_removeAttribute()', () => {
-			it( 'should remove attribute set on the text fragment', () => {
-				selection._setTo( [ rangeInFullP ] );
-				selection._setAttribute( 'foo', 'bar' );
-				selection._removeAttribute( 'foo' );
+			it( 'are removed only once in case of multi-op deltas', () => {
+				let batch;
+				const emptyP2 = new Element( 'p', null, 'x' );
+				root.appendChildren( emptyP2 );
 
-				expect( selection.getAttribute( 'foo' ) ).to.be.undefined;
-			} );
-		} );
+				emptyP.setAttribute( fooStoreAttrKey, 'bar' );
+				emptyP2.setAttribute( fooStoreAttrKey, 'bar' );
 
-		describe( '_getStoredAttributes()', () => {
-			it( 'should return no values if there are no ranges in selection', () => {
-				const values = Array.from( selection._getStoredAttributes() );
+				model.change( writer => {
+					batch = writer.batch;
+					// <emptyP>{}<emptyP2>
+					writer.merge( Position.createAfter( emptyP ) );
+				} );
 
-				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 } )
-				] );
+				expect( emptyP.hasAttribute( fooStoreAttrKey ) ).to.be.false;
+				// Attribute delta is only one.
+				expect( Array.from( batch.deltas, delta => delta.type ) ).to.deep.equal( [ 'merge', 'attribute' ] );
 			} );
 
-			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 ] ) ) ] );
+			it( 'uses model change to clear attributes', () => {
+				selection._setTo( [ rangeInEmptyP ] );
 
-				expect( Array.from( selection.getAttributes() ) ).to.deep.equal( [ [ 'b', true ] ] );
+				model.change( writer => {
+					writer.setSelectionAttribute( 'foo', 'bar' );
+					writer.insertText( 'x', rangeInEmptyP.start );
 
-				// Step into elements when looking for first character:
-				selection._setTo( [ new Range( new Position( root, [ 5 ] ), new Position( root, [ 7 ] ) ) ] );
+					// `emptyP` still has the attribute, because attribute clearing is in enqueued block.
+					expect( emptyP.hasAttribute( fooStoreAttrKey ) ).to.be.true;
+				} );
 
-				expect( Array.from( selection.getAttributes() ) ).to.deep.equal( [ [ 'd', true ] ] );
+				// When the dust settles, `emptyP` should not have the attribute.
+				expect( emptyP.hasAttribute( fooStoreAttrKey ) ).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 ] ] );
+			it( 'are not removed or merged when containing element is merged with another empty element', () => {
+				const emptyP2 = new Element( 'p', null );
+				root.appendChildren( emptyP2 );
 
-				// 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 ] ] );
+				emptyP.setAttribute( fooStoreAttrKey, 'bar' );
+				emptyP2.setAttribute( abcStoreAttrKey, 'bar' );
 
-				// 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( emptyP.hasAttribute( fooStoreAttrKey ) ).to.be.true;
+				expect( emptyP.hasAttribute( abcStoreAttrKey ) ).to.be.false;
 
-				// 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 ] ] );
+				model.change( writer => {
+					// <emptyP>{}<emptyP2>
+					writer.merge( Position.createAfter( emptyP ) );
+				} );
 
-				// 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( [] );
+				expect( emptyP.getAttribute( fooStoreAttrKey ) ).to.equal( 'bar' );
+				expect( emptyP.parent ).to.equal( root ); // Just to be sure we're checking the right element.
 			} );
 
-			it( 'should overwrite any previously set attributes', () => {
-				selection._setTo( new Position( root, [ 5, 0 ] ) );
+			// Rename and some other deltas don't specify range in doc#change event.
+			// So let's see if there's no crash or something.
+			it( 'are not removed on rename', () => {
+				model.change( writer => {
+					writer.setSelection( rangeInEmptyP );
+					writer.setSelectionAttribute( 'foo', 'bar' );
+				} );
 
-				selection._setAttribute( 'x', true );
-				selection._setAttribute( 'y', true );
+				sinon.spy( model, 'enqueueChange' );
 
-				expect( Array.from( selection.getAttributes() ) ).to.deep.equal( [ [ 'd', true ], [ 'x', true ], [ 'y', true ] ] );
+				model.change( writer => {
+					writer.rename( emptyP, 'pnew' );
+				} );
 
-				selection._setTo( new Position( root, [ 1 ] ) );
+				expect( model.enqueueChange.called ).to.be.false;
+				expect( emptyP.getAttribute( fooStoreAttrKey ) ).to.equal( 'bar' );
+			} );
+		} );
+	} );
 
-				expect( Array.from( selection.getAttributes() ) ).to.deep.equal( [ [ 'a', true ] ] );
+	describe( '_overrideGravity()', () => {
+		beforeEach( () => {
+			model.schema.extend( '$text', {
+				allowIn: '$root'
 			} );
+		} );
 
-			it( 'should fire change:attribute event', () => {
-				const spy = sinon.spy();
-				selection.on( 'change:attribute', spy );
+		it( 'should not inherit attributes from node before the caret', () => {
+			setData( model, '<$text bold="true" italic="true">foo[]</$text>' );
 
-				selection._setTo( [ new Range( new Position( root, [ 2 ] ), new Position( root, [ 5 ] ) ) ] );
+			expect( Array.from( selection.getAttributeKeys() ) ).to.have.members( [ 'bold', 'italic' ] );
 
-				expect( spy.calledOnce ).to.be.true;
-			} );
+			selection._overrideGravity();
 
-			it( 'should not fire change:attribute event if attributes did not change', () => {
-				selection._setTo( new Position( root, [ 5, 0 ] ) );
+			expect( Array.from( selection.getAttributeKeys() ) ).to.length( 0 );
+		} );
 
-				expect( Array.from( selection.getAttributes() ) ).to.deep.equal( [ [ 'd', true ] ] );
+		it( 'should inherit attributes from node after the caret', () => {
+			setData( model, '<$text>foo[]</$text><$text bold="true" italic="true">bar</$text>' );
 
-				const spy = sinon.spy();
-				selection.on( 'change:attribute', spy );
+			expect( Array.from( selection.getAttributeKeys() ) ).to.length( 0 );
 
-				selection._setTo( new Position( root, [ 5, 1 ] ) );
+			selection._overrideGravity();
 
-				expect( Array.from( selection.getAttributes() ) ).to.deep.equal( [ [ 'd', true ] ] );
-				expect( spy.called ).to.be.false;
-			} );
+			expect( Array.from( selection.getAttributeKeys() ) ).to.have.members( [ 'bold', 'italic' ] );
 		} );
 
-		// #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( 'should retain attributes that are set explicit', () => {
+			setData( model, '<$text italic="true">foo[]</$text>' );
 
-			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>' );
+			selection._setAttribute( 'bold', true );
 
-				expect( selection.hasAttribute( 'bold' ) ).to.equal( false );
-			} );
+			expect( Array.from( selection.getAttributeKeys() ) ).to.have.members( [ 'bold', 'italic' ] );
 
-			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>' );
+			selection._overrideGravity();
 
-				expect( selection.hasAttribute( 'bold' ) ).to.equal( false );
-			} );
+			expect( Array.from( selection.getAttributeKeys() ) ).to.have.members( [ 'bold' ] );
+		} );
 
-			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>' );
+		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>' );
 
-				expect( selection.hasAttribute( 'bold' ) ).to.equal( false );
-			} );
+			selection._overrideGravity();
 
-			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>' );
+			// Changed range but not directly.
+			model.change( writer => writer.insertText( 'abc', new Position( root, [ 0 ] ) ) );
 
-				expect( selection.getAttribute( 'bold' ) ).to.equal( true );
-			} );
+			expect( Array.from( selection.getAttributeKeys() ) ).to.have.members( [ 'italic' ] );
 
-			it( 'reads attributes when the entire selection inside an object', () => {
-				setData( model, '<p><image><caption><$text bold="true">[bar]</$text></caption></image></p>' );
+			// Changed range directly.
+			model.change( writer => writer.setSelection( new Position( root, [ 5 ] ) ) );
 
-				expect( selection.getAttribute( 'bold' ) ).to.equal( true );
-			} );
+			expect( Array.from( selection.getAttributeKeys() ) ).to.have.members( [ 'bold', 'italic' ] );
+		} );
+	} );
 
-			it( 'stops reading attributes if selection starts with an object', () => {
-				setData( model, '<p>[<image></image><$text bold="true">bar]</$text></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;
 
-				expect( selection.hasAttribute( 'bold' ) ).to.equal( false );
-			} );
-		} );
+		beforeEach( () => {
+			root.removeChildren( 0, root.childCount );
+			root.insertChildren( 0, [
+				new Element( 'p', [], new Text( 'abcdef' ) ),
+				new Element( 'p', [], new Text( 'foobar' ) ),
+				new Text( 'xyz' )
+			] );
 
-		describe( 'parent element\'s attributes', () => {
-			it( 'are set using a normal batch', () => {
-				const batchTypes = [];
+			selection._setTo( new Range( new Position( root, [ 0, 2 ] ), new Position( root, [ 1, 4 ] ) ) );
 
-				model.on( 'applyOperation', ( event, args ) => {
-					const operation = args[ 0 ];
-					const batch = operation.delta.batch;
+			spyRange = sinon.spy();
+			selection.on( 'change:range', spyRange );
+		} );
 
-					batchTypes.push( batch.type );
+		describe( 'InsertOperation', () => {
+			it( 'before selection', () => {
+				selection.on( 'change:range', ( evt, data ) => {
+					expect( data.directChange ).to.be.false;
 				} );
 
-				selection._setTo( [ rangeInEmptyP ] );
+				model.applyOperation( wrapInDelta(
+					new InsertOperation(
+						new Position( root, [ 0, 1 ] ),
+						'xyz',
+						doc.version
+					)
+				) );
 
-				model.change( writer => {
-					writer.setSelectionAttribute( 'foo', 'bar' );
-				} );
+				const range = selection.getFirstRange();
 
-				expect( batchTypes ).to.deep.equal( [ 'default' ] );
-				expect( emptyP.getAttribute( fooStoreAttrKey ) ).to.equal( '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;
 			} );
 
-			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();
+			it( 'inside selection', () => {
+				selection.on( 'change:range', ( evt, data ) => {
+					expect( data.directChange ).to.be.false;
+				} );
 
-				selection._setTo( rangeInEmptyP );
-				selection._setAttribute( 'foo', 'bar' );
-				selection._setAttribute( 'abc', 'bar' );
+				model.applyOperation( wrapInDelta(
+					new InsertOperation(
+						new Position( root, [ 1, 0 ] ),
+						'xyz',
+						doc.version
+					)
+				) );
 
-				model.on( 'applyOperation', ( event, args ) => {
-					const operation = args[ 0 ];
-					const batch = operation.delta.batch;
+				const range = selection.getFirstRange();
 
-					batchTypes.set( batch, batch.type );
-				} );
+				expect( range.start.path ).to.deep.equal( [ 0, 2 ] );
+				expect( range.end.path ).to.deep.equal( [ 1, 7 ] );
+				expect( spyRange.calledOnce ).to.be.true;
+			} );
+		} );
 
-				model.change( writer => {
-					writer.insertText( 'x', rangeInEmptyP.start );
+		describe( 'MoveOperation', () => {
+			it( 'move range from before a selection', () => {
+				selection.on( 'change:range', ( evt, data ) => {
+					expect( data.directChange ).to.be.false;
 				} );
 
-				expect( emptyP.hasAttribute( fooStoreAttrKey ) ).to.be.false;
-				expect( emptyP.hasAttribute( abcStoreAttrKey ) ).to.be.false;
+				model.applyOperation( wrapInDelta(
+					new MoveOperation(
+						new Position( root, [ 0, 0 ] ),
+						2,
+						new Position( root, [ 2 ] ),
+						doc.version
+					)
+				) );
 
-				expect( Array.from( batchTypes.values() ) ).to.deep.equal( [ 'default' ] );
-			} );
+				const range = selection.getFirstRange();
 
-			it( 'are removed when any content is moved into', () => {
-				selection._setTo( rangeInEmptyP );
-				selection._setAttribute( 'foo', 'bar' );
+				expect( range.start.path ).to.deep.equal( [ 0, 0 ] );
+				expect( range.end.path ).to.deep.equal( [ 1, 4 ] );
+				expect( spyRange.calledOnce ).to.be.true;
+			} );
 
-				model.change( writer => {
-					writer.move( Range.createOn( fullP.getChild( 0 ) ), rangeInEmptyP.start );
+			it( 'moved into before a selection', () => {
+				selection.on( 'change:range', ( evt, data ) => {
+					expect( data.directChange ).to.be.false;
 				} );
 
-				expect( emptyP.hasAttribute( fooStoreAttrKey ) ).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( 'are removed when containing element is merged with a non-empty element', () => {
-				const emptyP2 = new Element( 'p', null, 'x' );
-				root.appendChildren( emptyP2 );
+			it( 'move range from inside of selection', () => {
+				selection.on( 'change:range', ( evt, data ) => {
+					expect( data.directChange ).to.be.false;
+				} );
 
-				emptyP.setAttribute( fooStoreAttrKey, 'bar' );
-				emptyP2.setAttribute( fooStoreAttrKey, 'bar' );
+				model.applyOperation( wrapInDelta(
+					new MoveOperation(
+						new Position( root, [ 1, 0 ] ),
+						2,
+						new Position( root, [ 2 ] ),
+						doc.version
+					)
+				) );
 
-				model.change( writer => {
-					// <emptyP>{}<emptyP2>
-					writer.merge( Position.createAfter( emptyP ) );
+				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;
 				} );
 
-				expect( emptyP.hasAttribute( fooStoreAttrKey ) ).to.be.false;
-				expect( emptyP.parent ).to.equal( root ); // Just to be sure we're checking the right element.
+				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( 'are removed even when there is no selection in it', () => {
-				emptyP.setAttribute( fooStoreAttrKey, 'bar' );
+			it( 'split inside selection (do not break selection)', () => {
+				selection.on( 'change:range', ( evt, data ) => {
+					expect( data.directChange ).to.be.false;
+				} );
 
-				selection._setTo( [ rangeInFullP ] );
+				const batch = new Batch();
+				const splitDelta = new SplitDelta();
 
-				model.change( writer => {
-					writer.insertText( 'x', rangeInEmptyP.start );
+				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' ] );
 				} );
 
-				expect( emptyP.hasAttribute( fooStoreAttrKey ) ).to.be.false;
+				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( 'are removed only once in case of multi-op deltas', () => {
-				let batch;
-				const emptyP2 = new Element( 'p', null, 'x' );
-				root.appendChildren( emptyP2 );
+			it( 'should not overwrite previously set attributes', () => {
+				selection._setAttribute( 'foo', 'xyz' );
 
-				emptyP.setAttribute( fooStoreAttrKey, 'bar' );
-				emptyP2.setAttribute( fooStoreAttrKey, 'bar' );
+				const spyAttribute = sinon.spy();
+				selection.on( 'change:attribute', spyAttribute );
 
-				model.change( writer => {
-					batch = writer.batch;
-					// <emptyP>{}<emptyP2>
-					writer.merge( Position.createAfter( emptyP ) );
-				} );
+				model.applyOperation( wrapInDelta(
+					new AttributeOperation(
+						new Range( new Position( root, [ 0, 1 ] ), new Position( root, [ 0, 5 ] ) ),
+						'foo',
+						null,
+						'bar',
+						doc.version
+					)
+				) );
 
-				expect( emptyP.hasAttribute( fooStoreAttrKey ) ).to.be.false;
-				// Attribute delta is only one.
-				expect( Array.from( batch.deltas, delta => delta.type ) ).to.deep.equal( [ 'merge', 'attribute' ] );
+				expect( selection.getAttribute( 'foo' ) ).to.equal( 'xyz' );
+				expect( spyAttribute.called ).to.be.false;
 			} );
 
-			it( 'uses model change to clear attributes', () => {
-				selection._setTo( [ rangeInEmptyP ] );
+			it( 'should not overwrite previously set attributes with same values', () => {
+				selection._setAttribute( 'foo', 'xyz' );
 
-				model.change( writer => {
-					writer.setSelectionAttribute( 'foo', 'bar' );
-					writer.insertText( 'x', rangeInEmptyP.start );
+				const spyAttribute = sinon.spy();
+				selection.on( 'change:attribute', spyAttribute );
 
-					// `emptyP` still has the attribute, because attribute clearing is in enqueued block.
-					expect( emptyP.hasAttribute( fooStoreAttrKey ) ).to.be.true;
-				} );
+				model.applyOperation( wrapInDelta(
+					new AttributeOperation(
+						new Range( new Position( root, [ 0, 1 ] ), new Position( root, [ 0, 5 ] ) ),
+						'foo',
+						null,
+						'xyz',
+						doc.version
+					)
+				) );
 
-				// When the dust settles, `emptyP` should not have the attribute.
-				expect( emptyP.hasAttribute( fooStoreAttrKey ) ).to.be.false;
+				expect( selection.getAttribute( 'foo' ) ).to.equal( 'xyz' );
+				expect( spyAttribute.called ).to.be.false;
 			} );
 
-			it( 'are not removed or merged when containing element is merged with another empty element', () => {
-				const emptyP2 = new Element( 'p', null );
-				root.appendChildren( emptyP2 );
+			it( 'should not overwrite previously removed attributes', () => {
+				selection._setAttribute( 'foo', 'xyz' );
+				selection._removeAttribute( 'foo' );
 
-				emptyP.setAttribute( fooStoreAttrKey, 'bar' );
-				emptyP2.setAttribute( abcStoreAttrKey, 'bar' );
+				const spyAttribute = sinon.spy();
+				selection.on( 'change:attribute', spyAttribute );
 
-				expect( emptyP.hasAttribute( fooStoreAttrKey ) ).to.be.true;
-				expect( emptyP.hasAttribute( abcStoreAttrKey ) ).to.be.false;
+				model.applyOperation( wrapInDelta(
+					new AttributeOperation(
+						new Range( new Position( root, [ 0, 1 ] ), new Position( root, [ 0, 5 ] ) ),
+						'foo',
+						null,
+						'bar',
+						doc.version
+					)
+				) );
 
-				model.change( writer => {
-					// <emptyP>{}<emptyP2>
-					writer.merge( Position.createAfter( emptyP ) );
-				} );
+				expect( selection.hasAttribute( 'foo' ) ).to.be.false;
+				expect( spyAttribute.called ).to.be.false;
+			} );
+		} );
 
-				expect( emptyP.getAttribute( fooStoreAttrKey ) ).to.equal( 'bar' );
-				expect( emptyP.parent ).to.equal( root ); // Just to be sure we're checking the right element.
+		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 ] );
 			} );
 
-			// Rename and some other deltas don't specify range in doc#change event.
-			// So let's see if there's no crash or something.
-			it( 'are not removed on rename', () => {
-				model.change( writer => {
-					writer.setSelection( rangeInEmptyP );
-					writer.setSelectionAttribute( 'foo', 'bar' );
-				} );
+			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 ] ) ) ] );
 
-				sinon.spy( model, 'enqueueChange' );
+				model.applyOperation( wrapInDelta(
+					new RemoveOperation(
+						new Position( root, [ 1, 2 ] ),
+						2,
+						new Position( doc.graveyard, [ 0 ] ),
+						doc.version
+					)
+				) );
 
-				model.change( writer => {
-					writer.rename( emptyP, 'pnew' );
-				} );
+				expect( selection.getFirstPosition().path ).to.deep.equal( [ 1, 2 ] );
+			} );
 
-				expect( model.enqueueChange.called ).to.be.false;
-				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 ] ) ) ] );
+
+				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;
 		} );
 	} );