Skip to content
This repository was archived by the owner on Jun 26, 2020. It is now read-only.

Commit 3e74554

Browse files
authored
Merge pull request #1430 from ckeditor/t/ckeditor5/1024
Fix: Fixed view <-> DOM conversion of whitespaces around `<br>` elements. Closes ckeditor/ckeditor5#1024.
2 parents ba3d641 + da38213 commit 3e74554

File tree

4 files changed

+555
-29
lines changed

4 files changed

+555
-29
lines changed

src/view/domconverter.js

Lines changed: 88 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import indexOf from '@ckeditor/ckeditor5-utils/src/dom/indexof';
2323
import getAncestors from '@ckeditor/ckeditor5-utils/src/dom/getancestors';
2424
import getCommonAncestor from '@ckeditor/ckeditor5-utils/src/dom/getcommonancestor';
2525
import isText from '@ckeditor/ckeditor5-utils/src/dom/istext';
26+
import isElement from '@ckeditor/ckeditor5-utils/src/lib/lodash/isElement';
2627

2728
/**
2829
* DomConverter is a set of tools to do transformations between DOM nodes and view nodes. It also handles
@@ -948,10 +949,11 @@ export default class DomConverter {
948949
* Takes text data from native `Text` node and processes it to a correct {@link module:engine/view/text~Text view text node} data.
949950
*
950951
* Following changes are done:
952+
*
951953
* * multiple whitespaces are replaced to a single space,
952-
* * space at the beginning of the text node is removed, if it is a first text node in it's container
953-
* element or if previous text node ends by space character,
954-
* * space at the end of the text node is removed, if it is a last text node in it's container.
954+
* * space at the beginning of a text node is removed if it is the first text node in its container
955+
* element or if the previous text node ends with a space character,
956+
* * space at the end of the text node is removed, if it is the last text node in its container.
955957
*
956958
* @param {Node} node DOM text node to process.
957959
* @returns {String} Processed data.
@@ -970,17 +972,20 @@ export default class DomConverter {
970972
// We're replacing 1+ (and not 2+) to also normalize singular \n\t\r characters (#822).
971973
data = data.replace( /[ \n\t\r]{1,}/g, ' ' );
972974

973-
const prevNode = this._getTouchingDomTextNode( node, false );
974-
const nextNode = this._getTouchingDomTextNode( node, true );
975+
const prevNode = this._getTouchingInlineDomNode( node, false );
976+
const nextNode = this._getTouchingInlineDomNode( node, true );
977+
978+
const shouldLeftTrim = this._checkShouldLeftTrimDomText( prevNode );
979+
const shouldRightTrim = this._checkShouldRightTrimDomText( node, nextNode );
975980

976-
// If previous dom text node does not exist or it ends by whitespace character, remove space character from the beginning
981+
// If the previous dom text node does not exist or it ends by whitespace character, remove space character from the beginning
977982
// of this text node. Such space character is treated as a whitespace.
978-
if ( !prevNode || /[^\S\u00A0]/.test( prevNode.data.charAt( prevNode.data.length - 1 ) ) ) {
983+
if ( shouldLeftTrim ) {
979984
data = data.replace( /^ /, '' );
980985
}
981986

982-
// If next text node does not exist remove space character from the end of this text node.
983-
if ( !nextNode && !startsWithFiller( node ) ) {
987+
// If the next text node does not exist remove space character from the end of this text node.
988+
if ( shouldRightTrim ) {
984989
data = data.replace( / $/, '' );
985990
}
986991

@@ -1001,15 +1006,15 @@ export default class DomConverter {
10011006
// Then, change &nbsp; character that is at the beginning of the text node to space character.
10021007
// As above, that &nbsp; was created for rendering reasons but it's real meaning is just a space character.
10031008
// We do that replacement only if this is the first node or the previous node ends on whitespace character.
1004-
if ( !prevNode || /[^\S\u00A0]/.test( prevNode.data.charAt( prevNode.data.length - 1 ) ) ) {
1009+
if ( shouldLeftTrim ) {
10051010
data = data.replace( /^\u00A0/, ' ' );
10061011
}
10071012

10081013
// Since input text data could be: `x_ _`, we would not replace the first &nbsp; after `x` character.
10091014
// We have to fix it. Since we already change all ` &nbsp;`, we will have something like this at the end of text data:
10101015
// `x_ _ _` -> `x_ `. Find &nbsp; at the end of string (can be followed only by spaces).
1011-
// We do that replacement only if this is the last node or the next node starts by &nbsp;.
1012-
if ( !nextNode || nextNode.data.charAt( 0 ) == '\u00A0' ) {
1016+
// We do that replacement only if this is the last node or the next node starts with &nbsp; or is a <br>.
1017+
if ( isText( nextNode ) ? nextNode.data.charAt( 0 ) == '\u00A0' : true ) {
10131018
data = data.replace( /\u00A0( *)$/, ' $1' );
10141019
}
10151020

@@ -1018,6 +1023,39 @@ export default class DomConverter {
10181023
return data;
10191024
}
10201025

1026+
/**
1027+
* Helper function which checks if a DOM text node, preceded by the given `prevNode` should
1028+
* be trimmed from the left side.
1029+
*
1030+
* @param {Node} prevNode
1031+
*/
1032+
_checkShouldLeftTrimDomText( prevNode ) {
1033+
if ( !prevNode ) {
1034+
return true;
1035+
}
1036+
1037+
if ( isElement( prevNode ) ) {
1038+
return true;
1039+
}
1040+
1041+
return /[^\S\u00A0]/.test( prevNode.data.charAt( prevNode.data.length - 1 ) );
1042+
}
1043+
1044+
/**
1045+
* Helper function which checks if a DOM text node, succeeded by the given `nextNode` should
1046+
* be trimmed from the right side.
1047+
*
1048+
* @param {Node} node
1049+
* @param {Node} prevNode
1050+
*/
1051+
_checkShouldRightTrimDomText( node, nextNode ) {
1052+
if ( nextNode ) {
1053+
return false;
1054+
}
1055+
1056+
return !startsWithFiller( node );
1057+
}
1058+
10211059
/**
10221060
* Helper function. For given {@link module:engine/view/text~Text view text node}, it finds previous or next sibling
10231061
* that is contained in the same container element. If there is no such sibling, `null` is returned.
@@ -1033,12 +1071,17 @@ export default class DomConverter {
10331071
} );
10341072

10351073
for ( const value of treeWalker ) {
1074+
// ViewContainerElement is found on a way to next ViewText node, so given `node` was first/last
1075+
// text node in its container element.
10361076
if ( value.item.is( 'containerElement' ) ) {
1037-
// ViewContainerElement is found on a way to next ViewText node, so given `node` was first/last
1038-
// text node in its container element.
10391077
return null;
1040-
} else if ( value.item.is( 'textProxy' ) ) {
1041-
// Found a text node in the same container element.
1078+
}
1079+
// <br> found – it works like a block boundary, so do not scan further.
1080+
else if ( value.item.is( 'br' ) ) {
1081+
return null;
1082+
}
1083+
// Found a text node in the same container element.
1084+
else if ( value.item.is( 'textProxy' ) ) {
10421085
return value.item;
10431086
}
10441087
}
@@ -1047,15 +1090,27 @@ export default class DomConverter {
10471090
}
10481091

10491092
/**
1050-
* Helper function. For given `Text` node, it finds previous or next sibling that is contained in the same block element.
1051-
* If there is no such sibling, `null` is returned.
1093+
* Helper function. For the given text node, it finds the closest touching node which is either
1094+
* a text node or a `<br>`. The search is terminated at block element boundaries and if a matching node
1095+
* wasn't found so far, `null` is returned.
1096+
*
1097+
* In the following DOM structure:
1098+
*
1099+
* <p>foo<b>bar</b><br>bom</p>
1100+
*
1101+
* * `foo` doesn't have its previous touching inline node (`null` is returned),
1102+
* * `foo`'s next touching inline node is `bar`
1103+
* * `bar`'s next touching inline node is `<br>`
1104+
*
1105+
* This method returns text nodes and `<br>` elements because these types of nodes affect how
1106+
* spaces in the given text node need to be converted.
10521107
*
10531108
* @private
10541109
* @param {Text} node
10551110
* @param {Boolean} getNext
1056-
* @returns {Text|null}
1111+
* @returns {Text|Element|null}
10571112
*/
1058-
_getTouchingDomTextNode( node, getNext ) {
1113+
_getTouchingInlineDomNode( node, getNext ) {
10591114
if ( !node.parentNode ) {
10601115
return null;
10611116
}
@@ -1064,7 +1119,19 @@ export default class DomConverter {
10641119
const document = node.ownerDocument;
10651120
const topmostParent = getAncestors( node )[ 0 ];
10661121

1067-
const treeWalker = document.createTreeWalker( topmostParent, NodeFilter.SHOW_TEXT );
1122+
const treeWalker = document.createTreeWalker( topmostParent, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, {
1123+
acceptNode( node ) {
1124+
if ( isText( node ) ) {
1125+
return NodeFilter.FILTER_ACCEPT;
1126+
}
1127+
1128+
if ( node.tagName == 'BR' ) {
1129+
return NodeFilter.FILTER_ACCEPT;
1130+
}
1131+
1132+
return NodeFilter.FILTER_SKIP;
1133+
}
1134+
} );
10681135

10691136
treeWalker.currentNode = node;
10701137

tests/view/domconverter/dom-to-view.js

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,77 @@ describe( 'DomConverter', () => {
316316
expect( viewDiv.getChild( 0 ).getChild( 0 ).data ).to.equal( 'foo' );
317317
} );
318318

319+
it( 'after a <br>', () => {
320+
const domP = createElement( document, 'p', {}, [
321+
document.createTextNode( 'foo' ),
322+
createElement( document, 'br' ),
323+
document.createTextNode( ' bar' )
324+
] );
325+
326+
const viewP = converter.domToView( domP );
327+
328+
expect( viewP.childCount ).to.equal( 3 );
329+
expect( viewP.getChild( 2 ).data ).to.equal( 'bar' );
330+
} );
331+
332+
it( 'after a <br> – two spaces', () => {
333+
const domP = createElement( document, 'p', {}, [
334+
document.createTextNode( 'foo' ),
335+
createElement( document, 'br' ),
336+
document.createTextNode( ' \u00a0bar' )
337+
] );
338+
339+
const viewP = converter.domToView( domP );
340+
341+
expect( viewP.childCount ).to.equal( 3 );
342+
expect( viewP.getChild( 2 ).data ).to.equal( ' bar' );
343+
} );
344+
345+
// This TC ensures that the algorithm stops on <br>.
346+
// If not, situations like https://github.com/ckeditor/ckeditor5/issues/1024#issuecomment-393109558 might occur.
347+
it( 'after a <br> – when <br> is preceeded with a nbsp', () => {
348+
const domP = createElement( document, 'p', {}, [
349+
document.createTextNode( 'foo\u00a0' ),
350+
createElement( document, 'br' ),
351+
document.createTextNode( ' bar' )
352+
] );
353+
354+
const viewP = converter.domToView( domP );
355+
356+
expect( viewP.childCount ).to.equal( 3 );
357+
expect( viewP.getChild( 2 ).data ).to.equal( 'bar' );
358+
} );
359+
360+
it( 'after a <br> – when text after that <br> is nested', () => {
361+
const domP = createElement( document, 'p', {}, [
362+
document.createTextNode( 'foo' ),
363+
createElement( document, 'br' ),
364+
createElement( document, 'b', {}, [
365+
document.createTextNode( ' bar' )
366+
] )
367+
] );
368+
369+
const viewP = converter.domToView( domP );
370+
371+
expect( viewP.childCount ).to.equal( 3 );
372+
expect( viewP.getChild( 2 ).getChild( 0 ).data ).to.equal( 'bar' );
373+
} );
374+
375+
it( 'between <br>s - trim only the left boundary', () => {
376+
const domP = createElement( document, 'p', {}, [
377+
document.createTextNode( 'x' ),
378+
createElement( document, 'br' ),
379+
document.createTextNode( ' foo ' ),
380+
createElement( document, 'br' ),
381+
document.createTextNode( 'x' )
382+
] );
383+
384+
const viewP = converter.domToView( domP );
385+
386+
expect( viewP.childCount ).to.equal( 5 );
387+
expect( viewP.getChild( 2 ).data ).to.equal( 'foo ' );
388+
} );
389+
319390
it( 'multiple consecutive whitespaces changed to one', () => {
320391
const domDiv = createElement( document, 'div', {}, [
321392
createElement( document, 'p', {}, [
@@ -521,6 +592,57 @@ describe( 'DomConverter', () => {
521592
expect( viewDiv.getChild( 0 ).getChild( 1 ).getChild( 0 ).data ).to.equal( '\u00a0' );
522593
} );
523594

595+
// While we render `X&nbsp;<br>X`, `X <br>X` is ok too – the space needs to be preserved.
596+
it( 'not before a <br>', () => {
597+
const domP = createElement( document, 'p', {}, [
598+
document.createTextNode( 'foo ' ),
599+
createElement( document, 'br' )
600+
] );
601+
602+
const viewP = converter.domToView( domP );
603+
604+
expect( viewP.childCount ).to.equal( 2 );
605+
expect( viewP.getChild( 0 ).data ).to.equal( 'foo ' );
606+
} );
607+
608+
it( 'not before a <br> (nbsp+space)', () => {
609+
const domP = createElement( document, 'p', {}, [
610+
document.createTextNode( 'foo\u00a0 ' ),
611+
createElement( document, 'br' )
612+
] );
613+
614+
const viewP = converter.domToView( domP );
615+
616+
expect( viewP.childCount ).to.equal( 2 );
617+
expect( viewP.getChild( 0 ).data ).to.equal( 'foo ' );
618+
} );
619+
620+
it( 'before a <br> (space+space=>space)', () => {
621+
const domP = createElement( document, 'p', {}, [
622+
document.createTextNode( 'foo ' ),
623+
createElement( document, 'br' )
624+
] );
625+
626+
const viewP = converter.domToView( domP );
627+
628+
expect( viewP.childCount ).to.equal( 2 );
629+
expect( viewP.getChild( 0 ).data ).to.equal( 'foo ' );
630+
} );
631+
632+
it( 'not before a <br> – when text before that <br> is nested', () => {
633+
const domP = createElement( document, 'p', {}, [
634+
createElement( document, 'b', {}, [
635+
document.createTextNode( 'foo ' )
636+
] ),
637+
createElement( document, 'br' )
638+
] );
639+
640+
const viewP = converter.domToView( domP );
641+
642+
expect( viewP.childCount ).to.equal( 2 );
643+
expect( viewP.getChild( 0 ).getChild( 0 ).data ).to.equal( 'foo ' );
644+
} );
645+
524646
//
525647
// See also whitespace-handling-integration.js.
526648
//

0 commit comments

Comments
 (0)