瀏覽代碼

Merge pull request #1704 from ckeditor/t/1580-2

Feature: Introduced `UpcastConversionApi#getSplitParts`. Also, provided a way to set upcast conversion helper fired for every view element. Closes https://github.com/ckeditor/ckeditor5/issues/1580. Closes https://github.com/ckeditor/ckeditor5/issues/1581.
Piotr Jasiun 6 年之前
父節點
當前提交
f2508dbfdd

+ 123 - 29
packages/ckeditor5-engine/src/conversion/upcastdispatcher.js

@@ -108,15 +108,14 @@ export default class UpcastDispatcher {
 	 */
 	 */
 	constructor( conversionApi = {} ) {
 	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<module:engine/model/element~Element>}
+		 * @private
+		 * @type {Map.<module:engine/model/element~Element,Array.<module:engine/model/element~Element>>}
 		 */
 		 */
-		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
 		 * 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.convertItem = this._convertItem.bind( this );
 		this.conversionApi.convertChildren = this._convertChildren.bind( this );
 		this.conversionApi.convertChildren = this._convertChildren.bind( this );
 		this.conversionApi.splitToAllowedParent = this._splitToAllowedParent.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.
 		// Do the conversion.
 		const { modelRange } = this._convertItem( viewItem, this._modelCursor );
 		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();
 		const documentFragment = writer.createDocumentFragment();
 
 
 		// When there is a conversion result.
 		// When there is a conversion result.
 		if ( modelRange ) {
 		if ( modelRange ) {
-			// Remove all empty elements that was added to #_removeIfEmpty list.
+			// Remove all empty elements that were create while splitting.
 			this._removeEmptyElements();
 			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() ) ) {
 			for ( const item of Array.from( this._modelCursor.parent.getChildren() ) ) {
 				writer.append( item, documentFragment );
 				writer.append( item, documentFragment );
 			}
 			}
@@ -196,8 +196,8 @@ export default class UpcastDispatcher {
 		// Clear context position.
 		// Clear context position.
 		this._modelCursor = null;
 		this._modelCursor = null;
 
 
-		// Clear split elements.
-		this._removeIfEmpty.clear();
+		// Clear split elements lists.
+		this._splitParts.clear();
 
 
 		// Clear conversion API.
 		// Clear conversion API.
 		this.conversionApi.writer = null;
 		this.conversionApi.writer = null;
@@ -283,14 +283,31 @@ export default class UpcastDispatcher {
 		// Split element to allowed parent.
 		// Split element to allowed parent.
 		const splitResult = this.conversionApi.writer.split( modelCursor, allowedParent );
 		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:
+		//
+		// <limit><a><b><c>X[]Y</c></b><a></limit> ->
+		// <limit><a><b><c>X[</c></b></a><a><b><c>]Y</c></b></a>
 		//
 		//
-		// Loop through positions between elements in range (except split result position) and collect parents.
-		// <notSplit><split1><split2>[pos]</split2>[pos]</split1>[omit]<split1>[pos]<split2>[pos]</split2></split1></notSplit>
-		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
 	 * @private
 	 */
 	 */
 	_removeEmptyElements() {
 	_removeEmptyElements() {
-		let removed = false;
+		let anyRemoved = false;
 
 
-		for ( const element of this._removeIfEmpty ) {
+		for ( const element of this._splitParts.keys() ) {
 			if ( element.isEmpty ) {
 			if ( element.isEmpty ) {
 				this.conversionApi.writer.remove( element );
 				this.conversionApi.writer.remove( element );
-				this._removeIfEmpty.delete( element );
-				removed = true;
+				this._splitParts.delete( element );
+
+				anyRemoved = true;
 			}
 			}
 		}
 		}
 
 
-		if ( removed ) {
+		if ( anyRemoved ) {
 			this._removeEmptyElements();
 			this._removeEmptyElements();
 		}
 		}
 	}
 	}
@@ -408,7 +462,7 @@ function extractMarkersFromModelFragment( modelItem, writer ) {
 	return markers;
 	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 ) {
 function createContextTree( contextDefinition, writer ) {
 	let position;
 	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:element
  * @fires module:engine/conversion/upcastdispatcher~UpcastDispatcher#event:text
  * @fires module:engine/conversion/upcastdispatcher~UpcastDispatcher#event:text
  * @fires module:engine/conversion/upcastdispatcher~UpcastDispatcher#event:documentFragment
  * @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.
  * @param {module:engine/model/position~Position} modelCursor Position of conversion.
  * @returns {Object} result Conversion result.
  * @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.
  * @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.
  * 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.
+ *
+ *		<paragraph>Foo<image />bar<image />baz</paragraph> ->
+ *		<paragraph>Foo</paragraph><image /><paragraph>bar</paragraph><image /><paragraph>baz</paragraph>
+ *
+ * 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.<module:engine/model/element~Element>}
+ */
+
 /**
 /**
  * Stores information about what parts of processed view item are still waiting to be handled. After a piece of view item
  * 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}.
  * was converted, appropriate consumable value should be {@link module:engine/conversion/viewconsumable~ViewConsumable#consume consumed}.

+ 32 - 25
packages/ckeditor5-engine/src/conversion/upcasthelpers.js

@@ -66,7 +66,8 @@ export default class UpcastHelpers extends ConversionHelpers {
 	 *
 	 *
 	 * @method #elementToElement
 	 * @method #elementToElement
 	 * @param {Object} config Conversion configuration.
 	 * @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
 	 * @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.
 	 * 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.
 	 * @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.
 // See {@link ~UpcastHelpers#elementToElement `.elementToElement()` upcast helper} for examples.
 //
 //
 // @param {Object} config Conversion configuration.
 // @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
 // @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.
 // 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.
 // @param {module:utils/priorities~PriorityString} [config.converterPriority='normal'] Converter priority.
@@ -510,19 +512,26 @@ function getViewElementNameFromConfig( config ) {
 // @param {Object} config Conversion configuration.
 // @param {Object} config Conversion configuration.
 // @returns {Function} View to model converter.
 // @returns {Function} View to model converter.
 function prepareToElementConverter( config ) {
 function prepareToElementConverter( config ) {
-	const matcher = new Matcher( config.view );
+	const matcher = config.view ? new Matcher( config.view ) : null;
 
 
 	return ( evt, data, conversionApi ) => {
 	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.
 		// Force consuming element's name.
-		match.match.name = true;
+		match.name = true;
 
 
 		// Create model element basing on config.
 		// Create model element basing on config.
 		const modelElement = getModelElement( config.model, data.viewItem, conversionApi.writer );
 		const modelElement = getModelElement( config.model, data.viewItem, conversionApi.writer );
@@ -533,7 +542,7 @@ function prepareToElementConverter( config ) {
 		}
 		}
 
 
 		// When element was already consumed then skip it.
 		// When element was already consumed then skip it.
-		if ( !conversionApi.consumable.test( data.viewItem, match.match ) ) {
+		if ( !conversionApi.consumable.test( data.viewItem, match ) ) {
 			return;
 			return;
 		}
 		}
 
 
@@ -551,32 +560,30 @@ function prepareToElementConverter( config ) {
 		conversionApi.writer.insert( modelElement, splitResult.position );
 		conversionApi.writer.insert( modelElement, splitResult.position );
 
 
 		// Convert children and insert to element.
 		// 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.
 		// 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.
 		// Set conversion result range.
 		data.modelRange = new ModelRange(
 		data.modelRange = new ModelRange(
-			// Range should start before inserted element
 			conversionApi.writer.createPositionBefore( modelElement ),
 			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: <allowed>[]</allowed>
-			// after: <allowed>[<converted><child></child></converted><child></child><converted>]</converted></allowed>
-			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: <allowed><notAllowed>[]</notAllowed></allowed>
-		// after:  <allowed><notAllowed></notAllowed><converted></converted><notAllowed>[]</notAllowed></allowed>
+		// Now we need to check where the `modelCursor` should be.
 		if ( splitResult.cursorParent ) {
 		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: <allowed><notAllowed>foo[]</notAllowed></allowed>
+			// after:  <allowed><notAllowed>foo</notAllowed><converted></converted><notAllowed>[]</notAllowed></allowed>
 
 
-			// Otherwise just continue after inserted element.
+			data.modelCursor = conversionApi.writer.createPositionAt( splitResult.cursorParent, 0 );
 		} else {
 		} else {
+			// Otherwise just continue after inserted element.
+
 			data.modelCursor = data.modelRange.end;
 			data.modelCursor = data.modelRange.end;
 		}
 		}
 	};
 	};

+ 2 - 2
packages/ckeditor5-engine/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/position~Position} position Position of split.
 	 * @param {module:engine/model/node~Node} [limitElement] Stop splitting when this element will be reached.
 	 * @param {module:engine/model/node~Node} [limitElement] Stop splitting when this element will be reached.
 	 * @returns {Object} result Split result.
 	 * @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.
 	 * at the beginning of the first copy element.
 	 */
 	 */
 	split( position, limitElement ) {
 	split( position, limitElement ) {

+ 132 - 52
packages/ckeditor5-engine/tests/conversion/upcastdispatcher.js

@@ -5,6 +5,7 @@
 
 
 import UpcastDispatcher from '../../src/conversion/upcastdispatcher';
 import UpcastDispatcher from '../../src/conversion/upcastdispatcher';
 import ViewContainerElement from '../../src/view/containerelement';
 import ViewContainerElement from '../../src/view/containerelement';
+import ViewElement from '../../src/view/element';
 import ViewDocumentFragment from '../../src/view/documentfragment';
 import ViewDocumentFragment from '../../src/view/documentfragment';
 import ViewText from '../../src/view/text';
 import ViewText from '../../src/view/text';
 
 
@@ -38,10 +39,12 @@ describe( 'UpcastDispatcher', () => {
 			expect( dispatcher.conversionApi ).to.have.property( 'splitToAllowedParent' ).that.is.instanceof( Function );
 			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();
 			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;
 		let dispatcher;
 
 
 		beforeEach( () => {
 		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' ) );
 			const viewElement = new ViewContainerElement( 'p', null, new ViewText( 'foobar' ) );
 
 
 			// To be sure that both converters was called.
 			// To be sure that both converters was called.
@@ -64,13 +67,11 @@ describe( 'UpcastDispatcher', () => {
 			// Conversion process properties should be undefined/empty before conversion.
 			// Conversion process properties should be undefined/empty before conversion.
 			expect( dispatcher.conversionApi.writer ).to.not.ok;
 			expect( dispatcher.conversionApi.writer ).to.not.ok;
 			expect( dispatcher.conversionApi.store ).to.not.ok;
 			expect( dispatcher.conversionApi.store ).to.not.ok;
-			expect( dispatcher._removeIfEmpty.size ).to.equal( 0 );
 
 
 			dispatcher.on( 'element', ( evt, data, conversionApi ) => {
 			dispatcher.on( 'element', ( evt, data, conversionApi ) => {
 				// Check conversion api params.
 				// Check conversion api params.
 				expect( conversionApi.writer ).to.instanceof( ModelWriter );
 				expect( conversionApi.writer ).to.instanceof( ModelWriter );
 				expect( conversionApi.store ).to.deep.equal( {} );
 				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).
 				// Remember writer to check in next converter that is exactly the same instance (the same undo step).
 				writer = conversionApi.writer;
 				writer = conversionApi.writer;
@@ -78,9 +79,6 @@ describe( 'UpcastDispatcher', () => {
 				// Add some data to conversion storage to verify them in next converter.
 				// Add some data to conversion storage to verify them in next converter.
 				conversionApi.store.foo = 'bar';
 				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.
 				// Convert children - this will call second converter.
 				conversionApi.convertChildren( data.viewItem, data.modelCursor );
 				conversionApi.convertChildren( data.viewItem, data.modelCursor );
 
 
@@ -94,9 +92,6 @@ describe( 'UpcastDispatcher', () => {
 				// Data set by previous converter are remembered.
 				// Data set by previous converter are remembered.
 				expect( conversionApi.store ).to.deep.equal( { foo: 'bar' } );
 				expect( conversionApi.store ).to.deep.equal( { foo: 'bar' } );
 
 
-				// Split element is remembered as well.
-				expect( dispatcher._removeIfEmpty.size ).to.equal( 1 );
-
 				spy();
 				spy();
 			} );
 			} );
 
 
@@ -108,7 +103,6 @@ describe( 'UpcastDispatcher', () => {
 			// Conversion process properties should be cleared after conversion.
 			// Conversion process properties should be cleared after conversion.
 			expect( dispatcher.conversionApi.writer ).to.not.ok;
 			expect( dispatcher.conversionApi.writer ).to.not.ok;
 			expect( dispatcher.conversionApi.store ).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', () => {
 		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', () => {
 		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:
-				// <p><p></p></p>[<p></p>]<p></p><p>foo</p>
-				// 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 ) );
 			const result = model.change( writer => dispatcher.convert( viewElement, writer ) );
 
 
-			// Empty split elements should be removed and we should have the following result:
-			// [<p></p>]<p>foo</p>
-			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', () => {
 		it( 'should extract temporary markers elements from converter element and create static markers list', () => {
@@ -628,5 +614,99 @@ describe( 'UpcastDispatcher', () => {
 				sinon.assert.calledOnce( spy );
 				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;
+			} );
+		} );
 	} );
 	} );
 } );
 } );

+ 9 - 0
packages/ckeditor5-engine/tests/conversion/upcasthelpers.js

@@ -112,6 +112,15 @@ describe( 'UpcastHelpers', () => {
 			expectResult( new ViewContainerElement( 'p', { 'data-level': 2 } ), '' );
 			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' ), '<paragraph></paragraph>' );
+			expectResult( new ViewContainerElement( 'foo' ), '<paragraph></paragraph>' );
+		} );
+
 		it( 'should fire conversion of the element children', () => {
 		it( 'should fire conversion of the element children', () => {
 			upcastHelpers.elementToElement( { view: 'p', model: 'paragraph' } );
 			upcastHelpers.elementToElement( { view: 'p', model: 'paragraph' } );