ソースを参照

Merge pull request #666 from ckeditor/t/659

Rewrote filler management logic in the Rendrer.
Piotrek Koszuliński 9 年 前
コミット
376b1a04fa

+ 1 - 1
packages/ckeditor5-engine/src/model/position.js

@@ -104,7 +104,7 @@ export default class Position {
 	}
 
 	/**
-	 * Offset at which this position is located in it's {@link engine.model.Position#parent parent}. It is equal
+	 * Offset at which this position is located in its {@link engine.model.Position#parent parent}. It is equal
 	 * to the last item in position {@link engine.model.Position#path path}.
 	 *
 	 * @type {Number}

+ 2 - 1
packages/ckeditor5-engine/src/view/domconverter.js

@@ -486,7 +486,8 @@ export default class DomConverter {
 			} else {
 				const viewBefore = this.getCorrespondingView( domParent.childNodes[ domOffset - 1 ] );
 
-				if ( viewBefore ) {
+				// TODO #663
+				if ( viewBefore && viewBefore.parent ) {
 					return new ViewPosition( viewBefore.parent, viewBefore.index + 1 );
 				}
 			}

+ 119 - 46
packages/ckeditor5-engine/src/view/renderer.js

@@ -88,13 +88,12 @@ export default class Renderer {
 		this.selection = selection;
 
 		/**
-		 * Position of the inline {@link engine.view.filler filler}.
-		 * It should always be put before the text which contains filler.
+		 * The text node in which the inline filler was rendered.
 		 *
 		 * @private
-		 * @member {engine.view.Position} engine.view.Renderer#_inlineFillerPosition
+		 * @member {Text} engine.view.Renderer#_inlineFiller
 		 */
-		this._inlineFillerPosition = null;
+		this._inlineFiller = null;
 
 		/**
 		 * Indicates if view document is focused and selection can be rendered. Selection will not be rendered if
@@ -176,21 +175,30 @@ export default class Renderer {
 	 * removed as long selection is in the text node which needed it at first.
 	 */
 	render() {
-		if ( !this._isInlineFillerAtSelection() ) {
+		let inlineFillerPosition;
+
+		// There was inline filler rendered in the DOM but it's not
+		// at the selection position any more, so we can remove it
+		// (cause even if it's needed, it must be placed in another location).
+		if ( this._inlineFiller && !this._isSelectionInInlineFiller() ) {
 			this._removeInlineFiller();
+		}
 
-			if ( this._needAddInlineFiller() ) {
-				this._inlineFillerPosition = this.selection.getFirstPosition();
-				// Do not use `markToSync` so it will be added even if the parent is already added.
-				this.markedChildren.add( this._inlineFillerPosition.parent );
-			} else {
-				this._inlineFillerPosition = null;
-			}
+		// If we've got the filler, let's try to guess its position in the view.
+		if ( this._inlineFiller ) {
+			inlineFillerPosition = this._getInlineFillerPosition();
+		}
+		// Othewise, if it's needed, create it at the selection position.
+		else if ( this._needsInlineFillerAtSelection() ) {
+			inlineFillerPosition = this.selection.getFirstPosition();
+
+			// Do not use `markToSync` so it will be added even if the parent is already added.
+			this.markedChildren.add( inlineFillerPosition.parent );
 		}
 
 		for ( let node of this.markedTexts ) {
 			if ( !this.markedChildren.has( node.parent ) && this.domConverter.getCorrespondingDom( node.parent ) ) {
-				this._updateText( node );
+				this._updateText( node, { inlineFillerPosition } );
 			}
 		}
 
@@ -199,7 +207,7 @@ export default class Renderer {
 		}
 
 		for ( let element of this.markedChildren ) {
-			this._updateChildren( element );
+			this._updateChildren( element, { inlineFillerPosition } );
 		}
 
 		this._updateSelection();
@@ -208,63 +216,120 @@ export default class Renderer {
 		this.markedTexts.clear();
 		this.markedAttributes.clear();
 		this.markedChildren.clear();
+
+		// Remember the filler by its node.
+		this._inlineFiller = this._getInlineFillerNode( inlineFillerPosition );
+	}
+
+	/**
+	 * Gets the text node in which the inline filler is kept.
+	 *
+	 * @private
+	 * @param {engine.view.Position} fillerPosition The position on which the filler is needed in the view.
+	 * @returns {Text} The text node with the filler.
+	 */
+	_getInlineFillerNode( fillerPosition ) {
+		if ( !fillerPosition ) {
+			this._inlineFiller = null;
+
+			return;
+		}
+
+		const domPosition = this.domConverter.viewPositionToDom( fillerPosition );
+
+		/* istanbul ignore if */
+		if ( !domPosition || !startsWithFiller( domPosition.parent ) ) {
+			/**
+			 * Cannot find filler node by its position.
+			 *
+			 * @error view-renderer-cannot-find-filler
+			 */
+			throw new CKEditorError( 'view-renderer-cannot-find-filler: Cannot find filler node by its position.' );
+		}
+
+		return domPosition.parent;
+	}
+
+	/**
+	 * Gets the position of the inline filler based on the current selection.
+	 * Here, we assume that we know that the filler is needed and
+	 * {@link #_isSelectionInInlineFiller is at the selection position}, and, since it's needed,
+	 * it's somewhere at the selection postion.
+	 *
+	 * Note: we cannot restore the filler position based on the filler's DOM text node, because
+	 * when this method is called (before rendering) the bindings will often be broken. View to DOM
+	 * bindings are only dependable after rendering.
+	 *
+	 * @private
+	 * @returns {engine.view.Position}
+	 */
+	_getInlineFillerPosition() {
+		const firstPos = this.selection.getFirstPosition();
+
+		if ( firstPos.parent  instanceof ViewText ) {
+			return ViewPosition.createBefore( this.selection.getFirstPosition().parent );
+		} else {
+			return firstPos;
+		}
 	}
 
 	/**
-	 * Returns `true` if the inline filler and selection are in the same place.
-	 * If it is true it means filler had been added for a reason and selection does not
-	 * left text node, user can be in the middle of the composition so it should not be touched.
+	 * Returns `true` if the selection hasn't left the inline filler's text node.
+	 * If it is `true` it means that the filler had been added for a reason and the selection does not
+	 * left the filler's text node. E.g. the user can be in the middle of a composition so it should not be touched.
 	 *
 	 * @private
 	 * @returns {Boolean} True if the inline filler and selection are in the same place.
 	 */
-	_isInlineFillerAtSelection() {
+	_isSelectionInInlineFiller() {
 		if ( this.selection.rangeCount != 1 || !this.selection.isCollapsed ) {
 			return false;
 		}
 
+		// Note, we can't check if selection's position equals position of the
+		// this._inlineFiller node, because of #663. We may not be able to calculate
+		// the filler's position in the view at this stage.
+		// Instead, we check it the other way – whether selection is anchored in
+		// that text node or next to it.
+
+		// Possible options are:
+		// "FILLER{}"
+		// "FILLERadded-text{}"
+
 		const selectionPosition = this.selection.getFirstPosition();
-		const fillerPosition = this._inlineFillerPosition;
 
-		if ( !fillerPosition ) {
+		// If we cannot convert this position's parent it means that selection is in not yet rendered
+		// node, which means that the filler can't be there.
+		if ( !this.domConverter.getCorrespondingDom( selectionPosition.parent ) ) {
 			return false;
 		}
 
-		if ( fillerPosition.isEqual( selectionPosition )  ) {
-			return true;
-		}
+		const { parent: domParent } = this.domConverter.viewPositionToDom( selectionPosition );
 
-		if ( selectionPosition.parent instanceof ViewText ) {
-			if ( fillerPosition.isEqual( ViewPosition.createBefore( selectionPosition.parent ) ) ) {
-				return true;
-			}
+		if ( this.domConverter.isText( domParent ) && startsWithFiller( domParent ) ) {
+			return true;
 		}
 
 		return false;
 	}
 
 	/**
-	 * Removes inline filler.
+	 * Removes the inline filler.
 	 *
 	 * @private
 	 */
 	_removeInlineFiller() {
-		if ( !this._inlineFillerPosition ) {
-			// Nothing to remove.
-			return;
-		}
+		const domFillerNode = this._inlineFiller;
 
-		const domFillerPosition = this.domConverter.viewPositionToDom( this._inlineFillerPosition );
-		const domFillerNode = domFillerPosition.parent;
-
-		// If there is no filler viewPositionToDom will return parent node, so domFillerNode will be an element.
-		if ( !( this.domConverter.isText( domFillerNode ) ) || !startsWithFiller( domFillerNode ) ) {
+		// Something weird happened and the stored node doesn't contain the filler's text.
+		if ( !startsWithFiller( domFillerNode ) ) {
 			/**
-			 * No inline filler on expected position.
+			 * The inline filler node was lost. Most likely, something overwrote the filler text node
+			 * in the DOM.
 			 *
-			 * @error renderer-render-no-inline-filler.
+			 * @error view-renderer-filler-was-lost
 			 */
-			throw new CKEditorError( 'view-renderer-render-no-inline-filler: No inline filler on expected position.' );
+			throw new CKEditorError( 'view-renderer-filler-was-lost: The inline filler node was lost.' );
 		}
 
 		if ( isInlineFiller( domFillerNode ) ) {
@@ -272,15 +337,17 @@ export default class Renderer {
 		} else {
 			domFillerNode.data = domFillerNode.data.substr( INLINE_FILLER_LENGTH );
 		}
+
+		this._inlineFiller = null;
 	}
 
 	/**
-	 * Checks if the inline {@link engine.view.filler fillers} should be added.
+	 * Checks if the inline {@link engine.view.filler filler} should be added.
 	 *
 	 * @private
 	 * @returns {Boolean} True if the inline fillers should be added.
 	 */
-	_needAddInlineFiller() {
+	_needsInlineFillerAtSelection() {
 		if ( this.selection.rangeCount != 1 || !this.selection.isCollapsed ) {
 			return false;
 		}
@@ -318,15 +385,18 @@ export default class Renderer {
 	 *
 	 * @private
 	 * @param {engine.view.Text} viewText View text to update.
+	 * @param {Object} options
+	 * @param {engine.view.Position} options.inlineFillerPosition The position on which the inline
+	 * filler should be rendered.
 	 */
-	_updateText( viewText ) {
+	_updateText( viewText, options ) {
 		const domText = this.domConverter.getCorrespondingDom( viewText );
 		const newDomText = this.domConverter.viewToDom( viewText, domText.ownerDocument );
 
 		const actualText = domText.data;
 		let expectedText = newDomText.data;
 
-		const filler = this._inlineFillerPosition;
+		const filler = options.inlineFillerPosition;
 
 		if ( filler && filler.parent == viewText.parent && filler.offset == viewText.index ) {
 			expectedText = INLINE_FILLER + expectedText;
@@ -366,13 +436,16 @@ export default class Renderer {
 	 *
 	 * @private
 	 * @param {engine.view.Element} viewElement View element to update.
+	 * @param {Object} options
+	 * @param {engine.view.Position} options.inlineFillerPosition The position on which the inline
+	 * filler should be rendered.
 	 */
-	_updateChildren( viewElement ) {
+	_updateChildren( viewElement, options ) {
 		const domConverter = this.domConverter;
 		const domElement = domConverter.getCorrespondingDom( viewElement );
 		const domDocument = domElement.ownerDocument;
 
-		const filler = this._inlineFillerPosition;
+		const filler = options.inlineFillerPosition;
 
 		const actualDomChildren = domElement.childNodes;
 		const expectedDomChildren = Array.from( domConverter.viewChildrenToDom( viewElement, domDocument, { bind: true } ) );

+ 0 - 1
packages/ckeditor5-engine/tests/view/document/jumpoverinlinefiller.html

@@ -1 +0,0 @@
-<div contenteditable="true" id="editor"></div>

+ 61 - 32
packages/ckeditor5-engine/tests/view/document/jumpoverinlinefiller.js

@@ -3,7 +3,7 @@
  * For licensing, see LICENSE.md.
  */
 
-/* globals document */
+/* globals document, Range */
 /* bender-tags: view, browser-only */
 
 import ViewRange from '/ckeditor5/engine/view/range.js';
@@ -11,21 +11,34 @@ import ViewDocument from '/ckeditor5/engine/view/document.js';
 import { INLINE_FILLER_LENGTH, isInlineFiller, startsWithFiller } from '/ckeditor5/engine/view/filler.js';
 
 import { keyCodes } from '/ckeditor5/utils/keyboard.js';
+import createElement from '/ckeditor5/utils/dom/createelement.js';
 
 import { parse, setData } from '/ckeditor5/engine/dev-utils/view.js';
 
 describe( 'Document', () => {
-	let viewDocument;
+	let viewDocument, domRoot;
+
+	beforeEach( () => {
+		domRoot = createElement( document, 'div', {
+			contenteditable: 'true'
+		} );
+		document.body.appendChild( domRoot );
 
-	before( () => {
 		viewDocument = new ViewDocument();
-		viewDocument.createRoot( document.getElementById( 'editor' ) ) ;
+		viewDocument.createRoot( domRoot );
 
 		document.getSelection().removeAllRanges();
 
 		viewDocument.isFocused = true;
 	} );
 
+	afterEach( () => {
+		// There's no destroy() method yet, so let's at least kill the observers.
+		viewDocument.disableObservers();
+
+		domRoot.parentElement.removeChild( domRoot );
+	} );
+
 	describe( 'jump over inline filler hack', () => {
 		it( 'should jump over inline filler when left arrow is pressed after inline filler', () => {
 			setData( viewDocument, '<container:p>foo<attribute:b>[]</attribute:b>bar</container:p>' );
@@ -33,10 +46,11 @@ describe( 'Document', () => {
 
 			viewDocument.fire( 'keydown', { keyCode: keyCodes.arrowleft, domTarget: viewDocument.domRoots.get( 'main' ) } );
 
-			const domRange = document.getSelection().getRangeAt( 0 );
-			expect( isInlineFiller( domRange.startContainer ) ).to.be.true;
-			expect( domRange.startOffset ).to.equal( 0 );
-			expect( domRange.collapsed ).to.be.true;
+			const domSelection = document.getSelection();
+
+			expect( domSelection.anchorNode.data ).to.equal( 'foo' );
+			expect( domSelection.anchorOffset ).to.equal( 3 );
+			expect( domSelection.isCollapsed ).to.be.true;
 		} );
 
 		it( 'should do nothing when another key is pressed', () => {
@@ -45,10 +59,11 @@ describe( 'Document', () => {
 
 			viewDocument.fire( 'keydown', { keyCode: keyCodes.arrowright, domTarget: viewDocument.domRoots.get( 'main' ) } );
 
-			const domRange = document.getSelection().getRangeAt( 0 );
-			expect( isInlineFiller( domRange.startContainer ) ).to.be.true;
-			expect( domRange.startOffset ).to.equal( INLINE_FILLER_LENGTH );
-			expect( domRange.collapsed ).to.be.true;
+			const domSelection = document.getSelection();
+
+			expect( isInlineFiller( domSelection.anchorNode ) ).to.be.true;
+			expect( domSelection.anchorOffset ).to.equal( INLINE_FILLER_LENGTH );
+			expect( domSelection.isCollapsed ).to.be.true;
 		} );
 
 		it( 'should do nothing if range is not collapsed', () => {
@@ -57,43 +72,57 @@ describe( 'Document', () => {
 
 			viewDocument.fire( 'keydown', { keyCode: keyCodes.arrowleft, domTarget: viewDocument.domRoots.get( 'main' ) } );
 
-			const domRange = document.getSelection().getRangeAt( 0 );
-			expect( domRange.startContainer.data ).to.equal( 'x' );
-			expect( domRange.startOffset ).to.equal( 0 );
-			expect( domRange.endContainer.data ).to.equal( 'x' );
-			expect( domRange.endOffset ).to.equal( 1 );
+			const domSelection = document.getSelection();
+
+			expect( domSelection.anchorNode.data ).to.equal( 'x' );
+			expect( domSelection.anchorOffset ).to.equal( 0 );
+			expect( domSelection.focusNode.data ).to.equal( 'x' );
+			expect( domSelection.focusOffset ).to.equal( 1 );
 		} );
 
-		it( 'should do nothing if node does not start with filler', () => {
-			setData( viewDocument, '<container:p>foo<attribute:b>{}x</attribute:b>bar</container:p>' );
-			viewDocument.render();
+		// See #664
+		// it( 'should do nothing if node does not start with the filler', () => {
+		// 	setData( viewDocument, '<container:p>foo<attribute:b>{}x</attribute:b>bar</container:p>' );
+		// 	viewDocument.render();
 
-			viewDocument.fire( 'keydown', { keyCode: keyCodes.arrowleft, domTarget: viewDocument.domRoots.get( 'main' ) } );
+		// 	viewDocument.fire( 'keydown', { keyCode: keyCodes.arrowleft, domTarget: viewDocument.domRoots.get( 'main' ) } );
 
-			const domRange = document.getSelection().getRangeAt( 0 );
-			expect( domRange.startContainer.data ).to.equal( 'x' );
-			expect( domRange.startOffset ).to.equal( 0 );
-			expect( domRange.collapsed ).to.be.true;
-		} );
+		// 	const domSelection = document.getSelection();
+
+		// 	expect( domSelection.anchorNode.data ).to.equal( 'x' );
+		// 	expect( domSelection.anchorOffset ).to.equal( INLINE_FILLER_LENGTH );
+		// 	expect( domSelection.isCollapsed ).to.be.true;
+		// } );
 
-		it( 'should do nothing if caret is not directly before filler', () => {
+		it( 'should do nothing if caret is not directly before the filler', () => {
 			setData( viewDocument, '<container:p>foo<attribute:b>[]</attribute:b>bar</container:p>' );
 			viewDocument.render();
 
-			// '<container:p>foo<attribute:b>x{}</attribute:b>bar</container:p>'
+			// Insert a letter to the <b>: '<container:p>foo<attribute:b>x{}</attribute:b>bar</container:p>'
+			// Do this both in the view and in the DOM to simulate typing and to avoid rendering (which would remove the filler).
 			const viewB = viewDocument.selection.getFirstPosition().parent;
 			const viewTextX = parse( 'x' );
 			viewB.appendChildren( viewTextX );
 			viewDocument.selection.removeAllRanges();
 			viewDocument.selection.addRange( ViewRange.createFromParentsAndOffsets( viewTextX, 1, viewTextX, 1 ) );
+
+			const domB = viewDocument.getDomRoot( 'main' ).querySelector( 'b' );
+			const domSelection = document.getSelection();
+			domB.childNodes[ 0 ].data += 'x';
+
+			const domRange = new Range();
+			domSelection.removeAllRanges();
+			domRange.setStart( domB.childNodes[ 0 ], INLINE_FILLER_LENGTH + 1 );
+			domRange.collapse( true );
+			domSelection.addRange( domRange );
+
 			viewDocument.render();
 
 			viewDocument.fire( 'keydown', { keyCode: keyCodes.arrowleft, domTarget: viewDocument.domRoots.get( 'main' ) } );
 
-			const domRange = document.getSelection().getRangeAt( 0 );
-			expect( startsWithFiller( domRange.startContainer ) ).to.be.true;
-			expect( domRange.startOffset ).to.equal( INLINE_FILLER_LENGTH + 1 );
-			expect( domRange.collapsed ).to.be.true;
+			expect( startsWithFiller( domSelection.anchorNode ) ).to.be.true;
+			expect( domSelection.anchorOffset ).to.equal( INLINE_FILLER_LENGTH + 1 );
+			expect( domSelection.isCollapsed ).to.be.true;
 		} );
 	} );
 } );

+ 127 - 4
packages/ckeditor5-engine/tests/view/renderer.js

@@ -577,6 +577,112 @@ describe( 'Renderer', () => {
 			expect( domP.childNodes[ 1 ].childNodes[ 0 ].data ).to.equal( 'x' );
 		} );
 
+		// #659
+		// The element before the filler is removed so the position of the filler
+		// cannot be remembered as parent+offset.
+		it( 'should remove filler from a modified DOM in case <p>bar<b>foo</b>[]</p>', () => {
+			// Step 1: <p>bar<b>foo</b>"FILLER{}"</p>
+			const { view: viewP, selection: newSelection } = parse( '<container:p>bar<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( 3 );
+			expect( domP.childNodes[ 2 ].data ).to.equal( INLINE_FILLER );
+
+			// Step 2: Remove the <b> and update the selection (<p>bar[]</p>).
+			viewP.removeChildren( 1 );
+
+			selection.removeAllRanges();
+			selection.addRange( ViewRange.createFromParentsAndOffsets( viewP, 1, viewP, 1 ) );
+
+			renderer.markToSync( 'children', viewP );
+			renderer.render();
+
+			// Step 3: Check whether there's no filler in the DOM.
+			expect( domP.childNodes.length ).to.equal( 1 );
+			expect( domP.childNodes[ 0 ].data ).to.equal( 'bar' );
+		} );
+
+		// #659
+		it( 'should remove filler from a modified DOM when children moved', () => {
+			// Step 1: <p><b>foo</b>"FILLER{}"<b>bar</b></p><p></p>
+			const { view: viewFragment, selection: newSelection }
+				= parse( '<container:p><attribute:b>foo</attribute:b>[]<attribute:b>bar</attribute:b></container:p><container:p></container:p>' );
+			viewRoot.appendChildren( viewFragment );
+			selection.setTo( newSelection );
+
+			renderer.markToSync( 'children', viewRoot );
+			renderer.render();
+
+			expect( domRoot.childNodes.length ).to.equal( 2 );
+
+			const domP = domRoot.childNodes[ 0 ];
+			const domP2 = domRoot.childNodes[ 1 ];
+			expect( domP.childNodes.length ).to.equal( 3 );
+			expect( domP.childNodes[ 1 ].data ).to.equal( INLINE_FILLER );
+
+			// Step 2: Move <b>foo</b><b>bar</b> to the second paragraph and leave collapsed selection in the first one.
+			// <p>[]</p><p><b>foo</b><b>bar</b></p>
+			const viewP = viewRoot.getChild( 0 );
+			const viewP2 = viewRoot.getChild( 1 );
+			const removedChildren = viewP.removeChildren( 0, 2 );
+
+			viewP2.appendChildren( removedChildren );
+
+			selection.removeAllRanges();
+			selection.addRange( ViewRange.createFromParentsAndOffsets( viewP, 0, viewP, 0 ) );
+
+			renderer.markToSync( 'children', viewP );
+			renderer.markToSync( 'children', viewP2 );
+			renderer.render();
+
+			// Step 3: Check whether in the first paragraph there's a <br> filler and that
+			// in the second one there are two <b> tags.
+			expect( domP.childNodes.length ).to.equal( 1 );
+			expect( isBlockFiller( domP.childNodes[ 0 ], BR_FILLER ) ).to.be.true;
+
+			expect( domP2.childNodes.length ).to.equal( 2 );
+			expect( domP2.childNodes[ 0 ].tagName.toLowerCase() ).to.equal( 'b' );
+			expect( domP2.childNodes[ 1 ].tagName.toLowerCase() ).to.equal( 'b' );
+		} );
+
+		// Test for an edge case in the _isSelectionInInlineFiller which can be triggered like
+		// in one of ckeditor/ckeditor5-typing#59 automated tests.
+		it( 'should not break when selection is moved to a new element, when filler exists', () => {
+			// Step 1: <p>bar<b>"FILLER{}"</b></p>
+			const { view: viewP, selection: newSelection } = parse( '<container:p>bar<attribute:b>[]</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[ 1 ].childNodes[ 0 ].data ).to.equal( INLINE_FILLER );
+
+			// Step 2: Move selection to a new attribute element and remove the previous one
+			viewP.removeChildren( 1 ); // Remove <b>.
+
+			const viewI = parse( '<attribute:i></attribute:i>' );
+			viewP.appendChildren( viewI );
+
+			selection.removeAllRanges();
+			selection.addRange( ViewRange.createFromParentsAndOffsets( viewI, 0, viewI, 0 ) );
+
+			renderer.markToSync( 'children', viewP );
+			renderer.render();
+
+			// Step 3: Check whether new filler was created in the <i> element.
+			expect( domP.childNodes.length ).to.equal( 2 );
+			expect( domP.childNodes[ 1 ].tagName.toLowerCase() ).to.equal( 'i' );
+			expect( domP.childNodes[ 1 ].childNodes[ 0 ].data ).to.equal( INLINE_FILLER );
+		} );
+
 		it( 'should handle typing in empty block, do nothing if changes are already applied', () => {
 			const domSelection = document.getSelection();
 
@@ -699,6 +805,8 @@ describe( 'Renderer', () => {
 		it( 'should handle typing in empty attribute, do nothing if changes are already applied', () => {
 			const domSelection = document.getSelection();
 
+			// 1. Render <p><b>FILLER{}</b>foo</p>.
+
 			const { view: viewP, selection: newSelection } = parse(
 				'<container:p><attribute:b>[]</attribute:b>foo</container:p>' );
 
@@ -708,6 +816,8 @@ describe( 'Renderer', () => {
 			renderer.markToSync( 'children', viewRoot );
 			renderer.render();
 
+			// 2. Check the DOM.
+
 			const domP = domRoot.childNodes[ 0 ];
 
 			expect( domP.childNodes.length ).to.equal( 2 );
@@ -725,7 +835,8 @@ describe( 'Renderer', () => {
 			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>
+			// 3. Add text node to both the DOM and the view: <p><b>FILLERx</b>foo</p>.
+
 			domB.childNodes[ 0 ].data += 'x';
 
 			domSelection.removeAllRanges();
@@ -746,6 +857,8 @@ describe( 'Renderer', () => {
 		it( 'should handle typing in empty attribute as a children change, render if needed', () => {
 			const domSelection = document.getSelection();
 
+			// 1. Render <p><b>FILLER{}</b>foo</p>.
+
 			const { view: viewP, selection: newSelection } = parse(
 				'<container:p><attribute:b>[]</attribute:b>foo</container:p>' );
 
@@ -755,6 +868,8 @@ describe( 'Renderer', () => {
 			renderer.markToSync( 'children', viewRoot );
 			renderer.render();
 
+			// 2. Check the DOM.
+
 			const domP = domRoot.childNodes[ 0 ];
 
 			expect( domP.childNodes.length ).to.equal( 2 );
@@ -772,7 +887,8 @@ describe( 'Renderer', () => {
 			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>
+			// 3. Add text node only to the view: <p><b>x{}</b>foo</p>.
+
 			const viewText = new ViewText( 'x' );
 			viewB.appendChildren( viewText );
 			selection.removeAllRanges();
@@ -793,6 +909,8 @@ describe( 'Renderer', () => {
 		it( 'should handle typing in empty attribute as a text change, render if needed', () => {
 			const domSelection = document.getSelection();
 
+			// 1. Render <p><b>FILLER{}</b>foo</p>.
+
 			const { view: viewP, selection: newSelection } = parse(
 				'<container:p><attribute:b>[]</attribute:b>foo</container:p>' );
 
@@ -802,6 +920,8 @@ describe( 'Renderer', () => {
 			renderer.markToSync( 'children', viewRoot );
 			renderer.render();
 
+			// 2. Check the DOM.
+
 			const domP = domRoot.childNodes[ 0 ];
 
 			expect( domP.childNodes.length ).to.equal( 2 );
@@ -819,7 +939,8 @@ describe( 'Renderer', () => {
 			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>
+			// 3. Add text node only to the view: <p><b>x{}</b>foo</p>.
+
 			const viewText = new ViewText( 'x' );
 			viewB.appendChildren( viewText );
 			selection.removeAllRanges();
@@ -828,6 +949,8 @@ describe( 'Renderer', () => {
 			renderer.markToSync( 'text', viewText );
 			renderer.render();
 
+			// 4. Check the DOM.
+
 			expect( domB.childNodes.length ).to.equal( 1 );
 			expect( domB.childNodes[ 0 ].data ).to.equal( INLINE_FILLER + 'x' );
 
@@ -958,7 +1081,7 @@ describe( 'Renderer', () => {
 
 			expect( () => {
 				renderer.render();
-			} ).to.throw();
+			} ).to.throw( CKEditorError, /^view-renderer-filler-was-lost/ );
 		} );
 
 		it( 'should handle focusing element', () => {