diff --git a/src/view/renderer.js b/src/view/renderer.js index ecbc5c153..d1b3cc875 100644 --- a/src/view/renderer.js +++ b/src/view/renderer.js @@ -18,20 +18,21 @@ import remove from '@ckeditor/ckeditor5-utils/src/dom/remove'; import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin'; import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror'; import isText from '@ckeditor/ckeditor5-utils/src/dom/istext'; +import isNode from '@ckeditor/ckeditor5-utils/src/dom/isnode'; import fastDiff from '@ckeditor/ckeditor5-utils/src/fastdiff'; /** - * Renderer updates DOM structure and selection, to make them a reflection of the view structure and selection. + * Renderer is responsible for updating the DOM structure and the DOM selection based on + * the {@link module:engine/view/renderer~Renderer#markToSync information about updated view nodes}. + * In other words, it renders the view to the DOM. * - * View nodes which may need to be rendered needs to be {@link module:engine/view/renderer~Renderer#markToSync marked}. - * Then, on {@link module:engine/view/renderer~Renderer#render render}, renderer compares view nodes with DOM nodes - * in order to check which ones really need to be refreshed. Finally, it creates DOM nodes from these view nodes, - * {@link module:engine/view/domconverter~DomConverter#bindElements binds} them and inserts into the DOM tree. + * Its main responsibility is to make only the necessary, minimal changes to the DOM. However, unlike in many + * virtual DOM implementations, the primary reason for doing minimal changes is not the performance but ensuring + * that native editing features such as text composition, autocompletion, spell checking, selection's x-index are + * affected as little as possible. * - * Every time {@link module:engine/view/renderer~Renderer#render render} is called, renderer additionally checks if - * {@link module:engine/view/renderer~Renderer#selection selection} needs update and updates it if so. - * - * Renderer uses {@link module:engine/view/domconverter~DomConverter} to transform and bind nodes. + * Renderer uses {@link module:engine/view/domconverter~DomConverter} to transform view nodes and positions + * to and from the DOM. */ export default class Renderer { /** @@ -89,14 +90,6 @@ export default class Renderer { */ this.selection = selection; - /** - * The text node in which the inline filler was rendered. - * - * @private - * @member {Text} - */ - this._inlineFiller = null; - /** * Indicates if the view document is focused and selection can be rendered. Selection will not be rendered if * this is set to `false`. @@ -105,6 +98,14 @@ export default class Renderer { */ this.isFocused = false; + /** + * The text node in which the inline filler was rendered. + * + * @private + * @member {Text} + */ + this._inlineFiller = null; + /** * DOM element containing fake selection. * @@ -115,7 +116,7 @@ export default class Renderer { } /** - * Mark node to be synchronized. + * Marks a view node to be updated in the DOM by {@link #render `render()`}. * * Note that only view nodes which parents have corresponding DOM elements need to be marked to be synchronized. * @@ -154,32 +155,24 @@ export default class Renderer { } /** - * Render method checks {@link #markedAttributes}, - * {@link #markedChildren} and {@link #markedTexts} and updates all - * nodes which need to be updated. Then it clears all three sets. Also, every time render is called it compares and - * if needed updates the selection. + * Renders all buffered changes ({@link #markedAttributes}, {@link #markedChildren} and {@link #markedTexts}) and + * the current view selection (if needed) to the DOM by applying a minimal set of changes to it. * - * Renderer tries not to break text composition (e.g. IME) and x-index of the selection, + * Renderer tries not to break the text composition (e.g. IME) and x-index of the selection, * so it does as little as it is needed to update the DOM. * - * For attributes it adds new attributes to DOM elements, updates values and removes - * attributes which do not exist in the view element. - * - * For text nodes it updates the text string if it is different. Note that if parent element is marked as an element - * which changed child list, text node update will not be done, because it may not be possible to - * {@link module:engine/view/domconverter~DomConverter#findCorrespondingDomText find a corresponding DOM text}. - * The change will be handled in the parent element. - * - * For elements, which child lists have changed, it calculates a {@link module:utils/diff~diff} and adds or removes children which have - * changed. - * - * Rendering also handles {@link module:engine/view/filler fillers}. Especially, it checks if the inline filler is needed - * at selection position and adds or removes it. To prevent breaking text composition inline filler will not be + * Renderer also handles {@link module:engine/view/filler fillers}. Especially, it checks if the inline filler is needed + * at the selection position and adds or removes it. To prevent breaking text composition inline filler will not be * removed as long selection is in the text node which needed it at first. */ render() { let inlineFillerPosition; + // Refresh mappings. + for ( const element of this.markedChildren ) { + this._updateChildrenMappings( element ); + } + // 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). @@ -243,6 +236,84 @@ export default class Renderer { this.markedChildren.clear(); } + /** + * Updates mappings of `viewElement`'s children. + * + * Children which were replaced in the view structure by similar elements (same tag name) are treated as 'replaced'. + * This means that we can update their mappings so the new view elements are mapped to the existing DOM elements. + * Thanks to that we won't need to re-render these elements completely. + * + * @private + * @param {module:engine/view/node~Node} viewElement The view element which children mappings will be updated. + */ + _updateChildrenMappings( viewElement ) { + const domElement = this.domConverter.mapViewToDom( viewElement ); + + if ( !domElement ) { + // If there is no `domElement` it means that it was already removed from DOM and there is no need to process it. + return; + } + + const diff = this._diffChildren( viewElement ); + const actions = this._findReplaceActions( diff.actions, diff.actualDomChildren, diff.expectedDomChildren ); + + if ( actions.indexOf( 'replace' ) !== -1 ) { + const counter = { equal: 0, insert: 0, delete: 0 }; + + for ( const action of actions ) { + if ( action === 'replace' ) { + const insertIndex = counter.equal + counter.insert; + const deleteIndex = counter.equal + counter.delete; + const viewChild = viewElement.getChild( insertIndex ); + + // The 'uiElement' is a special one and its children are not stored in a view (#799), + // so we cannot use it with replacing flow (since it uses view children during rendering + // which will always result in rendering empty element). + if ( viewChild && !viewChild.is( 'uiElement' ) ) { + this._updateElementMappings( viewChild, diff.actualDomChildren[ deleteIndex ] ); + } + + remove( diff.expectedDomChildren[ insertIndex ] ); + counter.equal++; + } else { + counter[ action ]++; + } + } + } + } + + /** + * Updates mappings of a given `viewElement`. + * + * @private + * @param {module:engine/view/node~Node} viewElement The view element which mappings will be updated. + * @param {Node} domElement The DOM element representing given view element. + */ + _updateElementMappings( viewElement, domElement ) { + // Because we replace new view element mapping with the existing one, the corresponding DOM element + // will not be rerendered. The new view element may have different attributes than the previous one. + // Since its corresponding DOM element will not be rerendered, new attributes will not be added + // to the DOM, so we need to mark it here to make sure its attributes gets updated. + // Such situations may happen if only new view element was added to `this.markedAttributes` + // or none of the elements were added (relying on 'this._updateChildren()' which by rerendering the element + // also rerenders its attributes). See #1427 for more detailed case study. + const newViewChild = this.domConverter.mapDomToView( domElement ); + + // It may also happen that 'newViewChild' mapping is not present since its parent mapping + // was already removed (the 'domConverter.unbindDomElement()' method also unbinds children + // mappings) so we also check for '!newViewChild'. + if ( !newViewChild || newViewChild && !newViewChild.isSimilar( viewElement ) ) { + this.markedAttributes.add( viewElement ); + } + + // Remap 'DomConverter' bindings. + this.domConverter.unbindDomElement( domElement ); + this.domConverter.bindElements( domElement, viewElement ); + + // View element may have children which needs to be updated, but are not marked, mark them to update. + this.markedChildren.add( viewElement ); + } + /** * Adds inline filler at given position. * @@ -443,10 +514,19 @@ export default class Renderer { * Checks if attributes list needs to be updated and possibly updates it. * * @private - * @param {module:engine/view/element~Element} viewElement View element to update. + * @param {module:engine/view/element~Element} viewElement The view element to update. */ _updateAttrs( viewElement ) { const domElement = this.domConverter.mapViewToDom( viewElement ); + + if ( !domElement ) { + // If there is no `domElement` it means that 'viewElement' is outdated as its mapping was updated + // in 'this._updateChildrenMappings()'. There is no need to process it as new view element which + // replaced old 'viewElement' mapping was also added to 'this.markedAttributes' + // in 'this._updateChildrenMappings()' so it will be processed separately. + return; + } + const domAttrKeys = Array.from( domElement.attributes ).map( attr => attr.name ); const viewAttrKeys = viewElement.getAttributeKeys(); @@ -473,33 +553,24 @@ export default class Renderer { * filler should be rendered. */ _updateChildren( viewElement, options ) { - const domConverter = this.domConverter; - const domElement = domConverter.mapViewToDom( viewElement ); + const domElement = this.domConverter.mapViewToDom( viewElement ); if ( !domElement ) { // If there is no `domElement` it means that it was already removed from DOM. - // There is no need to update it. It will be updated when re-inserted. + // There is no need to process it. It will be processed when re-inserted. return; } - const domDocument = domElement.ownerDocument; - const filler = options.inlineFillerPosition; - const actualDomChildren = domElement.childNodes; - const expectedDomChildren = Array.from( domConverter.viewChildrenToDom( viewElement, domDocument, { bind: true } ) ); - - // Inline filler element has to be created during children update because we need it to diff actual dom - // elements with expected dom elements. We need inline filler in expected dom elements so we won't re-render - // text node if it is not necessary. - if ( filler && filler.parent == viewElement ) { - this._addInlineFiller( domDocument, expectedDomChildren, filler.offset ); - } - - const actions = diff( actualDomChildren, expectedDomChildren, sameNodes ); + const inlineFillerPosition = options.inlineFillerPosition; + // As binding may change actual DOM children we need to do this before diffing. + const expectedDomChildren = this._getElementExpectedChildren( viewElement, domElement, { bind: true, inlineFillerPosition } ); + const diff = this._diffChildren( viewElement, inlineFillerPosition ); + const actualDomChildren = diff.actualDomChildren; let i = 0; const nodesToUnbind = new Set(); - for ( const action of actions ) { + for ( const action of diff.actions ) { if ( action === 'insert' ) { insertAt( domElement, i, expectedDomChildren[ i ] ); i++; @@ -508,7 +579,7 @@ export default class Renderer { remove( actualDomChildren[ i ] ); } else { // 'equal' // Force updating text nodes inside elements which did not change and do not need to be re-rendered (#1125). - this._markDescendantTextToSync( domConverter.domToView( expectedDomChildren[ i ] ) ); + this._markDescendantTextToSync( this.domConverter.domToView( expectedDomChildren[ i ] ) ); i++; } } @@ -521,25 +592,102 @@ export default class Renderer { this.domConverter.unbindDomElement( node ); } } + } - function sameNodes( actualDomChild, expectedDomChild ) { - // Elements. - if ( actualDomChild === expectedDomChild ) { - return true; - } - // Texts. - else if ( isText( actualDomChild ) && isText( expectedDomChild ) ) { - return actualDomChild.data === expectedDomChild.data; - } - // Block fillers. - else if ( isBlockFiller( actualDomChild, domConverter.blockFiller ) && - isBlockFiller( expectedDomChild, domConverter.blockFiller ) ) { - return true; - } + /** + * Compares `viewElement`'s actual and expected children and returns actions sequence which can be used to transform + * actual children into expected ones. + * + * @private + * @param {module:engine/view/node~Node} viewElement The view element which children will be compared. + * @param {module:engine/view/position~Position} [inlineFillerPosition=null] The position on which the inline + * filler should be rendered. + * @returns {Object|null} result + * @returns {Array.} result.actions List of actions based on {@link module:utils/diff~diff} function. + * @returns {Array.} result.actualDomChildren Current `viewElement`'s DOM children. + * @returns {Array.} result.expectedDomChildren Expected `viewElement`'s DOM children. + */ + _diffChildren( viewElement, inlineFillerPosition = null ) { + const domElement = this.domConverter.mapViewToDom( viewElement ); + const actualDomChildren = domElement.childNodes; + const expectedDomChildren = this._getElementExpectedChildren( viewElement, domElement, + { withChildren: false, inlineFillerPosition } ); + + return { + actions: diff( actualDomChildren, expectedDomChildren, sameNodes.bind( null, this.domConverter.blockFiller ) ), + actualDomChildren, + expectedDomChildren + }; + } - // Not matching types. - return false; + /** + * Returns expected DOM children for a given `viewElement`. + * + * @private + * @param {module:engine/view/node~Node} viewElement View element which children will be returned. + * @param {Node} domElement DOM representation of a given view element. + * @param {Object} options See {@link module:engine/view/domconverter~DomConverter#viewToDom} options parameter. + * @param {module:engine/view/position~Position} [options.inlineFillerPosition=null] The position on which + * the inline filler should be rendered. + * @returns {Array.} The `viewElement`'s expected children. + */ + _getElementExpectedChildren( viewElement, domElement, options ) { + const expectedDomChildren = Array.from( this.domConverter.viewChildrenToDom( viewElement, domElement.ownerDocument, options ) ); + const filler = options.inlineFillerPosition; + + // Inline filler element has to be created as it is present in a DOM, but not in a view. It is required + // during diffing so text nodes could be compared correctly and also during rendering to maintain + // proper order and indexes while updating the DOM. + if ( filler && filler.parent === viewElement ) { + this._addInlineFiller( domElement.ownerDocument, expectedDomChildren, filler.offset ); } + + return expectedDomChildren; + } + + /** + * Finds DOM nodes which were replaced with the similar nodes (same tag name) in the view. All nodes are compared + * within one `insert`/`delete` action group, for example: + * + * Actual DOM:

FooBarBazBax

+ * Expected DOM:

Bar123Baz456

+ * Input actions: [ insert, insert, delete, delete, equal, insert, delete ] + * Output actions: [ insert, replace, delete, equal, replace ] + * + * @private + * @param {Array.} actions Actions array which is result of {@link module:utils/diff~diff} function. + * @param {Array.} actualDom Actual DOM children + * @param {Array.} expectedDom Expected DOM children. + * @returns {Array.} Actions array modified with `replace` actions. + */ + _findReplaceActions( actions, actualDom, expectedDom ) { + // If there is no both 'insert' and 'delete' actions, no need to check for replaced elements. + if ( actions.indexOf( 'insert' ) === -1 || actions.indexOf( 'delete' ) === -1 ) { + return actions; + } + + let newActions = []; + let actualSlice = []; + let expectedSlice = []; + + const counter = { equal: 0, insert: 0, delete: 0 }; + + for ( const action of actions ) { + if ( action === 'insert' ) { + expectedSlice.push( expectedDom[ counter.equal + counter.insert ] ); + } else if ( action === 'delete' ) { + actualSlice.push( actualDom[ counter.equal + counter.delete ] ); + } else { // equal + newActions = newActions.concat( diff( actualSlice, expectedSlice, areSimilar ).map( x => x === 'equal' ? 'replace' : x ) ); + newActions.push( 'equal' ); + // Reset stored elements on 'equal'. + actualSlice = []; + expectedSlice = []; + } + counter[ action ]++; + } + + return newActions.concat( diff( actualSlice, expectedSlice, areSimilar ).map( x => x === 'equal' ? 'replace' : x ) ); } /** @@ -764,3 +912,47 @@ function _isEditable( element ) { return !parent || parent.getAttribute( 'contenteditable' ) == 'true'; } + +// Whether two DOM nodes should be considered as similar. +// Nodes are considered similar if they have the same tag name. +// +// @private +// @param {Node} node1 +// @param {Node} node2 +// @returns {Boolean} +function areSimilar( node1, node2 ) { + return isNode( node1 ) && isNode( node2 ) && + !isText( node1 ) && !isText( node2 ) && + node1.tagName.toLowerCase() === node2.tagName.toLowerCase(); +} + +// Whether two dom nodes should be considered as the same. +// Two nodes which are considered the same are: +// +// * Text nodes with the same text. +// * Element nodes represented by the same object. +// * Two block filler elements. +// +// @private +// @param {Function} blockFiller Block filler creator function, see {@link module:engine/view/domconverter~DomConverter#blockFiller}. +// @param {Node} node1 +// @param {Node} node2 +// @returns {Boolean} +function sameNodes( blockFiller, actualDomChild, expectedDomChild ) { + // Elements. + if ( actualDomChild === expectedDomChild ) { + return true; + } + // Texts. + else if ( isText( actualDomChild ) && isText( expectedDomChild ) ) { + return actualDomChild.data === expectedDomChild.data; + } + // Block fillers. + else if ( isBlockFiller( actualDomChild, blockFiller ) && + isBlockFiller( expectedDomChild, blockFiller ) ) { + return true; + } + + // Not matching types. + return false; +} diff --git a/tests/view/renderer.js b/tests/view/renderer.js index 49ea9db05..7e6600138 100644 --- a/tests/view/renderer.js +++ b/tests/view/renderer.js @@ -12,6 +12,7 @@ import ViewAttributeElement from '../../src/view/attributeelement'; import ViewText from '../../src/view/text'; import ViewRange from '../../src/view/range'; import ViewPosition from '../../src/view/position'; +import UIElement from '../../src/view/uielement'; import DocumentSelection from '../../src/view/documentselection'; import DomConverter from '../../src/view/domconverter'; import Renderer from '../../src/view/renderer'; @@ -2182,6 +2183,831 @@ describe( 'Renderer', () => { expect( selectionExtendSpy.notCalled ).to.true; } ); } ); + + // #1417 + describe( 'optimal rendering', () => { + it( 'should render inline element replacement (before text)', () => { + viewRoot._appendChild( parse( 'A1' ) ); + + renderer.markToSync( 'children', viewRoot ); + renderer.render(); + + expect( domRoot.innerHTML ).to.equal( '

A1

' ); + + const viewP = viewRoot.getChild( 0 ); + viewP._removeChildren( 0, 2 ); + viewP._insertChild( 0, parse( 'B2' ) ); + + const domI = domRoot.childNodes[ 0 ].childNodes[ 0 ]; + + renderer.markToSync( 'children', viewRoot ); + renderer.markToSync( 'children', viewP ); + renderer.render(); + + expect( domRoot.innerHTML ).to.equal( '

B2

' ); + expect( domI ).to.equal( domRoot.childNodes[ 0 ].childNodes[ 0 ] ); + } ); + + it( 'should render inline element replacement (after text)', () => { + viewRoot._appendChild( parse( '1A' ) ); + + renderer.markToSync( 'children', viewRoot ); + renderer.render(); + + expect( domRoot.innerHTML ).to.equal( '

1A

' ); + + const viewP = viewRoot.getChild( 0 ); + viewP._removeChildren( 0, 2 ); + viewP._insertChild( 0, parse( '2B' ) ); + + const domI = domRoot.childNodes[ 0 ].childNodes[ 1 ]; + + renderer.markToSync( 'children', viewRoot ); + renderer.markToSync( 'children', viewP ); + renderer.render(); + + expect( domRoot.innerHTML ).to.equal( '

2B

' ); + expect( domI ).to.equal( domRoot.childNodes[ 0 ].childNodes[ 1 ] ); + } ); + + it( 'should render inline element replacement (before text swapped order)', () => { + viewRoot._appendChild( parse( 'A1' ) ); + + renderer.markToSync( 'children', viewRoot ); + renderer.render(); + + expect( domRoot.innerHTML ).to.equal( '

A1

' ); + + const viewP = viewRoot.getChild( 0 ); + viewP._removeChildren( 0, 2 ); + viewP._insertChild( 0, parse( '2B' ) ); + + const domI = domRoot.childNodes[ 0 ].childNodes[ 0 ]; + + renderer.markToSync( 'children', viewRoot ); + renderer.markToSync( 'children', viewP ); + renderer.render(); + + expect( domRoot.innerHTML ).to.equal( '

2B

' ); + expect( domI ).to.equal( domRoot.childNodes[ 0 ].childNodes[ 1 ] ); + } ); + + it( 'should render inline element replacement (after text swapped order)', () => { + viewRoot._appendChild( parse( '1A' ) ); + + renderer.markToSync( 'children', viewRoot ); + renderer.render(); + + expect( domRoot.innerHTML ).to.equal( '

1A

' ); + + const viewP = viewRoot.getChild( 0 ); + viewP._removeChildren( 0, 2 ); + viewP._insertChild( 0, parse( 'B2' ) ); + + const domI = domRoot.childNodes[ 0 ].childNodes[ 1 ]; + + renderer.markToSync( 'children', viewRoot ); + renderer.markToSync( 'children', viewP ); + renderer.render(); + + expect( domRoot.innerHTML ).to.equal( '

B2

' ); + expect( domI ).to.equal( domRoot.childNodes[ 0 ].childNodes[ 0 ] ); + } ); + + it( 'should render single replacement in p group', () => { + const content = '' + + '1' + + '2' + + '3'; + + viewRoot._appendChild( parse( content ) ); + + renderer.markToSync( 'children', viewRoot ); + renderer.render(); + + expect( domRoot.innerHTML ).to.equal( '

1

2

3

' ); + + viewRoot._removeChildren( 1 ); + viewRoot._insertChild( 1, parse( '4' ) ); + + const domP = domRoot.childNodes[ 1 ]; + + renderer.markToSync( 'children', viewRoot ); + renderer.render(); + + expect( domRoot.innerHTML ).to.equal( '

1

4

3

' ); + expect( domP ).to.equal( domRoot.childNodes[ 1 ] ); + } ); + + it( 'should render replacement and insertion in p group', () => { + const content = '' + + '1A' + + '2B' + + '3C'; + + const replacement = '' + + 'D' + + '5E'; + + viewRoot._appendChild( parse( content ) ); + + renderer.markToSync( 'children', viewRoot ); + renderer.render(); + + expect( domRoot.innerHTML ).to.equal( '

1A

2B

3C

' ); + + viewRoot._removeChildren( 1 ); + viewRoot._insertChild( 1, parse( replacement ) ); + + const domP2 = domRoot.childNodes[ 1 ]; + const domP3 = domRoot.childNodes[ 2 ]; + + renderer.markToSync( 'children', viewRoot ); + renderer.render(); + + expect( domRoot.innerHTML ).to.equal( '

1A

D

5E

3C

' ); + expect( domP2 ).to.equal( domRoot.childNodes[ 1 ] ); + expect( domP3 ).to.equal( domRoot.childNodes[ 3 ] ); + } ); + + it( 'should render replacement and deletion in p group', () => { + const content = '' + + 'A1' + + 'B2' + + 'C3'; + + viewRoot._appendChild( parse( content ) ); + + renderer.markToSync( 'children', viewRoot ); + renderer.render(); + + expect( domRoot.innerHTML ).to.equal( '

A1

B2

C3

' ); + + viewRoot._removeChildren( 0, 2 ); + viewRoot._insertChild( 0, parse( '4' ) ); + + const domP0 = domRoot.childNodes[ 0 ]; + const domP2 = domRoot.childNodes[ 2 ]; + + renderer.markToSync( 'children', viewRoot ); + renderer.render(); + + expect( domRoot.innerHTML ).to.equal( '

4

C3

' ); + expect( domP0 ).to.equal( domRoot.childNodes[ 0 ] ); + expect( domP2 ).to.equal( domRoot.childNodes[ 1 ] ); + } ); + + it( 'should render multiple continuous replacement in p group', () => { + const content = '' + + '1' + + '2' + + '3' + + '4' + + '5'; + + viewRoot._appendChild( parse( content ) ); + + renderer.markToSync( 'children', viewRoot ); + renderer.render(); + + expect( domRoot.innerHTML ).to.equal( '

1

2

3

4

5

' ); + + viewRoot._removeChildren( 0, 3 ); + viewRoot._insertChild( 0, parse( '6A7' ) ); + + const domP1 = domRoot.childNodes[ 0 ]; + const domP2 = domRoot.childNodes[ 1 ]; + const domP4 = domRoot.childNodes[ 3 ]; + const domP5 = domRoot.childNodes[ 4 ]; + + renderer.markToSync( 'children', viewRoot ); + renderer.render(); + + expect( domRoot.innerHTML ).to.equal( '

6A

7

4

5

' ); + expect( domP1 ).to.equal( domRoot.childNodes[ 0 ] ); + expect( domP2 ).to.equal( domRoot.childNodes[ 1 ] ); + expect( domP4 ).to.equal( domRoot.childNodes[ 2 ] ); + expect( domP5 ).to.equal( domRoot.childNodes[ 3 ] ); + } ); + + it( 'should render multiple replacement in p group', () => { + const content = '' + + '1' + + '2' + + '3' + + '4' + + '5'; + + viewRoot._appendChild( parse( content ) ); + + renderer.markToSync( 'children', viewRoot ); + renderer.render(); + + expect( domRoot.innerHTML ).to.equal( '

1

2

3

4

5

' ); + + viewRoot._removeChildren( 4 ); + viewRoot._removeChildren( 1, 2 ); + viewRoot._insertChild( 2, parse( '6' ) ); + viewRoot._insertChild( 1, parse( 'A7' ) ); + + const domP1 = domRoot.childNodes[ 0 ]; + const domP2 = domRoot.childNodes[ 1 ]; + const domP4 = domRoot.childNodes[ 3 ]; + const domP5 = domRoot.childNodes[ 4 ]; + + renderer.markToSync( 'children', viewRoot ); + renderer.render(); + + expect( domRoot.innerHTML ).to.equal( '

1

A7

4

6

' ); + expect( domP1 ).to.equal( domRoot.childNodes[ 0 ] ); + expect( domP2 ).to.equal( domRoot.childNodes[ 1 ] ); + expect( domP4 ).to.equal( domRoot.childNodes[ 2 ] ); + expect( domP5 ).to.equal( domRoot.childNodes[ 3 ] ); + } ); + + it( 'should not rerender DOM when view replaced with the same structure', () => { + const content = '' + + 'He' + + 'ading 1' + + '' + + 'Ph ' + + 'Bold' + + '' + + 'Link' + + '' + + '' + + '' + + '' + + 'Quoted item 1' + + '' + + ''; + + viewRoot._appendChild( parse( content ) ); + + renderer.markToSync( 'children', viewRoot ); + renderer.render(); + + expect( domRoot.innerHTML ).to.equal( '

Heading 1

Ph Bold' + + 'Link

  • ' + + 'Quoted item 1
' ); + + viewRoot._removeChildren( 0, viewRoot.childCount ); + viewRoot._appendChild( parse( content ) ); + + const viewH = viewRoot.getChild( 0 ); + const viewP = viewRoot.getChild( 1 ); + const viewQ = viewRoot.getChild( 2 ); + + const domH = domRoot.childNodes[ 0 ]; + const domHI = domH.childNodes[ 1 ]; + const domP = domRoot.childNodes[ 1 ]; + const domPT = domP.childNodes[ 0 ]; + const domPABI = domP.childNodes[ 2 ].childNodes[ 0 ].childNodes[ 1 ]; + const domQ = domRoot.childNodes[ 2 ]; + const domQULB = domQ.childNodes[ 0 ].childNodes[ 0 ].childNodes[ 1 ]; + + renderer.markToSync( 'children', viewRoot ); + renderer.render(); + + // Assert content. + expect( domRoot.innerHTML ).to.equal( '

Heading 1

Ph Bold' + + 'Link

  • ' + + 'Quoted item 1
' ); + + // Assert if DOM elements did not change. + expect( domRoot.childNodes[ 0 ] ).to.equal( domH ); + expect( domH.childNodes[ 1 ] ).to.equal( domHI ); + expect( domRoot.childNodes[ 1 ] ).to.equal( domP ); + expect( domP.childNodes[ 0 ] ).to.equal( domPT ); + expect( domP.childNodes[ 2 ].childNodes[ 0 ].childNodes[ 1 ] ).to.equal( domPABI ); + expect( domRoot.childNodes[ 2 ] ).to.equal( domQ ); + expect( domQ.childNodes[ 0 ].childNodes[ 0 ].childNodes[ 1 ] ).to.equal( domQULB ); + + // Assert mappings. + const mappings = renderer.domConverter._domToViewMapping; + expect( mappings.get( domH ) ).to.equal( viewH ); + expect( mappings.get( domHI ) ).to.equal( viewH.getChild( 1 ) ); + expect( mappings.get( domP ) ).to.equal( viewP ); + expect( mappings.get( domPABI ) ).to.equal( viewP.getChild( 2 ).getChild( 0 ).getChild( 1 ) ); + expect( mappings.get( domQ ) ).to.equal( viewQ ); + expect( mappings.get( domQULB ) ).to.equal( viewQ.getChild( 0 ).getChild( 0 ).getChild( 1 ) ); + } ); + + it( 'should not rerender DOM when view replaced with the same structure without first node', () => { + const content = '' + + 'He' + + 'ading 1' + + '' + + 'Ph ' + + 'Bold' + + '' + + 'Link' + + '' + + '' + + '' + + '' + + 'Quoted item 1' + + '' + + ''; + + const content2 = '' + + 'Ph ' + + 'Bold' + + '' + + 'Link' + + '' + + '' + + '' + + '' + + 'Quoted item 1' + + '' + + ''; + + viewRoot._appendChild( parse( content ) ); + + renderer.markToSync( 'children', viewRoot ); + renderer.render(); + + expect( domRoot.innerHTML ).to.equal( '

Heading 1

Ph Bold' + + 'Link

  • ' + + 'Quoted item 1
' ); + + viewRoot._removeChildren( 0, viewRoot.childCount ); + viewRoot._appendChild( parse( content2 ) ); + + const viewP = viewRoot.getChild( 0 ); + const viewQ = viewRoot.getChild( 1 ); + + const domP = domRoot.childNodes[ 1 ]; + const domPT = domP.childNodes[ 0 ]; + const domPABI = domP.childNodes[ 2 ].childNodes[ 0 ].childNodes[ 1 ]; + const domQ = domRoot.childNodes[ 2 ]; + const domQULB = domQ.childNodes[ 0 ].childNodes[ 0 ].childNodes[ 1 ]; + + renderer.markToSync( 'children', viewRoot ); + renderer.render(); + + // Assert content. + expect( domRoot.innerHTML ).to.equal( '

Ph Bold' + + 'Link

  • ' + + 'Quoted item 1
' ); + + // Assert if DOM elements did not change. + expect( domRoot.childNodes[ 0 ] ).to.equal( domP ); + expect( domP.childNodes[ 0 ] ).to.equal( domPT ); + expect( domP.childNodes[ 2 ].childNodes[ 0 ].childNodes[ 1 ] ).to.equal( domPABI ); + expect( domRoot.childNodes[ 1 ] ).to.equal( domQ ); + expect( domQ.childNodes[ 0 ].childNodes[ 0 ].childNodes[ 1 ] ).to.equal( domQULB ); + + // Assert mappings. + const mappings = renderer.domConverter._domToViewMapping; + expect( mappings.get( domP ) ).to.equal( viewP ); + expect( mappings.get( domPABI ) ).to.equal( viewP.getChild( 2 ).getChild( 0 ).getChild( 1 ) ); + expect( mappings.get( domQ ) ).to.equal( viewQ ); + expect( mappings.get( domQULB ) ).to.equal( viewQ.getChild( 0 ).getChild( 0 ).getChild( 1 ) ); + } ); + + it( 'should not rerender DOM when typing inside empty inline element', () => { + const view = parse( 'Foo Bar' ); + + viewRoot._appendChild( view ); + + renderer.markToSync( 'children', viewRoot ); + renderer.render(); + + expect( domRoot.innerHTML ).to.equal( '

Foo Bar

' ); + + const viewP = viewRoot.getChild( 0 ); + viewP._removeChildren( 1 ); + viewP._insertChild( 1, parse( 'a' ) ); + + const domP = domRoot.childNodes[ 0 ]; + const domText = domP.childNodes[ 0 ]; + const domB = domP.childNodes[ 1 ]; + + domB.innerHTML = 'a'; + + renderer.markToSync( 'children', viewRoot ); + renderer.markToSync( 'children', viewRoot.getChild( 0 ) ); + renderer.render(); + + // Assert content. + expect( domRoot.innerHTML ).to.equal( '

Foo Bara

' ); + + // Assert if DOM elements did not change. + expect( domRoot.childNodes[ 0 ] ).to.equal( domP ); + expect( domRoot.childNodes[ 0 ].childNodes[ 0 ] ).to.equal( domText ); + expect( domRoot.childNodes[ 0 ].childNodes[ 1 ] ).to.equal( domB ); + + // Assert mappings. + const mappings = renderer.domConverter._domToViewMapping; + expect( mappings.get( domP ) ).to.equal( viewP ); + expect( mappings.get( domB ) ).to.equal( viewP.getChild( 1 ) ); + } ); + + it( 'should handle complex view duplication', () => { + const content = '' + + '' + + '' + + 'Quoted item 1' + + 'Item 2' + + '' + + 'Link' + + '' + + '' + + ''; + + const expected = '' + + '
' + + '
    ' + + '
  • Quoted item 1
  • ' + + '
  • Item 2
  • ' + + '
  • Link
  • ' + + '
' + + '
'; + + viewRoot._appendChild( parse( content ) ); + + renderer.markToSync( 'children', viewRoot ); + renderer.render(); + + expect( domRoot.innerHTML ).to.equal( expected ); + + viewRoot._removeChildren( 0, viewRoot.childCount ); + viewRoot._appendChild( parse( content + content ) ); + + const domBQ = domRoot.childNodes[ 0 ]; + const domUL = domBQ.childNodes[ 0 ]; + const domLI1 = domUL.childNodes[ 0 ]; + const domLI2 = domUL.childNodes[ 1 ]; + const domLI3 = domUL.childNodes[ 2 ]; + + renderer.markToSync( 'children', viewRoot ); + renderer.render(); + + // Assert content. + expect( domRoot.innerHTML ).to.equal( expected + expected ); + + // Assert if DOM elements did not change. + expect( domRoot.childNodes[ 0 ] ).to.equal( domBQ ); + expect( domBQ.childNodes[ 0 ] ).to.equal( domUL ); + expect( domUL.childNodes[ 0 ] ).to.equal( domLI1 ); + expect( domUL.childNodes[ 1 ] ).to.equal( domLI2 ); + expect( domUL.childNodes[ 2 ] ).to.equal( domLI3 ); + + // Assert mappings. + const domMappings = renderer.domConverter._domToViewMapping; + expect( domMappings.get( domBQ ) ).to.equal( viewRoot.getChild( 0 ) ); + expect( domMappings.get( domUL ) ).to.equal( viewRoot.getChild( 0 ).getChild( 0 ) ); + expect( domMappings.get( domLI1 ) ).to.equal( viewRoot.getChild( 0 ).getChild( 0 ).getChild( 0 ) ); + expect( domMappings.get( domLI2 ) ).to.equal( viewRoot.getChild( 0 ).getChild( 0 ).getChild( 1 ) ); + expect( domMappings.get( domLI3 ) ).to.equal( viewRoot.getChild( 0 ).getChild( 0 ).getChild( 2 ) ); + + // Assert if new view elements are bind to new DOM elements. + const viewMappings = renderer.domConverter._domToViewMapping; + expect( viewMappings.get( viewRoot.getChild( 1 ) ) ).not.equal( domBQ ); + expect( viewMappings.get( viewRoot.getChild( 1 ).getChild( 0 ) ) ).not.equal( domUL ); + expect( viewMappings.get( viewRoot.getChild( 1 ).getChild( 0 ).getChild( 0 ) ) ).not.equal( domLI1 ); + expect( viewMappings.get( viewRoot.getChild( 1 ).getChild( 0 ).getChild( 1 ) ) ).not.equal( domLI2 ); + expect( viewMappings.get( viewRoot.getChild( 1 ).getChild( 0 ).getChild( 2 ) ) ).not.equal( domLI3 ); + } ); + + it( 'should handle complex view replace', () => { + const content = '' + + 'He' + + 'ading 1' + + '' + + 'Ph ' + + 'Bold' + + '' + + 'Link' + + '' + + '' + + '' + + '' + + 'Quoted item 1' + + 'Item 2' + + '' + + 'Link' + + '' + + '' + + ''; + + const replacement = '' + + '' + + '1' + + 'A' + + '' + + '' + + '' + + 'Li' + + 'nk' + + '' + + '' + + '' + + 'Heading ' + + '1' + + '' + + '' + + 'Heading 2' + + '' + + '' + + 'Heading' + + ' 3' + + '' + + '' + + 'Foo Bar Baz' + + '' + + '' + + '' + + 'Item ' + + '1' + + '' + + '' + + 'Item' + + ' 2' + + '' + + ''; + + viewRoot._appendChild( parse( content ) ); + + renderer.markToSync( 'children', viewRoot ); + renderer.render(); + + expect( domRoot.innerHTML ).to.equal( '' + + '

Heading 1

' + + '

Ph BoldLink

' + + '
    ' + + '
  • Quoted item 1
  • ' + + '
  • Item 2
  • Link
  • ' + + '
' ); + + viewRoot._removeChildren( 0, viewRoot.childCount ); + viewRoot._appendChild( parse( replacement ) ); + + renderer.markToSync( 'children', viewRoot ); + renderer.render(); + + // Here we just check if new DOM structure was properly rendered. + expect( domRoot.innerHTML ).to.equal( '' + + '

1A

' + + '

Link

' + + '

Heading 1

' + + '

Heading 2

' + + '

Heading 3

' + + '
Foo Bar Baz
' + + '' ); + } ); + + it( 'should handle br elements while refreshing bindings', () => { + const expected = `

Foo Bar

${ BR_FILLER( document ).outerHTML }

`; // eslint-disable-line new-cap + + viewRoot._appendChild( parse( 'Foo Bar' ) ); + + renderer.markToSync( 'children', viewRoot ); + renderer.render(); + + expect( domRoot.innerHTML ).to.equal( expected ); + + // There is a case in Safari that during accent panel navigation on macOS our 'BR_FILLER' is replaced with + // just '
' element which breaks accent composition in an empty paragraph. It also throws an error while + // refreshing mappings in a renderer. Simulate such behaviour (#1354). + domRoot.childNodes[ 1 ].innerHTML = '
'; + + viewRoot._removeChildren( 1 ); + viewRoot._insertChild( 1, parse( '' ) ); + + renderer.markToSync( 'children', viewRoot ); + renderer.render(); + + expect( domRoot.innerHTML ).to.equal( expected ); + } ); + + it( 'should handle list to paragraph conversion', () => { + const view = '' + + '' + + 'Item 1' + + '' + + 'Item 2' + + '' + + '' + + '' + + 'Paragraph' + + '' + + 'Item 3' + + '' + + 'Item 4' + + '' + + '' + + ''; + + viewRoot._appendChild( parse( view ) ); + + renderer.markToSync( 'children', viewRoot ); + renderer.render(); + + expect( domRoot.innerHTML ).to.equal( + '
  1. Item 1
    1. Item 2

Paragraph

  1. Item 3
    1. Item 4
' ); + + const viewOL1 = viewRoot.getChild( 0 ); + viewOL1.getChild( 0 )._removeChildren( 1 ); + viewRoot._removeChildren( 2 ); + viewRoot._insertChild( 1, parse( 'Item 2' ) ); + viewRoot._insertChild( 3, parse( 'Item 3' ) ); + viewRoot._insertChild( 4, parse( 'Item 4' ) ); + + const domOL1 = domRoot.childNodes[ 0 ]; + const domOL2 = domRoot.childNodes[ 2 ]; + const domP = domRoot.childNodes[ 1 ]; + + renderer.markToSync( 'children', viewRoot ); + renderer.markToSync( 'children', viewOL1.getChild( 0 ) ); + renderer.render(); + + expect( domRoot.innerHTML ).to.equal( + '
  1. Item 1

Item 2

Paragraph

Item 3

  1. Item 4
' ); + + expect( domRoot.childNodes[ 0 ] ).to.equal( domOL1 ); + expect( domRoot.childNodes[ 2 ] ).to.equal( domP ); + expect( domRoot.childNodes[ 4 ] ).to.equal( domOL2 ); + } ); + + it( 'should handle attributes change in replaced elements', () => { + const view = '' + + '' + + 'Item 1' + + '' + + 'Paragraph ' + + 'Link' + + '' + + 'BarBaz'; + + viewRoot._appendChild( parse( view ) ); + + renderer.markToSync( 'children', viewRoot ); + renderer.render(); + + expect( normalizeHtml( domRoot.innerHTML ) ).to.equal( normalizeHtml( + '
  1. Item 1
' + + '

Paragraph Link

BarBaz

' ) ); + + const viewOL = viewRoot.getChild( 0 ); + viewOL._removeChildren( 0 ); + viewOL._insertChild( 0, parse( 'Item 1' ) ); + + const viewP1 = viewRoot.getChild( 1 ); + viewP1._removeChildren( 1 ); + viewP1._insertChild( 1, parse( 'Foo' ) ); + + viewRoot._removeChildren( 2 ); + viewRoot._insertChild( 2, parse( 'Bar' ) ); + + renderer.markToSync( 'children', viewRoot ); + renderer.markToSync( 'children', viewOL ); + renderer.markToSync( 'children', viewP1 ); + renderer.render(); + + expect( normalizeHtml( domRoot.innerHTML ) ).to.equal( normalizeHtml( + '
  1. Item 1
' + + '

Paragraph Foo

Bar

' ) ); + } ); + + it( 'should handle classes change in replaced elements', () => { + const view = '' + + '' + + 'Item 1' + + '' + + 'BarBaz'; + + viewRoot._appendChild( parse( view ) ); + + renderer.markToSync( 'children', viewRoot ); + renderer.render(); + + expect( normalizeHtml( domRoot.innerHTML ) ).to.equal( normalizeHtml( + '
  1. Item 1

BarBaz

' ) ); + + const viewOL = viewRoot.getChild( 0 ); + const oldViewLI = viewOL.getChild( 0 ); + viewOL._removeChildren( 0 ); + viewOL._insertChild( 0, parse( 'Item 1' ) ); + + const oldViewP = viewRoot.getChild( 1 ); + viewRoot._removeChildren( 1 ); + viewRoot._insertChild( 1, parse( 'Foo' ) ); + + renderer.markToSync( 'attributes', oldViewLI ); + renderer.markToSync( 'attributes', oldViewP ); + renderer.markToSync( 'children', viewRoot ); + renderer.markToSync( 'children', viewOL ); + renderer.render(); + + expect( normalizeHtml( domRoot.innerHTML ) ).to.equal( normalizeHtml( + '
  1. Item 1

Foo

' ) ); + } ); + + it( 'should handle styles change in replaced elements', () => { + const view = '' + + '' + + 'Foo' + + 'Bar ' + + '' + + 'Baz' + + ' Bax' + + '' + + ''; + + viewRoot._appendChild( parse( view ) ); + + renderer.markToSync( 'children', viewRoot ); + renderer.render(); + + expect( normalizeHtml( domRoot.innerHTML ) ).to.equal( normalizeHtml( + '
  1. Foo
  2. ' + + '
  3. Bar Baz Bax
' ) ); + + const viewOL = viewRoot.getChild( 0 ); + const viewLI1 = viewOL.getChild( 0 ); + const viewLI2 = viewOL.getChild( 1 ); + + viewLI1._removeStyle( 'font-weight' ); + viewLI1._setStyle( { color: '#FFF' } ); + viewLI2._setStyle( { 'font-weight': 'bold' } ); + + viewLI2._removeChildren( 0, viewLI2.childCount ); + viewLI2._insertChild( 0, parse( 'Ba1 Ba3 ' + + 'Ba2' ) ); + + renderer.markToSync( 'attributes', viewLI1 ); + renderer.markToSync( 'attributes', viewLI2 ); + renderer.markToSync( 'children', viewRoot ); + renderer.markToSync( 'children', viewLI2 ); + renderer.render(); + + expect( normalizeHtml( domRoot.innerHTML ) ).to.equal( normalizeHtml( + '
  1. Foo
  2. ' + + '
  3. Ba1 Ba3 ' + + 'Ba2
' ) ); + } ); + + it( 'should handle uiElement rendering', () => { + function createUIElement( id, text ) { + const element = new UIElement( 'span' ); + element.render = function( domDocument ) { + const domElement = this.toDomElement( domDocument ); + domElement.innerText = `${ text }`; + return domElement; + }; + + return element; + } + + const ui1 = createUIElement( 'id1', 'UI1' ); + const ui2 = createUIElement( 'id2', 'UI2' ); + const viewP = new ViewContainerElement( 'p', null, [ new ViewText( 'Foo ' ), ui1, new ViewText( 'Bar' ) ] ); + viewRoot._appendChild( viewP ); + + renderer.markToSync( 'children', viewRoot ); + renderer.render(); + + expect( normalizeHtml( domRoot.innerHTML ) ).to.equal( normalizeHtml( + '

Foo UI1Bar

' ) ); + + viewP._removeChildren( 0, viewP.childCount ); + viewP._insertChild( 0, [ new ViewText( 'Foo' ), ui2, new ViewText( ' Bar' ) ] ); + + renderer.markToSync( 'children', viewRoot ); + renderer.markToSync( 'children', viewP ); + renderer.render(); + + expect( normalizeHtml( domRoot.innerHTML ) ).to.equal( normalizeHtml( + '

FooUI2 Bar

' ) ); + } ); + + it( 'should handle linking entire content', () => { + viewRoot._appendChild( parse( 'FooBar' ) ); + + renderer.markToSync( 'children', viewRoot ); + renderer.render(); + + expect( domRoot.innerHTML ).to.equal( '

FooBar

' ); + + const viewP = viewRoot.getChild( 0 ); + // While linking, the existing DOM children are moved to a new `a` element during binding + // inside the `domConverter.viewToDom()` method. It happens because of a modified view structure + // where view elements were moved to a newly created link view element. + const viewA = new ViewAttributeElement( 'a', { href: '#href' }, [ new ViewText( 'Foo' ), viewP.getChild( 1 ) ] ); + + viewP._removeChildren( 0, viewP.childCount ); + viewP._insertChild( 0, viewA ); + + renderer.markToSync( 'children', viewRoot ); + renderer.markToSync( 'children', viewP ); + renderer.render(); + + expect( domRoot.innerHTML ).to.equal( '

FooBar

' ); + } ); + } ); } ); describe( '#922', () => {