8
0
Pārlūkot izejas kodu

Small fixed and big tests for renderer.

Piotr Jasiun 9 gadi atpakaļ
vecāks
revīzija
e66b9b9f47

+ 3 - 2
packages/ckeditor5-engine/src/treeview/renderer.js

@@ -203,7 +203,8 @@ export default class Renderer {
 		const domFillerPosition = this.domConverter.viewPositionToDom( this._inlineFillerPosition );
 		const domFillerNode = domFillerPosition.parent;
 
-		if ( !startsWithFiller( domFillerNode ) ) {
+		// If there is no filler viewPositionToDom will return parent node, so domFillerNode will be an element.
+		if ( !( domFillerNode instanceof Text ) || !startsWithFiller( domFillerNode ) ) {
 			/**
 			 * No inline filler on expected position.
 			 *
@@ -255,7 +256,7 @@ export default class Renderer {
 
 		const filler = this._inlineFillerPosition;
 
-		if ( filler && filler.parent == viewText.parent && filler.offset == viewText.offset ) {
+		if ( filler && filler.parent == viewText.parent && filler.offset == viewText.getIndex() ) {
 			expectedText = INLINE_FILLER + expectedText;
 		}
 

+ 636 - 79
packages/ckeditor5-engine/tests/treeview/renderer.js

@@ -9,10 +9,13 @@
 
 import ViewElement from '/ckeditor5/engine/treeview/element.js';
 import ViewText from '/ckeditor5/engine/treeview/text.js';
+import ViewRange from '/ckeditor5/engine/treeview/range.js';
 import Selection from '/ckeditor5/engine/treeview/selection.js';
 import DomConverter from '/ckeditor5/engine/treeview/domconverter.js';
 import Renderer from '/ckeditor5/engine/treeview/renderer.js';
 import CKEditorError from '/ckeditor5/utils/ckeditorerror.js';
+import { parse } from '/tests/engine/_utils/view.js';
+import { INLINE_FILLER, INLINE_FILLER_LENGTH, isBlockFiller, BR_FILLER } from '/ckeditor5/engine/treeview/filler.js';
 
 describe( 'Renderer', () => {
 	let selection, domConverter, renderer;
@@ -24,14 +27,14 @@ describe( 'Renderer', () => {
 	} );
 
 	describe( 'markToSync', () => {
-		let viewNode;
+		let viewRoot;
 
 		beforeEach( () => {
-			viewNode = new ViewElement( 'p' );
+			viewRoot = new ViewElement( 'p' );
 
-			const domNode = document.createElement( 'p' );
-			domConverter.bindElements( domNode, viewNode );
-			viewNode.appendChildren( new ViewText( 'foo' ) );
+			const domRoot = document.createElement( 'p' );
+			domConverter.bindElements( domRoot, viewRoot );
+			viewRoot.appendChildren( new ViewText( 'foo' ) );
 
 			renderer.markedTexts.clear();
 			renderer.markedAttributes.clear();
@@ -39,35 +42,35 @@ describe( 'Renderer', () => {
 		} );
 
 		it( 'should mark attributes which need update', () => {
-			viewNode.setAttribute( 'class', 'foo' );
+			viewRoot.setAttribute( 'class', 'foo' );
 
-			renderer.markToSync( 'ATTRIBUTES', viewNode );
+			renderer.markToSync( 'ATTRIBUTES', viewRoot );
 
-			expect( renderer.markedAttributes.has( viewNode ) ).to.be.true;
+			expect( renderer.markedAttributes.has( viewRoot ) ).to.be.true;
 		} );
 
 		it( 'should mark children which need update', () => {
-			viewNode.appendChildren( new ViewText( 'foo' ) );
+			viewRoot.appendChildren( new ViewText( 'foo' ) );
 
-			renderer.markToSync( 'CHILDREN', viewNode );
+			renderer.markToSync( 'CHILDREN', viewRoot );
 
-			expect( renderer.markedChildren.has( viewNode ) ).to.be.true;
+			expect( renderer.markedChildren.has( viewRoot ) ).to.be.true;
 		} );
 
 		it( 'should not mark children if element has no corresponding node', () => {
-			// Overwrite viewNode with node without coresponding DOM node.
-			viewNode = new ViewElement( 'p' );
+			// Overwrite viewRoot with node without coresponding DOM node.
+			viewRoot = new ViewElement( 'p' );
 
-			viewNode.appendChildren( new ViewText( 'foo' ) );
+			viewRoot.appendChildren( new ViewText( 'foo' ) );
 
-			renderer.markToSync( 'CHILDREN', viewNode );
+			renderer.markToSync( 'CHILDREN', viewRoot );
 
-			expect( renderer.markedTexts.has( viewNode ) ).to.be.false;
+			expect( renderer.markedTexts.has( viewRoot ) ).to.be.false;
 		} );
 
 		it( 'should mark text which need update', () => {
 			const viewText = new ViewText( 'foo' );
-			viewNode.appendChildren( viewText );
+			viewRoot.appendChildren( viewText );
 			viewText.data = 'bar';
 
 			renderer.markToSync( 'TEXT', viewText );
@@ -77,10 +80,10 @@ describe( 'Renderer', () => {
 
 		it( 'should not mark text if parent has no corresponding node', () => {
 			const viewText = new ViewText( 'foo' );
-			// Overwrite viewNode with node without coresponding DOM node.
-			viewNode = new ViewElement( 'p' );
+			// Overwrite viewRoot with node without coresponding DOM node.
+			viewRoot = new ViewElement( 'p' );
 
-			viewNode.appendChildren( viewText );
+			viewRoot.appendChildren( viewText );
 			viewText.data = 'bar';
 
 			renderer.markToSync( 'TEXT', viewText );
@@ -90,98 +93,101 @@ describe( 'Renderer', () => {
 
 		it( 'should throw if the type is unknown', () => {
 			expect( () => {
-				renderer.markToSync( 'UNKNOWN', viewNode );
+				renderer.markToSync( 'UNKNOWN', viewRoot );
 			} ).to.throw( CKEditorError, /^renderer-unknown-type/ );
 		} );
 	} );
 
 	describe( 'render', () => {
-		let viewNode, domNode;
+		let viewRoot, domRoot;
 
 		beforeEach( () => {
-			viewNode = new ViewElement( 'p' );
-			domNode = document.createElement( 'p' );
+			viewRoot = new ViewElement( 'div' );
+			domRoot = document.createElement( 'div' );
+			document.body.appendChild( domRoot );
 
-			domConverter.bindElements( domNode, viewNode );
+			domConverter.bindElements( domRoot, viewRoot );
 
 			renderer.markedTexts.clear();
 			renderer.markedAttributes.clear();
 			renderer.markedChildren.clear();
+
+			selection.removeAllRanges();
 		} );
 
 		it( 'should update attributes', () => {
-			viewNode.setAttribute( 'class', 'foo' );
+			viewRoot.setAttribute( 'class', 'foo' );
 
-			renderer.markToSync( 'ATTRIBUTES', viewNode );
+			renderer.markToSync( 'ATTRIBUTES', viewRoot );
 			renderer.render();
 
-			expect( domNode.getAttribute( 'class' ) ).to.equal( 'foo' );
+			expect( domRoot.getAttribute( 'class' ) ).to.equal( 'foo' );
 
 			expect( renderer.markedAttributes.size ).to.equal( 0 );
 		} );
 
 		it( 'should remove attributes', () => {
-			viewNode.setAttribute( 'class', 'foo' );
-			domNode.setAttribute( 'id', 'bar' );
-			domNode.setAttribute( 'class', 'bar' );
+			viewRoot.setAttribute( 'class', 'foo' );
+			domRoot.setAttribute( 'id', 'bar' );
+			domRoot.setAttribute( 'class', 'bar' );
 
-			renderer.markToSync( 'ATTRIBUTES', viewNode );
+			renderer.markToSync( 'ATTRIBUTES', viewRoot );
 			renderer.render();
 
-			expect( domNode.getAttribute( 'class' ) ).to.equal( 'foo' );
-			expect( domNode.getAttribute( 'id' ) ).to.be.not.ok;
+			expect( domRoot.getAttribute( 'class' ) ).to.equal( 'foo' );
+			expect( domRoot.getAttribute( 'id' ) ).to.be.not.ok;
 
 			expect( renderer.markedAttributes.size ).to.equal( 0 );
 		} );
 
 		it( 'should add children', () => {
-			viewNode.appendChildren( new ViewText( 'foo' ) );
+			viewRoot.appendChildren( new ViewText( 'foo' ) );
 
-			renderer.markToSync( 'CHILDREN', viewNode );
+			renderer.markToSync( 'CHILDREN', viewRoot );
 			renderer.render();
 
-			expect( domNode.childNodes.length ).to.equal( 1 );
-			expect( domNode.childNodes[ 0 ].data ).to.equal( 'foo' );
+			expect( domRoot.childNodes.length ).to.equal( 1 );
+			expect( domRoot.childNodes[ 0 ].data ).to.equal( 'foo' );
 
 			expect( renderer.markedChildren.size ).to.equal( 0 );
 		} );
 
 		it( 'should remove children', () => {
-			viewNode.appendChildren( new ViewText( 'foo' ) );
+			viewRoot.appendChildren( new ViewText( 'foo' ) );
 
-			renderer.markToSync( 'CHILDREN', viewNode );
+			renderer.markToSync( 'CHILDREN', viewRoot );
 			renderer.render();
 
-			expect( domNode.childNodes.length ).to.equal( 1 );
-			expect( domNode.childNodes[ 0 ].data ).to.equal( 'foo' );
+			expect( domRoot.childNodes.length ).to.equal( 1 );
+			expect( domRoot.childNodes[ 0 ].data ).to.equal( 'foo' );
 
-			viewNode.removeChildren( 0, 1 );
+			viewRoot.removeChildren( 0, 1 );
 
-			renderer.markToSync( 'CHILDREN', viewNode );
+			renderer.markToSync( 'CHILDREN', viewRoot );
 			renderer.render();
 
-			expect( domNode.childNodes.length ).to.equal( 0 );
+			expect( domRoot.childNodes.length ).to.equal( 0 );
 
 			expect( renderer.markedChildren.size ).to.equal( 0 );
 		} );
 
 		it( 'should update text', () => {
 			const viewText = new ViewText( 'foo' );
-			viewNode.appendChildren( viewText );
+			viewRoot.appendChildren( viewText );
 
-			renderer.markToSync( 'CHILDREN', viewNode );
+			renderer.markToSync( 'CHILDREN', viewRoot );
 			renderer.render();
 
-			expect( domNode.childNodes.length ).to.equal( 1 );
-			expect( domNode.childNodes[ 0 ].data ).to.equal( 'foo' );
+			expect( domRoot.childNodes.length ).to.equal( 1 );
+			expect( domRoot.childNodes[ 0 ].data ).to.equal( 'foo' );
 
 			viewText.data = 'bar';
 
 			renderer.markToSync( 'TEXT', viewText );
 			renderer.render();
 
-			expect( domNode.childNodes.length ).to.equal( 1 );
-			expect( domNode.childNodes[ 0 ].data ).to.equal( 'bar' );
+			expect( domRoot.childNodes.length ).to.equal( 1 );
+			expect( domRoot.childNodes[ 0 ].data ).to.equal( 'bar' );
 
 			expect( renderer.markedTexts.size ).to.equal( 0 );
 		} );
@@ -189,84 +195,635 @@ describe( 'Renderer', () => {
 		it( 'should not update text parent child list changed', () => {
 			const viewImg = new ViewElement( 'img' );
 			const viewText = new ViewText( 'foo' );
-			viewNode.appendChildren( [ viewImg, viewText ] );
+			viewRoot.appendChildren( [ viewImg, viewText ] );
 
-			renderer.markToSync( 'CHILDREN', viewNode );
+			renderer.markToSync( 'CHILDREN', viewRoot );
 			renderer.markToSync( 'TEXT', viewText );
 			renderer.render();
 
-			expect( domNode.childNodes.length ).to.equal( 2 );
-			expect( domNode.childNodes[ 0 ].tagName ).to.equal( 'IMG' );
-			expect( domNode.childNodes[ 1 ].data ).to.equal( 'foo' );
+			expect( domRoot.childNodes.length ).to.equal( 2 );
+			expect( domRoot.childNodes[ 0 ].tagName ).to.equal( 'IMG' );
+			expect( domRoot.childNodes[ 1 ].data ).to.equal( 'foo' );
 		} );
 
 		it( 'should not change text if it is the same during text rendering', () => {
 			const viewText = new ViewText( 'foo' );
-			viewNode.appendChildren( viewText );
+			viewRoot.appendChildren( viewText );
 
-			renderer.markToSync( 'CHILDREN', viewNode );
+			renderer.markToSync( 'CHILDREN', viewRoot );
 			renderer.render();
 
 			// This should not be changed during the render.
-			const domText = domNode.childNodes[ 0 ];
+			const domText = domRoot.childNodes[ 0 ];
 
 			renderer.markToSync( 'TEXT', viewText );
 			renderer.render();
 
-			expect( domNode.childNodes.length ).to.equal( 1 );
-			expect( domNode.childNodes[ 0 ] ).to.equal( domText );
+			expect( domRoot.childNodes.length ).to.equal( 1 );
+			expect( domRoot.childNodes[ 0 ] ).to.equal( domText );
 		} );
 
 		it( 'should not change text if it is the same during children rendering', () => {
 			const viewText = new ViewText( 'foo' );
-			viewNode.appendChildren( viewText );
+			viewRoot.appendChildren( viewText );
 
-			renderer.markToSync( 'CHILDREN', viewNode );
+			renderer.markToSync( 'CHILDREN', viewRoot );
 			renderer.render();
 
 			// This should not be changed during the render.
-			const domText = domNode.childNodes[ 0 ];
+			const domText = domRoot.childNodes[ 0 ];
 
-			renderer.markToSync( 'CHILDREN', viewNode );
+			renderer.markToSync( 'CHILDREN', viewRoot );
 			renderer.render();
 
-			expect( domNode.childNodes.length ).to.equal( 1 );
-			expect( domNode.childNodes[ 0 ] ).to.equal( domText );
+			expect( domRoot.childNodes.length ).to.equal( 1 );
+			expect( domRoot.childNodes[ 0 ] ).to.equal( domText );
 		} );
 
 		it( 'should not change element if it is the same', () => {
 			const viewImg = new ViewElement( 'img' );
-			viewNode.appendChildren( viewImg );
+			viewRoot.appendChildren( viewImg );
 
 			// This should not be changed during the render.
 			const domImg = document.createElement( 'img' );
-			domNode.appendChild( domImg );
+			domRoot.appendChild( domImg );
 
 			domConverter.bindElements( domImg, viewImg );
 
-			renderer.markToSync( 'CHILDREN', viewNode );
+			renderer.markToSync( 'CHILDREN', viewRoot );
 			renderer.render();
 
-			expect( domNode.childNodes.length ).to.equal( 1 );
-			expect( domNode.childNodes[ 0 ] ).to.equal( domImg );
+			expect( domRoot.childNodes.length ).to.equal( 1 );
+			expect( domRoot.childNodes[ 0 ] ).to.equal( domImg );
 		} );
 
 		it( 'should change element if it is different', () => {
 			const viewImg = new ViewElement( 'img' );
-			viewNode.appendChildren( viewImg );
+			viewRoot.appendChildren( viewImg );
 
-			renderer.markToSync( 'CHILDREN', viewNode );
+			renderer.markToSync( 'CHILDREN', viewRoot );
 			renderer.render();
 
 			const viewP = new ViewElement( 'p' );
-			viewNode.removeChildren( 0, 1 );
-			viewNode.appendChildren( viewP );
+			viewRoot.removeChildren( 0, 1 );
+			viewRoot.appendChildren( viewP );
+
+			renderer.markToSync( 'CHILDREN', viewRoot );
+			renderer.render();
+
+			expect( domRoot.childNodes.length ).to.equal( 1 );
+			expect( domRoot.childNodes[ 0 ].tagName ).to.equal( 'P' );
+		} );
+
+		it( 'should add and remove inline filler in case <p>foo<b>[]</b>bar</p>', () => {
+			const domSelection = document.getSelection();
+
+			// Step 1: <p>foo<b>"FILLER{}"</b></p>
+			const { view: viewP, selection: newSelection } = parse(
+				'<container:p>foo<attribute:b>[]</attribute:b>bar</container:p>' );
+
+			viewRoot.appendChildren( viewP );
+			selection.setTo( newSelection );
+
+			renderer.markToSync( 'CHILDREN', viewRoot );
+			renderer.render();
+
+			expect( domRoot.childNodes.length ).to.equal( 1 );
+			expect( domRoot.childNodes[ 0 ].tagName.toLowerCase() ).to.equal( 'p' );
+
+			const domP = domRoot.childNodes[ 0 ];
+
+			expect( domP.childNodes.length ).to.equal( 3 );
+			expect( domP.childNodes[ 0 ].data ).to.equal( 'foo' );
+			expect( domP.childNodes[ 2 ].data ).to.equal( 'bar' );
+			expect( domP.childNodes[ 1 ].tagName.toLowerCase() ).to.equal( 'b' );
+			expect( domP.childNodes[ 1 ].childNodes.length ).to.equal( 1 );
+			expect( domP.childNodes[ 1 ].childNodes[ 0 ].data ).to.equal( INLINE_FILLER );
+
+			expect( domSelection.rangeCount ).to.equal( 1 );
+			expect( domSelection.getRangeAt( 0 ).startContainer ).to.equal( domP.childNodes[ 1 ].childNodes[ 0 ] );
+			expect( domSelection.getRangeAt( 0 ).startOffset ).to.equal( INLINE_FILLER_LENGTH );
+			expect( domSelection.getRangeAt( 0 ).collapsed ).to.be.true;
+
+			// Step 2: No mutation on second render
+			renderer.markToSync( 'CHILDREN', viewRoot );
+			renderer.markToSync( 'CHILDREN', viewP );
+
+			renderAndExpectNoChanges( renderer, domRoot );
+
+			// Step 3: <p>foo{}<b></b></p>
+			selection.removeAllRanges();
+			selection.addRange( ViewRange.createFromParentsAndOffsets( viewP.getChild( 0 ), 3, viewP.getChild( 0 ), 3 ) );
+
+			renderer.render();
+
+			expect( domP.childNodes.length ).to.equal( 3 );
+			expect( domP.childNodes[ 0 ].data ).to.equal( 'foo' );
+			expect( domP.childNodes[ 2 ].data ).to.equal( 'bar' );
+			expect( domP.childNodes[ 1 ].tagName.toLowerCase() ).to.equal( 'b' );
+			expect( domP.childNodes[ 1 ].childNodes.length ).to.equal( 0 );
+
+			expect( domSelection.rangeCount ).to.equal( 1 );
+			expect( domSelection.getRangeAt( 0 ).startContainer ).to.equal( domP.childNodes[ 0 ] );
+			expect( domSelection.getRangeAt( 0 ).startOffset ).to.equal( 3 );
+			expect( domSelection.getRangeAt( 0 ).collapsed ).to.be.true;
+
+			// Step 4: No mutation on second render
+			renderer.markToSync( 'CHILDREN', viewRoot );
+			renderer.markToSync( 'CHILDREN', viewP );
+
+			renderAndExpectNoChanges( renderer, domRoot );
+		} );
+
+		it( 'should add and remove inline filler in case <p>[]<b>foo</b></p>', () => {
+			const domSelection = document.getSelection();
+
+			// Step 1: <p>"FILLER{}"<b>foo</b></p>
+			const { view: viewP, selection: newSelection } = parse(
+				'<container:p>[]<attribute:b>foo</attribute:b></container:p>' );
+
+			viewRoot.appendChildren( viewP );
+			selection.setTo( newSelection );
+
+			renderer.markToSync( 'CHILDREN', viewRoot );
+			renderer.render();
+
+			const domP = domRoot.childNodes[ 0 ];
+
+			expect( domP.childNodes.length ).to.equal( 2 );
+			expect( domP.childNodes[ 0 ].data ).to.equal( INLINE_FILLER );
+			expect( domP.childNodes[ 1 ].tagName.toLowerCase() ).to.equal( 'b' );
+			expect( domP.childNodes[ 1 ].childNodes.length ).to.equal( 1 );
+			expect( domP.childNodes[ 1 ].childNodes[ 0 ].data ).to.equal( 'foo' );
+
+			expect( domSelection.rangeCount ).to.equal( 1 );
+			expect( domSelection.getRangeAt( 0 ).startContainer ).to.equal( domP.childNodes[ 0 ] );
+			expect( domSelection.getRangeAt( 0 ).startOffset ).to.equal( INLINE_FILLER_LENGTH );
+			expect( domSelection.getRangeAt( 0 ).collapsed ).to.be.true;
+
+			// Step 2: No mutation on second render
+			renderer.markToSync( 'CHILDREN', viewP );
+			renderAndExpectNoChanges( renderer, domRoot );
+
+			// Step 3: <p><b>{}foo</b></p>
+			selection.removeAllRanges();
+			selection.addRange( ViewRange.createFromParentsAndOffsets(
+				viewP.getChild( 0 ).getChild( 0 ), 0, viewP.getChild( 0 ).getChild( 0 ), 0 ) );
+
+			renderer.render();
+
+			expect( domP.childNodes.length ).to.equal( 1 );
+			expect( domP.childNodes[ 0 ].tagName.toLowerCase() ).to.equal( 'b' );
+			expect( domP.childNodes[ 0 ].childNodes.length ).to.equal( 1 );
+			expect( domP.childNodes[ 0 ].childNodes[ 0 ].data ).to.equal( 'foo' );
+
+			expect( domSelection.rangeCount ).to.equal( 1 );
+			expect( domSelection.getRangeAt( 0 ).startContainer ).to.equal( domP.childNodes[ 0 ] .childNodes[ 0 ] );
+			expect( domSelection.getRangeAt( 0 ).startOffset ).to.equal( 0 );
+			expect( domSelection.getRangeAt( 0 ).collapsed ).to.be.true;
+
+			// Step 4: No mutation on second render
+			renderer.markToSync( 'CHILDREN', viewP );
+			renderAndExpectNoChanges( renderer, domRoot );
+		} );
+
+		it( 'should add and remove inline filler in case <p><b>foo</b>[]</p>', () => {
+			const domSelection = document.getSelection();
+
+			// Step 1: <p>"FILLER{}"<b>foo</b></p>
+			const { view: viewP, selection: newSelection } = parse(
+				'<container:p><attribute:b>foo</attribute:b>[]</container:p>' );
+
+			viewRoot.appendChildren( viewP );
+			selection.setTo( newSelection );
+
+			renderer.markToSync( 'CHILDREN', viewRoot );
+			renderer.render();
+
+			const domP = domRoot.childNodes[ 0 ];
 
-			renderer.markToSync( 'CHILDREN', viewNode );
+			expect( domP.childNodes.length ).to.equal( 2 );
+			expect( domP.childNodes[ 0 ].tagName.toLowerCase() ).to.equal( 'b' );
+			expect( domP.childNodes[ 0 ].childNodes.length ).to.equal( 1 );
+			expect( domP.childNodes[ 0 ].childNodes[ 0 ].data ).to.equal( 'foo' );
+			expect( domP.childNodes[ 1 ].data ).to.equal( INLINE_FILLER );
+
+			expect( domSelection.rangeCount ).to.equal( 1 );
+			expect( domSelection.getRangeAt( 0 ).startContainer ).to.equal( domP.childNodes[ 1 ] );
+			expect( domSelection.getRangeAt( 0 ).startOffset ).to.equal( INLINE_FILLER_LENGTH );
+			expect( domSelection.getRangeAt( 0 ).collapsed ).to.be.true;
+
+			// Step 2: No mutation on second render
+			renderer.markToSync( 'CHILDREN', viewP );
+			renderAndExpectNoChanges( renderer, domRoot );
+
+			// Step 3: <p><b>foo{}</b></p>
+			selection.removeAllRanges();
+			selection.addRange( ViewRange.createFromParentsAndOffsets(
+				viewP.getChild( 0 ).getChild( 0 ), 3, viewP.getChild( 0 ).getChild( 0 ), 3 ) );
+
+			renderer.render();
+
+			expect( domP.childNodes.length ).to.equal( 1 );
+			expect( domP.childNodes[ 0 ].tagName.toLowerCase() ).to.equal( 'b' );
+			expect( domP.childNodes[ 0 ].childNodes.length ).to.equal( 1 );
+			expect( domP.childNodes[ 0 ].childNodes[ 0 ].data ).to.equal( 'foo' );
+
+			expect( domSelection.rangeCount ).to.equal( 1 );
+			expect( domSelection.getRangeAt( 0 ).startContainer ).to.equal( domP.childNodes[ 0 ] .childNodes[ 0 ] );
+			expect( domSelection.getRangeAt( 0 ).startOffset ).to.equal( 3 );
+			expect( domSelection.getRangeAt( 0 ).collapsed ).to.be.true;
+
+			// Step 4: No mutation on second render
+			renderer.markToSync( 'CHILDREN', viewP );
+			renderAndExpectNoChanges( renderer, domRoot );
+		} );
+
+		it( 'should add and remove inline filler in case <p><b>foo</b>[]<b>bar</b></p>', () => {
+			const domSelection = document.getSelection();
+
+			const { view: viewP, selection: newSelection } = parse(
+				'<container:p><attribute:b>foo</attribute:b>[]<attribute:b>bar</attribute:b></container:p>' );
+
+			viewRoot.appendChildren( viewP );
+			selection.setTo( newSelection );
+
+			renderer.markToSync( 'CHILDREN', viewRoot );
+			renderer.render();
+
+			const domP = domRoot.childNodes[ 0 ];
+
+			expect( domP.childNodes.length ).to.equal( 3 );
+			expect( domP.childNodes[ 0 ].tagName.toLowerCase() ).to.equal( 'b' );
+			expect( domP.childNodes[ 0 ].childNodes.length ).to.equal( 1 );
+			expect( domP.childNodes[ 0 ].childNodes[ 0 ].data ).to.equal( 'foo' );
+			expect( domP.childNodes[ 1 ].data ).to.equal( INLINE_FILLER );
+			expect( domP.childNodes[ 2 ].tagName.toLowerCase() ).to.equal( 'b' );
+			expect( domP.childNodes[ 2 ].childNodes.length ).to.equal( 1 );
+			expect( domP.childNodes[ 2 ].childNodes[ 0 ].data ).to.equal( 'bar' );
+
+			expect( domSelection.rangeCount ).to.equal( 1 );
+			expect( domSelection.getRangeAt( 0 ).startContainer ).to.equal( domP.childNodes[ 1 ] );
+			expect( domSelection.getRangeAt( 0 ).startOffset ).to.equal( INLINE_FILLER_LENGTH );
+			expect( domSelection.getRangeAt( 0 ).collapsed ).to.be.true;
+		} );
+
+		it( 'should handle typing in empty block, do nothing if changes are already applied', () => {
+			const domSelection = document.getSelection();
+
+			const { view: viewP, selection: newSelection } = parse( '<container:p>[]</container:p>' );
+
+			viewRoot.appendChildren( viewP );
+			selection.setTo( newSelection );
+
+			renderer.markToSync( 'CHILDREN', viewRoot );
+			renderer.render();
+
+			const domP = domRoot.childNodes[ 0 ];
+
+			expect( domP.childNodes.length ).to.equal( 1 );
+			expect( isBlockFiller( domP.childNodes[ 0 ], BR_FILLER ) ).to.be.true;
+
+			expect( domSelection.rangeCount ).to.equal( 1 );
+			expect( domSelection.getRangeAt( 0 ).startContainer ).to.equal( domP );
+			expect( domSelection.getRangeAt( 0 ).startOffset ).to.equal( 0 );
+			expect( domSelection.getRangeAt( 0 ).collapsed ).to.be.true;
+
+			// Remove filler and add text node to both DOM and View <p>x{}</p>
+			domP.removeChild( domP.childNodes[ 0 ] );
+			domP.appendChild( document.createTextNode( 'x' ) );
+
+			domSelection.removeAllRanges();
+			const domRange = new Range();
+			domRange.setStart( domP.childNodes[ 0 ], 1 );
+			domRange.collapse( true );
+			domSelection.addRange( domRange );
+
+			const viewText = new ViewText( 'x' );
+			viewP.appendChildren( viewText );
+			selection.removeAllRanges();
+			selection.addRange( ViewRange.createFromParentsAndOffsets( viewText, 1, viewText, 1 ) );
+
+			renderer.markToSync( 'CHILDREN', viewP );
+			renderAndExpectNoChanges( renderer, domRoot );
+		} );
+
+		it( 'should handle typing in empty block, render if needed', () => {
+			const domSelection = document.getSelection();
+
+			const { view: viewP, selection: newSelection } = parse( '<container:p>[]</container:p>' );
+
+			viewRoot.appendChildren( viewP );
+			selection.setTo( newSelection );
+
+			renderer.markToSync( 'CHILDREN', viewRoot );
 			renderer.render();
 
-			expect( domNode.childNodes.length ).to.equal( 1 );
-			expect( domNode.childNodes[ 0 ].tagName ).to.equal( 'P' );
+			const domP = domRoot.childNodes[ 0 ];
+
+			expect( domP.childNodes.length ).to.equal( 1 );
+			expect( isBlockFiller( domP.childNodes[ 0 ], BR_FILLER ) ).to.be.true;
+
+			expect( domSelection.rangeCount ).to.equal( 1 );
+			expect( domSelection.getRangeAt( 0 ).startContainer ).to.equal( domP );
+			expect( domSelection.getRangeAt( 0 ).startOffset ).to.equal( 0 );
+			expect( domSelection.getRangeAt( 0 ).collapsed ).to.be.true;
+
+			// Add text node only in View <p>x{}</p>
+			const viewText = new ViewText( 'x' );
+			viewP.appendChildren( viewText );
+			selection.removeAllRanges();
+			selection.addRange( ViewRange.createFromParentsAndOffsets( viewText, 1, viewText, 1 ) );
+
+			renderer.markToSync( 'CHILDREN', viewP );
+			renderer.render();
+
+			expect( domP.childNodes.length ).to.equal( 1 );
+			expect( domP.childNodes[ 0 ].data ).to.equal( 'x' );
+
+			expect( domSelection.rangeCount ).to.equal( 1 );
+			expect( domSelection.getRangeAt( 0 ).startContainer ).to.equal( domP.childNodes[ 0 ] );
+			expect( domSelection.getRangeAt( 0 ).startOffset ).to.equal( 1 );
+			expect( domSelection.getRangeAt( 0 ).collapsed ).to.be.true;
+		} );
+
+		it( 'should handle removing last character', () => {
+			const domSelection = document.getSelection();
+
+			const { view: viewP, selection: newSelection } = parse( '<container:p>x{}</container:p>' );
+
+			viewRoot.appendChildren( viewP );
+			selection.setTo( newSelection );
+
+			renderer.markToSync( 'CHILDREN', viewRoot );
+			renderer.render();
+
+			const domP = domRoot.childNodes[ 0 ];
+
+			expect( domP.childNodes.length ).to.equal( 1 );
+			expect( domP.childNodes[ 0 ].data ).to.equal( 'x' );
+
+			expect( domSelection.rangeCount ).to.equal( 1 );
+			expect( domSelection.getRangeAt( 0 ).startContainer ).to.equal( domP.childNodes[ 0 ] );
+			expect( domSelection.getRangeAt( 0 ).startOffset ).to.equal( 1 );
+			expect( domSelection.getRangeAt( 0 ).collapsed ).to.be.true;
+
+			// Remove text and add filler to both DOM and View <p>{}</p>
+			domP.removeChild( domP.childNodes[ 0 ] );
+			domP.appendChild( BR_FILLER( document ) );
+
+			domSelection.removeAllRanges();
+			const domRange = new Range();
+			domRange.setStart( domP.childNodes[ 0 ], 0 );
+			domRange.collapse( true );
+			domSelection.addRange( domRange );
+
+			viewP.removeChildren( 0 );
+
+			selection.removeAllRanges();
+			selection.addRange( ViewRange.createFromParentsAndOffsets( viewP, 0, viewP, 0 ) );
+
+			renderer.markToSync( 'CHILDREN', viewP );
+			renderAndExpectNoChanges( renderer, domRoot );
+		} );
+
+		it( 'should handle typing in empty attribute, do nothing if changes are already applied', () => {
+			const domSelection = document.getSelection();
+
+			const { view: viewP, selection: newSelection } = parse(
+				'<container:p><attribute:b>[]</attribute:b>foo</container:p>' );
+
+			viewRoot.appendChildren( viewP );
+			selection.setTo( newSelection );
+
+			renderer.markToSync( 'CHILDREN', viewRoot );
+			renderer.render();
+
+			const domP = domRoot.childNodes[ 0 ];
+
+			expect( domP.childNodes.length ).to.equal( 2 );
+			expect( domP.childNodes[ 0 ].tagName.toLowerCase() ).to.equal( 'b' );
+			expect( domP.childNodes[ 1 ].data ).to.equal( 'foo' );
+
+			const domB = domP.childNodes[ 0 ];
+			const viewB = viewP.getChild( 0 );
+
+			expect( domB.childNodes.length ).to.equal( 1 );
+			expect( domB.childNodes[ 0 ].data ).to.equal( INLINE_FILLER );
+
+			expect( domSelection.rangeCount ).to.equal( 1 );
+			expect( domSelection.getRangeAt( 0 ).startContainer ).to.equal( domB.childNodes[ 0 ] );
+			expect( domSelection.getRangeAt( 0 ).startOffset ).to.equal( INLINE_FILLER_LENGTH );
+			expect( domSelection.getRangeAt( 0 ).collapsed ).to.be.true;
+
+			// Add text node to both DOM and View <p><b>x</b>foo</p>
+			domB.childNodes[ 0 ].data += 'x';
+
+			domSelection.removeAllRanges();
+			const domRange = new Range();
+			domRange.setStart( domB.childNodes[ 0 ], INLINE_FILLER_LENGTH + 1 );
+			domRange.collapse( true );
+			domSelection.addRange( domRange );
+
+			const viewText = new ViewText( 'x' );
+			viewB.appendChildren( viewText );
+			selection.removeAllRanges();
+			selection.addRange( ViewRange.createFromParentsAndOffsets( viewText, 1, viewText, 1 ) );
+
+			renderer.markToSync( 'CHILDREN', viewP );
+			renderAndExpectNoChanges( renderer, domRoot );
+		} );
+
+		it( 'should handle typing in empty attribute as a CHILDREN change, render if needed', () => {
+			const domSelection = document.getSelection();
+
+			const { view: viewP, selection: newSelection } = parse(
+				'<container:p><attribute:b>[]</attribute:b>foo</container:p>' );
+
+			viewRoot.appendChildren( viewP );
+			selection.setTo( newSelection );
+
+			renderer.markToSync( 'CHILDREN', viewRoot );
+			renderer.render();
+
+			const domP = domRoot.childNodes[ 0 ];
+
+			expect( domP.childNodes.length ).to.equal( 2 );
+			expect( domP.childNodes[ 0 ].tagName.toLowerCase() ).to.equal( 'b' );
+			expect( domP.childNodes[ 1 ].data ).to.equal( 'foo' );
+
+			const domB = domP.childNodes[ 0 ];
+			const viewB = viewP.getChild( 0 );
+
+			expect( domB.childNodes.length ).to.equal( 1 );
+			expect( domB.childNodes[ 0 ].data ).to.equal( INLINE_FILLER );
+
+			expect( domSelection.rangeCount ).to.equal( 1 );
+			expect( domSelection.getRangeAt( 0 ).startContainer ).to.equal( domB.childNodes[ 0 ] );
+			expect( domSelection.getRangeAt( 0 ).startOffset ).to.equal( INLINE_FILLER_LENGTH );
+			expect( domSelection.getRangeAt( 0 ).collapsed ).to.be.true;
+
+			// Add text node only to View <p><b>x</b>foo</p>
+			const viewText = new ViewText( 'x' );
+			viewB.appendChildren( viewText );
+			selection.removeAllRanges();
+			selection.addRange( ViewRange.createFromParentsAndOffsets( viewText, 1, viewText, 1 ) );
+
+			renderer.markToSync( 'CHILDREN', viewB );
+			renderer.render();
+
+			expect( domB.childNodes.length ).to.equal( 1 );
+			expect( domB.childNodes[ 0 ].data ).to.equal( INLINE_FILLER + 'x' );
+
+			expect( domSelection.rangeCount ).to.equal( 1 );
+			expect( domSelection.getRangeAt( 0 ).startContainer ).to.equal( domB.childNodes[ 0 ] );
+			expect( domSelection.getRangeAt( 0 ).startOffset ).to.equal( INLINE_FILLER_LENGTH + 1 );
+			expect( domSelection.getRangeAt( 0 ).collapsed ).to.be.true;
+		} );
+
+		it( 'should handle typing in empty attribute as a TEXT change, render if needed', () => {
+			const domSelection = document.getSelection();
+
+			const { view: viewP, selection: newSelection } = parse(
+				'<container:p><attribute:b>[]</attribute:b>foo</container:p>' );
+
+			viewRoot.appendChildren( viewP );
+			selection.setTo( newSelection );
+
+			renderer.markToSync( 'CHILDREN', viewRoot );
+			renderer.render();
+
+			const domP = domRoot.childNodes[ 0 ];
+
+			expect( domP.childNodes.length ).to.equal( 2 );
+			expect( domP.childNodes[ 0 ].tagName.toLowerCase() ).to.equal( 'b' );
+			expect( domP.childNodes[ 1 ].data ).to.equal( 'foo' );
+
+			const domB = domP.childNodes[ 0 ];
+			const viewB = viewP.getChild( 0 );
+
+			expect( domB.childNodes.length ).to.equal( 1 );
+			expect( domB.childNodes[ 0 ].data ).to.equal( INLINE_FILLER );
+
+			expect( domSelection.rangeCount ).to.equal( 1 );
+			expect( domSelection.getRangeAt( 0 ).startContainer ).to.equal( domB.childNodes[ 0 ] );
+			expect( domSelection.getRangeAt( 0 ).startOffset ).to.equal( INLINE_FILLER_LENGTH );
+			expect( domSelection.getRangeAt( 0 ).collapsed ).to.be.true;
+
+			// Add text node only to View <p><b>x</b>foo</p>
+			const viewText = new ViewText( 'x' );
+			viewB.appendChildren( viewText );
+			selection.removeAllRanges();
+			selection.addRange( ViewRange.createFromParentsAndOffsets( viewText, 1, viewText, 1 ) );
+
+			renderer.markToSync( 'TEXT', viewText );
+			renderer.render();
+
+			expect( domB.childNodes.length ).to.equal( 1 );
+			expect( domB.childNodes[ 0 ].data ).to.equal( INLINE_FILLER + 'x' );
+
+			expect( domSelection.rangeCount ).to.equal( 1 );
+			expect( domSelection.getRangeAt( 0 ).startContainer ).to.equal( domB.childNodes[ 0 ] );
+			expect( domSelection.getRangeAt( 0 ).startOffset ).to.equal( INLINE_FILLER_LENGTH + 1 );
+			expect( domSelection.getRangeAt( 0 ).collapsed ).to.be.true;
+		} );
+
+		it( 'should handle not collapsed range', () => {
+			const domSelection = document.getSelection();
+
+			const { view: viewP, selection: newSelection } = parse(
+				'<container:p>fo{o<attribute:b>b}ar</attribute:b></container:p>' );
+
+			viewRoot.appendChildren( viewP );
+			selection.setTo( newSelection );
+
+			renderer.markToSync( 'CHILDREN', viewRoot );
+			renderer.render();
+
+			const domP = domRoot.childNodes[ 0 ];
+
+			expect( domP.childNodes.length ).to.equal( 2 );
+			expect( domP.childNodes[ 0 ].data ).to.equal( 'foo' );
+			expect( domP.childNodes[ 1 ].tagName.toLowerCase() ).to.equal( 'b' );
+			expect( domP.childNodes[ 1 ].childNodes.length ).to.equal( 1 );
+			expect( domP.childNodes[ 1 ].childNodes[ 0 ].data ).to.equal( 'bar' );
+
+			expect( domSelection.rangeCount ).to.equal( 1 );
+			expect( domSelection.getRangeAt( 0 ).startContainer ).to.equal( domP.childNodes[ 0 ] );
+			expect( domSelection.getRangeAt( 0 ).startOffset ).to.equal( 2 );
+			expect( domSelection.getRangeAt( 0 ).endContainer ).to.equal( domP.childNodes[ 1 ].childNodes[ 0 ] );
+			expect( domSelection.getRangeAt( 0 ).endOffset ).to.equal( 1 );
+
+			renderer.markToSync( 'CHILDREN', viewP );
+			renderAndExpectNoChanges( renderer, domRoot );
+		} );
+
+		it( 'should not add inline filler after text node', () => {
+			const domSelection = document.getSelection();
+
+			const { view: viewP, selection: newSelection } = parse( '<container:p>foo[]</container:p>' );
+
+			viewRoot.appendChildren( viewP );
+			selection.setTo( newSelection );
+
+			renderer.markToSync( 'CHILDREN', viewRoot );
+			renderer.render();
+
+			const domP = domRoot.childNodes[ 0 ];
+
+			expect( domP.childNodes.length ).to.equal( 1 );
+			expect( domP.childNodes[ 0 ].data ).to.equal( 'foo' );
+
+			expect( domSelection.rangeCount ).to.equal( 1 );
+			expect( domSelection.getRangeAt( 0 ).startContainer ).to.equal( domP );
+			expect( domSelection.getRangeAt( 0 ).startOffset ).to.equal( 1 );
+			expect( domSelection.getRangeAt( 0 ).collapsed ).to.be.true;
+		} );
+
+		it( 'should throw if there is no filler in expected position', () => {
+			const { view: viewP, selection: newSelection } = parse(
+				'<container:p>foo<attribute:b>[]</attribute:b>bar</container:p>' );
+
+			viewRoot.appendChildren( viewP );
+			selection.setTo( newSelection );
+
+			renderer.markToSync( 'CHILDREN', viewRoot );
+			renderer.render();
+
+			const domB = domRoot.childNodes[ 0 ].childNodes[ 1 ];
+			const viewB = viewP.getChild( 1 );
+
+			expect( domB.childNodes[ 0 ].data ).to.equal( INLINE_FILLER );
+
+			// Remove filler.
+			domB.childNodes[ 0 ].data = '';
+
+			selection.removeAllRanges();
+			renderer.markToSync( 'CHILDREN', viewB );
+
+			expect( () => {
+				renderer.render();
+			} ).to.throw();
 		} );
 	} );
 } );
+
+function renderAndExpectNoChanges( renderer, domRoot ) {
+	const config = {
+		childList: true,
+		characterData: true,
+		characterDataOldValue: true,
+		subtree: true
+	};
+
+	const mutationObserver = new window.MutationObserver( () => {
+		throw 'There should be not mutations';
+	} );
+	mutationObserver.observe( domRoot, config );
+
+	renderer.render();
+
+	const records = mutationObserver.takeRecords();
+	mutationObserver.disconnect();
+	expect( records.length ).to.equal( 0 );
+}