Răsfoiți Sursa

Merge pull request #856 from ckeditor/t/841

Fix: Fixed a bug where `LiveRange` position would be lost when using wrap and unwrap deltas. Closes #841.
Piotrek Koszuliński 8 ani în urmă
părinte
comite
ddcef732a0

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