소스 검색

Merge pull request #7652 from ckeditor/i/7336

Feature (engine): Introduced new upcast `ConversionApi` helper methods - `conversionApi.safeInsert()` and `conversionApi.updateConversionResult()`. New methods are intended to simplify writing event based element-to-element converters. Closes #7336.

MAJOR BREAKING CHANGE (engine): The `config.view` parameter for upcast element-to-element conversion helpers configurations is again mandatory. You can retain previous "catch-all" behavior for upcast converter using `config.view = /[\s\S]+/`.
Kuba Niegowski 5 년 전
부모
커밋
8d84af1610
20개의 변경된 파일1047개의 추가작업 그리고 279개의 파일을 삭제
  1. 3 37
      packages/ckeditor5-code-block/src/converters.js
  2. 0 0
      packages/ckeditor5-engine/docs/_snippets/framework/build-custom-element-converter-source.html
  3. 14 0
      packages/ckeditor5-engine/docs/_snippets/framework/build-custom-element-converter-source.js
  4. 43 0
      packages/ckeditor5-engine/docs/_snippets/framework/extending-content-custom-element-converter.html
  5. 165 0
      packages/ckeditor5-engine/docs/_snippets/framework/extending-content-custom-element-converter.js
  6. 2 2
      packages/ckeditor5-engine/docs/framework/guides/deep-dive/conversion-introduction.md
  7. 310 0
      packages/ckeditor5-engine/docs/framework/guides/deep-dive/custom-element-conversion.md
  8. 217 74
      packages/ckeditor5-engine/src/conversion/upcastdispatcher.js
  9. 18 61
      packages/ckeditor5-engine/src/conversion/upcasthelpers.js
  10. 1 1
      packages/ckeditor5-engine/src/dev-utils/model.js
  11. 242 5
      packages/ckeditor5-engine/tests/conversion/upcastdispatcher.js
  12. 3 2
      packages/ckeditor5-engine/tests/conversion/upcasthelpers.js
  13. 2 3
      packages/ckeditor5-heading/src/title.js
  14. 2 6
      packages/ckeditor5-image/src/image/converters.js
  15. 2 16
      packages/ckeditor5-list/src/converters.js
  16. 1 3
      packages/ckeditor5-list/tests/listediting.js
  17. 1 0
      packages/ckeditor5-paragraph/src/paragraph.js
  18. 15 65
      packages/ckeditor5-table/src/converters/upcasttable.js
  19. 5 3
      packages/ckeditor5-table/src/tableediting.js
  20. 1 1
      packages/ckeditor5-table/tests/converters/upcasttable.js

+ 3 - 37
packages/ckeditor5-code-block/src/converters.js

@@ -193,48 +193,14 @@ export function dataViewToModelCodeBlockInsertion( editingView, languageDefs ) {
 
 		writer.append( fragment, codeBlock );
 
-		// Let's see if the codeBlock can be inserted the current modelCursor.
-		const splitResult = conversionApi.splitToAllowedParent( codeBlock, data.modelCursor );
-
-		// When there is no split result it means that we can't insert element to model tree,
-		// so let's skip it.
-		if ( !splitResult ) {
+		// Let's try to insert code block.
+		if ( !conversionApi.safeInsert( codeBlock, data.modelCursor ) ) {
 			return;
 		}
 
-		// Insert element on allowed position.
-		writer.insert( codeBlock, splitResult.position );
-
 		consumable.consume( viewItem, { name: true } );
 		consumable.consume( viewChild, { name: true } );
 
-		const parts = conversionApi.getSplitParts( codeBlock );
-
-		// Set conversion result range.
-		data.modelRange = writer.createRange(
-			conversionApi.writer.createPositionBefore( codeBlock ),
-			conversionApi.writer.createPositionAfter( parts[ parts.length - 1 ] )
-		);
-
-		// If we had to split parent to insert our element then we want to continue conversion inside
-		// the split parent.
-		//
-		// before split:
-		//
-		//		<allowed><notAllowed>[]</notAllowed></allowed>
-		//
-		// after split:
-		//
-		//		<allowed>
-		//			<notAllowed></notAllowed>
-		//			<converted></converted>
-		//			<notAllowed>[]</notAllowed>
-		//		</allowed>
-		if ( splitResult.cursorParent ) {
-			data.modelCursor = writer.createPositionAt( splitResult.cursorParent, 0 );
-		} else {
-			// Otherwise just continue after the inserted element.
-			data.modelCursor = data.modelRange.end;
-		}
+		conversionApi.updateConversionResult( codeBlock, data );
 	};
 }

+ 0 - 0
packages/ckeditor5-engine/docs/_snippets/framework/build-custom-element-converter-source.html


+ 14 - 0
packages/ckeditor5-engine/docs/_snippets/framework/build-custom-element-converter-source.js

@@ -0,0 +1,14 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals window */
+
+import ClassicEditor from '@ckeditor/ckeditor5-build-classic/src/ckeditor';
+
+import { toWidget, toWidgetEditable } from '@ckeditor/ckeditor5-widget/src/utils';
+
+window.ClassicEditor = ClassicEditor;
+window.toWidget = toWidget;
+window.toWidgetEditable = toWidgetEditable;

+ 43 - 0
packages/ckeditor5-engine/docs/_snippets/framework/extending-content-custom-element-converter.html

@@ -0,0 +1,43 @@
+<style>
+	.info-box {
+		border: 1px solid hsl(0, 0%, 80%);
+		padding: 1em;
+		background: hsl(0, 0%, 45%);
+	}
+
+	.info-box-warning {
+		background: hsl(64, 74%, 85%);
+	}
+
+	.info-box-info {
+		background: hsl(205, 100%, 90%);
+	}
+
+	.info-box-title {
+		margin-bottom: 1em;
+		font-weight: bold;
+		color: inherit;
+	}
+
+	.info-box-content {
+		padding: 0 1em;
+		background: hsl(0, 0%, 100%);
+	}
+</style>
+
+<div id="editor-custom-element-converter">
+	<p>Info:</p>
+	<div class="info-box info-box-info">
+		<div class="info-box-title">Info</div>
+		<div class="info-box-content">
+			<p>Editable content of the <strong>info box</strong>.</p>
+		</div>
+	</div>
+	<p>Warning:</p>
+	<div class="info-box info-box-warning">
+		<div class="info-box-title">Warning</div>
+		<div class="info-box-content">
+			<p>Editable content of the <strong>info box</strong>.</p>
+		</div>
+	</div>
+</div>

+ 165 - 0
packages/ckeditor5-engine/docs/_snippets/framework/extending-content-custom-element-converter.js

@@ -0,0 +1,165 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals ClassicEditor, toWidget, toWidgetEditable, console, window, document */
+
+import { CS_CONFIG } from '@ckeditor/ckeditor5-cloud-services/tests/_utils/cloud-services-config';
+
+class InfoBox {
+	constructor( editor ) {
+		// Schema definition
+		editor.model.schema.register( 'infoBox', {
+			allowWhere: '$block',
+			allowContentOf: '$root',
+			isObject: true,
+			allowAttributes: [ 'infoBoxType' ]
+		} );
+
+		// Upcast converter.
+		editor.conversion.for( 'upcast' )
+			.add( dispatcher => dispatcher.on( 'element:div', upcastConverter ) );
+
+		// The downcast conversion must be split as we need a widget in the editing pipeline.
+		editor.conversion.for( 'editingDowncast' )
+			.add( dispatcher => dispatcher.on( 'insert:infoBox', editingDowncastConverter ) );
+		editor.conversion.for( 'dataDowncast' )
+			.add( dispatcher => dispatcher.on( 'insert:infoBox', dataDowncastConverter ) );
+	}
+}
+
+function upcastConverter( event, data, conversionApi ) {
+	const viewInfoBox = data.viewItem;
+
+	// Detect that view element is an info-box div.
+	// Otherwise, it should be handled by another converter.
+	if ( !viewInfoBox.hasClass( 'info-box' ) ) {
+		return;
+	}
+
+	// Create a model structure.
+	const modelElement = conversionApi.writer.createElement( 'infoBox', {
+		infoBoxType: getTypeFromViewElement( viewInfoBox )
+	} );
+
+	// Try to safely insert element - if it returns false the element can't be safely inserted
+	// into the content, and the conversion process must stop.
+	if ( !conversionApi.safeInsert( modelElement, data.modelCursor ) ) {
+		return;
+	}
+
+	// Mark info-box div as handled by this converter.
+	conversionApi.consumable.consume( viewInfoBox, { name: true } );
+
+	// Let's assume that the HTML structure is always the same.
+	const viewInfoBoxTitle = viewInfoBox.getChild( 0 );
+	const viewInfoBoxContent = viewInfoBox.getChild( 1 );
+
+	// Mark info-box inner elements as handled by this converter.
+	conversionApi.consumable.consume( viewInfoBoxTitle, { name: true } );
+	conversionApi.consumable.consume( viewInfoBoxContent, { name: true } );
+
+	// Let the editor handle children of the info-box content conversion.
+	conversionApi.convertChildren( viewInfoBoxContent, modelElement );
+
+	// Conversion requires updating result data structure properly.
+	conversionApi.updateConversionResult( modelElement, data );
+}
+
+function editingDowncastConverter( event, data, conversionApi ) {
+	let { infoBox, infoBoxContent, infoBoxTitle } = createViewElements( data, conversionApi );
+
+	// Decorate view items as widgets.
+	infoBox = toWidget( infoBox, conversionApi.writer, { label: 'simple box widget' } );
+	infoBoxContent = toWidgetEditable( infoBoxContent, conversionApi.writer );
+
+	insertViewElements( data, conversionApi, infoBox, infoBoxTitle, infoBoxContent );
+}
+
+function dataDowncastConverter( event, data, conversionApi ) {
+	const { infoBox, infoBoxContent, infoBoxTitle } = createViewElements( data, conversionApi );
+
+	insertViewElements( data, conversionApi, infoBox, infoBoxTitle, infoBoxContent );
+}
+
+function createViewElements( data, conversionApi ) {
+	const type = data.item.getAttribute( 'infoBoxType' );
+
+	const infoBox = conversionApi.writer.createContainerElement( 'div', {
+		class: `info-box info-box-${ type.toLowerCase() }`
+	} );
+	const infoBoxContent = conversionApi.writer.createEditableElement( 'div', {
+		class: 'info-box-content'
+	} );
+
+	const infoBoxTitle = conversionApi.writer.createUIElement( 'div',
+		{ class: 'info-box-title' },
+		function( domDocument ) {
+			const domElement = this.toDomElement( domDocument );
+
+			domElement.innerText = type;
+
+			return domElement;
+		} );
+
+	return { infoBox, infoBoxContent, infoBoxTitle };
+}
+
+function insertViewElements( data, conversionApi, infoBox, infoBoxTitle, infoBoxContent ) {
+	conversionApi.consumable.consume( data.item, 'insert' );
+
+	conversionApi.writer.insert(
+		conversionApi.writer.createPositionAt( infoBox, 0 ),
+		infoBoxTitle
+	);
+	conversionApi.writer.insert(
+		conversionApi.writer.createPositionAt( infoBox, 1 ),
+		infoBoxContent
+	);
+
+	conversionApi.mapper.bindElements( data.item, infoBox );
+	conversionApi.mapper.bindElements( data.item, infoBoxContent );
+
+	conversionApi.writer.insert(
+		conversionApi.mapper.toViewPosition( data.range.start ),
+		infoBox
+	);
+}
+
+ClassicEditor
+	.create( document.querySelector( '#editor-custom-element-converter' ), {
+		cloudServices: CS_CONFIG,
+		extraPlugins: [ InfoBox ],
+		image: {
+			toolbar: [ 'imageStyle:full', 'imageStyle:side', '|', 'imageTextAlternative' ]
+		},
+		table: {
+			contentToolbar: [
+				'tableColumn',
+				'tableRow',
+				'mergeTableCells'
+			]
+		},
+		toolbar: {
+			viewportTopOffset: window.getViewportTopOffsetConfig()
+		}
+	} )
+	.then( editor => {
+		window.editor = editor;
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );
+
+function getTypeFromViewElement( viewElement ) {
+	if ( viewElement.hasClass( 'info-box-info' ) ) {
+		return 'Info';
+	}
+
+	if ( viewElement.hasClass( 'info-box-warning' ) ) {
+		return 'Warning';
+	}
+
+	return 'None';
+}

+ 2 - 2
packages/ckeditor5-engine/docs/framework/guides/deep-dive/conversion-introduction.md

@@ -8,11 +8,11 @@ order: 10
 # For now, due to lack of content, it is called "advanced concepts".
 ---
 
-# Advanced conversion concepts
+# Advanced conversion concepts &mdash; attributes
 
 This guide extends the {@link framework/guides/architecture/editing-engine introduction to CKEditor 5 editing engine architecture}. Therefore, we highly recommend reading the former guide first.
 
-In this guide we will dive deeper into some of the conversion concepts.
+In this guide we will dive deeper into some of the conversion concepts related to model attributes.
 
 ## Inline and block content
 

+ 310 - 0
packages/ckeditor5-engine/docs/framework/guides/deep-dive/custom-element-conversion.md

@@ -0,0 +1,310 @@
+---
+category: framework-deep-dive-conversion
+menu-title: Custom element conversion
+order: 40
+---
+
+{@snippet framework/build-custom-element-converter-source}
+
+There are three levels on which elements can be converted:
+
+* By using the two-way converter: {@link module:engine/conversion/conversion~Conversion#elementToElement `conversion.elementToElement()`}. It is a fully declarative API. It is the least powerful option but it is the easiest one to use.
+* By using one-way converters: for example {@link module:engine/conversion/downcasthelpers~DowncastHelpers#elementToElement `conversion.for( 'downcast' ).elementToElement()`} and {@link module:engine/conversion/upcasthelpers~UpcastHelpers#elementToElement `conversion.for( 'upcast' ).elementToElement()`}. In this case, you need to define at least two converters (for upcast and downcast), but the "how" part becomes a callback, and hence you gain more control over it.
+* Finally, by using event-based converters. In this case, you need to listen to events fired by {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher} and {@link module:engine/conversion/upcastdispatcher~UpcastDispatcher}. This method has the full access to every bit of logic that a converter needs to implement and therefore it can be used to write the most complex conversion methods.
+
+In this guide, we will show you how to migrate from a simple two-way converter to an event-based converters as the requirements regarding the feature get more complex.
+
+## Introduction
+
+Let's assume that content in your application contains "info boxes". As for now, it was only required to wrap part of a content in a `<div>` element that would look in the data and editing views like this:
+
+```html
+<div class="info-box">
+	<!-- any editable content -->
+	<p>This is <strong>important!</strong></p>
+</div>
+```
+
+This data is represented in the model as the following structure:
+
+```html
+<infoBox>
+	<!-- any $block content: -->
+	<paragraph><$text>This is </$text><$text bold="true">important!</$text></paragraph>
+</infoBox>
+```
+
+This can be easily done with the below schema and converters in a simple `InfoBox` plugin:
+
+```js
+class InfoBox {
+	constructor( editor ) {
+		// 1. Define infoBox as an object that can be contained any other content.
+		editor.model.schema.register( 'infoBox', {
+			allowWhere: '$block',
+			allowContentOf: '$root',
+			isObject: true
+		} );
+
+		// 2. Conversion is straight forward:
+		editor.conversion.elementToElement( {
+			model: 'infoBox',
+			view: {
+				name: 'div',
+				classes: 'info-box'
+			}
+		} );
+	}
+}
+```
+
+## Migrating to an event-based converter
+
+Let's now assume that the requirements have changed and there is a need for adding an additional element in the data and editing views that will display the type of the info box (warning, error, info, etc.).
+
+The new info box structure:
+
+```html
+<div class="info-box info-box-warning">
+	<div class="info-box-title">Warning</div>
+	<div class="info-box-content">
+		<!-- any editable content -->
+		<p>This is <strong>important!</strong></p>
+	</div>
+</div>
+```
+
+The "Warning" part should not be editable. It defines a type of the info box so we can store this  bit of information as an attribute of the `<infoBox>` element:
+
+```html
+<infoBox infoBoxType="warning">
+	<!-- any $block content: -->
+	<paragraph><$text>This is </$text><$text bold="true">important!</$text></paragraph>
+</infoBox>
+```
+
+Let's see how to update our basic implementation to cover these requirements.
+
+### Demo
+
+Below is a demo of the editor with the example info box.
+
+{@snippet framework/extending-content-custom-element-converter}
+
+### Schema
+
+The type of the box is defined by the additional class on the main `<div>` but it is also represented as text in `<div class="info-box-title">`. All the info box content must be now placed inside `<div class="info-box-content">` instead of the main wrapper.
+
+For the above requirements we can see that the model structure of the `infoBox` does not need to change much. We can still use a single element in the model. The only addition to the model is an attribute that will hold information about the info box type:
+
+```js
+editor.model.schema.register( 'infoBox', {
+	allowWhere: '$block',
+	allowContentOf: '$root',
+	isObject: true,
+	allowAttributes: [ 'infoBoxType' ] // Added
+} );
+```
+
+### Event-based upcast converter
+
+The conversion of the type of the box itself could be achieved by using {@link module:engine/conversion/conversion~Conversion#attributeToAttribute `attributeToAttribute()`} (`info-box-*` CSS classes to the `infoBoxType` model attribute). However, two more changes were made to the data format that we need to handle:
+
+* There is the new `<div class="info-box-title">` element that should be ignored during upcast conversion as it duplicates the information conveyed by the main element's CSS class.
+* The content of the info box is now located inside another element (previously it was located directly in the main wrapper).
+
+Neither two-way nor one-way converters can handle such conversion. Therefore, we need to use an event-based converter with the following behavior:
+
+1. Create model `<infoBox>` element with `infoBoxType` attribute.
+1. Skip conversion of `<div class="info-box-title">` as the information about type can be obtained from the wrapper's CSS classes.
+1. Convert children of `<div class="info-box-content">` and insert them directly into `<infoBox>`.
+
+```js
+function upcastConverter( event, data, conversionApi ) {
+	const viewInfoBox = data.viewItem;
+
+	// Check whether the view element is an info box <div>.
+	// Otherwise, it should be handled by another converter.
+	if ( !viewInfoBox.hasClass( 'info-box' ) ) {
+		return;
+	}
+
+	// Create a model structure.
+	const modelElement = conversionApi.writer.createElement( 'infoBox', {
+		infoBoxType: getTypeFromViewElement( viewInfoBox )
+	} );
+
+	// Try to safely insert the element into the model structure.
+	// If `safeInsert()` returns `false` the element cannot be safely inserted
+	// into the content, and the conversion process must stop.
+	// This may happen if the data that we are converting has incorrect structure
+	// (e.g. was copied from an external website).
+	if ( !conversionApi.safeInsert( modelElement, data.modelCursor ) ) {
+		return;
+	}
+
+	// Mark the info box <div> as handled by this converter.
+	conversionApi.consumable.consume( viewInfoBox, { name: true } );
+
+	// Let's assume that the HTML structure is always the same.
+	// Note: for full bulletproofing this converter we should also check
+	// whether these elements are the right ones.
+	const viewInfoBoxTitle = viewInfoBox.getChild( 0 );
+	const viewInfoBoxContent = viewInfoBox.getChild( 1 );
+
+	// Mark info box inner elements (title and content <div>s) as handled by this converter.
+	conversionApi.consumable.consume( viewInfoBoxTitle, { name: true } );
+	conversionApi.consumable.consume( viewInfoBoxContent, { name: true } );
+
+	// Let the editor handle children of <div class="info-box-content">.
+	conversionApi.convertChildren( viewInfoBoxContent, modelElement );
+
+	// Finally, update the conversion's modelRange and modelCursor.
+	conversionApi.updateConversionResult( modelElement, data );
+}
+
+// Helper function to read the type from the view classes.
+function getTypeFromViewElement( viewElement ) {
+	if ( viewElement.hasClass( 'info-box-info' ) ) {
+		return 'Info';
+	}
+
+	if ( viewElement.hasClass( 'info-box-warning' ) ) {
+		return 'Warning';
+	}
+
+	return 'None';
+}
+```
+
+This upcast converter callback can now be plugged by adding a listener to the {@link module:engine/conversion/upcastdispatcher~UpcastDispatcher#element `UpcastDispatcher#element` event}. We will listen to `element:div` to ensure that the callback is called only for `<div>` elements.
+
+```js
+editor.conversion.for( 'upcast' )
+	.add( dispatcher => dispatcher.on( 'element:div', upcastConverter ) );
+```
+
+### Event-based downcast converter
+
+The missing bit are the downcast converters for the editing and data pipelines. We want to use the widget system to make the info box behave like an "object". The other aspect that we need to take care of is the fact that the view structure has more elements than the model structure. In this case, we could actually use one-way converters. However, we will showcase how an event-based converter would look.
+
+<info-box>
+	See the {@link framework/guides/tutorials/implementing-a-block-widget Implementing a block widget} to learn about the widget system.
+</info-box>
+
+The remaining downcast converters:
+
+```js
+function editingDowncastConverter( event, data, conversionApi ) {
+	let { infoBox, infoBoxContent, infoBoxTitle } = createViewElements( data, conversionApi );
+
+	// Decorate view items as a widget and widget editable area.
+	infoBox = toWidget( infoBox, conversionApi.writer, { label: 'info box widget' } );
+	infoBoxContent = toWidgetEditable( infoBoxContent, conversionApi.writer );
+
+	insertViewElements( data, conversionApi, infoBox, infoBoxTitle, infoBoxContent );
+}
+
+function dataDowncastConverter( event, data, conversionApi ) {
+	const { infoBox, infoBoxContent, infoBoxTitle } = createViewElements( data, conversionApi );
+
+	insertViewElements( data, conversionApi, infoBox, infoBoxTitle, infoBoxContent );
+}
+
+function createViewElements( data, conversionApi ) {
+	const type = data.item.getAttribute( 'infoBoxType' );
+
+	const infoBox = conversionApi.writer.createContainerElement( 'div', {
+		class: `info-box info-box-${ type.toLowerCase() }`
+	} );
+	const infoBoxContent = conversionApi.writer.createEditableElement( 'div', {
+		class: 'info-box-content'
+	} );
+
+	const infoBoxTitle = conversionApi.writer.createUIElement( 'div',
+		{ class: 'info-box-title' },
+		function( domDocument ) {
+			const domElement = this.toDomElement( domDocument );
+
+			domElement.innerText = type;
+
+			return domElement;
+		} );
+
+	return { infoBox, infoBoxContent, infoBoxTitle };
+}
+
+function insertViewElements( data, conversionApi, infoBox, infoBoxTitle, infoBoxContent ) {
+	conversionApi.consumable.consume( data.item, 'insert' );
+
+	conversionApi.writer.insert(
+		conversionApi.writer.createPositionAt( infoBox, 0 ),
+		infoBoxTitle
+	);
+	conversionApi.writer.insert(
+		conversionApi.writer.createPositionAt( infoBox, 1 ),
+		infoBoxContent
+	);
+
+	// The default mapping between the model <infoBox> and its view representation.
+	conversionApi.mapper.bindElements( data.item, infoBox );
+	// However, since the model <infoBox> content need to end up in the inner
+	// <div class="info-box-content"> we need to bind one with another overriding
+	// part of the default binding.
+	conversionApi.mapper.bindElements( data.item, infoBoxContent );
+
+	conversionApi.writer.insert(
+		conversionApi.mapper.toViewPosition( data.range.start ),
+		infoBox
+	);
+}
+```
+
+These two converters need to be plugged as listeners to the {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#insert `DowncastDispatcher#insert` event}:
+
+```js
+editor.conversion.for( 'editingDowncast' )
+	.add( dispatcher => dispatcher.on( 'insert:infoBox', editingDowncastConverter ) );
+editor.conversion.for( 'dataDowncast' )
+	.add( dispatcher => dispatcher.on( 'insert:infoBox', dataDowncastConverter ) );
+```
+
+### Updated plugin code
+
+The updated `InfoBox` plugin that glues all this together:
+
+```js
+class InfoBox {
+	constructor( editor ) {
+		// Schema definition
+		editor.model.schema.register( 'infoBox', {
+			allowWhere: '$block',
+			allowContentOf: '$root',
+			isObject: true,
+			allowAttributes: [ 'infoBoxType' ]
+		} );
+
+		// Upcast converter.
+		editor.conversion.for( 'upcast' )
+			.add( dispatcher => dispatcher.on( 'element:div', upcastConverter ) );
+
+		// The downcast conversion must be split as we need a widget in the editing pipeline.
+		editor.conversion.for( 'editingDowncast' )
+			.add( dispatcher => dispatcher.on( 'insert:infoBox', editingDowncastConverter ) );
+		editor.conversion.for( 'dataDowncast' )
+			.add( dispatcher => dispatcher.on( 'insert:infoBox', dataDowncastConverter ) );
+	}
+}
+
+function upcastConverter() {
+	// ...
+}
+
+function editingDowncastConverter() {
+	// ...
+}
+
+function dataDowncastConverter() {
+	// ...
+}
+```

+ 217 - 74
packages/ckeditor5-engine/src/conversion/upcastdispatcher.js

@@ -17,28 +17,34 @@ import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin';
 import mix from '@ckeditor/ckeditor5-utils/src/mix';
 
 /**
- * `UpcastDispatcher` is a central point of {@link module:engine/view/view view} conversion, which is a process of
+ * `UpcastDispatcher` is a central point of the view to model conversion, which is a process of
  * converting given {@link module:engine/view/documentfragment~DocumentFragment view document fragment} or
- * {@link module:engine/view/element~Element} into another structure.
- * In default application, {@link module:engine/view/view view} is converted to {@link module:engine/model/model}.
+ * {@link module:engine/view/element~Element view element} into a correct model structure.
  *
- * During conversion process, for all {@link module:engine/view/node~Node view nodes} from the converted view document fragment,
- * `UpcastDispatcher` fires corresponding events. Special callbacks called "converters" should listen to
- * `UpcastDispatcher` for those events.
+ * During the conversion process, the dispatcher fires events for all {@link module:engine/view/node~Node view nodes}
+ * from the converted view document fragment.
+ * Special callbacks called "converters" should listen to these events in order to convert these view nodes.
  *
- * Each callback, as the second argument, is passed a special object `data` that has `viewItem`, `modelCursor` and
- * `modelRange` properties. `viewItem` property contains {@link module:engine/view/node~Node view node} or
- * {@link module:engine/view/documentfragment~DocumentFragment view document fragment}
- * that is converted at the moment and might be handled by the callback. `modelRange` property should be used to save the result
- * of conversion and is always a {@link module:engine/model/range~Range} when conversion result is correct.
- * `modelCursor` property is a {@link module:engine/model/position~Position position} on which conversion result will be inserted
- * and is a context according to {@link module:engine/model/schema~Schema schema} will be checked before the conversion.
- * See also {@link ~UpcastDispatcher#convert}. It is also shared by reference by all callbacks listening to given event.
+ * The second parameter of the callback is the `data` object with the following properties:
  *
- * The third parameter passed to a callback is an instance of {@link ~UpcastDispatcher}
+ * * `data.viewItem` contains {@link module:engine/view/node~Node view node} or
+ * {@link module:engine/view/documentfragment~DocumentFragment view document fragment}
+ * that is converted at the moment and might be handled by the callback.
+ * * `data.modelRange` is used to point to the result
+ * of the current conversion (e.g. the element that is being inserted)
+ * and is always a {@link module:engine/model/range~Range} when the succeeds.
+ * * `data.modelCursor` is a {@link module:engine/model/position~Position position} on which the converter should insert
+ * newly created items.
+ *
+ * The third parameter of the callback is an instance of {@link module:engine/conversion/upcastdispatcher~UpcastConversionApi}
  * which provides additional tools for converters.
  *
- * Examples of providing callbacks for `UpcastDispatcher`:
+ * You can read more about conversion in the following guides:
+ *
+ * * {@glink framework/guides/deep-dive/conversion/conversion-introduction Advanced conversion concepts &mdash; attributes}
+ * * {@glink framework/guides/deep-dive/conversion/custom-element-conversion Custom element conversion}
+ *
+ * Examples of event-based converters:
  *
  *		// Converter for links (<a>).
  *		editor.data.upcastDispatcher.on( 'element:a', ( evt, data, conversionApi ) => {
@@ -76,42 +82,31 @@ import mix from '@ckeditor/ckeditor5-utils/src/mix';
  *			}
  *		}, { priority: 'low' } );
  *
- *		// Convert all elements which have no custom converter into paragraph (autoparagraphing).
- *  	editor.data.upcastDispatcher.on( 'element', ( evt, data, conversionApi ) => {
- *  	 	// When element is already consumed by higher priority converters then do nothing.
- *  	 	if ( conversionApi.consumable.test( data.viewItem, { name: data.viewItem.name } ) ) {
- *  	 			const paragraph = conversionApi.writer.createElement( 'paragraph' );
- *
- *  	 			// Find allowed parent for paragraph that we are going to insert. If current parent does not allow
- *  	 			// to insert paragraph but one of the ancestors does then split nodes to allowed parent.
- *  	 			const splitResult = conversionApi.splitToAllowedParent( paragraph, data.modelCursor );
- *
- *  	 			// When there is no split result it means that we can't insert paragraph in this position.
- *  	 			if ( splitResult ) {
- *  	 				// Insert paragraph in allowed position.
- *  	 				conversionApi.writer.insert( paragraph, splitResult.position );
- *
- *  	 				// Convert children to paragraph.
- *  	 				const { modelRange } = conversionApi.convertChildren(
- *  	 					data.viewItem,
- *  	 					conversionApi.writer.createPositionAt( paragraph, 0 )
- *  	 				);
- *
- * 						// Set as conversion result, attribute converters may use this property.
- *  	 				data.modelRange = conversionApi.writer.createRange(
- *  	 					conversionApi.writer.createPositionBefore( paragraph ),
- *  	 					modelRange.end
- *  	 				);
- *
- *  	 				// Continue conversion inside paragraph.
- *  	 				data.modelCursor = data.modelRange.end;
- *  	 			}
- *  	 		}
- *  	 	}
- *  	 }, { priority: 'low' } );
- *
- * Before each conversion process, `UpcastDispatcher` fires {@link ~UpcastDispatcher#event:viewCleanup}
- * event which can be used to prepare tree view for conversion.
+ *		// Convert all elements which have no custom converter into a paragraph (autoparagraphing).
+ *		editor.data.upcastDispatcher.on( 'element', ( evt, data, conversionApi ) => {
+ *			// Check if element can be converted.
+ *			if ( !conversionApi.consumable.test( data.viewItem, { name: data.viewItem.name } ) ) {
+ *				// When element is already consumed by higher priority converters then do nothing.
+ *				return;
+ *			}
+ *
+ *			const paragraph = conversionApi.writer.createElement( 'paragraph' );
+ *
+ *			// Try to safely insert paragraph at model cursor - it will find an allowed parent for a current element.
+ *			if ( !conversionApi.safeInsert( paragraph, data.modelCursor ) ) {
+ *				// When element was not inserted it means that we can't insert paragraph at this position.
+ *				return;
+ *			}
+ *
+ *			// Consume the inserted element.
+ *			conversionApi.consumable.consume( data.viewItem, { name: data.viewItem.name } ) );
+ *
+ *			// Convert children to paragraph.
+ *			const { modelRange } = conversionApi.convertChildren( data.viewItem,  paragraph ) );
+ *
+ *			// Update `modelRange` and `modelCursor` in a `data` as a conversion result.
+ *			conversionApi.updateConversionResult( paragraph, data );
+ *		}, { priority: 'low' } );
  *
  * @mixes module:utils/emittermixin~EmitterMixin
  * @fires viewCleanup
@@ -138,6 +133,16 @@ export default class UpcastDispatcher {
 		 */
 		this._splitParts = new Map();
 
+		/**
+		 * List of cursor parent elements that were created during splitting.
+		 *
+		 * After conversion process the list is cleared.
+		 *
+		 * @private
+		 * @type {Map.<module:engine/model/element~Element,Array.<module:engine/model/element~Element>>}
+		 */
+		this._cursorParents = new Map();
+
 		/**
 		 * Position in the temporary structure where the converted content is inserted. The structure reflect the context of
 		 * the target position where the content will be inserted. This property is build based on the context parameter of the
@@ -155,10 +160,13 @@ export default class UpcastDispatcher {
 		 */
 		this.conversionApi = Object.assign( {}, conversionApi );
 
-		// `convertItem`, `convertChildren` and `splitToAllowedParent` are bound to this `UpcastDispatcher`
-		// instance and set on `conversionApi`. This way only a part of `UpcastDispatcher` API is exposed.
+		// The below methods are bound to this `UpcastDispatcher` instance and set on `conversionApi`.
+		// This way only a part of `UpcastDispatcher` API is exposed.
 		this.conversionApi.convertItem = this._convertItem.bind( this );
 		this.conversionApi.convertChildren = this._convertChildren.bind( this );
+		this.conversionApi.safeInsert = this._safeInsert.bind( this );
+		this.conversionApi.updateConversionResult = this._updateConversionResult.bind( this );
+		// Advanced API - use only if custom position handling is needed.
 		this.conversionApi.splitToAllowedParent = this._splitToAllowedParent.bind( this );
 		this.conversionApi.getSplitParts = this._getSplitParts.bind( this );
 	}
@@ -217,8 +225,9 @@ export default class UpcastDispatcher {
 		// Clear context position.
 		this._modelCursor = null;
 
-		// Clear split elements lists.
+		// Clear split elements & parents lists.
 		this._splitParts.clear();
+		this._cursorParents.clear();
 
 		// Clear conversion API.
 		this.conversionApi.writer = null;
@@ -262,9 +271,11 @@ export default class UpcastDispatcher {
 	 * @private
 	 * @see module:engine/conversion/upcastdispatcher~UpcastConversionApi#convertChildren
 	 */
-	_convertChildren( viewItem, modelCursor ) {
-		const modelRange = new ModelRange( modelCursor );
-		let nextModelCursor = modelCursor;
+	_convertChildren( viewItem, elementOrModelCursor ) {
+		let nextModelCursor = elementOrModelCursor.is( 'position' ) ?
+			elementOrModelCursor : ModelPosition._createAt( elementOrModelCursor, 0 );
+
+		const modelRange = new ModelRange( nextModelCursor );
 
 		for ( const viewChild of Array.from( viewItem.getChildren() ) ) {
 			const result = this._convertItem( viewChild, nextModelCursor );
@@ -278,6 +289,61 @@ export default class UpcastDispatcher {
 		return { modelRange, modelCursor: nextModelCursor };
 	}
 
+	/**
+	 * @private
+	 * @see module:engine/conversion/upcastdispatcher~UpcastConversionApi#safeInsert
+	 */
+	_safeInsert( modelElement, position ) {
+		// Find allowed parent for element that we are going to insert.
+		// If current parent does not allow to insert element but one of the ancestors does
+		// then split nodes to allowed parent.
+		const splitResult = this._splitToAllowedParent( modelElement, position );
+
+		// When there is no split result it means that we can't insert element to model tree, so let's skip it.
+		if ( !splitResult ) {
+			return false;
+		}
+
+		// Insert element on allowed position.
+		this.conversionApi.writer.insert( modelElement, splitResult.position );
+
+		return true;
+	}
+
+	/**
+	 * @private
+	 * @see module:engine/conversion/upcastdispatcher~UpcastConversionApi#updateConversionResult
+	 */
+	_updateConversionResult( modelElement, data ) {
+		const parts = this._getSplitParts( modelElement );
+
+		const writer = this.conversionApi.writer;
+
+		// Set conversion result range - only if not set already.
+		if ( !data.modelRange ) {
+			data.modelRange = writer.createRange(
+				writer.createPositionBefore( modelElement ),
+				writer.createPositionAfter( parts[ parts.length - 1 ] )
+			);
+		}
+
+		const savedCursorParent = this._cursorParents.get( modelElement );
+
+		// Now we need to check where the `modelCursor` should be.
+		if ( savedCursorParent ) {
+			// 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>
+
+			data.modelCursor = writer.createPositionAt( savedCursorParent, 0 );
+		} else {
+			// Otherwise just continue after inserted element.
+
+			data.modelCursor = data.modelRange.end;
+		}
+	}
+
 	/**
 	 * @private
 	 * @see module:engine/conversion/upcastdispatcher~UpcastConversionApi#splitToAllowedParent
@@ -332,9 +398,12 @@ export default class UpcastDispatcher {
 			}
 		}
 
+		const cursorParent = splitResult.range.end.parent;
+		this._cursorParents.set( node, cursorParent );
+
 		return {
 			position: splitResult.position,
-			cursorParent: splitResult.range.end.parent
+			cursorParent
 		};
 	}
 
@@ -415,14 +484,9 @@ export default class UpcastDispatcher {
 	 * all elements conversion or to conversion of specific elements.
 	 *
 	 * @event element
-	 * @param {Object} data Conversion data. Keep in mind that this object is shared by reference between all
-	 * callbacks that will be called. This means that callbacks can override values if needed, and those values will
-	 * be available in other callbacks.
-	 * @param {module:engine/view/item~Item} data.viewItem Converted item.
-	 * @param {module:engine/model/position~Position} data.modelCursor Position where a converter should start changes.
-	 * Change this value for the next converter to tell where the conversion should continue.
-	 * @param {module:engine/model/range~Range} data.modelRange The current state of conversion result. Every change to
-	 * converted element should be reflected by setting or modifying this property.
+	 * @param {module:engine/conversion/upcastdispatcher~UpcastConversionData} data Conversion data. Keep in mind that this object is shared
+	 * by reference between all callbacks that will be called. This means that callbacks can override values if needed, and those values
+	 * will be available in other callbacks.
 	 * @param {module:engine/conversion/upcastdispatcher~UpcastConversionApi} conversionApi Conversion utilities to be used by callback.
 	 */
 
@@ -507,9 +571,8 @@ function createContextTree( contextDefinition, writer ) {
 }
 
 /**
- * Conversion interface that is registered for given {@link module:engine/conversion/upcastdispatcher~UpcastDispatcher}
- * and is passed as one of parameters when {@link module:engine/conversion/upcastdispatcher~UpcastDispatcher dispatcher}
- * fires it's events.
+ * A set of conversion utils available as the third parameter of
+ * {@link module:engine/conversion/upcastdispatcher~UpcastDispatcher upcast dispatcher}'s events.
  *
  * @interface module:engine/conversion/upcastdispatcher~UpcastConversionApi
  */
@@ -541,13 +604,74 @@ function createContextTree( contextDefinition, writer ) {
  * @fires module:engine/conversion/upcastdispatcher~UpcastDispatcher#event:text
  * @fires module:engine/conversion/upcastdispatcher~UpcastDispatcher#event:documentFragment
  * @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|module:engine/model/element~Element} positionOrElement Position or element 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.
  * When no children was converted then range is collapsed.
  * @returns {module:engine/model/position~Position} result.modelCursor Position where conversion should be continued.
  */
 
+/**
+ * Safely inserts an element to the document checking {@link module:engine/model/schema~Schema schema} to find allowed parent for
+ * an element that we are going to insert starting from given position. If current parent does not allow to insert element
+ * but one of the ancestors does then split nodes to allowed parent.
+ *
+ * If schema allows to insert node in given position, nothing is split.
+ *
+ * If it was not possible to find allowed parent, `false` is returned, nothing is split.
+ *
+ * Otherwise, ancestors are split.
+ *
+ * For instance, if `<image>` is not allowed in `<paragraph>` but is allowed in `$root`:
+ *
+ *		<paragraph>foo[]bar</paragraph>
+ *
+ *		-> safe insert for `<image>` will split ->
+ *
+ *		<paragraph>foo</paragraph>[]<paragraph>bar</paragraph>
+ *
+ * Example usage:
+ *
+ *		const myElement = conversionApi.writer.createElement( 'myElement' );
+ *
+ *		if ( !conversionApi.safeInsert( myElement, data.modelCursor ) ) {
+ *			return;
+ *		}
+ *
+ * The split result is saved and {@link #updateConversionResult} should be used to update
+ * {@link module:engine/conversion/upcastdispatcher~UpcastConversionData conversion data}.
+ *
+ * @method #safeInsert
+ * @param {module:engine/model/node~Node} node Node to insert.
+ * @param {module:engine/model/position~Position} position Position on which element is going to be inserted.
+ * @returns {Boolean} Split result. If it was not possible to find allowed position `false` is returned.
+ */
+
+/**
+ * Updates the conversion result and sets proper {@link module:engine/conversion/upcastdispatcher~UpcastConversionData#modelRange} and
+ * next {@link module:engine/conversion/upcastdispatcher~UpcastConversionData#modelCursor} after the conversion.
+ * Used together with {@link #safeInsert} enables you to easily convert elements without worrying if the node was split
+ * during its children conversion.
+ *
+ * Example of a usage in a converter code:
+ *
+ *		const myElement = conversionApi.writer.createElement( 'myElement' );
+ *
+ *		if ( !conversionApi.safeInsert( myElement, data.modelCursor ) ) {
+ *			return;
+ *		}
+ *
+ *		// Children conversion may split `myElement`.
+ *		conversionApi.convertChildren( data.viewItem, myElement );
+ *
+ *		conversionApi.updateConversionResult( myElement, data );
+ *
+ * @method #updateConversionResult
+ * @param {module:engine/model/element~Element} element
+ * @param {module:engine/conversion/upcastdispatcher~UpcastConversionData} data Conversion data.
+ * @param {module:engine/conversion/upcastdispatcher~UpcastConversionApi} conversionApi Conversion utilities to be used by callback.
+ */
+
 /**
  * Checks {@link module:engine/model/schema~Schema schema} to find allowed parent for element that we are going to insert
  * starting from given position. If current parent does not allow to insert element but one of the ancestors does then
@@ -563,13 +687,15 @@ function createContextTree( contextDefinition, writer ) {
  *
  *		<paragraph>foo[]bar</paragraph>
  *
- *  	-> split for `<image>` ->
+ *		-> split for `<image>` ->
  *
- *  	<paragraph>foo</paragraph>[]<paragraph>bar</paragraph>
+ *		<paragraph>foo</paragraph>[]<paragraph>bar</paragraph>
  *
  * In the sample above position between `<paragraph>` elements will be returned as `position` and the second `paragraph`
  * as `cursorParent`.
  *
+ * **Note:** This is an advanced method. For most cases {@link #safeInsert} and {@link #updateConversionResult} should be used.
+ *
  * @method #splitToAllowedParent
  * @param {module:engine/model/position~Position} position Position on which element is going to be inserted.
  * @param {module:engine/model/node~Node} node Node to insert.
@@ -596,7 +722,7 @@ function createContextTree( contextDefinition, writer ) {
  *		const myElement = conversionApi.writer.createElement( 'myElement' );
  *
  *		// Children conversion may split `myElement`.
- *		conversionApi.convertChildren( myElement, modelCursor );
+ *		conversionApi.convertChildren( data.viewItem, data.modelCursor );
  *
  *		const splitParts = conversionApi.getSplitParts( myElement );
  *		const lastSplitPart = splitParts[ splitParts.length - 1 ];
@@ -614,6 +740,8 @@ function createContextTree( contextDefinition, writer ) {
  * 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.
  *
+ * **Note:** This is an advanced method. For most cases {@link #safeInsert} and {@link #updateConversionResult} should be used.
+ *
  * @method #getSplitParts
  * @param {module:engine/model/element~Element} element
  * @returns {Array.<module:engine/model/element~Element>}
@@ -648,3 +776,18 @@ function createContextTree( contextDefinition, writer ) {
  *
  * @member {module:engine/model/writer~Writer} #writer
  */
+
+/**
+ * Conversion data.
+ *
+ * **Note:** Keep in mind that this object is shared by reference between all conversion callbacks that will be called.
+ * This means that callbacks can override values if needed, and those values will be available in other callbacks.
+ *
+ * @typedef {Object} module:engine/conversion/upcastdispatcher~UpcastConversionData
+ *
+ * @property {module:engine/view/item~Item} viewItem Converted item.
+ * @property {module:engine/model/position~Position} modelCursor Position where a converter should start changes.
+ * Change this value for the next converter to tell where the conversion should continue.
+ * @property {module:engine/model/range~Range} [modelRange] The current state of conversion result. Every change to
+ * converted element should be reflected by setting or modifying this property.
+ */

+ 18 - 61
packages/ckeditor5-engine/src/conversion/upcasthelpers.js

@@ -4,11 +4,9 @@
  */
 
 import Matcher from '../view/matcher';
-import ModelRange from '../model/range';
 import ConversionHelpers from './conversionhelpers';
 
 import { cloneDeep } from 'lodash-es';
-import ModelSelection from '../model/selection';
 import { attachLinkToDocumentation } from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 
 import priorities from '@ckeditor/ckeditor5-utils/src/priorities';
@@ -468,7 +466,10 @@ export function convertText() {
 
 				conversionApi.writer.insert( text, data.modelCursor );
 
-				data.modelRange = ModelRange._createFromPositionAndShift( data.modelCursor, text.offsetSize );
+				data.modelRange = conversionApi.writer.createRange(
+					data.modelCursor,
+					data.modelCursor.getShiftedBy( text.offsetSize )
+				);
 				data.modelCursor = data.modelRange.end;
 			}
 		}
@@ -492,7 +493,6 @@ export function convertText() {
 export function convertSelectionChange( model, mapper ) {
 	return ( evt, data ) => {
 		const viewSelection = data.newSelection;
-		const modelSelection = new ModelSelection();
 
 		const ranges = [];
 
@@ -500,7 +500,7 @@ export function convertSelectionChange( model, mapper ) {
 			ranges.push( mapper.toModelRange( viewRange ) );
 		}
 
-		modelSelection.setTo( ranges, { backward: viewSelection.isBackward } );
+		const modelSelection = model.createSelection( ranges, { backward: viewSelection.isBackward } );
 
 		if ( !modelSelection.isEqual( model.document.selection ) ) {
 			model.change( writer => {
@@ -736,80 +736,37 @@ function getViewElementNameFromConfig( viewConfig ) {
 // @param {Object} config Conversion configuration.
 // @returns {Function} View to model converter.
 function prepareToElementConverter( config ) {
-	const matcher = config.view ? new Matcher( config.view ) : null;
+	const matcher = new Matcher( config.view );
 
 	return ( evt, data, conversionApi ) => {
-		let match = {};
-
-		// 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;
-			}
+		const matcherResult = matcher.match( data.viewItem );
 
-			match = matcherResult.match;
+		if ( !matcherResult ) {
+			return;
 		}
 
+		const match = matcherResult.match;
+
 		// Force consuming element's name.
 		match.name = true;
 
-		// Create model element basing on config.
-		const modelElement = getModelElement( config.model, data.viewItem, conversionApi.writer );
-
-		// Do not convert if element building function returned falsy value.
-		if ( !modelElement ) {
-			return;
-		}
-
-		// When element was already consumed then skip it.
 		if ( !conversionApi.consumable.test( data.viewItem, match ) ) {
 			return;
 		}
 
-		// Find allowed parent for element that we are going to insert.
-		// If current parent does not allow to insert element but one of the ancestors does
-		// then split nodes to allowed parent.
-		const splitResult = conversionApi.splitToAllowedParent( modelElement, data.modelCursor );
+		const modelElement = getModelElement( config.model, data.viewItem, conversionApi.writer );
 
-		// When there is no split result it means that we can't insert element to model tree, so let's skip it.
-		if ( !splitResult ) {
+		if ( !modelElement ) {
 			return;
 		}
 
-		// Insert element on allowed position.
-		conversionApi.writer.insert( modelElement, splitResult.position );
-
-		// Convert children and insert to element.
-		conversionApi.convertChildren( data.viewItem, conversionApi.writer.createPositionAt( modelElement, 0 ) );
+		if ( !conversionApi.safeInsert( modelElement, data.modelCursor ) ) {
+			return;
+		}
 
-		// Consume appropriate value from consumable values list.
 		conversionApi.consumable.consume( data.viewItem, match );
-
-		const parts = conversionApi.getSplitParts( modelElement );
-
-		// Set conversion result range.
-		data.modelRange = new ModelRange(
-			conversionApi.writer.createPositionBefore( modelElement ),
-			conversionApi.writer.createPositionAfter( parts[ parts.length - 1 ] )
-		);
-
-		// Now we need to check where the `modelCursor` should be.
-		if ( splitResult.cursorParent ) {
-			// 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>
-
-			data.modelCursor = conversionApi.writer.createPositionAt( splitResult.cursorParent, 0 );
-		} else {
-			// Otherwise just continue after inserted element.
-
-			data.modelCursor = data.modelRange.end;
-		}
+		conversionApi.convertChildren( data.viewItem, modelElement );
+		conversionApi.updateConversionResult( modelElement, data );
 	};
 }
 

+ 1 - 1
packages/ckeditor5-engine/src/dev-utils/model.js

@@ -406,7 +406,7 @@ function convertToModelElement() {
 
 		conversionApi.mapper.bindElements( element, data.viewItem );
 
-		conversionApi.convertChildren( data.viewItem, ModelPosition._createAt( element, 0 ) );
+		conversionApi.convertChildren( data.viewItem, element );
 
 		data.modelRange = ModelRange._createOn( element );
 		data.modelCursor = data.modelRange.end;

+ 242 - 5
packages/ckeditor5-engine/tests/conversion/upcastdispatcher.js

@@ -275,7 +275,7 @@ describe( 'UpcastDispatcher', () => {
 				const modelElement = writer.createElement( data.viewItem.name );
 				writer.insert( modelElement, data.modelCursor );
 
-				const result = conversionApi.convertChildren( data.viewItem, writer.createPositionAt( modelElement, 0 ) );
+				const result = conversionApi.convertChildren( data.viewItem, modelElement );
 
 				data.modelRange = writer.createRange(
 					writer.createPositionBefore( modelElement ),
@@ -485,12 +485,39 @@ describe( 'UpcastDispatcher', () => {
 		} );
 
 		describe( 'convertChildren()', () => {
-			it( 'should fire conversion for all children of passed element and return conversion results ' +
-				'wrapped in document fragment', () => {
+			it( 'should fire conversion for all children of passed view element and return conversion results ' +
+				'wrapped in document fragment (using modelCursor)', () => {
 				dispatcher.on( 'documentFragment', ( evt, data, conversionApi ) => {
 					spy();
 
-					const result = conversionApi.convertChildren( data.viewItem, ModelPosition._createAt( rootMock, 0 ) );
+					const modelCursor = ModelPosition._createAt( rootMock, 0 );
+					const result = conversionApi.convertChildren( data.viewItem, modelCursor );
+
+					expect( result.modelRange ).to.be.instanceof( ModelRange );
+					expect( result.modelRange.start.path ).to.deep.equal( [ 0 ] );
+					expect( result.modelRange.end.path ).to.deep.equal( [ 7 ] );
+					expect( Array.from( result.modelRange.getItems() ) ).to.length( 2 );
+					expect( Array.from( result.modelRange.getItems() )[ 0 ] ).to.equal( modelP );
+					expect( Array.from( result.modelRange.getItems() )[ 1 ] ).to.instanceof( ModelTextProxy );
+					expect( Array.from( result.modelRange.getItems() )[ 1 ].data ).to.equal( 'foobar' );
+
+					expect( result.modelCursor ).instanceof( ModelPosition );
+					expect( result.modelCursor.path ).to.deep.equal( [ 7 ] );
+				} );
+
+				model.change( writer => dispatcher.convert( new ViewDocumentFragment( viewDocument, [ viewP, viewText ] ), writer ) );
+
+				expect( spy.calledOnce ).to.be.true;
+				expect( spyP.calledOnce ).to.be.true;
+				expect( spyText.calledOnce ).to.be.true;
+			} );
+
+			it( 'should fire conversion for all children of passed view element and return conversion results ' +
+				'wrapped in document fragment (using model element)', () => {
+				dispatcher.on( 'documentFragment', ( evt, data, conversionApi ) => {
+					spy();
+
+					const result = conversionApi.convertChildren( data.viewItem, rootMock );
 
 					expect( result.modelRange ).to.be.instanceof( ModelRange );
 					expect( result.modelRange.start.path ).to.deep.equal( [ 0 ] );
@@ -680,7 +707,7 @@ describe( 'UpcastDispatcher', () => {
 					const modelElement = conversionApi.writer.createElement( 'paragraph' );
 
 					conversionApi.writer.insert( modelElement, data.modelCursor );
-					conversionApi.convertChildren( data.viewItem, conversionApi.writer.createPositionAt( modelElement, 0 ) );
+					conversionApi.convertChildren( data.viewItem, modelElement );
 
 					const parts = conversionApi.getSplitParts( modelElement );
 
@@ -711,5 +738,215 @@ describe( 'UpcastDispatcher', () => {
 				expect( spy.called ).to.be.true;
 			} );
 		} );
+
+		describe( 'safeInsert()', () => {
+			beforeEach( () => {
+				model.schema.register( 'paragraph', {
+					allowIn: '$root'
+				} );
+			} );
+
+			it( 'should return true when element was inserted on given position', done => {
+				model.schema.register( 'span', {
+					allowIn: 'paragraph'
+				} );
+
+				dispatcher.on( 'documentFragment', ( evt, data, conversionApi ) => {
+					const span = conversionApi.writer.createElement( 'span' );
+					const position = conversionApi.writer.createPositionAt( modelP, 0 );
+
+					const wasInserted = conversionApi.safeInsert( span, position );
+
+					expect( wasInserted ).to.be.true;
+
+					expect( rootMock.getNodeByPath( [ 0, 0 ] ) ).to.equal( span );
+
+					done();
+				} );
+
+				model.change( writer => dispatcher.convert( new ViewDocumentFragment( viewDocument ), writer ) );
+			} );
+
+			it( 'should return true on split to allowed ancestor if element is allowed in one of the ancestors', done => {
+				model.schema.register( 'section', {
+					allowIn: '$root'
+				} );
+				model.schema.register( 'span', {
+					allowIn: 'paragraph'
+				} );
+				model.schema.extend( 'paragraph', {
+					allowIn: 'section'
+				} );
+
+				// Insert "section > paragraph > span".
+				model.change( writer => {
+					const section = writer.createElement( 'section' );
+					const paragraph = writer.createElement( 'paragraph' );
+					const span = writer.createElement( 'span' );
+
+					writer.insert( section, writer.createPositionAt( rootMock, 0 ) );
+					writer.insert( paragraph, writer.createPositionAt( section, 0 ) );
+					writer.insert( span, writer.createPositionAt( paragraph, 0 ) );
+				} );
+
+				dispatcher.on( 'documentFragment', ( evt, data, conversionApi ) => {
+					// Insert "paragraph" in "section > paragraph > span".
+					const span = rootMock.getNodeByPath( [ 0, 0, 0 ] );
+					const position = conversionApi.writer.createPositionAt( span, 0 );
+
+					const paragraph2 = conversionApi.writer.createElement( 'paragraph' );
+					const wasInserted = conversionApi.safeInsert( paragraph2, position );
+
+					expect( wasInserted ).to.be.true;
+
+					const section = rootMock.getNodeByPath( [ 0 ] );
+
+					// The "paragraph" should be split to 2 and 1 inserted paragraph.
+					expect( section.childCount ).to.equal( 3 );
+					expect( section.getChild( 1 ) ).to.equal( paragraph2 );
+					done();
+				} );
+
+				model.change( writer => dispatcher.convert( new ViewDocumentFragment( viewDocument ), writer ) );
+			} );
+
+			it( 'should return false if element is not allowed in position and any of ancestors', done => {
+				model.schema.register( 'span' );
+
+				dispatcher.on( 'documentFragment', ( evt, data, conversionApi ) => {
+					const paragraph = conversionApi.writer.createElement( 'paragraph' );
+					const span = conversionApi.writer.createElement( 'span' );
+					const position = conversionApi.writer.createPositionAt( paragraph, 0 );
+
+					const wasInserted = conversionApi.safeInsert( span, position );
+
+					expect( wasInserted ).to.be.false;
+					done();
+				} );
+
+				model.change( writer => dispatcher.convert( new ViewDocumentFragment( viewDocument ), writer ) );
+			} );
+
+			it( 'should return false if element is not allowed in position and any of ancestors but is allowed in context tree', done => {
+				model.schema.register( 'div', {
+					allowIn: '$root'
+				} );
+
+				dispatcher.on( 'documentFragment', ( evt, data, conversionApi ) => {
+					const code = conversionApi.writer.createElement( 'div' );
+					const wasInserted = conversionApi.safeInsert( code, data.modelCursor );
+
+					expect( wasInserted ).to.be.false;
+					done();
+				} );
+
+				model.change( writer => dispatcher.convert( new ViewDocumentFragment( viewDocument ), writer, [ '$root', 'paragraph' ] ) );
+			} );
+		} );
+
+		describe( 'updateConversionResult()', () => {
+			beforeEach( () => {
+				model.schema.register( 'paragraph', {
+					allowIn: '$root'
+				} );
+			} );
+
+			it( 'should update the modelCursor and modelRange in data when element was inserted on given position', done => {
+				model.schema.register( 'span', {
+					allowIn: 'paragraph'
+				} );
+
+				dispatcher.on( 'documentFragment', ( evt, data, conversionApi ) => {
+					const span = conversionApi.writer.createElement( 'span' );
+					const position = conversionApi.writer.createPositionAt( modelP, 0 );
+
+					conversionApi.safeInsert( span, position );
+
+					conversionApi.updateConversionResult( span, data );
+
+					const expectedPosition = conversionApi.writer.createPositionAfter( span );
+					expect( data.modelCursor.isEqual( expectedPosition ) ).to.be.true;
+
+					const expectedRange = conversionApi.writer.createRangeOn( span );
+					expect( data.modelRange.isEqual( expectedRange ) ).to.be.true;
+
+					done();
+				} );
+
+				model.change( writer => dispatcher.convert( new ViewDocumentFragment( viewDocument ), writer ) );
+			} );
+
+			it( 'should update the modelCursor and modelRange in data when split to allowed ancestor occurred', done => {
+				model.schema.register( 'section', {
+					allowIn: '$root'
+				} );
+				model.schema.register( 'span', {
+					allowIn: 'paragraph'
+				} );
+				model.schema.extend( 'paragraph', {
+					allowIn: 'section'
+				} );
+
+				// Insert "section > paragraph > span".
+				model.change( writer => {
+					const section = writer.createElement( 'section' );
+					const paragraph = writer.createElement( 'paragraph' );
+					const span = writer.createElement( 'span' );
+
+					writer.insert( section, writer.createPositionAt( rootMock, 0 ) );
+					writer.insert( paragraph, writer.createPositionAt( section, 0 ) );
+					writer.insert( span, writer.createPositionAt( paragraph, 0 ) );
+				} );
+
+				dispatcher.on( 'documentFragment', ( evt, data, conversionApi ) => {
+					// Insert "paragraph" in "section > paragraph > span".
+					const span = rootMock.getNodeByPath( [ 0, 0, 0 ] );
+					const position = conversionApi.writer.createPositionAt( span, 0 );
+
+					const paragraph2 = conversionApi.writer.createElement( 'paragraph' );
+					conversionApi.safeInsert( paragraph2, position );
+
+					conversionApi.updateConversionResult( paragraph2, data );
+
+					const expectedPosition = conversionApi.writer.createPositionAt( rootMock.getNodeByPath( [ 0, 2, 0 ] ), 0 );
+					expect( data.modelCursor.isEqual( expectedPosition ) ).to.be.true;
+
+					const expectedRange = conversionApi.writer.createRangeOn( paragraph2 );
+					expect( data.modelRange.isEqual( expectedRange ) ).to.be.true;
+
+					done();
+				} );
+
+				model.change( writer => dispatcher.convert( new ViewDocumentFragment( viewDocument ), writer ) );
+			} );
+
+			it( 'should not update the modelRange if it was already set on data (complex converter case - e.g. list)', done => {
+				model.schema.register( 'span', {
+					allowIn: 'paragraph'
+				} );
+
+				dispatcher.on( 'documentFragment', ( evt, data, conversionApi ) => {
+					const span = conversionApi.writer.createElement( 'span' );
+					const position = conversionApi.writer.createPositionAt( modelP, 0 );
+
+					conversionApi.safeInsert( span, position );
+
+					const start = conversionApi.writer.createPositionAt( span, 0 );
+					const end = conversionApi.writer.createPositionAt( span, 1 );
+					data.modelRange = conversionApi.writer.createRange( start, end );
+					conversionApi.updateConversionResult( span, data );
+
+					const expectedRange = conversionApi.writer.createRange( start, end );
+					expect( data.modelRange.isEqual( expectedRange ) ).to.be.true;
+
+					// Model cursor will be equal to range end - no split occurred.
+					expect( data.modelCursor.isEqual( end ) ).to.be.true;
+
+					done();
+				} );
+
+				model.change( writer => dispatcher.convert( new ViewDocumentFragment( viewDocument ), writer ) );
+			} );
+		} );
 	} );
 } );

+ 3 - 2
packages/ckeditor5-engine/tests/conversion/upcasthelpers.js

@@ -122,7 +122,8 @@ describe( 'UpcastHelpers', () => {
 
 		it( 'config.view is not set - should fire conversion for every element', () => {
 			upcastHelpers.elementToElement( {
-				model: 'paragraph'
+				model: 'paragraph',
+				view: /.+/
 			} );
 
 			expectResult( new ViewContainerElement( viewDocument, 'p' ), '<paragraph></paragraph>' );
@@ -1014,7 +1015,7 @@ describe( 'upcast-converters', () => {
 					const paragraph = conversionApi.writer.createElement( 'paragraph' );
 
 					conversionApi.writer.insert( paragraph, data.modelCursor );
-					conversionApi.convertChildren( data.viewItem, ModelPosition._createAt( paragraph, 0 ) );
+					conversionApi.convertChildren( data.viewItem, paragraph );
 
 					data.modelRange = ModelRange._createOn( paragraph );
 					data.modelCursor = data.modelRange.end;

+ 2 - 3
packages/ckeditor5-heading/src/title.js

@@ -445,10 +445,9 @@ function dataViewModelH1Insertion( evt, data, conversionApi ) {
 	modelWriter.append( titleContent, title );
 	modelWriter.insert( title, modelCursor );
 
-	conversionApi.convertChildren( viewItem, modelWriter.createPositionAt( titleContent, 0 ) );
+	conversionApi.convertChildren( viewItem, titleContent );
 
-	data.modelRange = modelWriter.createRangeOn( title );
-	data.modelCursor = modelWriter.createPositionAt( data.modelRange.end );
+	conversionApi.updateConversionResult( title, data );
 }
 
 // Maps position from the beginning of the model `title` element to the beginning of the view `h1` element.

+ 2 - 6
packages/ckeditor5-image/src/image/converters.js

@@ -55,13 +55,9 @@ export function viewFigureToModel() {
 		}
 
 		// Convert rest of the figure element's children as an image children.
-		conversionApi.convertChildren( data.viewItem, conversionApi.writer.createPositionAt( modelImage, 0 ) );
+		conversionApi.convertChildren( data.viewItem, modelImage );
 
-		// Set image range as conversion result.
-		data.modelRange = conversionResult.modelRange;
-
-		// Continue conversion where image conversion ends.
-		data.modelCursor = conversionResult.modelCursor;
+		conversionApi.updateConversionResult( modelImage, data );
 	}
 }
 

+ 2 - 16
packages/ckeditor5-list/src/converters.js

@@ -379,30 +379,16 @@ export function viewModelConverter( evt, data, conversionApi ) {
 		const type = data.viewItem.parent && data.viewItem.parent.name == 'ol' ? 'numbered' : 'bulleted';
 		writer.setAttribute( 'listType', type, listItem );
 
-		// Try to find allowed parent for list item.
-		const splitResult = conversionApi.splitToAllowedParent( listItem, data.modelCursor );
-
-		// When there is no allowed parent it means that list item cannot be converted at current model position
-		// and in any of position ancestors.
-		if ( !splitResult ) {
+		if ( !conversionApi.safeInsert( listItem, data.modelCursor ) ) {
 			return;
 		}
 
-		writer.insert( listItem, splitResult.position );
-
 		const nextPosition = viewToModelListItemChildrenConverter( listItem, data.viewItem.getChildren(), conversionApi );
 
 		// Result range starts before the first item and ends after the last.
 		data.modelRange = writer.createRange( data.modelCursor, nextPosition );
 
-		// When `data.modelCursor` parent had to be split to insert list item...
-		if ( splitResult.cursorParent ) {
-			// Continue conversion in the split element.
-			data.modelCursor = writer.createPositionAt( splitResult.cursorParent, 0 );
-		} else {
-			// Otherwise continue conversion after the last list item.
-			data.modelCursor = data.modelRange.end;
-		}
+		conversionApi.updateConversionResult( listItem, data );
 	}
 }
 

+ 1 - 3
packages/ckeditor5-list/tests/listediting.js

@@ -4284,9 +4284,7 @@ describe( 'ListEditing', () => {
 
 				// Use split to allowed parent logic to simulate a non-standard use of `modelCursor` after split.
 				const splitBlock = conversionApi.writer.createElement( 'splitBlock' );
-				const splitResult = conversionApi.splitToAllowedParent( splitBlock, data.modelCursor );
-
-				conversionApi.writer.insert( splitBlock, splitResult.position );
+				conversionApi.safeInsert( splitBlock, data.modelCursor );
 
 				data.modelRange = conversionApi.writer.createRangeOn( splitBlock );
 				data.modelCursor = conversionApi.writer.createPositionAfter( splitBlock );

+ 1 - 0
packages/ckeditor5-paragraph/src/paragraph.js

@@ -67,6 +67,7 @@ export default class Paragraph extends Plugin {
 
 				return modelWriter.createElement( 'paragraph' );
 			},
+			view: /.+/,
 			converterPriority: 'low'
 		} );
 

+ 15 - 65
packages/ckeditor5-table/src/converters/upcasttable.js

@@ -41,15 +41,10 @@ export default function upcastTable() {
 
 			const table = conversionApi.writer.createElement( 'table', attributes );
 
-			// Insert element on allowed position.
-			const splitResult = conversionApi.splitToAllowedParent( table, data.modelCursor );
-
-			// When there is no split result it means that we can't insert element to model tree, so let's skip it.
-			if ( !splitResult ) {
+			if ( !conversionApi.safeInsert( table, data.modelCursor ) ) {
 				return;
 			}
 
-			conversionApi.writer.insert( table, splitResult.position );
 			conversionApi.consumable.consume( viewTable, { name: true } );
 
 			// Upcast table rows in proper order (heading rows first).
@@ -63,29 +58,7 @@ export default function upcastTable() {
 				createEmptyTableCell( conversionApi.writer, conversionApi.writer.createPositionAt( row, 'end' ) );
 			}
 
-			// Set conversion result range.
-			data.modelRange = conversionApi.writer.createRange(
-				// Range should start before inserted element
-				conversionApi.writer.createPositionBefore( table ),
-				// 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( table )
-			);
-
-			// 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>
-			if ( splitResult.cursorParent ) {
-				data.modelCursor = conversionApi.writer.createPositionAt( splitResult.cursorParent, 0 );
-
-				// Otherwise just continue after inserted element.
-			} else {
-				data.modelCursor = data.modelRange.end;
-			}
+			conversionApi.updateConversionResult( table, data );
 		} );
 	};
 }
@@ -111,51 +84,28 @@ export function skipEmptyTableRow() {
 	};
 }
 
-export function upcastTableCell( elementName ) {
+/**
+ * Converter that ensures empty paragraph is inserted in a table cell if no other content was converted.
+ *
+ * @returns {Function} Conversion helper.
+ */
+export function ensureParagraphInTableCell( elementName ) {
 	return dispatcher => {
 		dispatcher.on( `element:${ elementName }`, ( evt, data, conversionApi ) => {
-			const viewTableCell = data.viewItem;
-
-			// When element was already consumed then skip it.
-			if ( !conversionApi.consumable.test( viewTableCell, { name: true } ) ) {
+			// The default converter will create a model range on converted table cell.
+			if ( !data.modelRange ) {
 				return;
 			}
 
-			const tableCell = conversionApi.writer.createElement( 'tableCell' );
-
-			// Insert element on allowed position.
-			const splitResult = conversionApi.splitToAllowedParent( tableCell, data.modelCursor );
+			const tableCell = data.modelRange.start.nodeAfter;
 
-			// When there is no split result it means that we can't insert element to model tree, so let's skip it.
-			if ( !splitResult ) {
-				return;
-			}
-
-			conversionApi.writer.insert( tableCell, splitResult.position );
-			conversionApi.consumable.consume( viewTableCell, { name: true } );
-
-			const modelCursor = conversionApi.writer.createPositionAt( tableCell, 0 );
-			conversionApi.convertChildren( viewTableCell, modelCursor );
-
-			// Ensure a paragraph in the model for empty table cells.
+			// Ensure a paragraph in the model for empty table cells for converted table cells.
 			if ( !tableCell.childCount ) {
+				const modelCursor = conversionApi.writer.createPositionAt( tableCell, 0 );
+
 				conversionApi.writer.insertElement( 'paragraph', modelCursor );
 			}
-
-			// Set conversion result range.
-			data.modelRange = conversionApi.writer.createRange(
-				// Range should start before inserted element
-				conversionApi.writer.createPositionBefore( tableCell ),
-				// 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( tableCell )
-			);
-
-			// Continue after inserted element.
-			data.modelCursor = data.modelRange.end;
-		} );
+		}, { priority: 'low' } );
 	};
 }
 

+ 5 - 3
packages/ckeditor5-table/src/tableediting.js

@@ -9,7 +9,7 @@
 
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 
-import upcastTable, { upcastTableCell, skipEmptyTableRow } from './converters/upcasttable';
+import upcastTable, { ensureParagraphInTableCell, skipEmptyTableRow } from './converters/upcasttable';
 import {
 	downcastInsertCell,
 	downcastInsertRow,
@@ -104,8 +104,10 @@ export default class TableEditing extends Plugin {
 		conversion.for( 'editingDowncast' ).add( downcastRemoveRow() );
 
 		// Table cell conversion.
-		conversion.for( 'upcast' ).add( upcastTableCell( 'td' ) );
-		conversion.for( 'upcast' ).add( upcastTableCell( 'th' ) );
+		conversion.for( 'upcast' ).elementToElement( { model: 'tableCell', view: 'td' } );
+		conversion.for( 'upcast' ).elementToElement( { model: 'tableCell', view: 'th' } );
+		conversion.for( 'upcast' ).add( ensureParagraphInTableCell( 'td' ) );
+		conversion.for( 'upcast' ).add( ensureParagraphInTableCell( 'th' ) );
 
 		conversion.for( 'editingDowncast' ).add( downcastInsertCell() );
 

+ 1 - 1
packages/ckeditor5-table/tests/converters/upcasttable.js

@@ -285,7 +285,7 @@ describe( 'upcastTable()', () => {
 		);
 
 		expectModel(
-			'<fooTable><fooRow><fooCell></fooCell></fooRow></fooTable>'
+			'<fooTable><fooRow><fooCell><paragraph></paragraph></fooCell></fooRow></fooTable>'
 		);
 	} );