Kaynağa Gözat

Merge pull request #7708 from ckeditor/i/7630

Feature (widget): Keyboard vertical navigation in the text lines next to objects should move the caret to the position closest to the object. Closes #7630.

Internal (table): Keyboard vertical navigation inside a table cell extracted to the widget plugin.
Maciej 5 yıl önce
ebeveyn
işleme
7984a14a41

+ 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.
 	 *

+ 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' ],

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