/**
* @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
* For licensing, see LICENSE.md.
*/
/* globals Range, document, window */
/* bender-tags: view, browser-only */
import ViewElement from '/ckeditor5/engine/view/element.js';
import ViewText from '/ckeditor5/engine/view/text.js';
import ViewRange from '/ckeditor5/engine/view/range.js';
import Selection from '/ckeditor5/engine/view/selection.js';
import DomConverter from '/ckeditor5/engine/view/domconverter.js';
import Renderer from '/ckeditor5/engine/view/renderer.js';
import CKEditorError from '/ckeditor5/utils/ckeditorerror.js';
import { parse } from '/tests/engine/_utils/view.js';
import { INLINE_FILLER, INLINE_FILLER_LENGTH, isBlockFiller, BR_FILLER } from '/ckeditor5/engine/view/filler.js';
import testUtils from '/tests/core/_utils/utils.js';
import createElement from '/ckeditor5/utils/dom/createelement.js';
testUtils.createSinonSandbox();
describe( 'Renderer', () => {
let selection, domConverter, renderer;
beforeEach( () => {
selection = new Selection();
domConverter = new DomConverter();
renderer = new Renderer( domConverter, selection );
renderer.domDocuments.add( document );
} );
describe( 'markToSync', () => {
let viewRoot;
beforeEach( () => {
viewRoot = new ViewElement( 'p' );
const domRoot = document.createElement( 'p' );
domConverter.bindElements( domRoot, viewRoot );
viewRoot.appendChildren( new ViewText( 'foo' ) );
renderer.markedTexts.clear();
renderer.markedAttributes.clear();
renderer.markedChildren.clear();
} );
it( 'should mark attributes which need update', () => {
viewRoot.setAttribute( 'class', 'foo' );
renderer.markToSync( 'attributes', viewRoot );
expect( renderer.markedAttributes.has( viewRoot ) ).to.be.true;
} );
it( 'should mark children which need update', () => {
viewRoot.appendChildren( new ViewText( 'foo' ) );
renderer.markToSync( 'children', viewRoot );
expect( renderer.markedChildren.has( viewRoot ) ).to.be.true;
} );
it( 'should not mark children if element has no corresponding node', () => {
// Overwrite viewRoot with node without coresponding DOM node.
viewRoot = new ViewElement( 'p' );
viewRoot.appendChildren( new ViewText( 'foo' ) );
renderer.markToSync( 'children', viewRoot );
expect( renderer.markedTexts.has( viewRoot ) ).to.be.false;
} );
it( 'should mark text which need update', () => {
const viewText = new ViewText( 'foo' );
viewRoot.appendChildren( viewText );
viewText.data = 'bar';
renderer.markToSync( 'text', viewText );
expect( renderer.markedTexts.has( viewText ) ).to.be.true;
} );
it( 'should not mark text if parent has no corresponding node', () => {
const viewText = new ViewText( 'foo' );
// Overwrite viewRoot with node without coresponding DOM node.
viewRoot = new ViewElement( 'p' );
viewRoot.appendChildren( viewText );
viewText.data = 'bar';
renderer.markToSync( 'text', viewText );
expect( renderer.markedTexts.has( viewText ) ).to.be.false;
} );
it( 'should throw if the type is unknown', () => {
expect( () => {
renderer.markToSync( 'UNKNOWN', viewRoot );
} ).to.throw( CKEditorError, /^view-renderer-unknown-type/ );
} );
} );
describe( 'render', () => {
let viewRoot, domRoot, selectionEditable;
beforeEach( () => {
viewRoot = new ViewElement( 'div' );
domRoot = document.createElement( 'div' );
document.body.appendChild( domRoot );
domConverter.bindElements( domRoot, viewRoot );
renderer.markedTexts.clear();
renderer.markedAttributes.clear();
renderer.markedChildren.clear();
selection.removeAllRanges();
selectionEditable = viewRoot;
renderer.isFocused = true;
// Fake selection editable - it is needed to render selection properly.
Object.defineProperty( selection, 'editableElement', {
get: function() {
return selectionEditable;
}
} );
} );
it( 'should update attributes', () => {
viewRoot.setAttribute( 'class', 'foo' );
renderer.markToSync( 'attributes', viewRoot );
renderer.render();
expect( domRoot.getAttribute( 'class' ) ).to.equal( 'foo' );
expect( renderer.markedAttributes.size ).to.equal( 0 );
} );
it( 'should remove attributes', () => {
viewRoot.setAttribute( 'class', 'foo' );
domRoot.setAttribute( 'id', 'bar' );
domRoot.setAttribute( 'class', 'bar' );
renderer.markToSync( 'attributes', viewRoot );
renderer.render();
expect( domRoot.getAttribute( 'class' ) ).to.equal( 'foo' );
expect( domRoot.getAttribute( 'id' ) ).to.be.not.ok;
expect( renderer.markedAttributes.size ).to.equal( 0 );
} );
it( 'should add children', () => {
viewRoot.appendChildren( new ViewText( 'foo' ) );
renderer.markToSync( 'children', viewRoot );
renderer.render();
expect( domRoot.childNodes.length ).to.equal( 1 );
expect( domRoot.childNodes[ 0 ].data ).to.equal( 'foo' );
expect( renderer.markedChildren.size ).to.equal( 0 );
} );
it( 'should remove children', () => {
viewRoot.appendChildren( new ViewText( 'foo' ) );
renderer.markToSync( 'children', viewRoot );
renderer.render();
expect( domRoot.childNodes.length ).to.equal( 1 );
expect( domRoot.childNodes[ 0 ].data ).to.equal( 'foo' );
viewRoot.removeChildren( 0, 1 );
renderer.markToSync( 'children', viewRoot );
renderer.render();
expect( domRoot.childNodes.length ).to.equal( 0 );
expect( renderer.markedChildren.size ).to.equal( 0 );
} );
it( 'should update text', () => {
const viewText = new ViewText( 'foo' );
viewRoot.appendChildren( viewText );
renderer.markToSync( 'children', viewRoot );
renderer.render();
expect( domRoot.childNodes.length ).to.equal( 1 );
expect( domRoot.childNodes[ 0 ].data ).to.equal( 'foo' );
viewText.data = 'bar';
renderer.markToSync( 'text', viewText );
renderer.render();
expect( domRoot.childNodes.length ).to.equal( 1 );
expect( domRoot.childNodes[ 0 ].data ).to.equal( 'bar' );
expect( renderer.markedTexts.size ).to.equal( 0 );
} );
it( 'should not update text parent child list changed', () => {
const viewImg = new ViewElement( 'img' );
const viewText = new ViewText( 'foo' );
viewRoot.appendChildren( [ viewImg, viewText ] );
renderer.markToSync( 'children', viewRoot );
renderer.markToSync( 'text', viewText );
renderer.render();
expect( domRoot.childNodes.length ).to.equal( 2 );
expect( domRoot.childNodes[ 0 ].tagName ).to.equal( 'IMG' );
expect( domRoot.childNodes[ 1 ].data ).to.equal( 'foo' );
} );
it( 'should not change text if it is the same during text rendering', () => {
const viewText = new ViewText( 'foo' );
viewRoot.appendChildren( viewText );
renderer.markToSync( 'children', viewRoot );
renderer.render();
// This should not be changed during the render.
const domText = domRoot.childNodes[ 0 ];
renderer.markToSync( 'text', viewText );
renderer.render();
expect( domRoot.childNodes.length ).to.equal( 1 );
expect( domRoot.childNodes[ 0 ] ).to.equal( domText );
} );
it( 'should not change text if it is the same during children rendering', () => {
const viewText = new ViewText( 'foo' );
viewRoot.appendChildren( viewText );
renderer.markToSync( 'children', viewRoot );
renderer.render();
// This should not be changed during the render.
const domText = domRoot.childNodes[ 0 ];
renderer.markToSync( 'children', viewRoot );
renderer.render();
expect( domRoot.childNodes.length ).to.equal( 1 );
expect( domRoot.childNodes[ 0 ] ).to.equal( domText );
} );
it( 'should not change element if it is the same', () => {
const viewImg = new ViewElement( 'img' );
viewRoot.appendChildren( viewImg );
// This should not be changed during the render.
const domImg = document.createElement( 'img' );
domRoot.appendChild( domImg );
domConverter.bindElements( domImg, viewImg );
renderer.markToSync( 'children', viewRoot );
renderer.render();
expect( domRoot.childNodes.length ).to.equal( 1 );
expect( domRoot.childNodes[ 0 ] ).to.equal( domImg );
} );
it( 'should change element if it is different', () => {
const viewImg = new ViewElement( 'img' );
viewRoot.appendChildren( viewImg );
renderer.markToSync( 'children', viewRoot );
renderer.render();
const viewP = new ViewElement( 'p' );
viewRoot.removeChildren( 0, 1 );
viewRoot.appendChildren( viewP );
renderer.markToSync( 'children', viewRoot );
renderer.render();
expect( domRoot.childNodes.length ).to.equal( 1 );
expect( domRoot.childNodes[ 0 ].tagName ).to.equal( 'P' );
} );
it( 'should not care about filler if there is no DOM', () => {
const { view: viewP, selection: newSelection } = parse(
'
foo[]bar
', () => { const domSelection = document.getSelection(); // Step 1:foo"FILLER{}"
const { view: viewP, selection: newSelection } = parse( 'foo{}
selection.removeAllRanges(); selection.addRange( ViewRange.createFromParentsAndOffsets( viewP.getChild( 0 ), 3, viewP.getChild( 0 ), 3 ) ); renderer.render(); expect( domP.childNodes.length ).to.equal( 3 ); expect( domP.childNodes[ 0 ].data ).to.equal( 'foo' ); expect( domP.childNodes[ 2 ].data ).to.equal( 'bar' ); expect( domP.childNodes[ 1 ].tagName.toLowerCase() ).to.equal( 'b' ); expect( domP.childNodes[ 1 ].childNodes.length ).to.equal( 0 ); expect( domSelection.rangeCount ).to.equal( 1 ); expect( domSelection.getRangeAt( 0 ).startContainer ).to.equal( domP.childNodes[ 0 ] ); expect( domSelection.getRangeAt( 0 ).startOffset ).to.equal( 3 ); expect( domSelection.getRangeAt( 0 ).collapsed ).to.be.true; // Step 4: No mutation on second render renderer.markToSync( 'children', viewRoot ); renderer.markToSync( 'children', viewP ); renderAndExpectNoChanges( renderer, domRoot ); } ); it( 'should add and remove inline filler in case[]foo
', () => { const domSelection = document.getSelection(); // Step 1:"FILLER{}"foo
const { view: viewP, selection: newSelection } = parse( '{}foo
selection.removeAllRanges(); selection.addRange( ViewRange.createFromParentsAndOffsets( viewP.getChild( 0 ).getChild( 0 ), 0, viewP.getChild( 0 ).getChild( 0 ), 0 ) ); renderer.render(); expect( domP.childNodes.length ).to.equal( 1 ); expect( domP.childNodes[ 0 ].tagName.toLowerCase() ).to.equal( 'b' ); expect( domP.childNodes[ 0 ].childNodes.length ).to.equal( 1 ); expect( domP.childNodes[ 0 ].childNodes[ 0 ].data ).to.equal( 'foo' ); expect( domSelection.rangeCount ).to.equal( 1 ); expect( domSelection.getRangeAt( 0 ).startContainer ).to.equal( domP.childNodes[ 0 ] .childNodes[ 0 ] ); expect( domSelection.getRangeAt( 0 ).startOffset ).to.equal( 0 ); expect( domSelection.getRangeAt( 0 ).collapsed ).to.be.true; // Step 4: No mutation on second render renderer.markToSync( 'children', viewP ); renderAndExpectNoChanges( renderer, domRoot ); } ); it( 'should add and remove inline filler in casefoo[]
', () => { const domSelection = document.getSelection(); // Step 1:"FILLER{}"foo
const { view: viewP, selection: newSelection } = parse( 'foo{}
selection.removeAllRanges(); selection.addRange( ViewRange.createFromParentsAndOffsets( viewP.getChild( 0 ).getChild( 0 ), 3, viewP.getChild( 0 ).getChild( 0 ), 3 ) ); renderer.render(); expect( domP.childNodes.length ).to.equal( 1 ); expect( domP.childNodes[ 0 ].tagName.toLowerCase() ).to.equal( 'b' ); expect( domP.childNodes[ 0 ].childNodes.length ).to.equal( 1 ); expect( domP.childNodes[ 0 ].childNodes[ 0 ].data ).to.equal( 'foo' ); expect( domSelection.rangeCount ).to.equal( 1 ); expect( domSelection.getRangeAt( 0 ).startContainer ).to.equal( domP.childNodes[ 0 ] .childNodes[ 0 ] ); expect( domSelection.getRangeAt( 0 ).startOffset ).to.equal( 3 ); expect( domSelection.getRangeAt( 0 ).collapsed ).to.be.true; // Step 4: No mutation on second render renderer.markToSync( 'children', viewP ); renderAndExpectNoChanges( renderer, domRoot ); } ); it( 'should add and remove inline filler in casefoo[]bar
', () => { const domSelection = document.getSelection(); const { view: viewP, selection: newSelection } = parse( 'foo"FILLER{}"
const { view: viewP, selection: newSelection } = parse( 'foo"FILLER{}"
selection.removeAllRanges(); const viewI = viewP.getChild( 2 ); selection.addRange( ViewRange.createFromParentsAndOffsets( viewI, 0, viewI, 0 ) ); renderer.render(); expect( domP.childNodes.length ).to.equal( 3 ); expect( domP.childNodes[ 0 ].data ).to.equal( 'foo' ); expect( domP.childNodes[ 1 ].tagName.toLowerCase() ).to.equal( 'b' ); expect( domP.childNodes[ 1 ].childNodes.length ).to.equal( 0 ); expect( domP.childNodes[ 2 ].tagName.toLowerCase() ).to.equal( 'i' ); expect( domP.childNodes[ 2 ].childNodes.length ).to.equal( 1 ); expect( domP.childNodes[ 2 ].childNodes[ 0 ].data ).to.equal( INLINE_FILLER ); } ); it( 'should remove filler when text is added and selection removed', () => { // Step 1:foo"FILLER{}"
const { view: viewP, selection: newSelection } = parse( 'x{}
domP.removeChild( domP.childNodes[ 0 ] ); domP.appendChild( document.createTextNode( 'x' ) ); domSelection.removeAllRanges(); const domRange = new Range(); domRange.setStart( domP.childNodes[ 0 ], 1 ); domRange.collapse( true ); domSelection.addRange( domRange ); const viewText = new ViewText( 'x' ); viewP.appendChildren( viewText ); selection.removeAllRanges(); selection.addRange( ViewRange.createFromParentsAndOffsets( viewText, 1, viewText, 1 ) ); renderer.markToSync( 'children', viewP ); renderAndExpectNoChanges( renderer, domRoot ); } ); it( 'should handle typing in empty block, render if needed', () => { const domSelection = document.getSelection(); const { view: viewP, selection: newSelection } = parse( 'x{}
const viewText = new ViewText( 'x' ); viewP.appendChildren( viewText ); selection.removeAllRanges(); selection.addRange( ViewRange.createFromParentsAndOffsets( viewText, 1, viewText, 1 ) ); renderer.markToSync( 'children', viewP ); renderer.render(); expect( domP.childNodes.length ).to.equal( 1 ); expect( domP.childNodes[ 0 ].data ).to.equal( 'x' ); expect( domSelection.rangeCount ).to.equal( 1 ); expect( domSelection.getRangeAt( 0 ).startContainer ).to.equal( domP.childNodes[ 0 ] ); expect( domSelection.getRangeAt( 0 ).startOffset ).to.equal( 1 ); expect( domSelection.getRangeAt( 0 ).collapsed ).to.be.true; } ); it( 'should handle removing last character', () => { const domSelection = document.getSelection(); const { view: viewP, selection: newSelection } = parse( '{}
domP.removeChild( domP.childNodes[ 0 ] ); domP.appendChild( BR_FILLER( document ) ); domSelection.removeAllRanges(); const domRange = new Range(); domRange.setStart( domP.childNodes[ 0 ], 0 ); domRange.collapse( true ); domSelection.addRange( domRange ); viewP.removeChildren( 0 ); selection.removeAllRanges(); selection.addRange( ViewRange.createFromParentsAndOffsets( viewP, 0, viewP, 0 ) ); renderer.markToSync( 'children', viewP ); renderAndExpectNoChanges( renderer, domRoot ); } ); it( 'should handle typing in empty attribute, do nothing if changes are already applied', () => { const domSelection = document.getSelection(); const { view: viewP, selection: newSelection } = parse( 'xfoo
domB.childNodes[ 0 ].data += 'x'; domSelection.removeAllRanges(); const domRange = new Range(); domRange.setStart( domB.childNodes[ 0 ], INLINE_FILLER_LENGTH + 1 ); domRange.collapse( true ); domSelection.addRange( domRange ); const viewText = new ViewText( 'x' ); viewB.appendChildren( viewText ); selection.removeAllRanges(); selection.addRange( ViewRange.createFromParentsAndOffsets( viewText, 1, viewText, 1 ) ); renderer.markToSync( 'children', viewP ); renderAndExpectNoChanges( renderer, domRoot ); } ); it( 'should handle typing in empty attribute as a children change, render if needed', () => { const domSelection = document.getSelection(); const { view: viewP, selection: newSelection } = parse( 'xfoo
const viewText = new ViewText( 'x' ); viewB.appendChildren( viewText ); selection.removeAllRanges(); selection.addRange( ViewRange.createFromParentsAndOffsets( viewText, 1, viewText, 1 ) ); renderer.markToSync( 'children', viewB ); renderer.render(); expect( domB.childNodes.length ).to.equal( 1 ); expect( domB.childNodes[ 0 ].data ).to.equal( INLINE_FILLER + 'x' ); expect( domSelection.rangeCount ).to.equal( 1 ); expect( domSelection.getRangeAt( 0 ).startContainer ).to.equal( domB.childNodes[ 0 ] ); expect( domSelection.getRangeAt( 0 ).startOffset ).to.equal( INLINE_FILLER_LENGTH + 1 ); expect( domSelection.getRangeAt( 0 ).collapsed ).to.be.true; } ); it( 'should handle typing in empty attribute as a text change, render if needed', () => { const domSelection = document.getSelection(); const { view: viewP, selection: newSelection } = parse( 'xfoo
const viewText = new ViewText( 'x' ); viewB.appendChildren( viewText ); selection.removeAllRanges(); selection.addRange( ViewRange.createFromParentsAndOffsets( viewText, 1, viewText, 1 ) ); renderer.markToSync( 'text', viewText ); renderer.render(); expect( domB.childNodes.length ).to.equal( 1 ); expect( domB.childNodes[ 0 ].data ).to.equal( INLINE_FILLER + 'x' ); expect( domSelection.rangeCount ).to.equal( 1 ); expect( domSelection.getRangeAt( 0 ).startContainer ).to.equal( domB.childNodes[ 0 ] ); expect( domSelection.getRangeAt( 0 ).startOffset ).to.equal( INLINE_FILLER_LENGTH + 1 ); expect( domSelection.getRangeAt( 0 ).collapsed ).to.be.true; } ); it( 'should handle not collapsed range', () => { const domSelection = document.getSelection(); const { view: viewP, selection: newSelection } = parse( '