8
0
Просмотр исходного кода

Merge branch 'master' into i/1006

Kamil Piechaczek 5 лет назад
Родитель
Сommit
89288bc0b9
50 измененных файлов с 3298 добавлено и 808 удалено
  1. 1 1
      docs/builds/guides/integration/advanced-setup.md
  2. 6 6
      package.json
  3. 3 37
      packages/ckeditor5-code-block/src/converters.js
  4. 0 0
      packages/ckeditor5-engine/docs/_snippets/framework/build-custom-element-converter-source.html
  5. 14 0
      packages/ckeditor5-engine/docs/_snippets/framework/build-custom-element-converter-source.js
  6. 43 0
      packages/ckeditor5-engine/docs/_snippets/framework/extending-content-custom-element-converter.html
  7. 165 0
      packages/ckeditor5-engine/docs/_snippets/framework/extending-content-custom-element-converter.js
  8. 2 2
      packages/ckeditor5-engine/docs/framework/guides/deep-dive/conversion-introduction.md
  9. 310 0
      packages/ckeditor5-engine/docs/framework/guides/deep-dive/custom-element-conversion.md
  10. 217 74
      packages/ckeditor5-engine/src/conversion/upcastdispatcher.js
  11. 18 61
      packages/ckeditor5-engine/src/conversion/upcasthelpers.js
  12. 1 1
      packages/ckeditor5-engine/src/dev-utils/model.js
  13. 242 5
      packages/ckeditor5-engine/tests/conversion/upcastdispatcher.js
  14. 3 2
      packages/ckeditor5-engine/tests/conversion/upcasthelpers.js
  15. 2 3
      packages/ckeditor5-heading/src/title.js
  16. 2 2
      packages/ckeditor5-image/docs/_snippets/features/image-caption.html
  17. 2 2
      packages/ckeditor5-image/docs/_snippets/features/image-link.html
  18. 1 1
      packages/ckeditor5-image/docs/_snippets/features/image-resize-buttons-dropdown.html
  19. 1 1
      packages/ckeditor5-image/docs/_snippets/features/image-resize-buttons.html
  20. 2 2
      packages/ckeditor5-image/docs/_snippets/features/image-resize-px.html
  21. 2 2
      packages/ckeditor5-image/docs/_snippets/features/image-resize.html
  22. 2 2
      packages/ckeditor5-image/docs/_snippets/features/image-style-presentational.html
  23. 1 1
      packages/ckeditor5-image/docs/_snippets/features/image-style.html
  24. 1 1
      packages/ckeditor5-image/docs/_snippets/features/image-toolbar.html
  25. 97 53
      packages/ckeditor5-image/docs/features/image.md
  26. 2 6
      packages/ckeditor5-image/src/image/converters.js
  27. 12 12
      packages/ckeditor5-image/src/imageresize/imageresizebuttons.js
  28. 1 1
      packages/ckeditor5-image/src/imageresize/imageresizecommand.js
  29. 1 1
      packages/ckeditor5-image/src/imageresize/imageresizeediting.js
  30. 1 1
      packages/ckeditor5-image/src/imageresize/imageresizehandles.js
  31. 1 1
      packages/ckeditor5-image/tests/imageresize/imageresizebuttons.js
  32. 2 16
      packages/ckeditor5-list/src/converters.js
  33. 1 3
      packages/ckeditor5-list/tests/listediting.js
  34. 1 0
      packages/ckeditor5-paragraph/src/paragraph.js
  35. 15 65
      packages/ckeditor5-table/src/converters/upcasttable.js
  36. 5 3
      packages/ckeditor5-table/src/tableediting.js
  37. 12 153
      packages/ckeditor5-table/src/tablekeyboard.js
  38. 1 1
      packages/ckeditor5-table/tests/converters/upcasttable.js
  39. 80 18
      packages/ckeditor5-table/tests/tablekeyboard.js
  40. 6 2
      packages/ckeditor5-ui/src/bindings/clickoutsidehandler.js
  41. 80 1
      packages/ckeditor5-ui/tests/bindings/clickoutsidehandler.js
  42. 1 1
      packages/ckeditor5-widget/src/utils.js
  43. 225 0
      packages/ckeditor5-widget/src/verticalnavigation.js
  44. 3 0
      packages/ckeditor5-widget/src/widget.js
  45. 28 4
      packages/ckeditor5-widget/tests/manual/keyboard.html
  46. 76 2
      packages/ckeditor5-widget/tests/manual/keyboard.js
  47. 4 0
      packages/ckeditor5-widget/tests/manual/keyboard.md
  48. 1300 0
      packages/ckeditor5-widget/tests/verticalnavigation.js
  49. 1 1
      packages/ckeditor5-widget/tests/widget.js
  50. 301 257
      yarn.lock

+ 1 - 1
docs/builds/guides/integration/advanced-setup.md

@@ -513,7 +513,7 @@ If you want to load two different editors on one page you need to make sure that
 
 There is no limit for how many editor classes a single build can export. By default, the official builds export a single editor class only. However, they can easily import more.
 
-You can start from forking (or copying) an existing build like in the {@link builds/guides/development/custom-builds "Creating custom builds"} guide. Let's say you forked and cloned the [`ckeditor5`](http://github.com/ckeditor /ckeditor5) repository and want to add {@link module:editor-inline/inlineeditor~InlineEditor} to the classic build:
+You can start from forking (or copying) an existing build like in the {@link builds/guides/development/custom-builds "Creating custom builds"} guide. Let's say you forked and cloned the [`ckeditor5`](http://github.com/ckeditor/ckeditor5) repository and want to add {@link module:editor-inline/inlineeditor~InlineEditor} to the classic build:
 
 ```bash
 git clone -b stable git@github.com:<your-username>/ckeditor5.git

+ 6 - 6
package.json

@@ -77,12 +77,12 @@
   },
   "devDependencies": {
     "@ckeditor/ckeditor5-comments": "^21.0.0",
-    "@ckeditor/ckeditor5-dev-docs": "^23.0.0",
-    "@ckeditor/ckeditor5-dev-env": "^23.0.0",
-    "@ckeditor/ckeditor5-dev-tests": "^23.0.0",
-    "@ckeditor/ckeditor5-dev-utils": "^23.0.0",
-    "@ckeditor/ckeditor5-dev-webpack-plugin": "^23.0.0",
-    "@ckeditor/ckeditor5-inspector": "^2.1.0",
+    "@ckeditor/ckeditor5-dev-docs": "^23.1.1",
+    "@ckeditor/ckeditor5-dev-env": "^23.1.1",
+    "@ckeditor/ckeditor5-dev-tests": "^23.1.1",
+    "@ckeditor/ckeditor5-dev-utils": "^23.1.1",
+    "@ckeditor/ckeditor5-dev-webpack-plugin": "^23.1.1",
+    "@ckeditor/ckeditor5-inspector": "^2.2.0",
     "@ckeditor/ckeditor5-react": "^2.1.0",
     "@ckeditor/ckeditor5-real-time-collaboration": "^21.0.0",
     "@ckeditor/ckeditor5-track-changes": "^21.0.0",

+ 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 - 2
packages/ckeditor5-image/docs/_snippets/features/image-caption.html

@@ -1,12 +1,12 @@
 <div id="snippet-image-caption">
-	<p>Image with caption:</p>
+	<p>Image with a caption:</p>
 
 	<figure class="image">
 		<img src="%BASE_PATH%/assets/img/fields.jpg" alt="Autumn fields">
 		<figcaption>Autumn fields by Aleksander Nowodziński</figcaption>
 	</figure>
 
-	<p>Image without caption:</p>
+	<p>Image without a caption:</p>
 
 	<figure class="image">
 		<img src="%BASE_PATH%/assets/img/fields.jpg" alt="Autumn fields">

+ 2 - 2
packages/ckeditor5-image/docs/_snippets/features/image-link.html

@@ -1,8 +1,8 @@
 <div id="snippet-image-link">
-	<h3>Linked image</h3>
+	<p>Linked image - use the contextual toolbar to edit image link properties:</p>
 
 	<figure class="image">
-		<a href="https://cksource.com">
+		<a href="https://cksource.com/">
 			<img src="%BASE_PATH%/assets/img/fields.jpg" alt="Autumn fields">
 		</a>
 	</figure>

+ 1 - 1
packages/ckeditor5-image/docs/_snippets/features/image-resize-buttons-dropdown.html

@@ -1,5 +1,5 @@
 <div id="snippet-image-resize-buttons-dropdown">
-	<h3>Resize me using the dropdown!</h3>
+	<p>Click me and resize using the contextual toolbar!</p>
 
 	<figure class="image">
 		<img src="%BASE_PATH%/assets/img/fields.jpg" alt="Autumn fields">

+ 1 - 1
packages/ckeditor5-image/docs/_snippets/features/image-resize-buttons.html

@@ -1,5 +1,5 @@
 <div id="snippet-image-resize-buttons">
-	<h3>Resize me using image toolbar buttons!</h3>
+	<p>Click me and resize using the image toolbar buttons!</p>
 
 	<figure class="image">
 		<img src="%BASE_PATH%/assets/img/fields.jpg" alt="Autumn fields">

+ 2 - 2
packages/ckeditor5-image/docs/_snippets/features/image-resize-px.html

@@ -1,12 +1,12 @@
 <div id="snippet-image-resize-px">
-	<h3>Resize me!</h3>
+	<p>Resize me using pixel values!</p>
 
 	<figure class="image">
 		<img src="%BASE_PATH%/assets/img/fields.jpg" alt="Autumn fields">
 		<figcaption>Autumn fields by Aleksander Nowodziński</figcaption>
 	</figure>
 
-	<h3>Resized image (width: 400px):</h3>
+	<p>Resized image (width: 400px):</p>
 
 	<figure class="image image_resized" style="width:400px;">
 		<img src="%BASE_PATH%/assets/img/fields.jpg" alt="Autumn fields">

+ 2 - 2
packages/ckeditor5-image/docs/_snippets/features/image-resize.html

@@ -1,12 +1,12 @@
 <div id="snippet-image-resize">
-	<h3>Resize me!</h3>
+	<p>Resize me by using handles!</p>
 
 	<figure class="image">
 		<img src="%BASE_PATH%/assets/img/fields.jpg" alt="Autumn fields">
 		<figcaption>Autumn fields by Aleksander Nowodziński</figcaption>
 	</figure>
 
-	<h3>Resized image (width: 75%):</h3>
+	<p>Resized image (width: 75%):</p>
 
 	<figure class="image image_resized" style="width:75%;">
 		<img src="%BASE_PATH%/assets/img/fields.jpg" alt="Autumn fields">

+ 2 - 2
packages/ckeditor5-image/docs/_snippets/features/image-style-presentational.html

@@ -5,11 +5,11 @@
 		<img src="%BASE_PATH%/assets/img/fields.jpg" alt="Autumn fields">
 	</figure>
 
-	<p>This is a right-aligned image:</p>
+	<p>This is a right-aligned image, resized to 50% width:</p>
 
 	<figure class="image image-style-align-right" style="width: 50%;">
 		<img src="%BASE_PATH%/assets/img/fields.jpg" alt="Autumn fields">
 	</figure>
 
-	<p>Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here.</p>
+	<p>Yet another sample paragraph and lots of text here, again. Quite a lot of text, just like before. And this huge amount of text forms a whole long paragraph. This whole lot of text is here to help you see the image's alignment in context. It serves no other purpose, though. But we still find it useful and plain like it. And it is even longer than the previous one. Go on an use the image style buttons to make the most out of this pretty text block and the image alignment demo.</p>
 </div>

+ 1 - 1
packages/ckeditor5-image/docs/_snippets/features/image-style.html

@@ -11,5 +11,5 @@
 		<img src="%BASE_PATH%/assets/img/fields.jpg" alt="Autumn fields">
 	</figure>
 
-	<p>Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here. Lots of text here.</p>
+	<p>Lots of text here. Quite a lot of text, indeed. In fact, there is plenty of text beside this image and it forms a whole long paragraph. This whole lot of text is here to help you see the image's alignment in context. It serves no other purpose, though. But we still find it useful and plain like it.</p>
 </div>

+ 1 - 1
packages/ckeditor5-image/docs/_snippets/features/image-toolbar.html

@@ -1,5 +1,5 @@
 <div id="snippet-image-toolbar">
-	<p>This is <a href="https://ckeditor.com">CKEditor&nbsp;5</a>.</p>
+	<p>This is <a href="https://ckeditor.com">CKEditor&nbsp;5 WYSIWYG editor</a>.</p>
 
 	<figure class="image">
 		<img src="%BASE_PATH%/assets/img/fields.jpg" alt="Autumn fields">

+ 97 - 53
packages/ckeditor5-image/docs/features/image.md

@@ -7,17 +7,17 @@ category: features
 
 The [`@ckeditor/ckeditor5-image`](https://www.npmjs.com/package/@ckeditor/ckeditor5-image) package contains multiple plugins that implement various image-related features:
 
-* {@link module:image/image~Image} implements basic support for images.
-* {@link module:image/imagetoolbar~ImageToolbar} adds the image feature's contextual toolbar.
-* {@link module:image/imagecaption~ImageCaption} adds support for captions.
-* {@link module:image/imagestyle~ImageStyle} adds support for image styles.
+* {@link module:image/image~Image} implements [basic support for images](#base-image-support).
+* {@link module:image/imagetoolbar~ImageToolbar} adds the image feature's [contextual toolbar](#image-contextual-toolbar).
+* {@link module:image/imagecaption~ImageCaption} adds support for [captions](#image-captions).
+* {@link module:image/imagestyle~ImageStyle} adds support for [image styles](#image-styles).
 * {@link module:image/imagetextalternative~ImageTextAlternative} adds support for adding text alternative.
-* {@link module:image/imageupload~ImageUpload} adds support for uploading dropped or pasted images (see: {@link features/image-upload Image upload}).
-* {@link module:image/imageresize~ImageResize} adds support for resizing images.
-* {@link module:link/linkimage~LinkImage} adds support for linking images.
+* {@link module:image/imageupload~ImageUpload} adds support for {@link features/image-upload uploading dropped or pasted images}.
+* {@link module:image/imageresize~ImageResize} adds support for [resizing images](#resizing-images).
+* {@link module:link/linkimage~LinkImage} adds support for [linking images](#linking-images).
 
 <info-box info>
-	All features listed above except the image resize and image linking are enabled by default in all WYSIWYG editor builds.
+	All features listed above except image resizing and image linking are enabled by default in all CKEditor 5 WYSIWYG editor builds.
 
 	Check the documentation of each subfeature to learn more about it.
 </info-box>
@@ -46,7 +46,7 @@ You can see the demo of a WYSIWYG editor with the base image feature enabled bel
 
 ## Image contextual toolbar
 
-The {@link module:image/imagetoolbar~ImageToolbar} plugin introduces a contextual toolbar for images. The toolbar appears when an image is selected and can be configured to contain any buttons you want. Usually, these will be image-related options such as the text alternative (which is introduced by the base image plugin) button and [image styles buttons](#image-styles).
+The {@link module:image/imagetoolbar~ImageToolbar} plugin introduces a contextual toolbar for images. The toolbar appears when an image is selected and can be configured to contain any buttons you want. Usually, these will be image-related options such as the text alternative button (a feature introduced by the base image plugin) and [image styles buttons](#image-styles).
 
 See a demo of a WYSIWYG editor with the contextual toolbar enabled:
 
@@ -70,11 +70,11 @@ The {@link module:image/imagecaption~ImageCaption} plugin adds support for image
 ```html
 <figure class="image">
 	<img src="..." alt="...">
-	<figcaption>Caption goes here...</figcaption>
+	<figcaption>A caption goes here...</figcaption>
 </figure>
 ```
 
-By default, if the image caption is empty, the `<figcaption>` element is not visible to the user. You can click the image to reveal the caption. See the demo below:
+By default, if the image caption is empty, the `<figcaption>` element is not visible to the user. You can click the image to reveal the caption field and write one. See the demo below:
 
 {@snippet features/image-caption}
 
@@ -84,23 +84,23 @@ See the {@link features/image-upload Image upload} guide.
 
 ## Responsive images
 
-Support for responsive images in CKEditor 5 is brought by the {@link features/easy-image Easy Image} feature without any additional configuration. Learn more how to use the feature in your project in the {@link features/easy-image#responsive-images Easy Image integration} guide.
+Support for responsive images in CKEditor 5 is brought by the {@link features/easy-image Easy Image} feature without any additional configuration. Refer to the {@link features/easy-image#responsive-images Easy Image integration} guide to learn how to use the feature in your project.
 
 ## Image styles
 
-In simple integrations it is enough to let the user insert images, set their text alternative and the editor's job is done. An example of such a simple solution are e.g. [GitHub](https://github.com) comments. The styling of the images (for example, their maximum width and margins) is controlled by GitHub through stylesheets.
+In simple integrations it is enough to let the user insert images, set their text alternative and the editor's job is done. An example of such a simple solution are, for example, [GitHub](https://github.com/) comments. The styling of the images (for example, their maximum width and margins) is controlled by GitHub through stylesheets.
 
-In more advanced scenarios, the user may need to be able to decide whether the image should take the whole width (if it is the article's main photo) or it should take, for example, 50% of the width and be pulled out of the content (so called "pulled images"). Various integration scenarios require different types of images to be used.
+In more advanced scenarios, the user may need to be able to decide about the image's width. Should it take up the whole width (if it is the article's main photo) or should it take up, for example, 50% of the width and be pulled out of the content (so called "pulled images")? Various integration scenarios require different types of images to be used.
 
-Finally, in certain situations, the user should be able to granularly control how an image is presented so they should be able to set the size and alignment separately.
+Finally, in certain situations, the user should be able to granularly control how an image is presented thanks to the ability to set the size and alignment separately.
 
-The {@link module:image/imagestyle~ImageStyle} feature solves the last two scenarios. The former is handled by so-called ["semantical styles"](#semantical-styles) and the latter by ["presentational styles"](#presentational-styles) in combination with [image resize](#resizing-images).
+The {@link module:image/imagestyle~ImageStyle} feature solves the last two scenarios. The former is handled by so-called ["semantical styles"](#semantical-styles) and the latter by ["presentational styles"](#presentational-styles) in combination with the [image resize](#resizing-images) feature.
 
 The available image styles can be configured using the {@link module:image/image~ImageConfig#styles `config.image.styles`} option. Respective buttons should also be added to the image toolbar via {@link module:image/image~ImageConfig#toolbar `config.image.toolbar`}.
 
 ### Semantical styles
 
-A semantical style let the user choose from a predefined "types" of images. The user is not able to set the image border, alignment, margins, width, etc. separately. Instead, they can pick one of the styles defined by the developer who prepared the WYSIWYG editor integration. This gives the developer control over how the users style images and makes the user's life easier by setting multiple properties at once.
+A semantical style lets the user choose from predefined "types" of images. The user is not able to set the image border, alignment, margins, width, etc. separately. Instead, they can pick one of the styles defined by the developer who prepared the WYSIWYG editor integration. This gives the developer control over how the users style their images and makes the user's life easier by setting multiple properties at once.
 
 A style is applied to the image in form of a class. By default, CKEditor 5 is configured to support two default semantical styles: **"full width"** (which does not apply any class &mdash; it is the default style) and **"side image"** (which applies the `image-style-side` class).
 
@@ -117,14 +117,14 @@ A side image:
 ```
 
 <info-box>
-	The actual styling of the images is the integrator's job. CKEditor 5 WYSIWYG editor comes with some default styles, but they will only be applied to images inside the editor. The integrator needs to style them appropriately on the target pages.
+	The actual styling of the images is the integrator's job. CKEditor 5 WYSIWYG editor comes with some default styles, but they will only be applied to the images inside the editor. The integrator needs to style them appropriately on the target pages.
 
 	You can find the source of the default styles applied by the editor here: [`ckeditor5-image/theme/imagestyle.css`](https://github.com/ckeditor/ckeditor5/blob/master/packages/ckeditor5-image/theme/imagestyle.css).
 
 	Read more about {@link builds/guides/integration/content-styles styling the content of the editor}.
 </info-box>
 
-Below you can see a demo of the WYSIWYG editor with the semantical image styles. The "full" and "side" styles are the default value of {@link module:image/image~ImageConfig#styles `config.image.styles`} so you do not need to set it.
+Below you can find a demo of the WYSIWYG editor with the semantical image styles. The "full" and "side" styles are the default value of {@link module:image/image~ImageConfig#styles `config.image.styles`} so you do not need to set it.
 
 ```js
 ClassicEditor
@@ -138,7 +138,7 @@ ClassicEditor
 				'imageTextAlternative'
 			],
 
-			// The default value,
+			// The default value.
 			styles: [
 				'full',
 				'side'
@@ -149,12 +149,12 @@ ClassicEditor
 	.catch( ... );
 ```
 
-See the result below. You can change the styles of images through the image's contextual toolbar.
+See the result in the WYSIWYG editor below. You can change the style of an image through the image's contextual toolbar.
 
 {@snippet features/image-style}
 
 <info-box hint>
-Try to understand what use cases the system needs to support and define semantic options accordingly. Defining useful and clear styles is one of the steps towards a good user experience and clear, portable output. For example, the "side image" style can be displayed as a floated image on wide screens and as a normal image on low resolution screens (e.g. mobile browsers).
+	Try to understand what use cases the system needs to support and define semantic options accordingly. Defining useful and clear styles is one of the steps towards a good user experience and clear, portable output. For example, the "side image" style can be displayed as a floated image on wide screens and as a normal image on low resolution screens (e.g. mobile browsers).
 </info-box>
 
 <info-box warning>
@@ -169,6 +169,14 @@ Presentational styles do not add any special meaning to the content. They direct
 
 Currently, the available presentational styles are "align center", "align left" and "align right".
 
+<info-box warning>
+	Presentational image styles should be combined with the optional [image resize feature](#resizing-images) as these features were designed to be used together. The image width is then controlled by the image resize feature.
+
+	If you do not enable the image resize feature in your setup using the default presentational styles, your images will always take up 100% of the editor width so the alignment may not be visible.
+
+	If you do not want to enable image resizing, use [semantical image styles](#semantical-styles).
+</info-box>
+
 ```js
 ClassicEditor
 	.create( document.querySelector( '#editor' ), {
@@ -178,10 +186,32 @@ ClassicEditor
 				'alignLeft', 'alignCenter', 'alignRight'
 			],
 
-			// You need to configure the image toolbar, too, so it shows the new style buttons.
+			// Configure the available image resize options.
+			resizeOptions: [
+				{
+					name: 'imageResize:original',
+					label: 'Original',
+					value: null
+				},
+				{
+					name: 'imageResize:50',
+					label: '50%',
+					value: '50'
+				},
+				{
+					name: 'imageResize:75',
+					label: '75%',
+					value: '75'
+				}
+			],
+
+			// You need to configure the image toolbar, too, so it shows the new style
+			// buttons as well as the resize buttons.
 			toolbar: [
 				'imageStyle:alignLeft', 'imageStyle:alignCenter', 'imageStyle:alignRight',
 				'|',
+				'imageResize',
+				'|',
 				'imageTextAlternative'
 			]
 		}
@@ -192,6 +222,8 @@ ClassicEditor
 
 The code sample above uses predefined presentational image styles: `'alignLeft'`, `'alignCenter'` and `'alignRight'`. They apply, respectively, the `.image-style-align-left`, `.image-style-align-center` and  `.image-style-align-right` classes to the `<figure>` element.
 
+In addition to that, the sample is configured to use the [image resize feature](#resizing-images) with three {@link module:image/image~ImageConfig#resizeOptions resize options} available: `'imageResize:original'`, `'imageResize:50'` and `'imageResize:75'`. They allow you to set the image width in the editor to the original image size, 50% and 75%, respectively.
+
 See the result below:
 
 {@snippet features/image-style-presentational}
@@ -214,29 +246,29 @@ you can also define your own styles or modify the existing ones.
 
 You can find advanced examples in the {@link module:image/image~ImageConfig#styles `config.image.styles`} configuration option documentation.
 
-<!-- TODO (live example)... -->
+<!-- TODO (live example)... not today, yet -->
 
 ## Resizing images
 
-The [image styles](#image-styles) feature is meant to give the user the choice between a set of styling options provided by the system (so by the developer or administrator who created it). There are also scenarios where the user should be able to freely set the width of an image. And that is where the image resize feature comes to play.
+The [image styles](#image-styles) feature is meant to give the user a choice between a set of styling options provided by the system (i.e. by the developer or administrator who created it). There are also scenarios where the user should be able to freely set the width of an image. And that is where the image resize feature comes into play. It is implemented by the {@link module:image/imageresize~ImageResize} plugin.
 
-It is implemented by the {@link module:image/imageresize~ImageResize} plugin and enables four "resize handles" displayed over the selected image. The user can freely resize the image by dragging them. The feature can be configured to use either percentage (default) or pixel values.
+### Methods to resize images
 
-The plugin also gives you an ability to change the size of the image through the image toolbar. You can set an optional static configuration with {@link module:image/image~ImageConfig#resizeOptions} and choose whether you want to use a dropdown or set of the standalone buttons.
+The editor offers different ways to resize images either by using "resize handles" or by using dedicated UI components &mdash; either a dropdown or standalone buttons.
 
-### Methods to resize images
+The {@link module:image/imageresize~ImageResize} plugin enables the four resize handles displayed over the selected image. The user can freely resize the image by dragging them. The feature can be configured to use either percentage (default) or pixel values.
 
-The editor offers different ways to resize images either by using resize handles or by using dedicated UI components.
+The plugin also gives you an ability to change the size of the image through the on-click image toolbar. You can set an optional static configuration with {@link module:image/image~ImageConfig#resizeOptions} and choose whether you want to use a dropdown or a set of standalone buttons.
 
-#### Using handles
+#### Using resize handles
 
-In this case, the user is able to resize images via dragging square handles displayed in each corner of the image. Once [image resizing was enabled](#enabling-image-resizing), this option does not require any additional configuration.
+In this case, the user is able to resize images by dragging square handles displayed in each corner of the image. Once [image resizing is enabled](#enabling-image-resizing), this option does not require any additional configuration.
 
 {@snippet features/image-resize}
 
-You can configure the editor for resizing images by handles in two different ways:
+You can configure resizing images by handles in two different ways in the CKEditor 5 WYSIWYG editor:
 
-- By installing the {@link module:image/imageresize~ImageResize} plugin, which contains **all** needed features (`ImageResizeEditing`, `ImageResizeHandles`, `ImageResizeButtons`).
+* Either by installing the {@link module:image/imageresize~ImageResize} plugin, which contains **all** needed features (`ImageResizeEditing`, `ImageResizeHandles`, `ImageResizeButtons`):
 
 ```js
 import Image from '@ckeditor/ckeditor5-image/src/image';
@@ -251,7 +283,7 @@ ClassicEditor
 	.catch( ... );
 ```
 
-- Or by installing the combination of {@link module:image/imageresize/imageresizeediting~ImageResizeEditing} and {@link module:image/imageresize/imageresizehandles~ImageResizeHandles} plugins.
+* Or by installing the combination of {@link module:image/imageresize/imageresizeediting~ImageResizeEditing} and {@link module:image/imageresize/imageresizehandles~ImageResizeHandles} plugins:
 
 ```js
 import Image from '@ckeditor/ckeditor5-image/src/image';
@@ -269,11 +301,11 @@ ClassicEditor
 
 Both ways enable resize handles by default.
 
-#### Using the dropdown
+#### Using resize dropdown
 
-In this case, the user is able to choose from a set of predefined options. These options can be displayed in the image toolbar in form of a dropdown.
+In this case, the user is able to choose from a set of predefined options. These options can be displayed in form of a dropdown in the image toolbar available after the user clicks the image.
 
-To use this option, you need to [enable image resizing](#enabling-image-resizing) and configure the available {@link module:image/image~ImageConfig#resizeOptions resize options}.
+To use this option, you need to [enable image resizing](#enabling-image-resizing) and configure the available {@link module:image/image~ImageConfig#resizeOptions resize options}. Then add the dropdown to the image toolbar configuration.
 
 ```js
 const imageConfiguration = {
@@ -298,13 +330,15 @@ const imageConfiguration = {
 }
 ```
 
+Try out the live demo of the resize dropdown available in the image toolbar below.
+
 {@snippet features/image-resize-buttons-dropdown}
 
-#### Using standalone buttons
+#### Using standalone resize buttons
 
-In this case, the resize options are displayed in form of separate buttons. The benefit of this solution is the smoothest UX as the user needs just one click to resize an image.
+In this case, the resize options are displayed in the form of separate buttons. The benefit of this solution is the smoothest UX as the user needs just one click to resize an image.
 
-To use this option, you need to [enabling image resizing](#enabling-image-resizing) and configure the available {@link module:image/image~ImageConfig#resizeOptions resize options}.
+To use this option, you need to [enable image resizing](#enabling-image-resizing) and configure the available {@link module:image/image~ImageConfig#resizeOptions resize options}. Then add appropriate buttons to the image toolbar configuration.
 
 ```js
 const imageConfiguration = {
@@ -333,12 +367,17 @@ const imageConfiguration = {
 	]
 }
 ```
+Try out the live demo of the individual resize buttons available in the image toolbar below:
 
 {@snippet features/image-resize-buttons}
 
 ### Disabling image resize handles
 
-If, for some reason, you want to configure the editor in such a way that images can be resized only by buttons you can do so by omitting the {@link module:image/imageresize/imageresizehandles~ImageResizeHandles `ImageResizeHandles`} plugin. As a result, plugins setup should look like this: `plugins: [ 'ImageResizeEditing', 'ImageResizeButtons', ... ]` as opposed to `plugins: [ 'ImageResize', ... ]`. It will enable resizing image feature only by means of the chosen UI ([dropdown](#using-the-dropdown) or [standalone buttons](#using-standalone-buttons)) in the image toolbar.
+If, for some reason, you want to configure the editor in such a way that images can be resized only by buttons, you can do so by omitting the {@link module:image/imageresize/imageresizehandles~ImageResizeHandles `ImageResizeHandles`} plugin.
+
+As a result, your plugin setup should look like this: `plugins: [ 'ImageResizeEditing', 'ImageResizeButtons', ... ]` as opposed to `plugins: [ 'ImageResize', ... ]`.
+
+This will enable the image resize feature only by means of the chosen UI: either a [dropdown](#using-resize-dropdown) or [standalone buttons](#using-standalone-resize-buttons)) in the image toolbar.
 
 ```js
 import Image from '@ckeditor/ckeditor5-image/src/image';
@@ -385,7 +424,7 @@ The image resize feature is not enabled by default in any of the editor builds.
 
 ### Markup and styling
 
-When you resize an image, the inline `width` style is used and the `<figure>` is assigned the `image_resized` class:
+When you resize an image, the inline `width` style is used and the `<figure>` element is assigned the `image_resized` class:
 
 ```html
 <figure class="image image_resized" style="width: 75%;">
@@ -411,7 +450,7 @@ And the `max-width` gets overridden by the following rule:
 }
 ```
 
-Another concern when styling resized images is that by default, CKEditor 5 uses `display: table` on `<figure class="image">` to make it take the size of the `<img>` element inside it. Unfortunately, [browsers do not yet support using `max-width` and `width` on the same element if it is styled with `display: table`](https://stackoverflow.com/questions/4019604/chrome-safari-ignoring-max-width-in-table/14420691#14420691). Therefore, `display: block` needs to be used when the image is resized:
+Another concern when styling resized images is that by default, CKEditor 5 uses `display: table` on `<figure class="image">` elements to make it take up the size of the `<img>` element inside it. Unfortunately, [browsers do not yet support using `max-width` and `width` on the same element if it is styled with `display: table`](https://stackoverflow.com/questions/4019604/chrome-safari-ignoring-max-width-in-table/14420691#14420691). Therefore, `display: block` needs to be used when the image is resized:
 
 ```css
 .ck-content .image.image_resized {
@@ -430,9 +469,9 @@ Another concern when styling resized images is that by default, CKEditor 5 uses
 
 ### Using pixels instead of percentage width
 
-Using percentage widths ensures that content stays responsive when displayed in different places than in the WYSIWYG editor. If the user made an image take 60% of the content's width in the editor, if you ever change the width of the target page (where this content is displayed), the image will still take 60% of that space. The same is true if the page is responsive and adjusts to the viewport's width.
+Using percentage widths ensures that the content stays responsive when displayed in places other than the WYSIWYG editor. When the user made an image take up, for example, 60% of the content's width in the editor, if you ever change the width of the target page (where this content is displayed), the image will still take up 60% of that space. The same is true if the page is responsive and adjusts to the viewport's width.
 
-If you configured the editor to use pixel values, the image could take, for example, too much space after you introduced a new layout for your website.
+If you configured the editor to use pixel values, the image could take up, for example, too much space after you introduced a new layout for your website.
 
 However, there are cases where pixel values may be preferred. You can thus configure the editor to use them by setting the {@link module:image/image~ImageConfig#resizeUnit `config.image.resizeUnit`} option:
 
@@ -447,16 +486,20 @@ ClassicEditor
 	.catch( ... );
 ```
 
+Check out the difference in the live demo below:
+
 {@snippet features/image-resize-px}
 
 ## Linking images
 
-The {@link module:link/linkimage~LinkImage} plugin adds support for linking images. Some use cases where this is needed are:
+The {@link module:link/linkimage~LinkImage} plugin adds support for linking images. Some use cases where this could be useful are:
 
 * Linking to a high-resolution version of an image.
 * Using images as thumbnails linking to an article or product page.
 * Creating banners linking to other pages.
 
+The image link can be added or edited via the image toolbar. An icon in top right corner of the image indicates the presence of a link.
+
 ```html
 <figure class="image">
 	<a href="...">
@@ -465,6 +508,7 @@ The {@link module:link/linkimage~LinkImage} plugin adds support for linking imag
 	<figcaption>Image caption</figcaption>
 </figure>
 ```
+Use the link icon in the image toolbar to access the edit options for links on image.
 
 {@snippet features/image-link}
 
@@ -484,7 +528,7 @@ To add image features to your rich-text editor, install the [`@ckeditor/ckeditor
 npm install --save @ckeditor/ckeditor5-image @ckeditor/ckeditor5-link
 ```
 
-And add the plugins that you need to your plugin list. You also need to set the image toolbar items.
+Next add the plugins that you need to your plugin list. You also need to set the desired image toolbar items.
 
 ```js
 import Image from '@ckeditor/ckeditor5-image/src/image';
@@ -522,12 +566,12 @@ The {@link module:image/image~Image} plugin registers:
 
 * The `'imageTextAlternative'` button.
 * The {@link module:image/imagetextalternative/imagetextalternativecommand~ImageTextAlternativeCommand `'imageTextAlternative'` command}
-* The {@link module:image/image/imageinsertcommand~ImageInsertCommand `'imageInsert'` command} which accepts a source (e.g. an URL) of an image to insert.
+* The {@link module:image/image/imageinsertcommand~ImageInsertCommand `'imageInsert'` command} that accepts a source (e.g. a URL) of an image to insert.
 
 The {@link module:image/imagestyle~ImageStyle} plugin registers:
 
-* A button for each defined style &mdash; e.g. `'imageStyle:full'` and `'imageStyle:side'`.
-* The {@link module:image/imagestyle/imagestylecommand~ImageStyleCommand `'imageStyle'` command} which accepts a value based on the {@link module:image/image~ImageConfig#styles `image.styles`} configuration option (e.g. `'full'` and `'side'`):
+* A button for each defined style, for example: `'imageStyle:full'` and `'imageStyle:side'`.
+* The {@link module:image/imagestyle/imagestylecommand~ImageStyleCommand `'imageStyle'` command} that accepts a value based on the {@link module:image/image~ImageConfig#styles `image.styles`} configuration option (for example, `'full'` and `'side'`):
 
 	```js
 	editor.execute( 'imageStyle', { value: 'side' } );
@@ -535,12 +579,12 @@ The {@link module:image/imagestyle~ImageStyle} plugin registers:
 
 The {@link module:image/imageupload~ImageUpload} plugin registers:
 
-* The `'imageUpload'` button which opens the native file browser to let you upload a file directly from your disk.
-* The {@link module:image/imageupload/imageuploadcommand~ImageUploadCommand `'imageUpload'` command} which accepts the file to upload.
+* The `'imageUpload'` button that opens the native file browser to let you upload a file directly from your disk.
+* The {@link module:image/imageupload/imageuploadcommand~ImageUploadCommand `'imageUpload'` command} that accepts the file to upload.
 
 The {@link module:image/imageresize~ImageResize} plugin registers:
 
-* The {@link module:image/imageresize/imageresizecommand~ImageResizeCommand `'imageResize'` command} which accepts the target width.
+* The {@link module:image/imageresize/imageresizecommand~ImageResizeCommand `'imageResize'` command} that accepts the target width.
 
 <info-box>
 	We recommend using the official {@link framework/guides/development-tools#ckeditor-5-inspector CKEditor 5 inspector} for development and debugging. It will give you tons of useful information about the state of the editor such as internal data structures, selection, commands, and many more.

+ 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 );
 	}
 }
 

+ 12 - 12
packages/ckeditor5-image/src/imageresize/imageresizebuttons.js

@@ -31,7 +31,7 @@ const RESIZE_ICONS = {
 };
 
 /**
- * The `ImageResizeButtons` plugin.
+ * The image resize buttons plugin.
  *
  * It adds a possibility to resize images using the toolbar dropdown or individual buttons, depending on the plugin configuration.
  *
@@ -90,7 +90,7 @@ export default class ImageResizeButtons extends Plugin {
 	 * A helper function that creates a standalone button component for the plugin.
 	 *
 	 * @private
-	 * @param {module:image/imageresize/imageresizebuttons~ImageResizeOption} resizeOption A model of resize option.
+	 * @param {module:image/imageresize/imageresizebuttons~ImageResizeOption} resizeOption A model of the resize option.
 	 */
 	_registerImageResizeButton( option ) {
 		const editor = this.editor;
@@ -116,7 +116,7 @@ export default class ImageResizeButtons extends Plugin {
 				throw new CKEditorError(
 					'imageresizebuttons-missing-icon: ' +
 					'The resize option "' + name + '" misses the "icon" property ' +
-					'or the property value doesn\'t match any of available icons.',
+					'or the property value doesn\'t match any of the available icons.',
 					editor,
 					option
 				);
@@ -143,7 +143,7 @@ export default class ImageResizeButtons extends Plugin {
 	}
 
 	/**
-	 * A helper function that creates a dropdown component for the plugin containing all resize options defined in
+	 * A helper function that creates a dropdown component for the plugin containing all the resize options defined in
 	 * the editor configuration.
 	 *
 	 * @private
@@ -200,7 +200,7 @@ export default class ImageResizeButtons extends Plugin {
 	 * @private
 	 * @param {module:image/imageresize/imageresizebuttons~ImageResizeOption} option A resize option object.
 	 * @param {Boolean} [forTooltip] An optional flag for creating a tooltip label.
-	 * @returns {String} A user-defined label, a label combined from the value and resize unit or the default label
+	 * @returns {String} A user-defined label combined from the numeric value and the resize unit or the default label
 	 * for reset options (`Original`).
 	 */
 	_getOptionLabelValue( option, forTooltip ) {
@@ -224,11 +224,11 @@ export default class ImageResizeButtons extends Plugin {
 	}
 
 	/**
-	 * A helper function that parses resize options and returns list item definitions ready for use in a dropdown.
+	 * A helper function that parses the resize options and returns list item definitions ready for use in the dropdown.
 	 *
 	 * @private
 	 * @param {Array.<module:image/imageresize/imageresizebuttons~ImageResizeOption>} options The resize options.
-	 * @param {module:image/imageresize/imageresizecommand~ImageResizeCommand} command A resize image command.
+	 * @param {module:image/imageresize/imageresizecommand~ImageResizeCommand} command The resize image command.
 	 * @returns {Iterable.<module:ui/dropdown/utils~ListDropdownItemDefinition>} Dropdown item definitions.
 	 */
 	_getResizeDropdownListItemDefinitions( options, command ) {
@@ -268,19 +268,19 @@ function getIsOnButtonCallback( value ) {
 }
 
 /**
- * An image resize option used in the {@link module:image/image~ImageConfig#resizeOptions image resize configuration}.
+ * The image resize option used in the {@link module:image/image~ImageConfig#resizeOptions image resize configuration}.
  *
  * @typedef {Object} module:image/imageresize/imageresizebuttons~ImageResizeOption
- * @property {String} name A name of the UI component that changes the image size.
+ * @property {String} name The name of the UI component that changes the image size.
  * * If you configure the feature using individual resize buttons, you can refer to this name in the
  * {@link module:image/image~ImageConfig#toolbar image toolbar configuration}.
  * * If you configure the feature using the resize dropdown, this name will be used for a list item in the dropdown.
- * @property {String} value A value of a resize option without the unit
+ * @property {String} value The value of the resize option without the unit
  * ({@link module:image/image~ImageConfig#resizeUnit configured separately}). `null` resets an image to its original size.
  * @property {String} [resizeOptions.icon] An icon used by an individual resize button (see the `name` property to learn more).
  * Available icons are: `'small'`, `'medium'`, `'large'`, `'original'`.
- * @property {String} [label] A label of the option displayed in the dropdown or, if the feature is configured using
+ * @property {String} [label] An option label displayed in the dropdown or, if the feature is configured using
  * individual buttons, a {@link module:ui/button/buttonview~ButtonView#tooltip} and an ARIA attribute of a button.
- * If not specified, the label is generated automatically based on the option `value` and the
+ * If not specified, the label is generated automatically based on the `value` option and the
  * {@link module:image/image~ImageConfig#resizeUnit `config.image.resizeUnit`}.
  */

+ 1 - 1
packages/ckeditor5-image/src/imageresize/imageresizecommand.js

@@ -11,7 +11,7 @@ import Command from '@ckeditor/ckeditor5-core/src/command';
 import { isImage } from '../image/utils';
 
 /**
- * The image resize command. Currently, it supports only the width attribute.
+ * The image resize command. Currently, it only supports the width attribute.
  *
  * @extends module:core/command~Command
  */

+ 1 - 1
packages/ckeditor5-image/src/imageresize/imageresizeediting.js

@@ -13,7 +13,7 @@ import ImageResizeCommand from './imageresizecommand';
 /**
  * The image resize editing feature.
  *
- * It adds a possibility to resize each image using handles or manually by
+ * It adds the ability to resize each image using handles or manually by
  * {@link module:image/imageresize/imageresizebuttons~ImageResizeButtons} buttons.
  *
  * @extends module:core/plugin~Plugin

+ 1 - 1
packages/ckeditor5-image/src/imageresize/imageresizehandles.js

@@ -13,7 +13,7 @@ import WidgetResize from '@ckeditor/ckeditor5-widget/src/widgetresize';
 /**
  * The image resize by handles feature.
  *
- * It adds a possibility to resize each image using handles or manually by
+ * It adds the ability to resize each image using handles or manually by
  * {@link module:image/imageresize/imageresizebuttons~ImageResizeButtons} buttons.
  *
  * @extends module:core/plugin~Plugin

+ 1 - 1
packages/ckeditor5-image/tests/imageresize/imageresizebuttons.js

@@ -294,7 +294,7 @@ describe( 'ImageResizeButtons', () => {
 				} );
 
 			const errMsg = 'The resize option "imageResize:noicon" misses the "icon" property ' +
-				'or the property value doesn\'t match any of available icons.';
+				'or the property value doesn\'t match any of the available icons.';
 
 			expectToThrowCKEditorError( () => {
 				editor.ui.componentFactory.create( 'imageResize:noicon' );

+ 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() );
 

+ 12 - 153
packages/ckeditor5-table/src/tablekeyboard.js

@@ -11,7 +11,6 @@ import TableSelection from './tableselection';
 import TableWalker from './tablewalker';
 
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
-import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect';
 import priorities from '@ckeditor/ckeditor5-utils/src/priorities';
 import {
 	isArrowKeyCode,
@@ -228,49 +227,20 @@ export default class TableKeyboard extends Plugin {
 			return false;
 		}
 
-		const cellRange = model.createRangeIn( tableCell );
-
-		// Let's check if the selection is at the beginning/end of the cell.
-		if ( this._isSelectionAtCellEdge( selection, isForward ) ) {
-			this._navigateFromCellInDirection( tableCell, direction, expandSelection );
-
-			return true;
+		// Navigation is in the opposite direction than the selection direction so this is shrinking of the selection.
+		// Selection for sure will not approach cell edge.
+		if ( expandSelection && !selection.isCollapsed && selection.isBackward == isForward ) {
+			return false;
 		}
 
-		// If there isn't any $text position between cell edge and selection then we shall move the selection to next cell.
-		const textRange = this._findTextRangeFromSelection( cellRange, selection, isForward );
-
-		if ( !textRange ) {
+		// Let's check if the selection is at the beginning/end of the cell.
+		if ( this._isSelectionAtCellEdge( selection, tableCell, isForward ) ) {
 			this._navigateFromCellInDirection( tableCell, direction, expandSelection );
 
 			return true;
 		}
 
-		// If the navigation is horizontal then we have no more custom cases.
-		if ( [ 'left', 'right' ].includes( direction ) ) {
-			return false;
-		}
-
-		// If the range is a single line then move the selection to the beginning/end of a cell content.
-		//
-		// We can't move the selection directly to the another cell because of dual position at the end/beginning
-		// of wrapped line (it's at the same time at the end of one line and at the start of the next line).
-		if ( this._isSingleLineRange( textRange, isForward ) ) {
-			model.change( writer => {
-				const newPosition = isForward ? cellRange.end : cellRange.start;
-
-				if ( expandSelection ) {
-					const newSelection = model.createSelection( selection.anchor );
-					newSelection.setFocus( newPosition );
-
-					writer.setSelection( newSelection );
-				} else {
-					writer.setSelection( newPosition );
-				}
-			} );
-
-			return true;
-		}
+		return false;
 	}
 
 	/**
@@ -278,10 +248,11 @@ export default class TableKeyboard extends Plugin {
 	 *
 	 * @private
 	 * @param {module:engine/model/selection~Selection} selection The current selection.
+	 * @param {module:engine/model/element~Element} tableCell The current table cell element.
 	 * @param {Boolean} isForward The expected navigation direction.
 	 * @returns {Boolean}
 	 */
-	_isSelectionAtCellEdge( selection, isForward ) {
+	_isSelectionAtCellEdge( selection, tableCell, isForward ) {
 		const model = this.editor.model;
 		const schema = this.editor.model.schema;
 
@@ -290,7 +261,9 @@ export default class TableKeyboard extends Plugin {
 		// If the current limit element is not table cell we are for sure not at the cell edge.
 		// Also `modifySelection` will not let us out of it.
 		if ( !schema.getLimitElement( focus ).is( 'element', 'tableCell' ) ) {
-			return false;
+			const boundaryPosition = model.createPositionAt( tableCell, isForward ? 'end' : 0 );
+
+			return boundaryPosition.isTouching( focus );
 		}
 
 		const probe = model.createSelection( focus );
@@ -301,120 +274,6 @@ export default class TableKeyboard extends Plugin {
 		return focus.isEqual( probe.focus );
 	}
 
-	/**
-	 * Truncates the range so that it spans from the last selection position
-	 * to the last allowed `$text` position (mirrored if `isForward` is false).
-	 *
-	 * Returns `null` if, according to the schema, the resulting range cannot contain a `$text` element.
-	 *
-	 * @private
-	 * @param {module:engine/model/range~Range} range The current table cell content range.
-	 * @param {module:engine/model/selection~Selection} selection The current selection.
-	 * @param {Boolean} isForward The expected navigation direction.
-	 * @returns {module:engine/model/range~Range|null}
-	 */
-	_findTextRangeFromSelection( range, selection, isForward ) {
-		const model = this.editor.model;
-
-		if ( isForward ) {
-			const position = selection.getLastPosition();
-			const lastRangePosition = this._getNearestVisibleTextPosition( range, 'backward' );
-
-			if ( lastRangePosition && position.isBefore( lastRangePosition ) ) {
-				return model.createRange( position, lastRangePosition );
-			}
-
-			return null;
-		} else {
-			const position = selection.getFirstPosition();
-			const firstRangePosition = this._getNearestVisibleTextPosition( range, 'forward' );
-
-			if ( firstRangePosition && position.isAfter( firstRangePosition ) ) {
-				return model.createRange( firstRangePosition, position );
-			}
-
-			return null;
-		}
-	}
-
-	/**
-	 * Basing on the provided range, finds the first or last (depending on `direction`) position inside the range
-	 * that can contain `$text` (according to schema) and is visible in the view.
-	 *
-	 * @private
-	 * @param {module:engine/model/range~Range} range The range to find the position in.
-	 * @param {'forward'|'backward'} direction Search direction.
-	 * @returns {module:engine/model/position~Position} The nearest selection range.
-	 */
-	_getNearestVisibleTextPosition( range, direction ) {
-		const schema = this.editor.model.schema;
-		const mapper = this.editor.editing.mapper;
-
-		for ( const { nextPosition, item } of range.getWalker( { direction } ) ) {
-			if ( schema.checkChild( nextPosition, '$text' ) ) {
-				const viewElement = mapper.toViewElement( item );
-
-				if ( viewElement && !viewElement.hasClass( 'ck-hidden' ) ) {
-					return nextPosition;
-				}
-			}
-		}
-	}
-
-	/**
-	 * Checks if the DOM range corresponding to the provided model range renders as a single line by analyzing DOMRects
-	 * (verifying if they visually wrap content to the next line).
-	 *
-	 * @private
-	 * @param {module:engine/model/range~Range} modelRange The current table cell content range.
-	 * @param {Boolean} isForward The expected navigation direction.
-	 * @returns {Boolean}
-	 */
-	_isSingleLineRange( modelRange, isForward ) {
-		const model = this.editor.model;
-		const editing = this.editor.editing;
-		const domConverter = editing.view.domConverter;
-
-		// Wrapped lines contain exactly the same position at the end of current line
-		// and at the beginning of next line. That position's client rect is at the end
-		// of current line. In case of caret at first position of the last line that 'dual'
-		// position would be detected as it's not the last line.
-		if ( isForward ) {
-			const probe = model.createSelection( modelRange.start );
-
-			model.modifySelection( probe );
-
-			// If the new position is at the end of the container then we can't use this position
-			// because it would provide incorrect result for eg caption of image and selection
-			// just before end of it. Also in this case there is no "dual" position.
-			if ( !probe.focus.isAtEnd && !modelRange.start.isEqual( probe.focus ) ) {
-				modelRange = model.createRange( probe.focus, modelRange.end );
-			}
-		}
-
-		const viewRange = editing.mapper.toViewRange( modelRange );
-		const domRange = domConverter.viewRangeToDom( viewRange );
-		const rects = Rect.getDomRangeRects( domRange );
-
-		let boundaryVerticalPosition;
-
-		for ( const rect of rects ) {
-			if ( boundaryVerticalPosition === undefined ) {
-				boundaryVerticalPosition = Math.round( rect.bottom );
-				continue;
-			}
-
-			// Let's check if this rect is in new line.
-			if ( Math.round( rect.top ) >= boundaryVerticalPosition ) {
-				return false;
-			}
-
-			boundaryVerticalPosition = Math.max( boundaryVerticalPosition, Math.round( rect.bottom ) );
-		}
-
-		return true;
-	}
-
 	/**
 	 * Moves the selection from the given table cell in the specified direction.
 	 *

+ 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>'
 		);
 	} );
 

+ 80 - 18
packages/ckeditor5-table/tests/tablekeyboard.js

@@ -2451,7 +2451,7 @@ describe( 'TableKeyboard', () => {
 							] ) );
 						} );
 
-						it( 'should expand not collapsed selection to the beginning of the cell content from the selection anchor', () => {
+						it( 'should not prevent default browser behavior for shrinking selection (up arrow)', () => {
 							setModelData( model, modelTable( [
 								[ '00', '01', '02' ],
 								[ '10', 'word [word]' + text, '12' ],
@@ -2460,12 +2460,25 @@ describe( 'TableKeyboard', () => {
 
 							editor.editing.view.document.fire( 'keydown', upArrowDomEvtDataStub );
 
+							sinon.assert.notCalled( upArrowDomEvtDataStub.preventDefault );
+							sinon.assert.notCalled( upArrowDomEvtDataStub.stopPropagation );
+						} );
+
+						it( 'should expand not collapsed selection to the beginning of the cell content from the selection anchor', () => {
+							setModelData( model, modelTable( [
+								[ '00', '01', '02' ],
+								[ '10', 'word [word]' + text, '12' ],
+								[ '20', '21', '22' ]
+							] ), { lastRangeBackward: true } );
+
+							editor.editing.view.document.fire( 'keydown', upArrowDomEvtDataStub );
+
 							sinon.assert.calledOnce( upArrowDomEvtDataStub.preventDefault );
 							sinon.assert.calledOnce( upArrowDomEvtDataStub.stopPropagation );
 
 							assertEqualMarkup( getModelData( model ), modelTable( [
 								[ '00', '01', '02' ],
-								[ '10', '[word ]word' + text, '12' ],
+								[ '10', '[word word]' + text, '12' ],
 								[ '20', '21', '22' ]
 							] ) );
 						} );
@@ -2489,6 +2502,19 @@ describe( 'TableKeyboard', () => {
 							] ) );
 						} );
 
+						it( 'should not prevent default browser behavior for shrinking selection (down arrow)', () => {
+							setModelData( model, modelTable( [
+								[ '00', '01', '02' ],
+								[ '10', text + '[word] word', '12' ],
+								[ '20', '21', '22' ]
+							] ), { lastRangeBackward: true } );
+
+							editor.editing.view.document.fire( 'keydown', downArrowDomEvtDataStub );
+
+							sinon.assert.notCalled( downArrowDomEvtDataStub.preventDefault );
+							sinon.assert.notCalled( downArrowDomEvtDataStub.stopPropagation );
+						} );
+
 						it( 'should expand not collapsed selection to the end of the cell content from the selection anchor', () => {
 							setModelData( model, modelTable( [
 								[ '00', '01', '02' ],
@@ -2759,7 +2785,7 @@ describe( 'TableKeyboard', () => {
 
 						assertEqualMarkup( getModelData( model ), modelTable( [
 							[ '00', '01', '02' ],
-							[ '10', `[<horizontalLine></horizontalLine>]<paragraph>word ${ text }</paragraph>`, '12' ],
+							[ '10', `<horizontalLine></horizontalLine><paragraph>[]word ${ text }</paragraph>`, '12' ],
 							[ '20', '21', '22' ]
 						] ) );
 					} );
@@ -2778,7 +2804,7 @@ describe( 'TableKeyboard', () => {
 
 						assertEqualMarkup( getModelData( model ), modelTable( [
 							[ '00', '01', '02' ],
-							[ '10', `<paragraph>${ text } word word</paragraph>[<horizontalLine></horizontalLine>]`, '12' ],
+							[ '10', `<paragraph>${ text } word word[]</paragraph><horizontalLine></horizontalLine>`, '12' ],
 							[ '20', '21', '22' ]
 						] ) );
 					} );
@@ -2844,7 +2870,7 @@ describe( 'TableKeyboard', () => {
 
 							assertEqualMarkup( getModelData( model ), modelTable( [
 								[ '00', '01', '02' ],
-								[ '10', '[<horizontalLine></horizontalLine><paragraph>foo]bar</paragraph>', '12' ],
+								[ '10', '<horizontalLine></horizontalLine><paragraph>[foo]bar</paragraph>', '12' ],
 								[ '20', '21', '22' ]
 							] ) );
 						} );
@@ -2863,7 +2889,7 @@ describe( 'TableKeyboard', () => {
 
 							assertEqualMarkup( getModelData( model ), modelTable( [
 								[ '00', '01', '02' ],
-								[ '10', '<paragraph>foo[bar</paragraph><horizontalLine></horizontalLine>]', '12' ],
+								[ '10', '<paragraph>foo[bar]</paragraph><horizontalLine></horizontalLine>', '12' ],
 								[ '20', '21', '22' ]
 							] ) );
 						} );
@@ -2897,7 +2923,7 @@ describe( 'TableKeyboard', () => {
 						sinon.assert.notCalled( rightArrowDomEvtDataStub.stopPropagation );
 					} );
 
-					it( 'should not navigate to the cell above', () => {
+					it( 'should not navigate to the cell above (only to closest limit boundary)', () => {
 						setModelData( model, modelTable( [
 							[ '00', '01', '02' ],
 							[ '10', `<paragraph>foo</paragraph><image src="${ imageUrl }"><caption>1[]1</caption></image>`, '12' ],
@@ -2906,11 +2932,30 @@ describe( 'TableKeyboard', () => {
 
 						editor.editing.view.document.fire( 'keydown', upArrowDomEvtDataStub );
 
+						sinon.assert.calledOnce( upArrowDomEvtDataStub.preventDefault );
+						sinon.assert.calledOnce( upArrowDomEvtDataStub.stopPropagation );
+
+						assertEqualMarkup( getModelData( model ), modelTable( [
+							[ '00', '01', '02' ],
+							[ '10', `<paragraph>foo</paragraph><image src="${ imageUrl }"><caption>[]11</caption></image>`, '12' ],
+							[ '20', '21', '22' ]
+						] ) );
+					} );
+
+					it( 'should not navigate to the cell above (only to paragraph above)', () => {
+						setModelData( model, modelTable( [
+							[ '00', '01', '02' ],
+							[ '10', `<paragraph>foo</paragraph><image src="${ imageUrl }"><caption>[]11</caption></image>`, '12' ],
+							[ '20', '21', '22' ]
+						] ) );
+
+						editor.editing.view.document.fire( 'keydown', upArrowDomEvtDataStub );
+
 						sinon.assert.notCalled( upArrowDomEvtDataStub.preventDefault );
 						sinon.assert.notCalled( upArrowDomEvtDataStub.stopPropagation );
 					} );
 
-					it( 'should not navigate to the cell above but should select the image widget', () => {
+					it( 'should not navigate to the cell above but should put caret at first position of the image caption', () => {
 						setModelData( model, modelTable( [
 							[ '00', '01', '02' ],
 							[ '10', `<image src="${ imageUrl }"><caption>1[]1</caption></image><paragraph>foo</paragraph>`, '12' ],
@@ -2924,12 +2969,12 @@ describe( 'TableKeyboard', () => {
 
 						assertEqualMarkup( getModelData( model ), modelTable( [
 							[ '00', '01', '02' ],
-							[ '10', `[<image src="${ imageUrl }"><caption>11</caption></image>]<paragraph>foo</paragraph>`, '12' ],
+							[ '10', `<image src="${ imageUrl }"><caption>[]11</caption></image><paragraph>foo</paragraph>`, '12' ],
 							[ '20', '21', '22' ]
 						] ) );
 					} );
 
-					it( 'should not navigate to the cell below when followed by a paragraph', () => {
+					it( 'should not navigate to the cell below when inside the image caption', () => {
 						setModelData( model, modelTable( [
 							[ '00', '01', '02' ],
 							[ '10', `<image src="${ imageUrl }"><caption>1[]1</caption></image><paragraph>foo</paragraph>`, '12' ],
@@ -2938,14 +2983,33 @@ describe( 'TableKeyboard', () => {
 
 						editor.editing.view.document.fire( 'keydown', downArrowDomEvtDataStub );
 
+						sinon.assert.calledOnce( downArrowDomEvtDataStub.preventDefault );
+						sinon.assert.calledOnce( downArrowDomEvtDataStub.stopPropagation );
+
+						assertEqualMarkup( getModelData( model ), modelTable( [
+							[ '00', '01', '02' ],
+							[ '10', `<image src="${ imageUrl }"><caption>11[]</caption></image><paragraph>foo</paragraph>`, '12' ],
+							[ '20', '21', '22' ]
+						] ) );
+					} );
+
+					it( 'should not navigate to the cell below when followed by a paragraph', () => {
+						setModelData( model, modelTable( [
+							[ '00', '01', '02' ],
+							[ '10', `<image src="${ imageUrl }"><caption>11[]</caption></image><paragraph>foo</paragraph>`, '12' ],
+							[ '20', '21', '22' ]
+						] ) );
+
+						editor.editing.view.document.fire( 'keydown', downArrowDomEvtDataStub );
+
 						sinon.assert.notCalled( downArrowDomEvtDataStub.preventDefault );
 						sinon.assert.notCalled( downArrowDomEvtDataStub.stopPropagation );
 					} );
 
-					it( 'should not navigate to the cell below but should select the image widget', () => {
+					it( 'should navigate to the cell below if the caret on last position in the image caption', () => {
 						setModelData( model, modelTable( [
 							[ '00', '01', '02' ],
-							[ '10', `<paragraph>foo</paragraph><image src="${ imageUrl }"><caption>1[]1</caption></image>`, '12' ],
+							[ '10', `<paragraph>foo</paragraph><image src="${ imageUrl }"><caption>11[]</caption></image>`, '12' ],
 							[ '20', '21', '22' ]
 						] ) );
 
@@ -2956,22 +3020,21 @@ describe( 'TableKeyboard', () => {
 
 						assertEqualMarkup( getModelData( model ), modelTable( [
 							[ '00', '01', '02' ],
-							[ '10', `<paragraph>foo</paragraph>[<image src="${ imageUrl }"><caption>11</caption></image>]`, '12' ],
-							[ '20', '21', '22' ]
+							[ '10', `<paragraph>foo</paragraph><image src="${ imageUrl }"><caption>11</caption></image>`, '12' ],
+							[ '20', '[]21', '22' ]
 						] ) );
 					} );
 
 					it( 'should not navigate to the cell above but should select the image widget without caption', () => {
 						setModelData( model, modelTable( [
 							[ '00', '01', '02' ],
-							[ '10', `<image src="${ imageUrl }"><caption></caption></image><paragraph>f[]oo</paragraph>`, '12' ],
+							[ '10', `<image src="${ imageUrl }"><caption></caption></image><paragraph>[]foo</paragraph>`, '12' ],
 							[ '20', '21', '22' ]
 						] ) );
 
 						editor.editing.view.document.fire( 'keydown', upArrowDomEvtDataStub );
 
 						sinon.assert.calledOnce( upArrowDomEvtDataStub.preventDefault );
-						sinon.assert.calledOnce( upArrowDomEvtDataStub.stopPropagation );
 
 						assertEqualMarkup( getModelData( model ), modelTable( [
 							[ '00', '01', '02' ],
@@ -2983,14 +3046,13 @@ describe( 'TableKeyboard', () => {
 					it( 'should not navigate to the cell below but should select the image widget without caption', () => {
 						setModelData( model, modelTable( [
 							[ '00', '01', '02' ],
-							[ '10', `<paragraph>f[]oo</paragraph><image src="${ imageUrl }"><caption></caption></image>`, '12' ],
+							[ '10', `<paragraph>foo[]</paragraph><image src="${ imageUrl }"><caption></caption></image>`, '12' ],
 							[ '20', '21', '22' ]
 						] ) );
 
 						editor.editing.view.document.fire( 'keydown', downArrowDomEvtDataStub );
 
 						sinon.assert.calledOnce( downArrowDomEvtDataStub.preventDefault );
-						sinon.assert.calledOnce( downArrowDomEvtDataStub.stopPropagation );
 
 						assertEqualMarkup( getModelData( model ), modelTable( [
 							[ '00', '01', '02' ],

+ 6 - 2
packages/ckeditor5-ui/src/bindings/clickoutsidehandler.js

@@ -25,13 +25,17 @@
  * @param {Function} options.callback An action executed by the handler.
  */
 export default function clickOutsideHandler( { emitter, activator, callback, contextElements } ) {
-	emitter.listenTo( document, 'mousedown', ( evt, { target } ) => {
+	emitter.listenTo( document, 'mousedown', ( evt, domEvt ) => {
 		if ( !activator() ) {
 			return;
 		}
 
+		// Check if `composedPath` is `undefined` in case the browser does not support native shadow DOM.
+		// Can be removed when all supported browsers support native shadow DOM.
+		const path = typeof domEvt.composedPath == 'function' ? domEvt.composedPath() : [];
+
 		for ( const contextElement of contextElements ) {
-			if ( contextElement.contains( target ) ) {
+			if ( contextElement.contains( domEvt.target ) || path.includes( contextElement ) ) {
 				return;
 			}
 		}

+ 80 - 1
packages/ckeditor5-ui/tests/bindings/clickoutsidehandler.js

@@ -13,6 +13,7 @@ import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
 
 describe( 'clickOutsideHandler', () => {
 	let activator, actionSpy, contextElement1, contextElement2;
+	let shadowRootContainer, shadowContextElement1, shadowContextElement2;
 
 	testUtils.createSinonSandbox();
 
@@ -20,15 +21,22 @@ describe( 'clickOutsideHandler', () => {
 		activator = testUtils.sinon.stub().returns( false );
 		contextElement1 = document.createElement( 'div' );
 		contextElement2 = document.createElement( 'div' );
+		shadowRootContainer = document.createElement( 'div' );
+		shadowRootContainer.attachShadow( { mode: 'open' } );
+		shadowContextElement1 = document.createElement( 'div' );
+		shadowContextElement2 = document.createElement( 'div' );
 		actionSpy = testUtils.sinon.spy();
 
 		document.body.appendChild( contextElement1 );
 		document.body.appendChild( contextElement2 );
+		shadowRootContainer.shadowRoot.appendChild( shadowContextElement1 );
+		shadowRootContainer.shadowRoot.appendChild( shadowContextElement2 );
+		document.body.appendChild( shadowRootContainer );
 
 		clickOutsideHandler( {
 			emitter: Object.create( DomEmitterMixin ),
 			activator,
-			contextElements: [ contextElement1, contextElement2 ],
+			contextElements: [ contextElement1, contextElement2, shadowContextElement1, shadowContextElement2 ],
 			callback: actionSpy
 		} );
 	} );
@@ -36,6 +44,7 @@ describe( 'clickOutsideHandler', () => {
 	afterEach( () => {
 		document.body.removeChild( contextElement1 );
 		document.body.removeChild( contextElement2 );
+		document.body.removeChild( shadowRootContainer );
 	} );
 
 	it( 'should execute upon #mousedown outside of the contextElements (activator is active)', () => {
@@ -46,6 +55,25 @@ describe( 'clickOutsideHandler', () => {
 		sinon.assert.calledOnce( actionSpy );
 	} );
 
+	it( 'should execute upon #mousedown outside of the contextElements (activator is active, unsupported shadow DOM)', () => {
+		activator.returns( true );
+
+		const event = new Event( 'mousedown', { bubbles: true } );
+		event.composedPath = undefined;
+
+		document.body.dispatchEvent( event );
+
+		sinon.assert.calledOnce( actionSpy );
+	} );
+
+	it( 'should execute upon #mousedown in the shadow root but outside the contextElements (activator is active)', () => {
+		activator.returns( true );
+
+		shadowRootContainer.shadowRoot.dispatchEvent( new Event( 'mousedown', { bubbles: true } ) );
+
+		sinon.assert.notCalled( actionSpy );
+	} );
+
 	it( 'should not execute upon #mousedown outside of the contextElements (activator is inactive)', () => {
 		activator.returns( false );
 
@@ -54,6 +82,25 @@ describe( 'clickOutsideHandler', () => {
 		sinon.assert.notCalled( actionSpy );
 	} );
 
+	it( 'should not execute upon #mousedown outside of the contextElements (activator is inactive, unsupported shadow DOM)', () => {
+		activator.returns( false );
+
+		const event = new Event( 'mousedown', { bubbles: true } );
+		event.composedPath = undefined;
+
+		document.body.dispatchEvent( event );
+
+		sinon.assert.notCalled( actionSpy );
+	} );
+
+	it( 'should not execute upon #mousedown in the shadow root but outside of the contextElements (activator is inactive)', () => {
+		activator.returns( false );
+
+		shadowRootContainer.shadowRoot.dispatchEvent( new Event( 'mousedown', { bubbles: true } ) );
+
+		sinon.assert.notCalled( actionSpy );
+	} );
+
 	it( 'should not execute upon #mousedown from one of the contextElements (activator is active)', () => {
 		activator.returns( true );
 
@@ -62,6 +109,12 @@ describe( 'clickOutsideHandler', () => {
 
 		contextElement2.dispatchEvent( new Event( 'mouseup', { bubbles: true } ) );
 		sinon.assert.notCalled( actionSpy );
+
+		shadowContextElement1.dispatchEvent( new Event( 'mouseup', { bubbles: true } ) );
+		sinon.assert.notCalled( actionSpy );
+
+		shadowContextElement2.dispatchEvent( new Event( 'mouseup', { bubbles: true } ) );
+		sinon.assert.notCalled( actionSpy );
 	} );
 
 	it( 'should not execute upon #mousedown from one of the contextElements (activator is inactive)', () => {
@@ -72,6 +125,12 @@ describe( 'clickOutsideHandler', () => {
 
 		contextElement2.dispatchEvent( new Event( 'mouseup', { bubbles: true } ) );
 		sinon.assert.notCalled( actionSpy );
+
+		shadowContextElement1.dispatchEvent( new Event( 'mouseup', { bubbles: true } ) );
+		sinon.assert.notCalled( actionSpy );
+
+		shadowContextElement2.dispatchEvent( new Event( 'mouseup', { bubbles: true } ) );
+		sinon.assert.notCalled( actionSpy );
 	} );
 
 	it( 'should execute if the activator function returns `true`', () => {
@@ -139,4 +198,24 @@ describe( 'clickOutsideHandler', () => {
 
 		sinon.assert.notCalled( actionSpy );
 	} );
+
+	it( 'should not execute if one of contextElements in the shadow root contains the DOM event target', () => {
+		const target = document.createElement( 'div' );
+		activator.returns( true );
+
+		shadowContextElement1.appendChild( target );
+		target.dispatchEvent( new Event( 'mousedown', { bubbles: true } ) );
+
+		sinon.assert.notCalled( actionSpy );
+	} );
+
+	it( 'should not execute if one of contextElements in the shadow root is the DOM event target', () => {
+		const target = document.createElement( 'div' );
+		activator.returns( true );
+
+		shadowRootContainer.shadowRoot.appendChild( target );
+		target.dispatchEvent( new Event( 'mousedown', { bubbles: true } ) );
+
+		sinon.assert.notCalled( actionSpy );
+	} );
 } );

+ 1 - 1
packages/ckeditor5-widget/src/utils.js

@@ -48,7 +48,7 @@ export function isWidget( node ) {
 /**
  * Converts the given {@link module:engine/view/element~Element} to a widget in the following way:
  *
- * * sets the `contenteditable` attribute to `"true"`,
+ * * sets the `contenteditable` attribute to `"false"`,
  * * adds the `ck-widget` CSS class,
  * * adds a custom {@link module:engine/view/element~Element#getFillerOffset `getFillerOffset()`} method returning `null`,
  * * adds a custom property allowing to recognize widget elements by using {@link ~isWidget `isWidget()`},

+ 225 - 0
packages/ckeditor5-widget/src/verticalnavigation.js

@@ -0,0 +1,225 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
+import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect';
+
+/**
+ * @module widget/verticalnavigationhandler
+ */
+
+/**
+ * Returns 'keydown' handler for up/down arrow keys that modifies the caret movement if it's in a text line next to an object.
+ *
+ * @param {module:engine/controller/editingcontroller~EditingController} editing The editing controller.
+ * @returns {Function}
+ */
+export default function verticalNavigationHandler( editing ) {
+	const model = editing.model;
+
+	return ( evt, data ) => {
+		const arrowUpPressed = data.keyCode == keyCodes.arrowup;
+		const arrowDownPressed = data.keyCode == keyCodes.arrowdown;
+		const expandSelection = data.shiftKey;
+		const selection = model.document.selection;
+
+		if ( !arrowUpPressed && !arrowDownPressed ) {
+			return;
+		}
+
+		const isForward = arrowDownPressed;
+
+		// Navigation is in the opposite direction than the selection direction so this is shrinking of the selection.
+		// Selection for sure will not approach any object.
+		if ( expandSelection && selectionWillShrink( selection, isForward ) ) {
+			return;
+		}
+
+		// Find a range between selection and closest limit element.
+		const range = findTextRangeFromSelection( editing, selection, isForward );
+
+		if ( !range || range.isCollapsed ) {
+			return;
+		}
+
+		// If the range is a single line (there is no word wrapping) then move the selection to the position closest to the limit element.
+		//
+		// We can't move the selection directly to the isObject element (eg. table cell) because of dual position at the end/beginning
+		// of wrapped line (it's at the same time at the end of one line and at the start of the next line).
+		if ( isSingleLineRange( editing, range, isForward ) ) {
+			model.change( writer => {
+				const newPosition = isForward ? range.end : range.start;
+
+				if ( expandSelection ) {
+					const newSelection = model.createSelection( selection.anchor );
+					newSelection.setFocus( newPosition );
+
+					writer.setSelection( newSelection );
+				} else {
+					writer.setSelection( newPosition );
+				}
+			} );
+
+			evt.stop();
+			data.preventDefault();
+			data.stopPropagation();
+		}
+	};
+}
+
+// Finds the range between selection and closest limit element (in the direction of navigation).
+// The position next to limit element is adjusted to the closest allowed `$text` position.
+//
+// Returns `null` if, according to the schema, the resulting range cannot contain a `$text` element.
+//
+// @param {module:engine/controller/editingcontroller~EditingController} editing The editing controller.
+// @param {module:engine/model/selection~Selection} selection The current selection.
+// @param {Boolean} isForward The expected navigation direction.
+// @returns {module:engine/model/range~Range|null}
+//
+function findTextRangeFromSelection( editing, selection, isForward ) {
+	const model = editing.model;
+
+	if ( isForward ) {
+		const startPosition = selection.isCollapsed ? selection.focus : selection.getLastPosition();
+		const endPosition = getNearestNonInlineLimit( model, startPosition, 'forward' );
+
+		// There is no limit element, browser should handle this.
+		if ( !endPosition ) {
+			return null;
+		}
+
+		const range = model.createRange( startPosition, endPosition );
+		const lastRangePosition = getNearestTextPosition( model.schema, range, 'backward' );
+
+		if ( lastRangePosition && startPosition.isBefore( lastRangePosition ) ) {
+			return model.createRange( startPosition, lastRangePosition );
+		}
+
+		return null;
+	} else {
+		const endPosition = selection.isCollapsed ? selection.focus : selection.getFirstPosition();
+		const startPosition = getNearestNonInlineLimit( model, endPosition, 'backward' );
+
+		// There is no limit element, browser should handle this.
+		if ( !startPosition ) {
+			return null;
+		}
+
+		const range = model.createRange( startPosition, endPosition );
+		const firstRangePosition = getNearestTextPosition( model.schema, range, 'forward' );
+
+		if ( firstRangePosition && endPosition.isAfter( firstRangePosition ) ) {
+			return model.createRange( firstRangePosition, endPosition );
+		}
+
+		return null;
+	}
+}
+
+// Finds the limit element position that is closest to startPosition.
+//
+// @param {module:engine/model/model~Model} model
+// @param {<module:engine/model/position~Position>} startPosition
+// @param {'forward'|'backward'} direction Search direction.
+// @returns {<module:engine/model/position~Position>|null}
+//
+function getNearestNonInlineLimit( model, startPosition, direction ) {
+	const schema = model.schema;
+	const range = model.createRangeIn( startPosition.root );
+
+	const walkerValueType = direction == 'forward' ? 'elementStart' : 'elementEnd';
+
+	for ( const { previousPosition, item, type } of range.getWalker( { startPosition, direction } ) ) {
+		if ( schema.isLimit( item ) && !schema.isInline( item ) ) {
+			return previousPosition;
+		}
+
+		// Stop looking for isLimit element if the next element is a block element (it is for sure not single line).
+		if ( type == walkerValueType && schema.isBlock( item ) ) {
+			return null;
+		}
+	}
+
+	return null;
+}
+
+// Basing on the provided range, finds the first or last (depending on `direction`) position inside the range
+// that can contain `$text` (according to schema).
+//
+// @param {module:engine/model/schema~Schema} schema The schema.
+// @param {module:engine/model/range~Range} range The range to find the position in.
+// @param {'forward'|'backward'} direction Search direction.
+// @returns {module:engine/model/position~Position} The nearest selection range.
+//
+function getNearestTextPosition( schema, range, direction ) {
+	const position = direction == 'backward' ? range.end : range.start;
+
+	if ( schema.checkChild( position, '$text' ) ) {
+		return position;
+	}
+
+	for ( const { nextPosition } of range.getWalker( { direction } ) ) {
+		if ( schema.checkChild( nextPosition, '$text' ) ) {
+			return nextPosition;
+		}
+	}
+}
+
+// Checks if the DOM range corresponding to the provided model range renders as a single line by analyzing DOMRects
+// (verifying if they visually wrap content to the next line).
+//
+// @param {module:engine/controller/editingcontroller~EditingController} editing The editing controller.
+// @param {module:engine/model/range~Range} modelRange The current table cell content range.
+// @param {Boolean} isForward The expected navigation direction.
+// @returns {Boolean}
+//
+function isSingleLineRange( editing, modelRange, isForward ) {
+	const model = editing.model;
+	const domConverter = editing.view.domConverter;
+
+	// Wrapped lines contain exactly the same position at the end of current line
+	// and at the beginning of next line. That position's client rect is at the end
+	// of current line. In case of caret at first position of the last line that 'dual'
+	// position would be detected as it's not the last line.
+	if ( isForward ) {
+		const probe = model.createSelection( modelRange.start );
+
+		model.modifySelection( probe );
+
+		// If the new position is at the end of the container then we can't use this position
+		// because it would provide incorrect result for eg caption of image and selection
+		// just before end of it. Also in this case there is no "dual" position.
+		if ( !probe.focus.isAtEnd && !modelRange.start.isEqual( probe.focus ) ) {
+			modelRange = model.createRange( probe.focus, modelRange.end );
+		}
+	}
+
+	const viewRange = editing.mapper.toViewRange( modelRange );
+	const domRange = domConverter.viewRangeToDom( viewRange );
+	const rects = Rect.getDomRangeRects( domRange );
+
+	let boundaryVerticalPosition;
+
+	for ( const rect of rects ) {
+		if ( boundaryVerticalPosition === undefined ) {
+			boundaryVerticalPosition = Math.round( rect.bottom );
+			continue;
+		}
+
+		// Let's check if this rect is in new line.
+		if ( Math.round( rect.top ) >= boundaryVerticalPosition ) {
+			return false;
+		}
+
+		boundaryVerticalPosition = Math.max( boundaryVerticalPosition, Math.round( rect.bottom ) );
+	}
+
+	return true;
+}
+
+function selectionWillShrink( selection, isForward ) {
+	return !selection.isCollapsed && selection.isBackward == isForward;
+}

+ 3 - 0
packages/ckeditor5-widget/src/widget.js

@@ -19,6 +19,7 @@ import env from '@ckeditor/ckeditor5-utils/src/env';
 
 import '../theme/widget.css';
 import priorities from '@ckeditor/ckeditor5-utils/src/priorities';
+import verticalNavigationHandler from './verticalnavigation';
 
 /**
  * The widget plugin. It enables base support for widgets.
@@ -119,6 +120,8 @@ export default class Widget extends Plugin {
 			this._preventDefaultOnArrowKeyPress( ...args );
 		}, { priority: priorities.get( 'high' ) - 20 } );
 
+		this.listenTo( viewDocument, 'keydown', verticalNavigationHandler( this.editor.editing ) );
+
 		// Handle custom delete behaviour.
 		this.listenTo( viewDocument, 'delete', ( evt, data ) => {
 			if ( this._handleDelete( data.direction == 'forward' ) ) {

+ 28 - 4
packages/ckeditor5-widget/tests/manual/keyboard.html

@@ -6,12 +6,12 @@
 		}
 
 		.ck-content .widget {
-			background: rgba( 0, 0, 0, 0.1 );
+			background: hsl(0, 0%, 90%);
 			min-height: 50px;
 		}
 
 		.ck-content placeholder {
-			background: #ffff00;
+			background: hsl(60, 100%, 50%);
 			padding: 4px 2px;
 			outline-offset: -2px;
 			line-height: 1em;
@@ -24,16 +24,38 @@
 		.ck-content placeholder::selection {
 			display: none;
 		}
+
+		.ck-content figure {
+			background: hsl(0, 0%, 90%);
+			padding: 10px;
+			margin: 0;
+		}
+
+		.ck-content figcaption {
+			background: hsl(0, 0%, 100%);
+			padding: 10px;
+		}
 	</style>
 </head>
 
+WidgetTypeAround: <button id="wta-enable">Enable</button> <button id="wta-disable">Disable</button>
+
 <h2>LTR content</h2>
 
 <div id="editor-ltr">
 	<h2>Heading 1</h2>
-	<p>Para<placeholder>inline widget</placeholder>graph</p>
+	<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis in ullamcorper purus. Nam vel neque non augue semper aliquet. Morbi suscipit in massa vitae iaculis. In pulvinar eros non scelerisque sagittis. Fusce dictum vel odio vel molestie. Sed scelerisque turpis dolor, laoreet semper nibh fermentum id. Nullam at diam volutpat, porta mi vitae, varius ex.</p>
+	<p>Nulla volutpat est eget euismod cursus. Praesent quis ligula hendrerit lacus consectetur viverra eget sed lectus. Aenean non condimentum dolor. <placeholder>inline widget</placeholder> Aliquam bibendum leo sed luctus semper. Praesent est libero, aliquam id varius congue, feugiat finibus tellus. Sed non lectus eros. Fusce viverra commodo ligula, eget eleifend purus aliquet et.</p>
 	<div class="widget"></div>
-	<p>Paragraph</p>
+	<p>Pellentesque nec rhoncus turpis. Quisque quis sagittis est, sed luctus turpis. Ut et nulla efficitur urna convallis rhoncus vel ut ipsum. Phasellus pharetra hendrerit eros, ac ultrices lorem tincidunt eu. </p>
+	<p>Aliquam bibendum leo sed luctus semper.</p>
+	<figure>
+		<figcaption>
+			<p>Sed in ante libero. Aliquam dignissim magna non mollis tempor. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin orci erat, consectetur sit amet lacinia non, dictum sit amet nibh. Maecenas congue urna justo, ut ultricies lectus lobortis nec. Sed porta, ex a hendrerit ultrices, massa nunc sollicitudin augue, ut mollis purus leo vel dolor.</p>
+			<p>Suspendisse ultricies leo eget quam pulvinar, ut auctor mauris luctus. Quisque id nisi sed augue vestibulum auctor tempus quis sapien. Etiam neque risus, congue sit amet dapibus at, faucibus vel mauris. Aliquam eu mauris est. Cras egestas, nunc nec dapibus pellentesque, lorem ante fringilla erat, eu elementum nulla tellus at massa. Aenean turpis elit, molestie a porttitor in, auctor in urna.</p>
+		</figcaption>
+	</figure>
+	<p>Aenean ullamcorper, libero id efficitur lobortis, lacus turpis rhoncus ex, eu scelerisque ipsum risus et neque. Etiam faucibus, metus pharetra pharetra aliquet, ipsum nulla lobortis lectus, ut feugiat nulla nibh et ex. Aliquam finibus ex sed augue accumsan gravida. Cras tempor justo vel tincidunt sagittis. </p>
 </div>
 
 <h2>RTL content</h2>
@@ -43,4 +65,6 @@
 	<p>مرحبا<placeholder>inline widget</placeholder>مرحبا</p>
 	<div class="widget"></div>
 	<p>مرحبا</p>
+	<figure><figcaption><p>مرحبا</p><p>مرحبا</p></figcaption></figure>
+	<p>مرحبا</p>
 </div>

+ 76 - 2
packages/ckeditor5-widget/tests/manual/keyboard.js

@@ -6,7 +6,7 @@
 /* globals console, window, document */
 
 import Widget from '../../src/widget';
-import { toWidget, viewToModelPositionOutsideModelElement } from '../../src/utils';
+import { toWidget, toWidgetEditable, viewToModelPositionOutsideModelElement } from '../../src/utils';
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
 import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset';
@@ -36,6 +36,66 @@ function BlockWidget( editor ) {
 	} );
 }
 
+function BlockWidgetWithNestedEditable( editor ) {
+	const model = editor.model;
+
+	model.schema.register( 'widget', {
+		inheritAllFrom: '$block',
+		isObject: true
+	} );
+
+	model.schema.register( 'nested', {
+		allowIn: 'widget',
+		isLimit: true
+	} );
+
+	model.schema.extend( '$block', {
+		allowIn: 'nested'
+	} );
+
+	editor.conversion.for( 'dataDowncast' )
+		.elementToElement( {
+			model: 'widget',
+			view: ( modelItem, writer ) => {
+				return writer.createContainerElement( 'figure' );
+			}
+		} )
+		.elementToElement( {
+			model: 'nested',
+			view: ( modelItem, writer ) => {
+				return writer.createContainerElement( 'figcaption' );
+			}
+		} );
+
+	editor.conversion.for( 'editingDowncast' )
+		.elementToElement( {
+			model: 'widget',
+			view: ( modelItem, writer ) => {
+				const div = writer.createContainerElement( 'figure' );
+
+				return toWidget( div, writer, { label: 'widget label' } );
+			}
+		} )
+		.elementToElement( {
+			model: 'nested',
+			view: ( modelItem, writer ) => {
+				const nested = writer.createEditableElement( 'figcaption' );
+
+				return toWidgetEditable( nested, writer );
+			}
+		} );
+
+	editor.conversion.for( 'upcast' )
+		.elementToElement( {
+			view: 'figure',
+			model: 'widget'
+		} )
+		.elementToElement( {
+			view: 'figcaption',
+			model: 'nested'
+		} );
+}
+
 class InlineWidget extends Plugin {
 	constructor( editor ) {
 		super( editor );
@@ -82,7 +142,7 @@ class InlineWidget extends Plugin {
 }
 
 const config = {
-	plugins: [ ArticlePluginSet, Widget, InlineWidget, BlockWidget ],
+	plugins: [ ArticlePluginSet, Widget, InlineWidget, BlockWidget, BlockWidgetWithNestedEditable ],
 	toolbar: [
 		'heading',
 		'|',
@@ -113,6 +173,8 @@ ClassicEditor
 	.create( document.querySelector( '#editor-ltr' ), config )
 	.then( editor => {
 		window.editorLtr = editor;
+
+		bindButtons( editor );
 	} )
 	.catch( err => {
 		console.error( err.stack );
@@ -124,7 +186,19 @@ ClassicEditor
 	} ) )
 	.then( editor => {
 		window.editorRtl = editor;
+
+		bindButtons( editor );
 	} )
 	.catch( err => {
 		console.error( err.stack );
 	} );
+
+function bindButtons( editor ) {
+	document.getElementById( 'wta-disable' ).addEventListener( 'click', () => {
+		editor.plugins.get( 'WidgetTypeAround' ).forceDisabled();
+	} );
+
+	document.getElementById( 'wta-enable' ).addEventListener( 'click', () => {
+		editor.plugins.get( 'WidgetTypeAround' ).clearForceDisabled();
+	} );
+}

+ 4 - 0
packages/ckeditor5-widget/tests/manual/keyboard.md

@@ -10,6 +10,10 @@
 5. Reach the end of the document.
 6. Go backwards using the **left arrow** key and repeat the entire scenario.
 
+Check if **up/down arrows** are working correctly. Caret should jump to text position closest to non-inline limit element if there are no more text lines between the caret and limit element. Note that limit is an external edge of a widget and also edge of nested editable inside widget.
+
+It's also worth to check **up/down arrows** at the beginnings and ends of lines.
+
 ## RTL (right–to–left) content navigation
 
 In this scenario the content is written in Arabic.

+ 1300 - 0
packages/ckeditor5-widget/tests/verticalnavigation.js

@@ -0,0 +1,1300 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import { toWidget, toWidgetEditable } from '../src/utils';
+
+import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
+import BlockQuote from '@ckeditor/ckeditor5-block-quote/src/blockquote';
+import HorizontalLine from '@ckeditor/ckeditor5-horizontal-line/src/horizontalline';
+import Image from '@ckeditor/ckeditor5-image/src/image';
+import ImageCaption from '@ckeditor/ckeditor5-image/src/imagecaption';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+
+import { getCode } from '@ckeditor/ckeditor5-utils/src/keyboard';
+import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
+import global from '@ckeditor/ckeditor5-utils/src/dom/global';
+import env from '@ckeditor/ckeditor5-utils/src/env';
+
+describe( 'Widget - vertical keyboard navigation near widgets', () => {
+	let editorElement, editor, model, styleElement;
+	let leftArrowDomEvtDataStub, rightArrowDomEvtDataStub, upArrowDomEvtDataStub, downArrowDomEvtDataStub;
+
+	const imageUrl = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAAAUCAQAAADRyVAeAAAAKklEQVR42u3PAQ0AAAwCI' +
+		'O0f+u/hoAHNZUJFRERERERERERERERERLYiD9N4FAFj2iK6AAAAAElFTkSuQmCC';
+
+	beforeEach( async () => {
+		editorElement = global.document.createElement( 'div' );
+		global.document.body.appendChild( editorElement );
+
+		editor = await ClassicTestEditor.create( editorElement, {
+			plugins: [ Paragraph, Image, ImageCaption, HorizontalLine, BlockQuote, BlockWidgetWithNestedEditable ]
+		} );
+
+		model = editor.model;
+
+		// The editing view must be focused because otherwise in Chrome the DOM selection will not contain
+		// any ranges and jumpOverUiElement will crash (for the right arrow when shift is pressed).
+		editor.editing.view.focus();
+
+		leftArrowDomEvtDataStub = {
+			keyCode: getCode( 'ArrowLeft' ),
+			preventDefault: sinon.spy(),
+			stopPropagation: sinon.spy(),
+			domTarget: global.document.body
+		};
+		rightArrowDomEvtDataStub = {
+			keyCode: getCode( 'ArrowRight' ),
+			preventDefault: sinon.spy(),
+			stopPropagation: sinon.spy(),
+			domTarget: global.document.body
+		};
+		upArrowDomEvtDataStub = {
+			keyCode: getCode( 'ArrowUp' ),
+			preventDefault: sinon.spy(),
+			stopPropagation: sinon.spy(),
+			domTarget: global.document.body
+		};
+		downArrowDomEvtDataStub = {
+			keyCode: getCode( 'ArrowDown' ),
+			preventDefault: sinon.spy(),
+			stopPropagation: sinon.spy(),
+			domTarget: global.document.body
+		};
+
+		// Those tests are checking text line wrapping so forcing some sizes are needed to make those tests stable.
+		// Some tests are excluded for Gecko because of differences in font rendering (text line wraps in different places).
+		styleElement = global.document.createElement( 'style' );
+		styleElement.appendChild( global.document.createTextNode(
+			`
+			* {
+				font-size: 12px !important;
+				font-family: serif !important;
+				margin: 0 !important;
+				padding: 0 !important;
+				border: 0 !important
+			}
+			.ck.ck-editor__editable { width: 300px !important; }
+			`
+		) );
+		global.document.querySelector( 'head' ).appendChild( styleElement );
+	} );
+
+	afterEach( async () => {
+		editorElement.remove();
+		styleElement.remove();
+		await editor.destroy();
+	} );
+
+	it( 'should do nothing if pressed left-arrow key', () => {
+		setModelData( model,
+			'<paragraph>foo</paragraph>' +
+			'<paragraph>b[]ar</paragraph>' +
+			'<paragraph>abc</paragraph>'
+		);
+
+		editor.editing.view.document.fire( 'keydown', leftArrowDomEvtDataStub );
+
+		sinon.assert.notCalled( leftArrowDomEvtDataStub.preventDefault );
+		sinon.assert.notCalled( leftArrowDomEvtDataStub.stopPropagation );
+
+		assertEqualMarkup(
+			getModelData( model ),
+			'<paragraph>foo</paragraph>' +
+			'<paragraph>b[]ar</paragraph>' +
+			'<paragraph>abc</paragraph>'
+		);
+	} );
+
+	it( 'should do nothing if pressed right-arrow key', () => {
+		setModelData( model,
+			'<paragraph>foo</paragraph>' +
+			'<paragraph>b[]ar</paragraph>' +
+			'<paragraph>abc</paragraph>'
+		);
+
+		editor.editing.view.document.fire( 'keydown', rightArrowDomEvtDataStub );
+
+		sinon.assert.notCalled( rightArrowDomEvtDataStub.preventDefault );
+		sinon.assert.notCalled( rightArrowDomEvtDataStub.stopPropagation );
+
+		assertEqualMarkup(
+			getModelData( model ),
+			'<paragraph>foo</paragraph>' +
+			'<paragraph>b[]ar</paragraph>' +
+			'<paragraph>abc</paragraph>'
+		);
+	} );
+
+	it( 'should do nothing if shrinking non-collapsed forward selection', () => {
+		setModelData( model, '<paragraph>fo[ob]ar</paragraph>' );
+
+		upArrowDomEvtDataStub.shiftKey = true;
+		editor.editing.view.document.fire( 'keydown', upArrowDomEvtDataStub );
+
+		sinon.assert.notCalled( upArrowDomEvtDataStub.preventDefault );
+		sinon.assert.notCalled( upArrowDomEvtDataStub.stopPropagation );
+	} );
+
+	it( 'should do nothing if shrinking non-collapsed backward selection', () => {
+		setModelData( model, '<paragraph>fo[ob]ar</paragraph>', { lastRangeBackward: true } );
+
+		downArrowDomEvtDataStub.shiftKey = true;
+		editor.editing.view.document.fire( 'keydown', downArrowDomEvtDataStub );
+
+		sinon.assert.notCalled( downArrowDomEvtDataStub.preventDefault );
+		sinon.assert.notCalled( downArrowDomEvtDataStub.stopPropagation );
+	} );
+
+	describe( 'with selection inside root content editable', () => {
+		describe( 'single paragraph surrounded with objects', () => {
+			describe( 'collapsed selection', () => {
+				beforeEach( () => {
+					setModelData( model,
+						'<horizontalLine></horizontalLine>' +
+						'<paragraph>foo[]bar</paragraph>' +
+						'<horizontalLine></horizontalLine>'
+					);
+				} );
+
+				it( 'should move caret to the position closest to object (navigating forward)', () => {
+					editor.editing.view.document.fire( 'keydown', downArrowDomEvtDataStub );
+
+					sinon.assert.calledOnce( downArrowDomEvtDataStub.preventDefault );
+					sinon.assert.calledOnce( downArrowDomEvtDataStub.stopPropagation );
+
+					assertEqualMarkup( getModelData( model ),
+						'<horizontalLine></horizontalLine>' +
+						'<paragraph>foobar[]</paragraph>' +
+						'<horizontalLine></horizontalLine>'
+					);
+				} );
+
+				it( 'should move caret to the position closest to object (navigating backward)', () => {
+					editor.editing.view.document.fire( 'keydown', upArrowDomEvtDataStub );
+
+					sinon.assert.calledOnce( upArrowDomEvtDataStub.preventDefault );
+					sinon.assert.calledOnce( upArrowDomEvtDataStub.stopPropagation );
+
+					assertEqualMarkup( getModelData( model ),
+						'<horizontalLine></horizontalLine>' +
+						'<paragraph>[]foobar</paragraph>' +
+						'<horizontalLine></horizontalLine>'
+					);
+				} );
+			} );
+
+			describe( 'non-collapsed forward selection', () => {
+				beforeEach( () => {
+					setModelData( model,
+						'<horizontalLine></horizontalLine>' +
+						'<paragraph>fo[ob]ar</paragraph>' +
+						'<horizontalLine></horizontalLine>'
+					);
+				} );
+
+				it( 'should move caret to the position closest to object (navigating forward)', () => {
+					editor.editing.view.document.fire( 'keydown', downArrowDomEvtDataStub );
+
+					sinon.assert.calledOnce( downArrowDomEvtDataStub.preventDefault );
+					sinon.assert.calledOnce( downArrowDomEvtDataStub.stopPropagation );
+
+					assertEqualMarkup( getModelData( model ),
+						'<horizontalLine></horizontalLine>' +
+						'<paragraph>foobar[]</paragraph>' +
+						'<horizontalLine></horizontalLine>'
+					);
+				} );
+
+				it( 'should move caret to the position closest to object (navigating backward)', () => {
+					editor.editing.view.document.fire( 'keydown', upArrowDomEvtDataStub );
+
+					sinon.assert.calledOnce( upArrowDomEvtDataStub.preventDefault );
+					sinon.assert.calledOnce( upArrowDomEvtDataStub.stopPropagation );
+
+					assertEqualMarkup( getModelData( model ),
+						'<horizontalLine></horizontalLine>' +
+						'<paragraph>[]foobar</paragraph>' +
+						'<horizontalLine></horizontalLine>'
+					);
+				} );
+
+				describe( 'with shift pressed', () => {
+					it( 'should expand the selection to the position closest to object (navigating forward)', () => {
+						downArrowDomEvtDataStub.shiftKey = true;
+						editor.editing.view.document.fire( 'keydown', downArrowDomEvtDataStub );
+
+						sinon.assert.calledOnce( downArrowDomEvtDataStub.preventDefault );
+						sinon.assert.calledOnce( downArrowDomEvtDataStub.stopPropagation );
+
+						assertEqualMarkup( getModelData( model ),
+							'<horizontalLine></horizontalLine>' +
+							'<paragraph>fo[obar]</paragraph>' +
+							'<horizontalLine></horizontalLine>'
+						);
+					} );
+
+					it( 'should not prevent default browser behavior while navigating backward', () => {
+						upArrowDomEvtDataStub.shiftKey = true;
+						editor.editing.view.document.fire( 'keydown', upArrowDomEvtDataStub );
+
+						sinon.assert.notCalled( upArrowDomEvtDataStub.preventDefault );
+						sinon.assert.notCalled( upArrowDomEvtDataStub.stopPropagation );
+					} );
+				} );
+			} );
+
+			describe( 'non-collapsed backward selection', () => {
+				beforeEach( () => {
+					setModelData( model,
+						'<horizontalLine></horizontalLine>' +
+						'<paragraph>fo[ob]ar</paragraph>' +
+						'<horizontalLine></horizontalLine>',
+						{ lastRangeBackward: true }
+					);
+				} );
+
+				it( 'should move caret to the position closest to object (navigating forward)', () => {
+					editor.editing.view.document.fire( 'keydown', downArrowDomEvtDataStub );
+
+					sinon.assert.calledOnce( downArrowDomEvtDataStub.preventDefault );
+					sinon.assert.calledOnce( downArrowDomEvtDataStub.stopPropagation );
+
+					assertEqualMarkup( getModelData( model ),
+						'<horizontalLine></horizontalLine>' +
+						'<paragraph>foobar[]</paragraph>' +
+						'<horizontalLine></horizontalLine>'
+					);
+				} );
+
+				it( 'should move caret to the position closest to object (navigating backward)', () => {
+					editor.editing.view.document.fire( 'keydown', upArrowDomEvtDataStub );
+
+					sinon.assert.calledOnce( upArrowDomEvtDataStub.preventDefault );
+					sinon.assert.calledOnce( upArrowDomEvtDataStub.stopPropagation );
+
+					assertEqualMarkup( getModelData( model ),
+						'<horizontalLine></horizontalLine>' +
+						'<paragraph>[]foobar</paragraph>' +
+						'<horizontalLine></horizontalLine>'
+					);
+				} );
+
+				describe( 'with shift pressed', () => {
+					it( 'should not prevent default browser behavior while navigating forward', () => {
+						downArrowDomEvtDataStub.shiftKey = true;
+						editor.editing.view.document.fire( 'keydown', downArrowDomEvtDataStub );
+
+						sinon.assert.notCalled( downArrowDomEvtDataStub.preventDefault );
+						sinon.assert.notCalled( downArrowDomEvtDataStub.stopPropagation );
+					} );
+
+					it( 'should expand the selection to the position closest to object (navigating backward)', () => {
+						upArrowDomEvtDataStub.shiftKey = true;
+						editor.editing.view.document.fire( 'keydown', upArrowDomEvtDataStub );
+
+						sinon.assert.calledOnce( upArrowDomEvtDataStub.preventDefault );
+						sinon.assert.calledOnce( upArrowDomEvtDataStub.stopPropagation );
+
+						assertEqualMarkup( getModelData( model ),
+							'<horizontalLine></horizontalLine>' +
+							'<paragraph>[foob]ar</paragraph>' +
+							'<horizontalLine></horizontalLine>'
+						);
+					} );
+				} );
+			} );
+		} );
+
+		describe( 'multiple paragraphs with object inside', () => {
+			describe( 'caret in the first paragraph', () => {
+				beforeEach( () => {
+					setModelData( model,
+						'<paragraph>fo[]oo</paragraph>' +
+						'<paragraph>bar</paragraph>' +
+						'<horizontalLine></horizontalLine>' +
+						'<paragraph>FOO</paragraph>' +
+						'<paragraph>BAR</paragraph>'
+					);
+				} );
+
+				it( 'should not prevent default on forward navigation', () => {
+					editor.editing.view.document.fire( 'keydown', downArrowDomEvtDataStub );
+
+					sinon.assert.notCalled( downArrowDomEvtDataStub.preventDefault );
+					sinon.assert.notCalled( downArrowDomEvtDataStub.stopPropagation );
+				} );
+
+				it( 'should not prevent default on backward navigation', () => {
+					editor.editing.view.document.fire( 'keydown', upArrowDomEvtDataStub );
+
+					sinon.assert.notCalled( upArrowDomEvtDataStub.preventDefault );
+					sinon.assert.notCalled( upArrowDomEvtDataStub.stopPropagation );
+				} );
+			} );
+
+			describe( 'caret in the second paragraph', () => {
+				beforeEach( () => {
+					setModelData( model,
+						'<paragraph>foo</paragraph>' +
+						'<paragraph>ba[]ar</paragraph>' +
+						'<horizontalLine></horizontalLine>' +
+						'<paragraph>FOO</paragraph>' +
+						'<paragraph>BAR</paragraph>'
+					);
+				} );
+
+				it( 'should move caret to the position closest to object on forward navigation', () => {
+					editor.editing.view.document.fire( 'keydown', downArrowDomEvtDataStub );
+
+					sinon.assert.calledOnce( downArrowDomEvtDataStub.preventDefault );
+					sinon.assert.calledOnce( downArrowDomEvtDataStub.stopPropagation );
+
+					assertEqualMarkup( getModelData( model ),
+						'<paragraph>foo</paragraph>' +
+						'<paragraph>baar[]</paragraph>' +
+						'<horizontalLine></horizontalLine>' +
+						'<paragraph>FOO</paragraph>' +
+						'<paragraph>BAR</paragraph>'
+					);
+				} );
+
+				it( 'should not prevent default on backward navigation', () => {
+					editor.editing.view.document.fire( 'keydown', upArrowDomEvtDataStub );
+
+					sinon.assert.notCalled( upArrowDomEvtDataStub.preventDefault );
+					sinon.assert.notCalled( upArrowDomEvtDataStub.stopPropagation );
+				} );
+			} );
+
+			describe( 'caret in the third paragraph', () => {
+				beforeEach( () => {
+					setModelData( model,
+						'<paragraph>foo</paragraph>' +
+						'<paragraph>bar</paragraph>' +
+						'<horizontalLine></horizontalLine>' +
+						'<paragraph>FO[]OO</paragraph>' +
+						'<paragraph>BAR</paragraph>'
+					);
+				} );
+
+				it( 'should not prevent default on forward navigation', () => {
+					editor.editing.view.document.fire( 'keydown', downArrowDomEvtDataStub );
+
+					sinon.assert.notCalled( downArrowDomEvtDataStub.preventDefault );
+					sinon.assert.notCalled( downArrowDomEvtDataStub.stopPropagation );
+				} );
+
+				it( 'should move caret to the position closest to object on backward navigation', () => {
+					editor.editing.view.document.fire( 'keydown', upArrowDomEvtDataStub );
+
+					sinon.assert.calledOnce( upArrowDomEvtDataStub.preventDefault );
+					sinon.assert.calledOnce( upArrowDomEvtDataStub.stopPropagation );
+
+					assertEqualMarkup( getModelData( model ),
+						'<paragraph>foo</paragraph>' +
+						'<paragraph>bar</paragraph>' +
+						'<horizontalLine></horizontalLine>' +
+						'<paragraph>[]FOOO</paragraph>' +
+						'<paragraph>BAR</paragraph>'
+					);
+				} );
+			} );
+
+			describe( 'caret in the forth paragraph', () => {
+				beforeEach( () => {
+					setModelData( model,
+						'<paragraph>foo</paragraph>' +
+						'<paragraph>bar</paragraph>' +
+						'<horizontalLine></horizontalLine>' +
+						'<paragraph>FOO</paragraph>' +
+						'<paragraph>BA[]AR</paragraph>'
+					);
+				} );
+
+				it( 'should not prevent default on backward navigation', () => {
+					editor.editing.view.document.fire( 'keydown', upArrowDomEvtDataStub );
+
+					sinon.assert.notCalled( upArrowDomEvtDataStub.preventDefault );
+					sinon.assert.notCalled( upArrowDomEvtDataStub.stopPropagation );
+				} );
+
+				it( 'should not prevent default on forward navigation', () => {
+					editor.editing.view.document.fire( 'keydown', downArrowDomEvtDataStub );
+
+					sinon.assert.notCalled( downArrowDomEvtDataStub.preventDefault );
+					sinon.assert.notCalled( downArrowDomEvtDataStub.stopPropagation );
+				} );
+			} );
+		} );
+
+		it( 'should integrate with the blockquote (forward navigation)', () => {
+			setModelData( model,
+				'<blockQuote>' +
+					'<paragraph>f[]oo</paragraph>' +
+				'</blockQuote>' +
+				'<horizontalLine></horizontalLine>' +
+				'<paragraph>bar</paragraph>'
+			);
+
+			editor.editing.view.document.fire( 'keydown', downArrowDomEvtDataStub );
+
+			sinon.assert.calledOnce( downArrowDomEvtDataStub.preventDefault );
+			sinon.assert.calledOnce( downArrowDomEvtDataStub.stopPropagation );
+
+			assertEqualMarkup( getModelData( model ),
+				'<blockQuote>' +
+					'<paragraph>foo[]</paragraph>' +
+				'</blockQuote>' +
+				'<horizontalLine></horizontalLine>' +
+				'<paragraph>bar</paragraph>'
+			);
+		} );
+
+		it( 'should integrate with the blockquote (backward navigation)', () => {
+			setModelData( model,
+				'<paragraph>foo</paragraph>' +
+				'<horizontalLine></horizontalLine>' +
+				'<blockQuote>' +
+					'<paragraph>ba[]r</paragraph>' +
+				'</blockQuote>'
+			);
+
+			editor.editing.view.document.fire( 'keydown', upArrowDomEvtDataStub );
+
+			sinon.assert.calledOnce( upArrowDomEvtDataStub.preventDefault );
+			sinon.assert.calledOnce( upArrowDomEvtDataStub.stopPropagation );
+
+			assertEqualMarkup( getModelData( model ),
+				'<paragraph>foo</paragraph>' +
+				'<horizontalLine></horizontalLine>' +
+				'<blockQuote>' +
+					'<paragraph>[]bar</paragraph>' +
+				'</blockQuote>'
+			);
+		} );
+	} );
+
+	describe( 'with selection inside nested content editable', () => {
+		describe( 'simple text content', () => {
+			describe( 'with collapsed selection', () => {
+				beforeEach( () => {
+					setModelData( model, '<widget><nested><paragraph>foo[]bar</paragraph></nested></widget>' );
+				} );
+
+				it( 'should move caret to the beginning of the nested editable content', () => {
+					editor.editing.view.document.fire( 'keydown', upArrowDomEvtDataStub );
+
+					sinon.assert.calledOnce( upArrowDomEvtDataStub.preventDefault );
+					sinon.assert.calledOnce( upArrowDomEvtDataStub.stopPropagation );
+
+					assertEqualMarkup( getModelData( model ), '<widget><nested><paragraph>[]foobar</paragraph></nested></widget>' );
+				} );
+
+				it( 'should move caret to the end of the nested editable content', () => {
+					editor.editing.view.document.fire( 'keydown', downArrowDomEvtDataStub );
+
+					sinon.assert.calledOnce( downArrowDomEvtDataStub.preventDefault );
+					sinon.assert.calledOnce( downArrowDomEvtDataStub.stopPropagation );
+
+					assertEqualMarkup( getModelData( model ), '<widget><nested><paragraph>foobar[]</paragraph></nested></widget>' );
+				} );
+
+				describe( 'when shift key is pressed', () => {
+					beforeEach( () => {
+						upArrowDomEvtDataStub.shiftKey = true;
+						downArrowDomEvtDataStub.shiftKey = true;
+					} );
+
+					it( 'should expand selection to the beginning of the nested editable content', () => {
+						editor.editing.view.document.fire( 'keydown', upArrowDomEvtDataStub );
+
+						sinon.assert.calledOnce( upArrowDomEvtDataStub.preventDefault );
+						sinon.assert.calledOnce( upArrowDomEvtDataStub.stopPropagation );
+
+						assertEqualMarkup( getModelData( model ), '<widget><nested><paragraph>[foo]bar</paragraph></nested></widget>' );
+					} );
+
+					it( 'should expand selection to the end of the nested editable content', () => {
+						editor.editing.view.document.fire( 'keydown', downArrowDomEvtDataStub );
+
+						sinon.assert.calledOnce( downArrowDomEvtDataStub.preventDefault );
+						sinon.assert.calledOnce( downArrowDomEvtDataStub.stopPropagation );
+
+						assertEqualMarkup( getModelData( model ), '<widget><nested><paragraph>foo[bar]</paragraph></nested></widget>' );
+					} );
+				} );
+			} );
+
+			describe( 'with non-collapsed forward selection', () => {
+				beforeEach( () => {
+					setModelData( model, '<widget><nested><paragraph>fo[ob]ar</paragraph></nested></widget>' );
+				} );
+
+				it( 'should move caret to the beginning of the nested editable content', () => {
+					editor.editing.view.document.fire( 'keydown', upArrowDomEvtDataStub );
+
+					sinon.assert.calledOnce( upArrowDomEvtDataStub.preventDefault );
+					sinon.assert.calledOnce( upArrowDomEvtDataStub.stopPropagation );
+
+					assertEqualMarkup( getModelData( model ), '<widget><nested><paragraph>[]foobar</paragraph></nested></widget>' );
+				} );
+
+				it( 'should move caret to the end of the nested editable content', () => {
+					editor.editing.view.document.fire( 'keydown', downArrowDomEvtDataStub );
+
+					sinon.assert.calledOnce( downArrowDomEvtDataStub.preventDefault );
+					sinon.assert.calledOnce( downArrowDomEvtDataStub.stopPropagation );
+
+					assertEqualMarkup( getModelData( model ), '<widget><nested><paragraph>foobar[]</paragraph></nested></widget>' );
+				} );
+
+				describe( 'when shift key is pressed', () => {
+					beforeEach( () => {
+						upArrowDomEvtDataStub.shiftKey = true;
+						downArrowDomEvtDataStub.shiftKey = true;
+					} );
+
+					it( 'should not prevent default browser behavior on arrow up press', () => {
+						editor.editing.view.document.fire( 'keydown', upArrowDomEvtDataStub );
+
+						sinon.assert.notCalled( upArrowDomEvtDataStub.preventDefault );
+						sinon.assert.notCalled( upArrowDomEvtDataStub.stopPropagation );
+
+						assertEqualMarkup( getModelData( model ), '<widget><nested><paragraph>fo[ob]ar</paragraph></nested></widget>' );
+					} );
+
+					it( 'should expand selection to the end of the nested editable content', () => {
+						editor.editing.view.document.fire( 'keydown', downArrowDomEvtDataStub );
+
+						sinon.assert.calledOnce( downArrowDomEvtDataStub.preventDefault );
+						sinon.assert.calledOnce( downArrowDomEvtDataStub.stopPropagation );
+
+						assertEqualMarkup( getModelData( model ), '<widget><nested><paragraph>fo[obar]</paragraph></nested></widget>' );
+					} );
+				} );
+			} );
+
+			describe( 'with non-collapsed backward selection', () => {
+				beforeEach( () => {
+					setModelData( model, '<widget><nested><paragraph>fo[ob]ar</paragraph></nested></widget>', { lastRangeBackward: true } );
+				} );
+
+				it( 'should move caret to the beginning of the nested editable content', () => {
+					editor.editing.view.document.fire( 'keydown', upArrowDomEvtDataStub );
+
+					sinon.assert.calledOnce( upArrowDomEvtDataStub.preventDefault );
+					sinon.assert.calledOnce( upArrowDomEvtDataStub.stopPropagation );
+
+					assertEqualMarkup( getModelData( model ), '<widget><nested><paragraph>[]foobar</paragraph></nested></widget>' );
+				} );
+
+				it( 'should move caret to the end of the nested editable content', () => {
+					editor.editing.view.document.fire( 'keydown', downArrowDomEvtDataStub );
+
+					sinon.assert.calledOnce( downArrowDomEvtDataStub.preventDefault );
+					sinon.assert.calledOnce( downArrowDomEvtDataStub.stopPropagation );
+
+					assertEqualMarkup( getModelData( model ), '<widget><nested><paragraph>foobar[]</paragraph></nested></widget>' );
+				} );
+
+				describe( 'when shift key is pressed', () => {
+					beforeEach( () => {
+						upArrowDomEvtDataStub.shiftKey = true;
+						downArrowDomEvtDataStub.shiftKey = true;
+					} );
+
+					it( 'should expand selection to the beginning of the nested editable content', () => {
+						editor.editing.view.document.fire( 'keydown', upArrowDomEvtDataStub );
+
+						sinon.assert.calledOnce( upArrowDomEvtDataStub.preventDefault );
+						sinon.assert.calledOnce( upArrowDomEvtDataStub.stopPropagation );
+
+						assertEqualMarkup( getModelData( model ), '<widget><nested><paragraph>[foob]ar</paragraph></nested></widget>' );
+					} );
+
+					it( 'should not prevent default browser behavior on arrow down press', () => {
+						editor.editing.view.document.fire( 'keydown', downArrowDomEvtDataStub );
+
+						sinon.assert.notCalled( downArrowDomEvtDataStub.preventDefault );
+						sinon.assert.notCalled( downArrowDomEvtDataStub.stopPropagation );
+
+						assertEqualMarkup( getModelData( model ), '<widget><nested><paragraph>fo[ob]ar</paragraph></nested></widget>' );
+					} );
+				} );
+			} );
+		} );
+
+		describe( 'selection inside paragraph', () => {
+			const text = new Array( 20 ).fill( 0 ).map( () => 'word' ).join( ' ' );
+
+			it( 'should not prevent default browser behavior if caret is in the middle line of a text', () => {
+				setModelData( model, `<widget><nested><paragraph>${ text + '[] ' + text }</paragraph></nested></widget>` );
+
+				editor.editing.view.document.fire( 'keydown', upArrowDomEvtDataStub );
+
+				sinon.assert.notCalled( upArrowDomEvtDataStub.preventDefault );
+				sinon.assert.notCalled( upArrowDomEvtDataStub.stopPropagation );
+			} );
+
+			it( 'should move caret to beginning of nested editable content if caret is in the first line of a text', () => {
+				setModelData( model, `<widget><nested><paragraph>${ 'word[] word' + text }</paragraph></nested></widget>` );
+
+				editor.editing.view.document.fire( 'keydown', upArrowDomEvtDataStub );
+
+				sinon.assert.calledOnce( upArrowDomEvtDataStub.preventDefault );
+				sinon.assert.calledOnce( upArrowDomEvtDataStub.stopPropagation );
+
+				assertEqualMarkup( getModelData( model ),
+					`<widget><nested><paragraph>${ '[]word word' + text }</paragraph></nested></widget>`
+				);
+			} );
+
+			it( 'should move caret to end of nested editable content if caret is in the last line of a text', () => {
+				setModelData( model, `<widget><nested><paragraph>${ text + 'word[] word' }</paragraph></nested></widget>` );
+
+				editor.editing.view.document.fire( 'keydown', downArrowDomEvtDataStub );
+
+				sinon.assert.calledOnce( downArrowDomEvtDataStub.preventDefault );
+				sinon.assert.calledOnce( downArrowDomEvtDataStub.stopPropagation );
+
+				assertEqualMarkup( getModelData( model ),
+					`<widget><nested><paragraph>${ text + 'word word[]' }</paragraph></nested></widget>`
+				);
+			} );
+
+			describe( 'when shift key is pressed', () => {
+				beforeEach( () => {
+					upArrowDomEvtDataStub.shiftKey = true;
+					downArrowDomEvtDataStub.shiftKey = true;
+				} );
+
+				it( 'should not prevent default browser behavior for the up arrow in the middle lines of the text', () => {
+					setModelData( model, `<widget><nested><paragraph>${ text + '[] ' + text }</paragraph></nested></widget>` );
+
+					editor.editing.view.document.fire( 'keydown', upArrowDomEvtDataStub );
+
+					sinon.assert.notCalled( upArrowDomEvtDataStub.preventDefault );
+					sinon.assert.notCalled( upArrowDomEvtDataStub.stopPropagation );
+				} );
+
+				it( 'should not prevent default browser behavior for the down arrow in the middle lines of text', () => {
+					setModelData( model, `<widget><nested><paragraph>${ text + '[] ' + text }</paragraph></nested></widget>` );
+
+					editor.editing.view.document.fire( 'keydown', downArrowDomEvtDataStub );
+
+					sinon.assert.notCalled( downArrowDomEvtDataStub.preventDefault );
+					sinon.assert.notCalled( downArrowDomEvtDataStub.stopPropagation );
+				} );
+
+				it( 'should expand collapsed selection to the beginning of the nested editable content', () => {
+					setModelData( model, `<widget><nested><paragraph>${ 'word[] word' + text }</paragraph></nested></widget>` );
+
+					editor.editing.view.document.fire( 'keydown', upArrowDomEvtDataStub );
+
+					sinon.assert.calledOnce( upArrowDomEvtDataStub.preventDefault );
+					sinon.assert.calledOnce( upArrowDomEvtDataStub.stopPropagation );
+
+					assertEqualMarkup( getModelData( model ),
+						`<widget><nested><paragraph>${ '[word] word' + text }</paragraph></nested></widget>`
+					);
+				} );
+
+				it( 'should not prevent default browser behavior for shrinking selection (up arrow)', () => {
+					setModelData( model, `<widget><nested><paragraph>${ 'word [word]' + text }</paragraph></nested></widget>` );
+
+					editor.editing.view.document.fire( 'keydown', upArrowDomEvtDataStub );
+
+					sinon.assert.notCalled( upArrowDomEvtDataStub.preventDefault );
+					sinon.assert.notCalled( upArrowDomEvtDataStub.stopPropagation );
+				} );
+
+				it( 'should expand not collapsed selection to the beginning of the editable content from the selection anchor', () => {
+					setModelData( model,
+						`<widget><nested><paragraph>${ 'word [word]' + text }</paragraph></nested></widget>`,
+						{ lastRangeBackward: true }
+					);
+
+					editor.editing.view.document.fire( 'keydown', upArrowDomEvtDataStub );
+
+					sinon.assert.calledOnce( upArrowDomEvtDataStub.preventDefault );
+					sinon.assert.calledOnce( upArrowDomEvtDataStub.stopPropagation );
+
+					assertEqualMarkup( getModelData( model ),
+						`<widget><nested><paragraph>${ '[word word]' + text }</paragraph></nested></widget>`
+					);
+				} );
+
+				it( 'should expand collapsed selection to the end of the nested editable content', () => {
+					setModelData( model, `<widget><nested><paragraph>${ text + 'word[] word' }</paragraph></nested></widget>` );
+
+					editor.editing.view.document.fire( 'keydown', downArrowDomEvtDataStub );
+
+					sinon.assert.calledOnce( downArrowDomEvtDataStub.preventDefault );
+					sinon.assert.calledOnce( downArrowDomEvtDataStub.stopPropagation );
+
+					assertEqualMarkup( getModelData( model ),
+						`<widget><nested><paragraph>${ text + 'word[ word]' }</paragraph></nested></widget>`
+					);
+				} );
+
+				it( 'should not prevent default browser behavior for shrinking selection (down arrow)', () => {
+					setModelData( model,
+						`<widget><nested><paragraph>${ text + '[word] word' }</paragraph></nested></widget>`,
+						{ lastRangeBackward: true }
+					);
+
+					editor.editing.view.document.fire( 'keydown', downArrowDomEvtDataStub );
+
+					sinon.assert.notCalled( downArrowDomEvtDataStub.preventDefault );
+					sinon.assert.notCalled( downArrowDomEvtDataStub.stopPropagation );
+				} );
+
+				it( 'should expand not collapsed selection to the end of the nested editable content from the selection anchor', () => {
+					setModelData( model, `<widget><nested><paragraph>${ text + '[word] word' }</paragraph></nested></widget>` );
+
+					editor.editing.view.document.fire( 'keydown', downArrowDomEvtDataStub );
+
+					sinon.assert.calledOnce( downArrowDomEvtDataStub.preventDefault );
+					sinon.assert.calledOnce( downArrowDomEvtDataStub.stopPropagation );
+
+					assertEqualMarkup( getModelData( model ),
+						`<widget><nested><paragraph>${ text + '[word word]' }</paragraph></nested></widget>`
+					);
+				} );
+			} );
+		} );
+
+		if ( !env.isGecko ) {
+			// These tests fails on Travis. They work correctly when started on local machine.
+			// Issue is probably related to text rendering and wrapping.
+
+			describe( 'with selection in the wrap area', () => {
+				const text = new Array( 10 ).fill( 0 ).map( () => 'word' ).join( ' ' );
+
+				it( 'should move the caret to end if the caret is after the last space in the line next to the last one', () => {
+					// This is also first position in the last line.
+					setModelData( model, `<widget><nested><paragraph>${ text + ' []word word word' }</paragraph></nested></widget>` );
+
+					editor.editing.view.document.fire( 'keydown', downArrowDomEvtDataStub );
+
+					sinon.assert.calledOnce( downArrowDomEvtDataStub.preventDefault );
+					sinon.assert.calledOnce( downArrowDomEvtDataStub.stopPropagation );
+
+					assertEqualMarkup( getModelData( model ),
+						`<widget><nested><paragraph>${ text + ' word word word[]' }</paragraph></nested></widget>`
+					);
+				} );
+
+				it( 'should move the caret to end if the caret is at the last space in the line next to last one', () => {
+					setModelData( model, `<widget><nested><paragraph>${ text + '[] word word word' }</paragraph></nested></widget>` );
+
+					editor.editing.view.document.fire( 'keydown', downArrowDomEvtDataStub );
+
+					sinon.assert.calledOnce( downArrowDomEvtDataStub.preventDefault );
+					sinon.assert.calledOnce( downArrowDomEvtDataStub.stopPropagation );
+
+					assertEqualMarkup( getModelData( model ),
+						`<widget><nested><paragraph>${ text + ' word word word[]' }</paragraph></nested></widget>`
+					);
+				} );
+
+				it( 'should not move the caret if it\'s just before the last space in the line next to last one', () => {
+					setModelData( model,
+						'<widget><nested><paragraph>' +
+							text.substring( 0, text.length - 1 ) + '[]d word word word' +
+						'</paragraph></nested></widget>'
+					);
+
+					editor.editing.view.document.fire( 'keydown', downArrowDomEvtDataStub );
+
+					sinon.assert.notCalled( downArrowDomEvtDataStub.preventDefault );
+					sinon.assert.notCalled( downArrowDomEvtDataStub.stopPropagation );
+				} );
+
+				describe( 'when shift key is pressed', () => {
+					beforeEach( () => {
+						upArrowDomEvtDataStub.shiftKey = true;
+						downArrowDomEvtDataStub.shiftKey = true;
+					} );
+
+					it( 'should expand collapsed selection to the end of the nested editable content', () => {
+						setModelData( model, `<widget><nested><paragraph>${ text + '[] word word word' }</paragraph></nested></widget>` );
+
+						editor.editing.view.document.fire( 'keydown', downArrowDomEvtDataStub );
+
+						sinon.assert.calledOnce( downArrowDomEvtDataStub.preventDefault );
+						sinon.assert.calledOnce( downArrowDomEvtDataStub.stopPropagation );
+
+						assertEqualMarkup( getModelData( model ),
+							`<widget><nested><paragraph>${ text + '[ word word word]' }</paragraph></nested></widget>`
+						);
+					} );
+
+					it( 'should expand not collapsed selection to the end of the nested editable content from the selection anchor', () => {
+						setModelData( model, `<widget><nested><paragraph>${ text + '[ word] word word' }</paragraph></nested></widget>` );
+
+						editor.editing.view.document.fire( 'keydown', downArrowDomEvtDataStub );
+
+						sinon.assert.calledOnce( downArrowDomEvtDataStub.preventDefault );
+						sinon.assert.calledOnce( downArrowDomEvtDataStub.stopPropagation );
+
+						assertEqualMarkup( getModelData( model ),
+							`<widget><nested><paragraph>${ text + '[ word word word]' }</paragraph></nested></widget>`
+						);
+					} );
+				} );
+			} );
+		}
+
+		describe( 'with multiple paragraphs of text', () => {
+			const text = new Array( 100 ).fill( 0 ).map( () => 'word' ).join( ' ' );
+
+			it( 'should not prevent default browser behavior if caret is in the middle of a line of text', () => {
+				setModelData( model,
+					'<widget><nested>' +
+						`<paragraph>${ text }[]${ text }</paragraph>` +
+						'<paragraph>foobar</paragraph>' +
+					'</nested></widget>'
+				);
+
+				editor.editing.view.document.fire( 'keydown', upArrowDomEvtDataStub );
+
+				sinon.assert.notCalled( upArrowDomEvtDataStub.preventDefault );
+				sinon.assert.notCalled( upArrowDomEvtDataStub.stopPropagation );
+			} );
+
+			it( 'should move the caret to the beginning of a nested editable content if the caret is in the first line of text', () => {
+				setModelData( model,
+					'<widget><nested>' +
+						`<paragraph>word[]${ text }</paragraph>` +
+						'<paragraph>foobar</paragraph>' +
+					'</nested></widget>'
+				);
+
+				editor.editing.view.document.fire( 'keydown', upArrowDomEvtDataStub );
+
+				sinon.assert.calledOnce( upArrowDomEvtDataStub.preventDefault );
+				sinon.assert.calledOnce( upArrowDomEvtDataStub.stopPropagation );
+
+				assertEqualMarkup( getModelData( model ),
+					'<widget><nested>' +
+						`<paragraph>[]word${ text }</paragraph>` +
+						'<paragraph>foobar</paragraph>' +
+					'</nested></widget>'
+				);
+			} );
+
+			it( 'should not move the caret to the end of a nested editable content if the caret is not in the last line of text', () => {
+				setModelData( model,
+					'<widget><nested>' +
+						`<paragraph>${ text }word []word</paragraph>` +
+						'<paragraph>foobar</paragraph>' +
+					'</nested></widget>'
+				);
+
+				editor.editing.view.document.fire( 'keydown', downArrowDomEvtDataStub );
+
+				sinon.assert.notCalled( downArrowDomEvtDataStub.preventDefault );
+				sinon.assert.notCalled( downArrowDomEvtDataStub.stopPropagation );
+			} );
+
+			it( 'should move the caret to end of a nested editable content if the caret is in the last line of text', () => {
+				setModelData( model,
+					'<widget><nested>' +
+						'<paragraph>foobar</paragraph>' +
+						`<paragraph>${ text }word []word</paragraph>` +
+					'</nested></widget>'
+				);
+
+				editor.editing.view.document.fire( 'keydown', downArrowDomEvtDataStub );
+
+				sinon.assert.calledOnce( downArrowDomEvtDataStub.preventDefault );
+				sinon.assert.calledOnce( downArrowDomEvtDataStub.stopPropagation );
+
+				assertEqualMarkup( getModelData( model ),
+					'<widget><nested>' +
+						'<paragraph>foobar</paragraph>' +
+						`<paragraph>${ text }word word[]</paragraph>` +
+					'</nested></widget>'
+				);
+			} );
+
+			describe( 'when shift key is pressed', () => {
+				beforeEach( () => {
+					upArrowDomEvtDataStub.shiftKey = true;
+					downArrowDomEvtDataStub.shiftKey = true;
+				} );
+
+				it( 'should expand selection to the beginning of the nested editable content', () => {
+					setModelData( model,
+						'<widget><nested>' +
+							`<paragraph>word[] ${ text }</paragraph>` +
+							`<paragraph>${ text }</paragraph>` +
+						'</nested></widget>'
+					);
+
+					editor.editing.view.document.fire( 'keydown', upArrowDomEvtDataStub );
+
+					sinon.assert.calledOnce( upArrowDomEvtDataStub.preventDefault );
+					sinon.assert.calledOnce( upArrowDomEvtDataStub.stopPropagation );
+
+					assertEqualMarkup( getModelData( model ),
+						'<widget><nested>' +
+							`<paragraph>[word] ${ text }</paragraph>` +
+							`<paragraph>${ text }</paragraph>` +
+						'</nested></widget>'
+					);
+				} );
+
+				it( 'should expand selection to the end of the nested editable content', () => {
+					setModelData( model,
+						'<widget><nested>' +
+							`<paragraph>${ text }</paragraph>` +
+							`<paragraph>${ text } []word</paragraph>` +
+						'</nested></widget>'
+					);
+
+					editor.editing.view.document.fire( 'keydown', downArrowDomEvtDataStub );
+
+					sinon.assert.calledOnce( downArrowDomEvtDataStub.preventDefault );
+					sinon.assert.calledOnce( downArrowDomEvtDataStub.stopPropagation );
+
+					assertEqualMarkup( getModelData( model ),
+						'<widget><nested>' +
+							`<paragraph>${ text }</paragraph>` +
+							`<paragraph>${ text } [word]</paragraph>` +
+						'</nested></widget>'
+					);
+				} );
+			} );
+		} );
+
+		describe( 'with horizontal line widget', () => {
+			const text = new Array( 100 ).fill( 0 ).map( () => 'word' ).join( ' ' );
+
+			it( 'should not navigate if the caret is in the middle line of text', () => {
+				setModelData( model,
+					'<widget><nested>' +
+						'<horizontalLine></horizontalLine>' +
+						`<paragraph>${ text }[]${ text }</paragraph>` +
+					'</nested></widget>'
+				);
+
+				editor.editing.view.document.fire( 'keydown', upArrowDomEvtDataStub );
+
+				sinon.assert.notCalled( upArrowDomEvtDataStub.preventDefault );
+				sinon.assert.notCalled( upArrowDomEvtDataStub.stopPropagation );
+			} );
+
+			it( 'should move the caret to the beginning of the editable non-object content if it\'s is in the first line of text', () => {
+				setModelData( model,
+					'<widget><nested>' +
+						'<horizontalLine></horizontalLine>' +
+						`<paragraph>word[] ${ text }</paragraph>` +
+					'</nested></widget>'
+				);
+
+				editor.editing.view.document.fire( 'keydown', upArrowDomEvtDataStub );
+
+				sinon.assert.calledOnce( upArrowDomEvtDataStub.preventDefault );
+				sinon.assert.calledOnce( upArrowDomEvtDataStub.stopPropagation );
+
+				assertEqualMarkup( getModelData( model ),
+					'<widget><nested>' +
+						'<horizontalLine></horizontalLine>' +
+						`<paragraph>[]word ${ text }</paragraph>` +
+					'</nested></widget>'
+				);
+			} );
+
+			it( 'should move the caret to the end of the editable non-object content if the caret is in the last line of text', () => {
+				setModelData( model,
+					'<widget><nested>' +
+						`<paragraph>${ text } word []word</paragraph>` +
+						'<horizontalLine></horizontalLine>' +
+					'</nested></widget>'
+				);
+
+				editor.editing.view.document.fire( 'keydown', downArrowDomEvtDataStub );
+
+				sinon.assert.calledOnce( downArrowDomEvtDataStub.preventDefault );
+				sinon.assert.calledOnce( downArrowDomEvtDataStub.stopPropagation );
+
+				assertEqualMarkup( getModelData( model ),
+					'<widget><nested>' +
+						`<paragraph>${ text } word word[]</paragraph>` +
+						'<horizontalLine></horizontalLine>' +
+					'</nested></widget>'
+				);
+			} );
+
+			it( 'should not move the caret to the end of nested editable content if widget is selected in middle of that content', () => {
+				setModelData( model,
+					'<widget><nested>' +
+						`<paragraph>${ text }</paragraph>` +
+						'[<horizontalLine></horizontalLine>]' +
+						`<paragraph>${ text }</paragraph>` +
+						'<horizontalLine></horizontalLine>' +
+					'</nested></widget>'
+				);
+
+				// Note: Two keydowns are necessary because the first one is handled by the WidgetTypeAround plugin
+				// to activate the "fake caret".
+				editor.editing.view.document.fire( 'keydown', downArrowDomEvtDataStub );
+				editor.editing.view.document.fire( 'keydown', downArrowDomEvtDataStub );
+
+				assertEqualMarkup( getModelData( model ),
+					'<widget><nested>' +
+						`<paragraph>${ text }</paragraph>` +
+						'<horizontalLine></horizontalLine>' +
+						`<paragraph>[]${ text }</paragraph>` +
+						'<horizontalLine></horizontalLine>' +
+					'</nested></widget>'
+				);
+			} );
+
+			it( 'should not move the caret to the end of nested editable content if widget is next to the selection', () => {
+				setModelData( model,
+					'<widget><nested>' +
+						`<paragraph>${ text }</paragraph>` +
+						'[]<horizontalLine></horizontalLine>' +
+					'</nested></widget>'
+				);
+
+				editor.editing.view.document.fire( 'keydown', downArrowDomEvtDataStub );
+
+				assertEqualMarkup( getModelData( model ),
+					'<widget><nested>' +
+						`<paragraph>${ text }</paragraph>` +
+						'[<horizontalLine></horizontalLine>]' +
+					'</nested></widget>'
+				);
+			} );
+
+			describe( 'when shift key is pressed', () => {
+				beforeEach( () => {
+					upArrowDomEvtDataStub.shiftKey = true;
+					downArrowDomEvtDataStub.shiftKey = true;
+				} );
+
+				it( 'should expand selection to the beginning of the nested editable content', () => {
+					setModelData( model,
+						'<widget><nested>' +
+							'<horizontalLine></horizontalLine>' +
+							'<paragraph>foo[]bar</paragraph>' +
+						'</nested></widget>'
+					);
+
+					editor.editing.view.document.fire( 'keydown', upArrowDomEvtDataStub );
+
+					sinon.assert.calledOnce( upArrowDomEvtDataStub.preventDefault );
+					sinon.assert.calledOnce( upArrowDomEvtDataStub.stopPropagation );
+
+					assertEqualMarkup( getModelData( model ),
+						'<widget><nested>' +
+							'<horizontalLine></horizontalLine>' +
+							'<paragraph>[foo]bar</paragraph>' +
+						'</nested></widget>'
+					);
+				} );
+
+				it( 'should expand selection to the end of the nested editable content', () => {
+					setModelData( model,
+						'<widget><nested>' +
+							'<paragraph>foo[]bar</paragraph>' +
+							'<horizontalLine></horizontalLine>' +
+						'</nested></widget>'
+					);
+
+					editor.editing.view.document.fire( 'keydown', downArrowDomEvtDataStub );
+
+					sinon.assert.calledOnce( downArrowDomEvtDataStub.preventDefault );
+					sinon.assert.calledOnce( downArrowDomEvtDataStub.stopPropagation );
+
+					assertEqualMarkup( getModelData( model ),
+						'<widget><nested>' +
+							'<paragraph>foo[bar]</paragraph>' +
+							'<horizontalLine></horizontalLine>' +
+						'</nested></widget>'
+					);
+				} );
+			} );
+		} );
+
+		describe( 'contains image widget with caption and selection inside the caption', () => {
+			it( 'should move caret to the closest limit boundary', () => {
+				setModelData( model,
+					'<widget><nested>' +
+						'<paragraph>foo</paragraph>' +
+						`<image src="${ imageUrl }"><caption>bar[]baz</caption></image>` +
+					'</nested></widget>'
+				);
+
+				editor.editing.view.document.fire( 'keydown', upArrowDomEvtDataStub );
+
+				sinon.assert.calledOnce( upArrowDomEvtDataStub.preventDefault );
+				sinon.assert.calledOnce( upArrowDomEvtDataStub.stopPropagation );
+
+				assertEqualMarkup( getModelData( model ), '<widget><nested>' +
+					'<paragraph>foo</paragraph>' +
+					`<image src="${ imageUrl }"><caption>[]barbaz</caption></image>` +
+					'</nested></widget>'
+				);
+			} );
+
+			it( 'should not prevent default browser behavior when caret at the beginning of nested editable', () => {
+				setModelData( model,
+					'<widget><nested>' +
+						'<paragraph>foo</paragraph>' +
+						`<image src="${ imageUrl }"><caption>[]barbaz</caption></image>` +
+					'</nested></widget>'
+				);
+
+				editor.editing.view.document.fire( 'keydown', upArrowDomEvtDataStub );
+
+				sinon.assert.notCalled( upArrowDomEvtDataStub.preventDefault );
+				sinon.assert.notCalled( upArrowDomEvtDataStub.stopPropagation );
+			} );
+
+			it( 'should move the caret to the first position of the image caption', () => {
+				setModelData( model,
+					'<widget><nested>' +
+						`<image src="${ imageUrl }"><caption>bar[]baz</caption></image>` +
+						'<paragraph>foo</paragraph>' +
+					'</nested></widget>'
+				);
+
+				editor.editing.view.document.fire( 'keydown', upArrowDomEvtDataStub );
+
+				sinon.assert.calledOnce( upArrowDomEvtDataStub.preventDefault );
+				sinon.assert.calledOnce( upArrowDomEvtDataStub.stopPropagation );
+
+				assertEqualMarkup( getModelData( model ),
+					'<widget><nested>' +
+						`<image src="${ imageUrl }"><caption>[]barbaz</caption></image>` +
+						'<paragraph>foo</paragraph>' +
+					'</nested></widget>'
+				);
+			} );
+
+			it( 'should move caret to the end of image caption', () => {
+				setModelData( model,
+					'<widget><nested>' +
+						`<image src="${ imageUrl }"><caption>bar[]baz</caption></image>` +
+						'<paragraph>foo</paragraph>' +
+					'</nested></widget>'
+				);
+
+				editor.editing.view.document.fire( 'keydown', downArrowDomEvtDataStub );
+
+				sinon.assert.calledOnce( downArrowDomEvtDataStub.preventDefault );
+				sinon.assert.calledOnce( downArrowDomEvtDataStub.stopPropagation );
+
+				assertEqualMarkup( getModelData( model ),
+					'<widget><nested>' +
+					`<image src="${ imageUrl }"><caption>barbaz[]</caption></image>` +
+					'<paragraph>foo</paragraph>' +
+					'</nested></widget>'
+				);
+			} );
+
+			it( 'should move caret to the end of image caption when caret is on the position next to the last one', () => {
+				setModelData( model,
+					'<widget><nested>' +
+						`<image src="${ imageUrl }"><caption>barba[]z</caption></image>` +
+						'<paragraph>foo</paragraph>' +
+					'</nested></widget>'
+				);
+
+				editor.editing.view.document.fire( 'keydown', downArrowDomEvtDataStub );
+
+				sinon.assert.calledOnce( downArrowDomEvtDataStub.preventDefault );
+				sinon.assert.calledOnce( downArrowDomEvtDataStub.stopPropagation );
+
+				assertEqualMarkup( getModelData( model ),
+					'<widget><nested>' +
+					`<image src="${ imageUrl }"><caption>barbaz[]</caption></image>` +
+					'<paragraph>foo</paragraph>' +
+					'</nested></widget>'
+				);
+			} );
+
+			it( 'should not prevent default browser behavior when caret inside image caption when followed by a paragraph', () => {
+				setModelData( model,
+					'<widget><nested>' +
+						`<image src="${ imageUrl }"><caption>barbaz[]</caption></image>` +
+						'<paragraph>foo</paragraph>' +
+					'</nested></widget>'
+				);
+
+				editor.editing.view.document.fire( 'keydown', downArrowDomEvtDataStub );
+
+				sinon.assert.notCalled( downArrowDomEvtDataStub.preventDefault );
+				sinon.assert.notCalled( downArrowDomEvtDataStub.stopPropagation );
+			} );
+		} );
+	} );
+
+	function BlockWidgetWithNestedEditable( editor ) {
+		const model = editor.model;
+
+		model.schema.register( 'widget', {
+			inheritAllFrom: '$block',
+			isObject: true
+		} );
+
+		model.schema.register( 'nested', {
+			allowIn: 'widget',
+			isLimit: true
+		} );
+
+		model.schema.extend( '$block', {
+			allowIn: 'nested'
+		} );
+
+		editor.conversion.for( 'dataDowncast' )
+			.elementToElement( {
+				model: 'widget',
+				view: ( modelItem, writer ) => {
+					return writer.createContainerElement( 'figure' );
+				}
+			} )
+			.elementToElement( {
+				model: 'nested',
+				view: ( modelItem, writer ) => {
+					return writer.createContainerElement( 'figcaption' );
+				}
+			} );
+
+		editor.conversion.for( 'editingDowncast' )
+			.elementToElement( {
+				model: 'widget',
+				view: ( modelItem, writer ) => {
+					const div = writer.createContainerElement( 'figure' );
+
+					return toWidget( div, writer, { label: 'widget label' } );
+				}
+			} )
+			.elementToElement( {
+				model: 'nested',
+				view: ( modelItem, writer ) => {
+					const nested = writer.createEditableElement( 'figcaption' );
+
+					return toWidgetEditable( nested, writer );
+				}
+			} );
+
+		editor.conversion.for( 'upcast' )
+			.elementToElement( {
+				view: 'figure',
+				model: 'widget'
+			} )
+			.elementToElement( {
+				view: 'figcaption',
+				model: 'nested'
+			} );
+	}
+} );

+ 1 - 1
packages/ckeditor5-widget/tests/widget.js

@@ -827,7 +827,7 @@ describe( 'Widget', () => {
 				for ( const action of actions ) {
 					viewDocument.fire( 'keydown', new DomEventData(
 						viewDocument,
-						{ target: document.createElement( 'div' ), preventDefault() {} },
+						{ target: document.createElement( 'div' ), preventDefault() {}, stopPropagation() {} },
 						action
 					) );
 				}

Разница между файлами не показана из-за своего большого размера
+ 301 - 257
yarn.lock


Некоторые файлы не были показаны из-за большого количества измененных файлов