diff --git a/package.json b/package.json index bf49578ff..8a5182796 100644 --- a/package.json +++ b/package.json @@ -44,8 +44,8 @@ "lint-staged": "^7.0.0" }, "engines": { - "node": ">=6.9.0", - "npm": ">=3.0.0" + "node": ">=8.0.0", + "npm": ">=5.7.1" }, "author": "CKSource (http://cksource.com/)", "license": "GPL-2.0-or-later", diff --git a/src/controller/datacontroller.js b/src/controller/datacontroller.js index 14efa575a..67d502f0c 100644 --- a/src/controller/datacontroller.js +++ b/src/controller/datacontroller.js @@ -115,17 +115,23 @@ export default class DataController { * Returns the model's data converted by downcast dispatchers attached to {@link #downcastDispatcher} and * formatted by the {@link #processor data processor}. * - * @param {String} [rootName='main'] Root name. + * @param {Object} [options] + * @param {String} [options.rootName='main'] Root name. + * @param {String} [options.trim='empty'] Whether returned data should be trimmed. This option is set to `empty` by default, + * which means whenever editor content is considered empty, an empty string will be returned. To turn off trimming completely + * use `'none'`. In such cases exact content will be returned (for example `

 

` for an empty editor). * @returns {String} Output data. */ - get( rootName = 'main' ) { + get( options ) { + const { rootName = 'main', trim = 'empty' } = options || {}; + if ( !this._checkIfRootsExists( [ rootName ] ) ) { /** * Cannot get data from a non-existing root. This error is thrown when {@link #get DataController#get() method} * is called with non-existent root name. For example, if there is an editor instance with only `main` root, * calling {@link #get} like: * - * data.get( 'root2' ); + * data.get( 'root2' ); * * will throw this error. * @@ -134,8 +140,13 @@ export default class DataController { throw new CKEditorError( 'datacontroller-get-non-existent-root: Attempting to get data from a non-existing root.' ); } - // Get model range. - return this.stringify( this.model.document.getRoot( rootName ) ); + const root = this.model.document.getRoot( rootName ); + + if ( trim === 'empty' && !this.model.hasContent( root, { ignoreWhitespaces: true } ) ) { + return ''; + } + + return this.stringify( root ); } /** diff --git a/src/model/model.js b/src/model/model.js index ef2f1f9dc..500de84ae 100644 --- a/src/model/model.js +++ b/src/model/model.js @@ -446,26 +446,49 @@ export default class Model { /** * Checks whether the given {@link module:engine/model/range~Range range} or - * {@link module:engine/model/element~Element element} - * has any content. + * {@link module:engine/model/element~Element element} has any meaningful content. * - * Content is any text node or element which is registered in the {@link module:engine/model/schema~Schema schema}. + * Meaningful content is: + * + * * any text node (`options.ignoreWhitespaces` allows controlling whether this text node must also contain + * any non-whitespace characters), + * * or any {@link module:engine/model/schema~Schema#isObject object element}, + * * or any {@link module:engine/model/markercollection~Marker marker} which + * {@link module:engine/model/markercollection~Marker#_affectsData affects data}. + * + * This means that a range containing an empty `` is not considered to have a meaningful content. + * However, a range containing an `` (which would normally be marked in the schema as an object element) + * is considered non-empty. * * @param {module:engine/model/range~Range|module:engine/model/element~Element} rangeOrElement Range or element to check. + * @param {Object} [options] + * @param {Boolean} [options.ignoreWhitespaces] Whether text node with whitespaces only should be considered empty. * @returns {Boolean} */ - hasContent( rangeOrElement ) { - if ( rangeOrElement instanceof ModelElement ) { - rangeOrElement = ModelRange._createIn( rangeOrElement ); - } + hasContent( rangeOrElement, options ) { + const range = rangeOrElement instanceof ModelElement ? ModelRange._createIn( rangeOrElement ) : rangeOrElement; - if ( rangeOrElement.isCollapsed ) { + if ( range.isCollapsed ) { return false; } - for ( const item of rangeOrElement.getItems() ) { - // Remember, `TreeWalker` returns always `textProxy` nodes. - if ( item.is( 'textProxy' ) || this.schema.isObject( item ) ) { + // Check if there are any markers which affects data in this given range. + for ( const intersectingMarker of this.markers.getMarkersIntersectingRange( range ) ) { + if ( intersectingMarker.affectsData ) { + return true; + } + } + + const { ignoreWhitespaces = false } = options || {}; + + for ( const item of range.getItems() ) { + if ( item.is( 'textProxy' ) ) { + if ( !ignoreWhitespaces ) { + return true; + } else if ( item.data.search( /\S/ ) !== -1 ) { + return true; + } + } else if ( this.schema.isObject( item ) ) { return true; } } diff --git a/src/model/operation/transform.js b/src/model/operation/transform.js index a07f35723..b0b918fc6 100644 --- a/src/model/operation/transform.js +++ b/src/model/operation/transform.js @@ -306,7 +306,7 @@ export function transformSets( operationsA, operationsB, options ) { originalOperationsBCount: operationsB.length }; - const contextFactory = new ContextFactory( options.document, options.useRelations ); + const contextFactory = new ContextFactory( options.document, options.useRelations, options.forceWeakRemove ); contextFactory.setOriginalOperations( operationsA ); contextFactory.setOriginalOperations( operationsB ); @@ -386,13 +386,17 @@ class ContextFactory { // @param {module:engine/model/document~Document} document Document which the operations change. // @param {Boolean} useRelations Whether during transformation relations should be used (used during undo for // better conflict resolution). - constructor( document, useRelations ) { + // @param {Boolean} [forceWeakRemove=false] If set to `false`, remove operation will be always stronger than move operation, + // so the removed nodes won't end up back in the document root. When set to `true`, context data will be used. + constructor( document, useRelations, forceWeakRemove = false ) { // `model.History` instance which information about undone operations will be taken from. this._history = document.history; // Whether additional context should be used. this._useRelations = useRelations; + this._forceWeakRemove = !!forceWeakRemove; + // For each operation that is created during transformation process, we keep a reference to the original operation // which it comes from. The original operation works as a kind of "identifier". Every contextual information // gathered during transformation that we want to save for given operation, is actually saved for the original operation. @@ -583,7 +587,8 @@ class ContextFactory { aWasUndone: this._wasUndone( opA ), bWasUndone: this._wasUndone( opB ), abRelation: this._useRelations ? this._getRelation( opA, opB ) : null, - baRelation: this._useRelations ? this._getRelation( opB, opA ) : null + baRelation: this._useRelations ? this._getRelation( opB, opA ) : null, + forceWeakRemove: this._forceWeakRemove }; } @@ -1313,7 +1318,7 @@ setTransformation( MergeOperation, MoveOperation, ( a, b, context ) => { // const removedRange = Range._createFromPositionAndShift( b.sourcePosition, b.howMany ); - if ( b.type == 'remove' && !context.bWasUndone ) { + if ( b.type == 'remove' && !context.bWasUndone && !context.forceWeakRemove ) { if ( a.deletionPosition.hasSameParentAs( b.sourcePosition ) && removedRange.containsPosition( a.sourcePosition ) ) { return [ new NoOperation( 0 ) ]; } @@ -1596,9 +1601,9 @@ setTransformation( MoveOperation, MoveOperation, ( a, b, context ) => { // // If only one of operations is a remove operation, we force remove operation to be the "stronger" one // to provide more expected results. - if ( a.type == 'remove' && b.type != 'remove' && !context.aWasUndone ) { + if ( a.type == 'remove' && b.type != 'remove' && !context.aWasUndone && !context.forceWeakRemove ) { aIsStrong = true; - } else if ( a.type != 'remove' && b.type == 'remove' && !context.bWasUndone ) { + } else if ( a.type != 'remove' && b.type == 'remove' && !context.bWasUndone && !context.forceWeakRemove ) { aIsStrong = false; } @@ -1768,7 +1773,7 @@ setTransformation( MoveOperation, SplitOperation, ( a, b, context ) => { if ( b.graveyardPosition ) { const movesGraveyardElement = moveRange.start.isEqual( b.graveyardPosition ) || moveRange.containsPosition( b.graveyardPosition ); - if ( a.howMany > 1 && movesGraveyardElement ) { + if ( a.howMany > 1 && movesGraveyardElement && !context.aWasUndone ) { ranges.push( Range._createFromPositionAndShift( b.insertionPosition, 1 ) ); } } @@ -1780,7 +1785,7 @@ setTransformation( MoveOperation, MergeOperation, ( a, b, context ) => { const movedRange = Range._createFromPositionAndShift( a.sourcePosition, a.howMany ); if ( b.deletionPosition.hasSameParentAs( a.sourcePosition ) && movedRange.containsPosition( b.sourcePosition ) ) { - if ( a.type == 'remove' ) { + if ( a.type == 'remove' && !context.forceWeakRemove ) { // Case 1: // // The element to remove got merged. @@ -1794,21 +1799,22 @@ setTransformation( MoveOperation, MergeOperation, ( a, b, context ) => { const results = []; let gyMoveSource = b.graveyardPosition.clone(); - let splitNodesMoveSource = b.targetPosition.clone(); + let splitNodesMoveSource = b.targetPosition._getTransformedByMergeOperation( b ); if ( a.howMany > 1 ) { results.push( new MoveOperation( a.sourcePosition, a.howMany - 1, a.targetPosition, 0 ) ); - gyMoveSource = gyMoveSource._getTransformedByInsertion( a.targetPosition, a.howMany - 1 ); + + gyMoveSource = gyMoveSource._getTransformedByMove( a.sourcePosition, a.targetPosition, a.howMany - 1 ); splitNodesMoveSource = splitNodesMoveSource._getTransformedByMove( a.sourcePosition, a.targetPosition, a.howMany - 1 ); } const gyMoveTarget = b.deletionPosition._getCombined( a.sourcePosition, a.targetPosition ); const gyMove = new MoveOperation( gyMoveSource, 1, gyMoveTarget, 0 ); - const targetPositionPath = gyMove.getMovedRangeStart().path.slice(); - targetPositionPath.push( 0 ); + const splitNodesMoveTargetPath = gyMove.getMovedRangeStart().path.slice(); + splitNodesMoveTargetPath.push( 0 ); - const splitNodesMoveTarget = new Position( gyMove.targetPosition.root, targetPositionPath ); + const splitNodesMoveTarget = new Position( gyMove.targetPosition.root, splitNodesMoveTargetPath ); splitNodesMoveSource = splitNodesMoveSource._getTransformedByMove( gyMoveSource, gyMoveTarget, 1 ); const splitNodesMove = new MoveOperation( splitNodesMoveSource, b.howMany, splitNodesMoveTarget, 0 ); @@ -2052,7 +2058,9 @@ setTransformation( SplitOperation, MoveOperation, ( a, b, context ) => { // is already moved to the correct position, we need to only move the nodes after the split position. // This will be done by `MoveOperation` instead of `SplitOperation`. // - if ( rangeToMove.start.isEqual( a.graveyardPosition ) || rangeToMove.containsPosition( a.graveyardPosition ) ) { + const gyElementMoved = rangeToMove.start.isEqual( a.graveyardPosition ) || rangeToMove.containsPosition( a.graveyardPosition ); + + if ( !context.bWasUndone && gyElementMoved ) { const sourcePosition = a.splitPosition._getTransformedByMoveOperation( b ); const newParentPosition = a.graveyardPosition._getTransformedByMoveOperation( b ); diff --git a/tests/controller/datacontroller.js b/tests/controller/datacontroller.js index fa8cc004c..e366e3ca0 100644 --- a/tests/controller/datacontroller.js +++ b/tests/controller/datacontroller.js @@ -346,15 +346,26 @@ describe( 'DataController', () => { downcastHelpers.elementToElement( { model: 'paragraph', view: 'p' } ); expect( data.get() ).to.equal( '

foo

' ); + expect( data.get( { trim: 'empty' } ) ).to.equal( '

foo

' ); } ); - it( 'should get empty paragraph', () => { + it( 'should trim empty paragraph by default', () => { schema.register( 'paragraph', { inheritAllFrom: '$block' } ); setData( model, '' ); downcastHelpers.elementToElement( { model: 'paragraph', view: 'p' } ); - expect( data.get() ).to.equal( '

 

' ); + expect( data.get() ).to.equal( '' ); + expect( data.get( { trim: 'empty' } ) ).to.equal( '' ); + } ); + + it( 'should get empty paragraph (with trim=none)', () => { + schema.register( 'paragraph', { inheritAllFrom: '$block' } ); + setData( model, '' ); + + downcastHelpers.elementToElement( { model: 'paragraph', view: 'p' } ); + + expect( data.get( { trim: 'none' } ) ).to.equal( '

 

' ); } ); it( 'should get two paragraphs', () => { @@ -364,6 +375,7 @@ describe( 'DataController', () => { downcastHelpers.elementToElement( { model: 'paragraph', view: 'p' } ); expect( data.get() ).to.equal( '

foo

bar

' ); + expect( data.get( { trim: 'empty' } ) ).to.equal( '

foo

bar

' ); } ); it( 'should get text directly in root', () => { @@ -371,6 +383,7 @@ describe( 'DataController', () => { setData( model, 'foo' ); expect( data.get() ).to.equal( 'foo' ); + expect( data.get( { trim: 'empty' } ) ).to.equal( 'foo' ); } ); it( 'should get paragraphs without bold', () => { @@ -380,6 +393,7 @@ describe( 'DataController', () => { downcastHelpers.elementToElement( { model: 'paragraph', view: 'p' } ); expect( data.get() ).to.equal( '

foobar

' ); + expect( data.get( { trim: 'empty' } ) ).to.equal( '

foobar

' ); } ); it( 'should get paragraphs with bold', () => { @@ -390,6 +404,7 @@ describe( 'DataController', () => { downcastHelpers.attributeToElement( { model: 'bold', view: 'strong' } ); expect( data.get() ).to.equal( '

foobar

' ); + expect( data.get( { trim: 'empty' } ) ).to.equal( '

foobar

' ); } ); it( 'should get root name as a parameter', () => { @@ -403,13 +418,13 @@ describe( 'DataController', () => { downcastHelpers.attributeToElement( { model: 'bold', view: 'strong' } ); expect( data.get() ).to.equal( '

foo

' ); - expect( data.get( 'main' ) ).to.equal( '

foo

' ); - expect( data.get( 'title' ) ).to.equal( 'Bar' ); + expect( data.get( { rootName: 'main' } ) ).to.equal( '

foo

' ); + expect( data.get( { rootName: 'title' } ) ).to.equal( 'Bar' ); } ); it( 'should throw an error when non-existent root is used', () => { expect( () => { - data.get( 'nonexistent' ); + data.get( { rootName: 'nonexistent' } ); } ).to.throw( CKEditorError, 'datacontroller-get-non-existent-root: Attempting to get data from a non-existing root.' diff --git a/tests/model/model.js b/tests/model/model.js index e1b8bea74..a0ef069bc 100644 --- a/tests/model/model.js +++ b/tests/model/model.js @@ -500,6 +500,9 @@ describe( 'Model', () => { isObject: true } ); schema.extend( 'image', { allowIn: 'div' } ); + schema.register( 'listItem', { + inheritAllFrom: '$block' + } ); setData( model, @@ -510,7 +513,10 @@ describe( 'Model', () => { 'foo' + '
' + '' + - '
' + '' + + '' + + '' + + '' ); root = model.document.getRoot(); @@ -522,6 +528,34 @@ describe( 'Model', () => { expect( model.hasContent( pFoo ) ).to.be.true; } ); + it( 'should return true if given element has text node (ignoreWhitespaces)', () => { + const pFoo = root.getChild( 1 ); + + expect( model.hasContent( pFoo, { ignoreWhitespaces: true } ) ).to.be.true; + } ); + + it( 'should return true if given element has text node containing spaces only', () => { + const pEmpty = root.getChild( 0 ).getChild( 0 ); + + model.enqueueChange( 'transparent', writer => { + // Model `setData()` method trims whitespaces so use writer here to insert whitespace only text. + writer.insertText( ' ', pEmpty, 'end' ); + } ); + + expect( model.hasContent( pEmpty ) ).to.be.true; + } ); + + it( 'should false true if given element has text node containing spaces only (ignoreWhitespaces)', () => { + const pEmpty = root.getChild( 0 ).getChild( 0 ); + + model.enqueueChange( 'transparent', writer => { + // Model `setData()` method trims whitespaces so use writer here to insert whitespace only text. + writer.insertText( ' ', pEmpty, 'end' ); + } ); + + expect( model.hasContent( pEmpty, { ignoreWhitespaces: true } ) ).to.be.false; + } ); + it( 'should return true if given element has element that is an object', () => { const divImg = root.getChild( 2 ); @@ -571,6 +605,113 @@ describe( 'Model', () => { expect( model.hasContent( range ) ).to.be.false; } ); + + it( 'should return false for empty list items', () => { + const range = new ModelRange( ModelPosition._createAt( root, 3 ), ModelPosition._createAt( root, 6 ) ); + + expect( model.hasContent( range ) ).to.be.false; + } ); + + it( 'should return false for empty element with marker (usingOperation=false, affectsData=false)', () => { + const pEmpty = root.getChild( 0 ).getChild( 0 ); + + model.enqueueChange( 'transparent', writer => { + // Insert marker. + const range = ModelRange._createIn( pEmpty ); + writer.addMarker( 'comment1', { range, usingOperation: false, affectsData: false } ); + } ); + + expect( model.hasContent( pEmpty ) ).to.be.false; + expect( model.hasContent( pEmpty, { ignoreWhitespaces: true } ) ).to.be.false; + } ); + + it( 'should return false for empty element with marker (usingOperation=true, affectsData=false)', () => { + const pEmpty = root.getChild( 0 ).getChild( 0 ); + + model.enqueueChange( 'transparent', writer => { + // Insert marker. + const range = ModelRange._createIn( pEmpty ); + writer.addMarker( 'comment1', { range, usingOperation: true, affectsData: false } ); + } ); + + expect( model.hasContent( pEmpty ) ).to.be.false; + expect( model.hasContent( pEmpty, { ignoreWhitespaces: true } ) ).to.be.false; + } ); + + it( 'should return false (ignoreWhitespaces) for empty text with marker (usingOperation=false, affectsData=false)', () => { + const pEmpty = root.getChild( 0 ).getChild( 0 ); + + model.enqueueChange( 'transparent', writer => { + // Insert empty text. + const text = writer.createText( ' ', { bold: true } ); + writer.append( text, pEmpty ); + + // Insert marker. + const range = ModelRange._createIn( pEmpty ); + writer.addMarker( 'comment1', { range, usingOperation: false, affectsData: false } ); + } ); + + expect( model.hasContent( pEmpty, { ignoreWhitespaces: true } ) ).to.be.false; + } ); + + it( 'should return true for empty text with marker (usingOperation=false, affectsData=false)', () => { + const pEmpty = root.getChild( 0 ).getChild( 0 ); + + model.enqueueChange( 'transparent', writer => { + // Insert empty text. + const text = writer.createText( ' ', { bold: true } ); + writer.append( text, pEmpty ); + + // Insert marker. + const range = ModelRange._createIn( pEmpty ); + writer.addMarker( 'comment1', { range, usingOperation: false, affectsData: false } ); + } ); + + expect( model.hasContent( pEmpty ) ).to.be.true; + } ); + + it( 'should return false for empty element with marker (usingOperation=false, affectsData=true)', () => { + const pEmpty = root.getChild( 0 ).getChild( 0 ); + + model.enqueueChange( 'transparent', writer => { + // Insert marker. + const range = ModelRange._createIn( pEmpty ); + writer.addMarker( 'comment1', { range, usingOperation: false, affectsData: true } ); + } ); + + expect( model.hasContent( pEmpty ) ).to.be.false; + expect( model.hasContent( pEmpty, { ignoreWhitespaces: true } ) ).to.be.false; + } ); + + it( 'should return false for empty element with marker (usingOperation=true, affectsData=true)', () => { + const pEmpty = root.getChild( 0 ).getChild( 0 ); + + model.enqueueChange( 'transparent', writer => { + // Insert marker. + const range = ModelRange._createIn( pEmpty ); + writer.addMarker( 'comment1', { range, usingOperation: true, affectsData: true } ); + } ); + + expect( model.hasContent( pEmpty ) ).to.be.false; + expect( model.hasContent( pEmpty, { ignoreWhitespaces: true } ) ).to.be.false; + } ); + + it( 'should return true (ignoreWhitespaces) for empty text with marker (usingOperation=false, affectsData=true)', () => { + const pEmpty = root.getChild( 0 ).getChild( 0 ); + + model.enqueueChange( 'transparent', writer => { + // Insert empty text. + const text = writer.createText( ' ', { bold: true } ); + writer.append( text, pEmpty ); + + // Insert marker. + const range = ModelRange._createIn( pEmpty ); + writer.addMarker( 'comment1', { range, usingOperation: false, affectsData: true } ); + } ); + + expect( model.hasContent( pEmpty ) ).to.be.true; + expect( model.hasContent( pEmpty, { ignoreWhitespaces: true } ) ).to.be.true; + } ); } ); describe( 'createPositionFromPath()', () => { diff --git a/tests/model/operation/transform/undo.js b/tests/model/operation/transform/undo.js index 2e03fcea7..4870a309a 100644 --- a/tests/model/operation/transform/undo.js +++ b/tests/model/operation/transform/undo.js @@ -5,6 +5,9 @@ import { Client, expectClients, clearBuffer } from './utils.js'; +import Element from '../../../../src/model/element'; +import Text from '../../../../src/model/text'; + describe( 'transform', () => { let john; @@ -574,4 +577,39 @@ describe( 'transform', () => { expectClients( 'XY' ); } ); + + // https://github.com/ckeditor/ckeditor5/issues/1385 + it( 'paste inside paste + undo, undo + redo, redo', () => { + const model = john.editor.model; + + john.setData( '[]' ); + + model.insertContent( getPastedContent() ); + + john.setSelection( [ 0, 3 ] ); + + model.insertContent( getPastedContent() ); + + expectClients( 'FooFoobarbar' ); + + john.undo(); + + expectClients( 'Foobar' ); + + john.undo(); + + expectClients( '' ); + + john.redo(); + + expectClients( 'Foobar' ); + + john.redo(); + + expectClients( 'FooFoobarbar' ); + + function getPastedContent() { + return new Element( 'heading1', null, new Text( 'Foobar' ) ); + } + } ); } ); diff --git a/tests/view/renderer.js b/tests/view/renderer.js index 47e1d41c0..8113f0743 100644 --- a/tests/view/renderer.js +++ b/tests/view/renderer.js @@ -3198,6 +3198,111 @@ describe( 'Renderer', () => { expect( domRoot.innerHTML ).to.equal( '

1

2

' ); } ); } ); + + // ckeditor/ckeditor5-utils#269 + // The expected times has a significant margin above the usual execution time (which is around 40-50% + // of the expected time) because it depends on the browser and environment in which tests are run. + // However, for larger data sets the difference between using `diff()` and `fastDiff()` (see above issue for context) + // is more than 10x in execution time so it is clearly visible in these tests when something goes wrong. + describe( 'rendering performance', () => { + before( function() { + // Ignore on Edge browser where performance is quite poor. + if ( env.isEdge ) { + this.skip(); + } + } ); + + it( 'should not take more than 350ms to render around 300 element nodes (same html)', () => { + const renderingTime = measureRenderingTime( viewRoot, generateViewData1( 65 ), generateViewData1( 55 ) ); + expect( renderingTime ).to.be.within( 0, 350 ); + } ); + + it( 'should not take more than 350ms to render around 300 element nodes (different html)', () => { + const renderingTime = measureRenderingTime( viewRoot, generateViewData1( 55 ), generateViewData2( 65 ) ); + expect( renderingTime ).to.be.within( 0, 350 ); + } ); + + it( 'should not take more than 350ms to render around 500 element nodes (same html)', () => { + const renderingTime = measureRenderingTime( viewRoot, generateViewData1( 105 ), generateViewData1( 95 ) ); + expect( renderingTime ).to.be.within( 0, 350 ); + } ); + + it( 'should not take more than 350ms to render around 500 element nodes (different html)', () => { + const renderingTime = measureRenderingTime( viewRoot, generateViewData1( 95 ), generateViewData2( 105 ) ); + expect( renderingTime ).to.be.within( 0, 350 ); + } ); + + it( 'should not take more than 350ms to render around 1000 element nodes (same html)', () => { + const renderingTime = measureRenderingTime( viewRoot, generateViewData1( 195 ), generateViewData1( 205 ) ); + expect( renderingTime ).to.be.within( 0, 350 ); + } ); + + it( 'should not take more than 350ms to render around 1000 element nodes (different html)', () => { + const renderingTime = measureRenderingTime( viewRoot, generateViewData1( 205 ), generateViewData2( 195 ) ); + expect( renderingTime ).to.be.within( 0, 350 ); + } ); + + function measureRenderingTime( viewRoot, initialData, newData ) { + // Set initial data. + const initialView = parse( initialData ); + viewRoot._appendChild( initialView ); + renderer.markToSync( 'children', viewRoot ); + renderer.render(); + + // Set new data. + const newView = parse( newData ); + viewRoot._removeChildren( 0, viewRoot.childCount ); + viewRoot._appendChild( newView ); + renderer.markToSync( 'children', viewRoot ); + + // Measure render time. + const start = Date.now(); + + renderer.render(); + + return Date.now() - start; + } + + function generateViewData1( repeat = 1 ) { + const viewData = '' + + '' + + 'CKEditor 5 h1 heading!' + + '' + + '' + + 'Foo Bar Baz and some text' + + '' + + '' + + 'Item 1' + + '' + + '' + + 'Item 2' + + '' + + '' + + 'Item 3' + + ''; + + return viewData.repeat( repeat ); + } + + function generateViewData2( repeat = 1 ) { + const viewData = '' + + '' + + '' + + 'Foo' + + '' + + '' + + '' + + 'Item 1' + + '' + + 'Heading 1' + + '' + + 'Heading 2' + + '' + + 'Heading 4'; + + return viewData.repeat( repeat ); + } + } ); } ); describe( '#922', () => {