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

Merge pull request #694 from ckeditor/t/684

Introduced getSelectedContent()
Szymon Kupś 9 лет назад
Родитель
Сommit
977a1f751f

+ 31 - 0
packages/ckeditor5-engine/src/controller/datacontroller.js

@@ -22,6 +22,7 @@ import ModelPosition from '../model/position.js';
 import insertContent from './insertcontent.js';
 import deleteContent from './deletecontent.js';
 import modifySelection from './modifyselection.js';
+import getSelectedContent from './getselectedcontent.js';
 
 /**
  * Controller for the data pipeline. The data pipeline controls how data is retrieved from the document
@@ -116,6 +117,9 @@ export default class DataController {
 		this.on( 'insertContent', ( evt, data ) => insertContent( this, data.content, data.selection, data.batch ) );
 		this.on( 'deleteContent', ( evt, data ) => deleteContent( data.selection, data.batch, data.options ) );
 		this.on( 'modifySelection', ( evt, data ) => modifySelection( data.selection, data.options ) );
+		this.on( 'getSelectedContent', ( evt, data ) => {
+			data.content = getSelectedContent( data.selection );
+		} );
 	}
 
 	/**
@@ -260,6 +264,21 @@ export default class DataController {
 	modifySelection( selection, options ) {
 		this.fire( 'modifySelection', { selection, options } );
 	}
+
+	/**
+	 * See {@link engine.controller.getSelectedContent}.
+	 *
+	 * @fires engine.controller.DataController#getSelectedContent
+	 * @param {engine.model.Selection} selection The selection of which content will be retrieved.
+	 * @returns {engine.model.DocumentFragment} Document fragment holding the clone of the selected content.
+	 */
+	getSelectedContent( selection ) {
+		const evtData = { selection };
+
+		this.fire( 'getSelectedContent', evtData );
+
+		return evtData.content;
+	}
 }
 
 mix( DataController, EmitterMixin );
@@ -298,3 +317,15 @@ mix( DataController, EmitterMixin );
  * @param {engine.model.Selection} data.selection
  * @param {Object} data.options See {@link engine.controller.modifySelection}'s options.
  */
+
+/**
+ * Event fired when {@link engine.controller.DataController#getSelectedContent} method is called.
+ * The {@link engine.controller.getSelectedContent default action of that method} is implemented as a
+ * listener to this event so it can be fully customized by the features.
+ *
+ * @event engine.controller.DataController#getSelectedContent
+ * @param {Object} data
+ * @param {engine.model.Selection} data.selection
+ * @param {engine.model.DocumentFragment} data.content The document fragment to return
+ * (holding a clone of the selected content).
+ */

+ 145 - 0
packages/ckeditor5-engine/src/controller/getselectedcontent.js

@@ -0,0 +1,145 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import DocumentFragment from '../model/documentfragment.js';
+import Range from '../model/range.js';
+import Position from '../model/position.js';
+import TextProxy from '../model/textproxy.js';
+import Text from '../model/text.js';
+import { remove } from '../model/writer.js';
+
+/**
+ * Gets a clone of the selected content.
+ *
+ * For example, for the following selection:
+ *
+ *		<p>x</p><quote><p>y</p><h>fir[st</h></quote><p>se]cond</p><p>z</p>
+ *
+ * It will return a document fragment with such a content:
+ *
+ *		<quote><h>st</h></quote><p>se</p>
+ *
+ * @method engine.controller.getSelectedContent
+ * @param {engine.model.Selection} selection The selection of which content will be returned.
+ * @returns {engine.model.DocumentFragment}
+ */
+export default function getSelectedContent( selection ) {
+	const frag = new DocumentFragment();
+	const range = selection.getFirstRange();
+
+	if ( !range || range.isCollapsed ) {
+		return frag;
+	}
+
+	const root = range.start.root;
+	const commonPath = range.start.getCommonPath( range.end );
+	const commonParent = root.getNodeByPath( commonPath );
+
+	// ## 1st step
+	//
+	// First, we'll clone a fragment represented by a minimal flat range
+	// containing the original range to be cloned.
+	// E.g. let's consider such a range:
+	//
+	// <p>x</p><quote><p>y</p><h>fir[st</h></quote><p>se]cond</p><p>z</p>
+	//
+	// A minimal flat range containing this one is:
+	//
+	// <p>x</p>[<quote><p>y</p><h>first</h></quote><p>second</p>]<p>z</p>
+	//
+	// We can easily clone this structure, preserving e.g. the <quote> element.
+	let flatSubtreeRange;
+
+	if ( range.start.parent == range.end.parent ) {
+		// The original range is flat, so take it.
+		flatSubtreeRange = range;
+	} else {
+		flatSubtreeRange = Range.createFromParentsAndOffsets(
+			commonParent, range.start.path[ commonPath.length ],
+			commonParent, range.end.path[ commonPath.length ] + 1
+		);
+	}
+
+	const howMany = flatSubtreeRange.end.offset - flatSubtreeRange.start.offset;
+
+	// Clone the whole contents.
+	for ( const item of flatSubtreeRange.getItems( { shallow: true } ) ) {
+		if ( item instanceof TextProxy ) {
+			frag.appendChildren( new Text( item.data, item.getAttributes() ) );
+		} else {
+			frag.appendChildren( item.clone( true ) );
+		}
+	}
+
+	// ## 2nd step
+	//
+	// If the original range wasn't flat, then we need to remove the excess nodes from the both ends of the cloned fragment.
+	//
+	// For example, for the range shown in the 1st step comment, we need to remove these pieces:
+	//
+	// <quote>[<p>y</p>]<h>[fir]st</h></quote><p>se[cond]</p>
+	//
+	// So this will be the final copied content:
+	//
+	// <quote><h>st</h></quote><p>se</p>
+	//
+	// In order to do that, we remove content from these two ranges:
+	//
+	// [<quote><p>y</p><h>fir]st</h></quote><p>se[cond</p>]
+	if ( flatSubtreeRange != range ) {
+		// Find the position of the original range in the cloned fragment.
+		const newRange = range._getTransformedByMove( flatSubtreeRange.start, Position.createAt( frag, 0 ), howMany )[ 0 ];
+
+		const leftExcessRange = new Range( Position.createAt( frag ), newRange.start );
+		const rightExcessRange = new Range( newRange.end, Position.createAt( frag, 'end' ) );
+
+		removeRangeContent( rightExcessRange );
+		removeRangeContent( leftExcessRange );
+	}
+
+	return frag;
+}
+
+// After https://github.com/ckeditor/ckeditor5-engine/issues/690 is fixed,
+// this function will, most likely, be able to rewritten using getMinimalFlatRanges().
+function removeRangeContent( range ) {
+	const parentsToCheck = [];
+
+	Array.from( range.getItems( { direction: 'backward' } ) )
+		// We should better store ranges because text proxies will lose integrity
+		// with the text nodes when we'll start removing content.
+		.map( item => Range.createOn( item ) )
+		// Filter only these items which are fully contained in the passed range.
+		//
+		// E.g. for the following range: [<quote><p>y</p><h>fir]st</h>
+		// the walker will return the entire <h> element, when only the "fir" item inside it is fully contained.
+		.filter( itemRange => {
+			// We should be able to use Range.containsRange, but https://github.com/ckeditor/ckeditor5-engine/issues/691.
+			const contained =
+				( itemRange.start.isAfter( range.start ) || itemRange.start.isEqual( range.start ) ) &&
+				( itemRange.end.isBefore( range.end ) || itemRange.end.isEqual( range.end ) );
+
+			return contained;
+		} )
+		.forEach( itemRange => {
+			parentsToCheck.push( itemRange.start.parent );
+
+			remove( itemRange );
+		} );
+
+	// Remove ancestors of the removed items if they turned to be empty now
+	// (their whole content was contained in the range).
+	parentsToCheck.forEach( parentToCheck => {
+		let parent = parentToCheck;
+
+		while ( parent.parent && parent.isEmpty ) {
+			const removeRange = Range.createOn( parent );
+
+			parent = parent.parent;
+
+			remove( removeRange );
+		}
+	} );
+}

+ 21 - 0
packages/ckeditor5-engine/src/model/element.js

@@ -198,6 +198,27 @@ export default class Element extends Node {
 		return nodes;
 	}
 
+	/**
+	 * Returns a descendant node by its path relative to this element.
+	 *
+	 *		// <this>a<b>c</b></this>
+	 *		this.getNodeByPath( [ 0 ] );     // -> "a"
+	 *		this.getNodeByPath( [ 1 ] );     // -> <b>
+	 *		this.getNodeByPath( [ 1, 0 ] );  // -> "c"
+	 *
+	 * @param {Array.<Number>} relativePath Path of the node to find, relative to this element.
+	 * @returns {engine.model.Node}
+	 */
+	getNodeByPath( relativePath ) {
+		let node = this;
+
+		for ( const index of relativePath ) {
+			node = node.getChild( index );
+		}
+
+		return node;
+	}
+
 	/**
 	 * Converts `Element` instance to plain object and returns it. Takes care of converting all of this element's children.
 	 *

+ 21 - 1
packages/ckeditor5-engine/src/model/position.js

@@ -251,7 +251,7 @@ export default class Position {
 	}
 
 	/**
-	 * Returns ancestors array of this position, that is this position's parent and it's ancestors.
+	 * Returns ancestors array of this position, that is this position's parent and its ancestors.
 	 *
 	 * @returns {Array.<engine.model.Item>} Array with ancestors.
 	 */
@@ -259,6 +259,26 @@ export default class Position {
 		return this.parent.getAncestors( { includeNode: true, parentFirst: true } );
 	}
 
+	/**
+	 * Returns the slice of two position {@link #path paths} which is identical. The {@link #root roots}
+	 * of these two paths must be identical.
+	 *
+	 * @param {engine.model.Position} position The second position.
+	 * @returns {Array.<Number>} The common path.
+	 */
+	getCommonPath( position ) {
+		if ( this.root != position.root ) {
+			return [];
+		}
+
+		// We find on which tree-level start and end have the lowest common ancestor
+		let cmp = compareArrays( this.path, position.path );
+		// If comparison returned string it means that arrays are same.
+		let diffAt = ( typeof cmp == 'string' ) ? Math.min( this.path.length, position.path.length ) : cmp;
+
+		return this.path.slice( 0, diffAt );
+	}
+
 	/**
 	 * Returns a new instance of `Position`, that has same {@link engine.model.Position#parent parent} but it's offset
 	 * is shifted by `shift` value (can be a negative value).

+ 6 - 11
packages/ckeditor5-engine/src/model/range.js

@@ -5,7 +5,6 @@
 
 import Position from './position.js';
 import TreeWalker from './treewalker.js';
-import compareArrays from '../../utils/comparearrays.js';
 
 /**
  * Range class. Range is iterable.
@@ -228,7 +227,7 @@ export default class Range {
 	/**
 	 * Computes and returns the smallest set of {@link engine.model.Range#isFlat flat} ranges, that covers this range in whole.
 	 *
-	 * See an example of model structure (`[` and `]` are range boundaries):
+	 * See an example of a model structure (`[` and `]` are range boundaries):
 	 *
 	 *		root                                                            root
 	 *		 |- element DIV                         DIV             P2              P3             DIV
@@ -256,21 +255,17 @@ export default class Range {
 	 *		( [ 1 ], [ 3 ] ) = element P2, element P3 ("foobar")
 	 *		( [ 3, 0, 0 ], [ 3, 0, 2 ] ) = "se"
 	 *
-	 * **Note:** if an {@link engine.model.Element element} is not contained wholly in this range, it won't be returned
-	 * in any of returned flat ranges. See in an example, how `H` elements at the beginning and at the end of the range
-	 * were omitted. Only it's parts that were wholly in the range were returned.
+	 * **Note:** if an {@link engine.model.Element element} is not wholly contained in this range, it won't be returned
+	 * in any of the returned flat ranges. See in the example how `H` elements at the beginning and at the end of the range
+	 * were omitted. Only their parts that were wholly in the range were returned.
 	 *
 	 * **Note:** this method is not returning flat ranges that contain no nodes.
 	 *
 	 * @returns {Array.<engine.model.Range>} Array of flat ranges covering this range.
 	 */
 	getMinimalFlatRanges() {
-		let ranges = [];
-
-		// We find on which tree-level start and end have the lowest common ancestor
-		let cmp = compareArrays( this.start.path, this.end.path );
-		// If comparison returned string it means that arrays are same.
-		let diffAt = ( typeof cmp == 'string' ) ? Math.min( this.start.path.length, this.end.path.length ) : cmp;
+		const ranges = [];
+		const diffAt = this.start.getCommonPath( this.end ).length;
 
 		let pos = Position.createFromPosition( this.start );
 		let posParent = pos.parent;

+ 42 - 0
packages/ckeditor5-engine/tests/controller/datacontroller.js

@@ -12,6 +12,7 @@ import buildModelConverter  from 'ckeditor5/engine/conversion/buildmodelconverte
 
 import ModelDocumentFragment from 'ckeditor5/engine/model/documentfragment.js';
 import ModelText from 'ckeditor5/engine/model/text.js';
+import ModelSelection from 'ckeditor5/engine/model/selection.js';
 
 import { getData, setData, stringify, parse } from 'ckeditor5/engine/dev-utils/model.js';
 
@@ -98,6 +99,20 @@ describe( 'DataController', () => {
 				.to.equal( '<paragraph>fo[o]bar</paragraph>' );
 			expect( modelDocument.selection.isBackward ).to.true;
 		} );
+
+		it( 'should add getSelectedContent listener', () => {
+			schema.registerItem( 'paragraph', '$block' );
+
+			setData( modelDocument, '<paragraph>fo[ob]ar</paragraph>' );
+
+			const evtData = {
+				selection: modelDocument.selection
+			};
+
+			data.fire( 'getSelectedContent', evtData );
+
+			expect( stringify( evtData.content ) ).to.equal( 'ob' );
+		} );
 	} );
 
 	describe( 'parse', () => {
@@ -363,4 +378,31 @@ describe( 'DataController', () => {
 			expect( evtData.options ).to.equal( opts );
 		} );
 	} );
+
+	describe( 'getSelectedContent', () => {
+		it( 'should fire the getSelectedContent event', () => {
+			const spy = sinon.spy();
+			const sel = new ModelSelection();
+
+			data.on( 'getSelectedContent', spy );
+
+			data.getSelectedContent( sel );
+
+			const evtData = spy.args[ 0 ][ 1 ];
+
+			expect( evtData.selection ).to.equal( sel );
+		} );
+
+		it( 'should return the evtData.content of the getSelectedContent event', () => {
+			const frag = new ModelDocumentFragment();
+
+			data.on( 'getSelectedContent', ( evt, data ) => {
+				data.content = frag;
+
+				evt.stop();
+			}, { priority: 'high' } );
+
+			expect( data.getSelectedContent() ).to.equal( frag );
+		} );
+	} );
 } );

+ 1 - 1
packages/ckeditor5-engine/tests/controller/deletecontent.js

@@ -7,7 +7,7 @@ import Document from 'ckeditor5/engine/model/document.js';
 import deleteContent from 'ckeditor5/engine/controller/deletecontent.js';
 import { setData, getData } from 'ckeditor5/engine/dev-utils/model.js';
 
-describe( 'Delete utils', () => {
+describe( 'DataController', () => {
 	let doc;
 
 	describe( 'deleteContent', () => {

+ 314 - 0
packages/ckeditor5-engine/tests/controller/getselectedcontent.js

@@ -0,0 +1,314 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Document from 'ckeditor5/engine/model/document.js';
+import DocumentFragment from 'ckeditor5/engine/model/documentfragment.js';
+import getSelectedContent from 'ckeditor5/engine/controller/getselectedcontent.js';
+import { setData, stringify } from 'ckeditor5/engine/dev-utils/model.js';
+
+describe( 'Delete utils', () => {
+	let doc;
+
+	describe( 'getSelectedContent', () => {
+		describe( 'in simple scenarios', () => {
+			beforeEach( () => {
+				doc = new Document();
+				doc.createRoot();
+
+				const schema = doc.schema;
+
+				schema.registerItem( 'image', '$inline' );
+
+				schema.allow( { name: '$text', inside: '$root' } );
+				schema.allow( { name: 'image', inside: '$root' } );
+				schema.allow( { name: '$inline', attributes: [ 'bold' ] } );
+				schema.allow( { name: '$inline', attributes: [ 'italic' ] } );
+			} );
+
+			it( 'returns empty fragment for no selection', () => {
+				setData( doc, 'abc' );
+
+				const frag = getSelectedContent( doc.selection );
+
+				expect( frag ).instanceOf( DocumentFragment );
+				expect( frag.isEmpty ).to.be.true;
+			} );
+
+			it( 'returns empty fragment for empty selection', () => {
+				setData( doc, 'a[]bc' );
+
+				const frag = getSelectedContent( doc.selection );
+
+				expect( frag ).instanceOf( DocumentFragment );
+				expect( frag.isEmpty ).to.be.true;
+			} );
+
+			it( 'gets one character', () => {
+				setData( doc, 'a[b]c' );
+
+				const frag = getSelectedContent( doc.selection );
+				const content = stringify( frag );
+
+				expect( frag ).instanceOf( DocumentFragment );
+				expect( content ).to.equal( 'b' );
+			} );
+
+			it( 'gets full text', () => {
+				setData( doc, '[abc]' );
+
+				const content = stringify( getSelectedContent( doc.selection ) );
+				expect( content ).to.equal( 'abc' );
+			} );
+
+			it( 'gets text with an attribute', () => {
+				setData( doc, 'xxx<$text bold="true">a[b]c</$text>' );
+
+				const content = stringify( getSelectedContent( doc.selection ) );
+				expect( content ).to.equal( '<$text bold="true">b</$text>' );
+			} );
+
+			it( 'gets text with attributes', () => {
+				setData( doc, 'x<$text bold="true">a[b</$text><$text italic="true">c]d</$text>x' );
+
+				const content = stringify( getSelectedContent( doc.selection ) );
+				expect( content ).to.equal( '<$text bold="true">b</$text><$text italic="true">c</$text>' );
+			} );
+
+			it( 'gets text with and without attribute', () => {
+				setData( doc, '<$text bold="true">a[b</$text>c]d' );
+
+				const content = stringify( getSelectedContent( doc.selection ) );
+				expect( content ).to.equal( '<$text bold="true">b</$text>c' );
+			} );
+
+			it( 'gets text and element', () => {
+				setData( doc, '[ab<image></image>c]' );
+
+				const content = stringify( getSelectedContent( doc.selection ) );
+				expect( content ).to.equal( 'ab<image></image>c' );
+			} );
+
+			it( 'gets one element', () => {
+				setData( doc, 'a[<image></image>]b' );
+
+				const content = stringify( getSelectedContent( doc.selection ) );
+				expect( content ).to.equal( '<image></image>' );
+			} );
+
+			it( 'gets multiple elements', () => {
+				setData( doc, '[<image></image><image></image>]' );
+
+				const content = stringify( getSelectedContent( doc.selection ) );
+				expect( content ).to.equal( '<image></image><image></image>' );
+			} );
+		} );
+
+		describe( 'in blocks', () => {
+			beforeEach( () => {
+				doc = new Document();
+				doc.createRoot();
+
+				const schema = doc.schema;
+
+				schema.registerItem( 'paragraph', '$block' );
+				schema.registerItem( 'heading1', '$block' );
+				schema.registerItem( 'blockImage' );
+				schema.registerItem( 'caption' );
+				schema.registerItem( 'image', '$inline' );
+
+				schema.allow( { name: 'blockImage', inside: '$root' } );
+				schema.allow( { name: 'caption', inside: 'blockImage' } );
+				schema.allow( { name: '$inline', inside: 'caption' } );
+
+				schema.allow( { name: '$inline', attributes: [ 'bold' ] } );
+			} );
+
+			it( 'gets one character', () => {
+				setData( doc, '<paragraph>a[b]c</paragraph>' );
+
+				const content = stringify( getSelectedContent( doc.selection ) );
+				expect( content ).to.equal( 'b' );
+			} );
+
+			it( 'gets entire paragraph content', () => {
+				setData( doc, '<paragraph>[a<image></image>b]</paragraph>' );
+
+				const content = stringify( getSelectedContent( doc.selection ) );
+				expect( content ).to.equal( 'a<image></image>b' );
+			} );
+
+			it( 'gets two blocks - partial, partial', () => {
+				setData( doc, '<heading1>a[bc</heading1><paragraph>de]f</paragraph>' );
+
+				const content = stringify( getSelectedContent( doc.selection ) );
+				expect( content ).to.equal( '<heading1>bc</heading1><paragraph>de</paragraph>' );
+			} );
+
+			it( 'gets two blocks - full, partial', () => {
+				setData( doc, '<heading1>[abc</heading1><paragraph>de]f</paragraph>' );
+
+				const content = stringify( getSelectedContent( doc.selection ) );
+				expect( content ).to.equal( '<heading1>abc</heading1><paragraph>de</paragraph>' );
+			} );
+
+			it( 'gets two blocks - full, partial 2', () => {
+				setData( doc, '<heading1>[abc</heading1><paragraph>de<image></image>]f</paragraph>' );
+
+				const content = stringify( getSelectedContent( doc.selection ) );
+				expect( content ).to.equal( '<heading1>abc</heading1><paragraph>de<image></image></paragraph>' );
+			} );
+
+			it( 'gets two blocks - full, partial 3', () => {
+				setData( doc, '<heading1>x</heading1><heading1>[abc</heading1><paragraph><image></image>de]f</paragraph>' );
+
+				const content = stringify( getSelectedContent( doc.selection ) );
+				expect( content ).to.equal( '<heading1>abc</heading1><paragraph><image></image>de</paragraph>' );
+			} );
+
+			it( 'gets two blocks - full, partial 4', () => {
+				setData( doc, '<heading1>[abc</heading1><paragraph>de]f<image></image></paragraph>' );
+
+				const content = stringify( getSelectedContent( doc.selection ) );
+				expect( content ).to.equal( '<heading1>abc</heading1><paragraph>de</paragraph>' );
+			} );
+
+			it( 'gets two blocks - partial, full', () => {
+				setData( doc, '<heading1>a[bc</heading1><paragraph>def]</paragraph>' );
+
+				const content = stringify( getSelectedContent( doc.selection ) );
+				expect( content ).to.equal( '<heading1>bc</heading1><paragraph>def</paragraph>' );
+			} );
+
+			it( 'gets two blocks - partial, full 2', () => {
+				setData( doc, '<heading1>a[<image></image>bc</heading1><paragraph>def]</paragraph>' );
+
+				const content = stringify( getSelectedContent( doc.selection ) );
+				expect( content ).to.equal( '<heading1><image></image>bc</heading1><paragraph>def</paragraph>' );
+			} );
+
+			// See https://github.com/ckeditor/ckeditor5-engine/issues/652#issuecomment-261358484
+			it( 'gets two blocks - empty, full', () => {
+				setData( doc, '<heading1>abc[</heading1><paragraph>def]</paragraph>' );
+
+				const content = stringify( getSelectedContent( doc.selection ) );
+				expect( content ).to.equal( '<paragraph>def</paragraph>' );
+			} );
+
+			// See https://github.com/ckeditor/ckeditor5-engine/issues/652#issuecomment-261358484
+			it( 'gets two blocks - partial, empty', () => {
+				setData( doc, '<heading1>a[bc</heading1><paragraph>]def</paragraph>' );
+
+				const content = stringify( getSelectedContent( doc.selection ) );
+				expect( content ).to.equal( '<heading1>bc</heading1>' );
+			} );
+
+			it( 'gets three blocks', () => {
+				setData( doc, '<heading1>a[bc</heading1><paragraph>x</paragraph><paragraph>de]f</paragraph>' );
+
+				const content = stringify( getSelectedContent( doc.selection ) );
+				expect( content ).to.equal( '<heading1>bc</heading1><paragraph>x</paragraph><paragraph>de</paragraph>' );
+			} );
+
+			it( 'gets block image', () => {
+				setData( doc, '<paragraph>a</paragraph>[<blockImage><caption>Foo</caption></blockImage>]<paragraph>b</paragraph>' );
+
+				const content = stringify( getSelectedContent( doc.selection ) );
+				expect( content ).to.equal( '<blockImage><caption>Foo</caption></blockImage>' );
+			} );
+
+			it( 'gets two blocks', () => {
+				setData( doc, '<paragraph>a</paragraph>[<blockImage></blockImage><blockImage></blockImage>]<paragraph>b</paragraph>' );
+
+				const content = stringify( getSelectedContent( doc.selection ) );
+				expect( content ).to.equal( '<blockImage></blockImage><blockImage></blockImage>' );
+			} );
+
+			// Purely related to the current implementation.
+			it( 'gets content when multiple text items needs to be removed from the right excess', () => {
+				setData( doc, '<paragraph>a[b</paragraph><paragraph>c]d<$text bold="true">e</$text>f</paragraph>' );
+
+				const content = stringify( getSelectedContent( doc.selection ) );
+				expect( content )
+					.to.equal( '<paragraph>b</paragraph><paragraph>c</paragraph>' );
+			} );
+
+			// Purely related to the current implementation.
+			it( 'gets content when multiple text items needs to be removed from the left excess', () => {
+				setData( doc, '<paragraph>a<$text bold="true">b</$text>c[d</paragraph><paragraph>e]f</paragraph>' );
+
+				const content = stringify( getSelectedContent( doc.selection ) );
+				expect( content )
+					.to.equal( '<paragraph>d</paragraph><paragraph>e</paragraph>' );
+			} );
+		} );
+
+		describe( 'in blocks (deeply nested)', () => {
+			beforeEach( () => {
+				doc = new Document();
+				doc.createRoot();
+
+				const schema = doc.schema;
+
+				schema.registerItem( 'paragraph', '$block' );
+				schema.registerItem( 'heading1', '$block' );
+				schema.registerItem( 'quote' );
+
+				schema.allow( { name: '$block', inside: 'quote' } );
+				schema.allow( { name: 'quote', inside: '$root' } );
+			} );
+
+			it( 'gets content when ends are equally deeply nested', () => {
+				setData( doc, '<heading1>x</heading1><quote><paragraph>a[bc</paragraph><paragraph>de]f</paragraph></quote>' );
+
+				const content = stringify( getSelectedContent( doc.selection ) );
+				expect( content ).to.equal( '<paragraph>bc</paragraph><paragraph>de</paragraph>' );
+			} );
+
+			it( 'gets content when left end nested deeper', () => {
+				setData( doc, '<quote><paragraph>a[bc</paragraph></quote><paragraph>de]f</paragraph>' );
+
+				const content = stringify( getSelectedContent( doc.selection ) );
+				expect( content ).to.equal( '<quote><paragraph>bc</paragraph></quote><paragraph>de</paragraph>' );
+			} );
+
+			it( 'gets content when left end nested deeper 2', () => {
+				setData( doc, '<quote><paragraph>a[bc</paragraph><heading1>x</heading1></quote><paragraph>de]f</paragraph>' );
+
+				const content = stringify( getSelectedContent( doc.selection ) );
+				expect( content ).to.equal( '<quote><paragraph>bc</paragraph><heading1>x</heading1></quote><paragraph>de</paragraph>' );
+			} );
+
+			it( 'gets content when left end nested deeper 3', () => {
+				setData( doc, '<quote><heading1>x</heading1><paragraph>a[bc</paragraph></quote><paragraph>de]f</paragraph>' );
+
+				const content = stringify( getSelectedContent( doc.selection ) );
+				expect( content ).to.equal( '<quote><paragraph>bc</paragraph></quote><paragraph>de</paragraph>' );
+			} );
+
+			// See https://github.com/ckeditor/ckeditor5-engine/issues/652#issuecomment-261358484
+			it( 'gets content when left end nested deeper 4', () => {
+				setData( doc, '<quote><heading1>x[</heading1><paragraph>abc</paragraph></quote><paragraph>de]f</paragraph>' );
+
+				const content = stringify( getSelectedContent( doc.selection ) );
+				expect( content ).to.equal( '<quote><paragraph>abc</paragraph></quote><paragraph>de</paragraph>' );
+			} );
+
+			it( 'gets content when right end nested deeper', () => {
+				setData( doc, '<paragraph>a[bc</paragraph><quote><paragraph>de]f</paragraph></quote>' );
+
+				const content = stringify( getSelectedContent( doc.selection ) );
+				expect( content ).to.equal( '<paragraph>bc</paragraph><quote><paragraph>de</paragraph></quote>' );
+			} );
+
+			it( 'gets content when both ends nested deeper than the middle element', () => {
+				setData( doc, '<quote><heading1>a[bc</heading1></quote><heading1>x</heading1><quote><heading1>de]f</heading1></quote>' );
+
+				const content = stringify( getSelectedContent( doc.selection ) );
+				expect( content )
+					.to.equal( '<quote><heading1>bc</heading1></quote><heading1>x</heading1><quote><heading1>de</heading1></quote>' );
+			} );
+		} );
+	} );
+} );

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

@@ -49,6 +49,9 @@ describe( 'DataController', () => {
 				// Otherwise it won't be passed to the temporary model fragment used inside insert().
 				schema.allow( { name: 'disallowedElement', inside: '$clipboardHolder' } );
 
+				schema.allow( { name: '$inline', attributes: [ 'bold' ] } );
+				schema.allow( { name: '$inline', attributes: [ 'italic' ] } );
+
 				schema.objects.add( 'image' );
 			} );
 
@@ -64,6 +67,41 @@ describe( 'DataController', () => {
 				expect( getData( doc ) ).to.equal( 'fooxyz[]' );
 			} );
 
+			it( 'inserts one text node with attribute', () => {
+				setData( doc, 'f[]oo' );
+				insertHelper( '<$text bold="true">xyz</$text>' );
+				expect( getData( doc ) ).to.equal( 'f<$text bold="true">xyz[]</$text>oo' );
+
+				expect( doc.selection.getAttribute( 'bold' ) ).to.be.true;
+			} );
+
+			it( 'inserts one text node with attribute into text with a different attribute', () => {
+				setData( doc, '<$text bold="true">f[]oo</$text>' );
+				insertHelper( '<$text italic="true">xyz</$text>' );
+				expect( getData( doc ) )
+					.to.equal( '<$text bold="true">f</$text><$text italic="true">xyz[]</$text><$text bold="true">oo</$text>' );
+
+				expect( doc.selection.getAttribute( 'italic' ) ).to.be.true;
+				expect( doc.selection.hasAttribute( 'bold' ) ).to.be.false;
+			} );
+
+			it( 'inserts one text node with attribute into text with the same attribute', () => {
+				setData( doc, '<$text bold="true">f[]oo</$text>' );
+				insertHelper( '<$text bold="true">xyz</$text>' );
+				expect( getData( doc ) )
+					.to.equal( '<$text bold="true">fxyz[]oo</$text>' );
+
+				expect( doc.selection.getAttribute( 'bold' ) ).to.be.true;
+			} );
+
+			it( 'inserts a text without attributes into a text with an attribute', () => {
+				setData( doc, '<$text bold="true">f[]oo</$text>' );
+				insertHelper( 'xyz' );
+				expect( getData( doc ) ).to.equal( '<$text bold="true">f</$text>xyz[]<$text bold="true">oo</$text>' );
+
+				expect( doc.selection.hasAttribute( 'bold' ) ).to.be.false;
+			} );
+
 			it( 'inserts an element', () => {
 				setData( doc, 'f[]oo' );
 				insertHelper( '<image></image>' );

+ 1 - 1
packages/ckeditor5-engine/tests/controller/modifyselection.js

@@ -8,7 +8,7 @@ import Selection from 'ckeditor5/engine/model/selection.js';
 import modifySelection from 'ckeditor5/engine/controller/modifyselection.js';
 import { setData, stringify } from 'ckeditor5/engine/dev-utils/model.js';
 
-describe( 'Delete utils', () => {
+describe( 'DataController', () => {
 	let document;
 
 	beforeEach( () => {

+ 20 - 0
packages/ckeditor5-engine/tests/model/element.js

@@ -144,6 +144,26 @@ describe( 'Element', () => {
 		} );
 	} );
 
+	describe( 'getNodeByPath', () => {
+		it( 'should return this node if path is empty', () => {
+			const element = new Element( 'elem' );
+
+			expect( element.getNodeByPath( [] ) ).to.equal( element );
+		} );
+
+		it( 'should return a descendant of this node', () => {
+			const image = new Element( 'image' );
+			const element = new Element( 'elem', [], [
+				new Element( 'elem', [], [
+					new Text( 'foo' ),
+					image
+				] )
+			] );
+
+			expect( element.getNodeByPath( [ 0, 1 ] ) ).to.equal( image );
+		} );
+	} );
+
 	describe( 'getChildIndex', () => {
 		it( 'should return child index', () => {
 			let element = new Element( 'elem', [], [ new Element( 'p' ), new Text( 'bar' ), new Element( 'h' ) ] );

+ 35 - 0
packages/ckeditor5-engine/tests/model/position.js

@@ -509,6 +509,41 @@ describe( 'position', () => {
 		} );
 	} );
 
+	describe( 'getCommonPath', () => {
+		it( 'returns the common part', () => {
+			const pos1 = new Position( root, [ 1, 0, 0 ] );
+			const pos2 = new Position( root, [ 1, 0, 1 ] );
+
+			expect( pos1.getCommonPath( pos2 ) ).to.deep.equal( [ 1, 0 ] );
+		} );
+
+		it( 'returns the common part when paths are equal', () => {
+			const pos1 = new Position( root, [ 1, 0, 1 ] );
+			const pos2 = new Position( root, [ 1, 0, 1 ] );
+			const commonPath = pos1.getCommonPath( pos2 );
+
+			// Ensure that we have a clone
+			expect( commonPath ).to.not.equal( pos1.path );
+			expect( commonPath ).to.not.equal( pos2.path );
+
+			expect( commonPath ).to.deep.equal( [ 1, 0, 1 ] );
+		} );
+
+		it( 'returns empty array when paths totally differ', () => {
+			const pos1 = new Position( root, [ 1, 1 ] );
+			const pos2 = new Position( root, [ 0 ] );
+
+			expect( pos1.getCommonPath( pos2 ) ).to.deep.equal( [] );
+		} );
+
+		it( 'returns empty array when roots differ, but paths are the same', () => {
+			const pos1 = new Position( root, [ 1, 1 ] );
+			const pos2 = new Position( otherRoot, [ 1, 1 ] );
+
+			expect( pos1.getCommonPath( pos2 ) ).to.deep.equal( [] );
+		} );
+	} );
+
 	describe( 'compareWith', () => {
 		it( 'should return same if positions are same', () => {
 			const position = new Position( root, [ 1, 2, 3 ] );