diff --git a/src/conversion/upcastdispatcher.js b/src/conversion/upcastdispatcher.js index ca7eba966..0379df5db 100644 --- a/src/conversion/upcastdispatcher.js +++ b/src/conversion/upcastdispatcher.js @@ -108,15 +108,14 @@ export default class UpcastDispatcher { */ constructor( conversionApi = {} ) { /** - * List of elements that will be checked after conversion process and if element in the list will be empty it - * will be removed from conversion result. + * List of the elements that were created during splitting. * - * After conversion process list is cleared. + * After conversion process the list is cleared. * - * @protected - * @type {Set} + * @private + * @type {Map.>} */ - this._removeIfEmpty = new Set(); + this._splitParts = new Map(); /** * Position in the temporary structure where the converted content is inserted. The structure reflect the context of @@ -140,6 +139,7 @@ export default class UpcastDispatcher { this.conversionApi.convertItem = this._convertItem.bind( this ); this.conversionApi.convertChildren = this._convertChildren.bind( this ); this.conversionApi.splitToAllowedParent = this._splitToAllowedParent.bind( this ); + this.conversionApi.getSplitParts = this._getSplitParts.bind( this ); } /** @@ -176,15 +176,15 @@ export default class UpcastDispatcher { // Do the conversion. const { modelRange } = this._convertItem( viewItem, this._modelCursor ); - // Conversion result is always a document fragment so let's create this fragment. + // Conversion result is always a document fragment so let's create it. const documentFragment = writer.createDocumentFragment(); // When there is a conversion result. if ( modelRange ) { - // Remove all empty elements that was added to #_removeIfEmpty list. + // Remove all empty elements that were create while splitting. this._removeEmptyElements(); - // Move all items that was converted to context tree to document fragment. + // Move all items that were converted in context tree to the document fragment. for ( const item of Array.from( this._modelCursor.parent.getChildren() ) ) { writer.append( item, documentFragment ); } @@ -196,8 +196,8 @@ export default class UpcastDispatcher { // Clear context position. this._modelCursor = null; - // Clear split elements. - this._removeIfEmpty.clear(); + // Clear split elements lists. + this._splitParts.clear(); // Clear conversion API. this.conversionApi.writer = null; @@ -283,14 +283,31 @@ export default class UpcastDispatcher { // Split element to allowed parent. const splitResult = this.conversionApi.writer.split( modelCursor, allowedParent ); - // Remember all elements that are created as a result of split. - // This is important because at the end of conversion we want to remove all empty split elements. + // Using the range returned by `model.Writer#split`, we will pair original elements with their split parts. + // + // The range returned from the writer spans "over the split" or, precisely saying, from the end of the original element (the one + // that got split) to the beginning of the other part of that element: + // + // X[]Y -> + // X[]Y // - // Loop through positions between elements in range (except split result position) and collect parents. - // [pos][pos][omit][pos][pos] - for ( const position of splitResult.range.getPositions() ) { - if ( !position.isEqual( splitResult.position ) ) { - this._removeIfEmpty.add( position.parent ); + // After the split there cannot be any full node between the positions in `splitRange`. The positions are touching. + // Also, because of how splitting works, it is easy to notice, that "closing tags" are in the reverse order than "opening tags". + // Also, since we split all those elements, each of them has to have the other part. + // + // With those observations in mind, we will pair the original elements with their split parts by saving "closing tags" and matching + // them with "opening tags" in the reverse order. For that we can use a stack. + const stack = []; + + for ( const treeWalkerValue of splitResult.range.getWalker() ) { + if ( treeWalkerValue.type == 'elementEnd' ) { + stack.push( treeWalkerValue.item ); + } else { + // There should not be any text nodes after the element is split, so the only other value is `elementStart`. + const originalPart = stack.pop(); + const splitPart = treeWalkerValue.item; + + this._registerSplitPair( originalPart, splitPart ); } } @@ -301,25 +318,62 @@ export default class UpcastDispatcher { } /** - * Checks if {@link #_removeIfEmpty} contains empty elements and remove them. - * We need to do it smart because there could be elements that are not empty because contains - * other empty elements and after removing its children they become available to remove. - * We need to continue iterating over split elements as long as any element will be removed. + * Registers that `splitPart` element is a split part of the `originalPart` element. + * + * Data set by this method is used by {@link #_getSplitParts} and {@link #_removeEmptyElements}. + * + * @private + * @param {module:engine/model/element~Element} originalPart + * @param {module:engine/model/element~Element} splitPart + */ + _registerSplitPair( originalPart, splitPart ) { + if ( !this._splitParts.has( originalPart ) ) { + this._splitParts.set( originalPart, [ originalPart ] ); + } + + const list = this._splitParts.get( originalPart ); + + this._splitParts.set( splitPart, list ); + list.push( splitPart ); + } + + /** + * @private + * @see module:engine/conversion/upcastdispatcher~UpcastConversionApi#getSplitParts + */ + _getSplitParts( element ) { + let parts; + + if ( !this._splitParts.has( element ) ) { + parts = [ element ]; + } else { + parts = this._splitParts.get( element ); + } + + return parts; + } + + /** + * Checks if there are any empty elements created while splitting and removes them. + * + * This method works recursively to re-check empty elements again after at least one element was removed in the initial call, + * as some elements might have become empty after other empty elements were removed from them. * * @private */ _removeEmptyElements() { - let removed = false; + let anyRemoved = false; - for ( const element of this._removeIfEmpty ) { + for ( const element of this._splitParts.keys() ) { if ( element.isEmpty ) { this.conversionApi.writer.remove( element ); - this._removeIfEmpty.delete( element ); - removed = true; + this._splitParts.delete( element ); + + anyRemoved = true; } } - if ( removed ) { + if ( anyRemoved ) { this._removeEmptyElements(); } } @@ -408,7 +462,7 @@ function extractMarkersFromModelFragment( modelItem, writer ) { return markers; } -// Creates model fragment according to given context and returns position in top element. +// Creates model fragment according to given context and returns position in the bottom (the deepest) element. function createContextTree( contextDefinition, writer ) { let position; @@ -465,7 +519,7 @@ function createContextTree( contextDefinition, writer ) { * @fires module:engine/conversion/upcastdispatcher~UpcastDispatcher#event:element * @fires module:engine/conversion/upcastdispatcher~UpcastDispatcher#event:text * @fires module:engine/conversion/upcastdispatcher~UpcastDispatcher#event:documentFragment - * @param {module:engine/view/item~Item} viewItem Item to convert. + * @param {module:engine/view/item~Item} viewItem Element which children should be converted. * @param {module:engine/model/position~Position} modelCursor Position of conversion. * @returns {Object} result Conversion result. * @returns {module:engine/model/range~Range} result.modelRange Model range containing results of conversion of all children of given item. @@ -504,6 +558,46 @@ function createContextTree( contextDefinition, writer ) { * continue conversion. When element is not defined it means that there was no split. */ +/** + * Returns all the split parts of given `element` that were created during upcasting through using {@link #splitToAllowedParent}. + * It enables you to easily track those elements and continue processing them after they are split during their children conversion. + * + * Foobarbaz -> + * Foobarbaz + * + * For a reference to any of above paragraphs, the function will return all three paragraphs (the original element included), + * sorted in the order of their creation (the original element is the first one). + * + * If given `element` was not split, an array with single element is returned. + * + * Example of a usage in a converter code: + * + * const myElement = conversionApi.writer.createElement( 'myElement' ); + * + * // Children conversion may split `myElement`. + * conversionApi.convertChildren( myElement, modelCursor ); + * + * const splitParts = conversionApi.getSplitParts( myElement ); + * const lastSplitPart = splitParts[ splitParts.length - 1 ]; + * + * // Setting `data.modelRange` basing on split parts: + * data.modelRange = conversionApi.writer.createRange( + * conversionApi.writer.createPositionBefore( myElement ), + * conversionApi.writer.createPositionAfter( lastSplitPart ) + * ); + * + * // Setting `data.modelCursor` to continue after the last split element: + * data.modelCursor = conversionApi.writer.createPositionAfter( lastSplitPart ); + * + * **Tip:** if you are unable to get a reference to the original element (for example because the code is split into multiple converters + * or even classes) but it was already converted, you might want to check first element in `data.modelRange`. This is a common situation + * if an attribute converter is separated from an element converter. + * + * @method #getSplitParts + * @param {module:engine/model/element~Element} element + * @returns {Array.} + */ + /** * Stores information about what parts of processed view item are still waiting to be handled. After a piece of view item * was converted, appropriate consumable value should be {@link module:engine/conversion/viewconsumable~ViewConsumable#consume consumed}. diff --git a/src/conversion/upcasthelpers.js b/src/conversion/upcasthelpers.js index f80a450a4..ad3b4250c 100644 --- a/src/conversion/upcasthelpers.js +++ b/src/conversion/upcasthelpers.js @@ -66,7 +66,8 @@ export default class UpcastHelpers extends ConversionHelpers { * * @method #elementToElement * @param {Object} config Conversion configuration. - * @param {module:engine/view/matcher~MatcherPattern} config.view Pattern matching all view elements which should be converted. + * @param {module:engine/view/matcher~MatcherPattern} [config.view] Pattern matching all view elements which should be converted. If not + * set, the converter will fire for every view element. * @param {String|module:engine/model/element~Element|Function} config.model Name of the model element, a model element * instance or a function that takes a view element and returns a model element. The model element will be inserted in the model. * @param {module:utils/priorities~PriorityString} [config.converterPriority='normal'] Converter priority. @@ -393,7 +394,8 @@ export function convertSelectionChange( model, mapper ) { // See {@link ~UpcastHelpers#elementToElement `.elementToElement()` upcast helper} for examples. // // @param {Object} config Conversion configuration. -// @param {module:engine/view/matcher~MatcherPattern} config.view Pattern matching all view elements which should be converted. +// @param {module:engine/view/matcher~MatcherPattern} [config.view] Pattern matching all view elements which should be converted. If not +// set, the converter will fire for every view element. // @param {String|module:engine/model/element~Element|Function} config.model Name of the model element, a model element // instance or a function that takes a view element and returns a model element. The model element will be inserted in the model. // @param {module:utils/priorities~PriorityString} [config.converterPriority='normal'] Converter priority. @@ -510,19 +512,26 @@ function getViewElementNameFromConfig( config ) { // @param {Object} config Conversion configuration. // @returns {Function} View to model converter. function prepareToElementConverter( config ) { - const matcher = new Matcher( config.view ); + const matcher = config.view ? new Matcher( config.view ) : null; return ( evt, data, conversionApi ) => { - // This will be usually just one pattern but we support matchers with many patterns too. - const match = matcher.match( data.viewItem ); + let match = {}; - // If there is no match, this callback should not do anything. - if ( !match ) { - return; + // If `config.view` has not been passed do not try matching. In this case, the converter should fire for all elements. + if ( matcher ) { + // This will be usually just one pattern but we support matchers with many patterns too. + const matcherResult = matcher.match( data.viewItem ); + + // If there is no match, this callback should not do anything. + if ( !matcherResult ) { + return; + } + + match = matcherResult.match; } // Force consuming element's name. - match.match.name = true; + match.name = true; // Create model element basing on config. const modelElement = getModelElement( config.model, data.viewItem, conversionApi.writer ); @@ -533,7 +542,7 @@ function prepareToElementConverter( config ) { } // When element was already consumed then skip it. - if ( !conversionApi.consumable.test( data.viewItem, match.match ) ) { + if ( !conversionApi.consumable.test( data.viewItem, match ) ) { return; } @@ -551,32 +560,30 @@ function prepareToElementConverter( config ) { conversionApi.writer.insert( modelElement, splitResult.position ); // Convert children and insert to element. - const childrenResult = conversionApi.convertChildren( data.viewItem, conversionApi.writer.createPositionAt( modelElement, 0 ) ); + conversionApi.convertChildren( data.viewItem, conversionApi.writer.createPositionAt( modelElement, 0 ) ); // Consume appropriate value from consumable values list. - conversionApi.consumable.consume( data.viewItem, match.match ); + conversionApi.consumable.consume( data.viewItem, match ); + + const parts = conversionApi.getSplitParts( modelElement ); // Set conversion result range. data.modelRange = new ModelRange( - // Range should start before inserted element conversionApi.writer.createPositionBefore( modelElement ), - // Should end after but we need to take into consideration that children could split our - // element, so we need to move range after parent of the last converted child. - // before: [] - // after: [] - conversionApi.writer.createPositionAfter( childrenResult.modelCursor.parent ) + conversionApi.writer.createPositionAfter( parts[ parts.length - 1 ] ) ); - // Now we need to check where the modelCursor should be. - // If we had to split parent to insert our element then we want to continue conversion inside split parent. - // - // before: [] - // after: [] + // Now we need to check where the `modelCursor` should be. if ( splitResult.cursorParent ) { - data.modelCursor = conversionApi.writer.createPositionAt( splitResult.cursorParent, 0 ); + // If we split parent to insert our element then we want to continue conversion in the new part of the split parent. + // + // before: foo[] + // after: foo[] - // Otherwise just continue after inserted element. + data.modelCursor = conversionApi.writer.createPositionAt( splitResult.cursorParent, 0 ); } else { + // Otherwise just continue after inserted element. + data.modelCursor = data.modelRange.end; } }; diff --git a/src/model/writer.js b/src/model/writer.js index 99ebdc942..182c9022d 100644 --- a/src/model/writer.js +++ b/src/model/writer.js @@ -717,8 +717,8 @@ export default class Writer { * @param {module:engine/model/position~Position} position Position of split. * @param {module:engine/model/node~Node} [limitElement] Stop splitting when this element will be reached. * @returns {Object} result Split result. - * @returns {module:engine/model/position~Position} result.position between split elements. - * @returns {module:engine/model/range~Range} result.range Range that stars from the end of the first split element and ands + * @returns {module:engine/model/position~Position} result.position Position between split elements. + * @returns {module:engine/model/range~Range} result.range Range that stars from the end of the first split element and ends * at the beginning of the first copy element. */ split( position, limitElement ) { diff --git a/tests/conversion/upcastdispatcher.js b/tests/conversion/upcastdispatcher.js index deb6e5ce7..d4138f6b5 100644 --- a/tests/conversion/upcastdispatcher.js +++ b/tests/conversion/upcastdispatcher.js @@ -5,6 +5,7 @@ import UpcastDispatcher from '../../src/conversion/upcastdispatcher'; import ViewContainerElement from '../../src/view/containerelement'; +import ViewElement from '../../src/view/element'; import ViewDocumentFragment from '../../src/view/documentfragment'; import ViewText from '../../src/view/text'; @@ -38,10 +39,12 @@ describe( 'UpcastDispatcher', () => { expect( dispatcher.conversionApi ).to.have.property( 'splitToAllowedParent' ).that.is.instanceof( Function ); } ); - it( 'should have properties', () => { + it( 'should not crash if no additional api is passed', () => { const dispatcher = new UpcastDispatcher(); - expect( dispatcher._removeIfEmpty ).to.instanceof( Set ); + expect( dispatcher.conversionApi ).to.have.property( 'convertItem' ).that.is.instanceof( Function ); + expect( dispatcher.conversionApi ).to.have.property( 'convertChildren' ).that.is.instanceof( Function ); + expect( dispatcher.conversionApi ).to.have.property( 'splitToAllowedParent' ).that.is.instanceof( Function ); } ); } ); @@ -49,10 +52,10 @@ describe( 'UpcastDispatcher', () => { let dispatcher; beforeEach( () => { - dispatcher = new UpcastDispatcher(); + dispatcher = new UpcastDispatcher( { schema: model.schema } ); } ); - it( 'should create api for current conversion process', () => { + it( 'should create api for a conversion process', () => { const viewElement = new ViewContainerElement( 'p', null, new ViewText( 'foobar' ) ); // To be sure that both converters was called. @@ -64,13 +67,11 @@ describe( 'UpcastDispatcher', () => { // Conversion process properties should be undefined/empty before conversion. expect( dispatcher.conversionApi.writer ).to.not.ok; expect( dispatcher.conversionApi.store ).to.not.ok; - expect( dispatcher._removeIfEmpty.size ).to.equal( 0 ); dispatcher.on( 'element', ( evt, data, conversionApi ) => { // Check conversion api params. expect( conversionApi.writer ).to.instanceof( ModelWriter ); expect( conversionApi.store ).to.deep.equal( {} ); - expect( dispatcher._removeIfEmpty.size ).to.equal( 0 ); // Remember writer to check in next converter that is exactly the same instance (the same undo step). writer = conversionApi.writer; @@ -78,9 +79,6 @@ describe( 'UpcastDispatcher', () => { // Add some data to conversion storage to verify them in next converter. conversionApi.store.foo = 'bar'; - // Add empty element and mark as a split result to check in next converter. - dispatcher._removeIfEmpty.add( conversionApi.writer.createElement( 'paragraph' ) ); - // Convert children - this will call second converter. conversionApi.convertChildren( data.viewItem, data.modelCursor ); @@ -94,9 +92,6 @@ describe( 'UpcastDispatcher', () => { // Data set by previous converter are remembered. expect( conversionApi.store ).to.deep.equal( { foo: 'bar' } ); - // Split element is remembered as well. - expect( dispatcher._removeIfEmpty.size ).to.equal( 1 ); - spy(); } ); @@ -108,7 +103,6 @@ describe( 'UpcastDispatcher', () => { // Conversion process properties should be cleared after conversion. expect( dispatcher.conversionApi.writer ).to.not.ok; expect( dispatcher.conversionApi.store ).to.not.ok; - expect( dispatcher._removeIfEmpty.size ).to.equal( 0 ); } ); it( 'should fire viewCleanup event on converted view part', () => { @@ -248,58 +242,50 @@ describe( 'UpcastDispatcher', () => { } ); it( 'should remove empty elements that was created as a result of split', () => { - const viewElement = new ViewContainerElement( 'p' ); + const viewElement = new ViewElement( 'div', null, [ + new ViewElement( 'p', null, [ + new ViewElement( 'img' ) + ] ) + ] ); - // To be sure that converter was called. - const spy = sinon.spy(); + model.schema.register( 'div', { allowIn: '$root' } ); + model.schema.register( 'p', { allowIn: 'div' } ); + model.schema.register( 'image', { allowIn: '$root' } ); - dispatcher.on( 'element', ( evt, data, conversionApi ) => { - // First let's convert target element. - const paragraph = conversionApi.writer.createElement( 'paragraph' ); - conversionApi.writer.insert( paragraph, data.modelCursor ); + dispatcher.on( 'element:img', ( evt, data, conversionApi ) => { + const writer = conversionApi.writer; - // Then add some elements and mark as split. + const modelElement = writer.createElement( 'image' ); + const splitResult = conversionApi.splitToAllowedParent( modelElement, data.modelCursor ); + writer.insert( modelElement, splitResult.position ); - // Create and insert empty split element before target element. - const emptySplit = conversionApi.writer.createElement( 'paragraph' ); - conversionApi.writer.insert( emptySplit, ModelPosition._createAfter( paragraph ) ); + data.modelRange = writer.createRangeOn( modelElement ); + data.modelCursor = writer.createPositionAt( splitResult.cursorParent, 0 ); - // Create and insert not empty split after target element. - const notEmptySplit = conversionApi.writer.createElement( 'paragraph' ); - conversionApi.writer.appendText( 'foo', notEmptySplit ); - conversionApi.writer.insert( notEmptySplit, ModelPosition._createAfter( emptySplit ) ); + // Prevent below converter to fire. + evt.stop(); + }, { priority: 'high' } ); - // Create and insert split with other split inside (both should be removed) - const outerSplit = conversionApi.writer.createElement( 'paragraph' ); - const innerSplit = conversionApi.writer.createElement( 'paragraph' ); - conversionApi.writer.append( innerSplit, outerSplit ); - conversionApi.writer.insert( outerSplit, ModelPosition._createBefore( paragraph ) ); + dispatcher.on( 'element', ( evt, data, conversionApi ) => { + const writer = conversionApi.writer; - dispatcher._removeIfEmpty.add( emptySplit ); - dispatcher._removeIfEmpty.add( notEmptySplit ); - dispatcher._removeIfEmpty.add( outerSplit ); - dispatcher._removeIfEmpty.add( innerSplit ); + const modelElement = writer.createElement( data.viewItem.name ); + writer.insert( modelElement, data.modelCursor ); - data.modelRange = ModelRange._createOn( paragraph ); - data.modelCursor = data.modelRange.end; + const result = conversionApi.convertChildren( data.viewItem, writer.createPositionAt( modelElement, 0 ) ); - // We have the following result: - //

[

]

foo

- // Everything out of selected range is a result of the split. - - spy(); + data.modelRange = writer.createRange( + writer.createPositionBefore( modelElement ), + conversionApi.writer.createPositionAfter( result.modelCursor.parent ) + ); + data.modelCursor = data.modelRange.end; } ); const result = model.change( writer => dispatcher.convert( viewElement, writer ) ); - // Empty split elements should be removed and we should have the following result: - // [

]

foo

- expect( result.childCount ).to.equal( 2 ); - expect( result.getChild( 0 ).name ).to.equal( 'paragraph' ); - expect( result.getChild( 0 ).childCount ).to.equal( 0 ); - expect( result.getChild( 1 ).name ).to.equal( 'paragraph' ); - expect( result.getChild( 1 ).childCount ).to.equal( 1 ); - expect( result.getChild( 1 ).getChild( 0 ).data ).to.equal( 'foo' ); + // After splits `div` and `p` are empty so they should be removed. + expect( result.childCount ).to.equal( 1 ); + expect( result.getChild( 0 ).name ).to.equal( 'image' ); } ); it( 'should extract temporary markers elements from converter element and create static markers list', () => { @@ -628,5 +614,99 @@ describe( 'UpcastDispatcher', () => { sinon.assert.calledOnce( spy ); } ); } ); + + describe( 'getSplitParts()', () => { + it( 'should return an array containing only passed element if the element has not been split', () => { + model.schema.register( 'paragraph', { allowIn: '$root' } ); + + const spy = sinon.spy(); + + dispatcher.on( 'element', ( evt, data, conversionApi ) => { + const modelElement = conversionApi.writer.createElement( 'paragraph' ); + const parts = conversionApi.getSplitParts( modelElement ); + + expect( parts ).to.deep.equal( [ modelElement ] ); + + spy(); + + // Overwrite converters specified in `beforeEach`. + evt.stop(); + }, { priority: 'high' } ); + + const viewElement = new ViewElement( 'p' ); + + model.change( writer => dispatcher.convert( viewElement, writer ) ); + + expect( spy.called ).to.be.true; + } ); + + it( 'should return all parts of the split element', () => { + model.schema.register( 'paragraph', { allowIn: '$root' } ); + model.schema.register( 'text', { allowIn: 'paragraph' } ); + model.schema.register( 'image', { allowIn: '$root' } ); + + dispatcher.on( 'text', ( evt, data, conversionApi ) => { + const modelText = conversionApi.writer.createText( data.viewItem.data ); + + conversionApi.writer.insert( modelText, data.modelCursor ); + + data.modelRange = conversionApi.writer.createRangeOn( modelText ); + data.modelCursor = data.modelRange.end; + + // Overwrite converters specified in `beforeEach`. + evt.stop(); + }, { priority: 'high' } ); + + dispatcher.on( 'element:image', ( evt, data, conversionApi ) => { + const modelElement = conversionApi.writer.createElement( 'image' ); + + const splitResult = conversionApi.splitToAllowedParent( modelElement, data.modelCursor ); + + conversionApi.writer.insert( modelElement, splitResult.position ); + + data.modelRange = conversionApi.writer.createRangeOn( modelElement ); + data.modelCursor = conversionApi.writer.createPositionAt( splitResult.cursorParent, 0 ); + + // Overwrite converters specified in `beforeEach`. + evt.stop(); + }, { priority: 'high' } ); + + const spy = sinon.spy(); + + dispatcher.on( 'element:p', ( evt, data, conversionApi ) => { + const modelElement = conversionApi.writer.createElement( 'paragraph' ); + + conversionApi.writer.insert( modelElement, data.modelCursor ); + conversionApi.convertChildren( data.viewItem, conversionApi.writer.createPositionAt( modelElement, 0 ) ); + + const parts = conversionApi.getSplitParts( modelElement ); + + expect( parts.length ).to.equal( 3 ); + + expect( parts[ 0 ].getChild( 0 ).data ).to.equal( 'foo' ); + expect( parts[ 1 ].getChild( 0 ).data ).to.equal( 'bar' ); + expect( parts[ 2 ].getChild( 0 ).data ).to.equal( 'xyz' ); + + expect( parts[ 0 ] ).to.equal( modelElement ); + + spy(); + + // Overwrite converters specified in `beforeEach`. + evt.stop(); + }, { priority: 'high' } ); + + const viewElement = new ViewElement( 'p', null, [ + new ViewText( 'foo' ), + new ViewElement( 'image' ), + new ViewText( 'bar' ), + new ViewElement( 'image' ), + new ViewText( 'xyz' ) + ] ); + + model.change( writer => dispatcher.convert( viewElement, writer, [ '$root' ] ) ); + + expect( spy.called ).to.be.true; + } ); + } ); } ); } ); diff --git a/tests/conversion/upcasthelpers.js b/tests/conversion/upcasthelpers.js index 99cb62254..c6e5f01b6 100644 --- a/tests/conversion/upcasthelpers.js +++ b/tests/conversion/upcasthelpers.js @@ -112,6 +112,15 @@ describe( 'UpcastHelpers', () => { expectResult( new ViewContainerElement( 'p', { 'data-level': 2 } ), '' ); } ); + it( 'config.view is not set - should fire conversion for every element', () => { + upcastHelpers.elementToElement( { + model: 'paragraph' + } ); + + expectResult( new ViewContainerElement( 'p' ), '' ); + expectResult( new ViewContainerElement( 'foo' ), '' ); + } ); + it( 'should fire conversion of the element children', () => { upcastHelpers.elementToElement( { view: 'p', model: 'paragraph' } );