瀏覽代碼

Merge branch 'master' into t/841

Piotrek Koszuliński 8 年之前
父節點
當前提交
2dcda525ea

+ 36 - 19
packages/ckeditor5-engine/src/controller/deletecontent.js

@@ -21,6 +21,8 @@ import compareArrays from '@ckeditor/ckeditor5-utils/src/comparearrays';
  * @param {Boolean} [options.merge=false] Merge elements after removing the contents of the selection.
  * For example, `<h>x[x</h><p>y]y</p>` will become: `<h>x^y</h>` with the option enabled
  * and: `<h>x^</h><p>y</p>` without it.
+ * Note: {@link module:engine/model/schema~Schema#objects object} and {@link module:engine/model/schema~Schema#limits limit}
+ * elements will not be merged.
  */
 export default function deleteContent( selection, batch, options = {} ) {
 	if ( selection.isCollapsed ) {
@@ -46,25 +48,7 @@ export default function deleteContent( selection, batch, options = {} ) {
 	// as it's hard to imagine what should actually be the default behavior. Usually, specific features will
 	// want to override that behavior anyway.
 	if ( options.merge ) {
-		const endPath = endPos.path;
-		const mergeEnd = Math.min( startPos.path.length - 1, endPath.length - 1 );
-		let mergeDepth = compareArrays( startPos.path, endPath );
-
-		if ( typeof mergeDepth == 'number' ) {
-			for ( ; mergeDepth < mergeEnd; mergeDepth++ ) {
-				const mergePath = startPos.path.slice( 0, mergeDepth );
-				mergePath.push( startPos.path[ mergeDepth ] + 1 );
-
-				const mergePos = new Position( endPos.root, mergePath );
-				const nextNode = mergePos.nodeAfter;
-
-				if ( nextNode.childCount > 0 ) {
-					batch.merge( mergePos );
-				} else {
-					batch.remove( nextNode );
-				}
-			}
-		}
+		mergeBranches( batch, startPos, endPos );
 	}
 
 	selection.collapse( startPos );
@@ -81,9 +65,42 @@ export default function deleteContent( selection, batch, options = {} ) {
 	endPos.detach();
 }
 
+function mergeBranches( batch, startPos, endPos ) {
+	const endPath = endPos.path;
+	const mergeEnd = Math.min( startPos.path.length - 1, endPath.length - 1 );
+	let mergeDepth = compareArrays( startPos.path, endPath );
+
+	if ( typeof mergeDepth == 'number' ) {
+		for ( ; mergeDepth < mergeEnd; mergeDepth++ ) {
+			const mergePath = startPos.path.slice( 0, mergeDepth );
+			mergePath.push( startPos.path[ mergeDepth ] + 1 );
+
+			const mergePos = new Position( endPos.root, mergePath );
+			const previousNode = mergePos.nodeBefore;
+			const nextNode = mergePos.nodeAfter;
+
+			if ( !checkCanBeMerged( previousNode ) || !checkCanBeMerged( nextNode ) ) {
+				return;
+			}
+
+			if ( nextNode.childCount > 0 ) {
+				batch.merge( mergePos );
+			} else {
+				batch.remove( nextNode );
+			}
+		}
+	}
+}
+
 function shouldAutoparagraph( doc, position ) {
 	const isTextAllowed = doc.schema.check( { name: '$text', inside: position } );
 	const isParagraphAllowed = doc.schema.check( { name: 'paragraph', inside: position } );
 
 	return !isTextAllowed && isParagraphAllowed;
 }
+
+function checkCanBeMerged( element ) {
+	const schema = element.document.schema;
+
+	return !schema.objects.has( element.name ) && !schema.limits.has( element.name );
+}

+ 7 - 0
packages/ckeditor5-engine/src/controller/insertcontent.js

@@ -330,6 +330,8 @@ class Insertion {
 
 	/**
 	 * @param {module:engine/model/node~Node} node
+	 * @returns {Boolean} Whether an allowed position was found.
+	 * `false` is returned if the node isn't allowed at any position up in the tree, `true` if was.
 	 */
 	_checkAndSplitToAllowedPosition( node ) {
 		const allowedIn = this._getAllowedIn( node, this.position.parent );
@@ -339,6 +341,11 @@ class Insertion {
 		}
 
 		while ( allowedIn != this.position.parent ) {
+			// If a parent which we'd need to leave is a limit element, break.
+			if ( this.schema.limits.has( this.position.parent.name ) ) {
+				return false;
+			}
+
 			if ( this.position.isAtStart ) {
 				const parent = this.position.parent;
 				this.position = Position.createBefore( parent );

+ 41 - 35
packages/ckeditor5-engine/src/controller/modifyselection.js

@@ -43,7 +43,7 @@ import { isInsideSurrogatePair, isInsideCombinedSymbol } from '@ckeditor/ckedito
 export default function modifySelection( dataController, selection, options = {} ) {
 	const schema = dataController.model.schema;
 	const isForward = options.direction != 'backward';
-	options.unit = options.unit ? options.unit : 'character';
+	const unit = options.unit ? options.unit : 'character';
 
 	const focus = selection.focus;
 	const walker = new TreeWalker( {
@@ -52,53 +52,59 @@ export default function modifySelection( dataController, selection, options = {}
 		direction: isForward ? 'forward' : 'backward'
 	} );
 
-	let next = walker.next();
+	const data = { walker, schema, isForward, unit };
 
-	// 1. Nothing to do here.
-	if ( next.done ) {
-		return;
-	}
-
-	let value = next.value;
+	let next;
 
-	// 2. Focus is before/after text. Extending by text data.
-	if ( value.type == 'text' ) {
-		selection.setFocus( getCorrectPosition( walker, options.unit ) );
+	while ( ( next = walker.next() ) ) {
+		if ( next.done ) {
+			return;
+		}
 
-		return;
-	}
+		const position = tryExtendingTo( data, next.value );
 
-	// 3. Focus is before/after element. Extend by whole element.
-	if ( value.type == ( isForward ? 'elementStart' : 'elementEnd' ) ) {
-		selection.setFocus( value.item, isForward ? 'after' : 'before' );
+		if ( position ) {
+			selection.setFocus( position );
 
-		return;
+			return;
+		}
 	}
+}
 
-	// 4. If previous scenarios are false, it means that focus is at the beginning/at the end of element and by
-	// extending we are "leaving" the element. Let's see what is further.
-	next = walker.next();
-
-	// 4.1. Nothing left, so let's stay where we were.
-	if ( next.done ) {
-		return;
+// Checks whether the selection can be extended to the the walker's next value (next position).
+function tryExtendingTo( data, value ) {
+	// If found text, we can certainly put the focus in it. Let's just find a correct position
+	// based on the unit.
+	if ( value.type == 'text' ) {
+		return getCorrectPosition( data.walker, data.unit );
 	}
 
-	value = next.value;
+	// Entering an element.
+	if ( value.type == ( data.isForward ? 'elementStart' : 'elementEnd' ) ) {
+		// If it's an object, we can select it now.
+		if ( data.schema.objects.has( value.item.name ) ) {
+			return Position.createAt( value.item, data.isForward ? 'after' : 'before' );
+		}
 
-	// 4.2. Text data found after leaving an element end. Put selection before it. This way extension will include
-	// "opening" element tag.
-	if ( value.type == 'text' ) {
-		selection.setFocus( value.previousPosition );
+		// If text allowed on this position, extend to this place.
+		if ( data.schema.check( { name: '$text', inside: value.nextPosition } ) ) {
+			return value.nextPosition;
+		}
 	}
-	// 4.3. An element found after leaving previous element.
-	// When element is an object - put focus before or after that element, otherwise put it inside that element,
-	// at it's beginning or end.
+	// Leaving an element.
 	else {
-		const isObject = schema.objects.has( value.item.name );
-		const offset = isObject ? ( isForward ? 'after' : 'before' ) : ( isForward ? 0 : 'end' );
+		// If leaving a limit element, stop.
+		if ( data.schema.limits.has( value.item.name ) ) {
+			// NOTE: Fast-forward the walker until the end.
+			data.walker.skip( () => true );
 
-		selection.setFocus( value.item, offset );
+			return;
+		}
+
+		// If text allowed on this position, extend to this place.
+		if ( data.schema.check( { name: '$text', inside: value.nextPosition } ) ) {
+			return value.nextPosition;
+		}
 	}
 }
 

+ 6 - 5
packages/ckeditor5-engine/src/conversion/buildmodelconverter.js

@@ -153,7 +153,7 @@ class ModelConverterBuilder {
 
 	/**
 	 * Registers what type of non-collapsed marker should be converted. For collapsed markers conversion, see
-	 * {@link ~fromCollapsedMarker}.
+	 * {@link #fromCollapsedMarker}.
 	 *
 	 * @chainable
 	 * @param {String} markerName Name of marker to convert.
@@ -171,7 +171,8 @@ class ModelConverterBuilder {
 	}
 
 	/**
-	 * Registers what type of collapsed marker should be converted. For non-collapsed markers conversion, see {@link ~fromMarker}.
+	 * Registers what type of collapsed marker should be converted. For non-collapsed markers conversion,
+	 * see {@link #fromMarker}.
 	 *
 	 * @chainable
 	 * @param {String} markerName Name of marker to convert.
@@ -233,8 +234,8 @@ class ModelConverterBuilder {
 	 * Creator function will be passed different values depending whether conversion is from element or from attribute:
 	 *
 	 * * from element: dispatcher's
-	 * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher#event:insert insert event} parameters
-	 * will be passed,
+	 * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher#event:insert insert event}
+	 * parameters will be passed,
 	 * * from attribute: there is one parameter and it is attribute value.
 	 *
 	 * This method also registers model selection to view selection converter, if conversion is from attribute.
@@ -242,7 +243,7 @@ class ModelConverterBuilder {
 	 * This method creates the converter and adds it as a callback to a proper
 	 * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher conversion dispatcher} event.
 	 *
-	 * @param {String|module:engine/view/element~ViewElement|Function} element Element created by converter or
+	 * @param {String|module:engine/view/element~Element|Function} element Element created by converter or
 	 * a function that returns view element.
 	 */
 	toElement( element ) {

+ 1 - 1
packages/ckeditor5-engine/src/conversion/model-selection-to-view-converters.js

@@ -164,7 +164,7 @@ export function convertSelectionAttribute( elementCreator ) {
  * **Note:** You can use the same `elementCreator` function for this converter factory
  * and {@link module:engine/conversion/model-to-view-converters~wrapRange}.
  *
- * @see {~convertSelectionAttribute}
+ * @see module:engine/conversion/model-selection-to-view-converters~convertSelectionAttribute
  * @param {module:engine/view/attributeelement~AttributeElement|Function} elementCreator View element,
  * or function returning a view element, which will be used for wrapping.
  * @returns {Function} Selection converter.

+ 1 - 1
packages/ckeditor5-engine/src/model/delta/markerdelta.js

@@ -95,7 +95,7 @@ register( 'setMarker', function( markerOrName, newRange ) {
  *
  * @chainable
  * @method module:engine/model/batch~Batch#removeMarker
- * @param {module:engine/model/markerscollection~Marker|String} markerOrName Marker or marker name to remove.
+ * @param {module:engine/model/markercollection~Marker|String} markerOrName Marker or marker name to remove.
  */
 register( 'removeMarker', function( markerOrName ) {
 	const name = typeof markerOrName == 'string' ? markerOrName : markerOrName.name;

+ 3 - 3
packages/ckeditor5-engine/src/model/document.js

@@ -30,7 +30,7 @@ const graveyardName = '$graveyard';
 
 /**
  * Document tree model describes all editable data in the editor. It may contain multiple
- * {@link module:engine/model/document~Document#_roots root elements}, for example if the editor have multiple editable areas,
+ * {@link module:engine/model/document~Document#roots root elements}, for example if the editor have multiple editable areas,
  * each area will be represented by the separate root.
  *
  * All changes in the document are done by {@link module:engine/model/operation/operation~Operation operations}. To create operations in
@@ -43,8 +43,8 @@ const graveyardName = '$graveyard';
  */
 export default class Document {
 	/**
-	 * Creates an empty document instance with no {@link #_roots} (other than
-	 * a {@link #graveyard graveyard root}).
+	 * Creates an empty document instance with no {@link #roots} (other than
+	 * the {@link #graveyard graveyard root}).
 	 */
 	constructor() {
 		/**

+ 1 - 1
packages/ckeditor5-engine/src/model/range.js

@@ -364,7 +364,7 @@ export default class Range {
 	 * moved to a different part of document tree). For this reason, an array is returned by this method and it
 	 * may contain one or more `Range` instances.
 	 *
-	 * @param {module:engine/model/delta~Delta} delta Delta to transform range by.
+	 * @param {module:engine/model/delta/delta~Delta} delta Delta to transform range by.
 	 * @returns {Array.<module:engine/model/range~Range>} Range which is the result of transformation.
 	 */
 	getTransformedByDelta( delta ) {

+ 2 - 2
packages/ckeditor5-engine/src/model/treewalker.js

@@ -158,8 +158,8 @@ export default class TreeWalker {
 	 * For example:
 	 *
 	 * 		walker.skip( value => value.type == 'text' ); // <paragraph>[]foo</paragraph> -> <paragraph>foo[]</paragraph>
-	 * 		walker.skip( value => true ); // Move the position to the end: <paragraph>[]foo</paragraph> -> <paragraph>foo</paragraph>[]
-	 * 		walker.skip( value => false ); // Do not move the position.
+	 * 		walker.skip( () => true ); // Move the position to the end: <paragraph>[]foo</paragraph> -> <paragraph>foo</paragraph>[]
+	 * 		walker.skip( () => false ); // Do not move the position.
 	 *
 	 * @param {Function} skip Callback function. Gets {@link module:engine/model/treewalker~TreeWalkerValue} and should
 	 * return `true` if the value should be skipped or `false` if not.

+ 138 - 0
packages/ckeditor5-engine/tests/controller/deletecontent.js

@@ -280,6 +280,37 @@ describe( 'DataController', () => {
 				expect( spyMerge.called ).to.be.false;
 				expect( spyRemove.called ).to.be.true;
 			} );
+
+			describe( 'object elements', () => {
+				beforeEach( () => {
+					const schema = doc.schema;
+
+					schema.registerItem( 'blockWidget' );
+					schema.registerItem( 'nestedEditable' );
+
+					schema.allow( { name: 'blockWidget', inside: '$root' } );
+
+					schema.allow( { name: 'nestedEditable', inside: 'blockWidget' } );
+					schema.allow( { name: '$text', inside: 'nestedEditable' } );
+
+					schema.objects.add( 'blockWidget' );
+					schema.limits.add( 'nestedEditable' );
+				} );
+
+				test(
+					'does not merge an object element (if it is first)',
+					'<blockWidget><nestedEditable>fo[o</nestedEditable></blockWidget><paragraph>b]ar</paragraph>',
+					'<blockWidget><nestedEditable>fo[]</nestedEditable></blockWidget><paragraph>ar</paragraph>',
+					{ merge: true }
+				);
+
+				test(
+					'does not merge an object element (if it is second)',
+					'<paragraph>ba[r</paragraph><blockWidget><nestedEditable>f]oo</nestedEditable></blockWidget>',
+					'<paragraph>ba[]</paragraph><blockWidget><nestedEditable>oo</nestedEditable></blockWidget>',
+					{ merge: true }
+				);
+			} );
 		} );
 
 		describe( 'in element selections scenarios', () => {
@@ -400,6 +431,113 @@ describe( 'DataController', () => {
 			} );
 		} );
 
+		describe( 'integration with inline limit elements', () => {
+			beforeEach( () => {
+				doc = new Document();
+				doc.createRoot();
+
+				const schema = doc.schema;
+
+				schema.registerItem( 'inlineLimit' );
+				schema.allow( { name: 'inlineLimit', inside: '$root' } );
+				schema.allow( { name: '$text', inside: 'inlineLimit' } );
+				schema.limits.add( 'inlineLimit' );
+
+				schema.allow( { name: '$inline', inside: '$root' } );
+
+				schema.registerItem( 'x' );
+				schema.allow( { name: '$text', inside: 'x' } );
+				schema.allow( { name: 'x', inside: '$root' } );
+			} );
+
+			test(
+				'should delete inside inline limit element',
+				'<inlineLimit>foo [bar] baz</inlineLimit>',
+				'<inlineLimit>foo [] baz</inlineLimit>'
+			);
+
+			test(
+				'should delete whole inline limit element',
+				'x[<inlineLimit>foo bar</inlineLimit>]x',
+				'x[]x'
+			);
+
+			test(
+				'should delete from two inline limit elements',
+				'<inlineLimit>foo [bar</inlineLimit><inlineLimit>baz] qux</inlineLimit>',
+				'<inlineLimit>foo []</inlineLimit><inlineLimit> qux</inlineLimit>'
+			);
+
+			test(
+				'merge option should be ignored if both elements are limits',
+				'<inlineLimit>foo [bar</inlineLimit><inlineLimit>baz] qux</inlineLimit>',
+				'<inlineLimit>foo []</inlineLimit><inlineLimit> qux</inlineLimit>',
+				{ merge: true }
+			);
+
+			test(
+				'merge option should be ignored if the first element is a limit',
+				'<inlineLimit>foo [bar</inlineLimit><x>baz] qux</x>',
+				'<inlineLimit>foo []</inlineLimit><x> qux</x>',
+				{ merge: true }
+			);
+
+			test(
+				'merge option should be ignored if the second element is a limit',
+				'<x>baz [qux</x><inlineLimit>foo] bar</inlineLimit>',
+				'<x>baz []</x><inlineLimit> bar</inlineLimit>',
+				{ merge: true }
+			);
+		} );
+
+		describe( 'integration with block limit elements', () => {
+			beforeEach( () => {
+				doc = new Document();
+				doc.createRoot();
+
+				const schema = doc.schema;
+
+				schema.registerItem( 'blockLimit' );
+				schema.allow( { name: 'blockLimit', inside: '$root' } );
+				schema.allow( { name: '$block', inside: 'blockLimit' } );
+				schema.limits.add( 'blockLimit' );
+
+				schema.registerItem( 'paragraph', '$block' );
+			} );
+
+			test(
+				'should delete inside block limit element',
+				'<blockLimit><paragraph>fo[o</paragraph><paragraph>b]ar</paragraph></blockLimit>',
+				'<blockLimit><paragraph>fo[]</paragraph><paragraph>ar</paragraph></blockLimit>'
+			);
+
+			test(
+				'should delete inside block limit element',
+				'<blockLimit><paragraph>fo[o</paragraph><paragraph>b]ar</paragraph></blockLimit>',
+				'<blockLimit><paragraph>fo[]ar</paragraph></blockLimit>',
+				{ merge: true }
+			);
+
+			test(
+				'should delete whole block limit element',
+				'<paragraph>x</paragraph>[<blockLimit><paragraph>foo</paragraph></blockLimit>]<paragraph>x</paragraph>',
+				'<paragraph>x</paragraph><paragraph>[]</paragraph><paragraph>x</paragraph>'
+			);
+
+			test(
+				'should delete from two block limit elements',
+				'<blockLimit><paragraph>foo [bar</paragraph></blockLimit><blockLimit><paragraph>baz] qux</paragraph></blockLimit>',
+				'<blockLimit><paragraph>foo []</paragraph></blockLimit><blockLimit><paragraph> qux</paragraph></blockLimit>'
+			);
+
+			test(
+				'merge option should be ignored if any of the elements is a limit',
+				'<blockLimit><paragraph>foo [bar</paragraph></blockLimit><blockLimit><paragraph>baz] qux</paragraph></blockLimit>',
+				'<blockLimit><paragraph>foo []</paragraph></blockLimit><blockLimit><paragraph> qux</paragraph></blockLimit>',
+				{ merge: true }
+			);
+		} );
+
 		function test( title, input, output, options ) {
 			it( title, () => {
 				setData( doc, input );

+ 61 - 0
packages/ckeditor5-engine/tests/controller/insertcontent.js

@@ -543,6 +543,67 @@ describe( 'DataController', () => {
 		} );
 	} );
 
+	describe( 'integration with limit elements', () => {
+		beforeEach( () => {
+			doc = new Document();
+			doc.createRoot();
+			dataController = new DataController( doc );
+
+			const schema = doc.schema;
+
+			schema.registerItem( 'limit' );
+			schema.allow( { name: 'limit', inside: '$root' } );
+			schema.allow( { name: '$text', inside: 'limit' } );
+			schema.limits.add( 'limit' );
+
+			schema.registerItem( 'disallowedElement' );
+			schema.allow( { name: 'disallowedElement', inside: '$clipboardHolder' } );
+
+			schema.registerItem( 'paragraph', '$block' );
+		} );
+
+		it( 'should insert limit element', () => {
+			insertHelper( '<limit></limit>' );
+
+			expect( getData( doc ) ).to.equal( '<limit>[]</limit>' );
+		} );
+
+		it( 'should insert text into limit element', () => {
+			setData( doc, '<limit>[]</limit>' );
+			insertHelper( 'foo bar' );
+
+			expect( getData( doc ) ).to.equal( '<limit>foo bar[]</limit>' );
+		} );
+
+		it( 'should insert text into limit element', () => {
+			setData( doc, '<limit>foo[</limit><limit>]bar</limit>' );
+			insertHelper( 'baz' );
+
+			expect( getData( doc ) ).to.equal( '<limit>foobaz[]</limit><limit>bar</limit>' );
+		} );
+
+		it( 'should not insert disallowed elements inside limit elements', () => {
+			setData( doc, '<limit>[]</limit>' );
+			insertHelper( '<disallowedElement></disallowedElement>' );
+
+			expect( getData( doc ) ).to.equal( '<limit>[]</limit>' );
+		} );
+
+		it( 'should not leave the limit element when inserting at the end', () => {
+			setData( doc, '<limit>foo[]</limit>' );
+			insertHelper( '<paragraph>a</paragraph><paragraph>b</paragraph>' );
+
+			expect( getData( doc ) ).to.equal( '<limit>fooab[]</limit>' );
+		} );
+
+		it( 'should not leave the limit element when inserting at the beginning', () => {
+			setData( doc, '<limit>[]foo</limit>' );
+			insertHelper( '<paragraph>a</paragraph><paragraph>b</paragraph>' );
+
+			expect( getData( doc ) ).to.equal( '<limit>ab[]foo</limit>' );
+		} );
+	} );
+
 	// @param {module:engine/model/item~Item|String} content
 	function insertHelper( content ) {
 		if ( typeof content == 'string' ) {

+ 140 - 45
packages/ckeditor5-engine/tests/controller/modifyselection.js

@@ -17,12 +17,7 @@ describe( 'DataController', () => {
 		dataController = new DataController( document );
 		document.schema.registerItem( 'p', '$block' );
 		document.schema.registerItem( 'x', '$block' );
-		document.schema.registerItem( 'img', '$inline' );
 
-		document.schema.allow( { name: '$text', inside: '$root' } );
-		document.schema.allow( { name: '$text', inside: 'img' } );
-		document.schema.allow( { name: '$text', inside: 'obj' } );
-		document.schema.allow( { name: '$text', inside: 'inlineObj' } );
 		document.schema.allow( { name: 'x', inside: 'p' } );
 
 		document.createRoot();
@@ -124,27 +119,6 @@ describe( 'DataController', () => {
 					expect( document.selection.isBackward ).to.false;
 				} );
 
-				test(
-					'extends one element forward',
-					'<p>f[]<img></img>oo</p>',
-					'<p>f[<img></img>]oo</p>'
-				);
-
-				test(
-					'extends one non-empty element forward',
-					'<p>f[]<img>x</img>oo</p>',
-					'<p>f[<img>x</img>]oo</p>'
-				);
-
-				it( 'extends one element backward', () => {
-					setData( document, '<p>fo<img></img>[]o</p>' );
-
-					modifySelection( dataController, document.selection, { direction: 'backward' } );
-
-					expect( stringify( document.getRoot(), document.selection ) ).to.equal( '<p>fo[<img></img>]o</p>' );
-					expect( document.selection.isBackward ).to.true;
-				} );
-
 				test(
 					'unicode support - combining mark forward',
 					'<p>foo[]b̂ar</p>',
@@ -244,31 +218,29 @@ describe( 'DataController', () => {
 				} );
 
 				test(
-					'extends over boundary when next element has nested elements',
+					'stops on the first position where text is allowed - inside block',
 					'<p>a[]</p><p><x>bcd</x></p>',
 					'<p>a[</p><p>]<x>bcd</x></p>'
 				);
 
 				test(
-					'extends over element when next element has nested elements',
+					'stops on the first position where text is allowed - inside inline element',
 					'<p>a[</p><p>]<x>bcd</x>ef</p>',
-					'<p>a[</p><p><x>bcd</x>]ef</p>'
+					'<p>a[</p><p><x>]bcd</x>ef</p>'
 				);
 
 				test(
 					'extends over element when next node is a text',
-					'<p>a[]</p>bc',
-					'<p>a[</p>]bc'
+					'<p><x>a[]</x>bc</p>',
+					'<p><x>a[</x>]bc</p>'
 				);
 
-				it( 'extends over element when next node is a text (backward)', () => {
-					setData( document, 'ab<p>[]c</p>' );
-
-					modifySelection( dataController, document.selection, { direction: 'backward' } );
-
-					expect( stringify( document.getRoot(), document.selection ) ).to.equal( 'ab[<p>]c</p>' );
-					expect( document.selection.isBackward ).to.true;
-				} );
+				test(
+					'extends over element when next node is a text - backward',
+					'<p>ab<x>[]c</x></p>',
+					'<p>ab[<x>]c</x></p>',
+					{ direction: 'backward' }
+				);
 
 				it( 'shrinks over boundary of empty elements', () => {
 					setData( document, '<p>[</p><p>]</p>', { lastRangeBackward: true } );
@@ -313,15 +285,65 @@ describe( 'DataController', () => {
 					expect( document.selection.getAttribute( 'bold' ) ).to.equal( true );
 				} );
 			} );
+
+			describe( 'beyond element – skipping incorrect positions', () => {
+				beforeEach( () => {
+					document.schema.registerItem( 'quote' );
+					document.schema.allow( { name: 'quote', inside: '$root' } );
+					document.schema.allow( { name: '$block', inside: 'quote' } );
+				} );
+
+				test(
+					'skips position at the beginning of an element which does not allow text',
+					'<p>x[]</p><quote><p>y</p></quote><p>z</p>',
+					'<p>x[</p><quote><p>]y</p></quote><p>z</p>'
+				);
+
+				test(
+					'skips position at the end of an element which does not allow text - backward',
+					'<p>x</p><quote><p>y</p></quote><p>[]z</p>',
+					'<p>x</p><quote><p>y[</p></quote><p>]z</p>',
+					{ direction: 'backward' }
+				);
+
+				test(
+					'skips position at the end of an element which does not allow text',
+					'<p>x[</p><quote><p>y]</p></quote><p>z</p>',
+					'<p>x[</p><quote><p>y</p></quote><p>]z</p>'
+				);
+
+				test(
+					'skips position at the beginning of an element which does not allow text - backward',
+					'<p>x</p><quote><p>[]y</p></quote><p>z</p>',
+					'<p>x[</p><quote><p>]y</p></quote><p>z</p>',
+					{ direction: 'backward' }
+				);
+
+				test(
+					'extends to an empty block after skipping incorrect position',
+					'<p>x[]</p><quote><p></p></quote><p>z</p>',
+					'<p>x[</p><quote><p>]</p></quote><p>z</p>'
+				);
+
+				test(
+					'extends to an empty block after skipping incorrect position - backward',
+					'<p>x</p><quote><p></p></quote><p>[]z</p>',
+					'<p>x</p><quote><p>[</p></quote><p>]z</p>',
+					{ direction: 'backward' }
+				);
+			} );
 		} );
 
 		describe( 'unit=codePoint', () => {
-			test(
-				'does nothing on empty content',
-				'[]',
-				'[]',
-				{ unit: 'codePoint' }
-			);
+			it( 'does nothing on empty content', () => {
+				document.schema.allow( { name: '$text', inside: '$root' } );
+
+				setData( document, '' );
+
+				modifySelection( dataController, document.selection, { unit: 'codePoint' } );
+
+				expect( stringify( document.getRoot(), document.selection ) ).to.equal( '[]' );
+			} );
 
 			test(
 				'does nothing on empty content (with empty element)',
@@ -394,8 +416,11 @@ describe( 'DataController', () => {
 			beforeEach( () => {
 				document.schema.registerItem( 'obj' );
 				document.schema.allow( { name: 'obj', inside: '$root' } );
+				document.schema.allow( { name: '$text', inside: 'obj' } );
 				document.schema.objects.add( 'obj' );
+
 				document.schema.registerItem( 'inlineObj', '$inline' );
+				document.schema.allow( { name: '$text', inside: 'inlineObj' } );
 				document.schema.objects.add( 'inlineObj' );
 			} );
 
@@ -439,6 +464,76 @@ describe( 'DataController', () => {
 				'<p>[<inlineObj>bar</inlineObj>]foo</p>',
 				{ direction: 'backward' }
 			);
+
+			test(
+				'extends over empty inline objects - forward',
+				'<p>foo[]<inlineObj></inlineObj></p>',
+				'<p>foo[<inlineObj></inlineObj>]</p>'
+			);
+
+			test(
+				'extends over empty inline objects - backward',
+				'<p><inlineObj></inlineObj>[]foo</p>',
+				'<p>[<inlineObj></inlineObj>]foo</p>',
+				{ direction: 'backward' }
+			);
+		} );
+
+		describe( 'limits handling', () => {
+			beforeEach( () => {
+				document.schema.registerItem( 'inlineLimit' );
+				document.schema.allow( { name: 'inlineLimit', inside: '$block' } );
+				document.schema.allow( { name: '$text', inside: 'inlineLimit' } );
+
+				document.schema.registerItem( 'blockLimit' );
+				document.schema.allow( { name: 'blockLimit', inside: '$root' } );
+				document.schema.allow( { name: 'p', inside: 'blockLimit' } );
+
+				document.schema.limits.add( 'inlineLimit' );
+				document.schema.limits.add( 'blockLimit' );
+			} );
+
+			test(
+				'should not extend to outside of inline limit element',
+				'<p>x<inlineLimit>foo[]</inlineLimit>x</p>',
+				'<p>x<inlineLimit>foo[]</inlineLimit>x</p>'
+			);
+
+			test(
+				'should not extend to outside of inline limit element - backward',
+				'<p>x<inlineLimit>[]foo</inlineLimit>x</p>',
+				'<p>x<inlineLimit>[]foo</inlineLimit>x</p>',
+				{ direction: 'backward' }
+			);
+
+			test(
+				'should not extend to outside of block limit element',
+				'<p>x</p><blockLimit><p>foo[]</p></blockLimit><p>x</p>',
+				'<p>x</p><blockLimit><p>foo[]</p></blockLimit><p>x</p>'
+			);
+
+			test(
+				'should not extend to outside of block limit element - backward',
+				'<p>x</p><blockLimit><p>[]foo</p></blockLimit><p>x</p>',
+				'<p>x</p><blockLimit><p>[]foo</p></blockLimit><p>x</p>',
+				{ direction: 'backward' }
+			);
+
+			// This may seem counterintuitive but it makes sense. The limit element means
+			// that it can't be left or modified from inside. If you want the same behavior from outside
+			// register it as an object.
+			test(
+				'should enter a limit element',
+				'<p>foo[]</p><blockLimit><p>x</p></blockLimit>',
+				'<p>foo[</p><blockLimit><p>]x</p></blockLimit>'
+			);
+
+			test(
+				'should enter a limit element - backward',
+				'<blockLimit><p>x</p></blockLimit><p>[]foo</p>',
+				'<blockLimit><p>x[</p></blockLimit><p>]foo</p>',
+				{ direction: 'backward' }
+			);
 		} );
 	} );
 

+ 4 - 0
packages/ckeditor5-engine/tests/model/schema/schema.js

@@ -43,6 +43,10 @@ describe( 'Schema', () => {
 			expect( schema.objects ).to.be.instanceOf( Set );
 		} );
 
+		it( 'should create the limits set', () => {
+			expect( schema.limits ).to.be.instanceOf( Set );
+		} );
+
 		describe( '$clipboardHolder', () => {
 			it( 'should allow $block', () => {
 				expect( schema.check( { name: '$block', inside: [ '$clipboardHolder' ] } ) ).to.be.true;