瀏覽代碼

Merge pull request #1779 from ckeditor/t/404

Fix: Remove only real block fillers on DOM to view conversion. Closes #404.

BREAKING CHANGE: The behavior of block filler detection on DOM to view conversion was changed. Now, it specifically checks the parent of a text node to check whether it is a block. Which means that a list of block element names has to be used. If you use custom elements or use one of the HTML elements which CKEditor 5 does not recognize as a block element, see #404 and `DomConverter.blockElements` documentation.
Piotrek Koszuliński 6 年之前
父節點
當前提交
77ff050a35

+ 1 - 2
packages/ckeditor5-engine/src/dataprocessor/htmldataprocessor.js

@@ -11,7 +11,6 @@
 
 import BasicHtmlWriter from './basichtmlwriter';
 import DomConverter from '../view/domconverter';
-import { NBSP_FILLER } from '../view/filler';
 
 /**
  * The HTML data processor class.
@@ -38,7 +37,7 @@ export default class HtmlDataProcessor {
 		 * @private
 		 * @member {module:engine/view/domconverter~DomConverter}
 		 */
-		this._domConverter = new DomConverter( { blockFiller: NBSP_FILLER } );
+		this._domConverter = new DomConverter( { blockFillerMode: 'nbsp' } );
 
 		/**
 		 * A basic HTML writer instance used to convert DOM elements to an HTML string.

+ 1 - 2
packages/ckeditor5-engine/src/dataprocessor/xmldataprocessor.js

@@ -11,7 +11,6 @@
 
 import BasicHtmlWriter from './basichtmlwriter';
 import DomConverter from '../view/domconverter';
-import { NBSP_FILLER } from '../view/filler';
 
 /**
  * The XML data processor class.
@@ -54,7 +53,7 @@ export default class XmlDataProcessor {
 		 * @private
 		 * @member {module:engine/view/domconverter~DomConverter}
 		 */
-		this._domConverter = new DomConverter( { blockFiller: NBSP_FILLER } );
+		this._domConverter = new DomConverter( { blockFillerMode: 'nbsp' } );
 
 		/**
 		 * A basic HTML writer instance used to convert DOM elements to an XML string.

+ 80 - 23
packages/ckeditor5-engine/src/view/domconverter.js

@@ -16,7 +16,7 @@ import ViewRange from './range';
 import ViewSelection from './selection';
 import ViewDocumentFragment from './documentfragment';
 import ViewTreeWalker from './treewalker';
-import { BR_FILLER, INLINE_FILLER_LENGTH, isBlockFiller, isInlineFiller, startsWithFiller, getDataWithoutFiller } from './filler';
+import { BR_FILLER, getDataWithoutFiller, INLINE_FILLER_LENGTH, isInlineFiller, NBSP_FILLER, startsWithFiller } from './filler';
 
 import global from '@ckeditor/ckeditor5-utils/src/dom/global';
 import indexOf from '@ckeditor/ckeditor5-utils/src/dom/indexof';
@@ -25,6 +25,9 @@ import getCommonAncestor from '@ckeditor/ckeditor5-utils/src/dom/getcommonancest
 import isText from '@ckeditor/ckeditor5-utils/src/dom/istext';
 import { isElement } from 'lodash-es';
 
+// eslint-disable-next-line new-cap
+const BR_FILLER_REF = BR_FILLER( document );
+
 /**
  * DomConverter is a set of tools to do transformations between DOM nodes and view nodes. It also handles
  * {@link module:engine/view/domconverter~DomConverter#bindElements binding} these nodes.
@@ -42,30 +45,19 @@ export default class DomConverter {
 	 * Creates DOM converter.
 	 *
 	 * @param {Object} options Object with configuration options.
-	 * @param {Function} [options.blockFiller=module:engine/view/filler~BR_FILLER] Block filler creator.
+	 * @param {module:engine/view/filler~BlockFillerMode} [options.blockFillerMode='br'] The type of the block filler to use.
 	 */
 	constructor( options = {} ) {
-		// Using WeakMap prevent memory leaks: when the converter will be destroyed all referenced between View and DOM
-		// will be removed. Also because it is a *Weak*Map when both view and DOM elements will be removed referenced
-		// will be also removed, isn't it brilliant?
-		//
-		// Yes, PJ. It is.
-		//
-		// You guys so smart.
-		//
-		// I've been here. Seen stuff. Afraid of code now.
-
 		/**
-		 * Block {@link module:engine/view/filler filler} creator, which is used to create all block fillers during the
-		 * view to DOM conversion and to recognize block fillers during the DOM to view conversion.
+		 * The mode of a block filler used by DOM converter.
 		 *
 		 * @readonly
-		 * @member {Function} module:engine/view/domconverter~DomConverter#blockFiller
+		 * @member {'br'|'nbsp'} module:engine/view/domconverter~DomConverter#blockFillerMode
 		 */
-		this.blockFiller = options.blockFiller || BR_FILLER;
+		this.blockFillerMode = options.blockFillerMode || 'br';
 
 		/**
-		 * Tag names of DOM `Element`s which are considered pre-formatted elements.
+		 * Elements which are considered pre-formatted elements.
 		 *
 		 * @readonly
 		 * @member {Array.<String>} module:engine/view/domconverter~DomConverter#preElements
@@ -73,12 +65,27 @@ export default class DomConverter {
 		this.preElements = [ 'pre' ];
 
 		/**
-		 * Tag names of DOM `Element`s which are considered block elements.
+		 * Elements which are considered block elements (and hence should be filled with a
+		 * {@link ~isBlockFiller block filler}).
+		 *
+		 * Whether an element is considered a block element also affects handling of trailing whitespaces.
+		 *
+		 * You can extend this array if you introduce support for block elements which are not yet recognized here.
 		 *
 		 * @readonly
 		 * @member {Array.<String>} module:engine/view/domconverter~DomConverter#blockElements
 		 */
-		this.blockElements = [ 'p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6' ];
+		this.blockElements = [ 'p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'dd', 'dt', 'figcaption' ];
+
+		/**
+		 * Block {@link module:engine/view/filler filler} creator, which is used to create all block fillers during the
+		 * view to DOM conversion and to recognize block fillers during the DOM to view conversion.
+		 *
+		 * @readonly
+		 * @private
+		 * @member {Function} module:engine/view/domconverter~DomConverter#_blockFiller
+		 */
+		this._blockFiller = this.blockFillerMode == 'br' ? BR_FILLER : NBSP_FILLER;
 
 		/**
 		 * DOM to View mapping.
@@ -255,7 +262,7 @@ export default class DomConverter {
 
 		for ( const childView of viewElement.getChildren() ) {
 			if ( fillerPositionOffset === offset ) {
-				yield this.blockFiller( domDocument );
+				yield this._blockFiller( domDocument );
 			}
 
 			yield this.viewToDom( childView, domDocument, options );
@@ -264,7 +271,7 @@ export default class DomConverter {
 		}
 
 		if ( fillerPositionOffset === offset ) {
-			yield this.blockFiller( domDocument );
+			yield this._blockFiller( domDocument );
 		}
 	}
 
@@ -371,7 +378,7 @@ export default class DomConverter {
 	 * or `null` if DOM node is a {@link module:engine/view/filler filler} or the given node is an empty text node.
 	 */
 	domToView( domNode, options = {} ) {
-		if ( isBlockFiller( domNode, this.blockFiller ) ) {
+		if ( this.isBlockFiller( domNode, this.blockFillerMode ) ) {
 			return null;
 		}
 
@@ -529,7 +536,7 @@ export default class DomConverter {
 	 * @returns {module:engine/view/position~Position} viewPosition View position.
 	 */
 	domPositionToView( domParent, domOffset ) {
-		if ( isBlockFiller( domParent, this.blockFiller ) ) {
+		if ( this.isBlockFiller( domParent, this.blockFillerMode ) ) {
 			return this.domPositionToView( domParent.parentNode, indexOf( domParent ) );
 		}
 
@@ -787,6 +794,23 @@ export default class DomConverter {
 	}
 
 	/**
+	 * Checks if the node is an instance of the block filler for this DOM converter.
+	 *
+	 *		const converter = new DomConverter( { blockFillerMode: 'br' } );
+	 *
+	 *		converter.isBlockFiller( BR_FILLER( document ) ); // true
+	 *		converter.isBlockFiller( NBSP_FILLER( document ) ); // false
+	 *
+	 * **Note:**: For the `'nbsp'` mode the method also checks context of a node so it cannot be a detached node.
+	 *
+	 * @param {Node} domNode DOM node to check.
+	 * @returns {Boolean} True if a node is considered a block filler for given mode.
+	 */
+	isBlockFiller( domNode ) {
+		return this.blockFillerMode == 'br' ? domNode.isEqualNode( BR_FILLER_REF ) : isNbspBlockFiller( domNode, this.blockElements );
+	}
+
+	/**
 	 * Returns `true` if given selection is a backward selection, that is, if it's `focus` is before `anchor`.
 	 *
 	 * @param {Selection} DOM Selection instance to check.
@@ -1197,3 +1221,36 @@ function forEachDomNodeAncestor( node, callback ) {
 		node = node.parentNode;
 	}
 }
+
+// Checks if given node is a nbsp block filler.
+//
+// A &nbsp; is a block filler only if it is a single child of a block element.
+//
+// @param {Node} domNode DOM node.
+// @returns {Boolean}
+function isNbspBlockFiller( domNode, blockElements ) {
+	const isNBSP = isText( domNode ) && domNode.data == '\u00A0';
+
+	return isNBSP && hasBlockParent( domNode, blockElements ) && domNode.parentNode.childNodes.length === 1;
+}
+
+// Checks if domNode has block parent.
+//
+// @param {Node} domNode DOM node.
+// @returns {Boolean}
+function hasBlockParent( domNode, blockElements ) {
+	const parent = domNode.parentNode;
+
+	return parent && parent.tagName && blockElements.includes( parent.tagName.toLowerCase() );
+}
+
+/**
+ * Enum representing type of the block filler.
+ *
+ * Possible values:
+ *
+ * * `br` - for `<br>` block filler used in editing view,
+ * * `nbsp` - for `&nbsp;` block fillers used in the data.
+ *
+ * @typedef {String} module:engine/view/filler~BlockFillerMode
+ */

+ 18 - 40
packages/ckeditor5-engine/src/view/filler.js

@@ -3,8 +3,6 @@
  * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
 
-/* globals window */
-
 import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
 import isText from '@ckeditor/ckeditor5-utils/src/dom/istext';
 
@@ -37,6 +35,15 @@ import isText from '@ckeditor/ckeditor5-utils/src/dom/istext';
  */
 
 /**
+ * Non-breaking space filler creator. This is a function which creates `&nbsp;` text node.
+ * It defines how the filler is created.
+ *
+ * @see module:engine/view/filler~BR_FILLER
+ * @function
+ */
+export const NBSP_FILLER = domDocument => domDocument.createTextNode( '\u00A0' );
+
+/**
  * `<br>` filler creator. This is a function which creates `<br data-cke-filler="true">` element.
  * It defines how the filler is created.
  *
@@ -51,27 +58,22 @@ export const BR_FILLER = domDocument => {
 };
 
 /**
- * Non-breaking space filler creator. This is a function which creates `&nbsp;` text node.
- * It defines how the filler is created.
- *
- * @see module:engine/view/filler~BR_FILLER
- * @function
- */
-export const NBSP_FILLER = domDocument => domDocument.createTextNode( '\u00A0' );
-
-/**
  * Length of the {@link module:engine/view/filler~INLINE_FILLER INLINE_FILLER}.
  */
 export const INLINE_FILLER_LENGTH = 7;
 
 /**
- * Inline filler which is sequence of the zero width spaces.
+ * Inline filler which is a sequence of the zero width spaces.
  */
-export let INLINE_FILLER = '';
+export const INLINE_FILLER = ( () => {
+	let inlineFiller = '';
 
-for ( let i = 0; i < INLINE_FILLER_LENGTH; i++ ) {
-	INLINE_FILLER += '\u200b';
-}
+	for ( let i = 0; i < INLINE_FILLER_LENGTH; i++ ) {
+		inlineFiller += '\u200b';
+	}
+
+	return inlineFiller;
+} )(); // Usu IIF so the INLINE_FILLER appears as a constant in the docs.
 
 /**
  * Checks if the node is a text node which starts with the {@link module:engine/view/filler~INLINE_FILLER inline filler}.
@@ -119,30 +121,6 @@ export function getDataWithoutFiller( domText ) {
 	}
 }
 
-// Cache block fillers templates to improve performance.
-const templateBlockFillers = new WeakMap();
-
-/**
- * Checks if the node is an instance of the block filler of the given type.
- *
- *		const brFillerInstance = BR_FILLER( document );
- *		isBlockFiller( brFillerInstance, BR_FILLER ); // true
- *
- * @param {Node} domNode DOM node to check.
- * @param {Function} blockFiller Block filler creator.
- * @returns {Boolean} True if text node contains only {@link module:engine/view/filler~INLINE_FILLER inline filler}.
- */
-export function isBlockFiller( domNode, blockFiller ) {
-	let templateBlockFiller = templateBlockFillers.get( blockFiller );
-
-	if ( !templateBlockFiller ) {
-		templateBlockFiller = blockFiller( window.document );
-		templateBlockFillers.set( blockFiller, templateBlockFiller );
-	}
-
-	return domNode.isEqualNode( templateBlockFiller );
-}
-
 /**
  * Assign key observer which move cursor from the end of the inline filler to the beginning of it when
  * the left arrow is pressed, so the filler does not break navigation.

+ 6 - 6
packages/ckeditor5-engine/src/view/renderer.js

@@ -11,7 +11,7 @@
 
 import ViewText from './text';
 import ViewPosition from './position';
-import { INLINE_FILLER, INLINE_FILLER_LENGTH, startsWithFiller, isInlineFiller, isBlockFiller } from './filler';
+import { INLINE_FILLER, INLINE_FILLER_LENGTH, startsWithFiller, isInlineFiller } from './filler';
 
 import mix from '@ckeditor/ckeditor5-utils/src/mix';
 import diff from '@ckeditor/ckeditor5-utils/src/diff';
@@ -590,7 +590,7 @@ export default class Renderer {
 	_diffNodeLists( actualDomChildren, expectedDomChildren ) {
 		actualDomChildren = filterOutFakeSelectionContainer( actualDomChildren, this._fakeSelectionContainer );
 
-		return diff( actualDomChildren, expectedDomChildren, sameNodes.bind( null, this.domConverter.blockFiller ) );
+		return diff( actualDomChildren, expectedDomChildren, sameNodes.bind( null, this.domConverter ) );
 	}
 
 	/**
@@ -938,11 +938,11 @@ function areSimilar( node1, node2 ) {
 //		* Two block filler elements.
 //
 // @private
-// @param {Function} blockFiller Block filler creator function, see {@link module:engine/view/domconverter~DomConverter#blockFiller}.
+// @param {String} blockFillerMode Block filler mode, see {@link module:engine/view/domconverter~DomConverter#blockFillerMode}.
 // @param {Node} node1
 // @param {Node} node2
 // @returns {Boolean}
-function sameNodes( blockFiller, actualDomChild, expectedDomChild ) {
+function sameNodes( domConverter, actualDomChild, expectedDomChild ) {
 	// Elements.
 	if ( actualDomChild === expectedDomChild ) {
 		return true;
@@ -952,8 +952,8 @@ function sameNodes( blockFiller, actualDomChild, expectedDomChild ) {
 		return actualDomChild.data === expectedDomChild.data;
 	}
 	// Block fillers.
-	else if ( isBlockFiller( actualDomChild, blockFiller ) &&
-		isBlockFiller( expectedDomChild, blockFiller ) ) {
+	else if ( domConverter.isBlockFiller( actualDomChild ) &&
+		domConverter.isBlockFiller( expectedDomChild ) ) {
 		return true;
 	}
 

+ 26 - 27
packages/ckeditor5-engine/tests/dataprocessor/htmldataprocessor.js

@@ -59,33 +59,32 @@ describe( 'HtmlDataProcessor', () => {
 			} );
 		}
 
-		// Uncomment this test after fixing #404.
-		// describe( 'https://github.com/ckeditor/ckeditor5-clipboard/issues/2#issuecomment-310417731 + #404', () => {
-		// 	it( 'does not lose whitespaces in Chrome\'s paste-like content', () => {
-		// 		const fragment = dataProcessor.toView(
-		// 			'<meta charset=\'utf-8\'>' +
-		// 			'<span>This is the<span>\u00a0</span></span>' +
-		// 			'<a href="url">third developer preview</a>' +
-		// 			'<span><span>\u00a0</span>of<span>\u00a0</span></span>' +
-		// 			'<strong>CKEditor\u00a05</strong>' +
-		// 			'<span>.</span>'
-		// 		);
-
-		// 		expect( stringify( fragment ) ).to.equal(
-		// 			'<span>This is the<span>\u00a0</span></span>' +
-		// 			'<a href="url">third developer preview</a>' +
-		// 			'<span><span>\u00a0</span>of<span>\u00a0</span></span>' +
-		// 			'<strong>CKEditor\u00a05</strong>' +
-		// 			'<span>.</span>'
-		// 		);
-
-		// 		// Just to be sure... stringify() uses conversion and the browser extensively,
-		// 		// so it's not entirely safe.
-		// 		expect( fragment.getChild( 0 ).getChild( 1 ).getChild( 0 ).data ).to.equal( '\u00a0' );
-		// 		expect( fragment.getChild( 2 ).getChild( 0 ).getChild( 0 ).data ).to.equal( '\u00a0' );
-		// 		expect( fragment.getChild( 2 ).getChild( 2 ).getChild( 0 ).data ).to.equal( '\u00a0' );
-		// 	} );
-		// } );
+		describe( 'https://github.com/ckeditor/ckeditor5-clipboard/issues/2#issuecomment-310417731 + #404', () => {
+			it( 'does not lose whitespaces in Chrome\'s paste-like content', () => {
+				const fragment = dataProcessor.toView(
+					'<meta charset=\'utf-8\'>' +
+					'<span>This is the<span>\u00a0</span></span>' +
+					'<a href="url">third developer preview</a>' +
+					'<span><span>\u00a0</span>of<span>\u00a0</span></span>' +
+					'<strong>CKEditor\u00a05</strong>' +
+					'<span>.</span>'
+				);
+
+				expect( stringify( fragment ) ).to.equal(
+					'<span>This is the<span>\u00a0</span></span>' +
+					'<a href="url">third developer preview</a>' +
+					'<span><span>\u00a0</span>of<span>\u00a0</span></span>' +
+					'<strong>CKEditor\u00a05</strong>' +
+					'<span>.</span>'
+				);
+
+				// Just to be sure... stringify() uses conversion and the browser extensively,
+				// so it's not entirely safe.
+				expect( fragment.getChild( 0 ).getChild( 1 ).getChild( 0 ).data ).to.equal( '\u00a0' );
+				expect( fragment.getChild( 2 ).getChild( 0 ).getChild( 0 ).data ).to.equal( '\u00a0' );
+				expect( fragment.getChild( 2 ).getChild( 2 ).getChild( 0 ).data ).to.equal( '\u00a0' );
+			} );
+		} );
 	} );
 
 	describe( 'toData()', () => {

+ 6 - 4
packages/ckeditor5-engine/tests/view/domconverter/dom-to-view.js

@@ -9,7 +9,7 @@ import ViewElement from '../../../src/view/element';
 import ViewDocumentSelection from '../../../src/view/documentselection';
 import DomConverter from '../../../src/view/domconverter';
 import ViewDocumentFragment from '../../../src/view/documentfragment';
-import { INLINE_FILLER, INLINE_FILLER_LENGTH, NBSP_FILLER } from '../../../src/view/filler';
+import { BR_FILLER, INLINE_FILLER, INLINE_FILLER_LENGTH, NBSP_FILLER } from '../../../src/view/filler';
 
 import { parse, stringify } from '../../../src/dev-utils/view';
 
@@ -157,7 +157,8 @@ describe( 'DomConverter', () => {
 		} );
 
 		it( 'should return null for block filler', () => {
-			const domFiller = converter.blockFiller( document );
+			// eslint-disable-next-line new-cap
+			const domFiller = BR_FILLER( document );
 
 			expect( converter.domToView( domFiller ) ).to.be.null;
 		} );
@@ -682,7 +683,8 @@ describe( 'DomConverter', () => {
 		} );
 
 		it( 'should skip filler', () => {
-			const domFiller = converter.blockFiller( document );
+			// eslint-disable-next-line new-cap
+			const domFiller = BR_FILLER( document );
 			const domP = createElement( document, 'p', null, domFiller );
 
 			const viewChildren = Array.from( converter.domChildrenToView( domP ) );
@@ -761,7 +763,7 @@ describe( 'DomConverter', () => {
 		} );
 
 		it( 'should converter position inside block filler', () => {
-			const converter = new DomConverter( { blockFiller: NBSP_FILLER } );
+			const converter = new DomConverter( { blockFillerMode: 'nbsp' } );
 			const domFiller = NBSP_FILLER( document ); // eslint-disable-line new-cap
 			const domP = createElement( document, 'p', null, domFiller );
 

+ 74 - 6
packages/ckeditor5-engine/tests/view/domconverter/domconverter.js

@@ -10,7 +10,7 @@ import ViewEditable from '../../../src/view/editableelement';
 import ViewDocument from '../../../src/view/document';
 import ViewUIElement from '../../../src/view/uielement';
 import ViewContainerElement from '../../../src/view/containerelement';
-import { BR_FILLER, NBSP_FILLER, INLINE_FILLER, INLINE_FILLER_LENGTH } from '../../../src/view/filler';
+import { BR_FILLER, INLINE_FILLER, INLINE_FILLER_LENGTH, NBSP_FILLER } from '../../../src/view/filler';
 import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
 import global from '@ckeditor/ckeditor5-utils/src/dom/global';
 
@@ -24,13 +24,13 @@ describe( 'DomConverter', () => {
 	} );
 
 	describe( 'constructor()', () => {
-		it( 'should create converter with BR block filler by default', () => {
-			expect( converter.blockFiller ).to.equal( BR_FILLER );
+		it( 'should create converter with BR block filler mode by default', () => {
+			expect( converter.blockFillerMode ).to.equal( 'br' );
 		} );
 
-		it( 'should create converter with defined block filler', () => {
-			converter = new DomConverter( { blockFiller: NBSP_FILLER } );
-			expect( converter.blockFiller ).to.equal( NBSP_FILLER );
+		it( 'should create converter with defined block mode filler', () => {
+			converter = new DomConverter( { blockFillerMode: 'nbsp' } );
+			expect( converter.blockFillerMode ).to.equal( 'nbsp' );
 		} );
 	} );
 
@@ -290,4 +290,72 @@ describe( 'DomConverter', () => {
 			} );
 		} );
 	} );
+
+	describe( 'isBlockFiller()', () => {
+		describe( 'mode "nbsp"', () => {
+			beforeEach( () => {
+				converter = new DomConverter( { blockFillerMode: 'nbsp' } );
+			} );
+
+			it( 'should return true if the node is an instance of the NBSP block filler', () => {
+				converter = new DomConverter( { blockFillerMode: 'nbsp' } );
+				const nbspFillerInstance = NBSP_FILLER( document ); // eslint-disable-line new-cap
+				// NBSP must be check inside a context.
+				const context = document.createElement( 'div' );
+				context.appendChild( nbspFillerInstance );
+
+				expect( converter.isBlockFiller( nbspFillerInstance ) ).to.be.true;
+			} );
+
+			it( 'should return false if the node is an instance of the BR block filler', () => {
+				const brFillerInstance = BR_FILLER( document ); // eslint-disable-line new-cap
+
+				expect( converter.isBlockFiller( brFillerInstance ) ).to.be.false;
+			} );
+
+			it( 'should return false if the nbsp filler is inside context', () => {
+				converter = new DomConverter( { blockFillerMode: 'nbsp' } );
+				const nbspFillerInstance = NBSP_FILLER( document ); // eslint-disable-line new-cap
+				// NBSP must be check inside a context.
+				const context = document.createElement( 'div' );
+				context.appendChild( nbspFillerInstance );
+				// eslint-disable-next-line new-cap
+				context.appendChild( NBSP_FILLER( document ) );
+
+				expect( converter.isBlockFiller( nbspFillerInstance ) ).to.be.false;
+			} );
+
+			it( 'should return false for inline filler', () => {
+				expect( converter.isBlockFiller( document.createTextNode( INLINE_FILLER ) ) ).to.be.false;
+			} );
+		} );
+
+		describe( 'mode "br"', () => {
+			beforeEach( () => {
+				converter = new DomConverter( { blockFillerMode: 'br' } );
+			} );
+
+			it( 'should return true if the node is an instance of the BR block filler', () => {
+				const brFillerInstance = BR_FILLER( document ); // eslint-disable-line new-cap
+
+				expect( converter.isBlockFiller( brFillerInstance ) ).to.be.true;
+				// Check it twice to ensure that caching breaks nothing.
+				expect( converter.isBlockFiller( brFillerInstance ) ).to.be.true;
+			} );
+
+			it( 'should return false if the node is an instance of the NBSP block filler', () => {
+				converter = new DomConverter( { blockFillerMode: 'br' } );
+				const nbspFillerInstance = NBSP_FILLER( document ); // eslint-disable-line new-cap
+				// NBSP must be check inside a context.
+				const context = document.createElement( 'div' );
+				context.appendChild( nbspFillerInstance );
+
+				expect( converter.isBlockFiller( nbspFillerInstance ) ).to.be.false;
+			} );
+
+			it( 'should return false for inline filler', () => {
+				expect( converter.isBlockFiller( document.createTextNode( INLINE_FILLER ) ) ).to.be.false;
+			} );
+		} );
+	} );
 } );

+ 3 - 3
packages/ckeditor5-engine/tests/view/domconverter/view-to-dom.js

@@ -13,7 +13,7 @@ import ViewAttributeElement from '../../../src/view/attributeelement';
 import ViewEmptyElement from '../../../src/view/emptyelement';
 import DomConverter from '../../../src/view/domconverter';
 import ViewDocumentFragment from '../../../src/view/documentfragment';
-import { INLINE_FILLER, INLINE_FILLER_LENGTH, isBlockFiller } from '../../../src/view/filler';
+import { INLINE_FILLER, INLINE_FILLER_LENGTH } from '../../../src/view/filler';
 
 import { parse } from '../../../src/dev-utils/view';
 
@@ -634,7 +634,7 @@ describe( 'DomConverter', () => {
 			const domChildren = Array.from( converter.viewChildrenToDom( viewP, document ) );
 
 			expect( domChildren.length ).to.equal( 1 );
-			expect( isBlockFiller( domChildren[ 0 ], converter.blockFiller ) ).to.be.true;
+			expect( converter.isBlockFiller( domChildren[ 0 ] ) ).to.be.true;
 		} );
 
 		it( 'should add filler according to fillerPositionOffset', () => {
@@ -644,7 +644,7 @@ describe( 'DomConverter', () => {
 			const domChildren = Array.from( converter.viewChildrenToDom( viewP, document ) );
 
 			expect( domChildren.length ).to.equal( 2 );
-			expect( isBlockFiller( domChildren[ 0 ], converter.blockFiller ) ).to.be.true;
+			expect( converter.isBlockFiller( domChildren[ 0 ] ) ).to.be.true;
 			expect( domChildren[ 1 ].data ).to.equal( 'foo' );
 		} );
 

+ 68 - 11
packages/ckeditor5-engine/tests/view/domconverter/whitespace-handling-integration.js

@@ -23,6 +23,9 @@ describe( 'DomConverter – whitespace handling – integration', () => {
 				.create( { plugins: [ Paragraph ] } )
 				.then( newEditor => {
 					editor = newEditor;
+
+					editor.model.schema.extend( '$text', { allowAttributes: [ 'bold' ] } );
+					editor.conversion.attributeToElement( { model: 'bold', view: 'b' } );
 				} );
 		} );
 
@@ -48,14 +51,13 @@ describe( 'DomConverter – whitespace handling – integration', () => {
 			expect( editor.getData() ).to.equal( '<p>foo</p>' );
 		} );
 
-		// Controversial result. See https://github.com/ckeditor/ckeditor5-engine/issues/987.
 		it( 'nbsp at the end of the content is not ignored', () => {
-			editor.setData( '<p>foo</p>' );
+			editor.setData( '<p>foo&nbsp;</p>' );
 
 			expect( getData( editor.model, { withoutSelection: true } ) )
-				.to.equal( '<paragraph>foo</paragraph>' );
+				.to.equal( '<paragraph>foo </paragraph>' );
 
-			expect( editor.getData() ).to.equal( '<p>foo</p>' );
+			expect( editor.getData() ).to.equal( '<p>foo&nbsp;</p>' );
 		} );
 
 		it( 'new line at the beginning of the content is ignored', () => {
@@ -76,14 +78,13 @@ describe( 'DomConverter – whitespace handling – integration', () => {
 			expect( editor.getData() ).to.equal( '<p>foo</p>' );
 		} );
 
-		// Controversial result. See https://github.com/ckeditor/ckeditor5-engine/issues/987.
 		it( 'nbsp at the beginning of the content is not ignored', () => {
-			editor.setData( '<p>foo</p>' );
+			editor.setData( '<p>&nbsp;foo</p>' );
 
 			expect( getData( editor.model, { withoutSelection: true } ) )
-				.to.equal( '<paragraph>foo</paragraph>' );
+				.to.equal( '<paragraph> foo</paragraph>' );
 
-			expect( editor.getData() ).to.equal( '<p>foo</p>' );
+			expect( editor.getData() ).to.equal( '<p>&nbsp;foo</p>' );
 		} );
 
 		it( 'new line between blocks is ignored', () => {
@@ -105,13 +106,33 @@ describe( 'DomConverter – whitespace handling – integration', () => {
 		} );
 
 		// Controversial result. See https://github.com/ckeditor/ckeditor5-engine/issues/987.
-		it( 'nbsp between blocks is not ignored', () => {
+		it( 'nbsp between blocks is not ignored (between paragraphs)', () => {
 			editor.setData( '<p>foo</p>&nbsp;<p>bar</p>' );
 
 			expect( getData( editor.model, { withoutSelection: true } ) )
-				.to.equal( '<paragraph>foo</paragraph><paragraph>bar</paragraph>' );
+				.to.equal( '<paragraph>foo</paragraph><paragraph> </paragraph><paragraph>bar</paragraph>' );
 
-			expect( editor.getData() ).to.equal( '<p>foo</p><p>bar</p>' );
+			expect( editor.getData() ).to.equal( '<p>foo</p><p>&nbsp;</p><p>bar</p>' );
+		} );
+
+		it( 'nbsp between blocks is not ignored (different blocks)', () => {
+			editor.model.schema.register( 'block', { inheritAllFrom: '$block' } );
+			editor.conversion.elementToElement( { model: 'block', view: 'block' } );
+			editor.setData( '<block>foo</block>&nbsp;<p>bar</p>' );
+
+			expect( getData( editor.model, { withoutSelection: true } ) )
+				.to.equal(
+					'<block>foo</block>' +
+					'<paragraph> </paragraph>' +
+					'<paragraph>bar</paragraph>'
+				);
+
+			expect( editor.getData() )
+				.to.equal(
+					'<block>foo</block>' +
+					'<p>&nbsp;</p>' +
+					'<p>bar</p>'
+				);
 		} );
 
 		it( 'new lines inside blocks are ignored', () => {
@@ -141,6 +162,15 @@ describe( 'DomConverter – whitespace handling – integration', () => {
 			expect( editor.getData() ).to.equal( '<p>&nbsp;foo&nbsp;</p>' );
 		} );
 
+		it( 'single nbsp inside blocks are ignored', () => {
+			editor.setData( '<p>&nbsp;</p>' );
+
+			expect( getData( editor.model, { withoutSelection: true } ) )
+				.to.equal( '<paragraph></paragraph>' );
+
+			expect( editor.getData() ).to.equal( '' ); // trimmed
+		} );
+
 		it( 'all whitespaces together are ignored', () => {
 			editor.setData( '\n<p>foo\n\r\n \t</p>\n<p> bar</p>' );
 
@@ -149,6 +179,33 @@ describe( 'DomConverter – whitespace handling – integration', () => {
 
 			expect( editor.getData() ).to.equal( '<p>foo</p><p>bar</p>' );
 		} );
+
+		it( 'nbsp between inline elements is not ignored', () => {
+			editor.setData( '<p><b>foo</b>&nbsp;<b>bar</b></p>' );
+
+			expect( getData( editor.model, { withoutSelection: true } ) )
+				.to.equal( '<paragraph><$text bold="true">foo</$text>\u00A0<$text bold="true">bar</$text></paragraph>' );
+
+			expect( editor.getData() ).to.equal( '<p><b>foo</b>&nbsp;<b>bar</b></p>' );
+		} );
+
+		it( 'nbsp before inline element is not ignored', () => {
+			editor.setData( '<p>&nbsp;<b>bar</b></p>' );
+
+			expect( getData( editor.model, { withoutSelection: true } ) )
+				.to.equal( '<paragraph> <$text bold="true">bar</$text></paragraph>' );
+
+			expect( editor.getData() ).to.equal( '<p>&nbsp;<b>bar</b></p>' );
+		} );
+
+		it( 'nbsp after inline element is not ignored', () => {
+			editor.setData( '<p><b>bar</b>&nbsp;</p>' );
+
+			expect( getData( editor.model, { withoutSelection: true } ) )
+				.to.equal( '<paragraph><$text bold="true">bar</$text> </paragraph>' );
+
+			expect( editor.getData() ).to.equal( '<p><b>bar</b>&nbsp;</p>' );
+		} );
 	} );
 
 	// https://github.com/ckeditor/ckeditor5/issues/1024

+ 3 - 29
packages/ckeditor5-engine/tests/view/filler.js

@@ -6,14 +6,11 @@
 /* globals document */
 
 import {
-	BR_FILLER,
-	NBSP_FILLER,
 	INLINE_FILLER_LENGTH,
 	INLINE_FILLER,
 	startsWithFiller,
 	isInlineFiller,
 	getDataWithoutFiller,
-	isBlockFiller
 } from '../../src/view/filler';
 
 describe( 'filler', () => {
@@ -23,7 +20,7 @@ describe( 'filler', () => {
 		} );
 	} );
 
-	describe( 'startsWithFiller', () => {
+	describe( 'startsWithFiller()', () => {
 		it( 'should be true for node which contains only filler', () => {
 			const node = document.createTextNode( INLINE_FILLER );
 
@@ -67,7 +64,7 @@ describe( 'filler', () => {
 		} );
 	} );
 
-	describe( 'getDataWithoutFiller', () => {
+	describe( 'getDataWithoutFiller()', () => {
 		it( 'should return data without filler', () => {
 			const node = document.createTextNode( INLINE_FILLER + 'foo' );
 
@@ -87,7 +84,7 @@ describe( 'filler', () => {
 		} );
 	} );
 
-	describe( 'isInlineFiller', () => {
+	describe( 'isInlineFiller()', () => {
 		it( 'should be true for inline filler', () => {
 			const node = document.createTextNode( INLINE_FILLER );
 
@@ -122,27 +119,4 @@ describe( 'filler', () => {
 			document.body.removeChild( iframe );
 		} );
 	} );
-
-	describe( 'isBlockFiller', () => {
-		it( 'should return true if the node is an instance of the BR block filler', () => {
-			const brFillerInstance = BR_FILLER( document ); // eslint-disable-line new-cap
-
-			expect( isBlockFiller( brFillerInstance, BR_FILLER ) ).to.be.true;
-			// Check it twice to ensure that caching breaks nothing.
-			expect( isBlockFiller( brFillerInstance, BR_FILLER ) ).to.be.true;
-		} );
-
-		it( 'should return true if the node is an instance of the NBSP block filler', () => {
-			const nbspFillerInstance = NBSP_FILLER( document ); // eslint-disable-line new-cap
-
-			expect( isBlockFiller( nbspFillerInstance, NBSP_FILLER ) ).to.be.true;
-			// Check it twice to ensure that caching breaks nothing.
-			expect( isBlockFiller( nbspFillerInstance, NBSP_FILLER ) ).to.be.true;
-		} );
-
-		it( 'should return false for inline filler', () => {
-			expect( isBlockFiller( document.createTextNode( INLINE_FILLER ), BR_FILLER ) ).to.be.false;
-			expect( isBlockFiller( document.createTextNode( INLINE_FILLER ), NBSP_FILLER ) ).to.be.false;
-		} );
-	} );
 } );

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

@@ -21,7 +21,7 @@ import DocumentFragment from '../../src/view/documentfragment';
 import DowncastWriter from '../../src/view/downcastwriter';
 
 import { parse, stringify, setData as setViewData, getData as getViewData } from '../../src/dev-utils/view';
-import { INLINE_FILLER, INLINE_FILLER_LENGTH, isBlockFiller, BR_FILLER } from '../../src/view/filler';
+import { BR_FILLER, INLINE_FILLER, INLINE_FILLER_LENGTH } from '../../src/view/filler';
 import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
 import createViewRoot from './_utils/createroot';
 import createElement from '@ckeditor/ckeditor5-utils/src/dom/createelement';
@@ -804,7 +804,7 @@ describe( 'Renderer', () => {
 			// 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( domConverter.isBlockFiller( domP.childNodes[ 0 ] ) ).to.be.true;
 
 			expect( domP2.childNodes.length ).to.equal( 2 );
 			expect( domP2.childNodes[ 0 ].tagName.toLowerCase() ).to.equal( 'b' );
@@ -886,7 +886,7 @@ describe( 'Renderer', () => {
 			const domP = domRoot.childNodes[ 0 ];
 
 			expect( domP.childNodes.length ).to.equal( 1 );
-			expect( isBlockFiller( domP.childNodes[ 0 ], BR_FILLER ) ).to.be.true;
+			expect( domConverter.isBlockFiller( domP.childNodes[ 0 ] ) ).to.be.true;
 
 			expect( domSelection.rangeCount ).to.equal( 1 );
 			expect( domSelection.getRangeAt( 0 ).startContainer ).to.equal( domP );
@@ -925,7 +925,7 @@ describe( 'Renderer', () => {
 			const domP = domRoot.childNodes[ 0 ];
 
 			expect( domP.childNodes.length ).to.equal( 1 );
-			expect( isBlockFiller( domP.childNodes[ 0 ], BR_FILLER ) ).to.be.true;
+			expect( domConverter.isBlockFiller( domP.childNodes[ 0 ] ) ).to.be.true;
 
 			expect( domSelection.rangeCount ).to.equal( 1 );
 			expect( domSelection.getRangeAt( 0 ).startContainer ).to.equal( domP );

+ 1 - 2
packages/ckeditor5-engine/tests/view/view/view.js

@@ -18,7 +18,6 @@ import ViewRange from '../../../src/view/range';
 import ViewElement from '../../../src/view/element';
 import ViewPosition from '../../../src/view/position';
 import ViewSelection from '../../../src/view/selection';
-import { isBlockFiller, BR_FILLER } from '../../../src/view/filler';
 
 import count from '@ckeditor/ckeditor5-utils/src/count';
 import global from '@ckeditor/ckeditor5-utils/src/dom/global';
@@ -565,7 +564,7 @@ describe( 'view', () => {
 			view.forceRender();
 
 			expect( domDiv.childNodes.length ).to.equal( 1 );
-			expect( isBlockFiller( domDiv.childNodes[ 0 ], BR_FILLER ) ).to.be.true;
+			expect( view.domConverter.isBlockFiller( domDiv.childNodes[ 0 ] ) ).to.be.true;
 
 			view.destroy();
 			domDiv.remove();