8
0
Просмотр исходного кода

Merge pull request #620 from ckeditor/t/379

Rendering and handling (in mutations) spaces at block boundaries and multiple spaces.
Piotrek Koszuliński 9 лет назад
Родитель
Сommit
b450d251c6

+ 10 - 0
packages/ckeditor5-engine/src/model/documentfragment.js

@@ -83,6 +83,16 @@ export default class DocumentFragment {
 		return this;
 		return this;
 	}
 	}
 
 
+	/**
+	 * Artificial parent of `DocumentFragment`. Returns `null`. Added for compatibility reasons.
+	 *
+	 * @readonly
+	 * @type {null}
+	 */
+	get parent() {
+		return null;
+	}
+
 	/**
 	/**
 	 * Gets the child at the given index. Returns `null` if incorrect index was passed.
 	 * Gets the child at the given index. Returns `null` if incorrect index was passed.
 	 *
 	 *

+ 10 - 0
packages/ckeditor5-engine/src/view/documentfragment.js

@@ -71,6 +71,16 @@ export default class DocumentFragment {
 		return this;
 		return this;
 	}
 	}
 
 
+	/**
+	 * Artificial parent of `DocumentFragment`. Returns `null`. Added for compatibility reasons.
+	 *
+	 * @readonly
+	 * @type {null}
+	 */
+	get parent() {
+		return null;
+	}
+
 	/**
 	/**
 	 * Returns ancestor elements of `DocumentFragment`, which is an empty array. Added for compatibility reasons.
 	 * Returns ancestor elements of `DocumentFragment`, which is an empty array. Added for compatibility reasons.
 	 *
 	 *

+ 267 - 4
packages/ckeditor5-engine/src/view/domconverter.js

@@ -3,7 +3,7 @@
  * For licensing, see LICENSE.md.
  * For licensing, see LICENSE.md.
  */
  */
 
 
-/* globals Range, Node */
+/* globals Range, Node, NodeFilter */
 
 
 import ViewText from './text.js';
 import ViewText from './text.js';
 import ViewElement from './element.js';
 import ViewElement from './element.js';
@@ -11,9 +11,13 @@ import ViewPosition from './position.js';
 import ViewRange from './range.js';
 import ViewRange from './range.js';
 import ViewSelection from './selection.js';
 import ViewSelection from './selection.js';
 import ViewDocumentFragment from './documentfragment.js';
 import ViewDocumentFragment from './documentfragment.js';
+import ViewContainerElement from './containerelement.js';
+import ViewTreeWalker from './treewalker.js';
 import { BR_FILLER, INLINE_FILLER_LENGTH, isBlockFiller, isInlineFiller, startsWithFiller, getDataWithoutFiller } from './filler.js';
 import { BR_FILLER, INLINE_FILLER_LENGTH, isBlockFiller, isInlineFiller, startsWithFiller, getDataWithoutFiller } from './filler.js';
 
 
 import indexOf from '../../utils/dom/indexof.js';
 import indexOf from '../../utils/dom/indexof.js';
+import getAncestors from '../../utils/dom/getancestors.js';
+import getCommonAncestor from '../../utils/dom/getcommonancestor.js';
 
 
 /**
 /**
  * DomConverter is a set of tools to do transformations between DOM nodes and view nodes. It also handles
  * DomConverter is a set of tools to do transformations between DOM nodes and view nodes. It also handles
@@ -54,6 +58,20 @@ export default class DomConverter {
 		 */
 		 */
 		this.blockFiller = options.blockFiller || BR_FILLER;
 		this.blockFiller = options.blockFiller || BR_FILLER;
 
 
+		/**
+		 * Tag names of DOM `Element`s which are considered pre-formatted elements.
+		 *
+		 * @member {Array.<String>} engine.view.DomConverter#preElements
+		 */
+		this.preElements = [ 'pre' ];
+
+		/**
+		 * Tag names of DOM `Element`s which are considered block elements.
+		 *
+		 * @member {Array.<String>} engine.view.DomConverter#blockElements
+		 */
+		this.blockElements = [ 'p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6' ];
+
 		/**
 		/**
 		 * DOM to View mapping.
 		 * DOM to View mapping.
 		 *
 		 *
@@ -110,7 +128,9 @@ export default class DomConverter {
 	 */
 	 */
 	viewToDom( viewNode, domDocument, options = {} ) {
 	viewToDom( viewNode, domDocument, options = {} ) {
 		if ( viewNode instanceof ViewText ) {
 		if ( viewNode instanceof ViewText ) {
-			return domDocument.createTextNode( viewNode.data );
+			const textData = this._processDataFromViewText( viewNode );
+
+			return domDocument.createTextNode( textData );
 		} else {
 		} else {
 			if ( this.getCorrespondingDom( viewNode ) ) {
 			if ( this.getCorrespondingDom( viewNode ) ) {
 				return this.getCorrespondingDom( viewNode );
 				return this.getCorrespondingDom( viewNode );
@@ -255,7 +275,7 @@ export default class DomConverter {
 	 * @param {Boolean} [options.withChildren=true] If `true`, node's and document fragment's children will be converted too.
 	 * @param {Boolean} [options.withChildren=true] If `true`, node's and document fragment's children will be converted too.
 	 * @param {Boolean} [options.keepOriginalCase=false] If `false`, node's tag name will be converter to lower case.
 	 * @param {Boolean} [options.keepOriginalCase=false] If `false`, node's tag name will be converter to lower case.
 	 * @returns {engine.view.Node|engine.view.DocumentFragment|null} Converted node or document fragment or `null`
 	 * @returns {engine.view.Node|engine.view.DocumentFragment|null} Converted node or document fragment or `null`
-	 * if DOM node is a {@link engine.view.filler filler}.
+	 * if DOM node is a {@link engine.view.filler filler} or the given node is an empty text node.
 	 */
 	 */
 	domToView( domNode, options = {} ) {
 	domToView( domNode, options = {} ) {
 		if ( isBlockFiller( domNode, this.blockFiller )  ) {
 		if ( isBlockFiller( domNode, this.blockFiller )  ) {
@@ -266,7 +286,9 @@ export default class DomConverter {
 			if ( isInlineFiller( domNode ) ) {
 			if ( isInlineFiller( domNode ) ) {
 				return null;
 				return null;
 			} else {
 			} else {
-				return new ViewText( getDataWithoutFiller( domNode ) );
+				const textData = this._processDataFromDomText( domNode );
+
+				return textData === '' ? null : new ViewText( textData );
 			}
 			}
 		} else {
 		} else {
 			if ( this.getCorrespondingView( domNode ) ) {
 			if ( this.getCorrespondingView( domNode ) ) {
@@ -651,4 +673,245 @@ export default class DomConverter {
 	isDocumentFragment( node ) {
 	isDocumentFragment( node ) {
 		return node && node.nodeType == Node.DOCUMENT_FRAGMENT_NODE;
 		return node && node.nodeType == Node.DOCUMENT_FRAGMENT_NODE;
 	}
 	}
+
+	/**
+	 * Takes text data from given {@link engine.view.Text#data} and processes it so it is correctly displayed in DOM.
+	 *
+	 * Following changes are done:
+	 * * multiple spaces are replaced to a chain of spaces and `&nbsp;`,
+	 * * space at the beginning of the text node is changed to `&nbsp;` if it is a first text node in it's container
+	 * element or if previous text node ends by space character,
+	 * * space at the end of the text node is changed to `&nbsp;` if it is a last text node in it's container.
+	 *
+	 * @private
+	 * @param {engine.view.Text} node View text node to process.
+	 * @returns {String} Processed text data.
+	 */
+	_processDataFromViewText( node ) {
+		let data = node.data;
+
+		// If any of node ancestors has a name which is in `preElements` array, then currently processed
+		// view text node is (will be) in preformatted element. We should not change whitespaces then.
+		if ( node.getAncestors().some( ( parent ) => this.preElements.includes( parent.name ) ) )  {
+			return data;
+		}
+
+		const prevNode = this._getTouchingViewTextNode( node, false );
+		const nextNode = this._getTouchingViewTextNode( node, true );
+
+		// Second part of text data, from the space after the last non-space character to the end.
+		// We separate `textEnd` and `textStart` because `textEnd` needs some special handling.
+		let textEnd = data.match( / *$/ )[ 0 ];
+		// First part of data, between first and last part of data.
+		let textStart = data.substr( 0, data.length - textEnd.length );
+
+		// If previous text node does not exist or it ends by space character, replace space character at the beginning of text.
+		// ` x`			-> `_x`
+		// `  x`		-> `_ x`
+		// `   x`		-> `_  x`
+		if ( !prevNode || prevNode.data.charAt( prevNode.data.length - 1 ) == ' ' ) {
+			textStart = textStart.replace( /^ /, '\u00A0' );
+		}
+
+		// Multiple consecutive spaces. Change them to ` &nbsp;` pairs.
+		// `_x  x`		-> `_x _x`
+		// `_ x  x`		-> `_ x _x`
+		// `_  x  x`	-> `_ _x _x`
+		// `_  x   x`	-> `_ _x _ x`
+		// `_  x    x`	-> `_ _x _ _x`
+		// `_   x    x` -> `_ _ x _ _x`
+		textStart = textStart.replace( /  /g, ' \u00A0' );
+
+		// Process `textEnd` only if there is anything to process.
+		if ( textEnd.length > 0 ) {
+			// (1) We need special treatment for the last part of text node, it has to end on `&nbsp;`, not space:
+			// `x `		-> `x_`
+			// `x  `	-> `x _`
+			// `x   `	-> `x_ _`
+			// `x    `	-> `x _ _`
+			// (2) Different case when there is a node after:
+			// `x <b>b</b>`		-> `x <b>b</b>`
+			// `x  <b>b</b>`	-> `x _<b>b</b>`
+			// `x   <b>b</b>`	-> `x _ <b>b</b>`
+			// `x    <b>b</b>`	-> `x _ _<b>b</b>`
+			// (3) But different, when that node starts by &nbsp; (or space that will be converted to &nbsp;):
+			// `x <b>_b</b>`	-> `x <b>_b</b>`
+			// `x  <b>_b</b>`	-> `x_ <b>_b</b>`
+			// `x   <b>_b</b>`	-> `x _ <b>_b</b>`
+			// `x    <b>_b</b>`	-> `x_ _ <b>_b</b>`
+			// Let's assume that starting from space is normal behavior, because starting from &nbsp; is a less frequent case.
+			let textEndStartsFromNbsp = false;
+
+			if ( !nextNode ) {
+				// (1)
+				if ( textEnd.length % 2 ) {
+					textEndStartsFromNbsp = true;
+				}
+			} else if ( nextNode.data.charAt( 0 ) == ' ' || nextNode.data.charAt( 0 ) == '\u00A0' ) {
+				// (3)
+				if ( textEnd.length % 2 === 0 ) {
+					textEndStartsFromNbsp = true;
+				}
+			}
+
+			if ( textEndStartsFromNbsp ) {
+				textEnd = '\u00A0' + textEnd.substr( 0, textEnd.length - 1 );
+			}
+
+			textEnd = textEnd.replace( /  /g, ' \u00A0' );
+		}
+
+		return textStart + textEnd;
+	}
+
+	/**
+	 * Helper function. For given {@link engine.view.Text view text node}, it finds previous or next sibling that is contained
+	 * in the same block element. If there is no such sibling, `null` is returned.
+	 *
+	 * @private
+	 * @param {engine.view.Text} node
+	 * @param {Boolean} getNext
+	 * @returns {engine.view.Text}
+	 */
+	_getTouchingViewTextNode( node, getNext ) {
+		if ( !node.parent ) {
+			return null;
+		}
+
+		const treeWalker = new ViewTreeWalker( {
+			startPosition: getNext ? ViewPosition.createAfter( node ) : ViewPosition.createBefore( node ),
+			direction: getNext ? 'forward' : 'backward'
+		} );
+
+		for ( let value of treeWalker ) {
+			if ( value.item instanceof ViewContainerElement ) {
+				// ViewContainerElement is found on a way to next ViewText node, so given `node` was first/last
+				// text node in it's container element.
+				return null;
+			} else if ( value.item instanceof ViewText ) {
+				// Found a text node in the same container element.
+				return value.item;
+			}
+		}
+
+		return null;
+	}
+
+	/**
+	 * Takes text data from native `Text` node and processes it to a correct {@link engine.view.Text view text node} data.
+	 *
+	 * Following changes are done:
+	 * * multiple whitespaces are replaced to a single space,
+	 * * space at the beginning of the text node is removed, if it is a first text node in it's container
+	 * element or if previous text node ends by space character,
+	 * * space at the end of the text node is removed, if it is a last text node in it's container.
+	 *
+	 * @param {Node} node DOM text node to process.
+	 * @returns {String} Processed data.
+	 * @private
+	 */
+	_processDataFromDomText( node ) {
+		let data = getDataWithoutFiller( node );
+
+		if ( _hasDomParentOfType( node, this.preElements ) ) {
+			return data;
+		}
+
+		// Change all consecutive whitespace characters to a single space character. That's how multiple whitespaces
+		// are treated when rendered, so we normalize those whitespaces.
+		// Note that &nbsp; (`\u00A0`) should not be treated as a whitespace because it is rendered.
+		data = data.replace( /[^\S\u00A0]{2,}/g, ' ' );
+
+		const prevNode = this._getTouchingDomTextNode( node, false );
+		const nextNode = this._getTouchingDomTextNode( node, true );
+
+		// If previous dom text node does not exist or it ends by whitespace character, remove space character from the beginning
+		// of this text node. Such space character is treated as a whitespace.
+		if ( !prevNode || /[^\S\u00A0]/.test( prevNode.data.charAt( prevNode.data.length - 1 ) ) ) {
+			data = data.replace( /^ /, '' );
+		}
+
+		// If next text node does not exist remove space character from the end of this text node.
+		if ( !nextNode ) {
+			data = data.replace( / $/, '' );
+		}
+		// At this point we should have removed all whitespaces from DOM text data.
+
+		// Now we have to change &nbsp; chars, that were in DOM text data because of rendering reasons, to spaces.
+		// First, change all ` \u00A0` pairs (space + &nbsp;) to two spaces. DOM converter changes two spaces from model/view as
+		// ` \u00A0` to ensure proper rendering. Since here we convert back, we recognize those pairs and change them
+		// to `  ` which is what we expect to have in model/view.
+		data = data.replace( / \u00A0/g, '  ' );
+		// Then, change &nbsp; character that is at the beginning of the text node to space character.
+		// As above, that &nbsp; was created for rendering reasons but it's real meaning is just a space character.
+		// We do that replacement only if this is the first node or the previous node ends on whitespace character.
+		if ( !prevNode || /[^\S\u00A0]/.test( prevNode.data.charAt( prevNode.data.length - 1 ) ) ) {
+			data = data.replace( /^\u00A0/, ' ' );
+		}
+		// Since input text data could be: `x_ _`, we would not replace the first &nbsp; after `x` character.
+		// We have to fix it. Since we already change all ` &nbsp;`, we will have something like this at the end of text data:
+		// `x_ _ _` -> `x_    `. Find &nbsp; at the end of string (can be followed only by spaces).
+		// We do that replacement only if this is the last node or the next node starts by &nbsp;.
+		if ( !nextNode || nextNode.data.charAt( 0 ) == '\u00A0' ) {
+			data = data.replace( /\u00A0( *)$/, ' $1' );
+		}
+
+		// At this point, all whitespaces should be removed and all &nbsp; created for rendering reasons should be
+		// changed to normal space. All left &nbsp; are &nbsp; inserted intentionally.
+		return data;
+	}
+
+	/**
+	 * Helper function. For given `Text` node, it finds previous or next sibling that is contained in the same block element.
+	 * If there is no such sibling, `null` is returned.
+	 *
+	 * @private
+	 * @param {Text} node
+	 * @param {Boolean} getNext
+	 * @returns {Text|null}
+	 */
+	_getTouchingDomTextNode( node, getNext ) {
+		if ( !node.parentNode ) {
+			return null;
+		}
+
+		const direction = getNext ? 'nextNode' : 'previousNode';
+		const document = node.ownerDocument;
+		const treeWalker = document.createTreeWalker( document.childNodes[ 0 ], NodeFilter.SHOW_TEXT );
+
+		treeWalker.currentNode = node;
+
+		const touchingNode = treeWalker[ direction ]();
+
+		if ( touchingNode !== null ) {
+			const lca = getCommonAncestor( node, touchingNode );
+
+			// If there is common ancestor between the text node and next/prev text node,
+			// and there are no block elements on a way from the text node to that ancestor,
+			// and there are no block elements on a way from next/prev text node to that ancestor...
+			if ( lca && !_hasDomParentOfType( node, this.blockElements, lca ) && !_hasDomParentOfType( touchingNode, this.blockElements, lca ) ) {
+				// Then they are in the same container element.
+				return touchingNode;
+			}
+		}
+
+		return null;
+	}
+}
+
+// Helper function.
+// Used to check if given native `Element` or `Text` node has parent with tag name from `types` array.
+//
+// @param {Node} node
+// @param {Array.<String>} types
+// @param {Boolean} [boundaryParent] Can be given if parents should be checked up to a given element (excluding that element).
+// @returns {Boolean} `true` if such parent exists or `false` if it does not.
+function _hasDomParentOfType( node, types, boundaryParent ) {
+	let parents = getAncestors( node );
+
+	if ( boundaryParent ) {
+		parents = parents.slice( parents.indexOf( boundaryParent ) + 1 );
+	}
+
+	return parents.some( ( parent ) => parent.tagName && types.includes( parent.tagName.toLowerCase() ) );
 }
 }

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

@@ -313,9 +313,10 @@ export default class Renderer {
 	 */
 	 */
 	_updateText( viewText ) {
 	_updateText( viewText ) {
 		const domText = this.domConverter.getCorrespondingDom( viewText );
 		const domText = this.domConverter.getCorrespondingDom( viewText );
+		const newDomText = this.domConverter.viewToDom( viewText, domText.ownerDocument );
 
 
 		const actualText = domText.data;
 		const actualText = domText.data;
-		let expectedText = viewText.data;
+		let expectedText = newDomText.data;
 
 
 		const filler = this._inlineFillerPosition;
 		const filler = this._inlineFillerPosition;
 
 

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

@@ -158,9 +158,180 @@ describe( 'DomConverter', () => {
 
 
 		it( 'should return null for block filler', () => {
 		it( 'should return null for block filler', () => {
 			const domFiller = converter.blockFiller( document );
 			const domFiller = converter.blockFiller( document );
-			const viewFiller = converter.domToView( domFiller );
 
 
-			expect( viewFiller ).to.be.null;
+			expect( converter.domToView( domFiller ) ).to.be.null;
+		} );
+
+		it( 'should return null for empty text node', () => {
+			const textNode = document.createTextNode( '' );
+
+			expect( converter.domToView( textNode ) ).to.be.null;
+		} );
+
+		describe( 'it should clear whitespaces', () => {
+			it( 'at the beginning of block element', () => {
+				const domDiv = createElement( document, 'div', {}, [
+					document.createTextNode( ' ' ),
+					createElement( document, 'p', {}, [
+						document.createTextNode( ' foo' )
+					] ),
+					createElement( document, 'p', {}, [
+						document.createTextNode( ' foo' )
+					] )
+				] );
+
+				const viewDiv = converter.domToView( domDiv );
+
+				expect( viewDiv.childCount ).to.equal( 2 );
+				expect( viewDiv.getChild( 0 ).getChild( 0 ).data ).to.equal( 'foo' );
+				expect( viewDiv.getChild( 1 ).getChild( 0 ).data ).to.equal( 'foo' );
+			} );
+
+			it( 'at the end of block element', () => {
+				const domDiv = createElement( document, 'div', {}, [
+					createElement( document, 'p', {}, [
+						document.createTextNode( 'foo ' )
+					] ),
+					createElement( document, 'p', {}, [
+						document.createTextNode( 'foo ' )
+					] ),
+					document.createTextNode( ' ' )
+				] );
+
+				const viewDiv = converter.domToView( domDiv );
+
+				expect( viewDiv.childCount ).to.equal( 2 );
+				expect( viewDiv.getChild( 0 ).getChild( 0 ).data ).to.equal( 'foo' );
+				expect( viewDiv.getChild( 1 ).getChild( 0 ).data ).to.equal( 'foo' );
+			} );
+
+			it( 'multiple consecutive whitespaces changed to one', () => {
+				const domDiv = createElement( document, 'div', {}, [
+					createElement( document, 'p', {}, [
+						document.createTextNode( '             f    o  o' )
+					] ),
+					createElement( document, 'p', {}, [
+						document.createTextNode( 'fo  o   ' )
+					] )
+				] );
+
+				const viewDiv = converter.domToView( domDiv );
+
+				expect( viewDiv.getChild( 0 ).getChild( 0 ).data ).to.equal( 'f o o' );
+				expect( viewDiv.getChild( 1 ).getChild( 0 ).data ).to.equal( 'fo o' );
+			} );
+
+			function test( inputTexts, output ) {
+				if ( typeof inputTexts == 'string' ) {
+					inputTexts = [ inputTexts ];
+				}
+
+				it( 'spaces in a text node: ' + inputTexts.join( '|' ) + ' -> ' + output, () => {
+					const domElement = createElement( document, 'div', {}, [] );
+
+					for ( let text of inputTexts ) {
+						domElement.appendChild( document.createTextNode( text.replace( /_/g, '\u00A0' ) ) );
+					}
+
+					const viewElement = converter.domToView( domElement );
+
+					let data = '';
+
+					for ( let child of viewElement.getChildren() ) {
+						data += child.data.replace( /\u00A0/g, '_' );
+					}
+
+					expect( data ).to.equal( output );
+				} );
+			}
+
+			// At the beginning.
+			test( '_x', ' x' );
+			test( '_ x', '  x' );
+			test( '_ _x', '   x' );
+			test( '_ _ x', '    x' );
+
+			// At the end.
+			test( 'x_', 'x ' );
+			test( 'x _', 'x  ' );
+			test( 'x_ _', 'x   ' );
+			test( 'x _ _', 'x    ' );
+
+			// In the middle.
+			test( 'x x', 'x x' );
+			test( 'x _x', 'x  x' );
+			test( 'x _ x', 'x   x' );
+			test( 'x _ _x', 'x    x' );
+
+			// Complex.
+			test( '_x_', ' x ' );
+			test( '_ x _x _', '  x  x  ' );
+			test( '_ _x x _', '   x x  ' );
+			test( '_ _x x_ _', '   x x   ' );
+			test( '_ _x _ _x_', '   x    x ' );
+			test( '_', ' ' );
+
+			// With hard &nbsp;
+			test( '_x', ' x' );
+			test( '__x', ' _x' );
+			test( '___x', ' __x' );
+			test( '__ x', ' _ x' );
+
+			test( 'x_', 'x ' );
+			test( 'x__', 'x_ ' );
+			test( 'x___', 'x__ ' );
+			// This is an edge case, but it's impossible to write elegant and compact algorithm that is also
+			// 100% correct. We might assume that expected result is `x  _` but it will be converted to `x   `
+			// by the algorithm. This is acceptable, though.
+			test( 'x __', 'x   ' );
+
+			test( 'x_x', 'x_x' );
+			test( 'x___x', 'x___x' );
+			test( 'x____x', 'x____x' );
+			test( 'x__ x', 'x__ x' );
+			test( 'x___ x', 'x___ x' );
+			test( 'x_ _x', 'x_  x' );
+			test( 'x __x', 'x  _x' );
+			test( 'x _ x', 'x   x' );
+			test( 'x __ _x', 'x  _  x' );
+
+			// Two text nodes.
+			test( [ 'x', 'y' ], 'xy' );
+			test( [ 'x ', 'y' ], 'x y' );
+			test( [ 'x _', 'y' ], 'x  y' );
+			test( [ 'x _ ', 'y' ], 'x   y' );
+			test( [ 'x _  _', 'y' ], 'x    y' );
+
+			test( [ 'x', ' y' ], 'x y' );
+			test( [ 'x ', '_y' ], 'x  y' );
+			test( [ 'x_ ', '_y' ], 'x   y' );
+			test( [ 'x _ ', '_y' ], 'x    y' );
+			test( [ 'x_ _ ', '_y' ], 'x     y' );
+
+			test( [ 'x', ' _y' ], 'x  y' );
+			test( [ 'x ', '_ y' ], 'x   y' );
+			test( [ 'x_ ', '_ y' ], 'x    y' );
+			test( [ 'x _ ', '_ y' ], 'x     y' );
+			test( [ 'x_ _ ', '_ y' ], 'x      y' );
+
+			// Some tests with hard &nbsp;
+			test( [ 'x', '_y' ], 'x_y' );
+			test( [ 'x_', 'y' ], 'x_y' );
+			test( [ 'x_', ' y' ], 'x_ y' );
+			test( [ 'x__', ' y' ], 'x__ y' );
+			test( [ 'x_ _', ' y' ], 'x_   y' );
+
+			it( 'not in preformatted blocks', () => {
+				const domDiv = createElement( document, 'div', {}, [
+					createElement( document, 'pre', {}, [
+						document.createTextNode( '   foo\n   foo  ' )
+					] )
+				] );
+
+				const viewDiv = converter.domToView( domDiv );
+
+				expect( viewDiv.getChild( 0 ).getChild( 0 ).data ).to.equal( '   foo\n   foo  ' );
+			} );
 		} );
 		} );
 	} );
 	} );
 
 

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

@@ -3,11 +3,13 @@
  * For licensing, see LICENSE.md.
  * For licensing, see LICENSE.md.
  */
  */
 
 
-/* globals Range, DocumentFragment, HTMLElement, document */
+/* globals Range, DocumentFragment, HTMLElement, document, Text */
 /* bender-tags: view, domconverter, browser-only */
 /* bender-tags: view, domconverter, browser-only */
 
 
 import ViewText from '/ckeditor5/engine/view/text.js';
 import ViewText from '/ckeditor5/engine/view/text.js';
 import ViewElement from '/ckeditor5/engine/view/element.js';
 import ViewElement from '/ckeditor5/engine/view/element.js';
+import ViewContainerElement from '/ckeditor5/engine/view/containerelement.js';
+import ViewAttributeElement from '/ckeditor5/engine/view/attributeelement.js';
 import DomConverter from '/ckeditor5/engine/view/domconverter.js';
 import DomConverter from '/ckeditor5/engine/view/domconverter.js';
 import ViewDocumentFragment from '/ckeditor5/engine/view/documentfragment.js';
 import ViewDocumentFragment from '/ckeditor5/engine/view/documentfragment.js';
 import { INLINE_FILLER, INLINE_FILLER_LENGTH, isBlockFiller } from '/ckeditor5/engine/view/filler.js';
 import { INLINE_FILLER, INLINE_FILLER_LENGTH, isBlockFiller } from '/ckeditor5/engine/view/filler.js';
@@ -164,10 +166,192 @@ describe( 'DomConverter', () => {
 
 
 			converter.bindDocumentFragments( domFragment, viewFragment );
 			converter.bindDocumentFragments( domFragment, viewFragment );
 
 
-			const domFragment2 = converter.viewToDom( viewFragment );
+			const domFragment2 = converter.viewToDom( viewFragment, document );
 
 
 			expect( domFragment2 ).to.equal( domFragment );
 			expect( domFragment2 ).to.equal( domFragment );
 		} );
 		} );
+
+		it( 'should create DOM text node from view text node', () => {
+			const viewTextNode = new ViewText( 'foo' );
+			const domTextNode = converter.viewToDom( viewTextNode, document );
+
+			expect( domTextNode ).to.be.instanceof( Text );
+			expect( domTextNode.data ).to.equal( 'foo' );
+		} );
+
+		describe( 'it should convert spaces to &nbsp;', () => {
+			it( 'at the beginning of each container element', () => {
+				const viewDiv = new ViewContainerElement( 'div', null, [
+					new ViewContainerElement( 'p', null, new ViewText( ' foo' ) ),
+					new ViewContainerElement( 'p', null, new ViewText( 'bar' ) ),
+					new ViewContainerElement( 'p', null, new ViewText( ' xxx' ) )
+				] );
+
+				const domDiv = converter.viewToDom( viewDiv, document );
+
+				expect( domDiv.innerHTML ).to.equal( '<p>&nbsp;foo</p><p>bar</p><p>&nbsp;xxx</p>' );
+			} );
+
+			it( 'at the end of each container element', () => {
+				const viewDiv = new ViewContainerElement( 'div', null, [
+					new ViewContainerElement( 'p', null, new ViewText( 'foo ' ) ),
+					new ViewContainerElement( 'p', null, new ViewText( 'bar' ) ),
+					new ViewContainerElement( 'p', null, new ViewText( 'xxx ' ) )
+				] );
+
+				const domDiv = converter.viewToDom( viewDiv, document );
+
+				expect( domDiv.innerHTML ).to.equal( '<p>foo&nbsp;</p><p>bar</p><p>xxx&nbsp;</p>' );
+			} );
+
+			it( 'when there are multiple spaces next to each other or between attribute elements', () => {
+				const viewDiv = new ViewContainerElement( 'div', null, [
+					new ViewText( 'x  x   x x ' ),
+					new ViewAttributeElement( 'b', null, new ViewText( ' x ' ) ),
+					new ViewAttributeElement( 'i', null,
+						new ViewAttributeElement( 'b', null,
+							new ViewAttributeElement( 'u' , null, new ViewText( ' x' ) )
+						)
+					)
+				] );
+
+				const domDiv = converter.viewToDom( viewDiv, document );
+
+				expect( domDiv.innerHTML ).to.equal( 'x &nbsp;x &nbsp; x x <b>&nbsp;x </b><i><b><u>&nbsp;x</u></b></i>' );
+			} );
+
+			it( 'all together', () => {
+				const viewDiv = new ViewContainerElement( 'div', null, [
+					new ViewContainerElement( 'p', null, [
+						new ViewText( ' x  x   x x ' ),
+						new ViewAttributeElement( 'b', null, new ViewText( ' x ' ) ),
+						new ViewAttributeElement( 'i', null,
+							new ViewAttributeElement( 'b', null,
+								new ViewAttributeElement( 'u' , null, new ViewText( ' x ' ) )
+							)
+						)
+					] ),
+					new ViewContainerElement( 'p', null, new ViewText( '  x  ' ) )
+				] );
+
+				const domDiv = converter.viewToDom( viewDiv, document );
+
+				expect( domDiv.innerHTML ).to.equal(
+					'<p>&nbsp;x &nbsp;x &nbsp; x x <b>&nbsp;x </b><i><b><u>&nbsp;x&nbsp;</u></b></i></p><p>&nbsp; x &nbsp;</p>'
+				);
+			} );
+
+			function test( inputTexts, output ) {
+				if ( typeof inputTexts == 'string' ) {
+					inputTexts = [ inputTexts ];
+				}
+
+				it( 'spaces in a text node: ' + inputTexts.join( '|' ) + ' -> ' + output, () => {
+					const viewElement = new ViewContainerElement( 'p' );
+
+					for ( let text of inputTexts ) {
+						viewElement.appendChildren( new ViewText( text.replace( /_/g, '\u00A0' ) ) );
+					}
+
+					const domElement = converter.viewToDom( viewElement, document );
+					const data = domElement.innerHTML.replace( /&nbsp;/g, '_' );
+
+					expect( data ).to.equal( output );
+				} );
+			}
+
+			// At the beginning.
+			test( ' x', '_x' );
+			test( '  x', '_ x' );
+			test( '   x', '_ _x' );
+			test( '    x', '_ _ x' );
+
+			// At the end.
+			test( 'x ', 'x_' );
+			test( 'x  ', 'x _' );
+			test( 'x   ', 'x_ _' );
+			test( 'x    ', 'x _ _' );
+
+			// In the middle.
+			test( 'x x', 'x x' );
+			test( 'x  x', 'x _x' );
+			test( 'x   x', 'x _ x' );
+			test( 'x    x', 'x _ _x' );
+
+			// Complex.
+			test( ' x ', '_x_' );
+			test( '  x  x  ', '_ x _x _' );
+			test( '   x x  ', '_ _x x _' );
+			test( '   x x   ', '_ _x x_ _' );
+			test( '   x    x ', '_ _x _ _x_' );
+			test( ' ', '_' );
+
+			// With hard &nbsp;
+			test( '_x', '_x' );
+			test( ' _x', '__x' );
+			test( '  _x', '_ _x' );
+			test( ' __x', '___x' );
+			test( '___x', '___x' );
+			test( '_ _x', '_ _x' );
+			test( ' _ x', '__ x' );
+			test( '  _x', '_ _x' );
+
+			test( 'x_', 'x_' );
+			test( 'x_ ', 'x__' );
+			test( 'x_  ', 'x_ _' );
+			test( 'x__ ', 'x___' );
+			test( 'x___', 'x___' );
+			test( 'x_ _', 'x_ _' );
+			test( 'x _ ', 'x __' );
+			test( 'x  _', 'x __' );
+
+			test( 'x_x', 'x_x' );
+			test( 'x___x', 'x___x' );
+			test( 'x__ x', 'x__ x' );
+			test( 'x_  x', 'x_ _x' );
+			test( 'x  _x', 'x __x' );
+			test( 'x __x', 'x __x' );
+			test( 'x _ x', 'x _ x' );
+			test( 'x  _  x', 'x __ _x' );
+
+			// Two text nodes.
+			test( [ 'x', 'y' ], 'xy' );
+			test( [ 'x ', 'y' ], 'x y' );
+			test( [ 'x  ', 'y' ], 'x _y' );
+			test( [ 'x   ', 'y' ], 'x _ y' );
+			test( [ 'x    ', 'y' ], 'x _ _y' );
+
+			test( [ 'x', ' y' ], 'x y' );
+			test( [ 'x ', ' y' ], 'x _y' );
+			test( [ 'x  ', ' y' ], 'x_ _y' );
+			test( [ 'x   ', ' y' ], 'x _ _y' );
+			test( [ 'x    ', ' y' ], 'x_ _ _y' );
+
+			test( [ 'x', '_y' ], 'x_y' );
+			test( [ 'x ', '_y' ], 'x _y' );
+			test( [ 'x  ', '_y' ], 'x_ _y' );
+			test( [ 'x   ', '_y' ], 'x _ _y' );
+			test( [ 'x    ', '_y' ], 'x_ _ _y' );
+
+			test( [ 'x', '  y' ], 'x _y' );
+			test( [ 'x ', '  y' ], 'x _ y' );
+			test( [ 'x  ', '  y' ], 'x_ _ y' );
+			test( [ 'x   ', '  y' ], 'x _ _ y' );
+			test( [ 'x    ', '  y' ], 'x_ _ _ y' );
+
+			test( [ 'x', '   y' ], 'x _ y' );
+			test( [ 'x ', '   y' ], 'x _ _y' );
+			test( [ 'x  ', '   y' ], 'x_ _ _y' );
+			test( [ 'x   ', '   y' ], 'x _ _ _y' );
+			test( [ 'x    ', '   y' ], 'x_ _ _ _y' );
+
+			it( 'not in preformatted blocks', () => {
+				const viewDiv = new ViewContainerElement( 'pre', null, new ViewText( '   foo   ' ) );
+				const domDiv = converter.viewToDom( viewDiv, document );
+
+				expect( domDiv.innerHTML ).to.equal( '   foo   ' );
+			} );
+		} );
 	} );
 	} );
 
 
 	describe( 'viewChildrenToDom', () => {
 	describe( 'viewChildrenToDom', () => {