Browse Source

Merge branch 'master' into t/787

Oskar Wróbel 8 years ago
parent
commit
2799ff083e

+ 40 - 0
packages/ckeditor5-engine/CHANGELOG.md

@@ -0,0 +1,40 @@
+Changelog
+=========
+
+## [0.8.0](https://github.com/ckeditor/ckeditor5-engine/compare/v0.7.0...v0.8.0) (2017-03-06)
+
+### Bug fixes
+
+* `view.Matcher#getElementName()` now returns proper value when named function is used as a pattern. Closes [#850](https://github.com/ckeditor/ckeditor5/issues/850). ([93f41c5](https://github.com/ckeditor/ckeditor5-engine/commit/93f41c5))
+* Collapsed `model.Range` no longer sticks to its neighbour node when that node is moved. Closes [#852](https://github.com/ckeditor/ckeditor5/issues/852). ([ccd23d1](https://github.com/ckeditor/ckeditor5-engine/commit/ccd23d1))
+* Default `remove()` converter no longer removes `view.UIElements` that are next to removed nodes. Closes [#854](https://github.com/ckeditor/ckeditor5/issues/854). ([c054ded](https://github.com/ckeditor/ckeditor5-engine/commit/c054ded))
+* `dev-utils/model#setData()` should work with custom roots. Closes [#815](https://github.com/ckeditor/ckeditor5/issues/815). ([0ad3074](https://github.com/ckeditor/ckeditor5-engine/commit/0ad3074))
+* You can now return `null` from the element creators in converters for selection attributes and markers. It does not crash the conversion anymore. Closes [#833](https://github.com/ckeditor/ckeditor5/issues/833). ([8ffa038](https://github.com/ckeditor/ckeditor5-engine/commit/8ffa038))
+
+### Features
+
+* Integrated `Schema#limits` with `DataController`'s methods. Closes [#818](https://github.com/ckeditor/ckeditor5/issues/818). ([e3c3e33](https://github.com/ckeditor/ckeditor5-engine/commit/e3c3e33))
+* Introduced `is()` method in model and view tree nodes and document fragments. Closes [#809](https://github.com/ckeditor/ckeditor5/issues/809). ([1525bde](https://github.com/ckeditor/ckeditor5-engine/commit/1525bde))
+* Introduced `model.Selection#getSelectedBlocks()`. Closes [#811](https://github.com/ckeditor/ckeditor5/issues/811). ([9f3f35f](https://github.com/ckeditor/ckeditor5-engine/commit/9f3f35f))
+* Introduced `Schema#limits` map. See [#818](https://github.com/ckeditor/ckeditor5/issues/818). ([a66bcdd](https://github.com/ckeditor/ckeditor5-engine/commit/a66bcdd))
+* Introduced `view.Document#selectionChangeDone` event. Closes [#791](https://github.com/ckeditor/ckeditor5/issues/791). ([3a15236](https://github.com/ckeditor/ckeditor5-engine/commit/3a15236))
+* Introduced `view.Range#getEnlarged()`, `view.Range#getTrimmed()`, `view.Position#getLastMatchingPosition()`, `model.Position#getLastMatchingPosition()`, `view.TreeWalker#skip()`, `model.TreeWalker#skip()`. Closes [#789](https://github.com/ckeditor/ckeditor5/issues/789). ([973f2ba](https://github.com/ckeditor/ckeditor5-engine/commit/973f2ba))
+* Introduced `view.UIElement` class and `view.writer.clear()` method. Closes [#788](https://github.com/ckeditor/ckeditor5/issues/788). ([64be1f6](https://github.com/ckeditor/ckeditor5-engine/commit/64be1f6))
+* Introduced debugging tools for the engine. Closes [#808](https://github.com/ckeditor/ckeditor5/issues/808). ([7b56e4a](https://github.com/ckeditor/ckeditor5-engine/commit/7b56e4a))
+* Introduced converters from model markers to `view.UIElement`. Closes [#792](https://github.com/ckeditor/ckeditor5/issues/792). ([1731e69](https://github.com/ckeditor/ckeditor5-engine/commit/1731e69))
+
+### Other changes
+
+* `ModelConversionDispatcher` now uses `remove` + `insert` events to convert `move` and `rename` changes, instead of dedicated `move` and `rename` events. Closes [#837](https://github.com/ckeditor/ckeditor5/issues/837). ([f63657c](https://github.com/ckeditor/ckeditor5-engine/commit/f63657c))
+* `view.Range.enlarge()` and `view.Range.shrink()` should not pass the container limit because the `view.Writer` expects that the whole range is in the same container. Closes [#830](https://github.com/ckeditor/ckeditor5/issues/830). ([8d4a1ca](https://github.com/ckeditor/ckeditor5-engine/commit/8d4a1ca))
+
+
+### BREAKING CHANGES
+
+* `ModelConversionDispatcher` no longer fires `move` and `rename` events. This means that feature converters added as callbacks to those should be replaced by `remove` and `insert` converters.
+* Removed `view.DocumentFragment#getAncestors()`. Closes [#803](https://github.com/ckeditor/ckeditor5/issues/803). Closes [#805](https://github.com/ckeditor/ckeditor5/issues/805).
+* `Position.getAncestors()` should return elements in the same order as `Node.getAncestors()`.
+
+### NOTE
+
+* It is advised to use either `Range#getTrimmed()` or `Range#getEnlarged()` before operating on a range returned from `Mapper`.

+ 12 - 12
packages/ckeditor5-engine/package.json

@@ -1,24 +1,24 @@
 {
   "name": "@ckeditor/ckeditor5-engine",
-  "version": "0.7.0",
+  "version": "0.8.0",
   "description": "CKEditor 5 Editing Engine",
   "keywords": [
     "CKEditor"
   ],
   "dependencies": {
-    "@ckeditor/ckeditor5-utils": "*"
+    "@ckeditor/ckeditor5-utils": "^0.8.0"
   },
   "devDependencies": {
-    "@ckeditor/ckeditor5-dev-lint": "^2.0.0",
-    "@ckeditor/ckeditor5-basic-styles": "*",
-    "@ckeditor/ckeditor5-core": "*",
-    "@ckeditor/ckeditor5-editor-classic": "*",
-    "@ckeditor/ckeditor5-enter": "*",
-    "@ckeditor/ckeditor5-heading": "*",
-    "@ckeditor/ckeditor5-list": "*",
-    "@ckeditor/ckeditor5-paragraph": "*",
-    "@ckeditor/ckeditor5-typing": "*",
-    "@ckeditor/ckeditor5-undo": "*",
+    "@ckeditor/ckeditor5-dev-lint": "^2.0.2",
+    "@ckeditor/ckeditor5-basic-styles": "^0.7.1",
+    "@ckeditor/ckeditor5-core": "^0.7.0",
+    "@ckeditor/ckeditor5-editor-classic": "^0.7.1",
+    "@ckeditor/ckeditor5-enter": "^0.8.0",
+    "@ckeditor/ckeditor5-heading": "^0.8.0",
+    "@ckeditor/ckeditor5-list": "^0.5.1",
+    "@ckeditor/ckeditor5-paragraph": "^0.6.1",
+    "@ckeditor/ckeditor5-typing": "^0.8.0",
+    "@ckeditor/ckeditor5-undo": "^0.7.1",
     "gulp": "^3.9.0",
     "guppy-pre-commit": "^0.4.0"
   },

+ 2 - 3
packages/ckeditor5-engine/src/conversion/modelconversiondispatcher.js

@@ -237,9 +237,8 @@ export default class ModelConversionDispatcher {
 	 *
 	 * @fires remove
 	 * @fires insert
-	 * @param {module:engine/model/position~Position} sourcePosition Position from where the range has been removed.
-	 * @param {module:engine/model/range~Range} range Removed range (after remove, in
-	 * {@link module:engine/model/document~Document#graveyard graveyard root}).
+	 * @param {module:engine/model/position~Position} sourcePosition The original position from which the range was moved.
+	 * @param {module:engine/model/range~Range} range The range containing the moved content.
 	 */
 	convertMove( sourcePosition, range ) {
 		this.convertRemove( sourcePosition, range );

+ 10 - 23
packages/ckeditor5-engine/src/model/delta/basic-transformations.js

@@ -97,33 +97,20 @@ addTransformationCase( InsertDelta, MergeDelta, ( a, b, isStrong ) => {
 	return defaultTransform( a, b, isStrong );
 } );
 
-// Add special case for MarkerDelta x SplitDelta
-addTransformationCase( MarkerDelta, SplitDelta, ( a, b, isStrong ) => {
-	// If marked range is split, we need to fix it:
-	// ab[cdef]gh   ==>  ab[cd
-	//                   ef]gh
-	// To mimic what normally happens with LiveRange if you split it.
-
-	// Mote: MarkerDelta can't get split to two deltas, neither can MarkerOperation.
-	const transformedDelta = defaultTransform( a, b, isStrong )[ 0 ];
+function transformMarkerDelta( a, b ) {
+	const transformedDelta = a.clone();
 	const transformedOp = transformedDelta.operations[ 0 ];
 
-	// Fix positions, if needed.
-	const markerOp = a.operations[ 0 ];
-
-	const source = b.position;
-	const target = b._moveOperation.targetPosition;
-
-	if ( markerOp.oldRange.containsPosition( b.position ) ) {
-		transformedOp.oldRange.end = markerOp.oldRange.end._getCombined( source, target );
-	}
-
-	if ( markerOp.newRange.containsPosition( b.position ) ) {
-		transformedOp.newRange.end = markerOp.newRange.end._getCombined( source, target );
-	}
+	transformedOp.oldRange = transformedOp.oldRange.getTransformedByDelta( b )[ 0 ];
+	transformedOp.newRange = transformedOp.newRange.getTransformedByDelta( b )[ 0 ];
 
 	return [ transformedDelta ];
-} );
+}
+
+addTransformationCase( MarkerDelta, SplitDelta, transformMarkerDelta );
+addTransformationCase( MarkerDelta, MergeDelta, transformMarkerDelta );
+addTransformationCase( MarkerDelta, WrapDelta, transformMarkerDelta );
+addTransformationCase( MarkerDelta, UnwrapDelta, transformMarkerDelta );
 
 // Add special case for MoveDelta x MergeDelta transformation.
 addTransformationCase( MoveDelta, MergeDelta, ( a, b, isStrong ) => {

+ 49 - 12
packages/ckeditor5-engine/src/model/range.js

@@ -447,20 +447,58 @@ export default class Range {
 	 * @returns {Array.<module:engine/model/range~Range>}
 	 */
 	_getTransformedByDocumentChange( type, deltaType, targetPosition, howMany, sourcePosition ) {
+		// IMPORTANT! Every special case added here has to be reflected in MarkerDelta transformations!
+		// Check /src/model/delta/basic-transformations.js.
 		if ( type == 'insert' ) {
 			return this._getTransformedByInsertion( targetPosition, howMany, false, false );
 		} else {
 			const ranges = this._getTransformedByMove( sourcePosition, targetPosition, howMany );
 
-			if ( deltaType == 'split' && this.containsPosition( sourcePosition ) ) {
-				// Special case for splitting element inside range.
-				// <p>f[ooba]r</p> -> <p>f[oo</p><p>ba]r</p>
-				ranges[ 0 ].end = ranges[ 1 ].end;
-				ranges.pop();
-			} else if ( deltaType == 'merge' && type == 'move' && this.isCollapsed && ranges[ 0 ].start.isEqual( sourcePosition ) ) {
-				// Special case when collapsed range is in merged element.
-				// <p>foo</p><p>[]bar{}</p> -> <p>foo[]bar{}</p>
-				ranges[ 0 ] = new Range( targetPosition.getShiftedBy( this.start.offset ) );
+			// Don't ask. Just debug.
+			// Like this: https://github.com/ckeditor/ckeditor5-engine/issues/841#issuecomment-282706488.
+			//
+			// In following cases, in examples, the last step is the fix step.
+			// When there are multiple ranges in an example, ranges[] array indices are represented as follows:
+			// * [] is ranges[ 0 ],
+			// * {} is ranges[ 1 ],
+			// * () is ranges[ 2 ].
+			if ( type == 'move' ) {
+				const sourceRange = Range.createFromPositionAndShift( sourcePosition, howMany );
+
+				if ( deltaType == 'split' && this.containsPosition( sourcePosition ) ) {
+					// Range contains a position where an element is split.
+					// <p>f[ooba]r</p> -> <p>f[ooba]r</p><p></p> -> <p>f[oo]</p><p>{ba}r</p> -> <p>f[oo</p><p>ba]r</p>
+					return [ new Range( ranges[ 0 ].start, ranges[ 1 ].end ) ];
+				} else if ( deltaType == 'merge' && this.isCollapsed && ranges[ 0 ].start.isEqual( sourcePosition ) ) {
+					// Collapsed range is in merged element.
+					// Without fix, the range would end up in the graveyard, together with removed element.
+					// <p>foo</p><p>[]bar</p> -> <p>foobar</p><p>[]</p> -> <p>foobar</p> -> <p>foo[]bar</p>
+					return [ new Range( targetPosition.getShiftedBy( this.start.offset ) ) ];
+				} else if ( deltaType == 'wrap' ) {
+					// Range intersects (at the start) with wrapped element (<p>ab</p>).
+					// <p>a[b</p><p>c]d</p> -> <p>a[b</p><w></w><p>c]d</p> -> [<w>]<p>a(b</p>){</w><p>c}d</p> -> <w><p>a[b</p></w><p>c]d</p>
+					if ( sourceRange.containsPosition( this.start ) && this.containsPosition( sourceRange.end ) ) {
+						return [ new Range( ranges[ 2 ].start, ranges[ 1 ].end ) ];
+					}
+					// Range intersects (at the end) with wrapped element (<p>cd</p>).
+					// <p>a[b</p><p>c]d</p> -> <p>a[b</p><p>c]d</p><w></w> -> <p>a[b</p>]<w>{<p>c}d</p></w> -> <p>a[b</p><w><p>c]d</p></w>
+					else if ( sourceRange.containsPosition( this.end ) && this.containsPosition( sourceRange.start ) ) {
+						return [ new Range( ranges[ 0 ].start, ranges[ 1 ].end ) ];
+					}
+				} else if ( deltaType == 'unwrap' ) {
+					// Range intersects (at the beginning) with unwrapped element (<w></w>).
+					// <w><p>a[b</p></w><p>c]d</p> -> <p>a{b</p>}<w>[</w><p>c]d</p> -> <p>a[b</p><w></w><p>c]d</p>
+					// <w></w> is removed in next operation, but the remove does not mess up ranges.
+					if ( sourceRange.containsPosition( this.start ) && this.containsPosition( sourceRange.end ) ) {
+						return [ new Range( ranges[ 1 ].start, ranges[ 0 ].end ) ];
+					}
+					// Range intersects (at the end) with unwrapped element (<w></w>).
+					// <p>a[b</p><w><p>c]d</p></w> -> <p>a[b</p>](<p>c)d</p>{<w>}</w> -> <p>a[b</p><p>c]d</p><w></w>
+					// <w></w> is removed in next operation, but the remove does not mess up ranges.
+					else if ( sourceRange.containsPosition( this.end ) && this.containsPosition( sourceRange.start ) ) {
+						return [ new Range( ranges[ 0 ].start, ranges[ 2 ].end ) ];
+					}
+				}
 			}
 
 			return ranges;
@@ -652,11 +690,10 @@ export default class Range {
 	 * Combines all ranges from the passed array into a one range. At least one range has to be passed.
 	 * Passed ranges must not have common parts.
 	 *
-	 * The first range from the array is a reference range. If other ranges starts or ends on the exactly same position where
+	 * The first range from the array is a reference range. If other ranges start or end on the exactly same position where
 	 * the reference range, they get combined into one range.
 	 *
-	 *		[  ][]  [    ][ ][  ref range  ][ ][]  [  ]  // Passed ranges, shown sorted. "Ref range" was the first range in original array.
-	 *		        [      returned range       ]  [  ]  // The combined range.
+	 *		[  ][]  [    ][ ][             ][ ][]  [  ]  // Passed ranges, shown sorted
 	 *		[    ]                                       // The result of the function if the first range was a reference range.
 	 *	            [                           ]        // The result of the function if the third-to-seventh range was a reference range.
 	 *	                                           [  ]  // The result of the function if the last range was a reference range.

+ 145 - 8
packages/ckeditor5-engine/tests/model/delta/transform/markerdelta.js

@@ -19,7 +19,10 @@ import {
 	expectDelta,
 	getFilledDocument,
 	getMarkerDelta,
-	getSplitDelta
+	getSplitDelta,
+	getMergeDelta,
+	getWrapDelta,
+	getUnwrapDelta
 } from '../../../model/delta/transform/_utils/utils';
 
 describe( 'transform', () => {
@@ -33,16 +36,16 @@ describe( 'transform', () => {
 	} );
 
 	describe( 'MarkerDelta by', () => {
-		let markerDelta;
+		describe( 'SplitDelta', () => {
+			let markerDelta;
 
-		beforeEach( () => {
-			const oldRange = new Range( new Position( root, [ 3, 0 ] ), new Position( root, [ 3, 3 ] ) );
-			const newRange = new Range( new Position( root, [ 3, 3, 3, 2 ] ), new Position( root, [ 3, 3, 3, 6 ] ) );
+			beforeEach( () => {
+				const oldRange = new Range( new Position( root, [ 3, 0 ] ), new Position( root, [ 3, 3 ] ) );
+				const newRange = new Range( new Position( root, [ 3, 3, 3, 2 ] ), new Position( root, [ 3, 3, 3, 6 ] ) );
 
-			markerDelta = getMarkerDelta( 'name', oldRange, newRange, baseVersion );
-		} );
+				markerDelta = getMarkerDelta( 'name', oldRange, newRange, baseVersion );
+			} );
 
-		describe( 'SplitDelta', () => {
 			it( 'split inside oldRange', () => {
 				let splitDelta = getSplitDelta( new Position( root, [ 3, 1 ] ), new Element( 'div' ), 3, baseVersion );
 				let transformed = transform( markerDelta, splitDelta );
@@ -93,5 +96,139 @@ describe( 'transform', () => {
 				} );
 			} );
 		} );
+
+		describe( 'MergeDelta', () => {
+			it( 'collapsed marker in merged element', () => {
+				// MarkerDelta with collapsed range, which changes from the beginning of merged element to the end.
+				const oldRange = new Range( new Position( root, [ 3, 3, 3, 0 ] ) );
+				const newRange = new Range( new Position( root, [ 3, 3, 3, 12 ] ) );
+
+				const markerDelta = getMarkerDelta( 'name', oldRange, newRange, baseVersion );
+
+				// MergeDelta merges the element in which is collapsed marker range with the previous element.
+				const mergeDelta = getMergeDelta( new Position( root, [ 3, 3, 3 ] ), 4, 12, baseVersion );
+
+				const transformed = transform( markerDelta, mergeDelta );
+
+				// It is expected, that ranges in MarkerDelta got correctly transformed:
+				// from start of merged element to the place where merged nodes where moved in the previous element,
+				// from end of merged element to the end of previous element.
+				const expectedOldRange = new Range( new Position( root, [ 3, 3, 2, 4 ] ), new Position( root, [ 3, 3, 2, 4 ] ) );
+				const expectedNewRange = new Range( new Position( root, [ 3, 3, 2, 16 ] ), new Position( root, [ 3, 3, 2, 16 ] ) );
+
+				expectDelta( transformed[ 0 ], {
+					type: MarkerDelta,
+					operations: [
+						{
+							type: MarkerOperation,
+							name: 'name',
+							oldRange: expectedOldRange,
+							newRange: expectedNewRange,
+							baseVersion: baseVersion + 2
+						}
+					]
+				} );
+			} );
+		} );
+
+		describe( 'WrapDelta', () => {
+			it( 'ranges intersecting with wrapped range', () => {
+				// MarkerDelta with ranges that intersects with wrapped range.
+				const oldRange = new Range( new Position( root, [ 0, 2 ] ), new Position( root, [ 1, 2 ] ) );
+				const newRange = new Range( new Position( root, [ 1, 2 ] ), new Position( root, [ 2, 2 ] ) );
+
+				const markerDelta = getMarkerDelta( 'name', oldRange, newRange, baseVersion );
+
+				// Wrap delta wraps element on position ( root [ 1 ] ), which intersects with both `oldRange` and `newRange`.
+				const wrapElement = new Element( 'w' );
+				const wrapRange = new Range( new Position( root, [ 1 ] ), new Position( root, [ 2 ] ) );
+				const wrapDelta = getWrapDelta( wrapRange, wrapElement, baseVersion );
+
+				const transformed = transform( markerDelta, wrapDelta );
+
+				// It is expected, that ranges in MarkerDelta got correctly transformed:
+				// `oldRange` end is in wrapped element,
+				// `newRange` start is in wrapped element.
+				const expectedOldRange = new Range( new Position( root, [ 0, 2 ] ), new Position( root, [ 1, 0, 2 ] ) );
+				const expectedNewRange = new Range( new Position( root, [ 1, 0, 2 ] ), new Position( root, [ 2, 2 ] ) );
+
+				expectDelta( transformed[ 0 ], {
+					type: MarkerDelta,
+					operations: [
+						{
+							type: MarkerOperation,
+							name: 'name',
+							oldRange: expectedOldRange,
+							newRange: expectedNewRange,
+							baseVersion: baseVersion + 2
+						}
+					]
+				} );
+			} );
+		} );
+
+		describe( 'UnwrapDelta', () => {
+			it( 'ranges intersecting with unwrapped element', () => {
+				// MarkerDelta with ranges that intersects with unwrapped element.
+				const oldRange = new Range( new Position( root, [ 0, 2 ] ), new Position( root, [ 1, 0, 2 ] ) );
+				const newRange = new Range( new Position( root, [ 1, 0, 2 ] ), new Position( root, [ 2, 2 ] ) );
+
+				const markerDelta = getMarkerDelta( 'name', oldRange, newRange, baseVersion );
+
+				// Unwrap delta unwraps element on position ( root [ 1, 0 ] ), which intersects with both `oldRange` and `newRange`.
+				const unwrapPosition = new Position( root, [ 1, 0 ] );
+				const unwrapDelta = getUnwrapDelta( unwrapPosition, 4, baseVersion );
+
+				const transformed = transform( markerDelta, unwrapDelta );
+
+				// It is expected, that ranges in MarkerDelta got correctly transformed.
+				const expectedOldRange = new Range( new Position( root, [ 0, 2 ] ), new Position( root, [ 1, 2 ] ) );
+				const expectedNewRange = new Range( new Position( root, [ 1, 2 ] ), new Position( root, [ 2, 2 ] ) );
+
+				expectDelta( transformed[ 0 ], {
+					type: MarkerDelta,
+					operations: [
+						{
+							type: MarkerOperation,
+							name: 'name',
+							oldRange: expectedOldRange,
+							newRange: expectedNewRange,
+							baseVersion: baseVersion + 2
+						}
+					]
+				} );
+			} );
+
+			it( 'ranges intersecting with unwrapped element #2', () => {
+				// MarkerDelta with ranges that intersects with unwrapped element.
+				const oldRange = new Range( new Position( root, [ 0, 2 ] ), new Position( root, [ 1, 2 ] ) );
+				const newRange = new Range( new Position( root, [ 1, 2 ] ), new Position( root, [ 2, 2 ] ) );
+
+				const markerDelta = getMarkerDelta( 'name', oldRange, newRange, baseVersion );
+
+				// Unwrap delta unwraps element on position ( root [ 1 ] ), which intersects with both `oldRange` and `newRange`.
+				const unwrapPosition = new Position( root, [ 1 ] );
+				const unwrapDelta = getUnwrapDelta( unwrapPosition, 4, baseVersion );
+
+				const transformed = transform( markerDelta, unwrapDelta );
+
+				// It is expected, that ranges in MarkerDelta got correctly transformed.
+				const expectedOldRange = new Range( new Position( root, [ 0, 2 ] ), new Position( root, [ 3 ] ) );
+				const expectedNewRange = new Range( new Position( root, [ 3 ] ), new Position( root, [ 5, 2 ] ) );
+
+				expectDelta( transformed[ 0 ], {
+					type: MarkerDelta,
+					operations: [
+						{
+							type: MarkerOperation,
+							name: 'name',
+							oldRange: expectedOldRange,
+							newRange: expectedNewRange,
+							baseVersion: baseVersion + 2
+						}
+					]
+				} );
+			} );
+		} );
 	} );
 } );

+ 164 - 1
packages/ckeditor5-engine/tests/model/liverange.js

@@ -9,11 +9,12 @@ import Position from '../../src/model/position';
 import LiveRange from '../../src/model/liverange';
 import Range from '../../src/model/range';
 import Text from '../../src/model/text';
+import { stringify, setData } from '../../src/dev-utils/model';
 
 describe( 'LiveRange', () => {
 	let doc, root, ul, p;
 
-	before( () => {
+	beforeEach( () => {
 		doc = new Document();
 		root = doc.createRoot();
 
@@ -361,6 +362,168 @@ describe( 'LiveRange', () => {
 				expect( spy.calledOnce ).to.be.true;
 			} );
 		} );
+
+		describe( 'wrap', () => {
+			// NOTE: it overrides the variable defined globally in these tests.
+			// These tests need to be rewritten to use the batch API anyway and then this variable can be removed.
+			let live;
+
+			beforeEach( () => {
+				doc.schema.registerItem( 'p', '$block' );
+				doc.schema.registerItem( 'w' );
+
+				doc.schema.allow( { name: 'p', inside: 'w' } );
+				doc.schema.allow( { name: 'w', inside: '$root' } );
+			} );
+
+			afterEach( () => {
+				live.detach();
+			} );
+
+			it( 'is inside the wrapped range', () => {
+				setData( doc, '<p>x</p><p>[a]</p><p>x</p>' );
+
+				live = new LiveRange( doc.selection.getFirstPosition(), doc.selection.getLastPosition() );
+
+				// [<p>a</p>]
+				doc.batch().wrap( new Range( new Position( root, [ 1 ] ), new Position( root, [ 2 ] ) ), 'w' );
+
+				expect( stringify( root, live ) ).to.equal( '<p>x</p><w><p>[a]</p></w><p>x</p>' );
+			} );
+
+			it( 'its start is intersecting with the wrapped range', () => {
+				setData( doc, '<p>a[b</p><p>x</p><p>c]d</p>' );
+
+				live = new LiveRange( doc.selection.getFirstPosition(), doc.selection.getLastPosition() );
+
+				// [<p>ab</p>]
+				doc.batch().wrap( new Range( new Position( root, [ 0 ] ), new Position( root, [ 1 ] ) ), 'w' );
+
+				expect( stringify( root, live ) ).to.equal( '<w><p>a[b</p></w><p>x</p><p>c]d</p>' );
+			} );
+
+			it( 'its end is intersecting with the wrapped range', () => {
+				setData( doc, '<p>a[b</p><p>x</p><p>c]d</p>' );
+
+				live = new LiveRange( doc.selection.getFirstPosition(), doc.selection.getLastPosition() );
+
+				// [<p>cd</p>]
+				doc.batch().wrap( new Range( new Position( root, [ 2 ] ), new Position( root, [ 3 ] ) ), 'w' );
+
+				expect( stringify( root, live ) ).to.equal( '<p>a[b</p><p>x</p><w><p>c]d</p></w>' );
+			} );
+
+			it( 'its start is intersecting with the wrapped range (multilpe elements)', () => {
+				setData( doc, '<p>a[b</p><p>x</p><p>c]d</p>' );
+
+				live = new LiveRange( doc.selection.getFirstPosition(), doc.selection.getLastPosition() );
+
+				// [<p>ab</p><p>x</p>]
+				doc.batch().wrap( new Range( new Position( root, [ 0 ] ), new Position( root, [ 2 ] ) ), 'w' );
+
+				expect( stringify( root, live ) ).to.equal( '<w><p>a[b</p><p>x</p></w><p>c]d</p>' );
+			} );
+
+			it( 'its end is intersecting with the wrapped range (multiple elements)', () => {
+				setData( doc, '<p>a[b</p><p>x</p><p>c]d</p>' );
+
+				live = new LiveRange( doc.selection.getFirstPosition(), doc.selection.getLastPosition() );
+
+				// [<p>x</p><p>cd</p>]
+				doc.batch().wrap( new Range( new Position( root, [ 1 ] ), new Position( root, [ 3 ] ) ), 'w' );
+
+				expect( stringify( root, live ) ).to.equal( '<p>a[b</p><w><p>x</p><p>c]d</p></w>' );
+			} );
+
+			it( 'contains element to wrap', () => {
+				setData( doc, '<p>a[b</p><p>x</p><p>c]d</p>' );
+
+				live = new LiveRange( doc.selection.getFirstPosition(), doc.selection.getLastPosition() );
+
+				// [<p>x</p>]
+				doc.batch().wrap( new Range( new Position( root, [ 1 ] ), new Position( root, [ 2 ] ) ), 'w' );
+
+				expect( stringify( root, live ) ).to.equal( '<p>a[b</p><w><p>x</p></w><p>c]d</p>' );
+			} );
+		} );
+
+		describe( 'unwrap', () => {
+			// NOTE: it overrides the variable defined globally in these tests.
+			// These tests need to be rewritten to use the batch API anyway and then this variable can be removed.
+			let live;
+
+			beforeEach( () => {
+				doc.schema.registerItem( 'p', '$block' );
+				doc.schema.registerItem( 'w' );
+
+				doc.schema.allow( { name: 'p', inside: 'w' } );
+				doc.schema.allow( { name: 'w', inside: '$root' } );
+			} );
+
+			afterEach( () => {
+				live.detach();
+			} );
+
+			it( 'is inside the wrapper to remove', () => {
+				setData( doc, '<p>x</p><w><p>[a]</p></w><p>x</p>' );
+
+				live = new LiveRange( doc.selection.getFirstPosition(), doc.selection.getLastPosition() );
+
+				doc.batch().unwrap( root.getChild( 1 ) );
+
+				expect( stringify( root, live ) ).to.equal( '<p>x</p><p>[a]</p><p>x</p>' );
+			} );
+
+			it( 'its start is intersecting with the wrapper to remove', () => {
+				setData( doc, '<w><p>a[b</p></w><p>c]d</p>' );
+
+				live = new LiveRange( doc.selection.getFirstPosition(), doc.selection.getLastPosition() );
+
+				doc.batch().unwrap( root.getChild( 0 ) );
+
+				expect( stringify( root, live ) ).to.equal( '<p>a[b</p><p>c]d</p>' );
+			} );
+
+			it( 'its end is intersecting with the wrapper to remove', () => {
+				setData( doc, '<p>a[b</p><w><p>c]d</p></w>' );
+
+				live = new LiveRange( doc.selection.getFirstPosition(), doc.selection.getLastPosition() );
+
+				doc.batch().unwrap( root.getChild( 1 ) );
+
+				expect( stringify( root, live ) ).to.equal( '<p>a[b</p><p>c]d</p>' );
+			} );
+
+			it( 'its start is intersecting with the wrapper to remove (multiple elements)', () => {
+				setData( doc, '<w><p>a[b</p><p>x</p></w><p>c]d</p>' );
+
+				live = new LiveRange( doc.selection.getFirstPosition(), doc.selection.getLastPosition() );
+
+				doc.batch().unwrap( root.getChild( 0 ) );
+
+				expect( stringify( root, live ) ).to.equal( '<p>a[b</p><p>x</p><p>c]d</p>' );
+			} );
+
+			it( 'its end is intersecting with the wrapper to remove (multiple elements)', () => {
+				setData( doc, '<p>a[b</p><w><p>x</p><p>c]d</p></w>' );
+
+				live = new LiveRange( doc.selection.getFirstPosition(), doc.selection.getLastPosition() );
+
+				doc.batch().unwrap( root.getChild( 1 ) );
+
+				expect( stringify( root, live ) ).to.equal( '<p>a[b</p><p>x</p><p>c]d</p>' );
+			} );
+
+			it( 'contains wrapped element', () => {
+				setData( doc, '<p>a[b</p><w><p>x</p></w><p>c]d</p>' );
+
+				live = new LiveRange( doc.selection.getFirstPosition(), doc.selection.getLastPosition() );
+
+				doc.batch().unwrap( root.getChild( 1 ) );
+
+				expect( stringify( root, live ) ).to.equal( '<p>a[b</p><p>x</p><p>c]d</p>' );
+			} );
+		} );
 	} );
 
 	describe( 'should not get transformed if', () => {

+ 142 - 1
packages/ckeditor5-engine/tests/model/range.js

@@ -19,7 +19,9 @@ import {
 	getRemoveDelta,
 	getRenameDelta,
 	getSplitDelta,
-	getMergeDelta
+	getMergeDelta,
+	getWrapDelta,
+	getUnwrapDelta
 } from '../../tests/model/delta/transform/_utils/utils';
 
 describe( 'Range', () => {
@@ -857,6 +859,145 @@ describe( 'Range', () => {
 					expect( transformed[ 0 ].end.path ).to.deep.equal( [ 0, 3 ] );
 				} );
 			} );
+
+			describe( 'by WrapDelta', () => {
+				it( 'maintans start position when wrapping element in which the range starts and ends', () => {
+					root.removeChildren( root.childCount );
+					root.appendChildren( [ new Element( 'p', null, new Text( 'foo' ) ), new Element( 'p', null, new Text( 'bar' ) ) ] );
+
+					// <p>f[o]o</p><p>bar</p>
+					range.start = new Position( root, [ 0, 1 ] );
+					range.end = new Position( root, [ 0, 2 ] );
+
+					const wrapRange = new Range( new Position( root, [ 0 ] ), new Position( root, [ 1 ] ) );
+					const wrapElement = new Element( 'w' );
+					const delta = getWrapDelta( wrapRange, wrapElement, 1 );
+
+					const transformed = range.getTransformedByDelta( delta );
+
+					// <w><p>f[o]o</p></w><p>bar</p>
+					expect( transformed.length ).to.equal( 1 );
+					expect( transformed[ 0 ].start.path ).to.deep.equal( [ 0, 0, 1 ] );
+					expect( transformed[ 0 ].end.path ).to.deep.equal( [ 0, 0, 2 ] );
+				} );
+
+				it( 'maintans start position when wrapping element in which the range starts but not ends', () => {
+					root.removeChildren( root.childCount );
+					root.appendChildren( [ new Element( 'p', null, new Text( 'foo' ) ), new Element( 'p', null, new Text( 'bar' ) ) ] );
+
+					// <p>f[oo</p><p>b]ar</p>
+					range.start = new Position( root, [ 0, 1 ] );
+					range.end = new Position( root, [ 1, 1 ] );
+
+					const wrapRange = new Range( new Position( root, [ 0 ] ), new Position( root, [ 1 ] ) );
+					const wrapElement = new Element( 'w' );
+					const delta = getWrapDelta( wrapRange, wrapElement, 1 );
+
+					const transformed = range.getTransformedByDelta( delta );
+
+					// <w><p>f[oo</p></w><p>b]ar</p>
+					expect( transformed.length ).to.equal( 1 );
+					expect( transformed[ 0 ].start.path ).to.deep.equal( [ 0, 0, 1 ] );
+					expect( transformed[ 0 ].end.path ).to.deep.equal( [ 1, 1 ] );
+				} );
+
+				it( 'maintans end position when wrapping element in which the range ends but not starts', () => {
+					root.removeChildren( root.childCount );
+					root.appendChildren( [ new Element( 'p', null, new Text( 'foo' ) ), new Element( 'p', null, new Text( 'bar' ) ) ] );
+
+					// <p>f[oo</p><p>b]ar</p>
+					range.start = new Position( root, [ 0, 1 ] );
+					range.end = new Position( root, [ 1, 1 ] );
+
+					const wrapRange = new Range( new Position( root, [ 1 ] ), new Position( root, [ 2 ] ) );
+					const wrapElement = new Element( 'w' );
+					const delta = getWrapDelta( wrapRange, wrapElement, 1 );
+
+					const transformed = range.getTransformedByDelta( delta );
+
+					// <p>f[oo</p><w><p>b]ar</p></w>
+					expect( transformed.length ).to.equal( 1 );
+					expect( transformed[ 0 ].start.path ).to.deep.equal( [ 0, 1 ] );
+					expect( transformed[ 0 ].end.path ).to.deep.equal( [ 1, 0, 1 ] );
+				} );
+			} );
+
+			describe( 'by UnwrapDelta', () => {
+				it( 'maintans start position when wrapping element in which the range starts and ends', () => {
+					root.removeChildren( root.childCount );
+					root.appendChildren( [
+						new Element( 'w', null, [
+							new Element( 'p', null, new Text( 'foo' ) )
+						] ),
+						new Element( 'p', null, new Text( 'bar' ) )
+					] );
+
+					// <w><p>f[o]o</p></w><p>bar</p>
+					range.start = new Position( root, [ 0, 0, 1 ] );
+					range.end = new Position( root, [ 0, 0, 2 ] );
+
+					const unwrapPosition = new Position( root, [ 0 ] );
+					const delta = getUnwrapDelta( unwrapPosition, 1, 1 );
+
+					const transformed = range.getTransformedByDelta( delta );
+
+					// <p>f[o]o</p><p>bar</p>
+					expect( transformed.length ).to.equal( 1 );
+					expect( transformed[ 0 ].start.path ).to.deep.equal( [ 0, 1 ] );
+					expect( transformed[ 0 ].end.path ).to.deep.equal( [ 0, 2 ] );
+				} );
+
+				it( 'maintans start position when wrapping element in which the range starts but not ends', () => {
+					root.removeChildren( root.childCount );
+					root.appendChildren( [
+						new Element( 'w', null, [
+							new Element( 'p', null, new Text( 'foo' ) )
+						] ),
+						new Element( 'p', null, new Text( 'bar' ) )
+					] );
+
+					// <w><p>f[oo</p></w><p>b]ar</p>
+					range.start = new Position( root, [ 0, 0, 1 ] );
+					range.end = new Position( root, [ 1, 1 ] );
+
+					const unwrapPosition = new Position( root, [ 0 ] );
+					const delta = getUnwrapDelta( unwrapPosition, 1, 1 );
+
+					const transformed = range.getTransformedByDelta( delta );
+
+					// <p>f[oo</p><p>b]ar</p>
+					expect( transformed.length ).to.equal( 2 );
+
+					expect( transformed[ 0 ].start.path ).to.deep.equal( [ 0, 1 ] );
+					expect( transformed[ 0 ].end.path ).to.deep.equal( [ 1, 1 ] );
+
+					expect( transformed[ 1 ].root.rootName  ).to.equal( '$graveyard' );
+				} );
+
+				it( 'maintans end position when wrapping element in which the range ends but not starts', () => {
+					root.removeChildren( root.childCount );
+					root.appendChildren( [
+						new Element( 'p', null, new Text( 'foo' ) ),
+						new Element( 'w', null, [
+							new Element( 'p', null, new Text( 'bar' ) )
+						] )
+					] );
+
+					// <p>f[oo</p><w><p>b]ar</p></w>
+					range.start = new Position( root, [ 0, 1 ] );
+					range.end = new Position( root, [ 1, 0, 1 ] );
+
+					const unwrapPosition = new Position( root, [ 1 ] );
+					const delta = getUnwrapDelta( unwrapPosition, 1, 1 );
+
+					const transformed = range.getTransformedByDelta( delta );
+
+					// <p>f[oo</p><p>b]ar</p>
+					expect( transformed.length ).to.equal( 1 );
+					expect( transformed[ 0 ].start.path ).to.deep.equal( [ 0, 1 ] );
+					expect( transformed[ 0 ].end.path ).to.deep.equal( [ 1, 1 ] );
+				} );
+			} );
 		} );
 	} );