8
0
فهرست منبع

Merge pull request #362 from ckeditor/t/341

Introduced model composer.
Szymon Cofalik 9 سال پیش
والد
کامیت
6aa286af90

+ 87 - 0
packages/ckeditor5-engine/src/treemodel/composer/composer.js

@@ -0,0 +1,87 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import utils from '../../../utils/utils.js';
+import EmitterMixin from '../../../utils/emittermixin.js';
+import deleteContents from './deletecontents.js';
+import modifySelection from './modifyselection.js';
+
+/**
+ * Set of frequently used tools to work with a document.
+ * The instance of composer is available in {@link engine.treeModel.Document#composer}.
+ *
+ * By default this class implements only a very basic version of those algorithms. However, all its methods can be extended
+ * by features by listening to related events. The default action of those events are implemented
+ * by functions available in the {@link engine.treeModel.composer} namespace, so they can be reused
+ * in the algorithms implemented by features.
+ *
+ * @member engine.treeModel.composer
+ * @mixes utils.EmitterMixin
+ */
+export default class Composer {
+	/**
+	 * Creates an instance of the composer.
+	 */
+	constructor() {
+		this.on( 'deleteContents', ( evt, data ) => deleteContents( data.batch, data.selection ) );
+		this.on( 'modifySelection', ( evt, data ) => modifySelection( data.selection, data.options ) );
+	}
+
+	/**
+	 * See {@link engine.treeModel.composer.deleteContents}.
+	 *
+	 * Note: For the sake of predictability, the resulting selection should always be collapsed.
+	 * In cases where a feature wants to modify deleting behavior so selection isn't collapsed
+	 * (e.g. a table feature may want to keep row selection after pressing <kbd>Backspace</kbd>),
+	 * then that behavior should be implemented in the view's listener. At the same time, the table feature
+	 * will need to modify this method's behavior too, e.g. to "delete contents and then collapse
+	 * the selection inside the last selected cell" or "delete the row and collapse selection somewhere near".
+	 * That needs to be done in order to ensure that other features which use `deleteContents()` work well with tables.
+	 *
+	 * @fires engine.treeModel.composer.Composer#deleteContents
+	 * @param {engine.treeModel.Batch} batch Batch to which deltas will be added.
+	 * @param {engine.treeModel.Selection} selection Selection of which the content should be deleted.
+	 */
+	deleteContents( batch, selection ) {
+		this.fire( 'deleteContents', { batch, selection } );
+	}
+
+	/**
+	 * See {@link engine.treeModel.composer.modifySelection}.
+	 *
+	 * @fires engine.treeModel.composer.Composer#modifySelection
+	 * @param {engine.treeModel.Selection} The selection to modify.
+	 * @param {Object} options See {@link engine.treeModel.composer.modifySelection}'s options.
+	 */
+	modifySelection( selection, options ) {
+		this.fire( 'modifySelection', { selection, options } );
+	}
+}
+
+utils.mix( Composer, EmitterMixin );
+
+/**
+ * Event fired when {@link engine.treeModel.composer.Composer#deleteContents} method is called.
+ * The {@link engine.treeModel.composer.deleteContents default action of the composer} is implemented as a
+ * listener to that event so it can be fully customized by the features.
+ *
+ * @event engine.treeModel.composer.Composer#deleteContents
+ * @param {Object} data
+ * @param {engine.treeModel.Batch} data.batch
+ * @param {engine.treeModel.Selection} data.selection
+ */
+
+/**
+ * Event fired when {@link engine.treeModel.composer.Composer#modifySelection} method is called.
+ * The {@link engine.treeModel.composer.modifySelection default action of the composer} is implemented as a
+ * listener to that event so it can be fully customized by the features.
+ *
+ * @event engine.treeModel.composer.Composer#modifySelection
+ * @param {Object} data
+ * @param {engine.treeModel.Selection} data.selection
+ * @param {Object} data.options See {@link engine.treeModel.composer.modifySelection}'s options.
+ */

+ 54 - 0
packages/ckeditor5-engine/src/treemodel/composer/deletecontents.js

@@ -0,0 +1,54 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import LivePosition from '../liveposition.js';
+import Position from '../position.js';
+import utils from '../../../utils/utils.js';
+
+/**
+ * Delete contents of the selection and merge siblings. The resulting selection is always collapsed.
+ *
+ * @method engine.treeModel.composer.deleteContents
+ * @param {engine.treeModel.Batch} batch Batch to which the deltas will be added.
+ * @param {engine.treeModel.Selection} selection Selection of which the content should be deleted.
+ */
+export default function deleteContents( batch, selection ) {
+	if ( selection.isCollapsed ) {
+		return;
+	}
+
+	const startPos = selection.getFirstRange().start;
+	const endPos = LivePosition.createFromPosition( selection.getFirstRange().end );
+
+	// 1. Remove the contents.
+	batch.remove( selection.getFirstRange() );
+
+	// 2. Merge elements in the right branch to the elements in the left branch.
+	// The only reasonable (in terms of data and selection correctness) case in which we need to do that is:
+	//
+	// <heading type=1>Fo[</heading><paragraph>]ar</paragraph> => <heading type=1>Fo^ar</heading>
+	//
+	// However, the algorithm supports also merging deeper structures (up to the depth of the shallower branch),
+	// as it's hard to imagine what should actually be the default behavior. Usually, specific features will
+	// want to override that behavior anyway.
+	const endPath = endPos.path;
+	const mergeEnd = Math.min( startPos.path.length - 1, endPath.length - 1 );
+	let mergeDepth = utils.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 );
+
+			batch.merge( new Position( endPos.root, mergePath ) );
+		}
+	}
+
+	selection.collapse( startPos );
+
+	endPos.detach();
+}

+ 84 - 0
packages/ckeditor5-engine/src/treemodel/composer/modifyselection.js

@@ -0,0 +1,84 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import Position from '../position.js';
+import TreeWalker from '../treewalker.js';
+import Range from '../range.js';
+
+/**
+ * Modifies the selection. Currently the supported modifications are:
+ *
+ * * Extending. The selection focus is moved in the specified `options.direction` with a step specified in `options.unit`
+ * (defaults to `'CHARACTER'`, other values are not not yet supported).
+ * Note: if you extend a forward selection in a backward direction you will in fact shrink it.
+ *
+ * @method engine.treeModel.composer.modifySelection
+ * @param {engine.treeModel.Selection} The selection to modify.
+ * @param {Object} [options]
+ * @param {'FORWARD'|'BACKWARD'} [options.direction='FORWARD'] The direction in which the selection should be modified.
+ */
+export default function modifySelection( selection, options = {} ) {
+	const isForward = options.direction != 'BACKWARD';
+
+	const focus = selection.focus;
+	const walker = new TreeWalker( {
+		boundaries: getSearchRange( focus, isForward ),
+		singleCharacters: true
+	} );
+
+	const items = Array.from( walker );
+	let next = items[ isForward ? 'shift' : 'pop' ]();
+
+	// 1. Nothing to do here.
+	if ( !next ) {
+		return;
+	}
+
+	// 2. Consume next character.
+	if ( next.type == 'CHARACTER' ) {
+		selection.setFocus( next[ isForward ? 'nextPosition' : 'previousPosition' ] );
+
+		return;
+	}
+
+	// 3. We're entering an element, so let's consume it fully.
+	if ( next.type == ( isForward ? 'ELEMENT_START' : 'ELEMENT_END' ) ) {
+		selection.setFocus( next.item, isForward ? 'AFTER' : 'BEFORE' );
+
+		return;
+	}
+
+	// 4. We're leaving an element. That's more tricky.
+
+	next = items[ isForward ? 'shift' : 'pop' ]();
+
+	// 4.1. Nothing left, so let's stay where we were.
+	if ( !next ) {
+		return;
+	}
+
+	// 4.2. Character found after element end. Not really a valid case in our data model, but let's
+	// do something sensible and put the selection focus before that character.
+	if ( next.type == 'CHARACTER' ) {
+		selection.setFocus( next[ isForward ? 'previousPosition' : 'nextPosition' ] );
+	}
+	// 4.3. OK, we're entering a new element. So let's place there the focus.
+	else {
+		selection.setFocus( next.item, isForward ? 0 : 'END' );
+	}
+}
+
+function getSearchRange( start, isForward ) {
+	const root = start.root;
+	const searchEnd = Position.createAt( root, isForward ? 'END' : 0 );
+
+	if ( isForward ) {
+		return new Range( start, searchEnd );
+	} else {
+		return new Range( searchEnd, start );
+	}
+}

+ 10 - 0
packages/ckeditor5-engine/src/treemodel/document.js

@@ -17,6 +17,7 @@ import EmitterMixin from '../../utils/emittermixin.js';
 import CKEditorError from '../../utils/ckeditorerror.js';
 import utils from '../../utils/utils.js';
 import Schema from './schema.js';
+import Composer from './composer/composer.js';
 import clone from '../../utils/lib/lodash/clone.js';
 
 const graveyardSymbol = Symbol( 'graveyard' );
@@ -65,6 +66,15 @@ export default class Document {
 		 */
 		this.schema = new Schema();
 
+		/**
+		 * Composer for this document. Set of tools to work with the document.
+		 *
+		 * The features can tune up these tools to better work on their specific cases.
+		 *
+		 * @member {engine.treeModel.composer.Composer} engine.treeModel.Document#composer
+		 */
+		this.composer = new Composer();
+
 		/**
 		 * Array of pending changes. See: {@link engine.treeModel.Document#enqueueChanges}.
 		 *

+ 54 - 2
packages/ckeditor5-engine/src/treemodel/position.js

@@ -7,6 +7,7 @@
 
 import RootElement from './rootelement.js';
 import DocumentFragment from './documentfragment.js';
+import Element from './element.js';
 import last from '../../utils/lib/lodash/last.js';
 import utils from '../../utils/utils.js';
 import CKEditorError from '../../utils/ckeditorerror.js';
@@ -416,6 +417,47 @@ export default class Position {
 		}
 	}
 
+	/**
+	 * Creates position at the given location. The location can be specified as:
+	 *
+	 * * a {@link engine.treeModel.Position position},
+	 * * parent element and offset (offset defaults to `0`),
+	 * * parent element and `'END'` (sets selection at the end of that element),
+	 * * node and `'BEFORE'` or `'AFTER'` (sets selection before or after the given node).
+	 *
+	 * This method is a shortcut to other constructors such as:
+	 *
+	 * * {@link engine.treeModel.Position.createBefore},
+	 * * {@link engine.treeModel.Position.createAfter},
+	 * * {@link engine.treeModel.Position.createFromParentAndOffset},
+	 * * {@link engine.treeModel.Position.createFromPosition}.
+	 *
+	 * @param {engine.treeModel.Node|engine.treeModel.Position} nodeOrPosition
+	 * @param {Number|'END'|'BEFORE'|'AFTER'} [offset=0] Offset or one of the flags. Used only when
+	 * first parameter is a node.
+	 */
+	static createAt( nodeOrPosition, offset ) {
+		let node;
+
+		if ( nodeOrPosition instanceof Position ) {
+			return this.createFromPosition( nodeOrPosition );
+		} else {
+			node = nodeOrPosition;
+
+			if ( offset == 'END' ) {
+				offset = node.getChildCount();
+			} else if ( offset == 'BEFORE' ) {
+				return this.createBefore( node );
+			} else if ( offset == 'AFTER' ) {
+				return this.createAfter( node );
+			} else if ( !offset ) {
+				offset = 0;
+			}
+
+			return this.createFromParentAndOffset( node, offset );
+		}
+	}
+
 	/**
 	 * Creates a new position after given node.
 	 *
@@ -463,11 +505,21 @@ export default class Position {
 	/**
 	 * Creates a new position from the parent element and the offset in that element.
 	 *
-	 * @param {engine.treeModel.Element} parent Position parent element.
-	 * @param {Number} offset Position offset.
+	 * @param {engine.treeModel.Element|engine.treeModel.DocumentFragment} parent Position's parent element or
+	 * document fragment.
+	 * @param {Number} offset Position's offset.
 	 * @returns {engine.treeModel.Position}
 	 */
 	static createFromParentAndOffset( parent, offset ) {
+		if ( !( parent instanceof Element || parent instanceof DocumentFragment ) ) {
+			/**
+			 * Position parent have to be a model element or model document fragment.
+			 *
+			 * @error position-parent-incorrect
+			 */
+			throw new CKEditorError( 'position-parent-incorrect: Position parent have to be a model element or model document fragment.' );
+		}
+
 		const path = parent.getPath();
 
 		path.push( offset );

+ 42 - 36
packages/ckeditor5-engine/src/treemodel/selection.js

@@ -107,12 +107,12 @@ export default class Selection {
 	}
 
 	/**
-	 * Specifies whether the last added range was added as a backward or forward range.
+	 * Specifies whether the {@link engine.treeModel.Selection#focus} precedes {@link engine.treeModel.Selection#anchor}.
 	 *
 	 * @type {Boolean}
 	 */
 	get isBackward() {
-		return this._lastRangeBackward;
+		return !this.isCollapsed && this._lastRangeBackward;
 	}
 
 	/**
@@ -124,7 +124,7 @@ export default class Selection {
 	 * to {@link engine.treeModel.Range#start}. The flag is used to set {@link engine.treeModel.Selection#anchor} and
 	 * {@link engine.treeModel.Selection#focus} properties.
 	 *
-	 * @fires {@link engine.treeModel.Selection#change:range change:range}
+	 * @fires engine.treeModel.Selection#change:range
 	 * @param {engine.treeModel.Range} range Range to add.
 	 * @param {Boolean} [isBackward] Flag describing if added range was selected forward - from start to end (`false`)
 	 * or backward - from end to start (`true`). Defaults to `false`.
@@ -194,7 +194,7 @@ export default class Selection {
 	/**
 	 * Removes all ranges that were added to the selection. Fires update event.
 	 *
-	 * @fires {@link engine.treeModel.Selection#change:range change:range}
+	 * @fires engine.treeModel.Selection#change:range
 	 */
 	removeAllRanges() {
 		this.destroy();
@@ -208,7 +208,7 @@ export default class Selection {
 	 * is treated like the last added range and is used to set {@link #anchor} and {@link #focus}. Accepts a flag
 	 * describing in which way the selection is made (see {@link #addRange}).
 	 *
-	 * @fires {@link engine.treeModel.Selection#change:range change:range}
+	 * @fires engine.treeModel.Selection#change:range
 	 * @param {Array.<engine.treeModel.Range>} newRanges Array of ranges to set.
 	 * @param {Boolean} [isLastBackward] Flag describing if last added range was selected forward - from start to end (`false`)
 	 * or backward - from end to start (`true`). Defaults to `false`.
@@ -229,44 +229,49 @@ export default class Selection {
 	/**
 	 * Sets collapsed selection in the specified location.
 	 *
-	 * The location can be specified as:
+	 * The location can be specified in the same form as {@link engine.treeModel.Position.createAt} parameters.
 	 *
-	 * * a {@link engine.treeModel.Position position},
-	 * * parent element and offset (offset defaults to `0`),
-	 * * parent element and `'END'` (sets selection at the end of that element),
-	 * * node and `'BEFORE'` or `'AFTER'` (sets selection before or after the given node).
-	 *
-	 * @fires {@link engine.treeModel.Selection#change:range change:range}
+	 * @fires engine.treeModel.Selection#change:range
 	 * @param {engine.treeModel.Node|engine.treeModel.Position} nodeOrPosition
 	 * @param {Number|'END'|'BEFORE'|'AFTER'} [offset=0] Offset or one of the flags. Used only when
 	 * first parameter is a node.
 	 */
 	collapse( nodeOrPosition, offset ) {
-		let node, pos;
+		const pos = Position.createAt( nodeOrPosition, offset );
+		const range = new Range( pos, pos );
 
-		if ( nodeOrPosition instanceof Position ) {
-			pos = nodeOrPosition;
-		} else {
-			node = nodeOrPosition;
-
-			if ( offset == 'END' ) {
-				offset = node.getChildCount();
-			} else if ( offset == 'BEFORE' ) {
-				offset = node.getIndex();
-				node = node.parent;
-			} else if ( offset == 'AFTER' ) {
-				offset = node.getIndex() + 1;
-				node = node.parent;
-			} else if ( !offset ) {
-				offset = 0;
-			}
+		this.setRanges( [ range ] );
+	}
+
+	/**
+	 * Sets {@link engine.treeModel.Selection#focus} in the specified location.
+	 *
+	 * The location can be specified in the same form as {@link engine.treeModel.Position.createAt} parameters.
+	 *
+	 * @fires engine.treeModel.Selection#change:range
+	 * @param {engine.treeModel.Node|engine.treeModel.Position} nodeOrPosition
+	 * @param {Number|'END'|'BEFORE'|'AFTER'} [offset=0] Offset or one of the flags. Used only when
+	 * first parameter is a node.
+	 */
+	setFocus( nodeOrPosition, offset ) {
+		const newFocus = Position.createAt( nodeOrPosition, offset );
 
-			pos = Position.createFromParentAndOffset( node, offset );
+		if ( newFocus.compareWith( this.focus ) == 'SAME' ) {
+			return;
 		}
 
-		const range = new Range( pos, pos );
+		const anchor = this.anchor;
 
-		this.setRanges( [ range ] );
+		if ( this._ranges.length ) {
+			// TODO Replace with _popRange, so child classes can override this (needed for #329).
+			this._ranges.pop().detach();
+		}
+
+		if ( newFocus.compareWith( anchor ) == 'BEFORE' ) {
+			this.addRange( new Range( newFocus, anchor ), true );
+		} else {
+			this.addRange( new Range( anchor, newFocus ) );
+		}
 	}
 
 	/**
@@ -313,7 +318,7 @@ export default class Selection {
 	/**
 	 * Removes an attribute with given key from the selection.
 	 *
-	 * @fires {@link engine.treeModel.Selection#change:range change:range}
+	 * @fires engine.treeModel.Selection#change:attribute
 	 * @param {String} key Key of attribute to remove.
 	 */
 	removeAttribute( key ) {
@@ -326,7 +331,7 @@ export default class Selection {
 	/**
 	 * Sets attribute on the selection. If attribute with the same key already is set, it overwrites its values.
 	 *
-	 * @fires {@link engine.treeModel.Selection#change:range change:range}
+	 * @fires engine.treeModel.Selection#change:attribute
 	 * @param {String} key Key of attribute to set.
 	 * @param {*} value Attribute value.
 	 */
@@ -340,7 +345,7 @@ export default class Selection {
 	/**
 	 * Removes all attributes from the selection and sets given attributes.
 	 *
-	 * @fires {@link engine.treeModel.Selection#change:range change:range}
+	 * @fires engine.treeModel.Selection#change:attribute
 	 * @param {Iterable|Object} attrs Iterable object containing attributes to be set.
 	 */
 	setAttributesTo( attrs ) {
@@ -553,8 +558,9 @@ export default class Selection {
 	 */
 	_getDefaultRange() {
 		const defaultRoot = this._document._getDefaultRoot();
+		const pos = new Position( defaultRoot, [ 0 ] );
 
-		return new Range( new Position( defaultRoot, [ 0 ] ), new Position( defaultRoot, [ 0 ] ) );
+		return new Range( pos, pos );
 	}
 
 	/**

+ 0 - 1
packages/ckeditor5-engine/tests/treecontroller/advanced-converters.js

@@ -16,7 +16,6 @@ import ModelRange from '/ckeditor5/engine/treemodel/range.js';
 import ModelPosition from '/ckeditor5/engine/treemodel/position.js';
 import ModelWalker from '/ckeditor5/engine/treemodel/treewalker.js';
 
-import ViewDocumentFragment from '/ckeditor5/engine/treeview/documentfragment.js';
 import ViewElement from '/ckeditor5/engine/treeview/element.js';
 import ViewContainerElement from '/ckeditor5/engine/treeview/containerelement.js';
 import ViewAttributeElement from '/ckeditor5/engine/treeview/attributeelement.js';

+ 82 - 0
packages/ckeditor5-engine/tests/treemodel/composer/composer.js

@@ -0,0 +1,82 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treemodel, composer */
+
+'use strict';
+
+import Document from '/ckeditor5/engine/treemodel/document.js';
+import Composer from '/ckeditor5/engine/treemodel/composer/composer.js';
+import { setData, getData } from '/tests/engine/_utils/model.js';
+
+describe( 'Composer', () => {
+	let document, composer;
+
+	beforeEach( () => {
+		document = new Document();
+		document.createRoot( 'main', '$root' );
+
+		composer = new Composer();
+	} );
+
+	describe( 'constructor', () => {
+		it( 'attaches deleteContents default listener', () => {
+			setData( document, 'main', '<p><selection>foo</selection>bar</p>' );
+
+			const batch = document.batch();
+
+			composer.fire( 'deleteContents', { batch, selection: document.selection } );
+
+			expect( getData( document, 'main' ) ).to.equal( '<p>bar</p>' );
+			expect( batch.deltas ).to.not.be.empty;
+		} );
+
+		it( 'attaches modifySelection default listener', () => {
+			setData( document, 'main', '<p>foo<selection />bar</p>' );
+
+			composer.fire( 'modifySelection', {
+				selection: document.selection,
+				options: {
+					direction: 'BACKWARD'
+				}
+			} );
+
+			expect( getData( document, 'main', { selection: true } ) )
+				.to.equal( '<p>fo<selection backward>o</selection>bar</p>' );
+		} );
+	} );
+
+	describe( 'deleteContents', () => {
+		it( 'fires deleteContents event', () => {
+			const spy = sinon.spy();
+			const batch = document.batch();
+
+			composer.on( 'deleteContents', spy );
+
+			composer.deleteContents( batch, document.selection );
+
+			const data = spy.args[ 0 ][ 1 ];
+
+			expect( data.batch ).to.equal( batch );
+			expect( data.selection ).to.equal( document.selection );
+		} );
+	} );
+
+	describe( 'modifySelection', () => {
+		it( 'fires deleteContents event', () => {
+			const spy = sinon.spy();
+			const opts = { direction: 'backward' };
+
+			composer.on( 'modifySelection', spy );
+
+			composer.modifySelection( document.selection, opts );
+
+			const data = spy.args[ 0 ][ 1 ];
+
+			expect( data.selection ).to.equal( document.selection );
+			expect( data.options ).to.equal( opts );
+		} );
+	} );
+} );

+ 201 - 0
packages/ckeditor5-engine/tests/treemodel/composer/deletecontents.js

@@ -0,0 +1,201 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treemodel, composer */
+
+'use strict';
+
+import Document from '/ckeditor5/engine/treemodel/document.js';
+import deleteContents from '/ckeditor5/engine/treemodel/composer/deletecontents.js';
+import { setData, getData } from '/tests/engine/_utils/model.js';
+
+describe( 'Delete utils', () => {
+	let document;
+
+	beforeEach( () => {
+		document = new Document();
+		document.createRoot( 'main', '$root' );
+
+		const schema = document.schema;
+
+		// Note: We used short names instead of "image", "paragraph", etc. to make the tests shorter.
+		// We could use any random names in fact, but using HTML tags may explain the tests a bit better.
+		schema.registerItem( 'img', '$inline' );
+		schema.registerItem( 'p', '$block' );
+		schema.registerItem( 'h1', '$block' );
+		schema.registerItem( 'pchild' );
+
+		schema.allow( { name: 'pchild', inside: 'p' } );
+		schema.allow( { name: '$text', attributes: [ 'bold', 'italic' ] } );
+		schema.allow( { name: 'p', attributes: [ 'align' ] } );
+	} );
+
+	describe( 'deleteContents', () => {
+		describe( 'in simple scenarios', () => {
+			test(
+				'does nothing on collapsed selection',
+				'f<selection />oo',
+				'f<selection />oo'
+			);
+
+			test(
+				'deletes single character',
+				'f<selection>o</selection>o',
+				'f<selection />o'
+			);
+
+			test(
+				'xdeletes single character (backward selection)',
+				'f<selection backward>o</selection>o',
+				'f<selection />o'
+			);
+
+			test(
+				'deletes whole text',
+				'<selection>foo</selection>',
+				'<selection />'
+			);
+
+			test(
+				'deletes whole text between nodes',
+				'<img></img><selection>foo</selection><img></img>',
+				'<img></img><selection /><img></img>'
+			);
+
+			test(
+				'deletes an element',
+				'x<selection><img></img></selection>y',
+				'x<selection />y'
+			);
+
+			test(
+				'deletes a bunch of nodes',
+				'w<selection>x<img></img>y</selection>z',
+				'w<selection />z'
+			);
+
+			test(
+				'deletes a bunch of nodes',
+				'w<selection>x<img></img>y</selection>z',
+				'w<selection />z'
+			);
+		} );
+
+		describe( 'with text attributes', () => {
+			test(
+				'deletes characters (first half has attrs)',
+				'<$text bold=true>fo<selection bold=true>o</$text>b</selection>ar',
+				'<$text bold=true>fo</$text><selection bold=true />ar'
+			);
+
+			test(
+				'deletes characters (2nd half has attrs)',
+				'fo<selection bold=true>o<$text bold=true>b</selection>ar</$text>',
+				'fo<selection /><$text bold=true>ar</$text>'
+			);
+
+			test(
+				'clears selection attrs when emptied content',
+				'<p>x</p><p><selection bold=true><$text bold=true>foo</$text></selection></p><p>y</p>',
+				'<p>x</p><p><selection /></p><p>y</p>'
+			);
+
+			test(
+				'leaves selection attributes when text contains them',
+				'<p>x<$text bold=true>a<selection bold=true>foo</selection>b</$text>y</p>',
+				'<p>x<$text bold=true>a<selection bold=true />b</$text>y</p>'
+			);
+		} );
+
+		// Note: The algorithm does not care what kind of it's merging as it knows nothing useful about these elements.
+		// In most cases it handles all elements like you'd expect to handle block elements in HTML. However,
+		// in some scenarios where the tree depth is bigger results may be hard to justify. In fact, such cases
+		// should not happen unless we're talking about lists or tables, but these features will need to cover
+		// their scenarios themselves. In all generic scenarios elements are never nested.
+		//
+		// You may also be thinking – but I don't want my elements to be merged. It means that there are some special rules,
+		// like – multiple editing hosts (cE=true/false in use) or block limit elements like <td>.
+		// Those case should, again, be handled by their specific implementations.
+		describe( 'in multi-element scenarios', () => {
+			test(
+				'do not merge when no need to',
+				'<p>x</p><p><selection>foo</selection></p><p>y</p>',
+				'<p>x</p><p><selection /></p><p>y</p>'
+			);
+
+			test(
+				'merges second element into the first one (same name)',
+				'<p>x</p><p>fo<selection>o</p><p>b</selection>ar</p><p>y</p>',
+				'<p>x</p><p>fo<selection />ar</p><p>y</p>'
+			);
+
+			test(
+				'merges second element into the first one (different name)',
+				'<p>x</p><h1>fo<selection>o</h1><p>b</selection>ar</p><p>y</p>',
+				'<p>x</p><h1>fo<selection />ar</h1><p>y</p>'
+			);
+
+			test(
+				'merges second element into the first one (different name, backward selection)',
+				'<p>x</p><h1>fo<selection backward>o</h1><p>b</selection>ar</p><p>y</p>',
+				'<p>x</p><h1>fo<selection />ar</h1><p>y</p>'
+			);
+
+			test(
+				'merges second element into the first one (different attrs)',
+				'<p>x</p><p align="l">fo<selection>o</p><p>b</selection>ar</p><p>y</p>',
+				'<p>x</p><p align="l">fo<selection />ar</p><p>y</p>'
+			);
+
+			test(
+				'merges second element to an empty first element',
+				'<p>x</p><h1><selection></h1><p>fo</selection>o</p><p>y</p>',
+				'<p>x</p><h1><selection />o</h1><p>y</p>'
+			);
+
+			test(
+				'merges elements when deep nested',
+				'<p>x<pchild>fo<selection>o</pchild></p><p><pchild>b</selection>ar</pchild>y</p>',
+				'<p>x<pchild>fo<selection />ar</pchild>y</p>'
+			);
+
+			// If you disagree with this case please read the notes before this section.
+			test(
+				'merges elements when left end deep nested',
+				'<p>x<pchild>fo<selection>o</pchild></p><p>b</selection>ary</p>',
+				'<p>x<pchild>fo<selection /></pchild>ary</p>'
+			);
+
+			// If you disagree with this case please read the notes before this section.
+			test(
+				'merges elements when right end deep nested',
+				'<p>xfo<selection>o</p><p><pchild>b</selection>ar</pchild>y<img></img></p>',
+				'<p>xfo<selection /><pchild>ar</pchild>y<img></img></p>'
+			);
+
+			test(
+				'merges elements when more content in the right branch',
+				'<p>xfo<selection>o</p><p>b</selection>a<pchild>r</pchild>y</p>',
+				'<p>xfo<selection />a<pchild>r</pchild>y</p>'
+			);
+
+			test(
+				'leaves just one element when all selected',
+				'<h1><selection>x</h1><p>foo</p><p>y</selection></p>',
+				'<h1><selection /></h1>'
+			);
+		} );
+
+		function test( title, input, output ) {
+			it( title, () => {
+				setData( document, 'main', input );
+
+				deleteContents( document.batch(), document.selection );
+
+				expect( getData( document, 'main', { selection: true } ) ).to.equal( output );
+			} );
+		}
+	} );
+} );

+ 239 - 0
packages/ckeditor5-engine/tests/treemodel/composer/modifyselection.js

@@ -0,0 +1,239 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treemodel, composer */
+
+'use strict';
+
+import Document from '/ckeditor5/engine/treemodel/document.js';
+import modifySelection from '/ckeditor5/engine/treemodel/composer/modifyselection.js';
+import { setData, getData } from '/tests/engine/_utils/model.js';
+
+describe( 'Delete utils', () => {
+	let document;
+
+	beforeEach( () => {
+		document = new Document();
+		document.createRoot( 'main', '$root' );
+	} );
+
+	describe( 'modifySelection', () => {
+		describe( 'unit=character', () => {
+			describe( 'within element', () => {
+				test(
+					'does nothing on empty content',
+					'<selection />',
+					'<selection />'
+				);
+
+				test(
+					'does nothing on empty content (with empty element)',
+					'<p><selection /></p>',
+					'<p><selection /></p>'
+				);
+
+				test(
+					'does nothing on empty content (backward)',
+					'<selection />',
+					'<selection />',
+					{ direction: 'BACKWARD' }
+				);
+
+				test(
+					'does nothing on root boundary',
+					'<p>foo<selection /></p>',
+					'<p>foo<selection /></p>'
+				);
+
+				test(
+					'does nothing on root boundary (backward)',
+					'<p><selection />foo</p>',
+					'<p><selection />foo</p>',
+					{ direction: 'BACKWARD' }
+				);
+
+				test(
+					'extends one character forward',
+					'<p>f<selection />oo</p>',
+					'<p>f<selection>o</selection>o</p>'
+				);
+
+				test(
+					'extends one character backward',
+					'<p>fo<selection />o</p>',
+					'<p>f<selection backward>o</selection>o</p>',
+					{ direction: 'BACKWARD' }
+				);
+
+				test(
+					'extends one character forward (non-collapsed)',
+					'<p>f<selection>o</selection>obar</p>',
+					'<p>f<selection>oo</selection>bar</p>'
+				);
+
+				test(
+					'extends one character backward (non-collapsed)',
+					'<p>foob<selection backward>a</selection>r</p>',
+					'<p>foo<selection backward>ba</selection>r</p>',
+					{ direction: 'BACKWARD' }
+				);
+
+				test(
+					'extends to element boundary',
+					'<p>fo<selection />o</p>',
+					'<p>fo<selection>o</selection></p>'
+				);
+
+				test(
+					'extends to element boundary (backward)',
+					'<p>f<selection />oo</p>',
+					'<p><selection backward>f</selection>oo</p>',
+					{ direction: 'BACKWARD' }
+				);
+
+				test(
+					'shrinks forward selection (to collapsed)',
+					'<p>foo<selection>b</selection>ar</p>',
+					'<p>foo<selection />bar</p>',
+					{ direction: 'BACKWARD' }
+				);
+
+				test(
+					'shrinks backward selection (to collapsed)',
+					'<p>foo<selection backward>b</selection>ar</p>',
+					'<p>foob<selection />ar</p>'
+				);
+
+				test(
+					'extends one element forward',
+					'<p>f<selection /><img></img>oo</p>',
+					'<p>f<selection><img></img></selection>oo</p>'
+				);
+
+				test(
+					'extends one non-empty element forward',
+					'<p>f<selection /><img>x</img>oo</p>',
+					'<p>f<selection><img>x</img></selection>oo</p>'
+				);
+
+				test(
+					'extends one element backward',
+					'<p>fo<img></img><selection />o</p>',
+					'<p>fo<selection backward><img></img></selection>o</p>',
+					{ direction: 'BACKWARD' }
+				);
+			} );
+
+			describe( 'beyond element', () => {
+				test(
+					'extends over boundary of empty elements',
+					'<p><selection /></p><p></p><p></p>',
+					'<p><selection></p><p></selection></p><p></p>'
+				);
+
+				test(
+					'extends over boundary of empty elements (backward)',
+					'<p></p><p></p><p><selection /></p>',
+					'<p></p><p><selection backward></p><p></selection></p>',
+					{ direction: 'BACKWARD' }
+				);
+
+				test(
+					'extends over boundary of non-empty elements',
+					'<p>a<selection /></p><p>bcd</p>',
+					'<p>a<selection></p><p></selection>bcd</p>'
+				);
+
+				test(
+					'extends over boundary of non-empty elements (backward)',
+					'<p>a</p><p><selection />bcd</p>',
+					'<p>a<selection backward></p><p></selection>bcd</p>',
+					{ direction: 'BACKWARD' }
+				);
+
+				test(
+					'extends over character after boundary',
+					'<p>a<selection></p><p></selection>bcd</p>',
+					'<p>a<selection></p><p>b</selection>cd</p>'
+				);
+
+				test(
+					'extends over character after boundary (backward)',
+					'<p>abc<selection backward></p><p></selection>d</p>',
+					'<p>ab<selection backward>c</p><p></selection>d</p>',
+					{ direction: 'BACKWARD' }
+				);
+
+				test(
+					'extends over boundary when next element has nested elements',
+					'<p>a<selection /></p><p><x>bcd</x></p>',
+					'<p>a<selection></p><p></selection><x>bcd</x></p>'
+				);
+
+				test(
+					'extends over element when next element has nested elements',
+					'<p>a<selection></p><p></selection><x>bcd</x>ef</p>',
+					'<p>a<selection></p><p><x>bcd</x></selection>ef</p>'
+				);
+
+				test(
+					'extends over element when next node is a text',
+					'<p>a<selection /></p>bc',
+					'<p>a<selection></p></selection>bc'
+				);
+
+				test(
+					'extends over element when next node is a text (backward)',
+					'ab<p><selection />c</p>',
+					'ab<selection backward><p></selection>c</p>',
+					{ direction: 'BACKWARD' }
+				);
+
+				test(
+					'shrinks over boundary of empty elements',
+					'<p><selection backward></p><p></selection></p>',
+					'<p></p><p><selection /></p>'
+				);
+
+				test(
+					'shrinks over boundary of empty elements (backward)',
+					'<p><selection></p><p></selection></p>',
+					'<p><selection /></p><p></p>',
+					{ direction: 'BACKWARD' }
+				);
+
+				test(
+					'shrinks over boundary of non-empty elements',
+					'<p>a<selection backward></p><p></selection>b</p>',
+					'<p>a</p><p><selection />b</p>'
+				);
+
+				test(
+					'shrinks over boundary of non-empty elements (backward)',
+					'<p>a<selection></p><p></selection>b</p>',
+					'<p>a<selection /></p><p>b</p>',
+					{ direction: 'BACKWARD' }
+				);
+			} );
+		} );
+
+		test(
+			'updates selection attributes',
+			'<p><$text bold=true>foo</$text><selection>b</selection></p>',
+			'<p><$text bold=true>foo</$text><selection bold=true />b</p>',
+			{ direction: 'BACKWARD' }
+		);
+	} );
+
+	function test( title, input, output, options ) {
+		it( title, () => {
+			setData( document, 'main', input );
+
+			modifySelection( document.selection, options );
+
+			expect( getData( document, 'main', { selection: true } ) ).to.equal( output );
+		} );
+	}
+} );

+ 5 - 0
packages/ckeditor5-engine/tests/treemodel/document/document.js

@@ -8,6 +8,8 @@
 'use strict';
 
 import Document from '/ckeditor5/engine/treemodel/document.js';
+import Schema from '/ckeditor5/engine/treemodel/schema.js';
+import Composer from '/ckeditor5/engine/treemodel/composer/composer.js';
 import RootElement from '/ckeditor5/engine/treemodel/rootelement.js';
 import Batch from '/ckeditor5/engine/treemodel/batch.js';
 import CKEditorError from '/ckeditor5/utils/ckeditorerror.js';
@@ -27,6 +29,9 @@ describe( 'Document', () => {
 			expect( doc.graveyard ).to.be.instanceof( RootElement );
 			expect( doc.graveyard.getChildCount() ).to.equal( 0 );
 			expect( utils.count( doc.selection.getRanges() ) ).to.equal( 1 );
+
+			expect( doc.composer ).to.be.instanceof( Composer );
+			expect( doc.schema ).to.be.instanceof( Schema );
 		} );
 	} );
 

+ 127 - 74
packages/ckeditor5-engine/tests/treemodel/position.js

@@ -12,6 +12,9 @@ import DocumentFragment from '/ckeditor5/engine/treemodel/documentfragment.js';
 import Element from '/ckeditor5/engine/treemodel/element.js';
 import Position from '/ckeditor5/engine/treemodel/position.js';
 import CKEditorError from '/ckeditor5/utils/ckeditorerror.js';
+import testUtils from '/tests/ckeditor5/_utils/utils.js';
+
+testUtils.createSinonSandbox();
 
 describe( 'position', () => {
 	let doc, root, otherRoot, p, ul, li1, li2, f, o, z, b, a, r;
@@ -52,111 +55,161 @@ describe( 'position', () => {
 		root.insertChildren( 0, [ p, ul ] );
 	} );
 
-	it( 'should create a position with path and document', () => {
-		let position = new Position( root, [ 0 ] );
+	describe( 'constructor', () => {
+		it( 'should create a position with path and document', () => {
+			let position = new Position( root, [ 0 ] );
 
-		expect( position ).to.have.property( 'path' ).that.deep.equals( [ 0 ] );
-		expect( position ).to.have.property( 'root' ).that.equals( root );
-	} );
+			expect( position ).to.have.property( 'path' ).that.deep.equals( [ 0 ] );
+			expect( position ).to.have.property( 'root' ).that.equals( root );
+		} );
 
-	it( 'should accept DocumentFragment as a root', () => {
-		expect( () => {
-			new Position( new DocumentFragment(), [ 0 ] );
-		} ).not.to.throw;
-	} );
+		it( 'should accept DocumentFragment as a root', () => {
+			expect( () => {
+				new Position( new DocumentFragment(), [ 0 ] );
+			} ).not.to.throw;
+		} );
 
-	it( 'should throw error if given path is incorrect', () => {
-		expect( () => {
-			new Position( root, {} );
-		} ).to.throw( CKEditorError, /position-path-incorrect/ );
+		it( 'should throw error if given path is incorrect', () => {
+			expect( () => {
+				new Position( root, {} );
+			} ).to.throw( CKEditorError, /position-path-incorrect/ );
 
-		expect( () => {
-			new Position( root, [] );
-		} ).to.throw( CKEditorError, /position-path-incorrect/ );
-	} );
+			expect( () => {
+				new Position( root, [] );
+			} ).to.throw( CKEditorError, /position-path-incorrect/ );
+		} );
 
-	it( 'should throw error if given root is invalid', () => {
-		expect( () => {
-			new Position();
-		} ).to.throw( CKEditorError, /position-root-invalid/ );
+		it( 'should throw error if given root is invalid', () => {
+			expect( () => {
+				new Position();
+			} ).to.throw( CKEditorError, /position-root-invalid/ );
 
-		expect( () => {
-			new Position( new Element( 'p' ), [ 0 ] );
-		} ).to.throw( CKEditorError, /position-root-invalid/ );
+			expect( () => {
+				new Position( new Element( 'p' ), [ 0 ] );
+			} ).to.throw( CKEditorError, /position-root-invalid/ );
+		} );
 	} );
 
-	it( 'should create positions form node and offset', () => {
-		expect( Position.createFromParentAndOffset( root, 0 ) ).to.have.property( 'path' ).that.deep.equals( [ 0 ] );
-		expect( Position.createFromParentAndOffset( root, 1 ) ).to.have.property( 'path' ).that.deep.equals( [ 1 ] );
-		expect( Position.createFromParentAndOffset( root, 2 ) ).to.have.property( 'path' ).that.deep.equals( [ 2 ] );
+	describe( 'createFromParentAndOffset', () => {
+		it( 'should create positions form node and offset', () => {
+			expect( Position.createFromParentAndOffset( root, 0 ) ).to.have.property( 'path' ).that.deep.equals( [ 0 ] );
+			expect( Position.createFromParentAndOffset( root, 1 ) ).to.have.property( 'path' ).that.deep.equals( [ 1 ] );
+			expect( Position.createFromParentAndOffset( root, 2 ) ).to.have.property( 'path' ).that.deep.equals( [ 2 ] );
 
-		expect( Position.createFromParentAndOffset( p, 0 ) ).to.have.property( 'path' ).that.deep.equals( [ 0, 0 ] );
+			expect( Position.createFromParentAndOffset( p, 0 ) ).to.have.property( 'path' ).that.deep.equals( [ 0, 0 ] );
 
-		expect( Position.createFromParentAndOffset( ul, 0 ) ).to.have.property( 'path' ).that.deep.equals( [ 1, 0 ] );
-		expect( Position.createFromParentAndOffset( ul, 1 ) ).to.have.property( 'path' ).that.deep.equals( [ 1, 1 ] );
-		expect( Position.createFromParentAndOffset( ul, 2 ) ).to.have.property( 'path' ).that.deep.equals( [ 1, 2 ] );
+			expect( Position.createFromParentAndOffset( ul, 0 ) ).to.have.property( 'path' ).that.deep.equals( [ 1, 0 ] );
+			expect( Position.createFromParentAndOffset( ul, 1 ) ).to.have.property( 'path' ).that.deep.equals( [ 1, 1 ] );
+			expect( Position.createFromParentAndOffset( ul, 2 ) ).to.have.property( 'path' ).that.deep.equals( [ 1, 2 ] );
 
-		expect( Position.createFromParentAndOffset( li1, 0 ) ).to.have.property( 'path' ).that.deep.equals( [ 1, 0, 0 ] );
-		expect( Position.createFromParentAndOffset( li1, 1 ) ).to.have.property( 'path' ).that.deep.equals( [ 1, 0, 1 ] );
-		expect( Position.createFromParentAndOffset( li1, 2 ) ).to.have.property( 'path' ).that.deep.equals( [ 1, 0, 2 ] );
-		expect( Position.createFromParentAndOffset( li1, 3 ) ).to.have.property( 'path' ).that.deep.equals( [ 1, 0, 3 ] );
+			expect( Position.createFromParentAndOffset( li1, 0 ) ).to.have.property( 'path' ).that.deep.equals( [ 1, 0, 0 ] );
+			expect( Position.createFromParentAndOffset( li1, 1 ) ).to.have.property( 'path' ).that.deep.equals( [ 1, 0, 1 ] );
+			expect( Position.createFromParentAndOffset( li1, 2 ) ).to.have.property( 'path' ).that.deep.equals( [ 1, 0, 2 ] );
+			expect( Position.createFromParentAndOffset( li1, 3 ) ).to.have.property( 'path' ).that.deep.equals( [ 1, 0, 3 ] );
+		} );
+
+		it( 'throws when parent is not an element', () => {
+			expect( () => {
+				Position.createFromParentAndOffset( b, 0 );
+			} ).to.throw( CKEditorError, /^position-parent-incorrect/ );
+		} );
+
+		it( 'works with a doc frag', () => {
+			const frag = new DocumentFragment();
+
+			expect( Position.createFromParentAndOffset( frag, 0 ) ).to.have.property( 'root', frag );
+		} );
 	} );
 
-	it( 'should create positions before elements', () => {
-		expect( Position.createBefore( p ) ).to.have.property( 'path' ).that.deep.equals( [ 0 ] );
+	describe( 'createAt', () => {
+		it( 'should create positions from positions', () => {
+			const spy = testUtils.sinon.spy( Position, 'createFromPosition' );
 
-		expect( Position.createBefore( ul ) ).to.have.property( 'path' ).that.deep.equals( [ 1 ] );
+			expect( Position.createAt( Position.createAt( ul ) ) ).to.have.property( 'path' ).that.deep.equals( [ 1, 0 ] );
 
-		expect( Position.createBefore( li1 ) ).to.have.property( 'path' ).that.deep.equals( [ 1, 0 ] );
+			expect( spy.calledOnce ).to.be.true;
+		} );
 
-		expect( Position.createBefore( f ) ).to.have.property( 'path' ).that.deep.equals( [ 1, 0, 0 ] );
-		expect( Position.createBefore( o ) ).to.have.property( 'path' ).that.deep.equals( [ 1, 0, 1 ] );
-		expect( Position.createBefore( z ) ).to.have.property( 'path' ).that.deep.equals( [ 1, 0, 2 ] );
+		it( 'should create positions from node and offset', () => {
+			expect( Position.createAt( ul ) ).to.have.property( 'path' ).that.deep.equals( [ 1, 0 ] );
+			expect( Position.createAt( li1 ) ).to.have.property( 'path' ).that.deep.equals( [ 1, 0, 0 ] );
+			expect( Position.createAt( ul, 1 ) ).to.have.property( 'path' ).that.deep.equals( [ 1, 1 ] );
+		} );
 
-		expect( Position.createBefore( li2 ) ).to.have.property( 'path' ).that.deep.equals( [ 1, 1 ] );
+		it( 'should create positions from node and flag', () => {
+			expect( Position.createAt( root, 'END' ) ).to.have.property( 'path' ).that.deep.equals( [ 2 ] );
 
-		expect( Position.createBefore( b ) ).to.have.property( 'path' ).that.deep.equals( [ 1, 1, 0 ] );
-		expect( Position.createBefore( a ) ).to.have.property( 'path' ).that.deep.equals( [ 1, 1, 1 ] );
-		expect( Position.createBefore( r ) ).to.have.property( 'path' ).that.deep.equals( [ 1, 1, 2 ] );
-	} );
+			expect( Position.createAt( p, 'BEFORE' ) ).to.have.property( 'path' ).that.deep.equals( [ 0 ] );
+			expect( Position.createAt( a, 'BEFORE' ) ).to.have.property( 'path' ).that.deep.equals( [ 1, 1, 1 ] );
+
+			expect( Position.createAt( p, 'AFTER' ) ).to.have.property( 'path' ).that.deep.equals( [ 1 ] );
+			expect( Position.createAt( a, 'AFTER' ) ).to.have.property( 'path' ).that.deep.equals( [ 1, 1, 2 ] );
 
-	it( 'should throw error if one try to create positions before root', () => {
-		expect( () => {
-			Position.createBefore( root );
-		} ).to.throw( CKEditorError, /position-before-root/ );
+			expect( Position.createAt( ul, 'END' ) ).to.have.property( 'path' ).that.deep.equals( [ 1, 2 ] );
+		} );
 	} );
 
-	it( 'should create positions after elements', () => {
-		expect( Position.createAfter( p ) ).to.have.property( 'path' ).that.deep.equals( [ 1 ] );
+	describe( 'createBefore', () => {
+		it( 'should create positions before elements', () => {
+			expect( Position.createBefore( p ) ).to.have.property( 'path' ).that.deep.equals( [ 0 ] );
+
+			expect( Position.createBefore( ul ) ).to.have.property( 'path' ).that.deep.equals( [ 1 ] );
 
-		expect( Position.createAfter( ul ) ).to.have.property( 'path' ).that.deep.equals( [ 2 ] );
+			expect( Position.createBefore( li1 ) ).to.have.property( 'path' ).that.deep.equals( [ 1, 0 ] );
 
-		expect( Position.createAfter( li1 ) ).to.have.property( 'path' ).that.deep.equals( [ 1, 1 ] );
+			expect( Position.createBefore( f ) ).to.have.property( 'path' ).that.deep.equals( [ 1, 0, 0 ] );
+			expect( Position.createBefore( o ) ).to.have.property( 'path' ).that.deep.equals( [ 1, 0, 1 ] );
+			expect( Position.createBefore( z ) ).to.have.property( 'path' ).that.deep.equals( [ 1, 0, 2 ] );
 
-		expect( Position.createAfter( f ) ).to.have.property( 'path' ).that.deep.equals( [ 1, 0, 1 ] );
-		expect( Position.createAfter( o ) ).to.have.property( 'path' ).that.deep.equals( [ 1, 0, 2 ] );
-		expect( Position.createAfter( z ) ).to.have.property( 'path' ).that.deep.equals( [ 1, 0, 3 ] );
+			expect( Position.createBefore( li2 ) ).to.have.property( 'path' ).that.deep.equals( [ 1, 1 ] );
 
-		expect( Position.createAfter( li2 ) ).to.have.property( 'path' ).that.deep.equals( [ 1, 2 ] );
+			expect( Position.createBefore( b ) ).to.have.property( 'path' ).that.deep.equals( [ 1, 1, 0 ] );
+			expect( Position.createBefore( a ) ).to.have.property( 'path' ).that.deep.equals( [ 1, 1, 1 ] );
+			expect( Position.createBefore( r ) ).to.have.property( 'path' ).that.deep.equals( [ 1, 1, 2 ] );
+		} );
 
-		expect( Position.createAfter( b ) ).to.have.property( 'path' ).that.deep.equals( [ 1, 1, 1 ] );
-		expect( Position.createAfter( a ) ).to.have.property( 'path' ).that.deep.equals( [ 1, 1, 2 ] );
-		expect( Position.createAfter( r ) ).to.have.property( 'path' ).that.deep.equals( [ 1, 1, 3 ] );
+		it( 'should throw error if one try to create positions before root', () => {
+			expect( () => {
+				Position.createBefore( root );
+			} ).to.throw( CKEditorError, /position-before-root/ );
+		} );
 	} );
 
-	it( 'should create a copy of given position', () => {
-		let original = new Position( root, [ 1, 2, 3 ] );
-		let position = Position.createFromPosition( original );
+	describe( 'createAfter', () => {
+		it( 'should create positions after elements', () => {
+			expect( Position.createAfter( p ) ).to.have.property( 'path' ).that.deep.equals( [ 1 ] );
+
+			expect( Position.createAfter( ul ) ).to.have.property( 'path' ).that.deep.equals( [ 2 ] );
+
+			expect( Position.createAfter( li1 ) ).to.have.property( 'path' ).that.deep.equals( [ 1, 1 ] );
 
-		expect( position ).to.be.instanceof( Position );
-		expect( position.isEqual( original ) ).to.be.true;
-		expect( position ).not.to.be.equal( original );
+			expect( Position.createAfter( f ) ).to.have.property( 'path' ).that.deep.equals( [ 1, 0, 1 ] );
+			expect( Position.createAfter( o ) ).to.have.property( 'path' ).that.deep.equals( [ 1, 0, 2 ] );
+			expect( Position.createAfter( z ) ).to.have.property( 'path' ).that.deep.equals( [ 1, 0, 3 ] );
+
+			expect( Position.createAfter( li2 ) ).to.have.property( 'path' ).that.deep.equals( [ 1, 2 ] );
+
+			expect( Position.createAfter( b ) ).to.have.property( 'path' ).that.deep.equals( [ 1, 1, 1 ] );
+			expect( Position.createAfter( a ) ).to.have.property( 'path' ).that.deep.equals( [ 1, 1, 2 ] );
+			expect( Position.createAfter( r ) ).to.have.property( 'path' ).that.deep.equals( [ 1, 1, 3 ] );
+		} );
+
+		it( 'should throw error if one try to make positions after root', () => {
+			expect( () => {
+				Position.createAfter( root );
+			} ).to.throw( CKEditorError, /position-after-root/ );
+		} );
 	} );
 
-	it( 'should throw error if one try to make positions after root', () => {
-		expect( () => {
-			Position.createAfter( root );
-		} ).to.throw( CKEditorError, /position-after-root/ );
+	describe( 'createFromPosition', () => {
+		it( 'should create a copy of given position', () => {
+			let original = new Position( root, [ 1, 2, 3 ] );
+			let position = Position.createFromPosition( original );
+
+			expect( position ).to.be.instanceof( Position );
+			expect( position.isEqual( original ) ).to.be.true;
+			expect( position ).not.to.be.equal( original );
+		} );
 	} );
 
 	it( 'should have parent', () => {

+ 205 - 8
packages/ckeditor5-engine/tests/treemodel/selection.js

@@ -18,6 +18,7 @@ import InsertOperation from '/ckeditor5/engine/treemodel/operation/insertoperati
 import MoveOperation from '/ckeditor5/engine/treemodel/operation/moveoperation.js';
 import CKEditorError from '/ckeditor5/utils/ckeditorerror.js';
 import testUtils from '/tests/ckeditor5/_utils/utils.js';
+import utils from '/ckeditor5/utils/utils.js';
 
 testUtils.createSinonSandbox();
 
@@ -82,6 +83,24 @@ describe( 'Selection', () => {
 		} );
 	} );
 
+	describe( 'isBackward', () => {
+		it( 'is defined by the last added range', () => {
+			selection.addRange( range, true );
+			expect( selection ).to.have.property( 'isBackward', true );
+
+			selection.addRange( liveRange );
+			expect( selection ).to.have.property( 'isBackward', false );
+		} );
+
+		it( 'is false when last range is collapsed', () => {
+			const pos = Position.createAt( root, 0 );
+
+			selection.addRange( new Range( pos, pos ), true );
+
+			expect( selection.isBackward ).to.be.false;
+		} );
+	} );
+
 	describe( 'addRange', () => {
 		it( 'should copy added ranges and store multiple ranges', () => {
 			selection.addRange( liveRange );
@@ -120,14 +139,6 @@ describe( 'Selection', () => {
 			expect( selection.focus.path ).to.deep.equal( [ 2 ] );
 		} );
 
-		it( 'should set isBackward', () => {
-			selection.addRange( range, true );
-			expect( selection ).to.have.property( 'isBackward', true );
-
-			selection.addRange( liveRange );
-			expect( selection ).to.have.property( 'isBackward', false );
-		} );
-
 		it( 'should return a copy of (not a reference to) array of stored ranges', () => {
 			selection.addRange( liveRange );
 
@@ -272,6 +283,192 @@ describe( 'Selection', () => {
 		} );
 	} );
 
+	describe( 'setFocus', () => {
+		it( 'keeps all existing ranges and fires no change:range when no modifications needed', () => {
+			selection.addRange( range );
+			selection.addRange( liveRange );
+
+			const spy = sinon.spy();
+			selection.on( 'change:range', spy );
+
+			selection.setFocus( selection.focus );
+
+			expect( utils.count( selection.getRanges() ) ).to.equal( 2 );
+			expect( spy.callCount ).to.equal( 0 );
+		} );
+
+		it( 'fires change:range', () => {
+			selection.addRange( range );
+
+			const spy = sinon.spy();
+			selection.on( 'change:range', spy );
+
+			selection.setFocus( Position.createAt( root, 'END' ) );
+
+			expect( spy.calledOnce ).to.be.true;
+		} );
+
+		it( 'modifies default range', () => {
+			const startPos = selection.getFirstPosition();
+			const endPos = Position.createAt( root, 'END' );
+
+			selection.setFocus( endPos );
+
+			expect( selection.anchor.compareWith( startPos ) ).to.equal( 'SAME' );
+			expect( selection.focus.compareWith( endPos ) ).to.equal( 'SAME' );
+		} );
+
+		it( 'modifies existing collapsed selection', () => {
+			const startPos = Position.createAt( root, 1 );
+			const endPos = Position.createAt( root, 2 );
+
+			selection.collapse( startPos );
+
+			selection.setFocus( endPos );
+
+			expect( selection.anchor.compareWith( startPos ) ).to.equal( 'SAME' );
+			expect( selection.focus.compareWith( endPos ) ).to.equal( 'SAME' );
+		} );
+
+		it( 'makes existing collapsed selection a backward selection', () => {
+			const startPos = Position.createAt( root, 1 );
+			const endPos = Position.createAt( root, 0 );
+
+			selection.collapse( startPos );
+
+			selection.setFocus( endPos );
+
+			expect( selection.anchor.compareWith( startPos ) ).to.equal( 'SAME' );
+			expect( selection.focus.compareWith( endPos ) ).to.equal( 'SAME' );
+			expect( selection.isBackward ).to.be.true;
+		} );
+
+		it( 'modifies existing non-collapsed selection', () => {
+			const startPos = Position.createAt( root, 1 );
+			const endPos = Position.createAt( root, 2 );
+			const newEndPos = Position.createAt( root, 3 );
+
+			selection.addRange( new Range( startPos, endPos ) );
+
+			selection.setFocus( newEndPos );
+
+			expect( selection.anchor.compareWith( startPos ) ).to.equal( 'SAME' );
+			expect( selection.focus.compareWith( newEndPos ) ).to.equal( 'SAME' );
+		} );
+
+		it( 'makes existing non-collapsed selection a backward selection', () => {
+			const startPos = Position.createAt( root, 1 );
+			const endPos = Position.createAt( root, 2 );
+			const newEndPos = Position.createAt( root, 0 );
+
+			selection.addRange( new Range( startPos, endPos ) );
+
+			selection.setFocus( newEndPos );
+
+			expect( selection.anchor.compareWith( startPos ) ).to.equal( 'SAME' );
+			expect( selection.focus.compareWith( newEndPos ) ).to.equal( 'SAME' );
+			expect( selection.isBackward ).to.be.true;
+		} );
+
+		it( 'makes existing backward selection a forward selection', () => {
+			const startPos = Position.createAt( root, 1 );
+			const endPos = Position.createAt( root, 2 );
+			const newEndPos = Position.createAt( root, 3 );
+
+			selection.addRange( new Range( startPos, endPos ), true );
+
+			selection.setFocus( newEndPos );
+
+			expect( selection.anchor.compareWith( endPos ) ).to.equal( 'SAME' );
+			expect( selection.focus.compareWith( newEndPos ) ).to.equal( 'SAME' );
+			expect( selection.isBackward ).to.be.false;
+		} );
+
+		it( 'modifies existing backward selection', () => {
+			const startPos = Position.createAt( root, 1 );
+			const endPos = Position.createAt( root, 2 );
+			const newEndPos = Position.createAt( root, 0 );
+
+			selection.addRange( new Range( startPos, endPos ), true );
+
+			selection.setFocus( newEndPos );
+
+			expect( selection.anchor.compareWith( endPos ) ).to.equal( 'SAME' );
+			expect( selection.focus.compareWith( newEndPos ) ).to.equal( 'SAME' );
+			expect( selection.isBackward ).to.be.true;
+		} );
+
+		it( 'modifies only the last range', () => {
+			// Offsets are chosen in this way that the order of adding ranges must count, not their document order.
+			const startPos1 = Position.createAt( root, 4 );
+			const endPos1 = Position.createAt( root, 5 );
+			const startPos2 = Position.createAt( root, 1 );
+			const endPos2 = Position.createAt( root, 2 );
+
+			const newEndPos = Position.createAt( root, 0 );
+
+			selection.addRange( new Range( startPos1, endPos1 ) );
+			selection.addRange( new Range( startPos2, endPos2 ) );
+
+			const spy = sinon.spy();
+
+			selection.on( 'change:range', spy );
+
+			selection.setFocus( newEndPos );
+
+			const ranges = Array.from( selection.getRanges() );
+
+			expect( ranges ).to.have.lengthOf( 2 );
+			expect( ranges[ 0 ].start.compareWith( startPos1 ) ).to.equal( 'SAME' );
+			expect( ranges[ 0 ].end.compareWith( endPos1 ) ).to.equal( 'SAME' );
+
+			expect( selection.anchor.compareWith( startPos2 ) ).to.equal( 'SAME' );
+			expect( selection.focus.compareWith( newEndPos ) ).to.equal( 'SAME' );
+			expect( selection.isBackward ).to.be.true;
+
+			expect( spy.calledOnce ).to.be.true;
+		} );
+
+		it( 'collapses the selection when extending to the anchor', () => {
+			const startPos = Position.createAt( root, 1 );
+			const endPos = Position.createAt( root, 2 );
+
+			selection.addRange( new Range( startPos, endPos ) );
+
+			selection.setFocus( startPos );
+
+			expect( selection.focus.compareWith( startPos ) ).to.equal( 'SAME' );
+			expect( selection.isCollapsed ).to.be.true;
+		} );
+
+		it( 'uses Position.createAt', () => {
+			const startPos = Position.createAt( root, 1 );
+			const endPos = Position.createAt( root, 2 );
+			const newEndPos = Position.createAt( root, 4 );
+			const spy = testUtils.sinon.stub( Position, 'createAt', () => newEndPos );
+
+			selection.addRange( new Range( startPos, endPos ) );
+
+			selection.setFocus( root, 'END' );
+
+			expect( spy.calledOnce ).to.be.true;
+			expect( selection.focus.compareWith( newEndPos ) ).to.equal( 'SAME' );
+		} );
+
+		it( 'detaches the range it replaces', () => {
+			const startPos = Position.createAt( root, 1 );
+			const endPos = Position.createAt( root, 2 );
+			const newEndPos = Position.createAt( root, 4 );
+			const spy = testUtils.sinon.spy( LiveRange.prototype, 'detach' );
+
+			selection.addRange( new Range( startPos, endPos ) );
+
+			selection.setFocus( newEndPos );
+
+			expect( spy.calledOnce ).to.be.true;
+		} );
+	} );
+
 	describe( 'removeAllRanges', () => {
 		let spy, ranges;